1. EDSR 相比 SRResNet 到底动了哪三处
第一次把 EDSR 的权重跑起来,很多人会觉得“这网络也没什么特别的”——还是残差堆叠,还是卷积加 ReLU,结构图上几乎和 SRResNet 长得一样。真正动手从头训一遍才发现,两个方案的差距不在层数,而在三个很不起眼的地方:BN 层被整个拆掉了、残差分支乘了一个 0.1、上采样挪到了网络最尾部。这三处改动,任何一处做错,PSNR 都会掉 0.3 到 0.6 dB,而且是那种“看起来能跑、图也不糊,就是指标上不去”的隐性损失。
图像超分辨率重建这个任务,本质是让网络学一个从低分辨率到高分辨率的映射。指标用的是 PSNR 和 SSIM,属于像素级的相似度度量。这类度量对亮度、对比度的偏移极其敏感,任何一个环节引入的微小数值漂移,都会被放大成 dB 级别的差异。EDSR 的结构设计,从头到尾都在解决这个问题:怎么让信号在几十层卷积里传递时,不被人为地扭曲。
1.1 拆掉 BN 层之后,网络靠什么稳住数值分布
BN 层在分类网络里几乎是标配,作用是归一化每层输入的分布,让训练更稳、学习率可以调大。但它搬到超分辨率任务里就有副作用了。BN 在训练时用当前 batch 的均值和方差做归一化,推理时用滑动平均的统计量,这两个统计量之间必然存在偏差。对分类任务来说,这点偏差不影响最终判断哪个类别概率高;但对超分来说,输出就是像素值本身,统计量的偏移会直接变成图像整体的色调漂移和对比度变化。
EDSR 的做法是把 BN 一刀切掉,所有卷积层的权重就在原始尺度上累积。代价是训练前期没有归一化保护,对初始学习率更敏感;收益是同样的训练轮数下,验证集 PSNR 稳定高出一截,而且推理时不再有训练/测试分布不一致的隐患。实测下来,去掉 BN 后单张图像的推理速度还能快 10% 到 20%,因为省掉了一层 element-wise 运算。
这里有一个很容易踩的坑:去掉 BN 之后,权重初始化必须换掉。默认的 Kaiming 初始化是针对带 ReLU 的卷积设计的,在几十层堆叠下,如果初始权重偏大,输出会迅速饱和到 ReLU 的负半轴,前向传播几层之后整张图变成一片灰。稳妥的做法是对所有卷积层使用小方差的初始化,或者干脆先在浅层网络上验证梯度是否正常,再扩展到 32 个残差块。
1.2 残差缩放系数 0.1:不是玄学,是方差累积的补偿
EDSR 的每个残差块长这样:输入经过两层 3×3 卷积和中间一个 ReLU,得到的结果乘上 0.1,再加回输入。这个 0.1 就是所谓的 residual scaling。
为什么需要它?把残差块的递推式写出来就清楚了:假设第 $l$ 个残差块的输出是 $x_{l+1} = x_l + 0.1 \cdot f(x_l)$。如果残差分支的输出方差和主干相当,那么每经过一个块,总方差就会增加一点。32 个块串起来,最后一层的激活值幅度可能是第一层的几倍,后面的卷积很容易饱和。乘上 0.1 之后,残差分支对方差的贡献被压缩到 1%,主干信号几乎原样往后传,整个网络的数值范围就稳住了。
这个系数在 EDSR 的基线版本里其实是 1.0,因为基线只有 16 个块、64 个通道,深度不足以让方差失控。而完整版 EDSR 用了 32 个块、256 个通道,参数量从 1.5M 涨到 43M,必须靠缩放来压住。所以如果你打算自己搭一个中等规模的版本(比如 16 块 128 通道),res_scale 取 0.1 是安全的,取 1.0 就要小心 loss 在前期剧烈抖动。
| 配置 | 残差块数 | 通道数 | res_scale | 参数量(x4) | 适用场景 |
|---|---|---|---|---|---|
| 基线 | 16 | 64 | 1.0 | 约 1.5M | 快速验证流程、移动端 |
| 中等 | 16 | 128 | 0.1 | 约 5.5M | 单卡训练、消费级显卡 |
| 完整 EDSR | 32 | 256 | 0.1 | 约 43M | 追求指标、多卡训练 |
1.3 上采样放在网络尾部:PixelShuffle 的代价与收益
早期的一些方案(比如在特征域做反卷积的那批)习惯在网络中间穿插上采样层,先放大再细化。EDSR 选择了另一条路:所有卷积都在低分辨率空间做,只在最后一段用 PixelShuffle 一次性放大。
好处很直接。在低分辨率空间做 3×3 卷积,感受野覆盖的实际图像范围更大,计算量却只有高分辨率空间的四分之一。把放大挪到最后,等于把绝大部分计算都留在了便宜的地方。以 x4 为例,网络主体在 LR 尺寸上跑 32 个残差块,最后两个上采样模块各做一次通道重排,总 FLOPs 比“先放大再卷积”的方案低一个数量级。
PixelShuffle 的原理是把通道维度的数据按空间位置重新排列:输入 $C \times r^2$ 个通道,输出 $C$ 个通道、空间尺寸放大 $r$ 倍。它是无参数的,纯重排操作,所以不会引入额外的插值模糊。这一点和双三次插值有本质区别——插值是“猜”出来的像素,重排是网络已经算好的特征。
x2 和 x4 好办,连续做两次和一次重排即可。x3 是个特例,因为 3 不是 2 的幂。处理办法是直接做一次 $r=3$ 的重排:卷积输出 27 个通道(3×3²),然后 PixelShuffle(3) 排成 3 通道、尺寸放大 3 倍。这个细节在复现时经常被忽略,有人会先用 x2 再用插值凑到 x3,结果边界上出现明显的模糊带。
上采样层的卷积核初始化也要注意。如果这一层的初始权重方差过大,重排后会出现规律的棋盘格纹理,尤其在放大的平坦区域(比如天空、墙面)特别明显。用较小的初始化方差,或者在这层之前不做 ReLU,能明显缓解。
2. 训练数据怎么造:配对样本的每一个细节都会反映在 PSNR 上
超分模型的训练集是“高分辨率原图 + 低分辨率退化图”的配对。这个配对怎么造,直接决定了模型最终能解决什么问题。很多人把数据管线当成无关紧要的样板代码,随手写个 resize 就开训,最后模型的指标卡在某个位置怎么调都上不去,问题往往就出在这里。
公开的常用数据集里,DIV2K 的 800 张训练图是 EDSR 原论文的配置。这批图分辨率在 2K 左右,题材覆盖风景、人物、建筑、微距,多样性够用。测试集一般用 Set5、Set14、B100、Urban100 这几套,其中 Urban100 全是建筑纹理,是对模型细节恢复能力最狠的考验。
2.1 裁剪尺寸与批大小的配合关系
训练时不会整图喂进去,而是随机裁固定大小的 patch。EDSR 原配置是 48×48 的 HR patch,对应 12×12 的 LR patch(x4)。这个尺寸看着很小,但对于 32 块 256 通道的网络来说,48×48 已经能把显存吃满了。
显存占用大致可以这么估:单个残差块的中间激活是 $256 \times 48 \times 48 \times 4$ 字节(float32),约 2.3MB。32 个块如果全部保留梯度,光激活就是 75MB 一张图,batch 16 就是 1.2GB,再加上反向传播的中间变量和优化器状态,实际占用会到 11GB 上下。所以如果你只有 8GB 显存的卡,把 batch 降到 8、patch 降到 40 是更现实的选择,靠增加迭代次数来补偿。
这里有个经验:patch 尺寸对最终指标的影响,比 batch 大小更大。patch 太小时,网络看到的全是局部纹理,学不到长程结构,放大后的图像在大面积平坦区域容易出现色块;patch 太大又训不动。48 到 64 是比较稳的区间,再往上收益递减明显。
| patch 尺寸 | batch | 显存估算 | 训练稳定性 | 建议 |
|---|---|---|---|---|
| 32×32 | 16 | 约 6GB | 一般,纹理学习不足 | 仅用于快速跑通 |
| 48×48 | 16 | 约 11GB | 好 | 原论文配置 |
| 64×64 | 8 | 约 11GB | 好,长程结构更完整 | 显存紧张时的替代 |
| 96×96 | 4 | 约 12GB | 一般,batch 太小 loss 抖动 | 需要调小学习率 |
2.2 数据增强别只做水平翻转
标准的增强是随机水平翻转、垂直翻转、90 度旋转,这四种组合能把 800 张图扩成 3200 种视角。听起来够了,但对超分任务来说,退化过程本身也是一种“增强”。
一个常被忽略的点是退化核的多样性。如果你只用标准的双三次下采样造 LR,模型就会过拟合到这一种退化方式,遇到真实场景里带轻微模糊或压缩失真的图,效果会断崖式下跌。想提升泛化性,可以在造 LR 时随机混入不同的下采样核(不同 sigma 的高斯模糊后再降采样),或者加一点轻微的 JPEG 压缩噪声。代价是标准测试集上的 PSNR 会掉一点点,但实际可用性会好很多。
另一个细节是裁剪位置的随机性。如果每次都从图像左上角开始裁,模型会反复看到同样的纹理。用random.randint取左上角坐标,并且保证裁剪框完全落在图像内部,这是基本功。有人为了图省事直接用transforms.RandomCrop,结果它对 HR 和 LR 分别随机,两边裁的位置对不上,训练集直接变成噪声,loss 死活降不下去。这种错误排查起来极其痛苦,因为代码不报错。
2.3 退化模型决定了模型能力的上限
双三次下采样是最常见的做法,因为测试集的 LR 也是这么造的,训练测试一致,指标自然好看。但要清楚这里面的逻辑:你不是在教模型“如何超分”,而是在教它“如何逆转双三次下采样”。这两件事的难度完全不同。
真实场景的低分辨率来源通常是传感器限制、光学模糊、编码压缩的叠加,远不是一次理想的双三次能描述的。所以工业界落地时,往往会用退化建模的思路:先估计真实图像的退化核,再按这个核造训练数据,或者干脆用不成对的数据做训练。
如果你只是想做学术对比、复现论文指标,那双三次就够了,不用折腾。如果目标是处理真实图片,建议在训练集里混入 20% 到 30% 的“脏数据”,用不同的模糊核和噪声水平造出来,让模型见过世面。
还有一个容易忽略的细节:下采样时用的库和插值方式必须固定。OpenCV 的
INTER_CUBIC和 PIL 的BICUBIC在边界处理和系数上有细微差异,训练用一套、测试用另一套,PSNR 会莫名其妙低 0.1 dB 左右。务必把退化函数封装成一个统一入口,训练和评估都调它。
3. 从零写一个 EDSR:网络结构与训练循环
网上能找到的 EDSR 实现很多,但把结构写对、把训练跑通、把指标复现出来,中间隔着不少细节。这一节把关键代码和参数选择讲清楚,重点解释“为什么这么写”,而不是贴一份能跑就行的代码。
3.1 残差块与主干的实现要点
残差块本身很简单,两个 3×3 卷积夹一个 ReLU,输出乘缩放系数加回输入。真正的坑在于顺序和 padding。卷积必须用padding=1保持空间尺寸不变,否则每过一个块尺寸缩 2,32 个块之后图就没了。ReLU 放在两层卷积中间,不能放在最后,因为残差分支的末尾如果是 ReLU,负值会被截断,加回主干时会引入系统性偏移。
import torch import torch.nn as nn import math class ResBlock(nn.Module): def __init__(self, n_feats, res_scale=0.1): super().__init__() self.body = nn.Sequential( nn.Conv2d(n_feats, n_feats, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(n_feats, n_feats, 3, padding=1), ) self.res_scale = res_scale def forward(self, x): return x + self.body(x) * self.res_scale主干部分要注意的是长跳跃连接的位置。EDSR 在主残差块序列之后、上采样之前,还有一次全局的残差相加:把 head 的输出直接加到 body 的输出上。这个连接让梯度能绕过全部 32 个块直接回流到浅层,是深层网络能训起来的关键。
class EDSR(nn.Module): def __init__(self, scale=4, n_resblocks=32, n_feats=256, res_scale=0.1): super().__init__() self.head = nn.Conv2d(3, n_feats, 3, padding=1) self.body = nn.Sequential( *[ResBlock(n_feats, res_scale) for _ in range(n_resblocks)] ) self.body_tail = nn.Conv2d(n_feats, n_feats, 3, padding=1) layers = [] if scale == 3: layers.append(nn.Conv2d(n_feats, 3 * 9, 3, padding=1)) layers.append(nn.PixelShuffle(3)) else: for _ in range(int(math.log2(scale))): layers.append(nn.Conv2d(n_feats, 4 * n_feats, 3, padding=1)) layers.append(nn.PixelShuffle(2)) layers.append(nn.Conv2d(n_feats, 3, 3, padding=1)) self.tail = nn.Sequential(*layers) def forward(self, x): x = self.head(x) x = x + self.body_tail(self.body(x)) return self.tail(x)x3 那一段要单独处理,原因前面说过:3 不是 2 的幂,没法用两次 PixelShuffle(2) 凑出来。上面代码里直接一次重排到 3 倍,卷积输出通道数是 3×3²=27,正好对上。
3.2 损失函数为什么选 L1 而不是 L2
MSE(L2 loss)是最直观的选择,它和 PSNR 的数学形式直接相关——最小化 MSE 等价于最大化 PSNR。但实际训练中,EDSR 和后续大量工作都选了 L1。
原因在梯度上。L2 的梯度是 $2(y - \hat{y})$,误差越大梯度越大,模型会本能地优先修正那些误差极大的像素,比如锐利边缘。这听起来是好事,但代价是平坦区域的细微噪声被忽略,图像整体观感偏“平滑”。L1 的梯度是常数($-\text{sign}(y-\hat{y})$),对所有误差一视同仁,训练出来的图像锐度更好,边缘更干净。
实测对比,同样的网络和迭代次数,L1 训出来的模型在 PSNR 上和 L2 接近甚至略高,但视觉效果明显更讨喜,尤其是文字、建筑这类高频内容。所以除非你的下游任务明确需要 MSE 最优,否则直接用 L1。
criterion = nn.L1Loss()还有一个变体是 Charbonnier loss,本质是平滑版的 L1,在零点附近可导性更好,训练前期收敛更平滑。如果 L1 训起来前期 loss 抖得厉害,可以试试它。
3.3 优化器、学习率与显存的三方博弈
Adam 是标配,beta1 取 0.9,beta2 取 0.999。初始学习率 1e-4,每 20 万次迭代减半,总迭代 100 万次(基线版本 30 万次就够)。
学习率的设置有一个反直觉的地方:去掉 BN 之后,学习率不能照搬分类网络的经验。分类网络去掉 BN 常用 1e-3 起步,超分网络用这个值,前几百次迭代 loss 就会飙到 NaN。原因是输入图像的像素范围在 0 到 1 之间,梯度尺度本身就小,加上深层残差堆叠,1e-3 足以让权重在几步内爆掉。1e-4 是一个经过验证的安全起点,如果你的显存小、batch 只有 4 或 8,还要再降一半。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, betas=(0.9, 0.999)) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=200000, gamma=0.5)显存控制除了调 batch 和 patch,还有几个实用手段:
- 混合精度训练。用
torch.cuda.amp把前向和反向放到 fp16 上跑,显存能省 30% 到 40%,速度提升 20% 左右。要注意梯度缩放,否则小梯度会下溢成 0。 - 梯度累积。显存只够 batch 4,但想要 batch 16 的效果,就累积 4 步再更新一次参数。注意学习率要按有效 batch 相应调整。
- 及时释放中间变量。训练循环里别把每个 batch 的 loss 都存进 list 里,几百轮之后光这些标量就能占掉几百 MB。
还有一个隐性显存杀手:DataLoader 的
num_workers开太大。每个 worker 都会独立加载一份数据到内存,worker 数超过 CPU 核心数反而会拖慢速度。一般设成 CPU 核心数的一半就够了。另外pin_memory=True配合non_blocking=True能加速数据传输,但要注意别在 CPU 上跑这两个参数,没意义还占内存。
4. 推理阶段的四个高频翻车点
训练收敛、权重保存好,很多人以为到这里就结束了。真正把这套东西用起来才发现,推理阶段的坑一点不比训练少,而且更隐蔽——训练时至少有 loss 曲线能看,推理出问题往往只能靠肉眼盯图。
4.1 大图直接喂进去会炸显存
训练时用 48×48 的 patch,推理时拿到一张 2000×3000 的照片,直接送进网络会怎样?显存瞬间爆掉。原因在于中间激活的尺寸和输入面积成正比,2000×3000 的图,光是第一个卷积层的输出就有 $256 \times 2000 \times 3000 \times 4$ 字节,接近 6GB,后面还有 32 个残差块。
解决办法是分块推理:把大图切成有重叠的小块,逐块送去网络,再把结果拼回去。块大小取 128 到 256 比较合适,太小了块数多、速度慢,太大了显存又吃紧。
@torch.no_grad() def tiled_inference(model, img, scale, tile=192, overlap=16): # img: 1 x 3 x H x W, 值域 0~1 _, _, h, w = img.shape out = torch.zeros(1, 3, h * scale, w * scale, device=img.device) step = tile - overlap for y in range(0, h, step): for x in range(0, w, step): y0, x0 = y, x y1, x1 = min(y + tile, h), min(x + tile, w) patch = img[:, :, y0:y1, x0:x1] sr = model(patch) out[:, :, y0*scale:y1*scale, x0*scale:x1*scale] = sr return out这段代码能跑,但有个瑕疵:重叠区域被后处理的块直接覆盖了,接缝处会有轻微的不连续。更稳的做法是对重叠区域做加权平均,块中心权重高、边缘权重低,过渡就平滑了。
4.2 接缝处理不好会出现网格纹
分块推理不加重叠,相邻块的边缘卷积结果会有细微差异,拼起来就是一张带网格的图。这个网格在暗部和平坦区域特别明显,因为那些地方没有纹理掩盖。
处理办法有三种,从简单到复杂:
- 重叠 + 直接覆盖。块之间重叠 16 到 32 像素,后处理的块覆盖前面的。实现最简单,效果凑合,适合对画质要求不极致的场景。
- 重叠 + 加权融合。预先算一个二维的权重窗(比如汉宁窗),重叠区域按权重加权平均。接缝几乎看不出来,代价是要多存一份中间结果。
- 整图 padding 到能整除的尺寸。如果图不算太大,把边界补齐到 tile 的整数倍,一次性推理完再裁掉多余部分,根本不会有接缝问题。
还有一种特殊情况是图像边缘。整图推理时,最外圈几个像素的卷积会因为 padding 而缺少真实邻域信息,结果容易发虚。分块推理时这个问题在每个块的边界都存在,所以重叠区域一定要比卷积核的影响范围大,16 像素是个安全的经验值。
4.3 Y 通道评估和 RGB 评估差多少
论文里报的 PSNR 几乎都是在 YCbCr 空间的 Y 通道上算的,而且会裁掉边界上的若干像素。这个细节如果不注意,你自己算出来的指标和论文对不上,很容易误以为自己训崩了。
差距有多大?同样的模型,RGB 三通道平均算出来的 PSNR,通常比 Y 通道低 0.1 到 0.4 dB。原因是色度通道(Cb、Cr)本身信息量少、变化平缓,超分模型在色度上的重建误差相对更大,拉低了平均值。而人眼对亮度更敏感,所以业界习惯只看 Y 通道。
边界裁剪的原因是卷积的 padding 效应。放大倍数 4,就在四周各裁掉 4 像素;放大 2 倍就裁 2 像素。不裁的话,边界上那些靠 padding 猜出来的像素会虚高 PSNR,一般能虚高 0.05 dB 左右,虽然不多,但做对比实验时足以颠倒结论。
def calc_psnr_y(sr, hr, scale): # sr, hr: 1 x 3 x H x W, 0~1 sr_y = rgb_to_y(sr) hr_y = rgb_to_y(hr) sr_y = sr_y[:, :, scale:-scale, scale:-scale] hr_y = hr_y[:, :, scale:-scale, scale:-scale] mse = ((sr_y - hr_y) ** 2).mean() return (10 * torch.log10(1.0 / mse)).item()4.4 预训练权重加载失败的几种典型原因
直接下载别人训好的权重来用,是快速验证的最省事办法。但加载时经常报 key 不匹配,原因大概有这么几类:
| 报错现象 | 原因 | 处理方式 |
|---|---|---|
key 全部对不上,多出module.前缀 | 权重是从多卡训练保存的 | 遍历 state_dict,去掉前缀再加载 |
| 部分 key 缺失 | 网络结构参数不一致(块数、通道数) | 核对 n_resblocks 和 n_feats |
| 尺寸不匹配 | scale 不同,上采样层通道数不同 | x2 权重不能用于 x4,必须换 |
| 加载成功但输出全黑/全白 | 输入值域不对(0-255 而非 0-1) | 加 ToTensor 或手动除以 255 |
第一条最常见。多卡训练保存的权重,每个参数名前面都带module.,单卡加载时对不上。处理代码很短:
state = torch.load(path, map_location='cpu') new_state = {k.replace('module.', ''): v for k, v in state.items()} model.load_state_dict(new_state, strict=True)建议一律用strict=True,让不匹配直接报错。用strict=False图省事,结果某个关键层没加载上,模型输出一片模糊,排查半天还找不到原因。
5. 指标之外:EDSR 的效果边界与轻量化取舍
把 EDSR 训出来、指标对上论文,只能说是走完了流程的一半。真正决定这套方案能不能用在项目里,还得看它在什么情况下失效,以及有没有更划算的替代路线。
5.1 PSNR 高的模型不一定好看
PSNR 衡量的是像素级平均误差,它对整体亮度偏移极其敏感,对结构合理性却几乎无感。这就导致一个现象:某些模型的 PSNR 很高,放大后的图却显得“糊”,细节被过度平滑;另一些模型 PSNR 略低,但边缘锐利、纹理自然,肉眼观感好得多。
EDSR 属于偏向前者的方案。它用 L1 训练,比纯 MSE 好些,但整体风格仍然是保守的——不会去“编造”细节,宁可模糊也不出错。放大倍数越高,这个倾向越明显。x2 的时候几乎完美,x4 的时候纹理密集区(比如草地、毛发)会明显发软,x8 就基本只能看个轮廓了。
如果目标是观感优先,可以考虑在 EDSR 的基础上接一个对抗损失微调,或者直接用生成式的方案。代价是 PSNR 会掉 0.5 dB 以上,而且可能引入伪纹理。这是个明确的取舍,没有对错,取决于你的下游用途:做监控画面增强,宁可糊也别造假;做老照片修复展示,锐度更重要。
5.2 轻量化路线的现实考量
完整版 EDSR 的 43M 参数,在服务器上跑没问题,但要塞进移动端或者嵌入式设备就完全不可行了。单张 1080p 图像在桌面显卡上要跑几百毫秒,在手机上是秒级,用户根本等不了。
轻量化的思路有几条:
- 砍深度。从 32 个残差块降到 16 个,通道从 256 降到 64,参数量掉到 1.5M,速度提升十倍以上。PSNR 会掉 0.3 到 0.5 dB,对多数应用来说可以接受。
- 换结构。用更高效的块设计(比如把标准卷积换成深度可分离卷积,或者用通道注意力的轻量变体),在同等参数量下能多换回 0.2 dB 左右。
- 蒸馏。先用大模型在数据集上生成高质量的“教师输出”,再让小模型去拟合这些输出。小模型能学到比直接训更好的结果,代价是要多跑一遍大模型的推理。
- 只在需要的区域超分。如果图像大部分是平坦背景,只对检测到的纹理区域做超分,整体耗时会大幅下降。这条路线工程量大,但收益也最直接。
还有一个经常被忽略的点:放大倍数的选择。x4 的模型拿去做 x2,效果不如专门的 x2 模型;但如果你的目标是 x8,用 x4 模型跑两次,效果会比直接训 x8 好不少,因为 x8 的训练数据太难造,模型学不到有效信息。级联两次 x4 是个实用的折中方案,代价是误差会累积,边缘可能出现轻微振铃。
最后提醒一句:EDSR 在原论文里还有一个多尺度版本,多个放大倍数共享浅层特征,只在尾部各自接上采样模块。如果你的项目需要同时支持 x2、x3、x4,用这种共享结构比训三个独立模型省很多显存和训练时间,指标也只差 0.05 dB 左右。实现上就是把 head 和前面的残差块共用,每个尺度各接一段独立的 body 和 tail,代码改动不大,值得一试。