ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

超分辨率残差学习:VDSR原理、感受野与多尺度训练实践

超分辨率残差学习:VDSR原理、感受野与多尺度训练实践

简介:这份资源围绕CVPR2016论文《Accurate Image Super-Resolution Using Very Deep Convolutional Networks》展开,提供VDSR模型的Caffe实现及MatConvNet测试代码,面向已具备深度学习基础、希望复现或改进超分辨率算法的研究者与工程人员,可用于图像超分实验、模型训练与推理验证。压缩包共286个文件,约9.75MB,以m脚本、cu与cpp源码、hpp头文件为主,辅以proto与prototxt网络配置、caffemodel权重、py工具脚本、sh运行脚本及少量bmp测试图与pdf文档,覆盖从数据准备、网络定义到训练测试的完整链路。已有265人学习下载,说明该实现具备一定参考价值。读者可据此对照论文理解VDSR的深层卷积结构与残差学习思路,借助现成配置快速搭建训练环境,并利用测试脚本验证超分效果,为后续调参与算法改进提供可运行的代码基础。

1. 超分辨率为什么不能只靠插值:从 VDSR 的残差学习说起

把一张 256×256 的图放大到 1024×1024,双三次插值只需要几毫秒,但放大后的边缘一定是糊的。原因不复杂:插值算法本质上是在已知像素之间做加权平均,它没有任何关于「边缘应该长什么样」的先验知识,高频信息在降采样那一刻就已经丢了,插值只是把丢失后的结果平滑地铺开。超分辨率(super resolution)要做的,是从低分辨率图里把高频细节「猜」回来,而 VDSR 给出的答案相当反直觉——它不让网络直接输出高分辨率图像,而是让网络只学插值结果和高分辨率真值之间的那层残差。这个设计选择背后牵扯到感受野、收敛速度、单模型多尺度复用三个层面的机制,也是理解后续一大票超分网络的地基。搞懂它,你再看任何「为什么加深网络、为什么用残差、为什么训练要调学习率」的问题,都不会再停留在玄学层面。

2. 残差学习:为什么网络只学「差值」反而学得更好

2.1 从恒等映射的难度说起

假设输入低分辨率图经过双三次插值上采样后得到一张图,记作 x,真实的高分辨率图记作 y。最朴素的做法是训练一个卷积网络 f,让 f(x) ≈ y。问题在于,x 和 y 在绝大部分区域是高度相似的——平坦区域、渐变区域,插值结果已经足够接近真值,真正有差异的只是边缘和纹理附近的高频部分。如果让网络直接拟合 y,它必须先把 x 原样「抄」过去,再在少数位置做修正,等于让一堆卷积核去学一个近似恒等映射,这在优化上是很别扭的:卷积层的权重初始化接近零均值时,输出天然偏向零,要让它学会「原样传递」,需要额外的训练成本。

VDSR 的做法是把目标改成 f(x) ≈ y − x,也就是残差 r。网络输出 r 之后,最终结果用 x + r 还原。这样一来,网络在平坦区域只需要输出接近零的值,而在边缘处输出较大的修正量。零输出对卷积网络来说是「默认状态」,学起来轻松得多。常见的实现思路是:残差分支的最后一层卷积不加激活函数,初始化权重调小,让网络一开始就接近输出零,等价于从「纯插值」这个已经不错的起点开始训练,而不是从随机噪声开始。

这个思路在数值上还有一个隐性好处。自然图像的像素值范围通常在 [0, 255] 或归一化到 [0, 1],而残差的分布集中在零附近,方差远小于原图。对同一个损失函数来说,目标值的动态范围变小了,梯度尺度更稳定,学习率的容忍区间更宽。血泪经验是:直接学原图的网络往往要把初始学习率压得很低才不炸,而残差版本可以用更大的步长快速下降。

2.2 残差结构在代码里长什么样

以 Caffe 的 prototxt 描述为例,残差学习的核心就是把插值结果作为一条旁路,和卷积网络的输出做逐元素相加。下面是一段简化后的结构描述,只保留关键层:

# 旁路:双三次插值上采样,作为残差相加的基准 layer { name: "upsample_bicubic" type: "Interp" bottom: "data_lr" top: "base" interp_param { height: 256 width: 256 } # 目标尺寸,按放大倍数设定 } # 主干:20 层卷积,全部 3x3,通道数 64 layer { name: "conv1" type: "Convolution" bottom: "data_lr" top: "conv1" param { lr_mult: 0.1 } # 首层学习率降权,防止输入统计被过快改写 convolution_param { num_output: 64 kernel_size: 3 pad: 1 weight_filler { type: "gaussian" std: 0.001 } } } layer { name: "relu1" type: "ReLU" bottom: "conv1" top: "conv1" } # ... 中间若干 conv+relu,结构相同 ... # 最后一层:输出残差,不加 ReLU layer { name: "conv_out" type: "Convolution" bottom: "conv19" top: "residual" param { lr_mult: 0.1 } convolution_param { num_output: 1 # 单通道,对应灰度或单通道处理 kernel_size: 3 pad: 1 weight_filler { type: "gaussian" std: 0.001 } } } # 残差相加:base + residual layer { name: "sum" type: "Eltwise" bottom: "base" bottom: "residual" top: "output" eltwise_param { operation: SUM } }

几个参数值得单独说。lr_mult: 0.1出现在首层和末层,是因为这两层直接接触输入和输出,梯度尺度和其他层不一致,统一学习率容易让它们震荡;把它们的更新步长压小,是训练深层超分网络的常规操作。weight_filler用标准差 0.001 的高斯分布而不是默认的 Xavier,是为了让初始输出接近零,配合残差结构形成「从插值出发」的起点。Eltwise的 SUM 操作是逐元素相加,要求base和residual的空间尺寸、通道数完全一致,所以插值层的目标尺寸必须和网络输出对齐,这一点在改放大倍数时最容易翻车。

2.3 为什么残差让「一个模型管多个尺度」成为可能

VDSR 另一个被低估的设计是单模型多尺度。传统做法是每个放大倍数训练一个模型,2 倍、3 倍、4 倍各一个,参数不能共享。VDSR 把输入统一处理成「已经插值到目标尺寸的低分辨率图」,网络只负责补高频残差,于是同一个网络可以接受任意放大倍数产生的插值输入。训练时把不同倍数的样本混在一起,网络学到的是「如何从模糊图恢复细节」这个与尺度无关的映射,而不是「2 倍放大」这种与尺度绑定的映射。

这个设计动机很实际:多尺度混训相当于数据增强,样本多样性上去了,模型对单一尺度的过拟合被压制;部署时也省事,一个权重文件覆盖多个倍数,不用维护多套模型。代价是网络容量要同时服务多个尺度,单尺度精度可能略逊于专门训练的模型,这是一个明确的取舍,后面讲设计权衡时还会回到这一点。

3. 感受野与深度:20 层 3×3 卷积到底在算什么

3.1 感受野的递推关系

VDSR 的主干是 20 层卷积,每层 3×3、步长 1、padding 1。单层 3×3 卷积的感受野是 3×3,两层堆叠后是 5×5,三层是 7×7,递推公式是:

R(n) = R(n-1) + (k - 1) * stride_accumulated

在步长全为 1 的情况下,每加一层 3×3 卷积,感受野增加 2。20 层下来,理论感受野是 3 + 2×19 = 41,也就是输出上的一个像素能看到输入上 41×41 的区域。这个数字不是随便定的:超分要恢复的高频结构,比如边缘的走向、纹理的周期性,往往需要几十个像素的上下文才能判断。感受野太小,网络只能做局部锐化,遇到大尺度结构就抓瞎;感受野过大,参数和计算量又吃不消。41×41 是一个在质量和成本之间被反复验证过的平衡点。

这里有个容易混淆的点:理论感受野不等于有效感受野。实际训练出来的网络,中心区域的权重贡献远大于边缘,有效感受野通常小于理论值。所以「堆到 20 层」不是拍脑袋,而是要让有效感受野也足够覆盖有意义的上下文。我一般会这么理解:理论感受野是上限,有效感受野是实际能吃到的信息量,层数不够时后者会明显拖后腿。

3.2 为什么不用大卷积核堆同样的感受野

要达到 41×41 的感受野,用单层 41×41 卷积也行,但参数量是 41×41×C×C,而 20 层 3×3 是 20×9×C×C,后者小一个数量级以上。更重要的是非线性:每层卷积后面跟一个 ReLU,20 层就有 20 次非线性变换,网络的表达能力远强于单层大核。这是 VGG 时代就确立的经验,VDSR 直接继承。常见的实现思路是用「小核深堆」换感受野和表达力,代价是训练更难、需要残差和合理的初始化来兜底,这也解释了为什么残差学习和深度是配套出现的,单独拿出任何一个都不成立。

3.3 训练目标的数值细节

损失函数用的是均方误差,作用在残差上:

# 伪代码:残差目标的损失计算 # pred_residual: 网络输出,形状 [N, 1, H, W] # gt_hr: 高分辨率真值 # base: 双三次插值结果 loss = mean((pred_residual - (gt_hr - base)) ** 2)

注意这里减的是gt_hr - base,不是gt_hr。如果误写成直接对gt_hr求损失,网络会被迫去拟合整张图,残差结构的优势全部丢失,表现为收敛慢、平坦区域出现本不该有的波动。另一个细节是边界处理:卷积用 padding 1 保持尺寸,但图像边缘的 padding 是补零的,会在最外圈引入偏差,训练时通常把损失计算裁掉若干像素的边界,推理时再裁掉输出边缘,避免边界伪影。这个裁剪量一般取网络深度对应的感受野半径,20 层 3×3 对应裁 20 像素左右。

4. 避坑与排查:训练 VDSR 时最容易翻车的几处

4.1 损失不降反升,输出全是灰蒙蒙的均值

现象:训练几个 epoch 后,验证集 PSNR 卡在插值基线附近,输出图看起来像被高斯模糊过。原因通常是残差相加的旁路接错了——base没有正确对齐到网络输出的尺寸,或者Eltwise的输入顺序导致相减而非相加。排查方法:单独前向一次,把base、residual、output三个 blob 分别可视化,确认output ≈ base + residual且residual的量级在 ±0.1 以内。如果residual量级和原图相当,说明网络在学整图而不是残差,回头检查损失函数的目标是不是写成了gt_hr。

4.2 换放大倍数后输出尺寸对不上,推理直接报错

现象:训练时用 2 倍,推理换成 3 倍,Eltwise层报维度不匹配。原因是插值层的目标尺寸被写死成了固定值,而网络输出的尺寸由输入尺寸决定,两者在非整数倍或不同输入尺寸下不再一致。解决:插值层的目标尺寸不要硬编码,改为按输入尺寸乘以放大倍数动态计算,或者在数据层统一把输入裁成固定尺寸再送进网络。血泪经验是:多尺度混训时,每个 batch 内的样本尺寸必须一致,否则 Caffe 的 blob 形状会在 batch 维度上打架。

4.3 首层学习率没降权,训练前期 loss 剧烈震荡

现象:前几百次迭代 loss 上下跳,偶尔出现 NaN。原因:首层直接接触输入像素,梯度尺度比中间层大,用全局学习率会让它的更新步长过大。解决:给首层和末层加lr_mult: 0.1,或者用梯度裁剪把范数限制在合理范围。这个坑在深层网络里特别常见,层数越多越明显,因为梯度回传路径长,尺度不一致会被放大。

4.4 边界出现一圈亮边或暗边

现象:推理结果四周有一圈明显的伪影,PSNR 在整图上被拉低。原因:卷积 padding 补零,边缘像素的上下文不完整,网络在边界处的预测偏离。解决:训练时损失裁剪边界,推理时裁掉输出边缘若干像素再评估;如果必须保留全图,可以在推理前对输入做镜像 padding,让边界也有真实上下文。注意裁剪量要和感受野匹配,裁太少没效果,裁太多浪费有效区域。

4.5 多尺度混训时小倍数样本主导梯度

现象:混训 2/3/4 倍后,2 倍精度不错,4 倍明显退化。原因:不同倍数的残差分布差异大,2 倍残差小、4 倍残差大,混在一起时梯度被小残差样本主导,大倍数学不充分。解决:按倍数分层采样,保证每个 batch 内各倍数比例均衡;或者对残差做尺度归一化,让不同倍数的目标值落在相近范围。这个问题的本质是残差学习把「尺度差异」转化成了「目标值分布差异」,混训时必须显式处理。

5. 设计取舍:残差、深度与多尺度之间的三角平衡

把 VDSR 拆到最后,会发现它的三个核心选择互相牵制,任何一个单独拿出来都不完整。残差学习降低了优化难度,才让 20 层深度可训练;深度带来大感受野,才让多尺度共享权重有意义;而多尺度混训又反过来要求残差目标在不同倍数间保持可比,逼着训练策略做归一化和分层采样。这是一个典型的「机制耦合」案例:论文里每个模块看起来都简单,真正难的是让它们同时成立。

从工程角度看,这套设计的取舍很清晰。用残差换收敛速度,代价是推理时多一次逐元素加法,几乎可以忽略;用深度换感受野,代价是参数量和显存,20 层 64 通道在当年的硬件上是经过计算的;用单模型换多尺度,代价是单尺度精度略让位于通用性。如果你的场景只做固定 4 倍放大、且对精度极度敏感,专门训练一个单尺度模型可能更划算;如果要做通用图像增强、倍数不固定,VDSR 这种残差加多尺度的组合仍然是性价比很高的基线。

验证这套机制是否真的 work,最直接的办法是做消融:把残差旁路去掉、把深度砍到 10 层、把多尺度混训改成单尺度,分别看 PSNR 和收敛曲线的变化。我一般会这么理解:消融不是为了复现论文数字,而是为了确认每个模块在你的数据分布上是否还成立——自然图像上有效的感受野,换到医学影像或卫星图上可能就不够用,这时候深度和残差的配比要重新调。

从那以后我每次搭超分网络,都会先把「残差目标 + 插值旁路 + 边界裁剪」这三件事在最小可运行版本里跑通,再往上加深度和尺度,避免一上来就堆结构、最后连 loss 为什么不降都查不出来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进