简介这份基于 PyTorch 的 SRCNN 图像超分辨率实践包面向刚接触超分任务、希望完整跑通经典模型的小白读者围绕 SRCNN 的复现与实验展开帮助解决从网络结构理解、数据准备到训练测试落地的问题。压缩包共 28 个文件、约 74.6MB包含 6 个 Python 脚本、7 个 pth 模型权重、9 张 bmp 对比图以及 h5 格式数据集和说明文档覆盖数据准备、模型定义、训练与测试等环节其中权重和结果图便于直接观察三倍超分效果脚本则展示了网络结构与训练流程读者可以据此核对输入输出、损失计算和指标变化。已有 2978 人学习说明它在入门实践中具有一定参考价值。读者拿到的是一套可直接运行的完整工程现成数据集、预训练权重与双三次插值对照图相互配合既方便复现 SRCNN 的三倍超分结果也便于理解经典超分模型从数据到推理的基本链路并在此基础上改造成自己的实验。1. 先用三层卷积把超分跑通别一上来就啃大模型同事发来一张十年前的老照片问能不能放大四倍还看得清脸。我给了他一个 SRCNN 图像超分的脚本跑完他说“就这”——是的就这。SRCNN 是深度卷积第一次被正经用到单图超分辨率上的模型结构只有三层卷积参数量不到一万一张消费级显卡训几个小时就能出结果。它不可能打赢现在的扩散类方案但它能让你在半天内搞清楚 SR 任务里最容易被糊弄过去的几件事LR 和 HR 到底怎么配成对、PSNR 为什么必须在 Y 通道上算、bicubic 上采样放在网络前面到底付出了什么代价。适合两类人从检测、分类转过来想摸清超分评估口径的新手以及需要一个足够便宜的 baseline 做对照实验的熟手。下面直接给能跑的 PyTorch 代码、能抄的训练脚本还有我踩过的五个坑。2. SRCNN 的三层结构9-1-5 卷积核各自在算什么很多人看完结构图觉得“这也太简单了”然后动手写的时候在通道数、padding、输出尺寸上连翻三次车。先把每一层的职责和形状钉死后面调参才有依据。2.1 三个卷积层的数据流与参数量SRCNN 把超分拆成三步抽 patch、做非线性映射、重建。对应到卷积上就是 9×9、1×1、5×5 三层中间夹 ReLU最后一层不加激活。层卷积核输入通道输出通道padding激活参数量conv19×91644ReLU5248conv21×164320ReLU2080conv35×53212无801第一层的 9×9 大核不是随便定的。它的感受野要覆盖足够多的邻域像素才能判断当前这块区域是纹理还是平滑区核太小网络拿不到上下文输出会退化成对输入的直接复制。64 个通道意味着在同一个位置上抽出 64 种不同的响应模式有的对边缘敏感有的对角落敏感。第二层用 1×1 是这篇工作后期版本的一个务实改动。最早用的是 5×5改成 1×1 之后参数量从 25×64×32 直接降到 64×32速度提升明显而效果几乎不掉。原因在于这一层的本质是“逐位置地在通道维度上做组合”不涉及空间邻域用 1×1 卷积等价于对每个像素点做一次全连接空间上本来就不需要感受野。第三层 5×5 负责把 32 维特征重新压回图像空间输出通道数必须等于你要重建的通道数只处理 Y 通道就是 1处理 RGB 就是 3。这一层不加 ReLU因为重建出来的是像素值负数在 [0,1] 或 [0,255] 的值域里没有意义。三层加起来 8129 个参数。这个量级放在今天连一个残差块的零头都算不上也正因为如此你可以在笔记本上十分钟跑完一次完整训练把注意力放在流程正确性上而不是等卡。2.2 感受野与“先上采样再卷积”的代价把三层的核尺寸串起来算输出上一个像素能“看到”的输入范围是 9 (1-1) (5-1) 13也就是 13×13 的窗口。这个窗口是在已经放大过的 HR 空间里量的换算回原始 LR 空间要除以放大倍数4 倍超分时网络实际只看到了 LR 图上约 3.3×3.3 的一块。这是 SRCNN 在 4 倍任务上提升有限的结构性原因——信息入口太窄。更值得提前知道的是计算代价。因为 bicubic 上采样放在了网络入口所有卷积都在 HR 分辨率上跑。放大 4 倍意味着特征图面积是 LR 空间的 16 倍同样的三层结构4 倍超分比 1 倍多花 16 倍算力。这就是后续工作ESPCN、FSRCNN 那一系列统一把上采样挪到网络尾部的直接动机在 LR 空间做特征提取最后一步才放大。注意如果你的目标是 4K 图或者要嵌到端侧设备不要照抄 SRCNN 这个前置上采样结构它在这个场景下基本不可用。它适合的是“我想在一个白天内验证整条链路通不通”。2.3 从公式到 nn.Module把结构写成代码import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels1): super().__init__() # 第一层9x9 大核padding4 保证 H/W 不变 self.conv1 nn.Conv2d(num_channels, 64, kernel_size9, padding4) # 第二层1x1 非线性映射只做通道组合 self.conv2 nn.Conv2d(64, 32, kernel_size1, padding0) # 第三层5x5 重建padding2 同样保持尺寸 self.conv3 nn.Conv2d(32, num_channels, kernel_size5, padding2) self.relu nn.ReLU(inplaceTrue) self._init_weights() def _init_weights(self): # He 初始化适配 ReLU不初始化容易在 lr1e-4 时前期 loss 抖动 for m in [self.conv1, self.conv2, self.conv3]: nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) nn.init.zeros_(m.bias) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) x self.conv3(x) # 最后一层不加激活 return x逻辑说明三层卷积全部用padding kernel_size // 2是为了让输出和输入严格同尺寸。这一点非常关键——如果你按原始论文的 valid 卷积写输出会比输入小 12 个像素9-1 加 5-1后面做 MSE 时要么广播出错要么得手动裁 HR 的边界新手在这里卡住的比例很高。参数说明num_channels控制输入输出通道做 Y 通道实验填 1做 RGB 填 3改这一个参数就行别去动中间层的 64 和 32——中间通道数是和 9×9、1×1 的核尺寸绑定的经验配置改了要先确认显存和收敛速度。inplaceTrue的 ReLU 省一点显存在只有三层的情况下没有梯度问题。3. 数据管线与训练循环把 HR/LR 对造出来超分训练最容易被做错的地方不是模型是数据对怎么生成。很多教程直接从磁盘上读一对已经准备好的 LR/HR 图结果训练集和测试集的退化方式不一致指标看着挺高换张真实低质图就废。3.1 退化建模先降采样再升采样两步都不能省SRCNN 的输入不是原始低分辨率图而是“低分辨率图经过 bicubic 放大回目标尺寸”的图。所以造数据要两步HR 先缩小 scale 倍得到 LR再把 LR 放大回 HR 尺寸。这一步多做一次插值网络学到的就只是“插值之后剩下的高频残差”任务难度被显著降低这也是它能在三层结构下收敛的原因。只做第一步缩小不做第二步网络输入尺寸和标签对不上训练直接崩。只做第二步不做第一步那叫图像增强不叫超分。import glob import os import random import torch.utils.data as data from PIL import Image import torchvision.transforms as T to_tensor T.ToTensor() class SRDataset(data.Dataset): def __init__(self, hr_dir, scale3, patch_size33, repeat20): self.paths sorted(glob.glob(os.path.join(hr_dir, *.*))) self.scale scale self.patch_size patch_size self.repeat repeat # 每张图重复采样次数等价于扩大 epoch def __len__(self): return len(self.paths) * self.repeat def __getitem__(self, idx): path self.paths[idx % len(self.paths)] # 只取 YCbCr 的 Y 通道和评估口径保持一致 hr Image.open(path).convert(YCbCr).split()[0] if hr.width self.patch_size or hr.height self.patch_size: hr hr.resize((self.patch_size, self.patch_size), Image.BICUBIC) # 随机裁一块 33x33 的 HR patch x0 random.randint(0, hr.width - self.patch_size) y0 random.randint(0, hr.height - self.patch_size) hr_patch hr.crop((x0, y0, x0 self.patch_size, y0 self.patch_size)) # 降采样 - 升采样得到网络输入 low hr_patch.resize( (self.patch_size // self.scale, self.patch_size // self.scale), Image.BICUBIC, ) lr_patch low.resize((self.patch_size, self.patch_size), Image.BICUBIC) return to_tensor(lr_patch), to_tensor(hr_patch)逻辑说明先裁 HR 再降采样而不是先降采样再裁。顺序反了会导致 LR patch 的边界不对齐——降采样后的坐标是浮点映射先缩后裁拿到的不是同一个空间位置训练时网络会学到偏移。idx % len(self.paths)配合repeat参数让每张图在一个 epoch 内被采样多次这比直接复制图片文件省磁盘。参数说明patch_size33是原始配置比 13 的感受野大不少能保证每个 patch 内部有足够多的有效像素参与损失。scale决定退化强度2/3/4 各训一个模型是最常见的做法。repeat20在小数据集几十到几百张图上是必须的否则一个 epoch 只有几十个 batch训练曲线会剧烈震荡。3.2 训练循环损失、优化器与学习率损失函数用 MSE没有别的选择因为这个模型的目标就是在像素值上逼近。优化器用 Adam学习率从 1e-4 起步训到一定轮次后按步长衰减。原始工作用的是 SGD momentum但 Adam 在这么小的模型上收敛更快对新手更友好。import torch import torch.nn as nn from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) dataset SRDataset(./data/train_hr, scale3, patch_size33, repeat20) loader DataLoader(dataset, batch_size64, shuffleTrue, num_workers4, drop_lastTrue) model SRCNN(num_channels1).to(device) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.1) for epoch in range(60): model.train() running 0.0 for lr_img, hr_img in loader: lr_img, hr_img lr_img.to(device), hr_img.to(device) sr_img model(lr_img) loss criterion(sr_img, hr_img) optimizer.zero_grad() loss.backward() optimizer.step() running loss.item() * lr_img.size(0) scheduler.step() avg running / len(dataset) print(fepoch {epoch:03d} | mse {avg:.6f} | lr {scheduler.get_last_lr()[0]:.2e})逻辑说明drop_lastTrue很重要最后一个不完整的 batch 如果只有一个样本BN 类层会出问题虽然 SRCNN 没有 BN但保持这个习惯能避免你以后换模型时踩坑。scheduler.step()放在 epoch 循环里不是 batch 里——步长衰减的粒度是轮次放错了学习率会在几分钟内掉到 1e-8训练直接停滞。参数说明batch_size64对应 33×33 的 patch显存占用很小可以往 128 加。step_size30, gamma0.1的意思是每 30 轮学习率乘 0.160 轮里衰减两次这个节奏适合小数据集。如果你的 loss 在 20 轮左右就基本不动了把总轮次降到 40step_size改成 20别硬跑。判断训练是否正常看两个数MSE 应该从 0.01 量级稳步降到 1e-4 量级学习率衰减的那一轮MSE 会有一个明显的小台阶下降如果衰减后反而上升说明衰减太早。4. 推理与评估PSNR 算不对前面全白干训练完跑一次评估发现 PSNR 只有 22dB比 bicubic 还低先别怀疑模型。超分的评估口径比训练更容易出错而且错得很隐蔽——数值看起来“像那么回事”但和公开结果差 3 到 5 个 dB。4.1 任意尺寸输入与边界裁剪SRCNN 因为全用 padding可以吃任意尺寸的输入输出尺寸和输入一致。但 padding 在边界处补的是零网络对边界像素的预测是不可信的越靠边误差越大。标准做法是评估前裁掉边界 scale 个像素。import torch torch.no_grad() def infer(model, lr_tensor, scale3, crop_border3): model.eval() sr model(lr_tensor) sr sr.clamp(0.0, 1.0) # 输出可能越界必须先裁剪再算指标 if crop_border 0: c crop_border sr sr[..., c:-c, c:-c] return sr逻辑说明clamp是必须的。网络最后一层没有激活输出可以小于 0 或大于 1如果不裁转成 uint8 时会溢出回绕一个 1.2 的像素变成 51PSNR 直接崩掉。crop_border取 scale 是因为放大倍数越大边界误差影响的范围越宽。参数说明crop_border和训练时的 padding 有关padding4 的话理论边界影响范围是 4但实测取 scale 就够取太大反而损失有效像素、让指标虚高。评估时 SR 和 HR 必须裁同一个位置别只裁一个。4.2 PSNR/SSIM 的通道口径与数据范围超分领域报的 PSNR 默认是在 YCbCr 的 Y 通道上算的不是 RGB 平均。原因很实际人眼对亮度敏感、对色度不敏感很多方案只重建 Y 通道Cb/Cr 直接 bicubic 放大报 RGB 的 PSNR 会把这些没优化的通道也算进去不同方案之间没法比。import numpy as np from skimage.metrics import peak_signal_noise_ratio, structural_similarity def evaluate(sr_uint8, hr_uint8, scale3): # 输入均为 HxW 的单通道 uint8已裁掉边界 psnr peak_signal_noise_ratio(hr_uint8, sr_uint8, data_range255) ssim structural_similarity(hr_uint8, sr_uint8, data_range255) return psnr, ssim逻辑说明data_range255不能省。skimage 的默认行为会根据输入 dtype 推断范围但如果你传进去的是 float 且值域是 0-1它可能按 0-1 处理再和 0-255 的结果混着比误差直接是 48dB 量级。统一转成 uint8 再算最稳。参数说明SSIM 用默认的 7×7 窗口和 K10.01、K20.03 就行改了就和公开结果对不上。gaussian_weights保持 False除非你明确在复现某篇用高斯窗的论文。4.3 评估脚本与结果落盘import os from PIL import Image def eval_folder(model, hr_dir, scale3, devicecuda): model.eval() psnrs [] for name in sorted(os.listdir(hr_dir)): hr Image.open(os.path.join(hr_dir, name)).convert(YCbCr).split()[0] w, h hr.width, hr.height # 尺寸对齐到 scale 的整数倍避免降采样丢像素 hr hr.crop((0, 0, w - w % scale, h - h % scale)) low hr.resize((hr.width // scale, hr.height // scale), Image.BICUBIC) lr_up low.resize((hr.width, hr.height), Image.BICUBIC) lr_t T.ToTensor()(lr_up).unsqueeze(0).to(device) sr infer(model, lr_t, scalescale, crop_borderscale) sr_u8 (sr.squeeze().cpu().numpy() * 255).round().astype(np.uint8) hr_u8 np.array(hr)[scale:-scale, scale:-scale] p, s evaluate(sr_u8, hr_u8) psnrs.append(p) print(f{name}: psnr{p:.2f} ssim{s:.4f}) print(fmean psnr {np.mean(psnrs):.2f})逻辑说明先把 HR 裁到 scale 的整数倍是因为降采样时width // scale会丢掉余数像素导致后面 resize 回去尺寸对不上。SR 和 HR 两边都裁scale个边界像素保证评估区域严格一致。参数说明round()而不是astype直接截断截断会引入 -0.5 的平均偏差PSNR 会系统性低 0.1dB 左右。测试集用公开的 5 张图小集合就够跑一次十几秒适合快速迭代。5. 避坑与排查五个把 PSNR 卡住的真实故障下面五条都是我在不同机器、不同数据集上实际撞过的按发生频率排序。每条给出可观察的现象方便你对照自己的日志。5.1 训练侧loss 在降指标不动现象训练 MSE 从 0.008 稳定降到 0.0002评估 PSNR 却始终在 24dB 附近徘徊比 bicubic 基线高不到 0.3dB。原因repeat设成了 1几百张图的数据集一个 epoch 只有几个 batch60 轮下来总迭代次数不到一千次。三层卷积虽然小也需要几万次迭代才能把权重从随机初始化推到有用状态。日志上 loss 在降是因为它降的是训练集上的记忆误差不是泛化能力。解决把repeat提到 20 以上或者直接扩训练集。经验值是总迭代步数至少 2 万步起步低于这个数不要看评估结果。现象训练前几个 epoch loss 直接变成 nan。原因学习率用了 1e-3 并且没做权重初始化9×9 大核的初始输出方差过大第一层就把激活值推到了极端区域。解决加 He 初始化学习率回到 1e-4。如果还是 nan在backward前打印一次sr_img.min()和max()看是不是数据里混进了全黑图或者值域不是 0-1。5.2 评估侧指标虚高或对不上现象自己算出来的 PSNR 比公开结果高 4 个 dB高兴了半小时。原因把 SR 和“bicubic 上采样后的 LR”做了比较而不是和原始 HR 比。这相当于让模型和自己输入比当然是高分。另一种变体是把 HR 先 resize 过一遍再当参考图效果一样。解决评估脚本里只允许出现一个 HR 来源且它必须是训练时完全没参与过的那份原始文件。写死后不要中途改我一般会在脚本开头打印一次 HR 的文件路径和尺寸肉眼确认。现象同一张图脚本算出来 31.2dB手工用别的工具算出来 28.5dB。原因通道口径不一致。训练和推理都只处理 Y 通道但评估时把 SR 的灰度图直接和 HR 的 RGB 转灰度比。PIL 的convert(L)用的是 BT.601 加权系数和convert(YCbCr)取 Y 的结果不完全相同差 0.3 到 1dB 很常见。解决全流程统一用convert(YCbCr).split()[0]训练、推理、评估三处一个字都不要改。要输出彩色结果时单独把 Cb/Cr 用 bicubic 放大再合并不要把合并后的 RGB 塞回评估函数。现象模型在 2 倍任务上正常换到 4 倍任务 PSNR 掉到 20dB 以下。原因patch_size还是 33但 4 倍降采样后 LR patch 只有 8×8再 bicubic 放大回 33×33 时有效信息已经被压没了网络看到的基本是一片模糊。同时crop_border还按 2 倍时的 2 设置边界误差没裁干净。解决patch_size随 scale 放大4 倍时提到 48 或 64crop_border改成等于 scale。这两处改完4 倍任务的 PSNR 能回到正常区间。提示排错的顺序永远是“先确认数据对再确认尺寸对最后怀疑模型”。三层网络没什么可调的九成的异常都在数据和评估这两段。6. 进阶把 SRCNN 当 baseline而不是终点真要把这个模型用在对照实验里有三处改动性价比最高加起来不到二十行代码。第一处是残差学习。网络不去预测整张 HR只预测HR - LR_up这个差值输出加回输入。高频残差的数值范围小优化目标更集中收敛速度快一截。def forward(self, x): residual self.conv3(self.relu(self.conv2(self.relu(self.conv1(x))))) return x residual # 只学高频输入直接短路加回来第二处是训练时随机切换 scale。把scale从固定值改成每个 batch 随机取 2/3/4一个模型能覆盖多个放大倍数代价是同 scale 下的峰值指标会掉 0.1 到 0.2dB。做产品比做刷榜更在意这个。第三处是换更大的训练集。九十来张图的年代早就过去了现在动辄上千张高清图配合repeat调小到 5泛化差距很明显。最后一处是别碰 Cb/Cr。想做彩色输出Y 通道走网络Cb/Cr 直接 bicubic 放大后合并回 YCbCr 再转 RGB视觉上和全通道训练的差别小到看不出来但省了三分之二的算力。改动代码量典型收益主要代价残差学习1 行收敛快 30% 左右几乎无多尺度训练5 行一模型多倍率单倍率指标略降扩大训练集0 行泛化提升明显训练时间线性增长当年我第一次复现这个模型训完直接在 RGB 上算 PSNR报了个 34dB 出去被人一句“你裁边界了吗”问住回去重算只有 29.8。从那以后我每次跑超分实验都强制先跑一遍评估脚本的自检打印 HR 的路径和尺寸、打印 SR 和 HR 的裁剪后形状、打印两者的值域三行日志对上了才开始训。这套习惯后来帮我省下的时间比调任何超参数都多。希望帮到你。本文还有配套的精品资源点击获取