ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

VDSR超分网络复现指南:残差学习、梯度裁剪与PyTorch实践

VDSR超分网络复现指南:残差学习、梯度裁剪与PyTorch实践 简介一份基于Pytorch实现VDSR超分辨率重建的完整复现资源面向深度学习入门者或图像超分方向的研究人员覆盖从数据集制作、模型搭建、训练到测试评估的完整流程并提供了清晰可运行的工程结构。资源包共64个文件压缩后约86.62MB核心内容包括全套Python脚本数据增强、h5数据集生成、VDSR网络定义、训练与测试、3个Pytorch权重文件、用于验证的bmp/png图像、Matlab参考数据及h5格式训练数据集目录划分明确。已有668人学习下载。数据增强支持旋转、翻转和可选缩放数据集制作脚本使用Python统一实现与GitHub上的Matlab版本功能一致训练参数与论文完全对齐测试环节提供PSNR评估和与Bicubic双三次插值的结果对比可视化。下载即可获得可运行代码、预训练权重和测试样例便于快速复现VDSR超分效果。1. 复现 VDSR一篇 2016 年的超分论文为什么今天还用 PyTorch 重写VDSR 是 CVPR 2016 的工作靠 20 层卷积把超分辨率重建精度一次拉高一大截同时期的 SRCNN 只有三层。现在做超分的人绕不开它EDSR、RCAN 这些后辈都拿它当对照 baseline。用 PyTorch 复现 VDSR 不是考古而是它把「训练一个能收敛的深网络」做到极简残差学习、高学习率、梯度裁剪三招加在一起单卡一天就能跑出接近论文的结果。这篇笔记不只贴代码。我从网络结构和训练 trick 讲起落到 PyTorch 的 Dataset、模型定义、训练循环和评测口径最后是五条踩坑记录。适合三种人第一次做超分复现的学生、要在项目里补超分 baseline 的工程师、以及「照着论文写代码但 PSNR 就是差半格」的人。2. VDSR 原理与网络结构20 层 3×3 卷积、残差学习与梯度裁剪先理解再动手2.1 网络结构拆解为什么是 20 层 3×3感受野怎么算VDSR 的网络结构单调到一眼能记完除了第一层和最后一层中间全是 64 通道的 3×3 卷积接 ReLU。论文报告的 20 层指的是有可学习参数的卷积层数——第 1 层输入是 1 个通道也就是 YCrCb 颜色空间里的亮度通道 Y第 2 到第 19 层保持 64 通道第 20 层输出 1 个通道即残差图。每层卷积都带 padding1特征图尺寸从输入到输出完全不变。这个设计决定了一个关键约束网络的输入输出必须同尺寸。所以 VDSR 不做「小图直接变大图」而是先把低分辨率图用 bicubic 上采样到目标尺寸再送进网络。你在任何教程里看到那种「先插值再进网络」的流程源头就是这里。与 SRCNN 相比VDSR 的网络深度从 3 层跳到 20 层等价感受野从 13 个像素涨到 41 个像素输出像素能参考的输入范围大得多边缘和纹理的重建质量自然更高。另一个容易忽略的点网络里没有池化、没有批归一化、没有 dropout。池化会丢位置信息超分是逐像素对齐的任务不能要BN 在 2016 年已经流行但 VDSR 作者发现残差 梯度裁剪已经能稳定训练加 BN 只增加显存和时间成本。复现时别手痒加模块加了之后学习率、裁剪阈值全都要重新调。多尺度是这个网络另一个卖点同一个权重文件输入尺度 2、3、4 的退化图都能输出对应超分结果训练时每次随机抽一个 scale 就行模型结构一字节不用改。2.2 残差学习与梯度裁剪lr0.1 为什么敢这么设如果让网络直接学 HR 图本身它等于要学一个接近恒等映射的复杂函数深层网络在这种任务上收敛很慢。VDSR 换成学残差输入是 bicubic 上采样得到的模糊图目标是 HR 图与这张模糊图的差值。差值图大部分区域接近 0能量集中在边缘和纹理上网络要拟合的映射在恒等附近小幅摆动训练难度直接降一个量级。这也是后来 EDSR、RCAN 全都在用残差结构的原因。梯度裁剪是配套的另一半。学习率 0.1 在 20 层网络上相当激进前几百步 loss 可能直接冲到几十不裁剪根本训不动。裁剪规则是把所有参数的梯度拼成一个向量算 L2 范数超过阈值就整体等比缩放方向不变、步长被按住。论文和大多数复现版把阈值定在 0.4。很多人第一次复现时倒在「想当然」上以为梯度裁剪值是越小越好随手填 0.01结果 loss 在 0.05 附近磨蹭几个 epoch 都不动。0.4 是论文和社区验证过的稳定值先不要自作主张改。这里有个反直觉的结论VDSR 用高学习率 裁剪比直接上 Adam 更稳、也更贴论文。Adam 的自适应步长在深网络表现不差但你是来复现的先按论文的 SGD momentum0.9 weight decay1e-4 跑通再谈优化器对比实验。我在实际对比里发现VDSR 用 Adam 需要把 lr 降到 1e-4 以下训练速度反而慢最终精度还比 SGD 版本低那么一点。提示梯度裁剪必须在 backward 之后、optimizer.step() 之前执行。顺序错了裁剪等于没做该炸还是炸。2.3 从论文到代码VDSR 模型定义与初始化模型定义用循环拼 Sequential 就行真正的细节在初始化。论文的初始化方案分两段前 19 层用 He 初始化配合 ReLU最后一层卷积权重和偏置全部置零。最后一层零初始化的意义很巧妙训练一开始网络输出全 0 残差等价于直接输出插值图初始 loss 恰好是 bicubic 退化本身的误差梯度从一开始就落在有意义的量级上。import torch import torch.nn as nn class VDSR(nn.Module): def __init__(self, num_layers20, num_channels64): super().__init__() layers [] for i in range(num_layers): in_c 1 if i 0 else num_channels out_c 1 if i num_layers - 1 else num_channels conv nn.Conv2d(in_c, out_c, kernel_size3, padding1) layers.append(conv) if i num_layers - 1: # 最后一层后面不加 ReLU layers.append(nn.ReLU(inplaceTrue)) self.net nn.Sequential(*layers) self._init_weights() def _init_weights(self): # 前 19 层 kaiming 初始化第 20 层零初始化 for m in self.net: if isinstance(m, nn.Conv2d): if m.out_channels 1: nn.init.zeros_(m.weight) nn.init.zeros_(m.bias) else: nn.init.kaiming_normal_( m.weight, modefan_in, nonlinearityrelu) nn.init.zeros_(m.bias) def forward(self, x): return self.net(x) # 输出残差不是最终 HRforward 返回的是残差图而不是 HR 图残差需要在外部与输入 x 相加才是超分结果这个加法放到训练循环和推理函数里做模型本身保持纯净。padding1 保证任意尺寸输入输出不变后面测试任意大小图片都不用改模型结构。ReLU 用 inplaceTrue 能省一部分激活内存模型参数总共六十多万显存压力主要来自中间特征图能省一点是一点。3. PyTorch 数据管线与训练循环从 91 张图到可复现的完整训练3.1 Dataset 实现Y 通道、双三次退化与残差标签VDSR 原文训练数据是 SRCNN 时代流传下来的 91 张经典图像到今天这个图集还是超分入门最常用的训练集。数据管线的核心决策有四个只用 Y 通道、退化方式必须是 bicubic 先降后升、裁剪 41×41 patch、标签直接存残差而不是 HR 图。为什么只用 Y 通道论文在 YCrCb 空间训练和评测亮度通道承载了绝大多数高频细节色度通道对人眼不敏感省掉色度能省一半的显存和计算。推理时把 Y 通道的预测结果和原图的 Cb/Cr 合并再转回 BGR 保存。退化方式必须用 bicubic先对 HR 降采样到 1/scale再用 bicubic 升采样回原尺寸这张「升回来的模糊图」就是网络输入。千万别用最近邻或线性插值退化核和论文不一致训练出来的模型在评测时会平白掉精度。import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class VDSRDataset(Dataset): def __init__(self, hr_dir, scale3, patch_size41, trainTrue): self.hr_paths sorted( [os.path.join(hr_dir, f) for f in os.listdir(hr_dir)]) self.scale scale self.patch_size patch_size self.train train def __len__(self): return len(self.hr_paths) def __getitem__(self, idx): img cv2.imread(self.hr_paths[idx], cv2.IMREAD_COLOR) img cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) hr img[:, :, 0].astype(np.float32) / 255.0 # 只取亮度通道 h, w hr.shape[:2] lr_down cv2.resize(hr, (w // self.scale, h // self.scale), interpolationcv2.INTER_CUBIC) lr cv2.resize(lr_down, (w, h), interpolationcv2.INTER_CUBIC) # 双三次退化 if self.train: rh np.random.randint(0, h - self.patch_size 1) rw np.random.randint(0, w - self.patch_size 1) hr hr[rh:rh self.patch_size, rw:rw self.patch_size] lr lr[rh:rh self.patch_size, rw:rw self.patch_size] hr torch.from_numpy(hr.copy()).float().unsqueeze(0) lr torch.from_numpy(lr.copy()).float().unsqueeze(0) return lr, hr - lr # 输入是插值图标签是残差这里有个隐藏的坑cv2.resize 的 INTER_CUBIC 和 MATLAB imresize 的内核并不完全一致这是超分复现最大的隐蔽分歧点。同一份训练代码退化库不同最终 PSNR 能差 0.1 到 0.3 dB。我在避坑章节专门展开讲。训练模式下随机裁剪 41×41测试模式返回整图。Dataset 在返回前就算好残差训练循环里只算 MSE逻辑干净。3.2 数据增强8 倍是白送的精度VDSR 的数据增强说起来不值钱但效果实在水平翻转加 90 度旋转组合出 8 倍数据量。91 张图裁剪出的 patch 本来就是几万个再乘 8一个 epoch 足够喂饱网络。加这一步的主要原因不是防止过拟合而是让网络对常见的图像方向变换保持鲁棒评测时对标准测试集的方向更不敏感。if np.random.rand() 0.5: hr np.fliplr(hr) lr np.fliplr(lr) k np.random.randint(0, 4) hr np.rot90(hr, k) lr np.rot90(lr, k)这段代码放在裁剪之后、转 tensor 之前执行。注意带下划线的两个 np 函数np.rot90、np.fliplr 返回的是视图不是拷贝后面转 torch 之前必须用 .copy() 脱离共享内存避免 DataLoader 多进程下踩到脏数据。翻转和旋转必须对 hr 与 lr 同步操作两者是逐像素对应的关系转歪了等于喂脏数据训练出来的 PSNR 会掉。8 倍增强几乎不增加训练时间却能稳定换来零点几个 dB 的提升属于白捡的精度。3.3 训练循环与学习率调度照着论文抄就行训练主体用一个双层循环写完每 20 个 epoch 把学习率乘 0.1总共 100 个 epoch。假设你已经装好了带 CUDA 的 PyTorch 环境这块代码直接能在单卡上跑。第一次复现的人最容易在梯度裁剪这里栽跟头想起来要裁剪但不知道裁多少随手填了个大值前几百步 loss 照样爆炸。import torch import torch.nn as nn import torch.optim as optim model VDSR().cuda() criterion nn.MSELoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) for epoch in range(100): model.train() for lr_in, residual in train_loader: lr_in lr_in.cuda() residual residual.cuda() optimizer.zero_grad() out model(lr_in) loss criterion(out, residual) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.4) # 全局梯度裁剪 optimizer.step() if (epoch 1) % 20 0: for g in optimizer.param_groups: g[lr] * 0.1clip_grad_norm_ 的第二个参数是最大全局 L2 范数超过 0.4 就把所有梯度等比缩小到 0.4方向不变、步长被按住。这是 VDSR 能稳住 lr0.1 的核心机制。数据加载建议用 DataLoader(..., num_workers4, pin_memoryTrue)否则 GPU 大部分时间在等 CPU 准备数据具体表现见避坑章节第 4 条。没有 GPU 也能跑把 .cuda() 换成 .cpu()batch 降到 16就是慢一个晚上也能跑完。loss 的合理量级patch 归一化到 0-1 后初始 loss 大约在 0.05 上下训练稳定后掉到 1e-4 以下。如果你第一轮迭代 loss 就到了几十甚至几百先查梯度裁剪和最后一层零初始化别去怀疑代码逻辑。目标检测那套 warmup、余弦退火在这里都不需要VDSR 的节奏就是论文那张死板表0.1 起步、每 20 epoch 衰减一次。3.4 训练超参数总览参数论文/复现取值备注输入通道1Y 通道与论文评测口径一致卷积层数20 层 3×3结构与参数量敏感特征通道64显存紧可降到 32精度略掉patch 大小41×41与感受野一致别再小batch size6441×41 下单卡可跑优化器SGD momentum 0.9复现阶段先不要换 Adam学习率0.1每 20 epoch ×0.1必须配合梯度裁剪weight decay1e-4保持梯度裁剪全局 L2 范数 0.4backward 后、step 前总 epoch100loss 进平台期后可提前停这张表我实际复现过多次唯一动过的是特征通道从 64 降到 32显存直接砍半PSNR 大约掉 0.2 dB。patch 不建议缩小小于 41 时感受野超出 patch 边界边缘像素学不到完整上下文训练出的模型在真实大图上会留下明显的边界伪影。4. 测试流程与指标对齐PSNR/SSIM 怎么算才能对上论文数字4.1 推理流程残差回加、YCrCb 合并与保存训练完的模型只输出残差推理时要做的事比训练多一步把残差加回 bicubic 上采样图裁剪到 0-1 范围再和原始 Cb/Cr 通道合并转回 BGR 保存。这一步看着简单做错却很隐蔽很多人把残差直接当最终结果保存出来的图只有边缘纹理整体灰蒙蒙的——这种图 PSNR 低得离谱但看起来居然还有几分「锐化」效果最迷惑人。def infer(model, img_bgr, scale3, devicecuda): # img_bgr 是 0-255 范围的 BGR 图 ycrcb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2YCrCb) y ycrcb[:, :, 0].astype(np.float32) / 255.0 h, w y.shape[:2] lr cv2.resize(cv2.resize(y, (w // scale, h // scale), interpolationcv2.INTER_CUBIC), (w, h), interpolationcv2.INTER_CUBIC) with torch.no_grad(): lr_t torch.from_numpy(lr).float().unsqueeze(0).unsqueeze(0).to(device) residual model(lr_t).squeeze().cpu().numpy() sr_y np.clip(lr residual, 0, 1) # 残差 插值图 超分图 ycrcb[:, :, 0] (sr_y * 255.0).astype(np.uint8) return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)注意输入尺寸对齐如果 HR 的宽高能被 scale 整除Cb/Cr 通道用原图直接合并没问题如果除不净先把原图 resize 到能被 scale 整除的尺寸再走退化流程否则 Cb/Cr 和 Y 通道尺寸对不上合并时直接报错。标准测试集上不会有这个问题但用在任意用户图上几乎一定会踩。4.2 PSNR 与 SSIMY 通道、裁边界、数据范围三处必须统一评测口径不统一是复现超分论文最典型的翻车现场。VDSR 论文的评测约定是在 Y 通道上算 PSNR/SSIM边界像素裁掉像素取值范围按 0-255 计。三条少任何一条数字都对不上论文。import math import numpy as np from skimage.metrics import structural_similarity as ssim def calc_psnr_ssim(hr_y, sr_y, border): # 先裁掉边界再做指标计算 hr_y hr_y[border:-border, border:-border] sr_y sr_y[border:-border, border:-border] hr_y hr_y.astype(np.float64) / 255.0 sr_y sr_y.astype(np.float64) / 255.0 mse np.mean((hr_y - sr_y) ** 2) if mse 0: return float(inf), 1.0 psnr 10.0 * math.log10(255.0 ** 2 / mse) s ssim(hr_y, sr_y, data_range1.0) return psnr, s三个细节必须盯住。第一必须在 Y 通道算RGB 通道算出来的 PSNR 会比 Y 通道低 0.5 甚至更多因为色度噪声拖后腿第二border 的取值VDSR 论文和不同复现版对尺度 2/3/4 各裁几个像素没有统一的公开口径你从哪份脚本抄的评测就沿用它千万别混用两家的边界设置第三虽然输入输出归一化到 0-1算 PSNR 时最大像素值要按 255 算否则 log 里差 48 dB 量级。这个错误最容易犯也最隐蔽。注意训练时也要统一这套口径。有人训练时用随机 scale测试用固定 scale退化方式还不一样这种系统性偏差会让模型成绩忽高忽低看起来像玄学其实是口径问题。4.3 结果对照Set5 上能跑到多少才算复现成功完整跑完 100 个 epoch 后用 Set5 做 sanity checkY 通道、裁边界口径下我复现跑到的经验量级是这样的尺度期望量级Y 通道、裁边界判断依据×237.0–37.6 dB论文公开数字 37.53 是最常被引用的锚点×332.8–33.3 dB退化核差异在这个尺度最明显×430 dB 以上且比 SRCNN 高不同复现波动大别死磕绝对值判断复现是否成功的核心不是绝对数字而是两条同口径下比 SRCNN 高 0.5 dB 以上训练曲线走势和论文图一致第 40 epoch 后进入平台期第 60、80 两个衰减点各跳一次。如果 ×2 连 36.5 都不到先回避坑章节查退化核和评测口径而不是去调模型结构。网上各种教程贴的数字口径差别很大有的用 MATLAB imresize 退化有的用 PIL有的没裁边界直接横向对比没有意义。5. VDSR 复现避坑指南五个常见问题的现象、原因与解法这一章是我反复复现 VDSR 攒下来的血泪经验前四条是训练和评测阶段的硬伤第五条是工程习惯问题。每条都按「现象 → 原因 → 解决」写希望你一次跑通不用经历我当初的返工。5.1 loss 不降反升前几百步直接 NaN现象第一个 epoch 的 loss 从 0.05 涨到几十甚至几百偶尔直接 NaN终端刷一片红色告警。原因梯度爆炸。20 层卷积对梯度非常敏感lr0.1 下前向传播稍有扰动反向梯度就可能把权重推到数值溢出如果最后一层漏了零初始化初始 loss 直接是随机噪声量级永远训不下去。解决三层保险一起上。第一最后一层权重和 bias 用 zeros_ 初始化代码见 2.3 节第二backward 之后必须执行 clip_grad_norm_(model.parameters(), 0.4)第三lr 确认是 0.1不是 0.01——太低训不动太高必炸。我从第一次复现到现在凡是 NaN全是这三件事少了一件没有例外。5.2 显存 OOM但模型明明只有六十多万参数现象VDSR 参数不大batch 设 64 却报 CUDA out of memory。原因显存大头不在参数在中间特征图。20 层 64 通道的 3×3 卷积每层都要存一份 batch×64×41×41 的激活值用于反向传播叠加起来是参数量的几十倍DataLoader 开多进程后洪峰峰值还要更高。解决显存紧张时优先把 batch 降到 32 或 16patch 保持 41 不动再把特征通道从 64 降到 32是第二选择代价是精度掉 0.1–0.2 dB。我在 1080Ti 上用 batch 64 patch 41 跑得动显存再小就老实降 batch别动 patch。5.3 PSNR 比论文低 0.5 dB 以上怎么查都对不上现象训练和推理都没报错PSNR 却比论文低一截或者比同组师兄的复现结果低。原因大概率不是网络问题是口径问题。第一嫌疑是退化核cv2.INTER_CUBIC 的内核系数和 MATLAB imresize 不同Set5 上足以造成 0.1–0.3 dB 偏差第二是评测范围在 RGB 上算或没裁边界各能偷掉 0.2–0.5 dB第三是像素范围0-1 的图直接套 255 的公式PSNR 虚高。解决先锁口径。退化统一用 cv2训练和测试的退化流程必须完全一致评测统一 skimage在 Y 通道、裁边界、0-255 范围内计算。全部对上了还差查有没有用翻成 reflect padding 之类的边界操作——torch 默认 zero padding 与论文一致别换。5.4 训练一个 epoch 特别慢GPU 利用率只有 30%现象loss 正常、代码没死但 GPU 利用率上不去一个 epoch 要跑很久。原因getitem里每次都要读图、解码、两次 resize这些全发生在 CPU 上num_workers 默认 0 时数据加载完全串行GPU 大部分时间在空等。解决DataLoader 里设 num_workers4 或 8pin_memoryTrue如果机器内存够启动脚本把全部训练图一次性退化并裁剪成 patch 存成 .npy训练时直接读预处理的 patch省掉每次 resize 的开销。还有一条隐藏提速点用固定 stride 提前切好 patch 再训练比每次随机裁剪更快因为能走 numpy 批量切片。5.5 断点续训后 loss 异常现象保存 checkpoint 后中断续训第一个 epoch 的 loss 突然比保存时高很多甚至重新爬升。原因最常见的是只保存了模型权重没保存优化器状态。PyTorch 的 SGD 里 momentum 的滑动平均状态不恢复重启后第一步的更新方向是坏的学习率调度器如果从 epoch 0 重数还会在衰减点重复打对折。解决保存时用字典把状态打包torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, }, vdsr_ckpt.pth)加载时逐项 load_state_dict再把 scheduler 的 last_epoch 手动与保存的 epoch 对齐。这个坑我第四次续训时栽过lr 表记错白跑两小时。从此以后我所有训练脚本的 checkpoint 都是全家桶式保存。6. 进阶用法把 VDSR 用在自备数据集上的三处改动与验证习惯6.1 小改三处就能换成多尺度训练VDSR 论文本身就支持单模型多尺度一个权重文件通吃 2/3/4 三个放大因子。改动只有三处Dataset 里 scale 改成 None训练时每次随机抽一个 scale 做退化模型结构一字节不用动。if self.train and self.scale is None: scale np.random.choice([2, 3, 4]) else: scale self.scale这样训出来的模型省内存也省部署体积代价是每个尺度的精度比单尺度专门训练略低论文原文就是这个权衡。如果你只需要 4 倍放大可以只训 scale4用单尺度版本精度会更好一点。6.2 三个验证习惯换数据、换尺寸、换退化核这套三连验证是我后来复现每篇超分论文都强制走一遍的流程。第一换测试集Set5 之外至少再跑一次 Set14两个都过才算稳定只过一个容易盲信单一测试集。第二换输入尺寸训练和推理尺寸不一致时确认模型没有维度 bug、边界没有异常伪影。VDSR 全部卷积带 padding1理论任意尺寸通吃实际跑一下更放心。第三换退化核把测试输入分别用 cv2 和 PIL 两种 bicubic 生成看看结果差多少能暴露模型对特定退化核的过拟合程度。6.3 部署时的一个坑单通道输入别搞丢颜色变换最后提醒一个部署层面的细节VDSR 的输入输出都是 Y 通道。导出 ONNX 或封装推理 API 时预测的残差必须和原图的 Cb/Cr 通道合并再转回 BGR。很多人预处理只做了一半导出模型只接了网络本身部署端拿到的是一张只有亮度的灰度图。这不算 VDSR 的坑是工程封装的口径坑我在实际项目里见过两次。从那以后我每次复现超分论文第一件事就是先把退化核和评测口径写死成一份配置文件训练、测试、部署全部从这份配置走再也不用纠结「为什么数字对不上」这种问题。这套思路帮我省了至少三个通宵希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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