简介:这是一份基于PyTorch实现经典超分辨率模型SRCNN的完整入门资料,特别适合刚接触图像超分或PyTorch的小白用户。资源将模型定义、数据加载、训练与测试代码分模块组织,并配有运行说明文档,帮助读者理解从数据准备到输出重建图的整体流程。压缩包共二十八个文件,约七十四点六兆字节,其中包含用于训练与评估的h5数据集、不同训练阶段的pth权重文件、用于对比展示的bmp原图与超分结果图,以及对应数据预处理、模型搭建、训练和测试等环节的多个py脚本,目录结构清晰,解压后按说明操作即可运行。目前已有近三千人学习使用,对于希望快速掌握SRCNN实现细节的读者来说,省去了自行收集数据、编写代码和调试环境的时间,是一份高性价比的入门选择。
1. 用 PyTorch 做超分的第一个模型:SRCNN 解决的是哪一环
很多人第一次接触超分辨率,脑子里想的都是 GAN、扩散模型或者动辄几百层的 Transformer,觉得自己得追上最新的 SOTA。但实际上,SRCNN 这个 2014 年提出的三层卷积模型,至今仍是超分任务里最值得先跑通的一个基线:它结构极简,先用双三次插值把低分辨率图放大到目标尺寸,再用三层卷积把图像细节拉回来。用 PyTorch 实现它,核心代码不到一百行,却能把超分任务里最典型的数据准备、模型搭建、评估口径全部过一遍。如果你正在做课题需要快速拿到一个对比 baseline,或者刚开始学超分想彻底搞懂整套流程,这篇笔记就是按我实际用过的落地路径写的——原理放前面,代码和参数放中间,最后把容易翻车的地方摊开讲。
2. SRCNN 原理与选型:三层卷积为什么能在超分上立住
SRCNN 的设计思路其实来自传统稀疏编码超分:把低分辨率图像拆成小块,在高维特征空间里做字典匹配和重建,再把结果拼回去。作者把这个流程简化成了三个卷积层,分别承担特征提取、非线性映射和重建三件事。理解这个对应关系,你就知道为什么网络这么浅也有可观的重建能力。
2.1 三层结构拆解:特征提取、非线性映射、重建各自的职责
我刚接触 SRCNN 时觉得它“简单到不像超分”,但把三层卷积的对应关系看清之后,才发现每一层的设计都有明确目的。第一层是 9×9 卷积,输出 64 个特征图,职责是提取图像块级别的特征,类似稀疏编码里的字典表示;第二层是 1×1 卷积,把 64 维特征变换成 32 维特征,这一步是关键的非线性映射,对应“在特征空间里找匹配”;第三层是 5×5 卷积,把 32 维特征聚合回像素输出,对应重建。
| 层级 | 卷积核 | 输出通道 | padding | 职责 |
|---|---|---|---|---|
| conv1 | 9×9 | 64 | 4 | 特征提取,获得较大的局部感受野 |
| conv2 | 1×1 | 32 | 0 | 逐像素非线性映射,核心重建能力来源 |
| conv3 | 5×5 | 1 | 2 | 聚合特征,输出重建像素 |
三个卷积的 padding 我都是按照kernel_size // 2设置的,这样特征图尺寸在全程保持不变,输入输出尺寸一致,不需要额外设计尺寸对齐逻辑。第一层用 9×9 而不是 3×3,是因为图像纹理的上下文范围往往大于单个像素,太小感受野抓不到结构;第三层用 5×5 而不是 1×1,则是因为最后要把分布在通道上的特征在空间邻域内重新融合,需要一定的聚合范围。
前两层卷积后面接 ReLU 激活,第三层输出不加激活函数。原因是超分本质是像素回归,输出值直接对应亮度,不约束激活范围反而让模型更容易拟合任意亮度的像素值。值得注意的是,整个网络没有任何 BatchNorm 层,原版没有,实践上也不建议加。只有三层网络加 BN,在小 batch 下会让训练集和验证集的统计量不一致,反而掉 PSNR。
2.2 输入输出约定:先插值再卷积,与颜色通道的选择
SRCNN 和各种后期超分模型最大的区别在输入约定:它要求输入已经是放大到目标尺寸的图。常见做法是先用双三次插值把低分辨率图放大到 HR 尺寸,然后网络在插值后的图像基础上做细节恢复。这意味着训练时我们构建的输入输出对也有固定套路:从 HR 图上裁剪一个 patch,先按 scale 因子缩小,再用双三次插值放大回原尺寸,把放大的结果当输入,原始的 HR patch 当目标。
所以你会发现 SRCNN 的网络输入输出尺寸完全一致,模型学的是“插值后图像 → 真实 HR 图像”的映射。这和 FSRCNN、ESPCN 完全不同,它们是在低分辨率空间做计算,最后一步才用转置卷积或亚像素卷积上采样。实现 SRCNN 时不需要任何上采样层,搞清楚这一点,你对后面代码里的预处理部分就不会迷惑了。
颜色通道方面,有两条路线。论文原版针对亮度通道设计:先把 RGB 转成 YCbCr,只取 Y 通道训练网络,重建时把插值后的 CbCr 通道拼回去再转回 RGB。这是基于人眼对亮度更敏感的事实,色度细节用双三次插值就够了。近年超分模型基本都在 RGB 上直接训练,结构上改动也很小。我做 baseline 时建议先用 Y 通道版跑通,数字更容易贴近原论文;要切 RGB 版本,只需要把网络第一层和第三层的通道数改成 3,数据集里不转 YCbCr 直接返回 RGB patch 即可。
2.3 MSE 损失为什么是 SRCNN 的选择:优化目标和 PSNR 的等价关系
MSE 和 PSNR 之间有直接的数学联系:在数据范围固定的前提下,MSE 越小,PSNR 越高。SRCNN 原论文用的就是 MSE,所以复现时第一选择也应该跟着用 MSE,否则你拿 30.2dB 的复现结果和别人 30.5dB 的原始结果对比,会说不清损失函数差异带来的影响。
L1 损失是超分任务里另一个常见选择,它对离群残差不那么敏感,训练早期表现往往更稳定,也被认为更利于保留边缘。但如果目标是复现 SRCNN 的指标,切换到 L1 后验证 PSNR 大概率不会立刻变好,因为评估口径就是 PSNR,而 PSNR 本质对应的是均方误差。我自己实际对比过,L1 训练到收敛后的 PSNR 比 MSE 低 0.1~0.2dB 是很常见的,除非你追求更锐利的视觉效果,否则不推荐在 SRCNN 上轻易替换损失。
还要提醒一个新手容易踩的心理预期问题:MSE loss 数值很小,不代表图像质量高。输入输出被归一化到 [0, 1] 后,一个表现尚可的 SRCNN 的 loss 大约在 0.002 到 0.005,换算成 PSNR 也就是 23~30dB 这个区间。训练时盯着 loss 曲线看很难判断好坏,更靠谱的做法是每个 epoch 结束后在固定验证集上算 PSNR,这也是我后续章节反复强调的缓考习惯。
3. 从零实现 SRCNN:模型定义、数据加载与训练主循环
这一章给你一套可以原样复现的最小代码骨架。我自己的做法是拆成三个文件:model.py 放网络定义,dataset.py 放数据加载,train.py 放训练主循环。代码里尽量避免和具体实验环境耦合,方便你直接改路径和参数。
3.1 网络定义:nn.Conv2d 与权重初始化的关键配置
import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, in_channels: int = 1): super().__init__() self.conv1 = nn.Conv2d(in_channels, 64, kernel_size=9, padding=9 // 2) self.conv2 = nn.Conv2d(64, 32, kernel_size=1, padding=0) self.conv3 = nn.Conv2d(32, in_channels, kernel_size=5, padding=5 // 2) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) x = self.conv3(x) return x def init_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0)这段代码就是 SRCNN 的全部网络结构。forward 里三个卷积按顺序串联,前两个接 ReLU,第三个直接输出。in_channels 参数让我可以在 Y 通道版和 RGB 版之间切换:Y 通道版传 1,RGB 版传 3,conv3 的 out_channels 也会跟着变成 3。
权重初始化我选了 Kaiming 初始化,这是 ReLU 系列网络的默认选择。mode='fan_in'保证前向传播的方差稳定,nonlinearity='relu'会把 ReLU 造成的方差衰减考虑进去。1×1 卷积也要做初始化,不要因为层数少就跳过。关于参数量有个直观感受:单通道输入时第一层权重是 9×9×1×64 一共 5184 个参数;换成 RGB 输入就是 9×9×3×64 一共 15552 个,训练速度会略慢,显存占用也会上升。
3.2 数据准备:HR 裁剪、双三次下采样、再插值回原尺寸
import random from pathlib import Path from PIL import Image import torchvision.transforms.functional as TF from torch.utils.data import Dataset class SRCNNDataset(Dataset): def __init__(self, hr_dir: str, patch_size: int = 96, scale: int = 2, train: bool = True): self.hr_paths = sorted(Path(hr_dir).glob('*.png')) self.patch_size = patch_size self.scale = scale self.train = train def __len__(self): return len(self.hr_paths) def __getitem__(self, idx): hr = Image.open(self.hr_paths[idx]).convert('YCbCr') hr_y, _, _ = hr.split() if self.train: i = random.randint(0, hr_y.height - self.patch_size) j = random.randint(0, hr_y.width - self.patch_size) hr_patch = hr_y.crop((j, i, j + self.patch_size, i + self.patch_size)) if random.random() < 0.5: hr_patch = hr_patch.transpose(Image.FLIP_LEFT_RIGHT) else: hr_patch = hr_y # 先按 scale 缩小,再双三次放大回原尺寸,构造 SRCNN 输入 lr = hr_patch.resize( (hr_patch.width // self.scale, hr_patch.height // self.scale), Image.BICUBIC ) lr = lr.resize((hr_patch.width, hr_patch.height), Image.BICUBIC) lr_t = TF.to_tensor(lr) hr_t = TF.to_tensor(hr_patch) return lr_t, hr_t这个数据集类做了三件事:读图并取 Y 通道,训练时随机裁剪并做水平翻转,最后构造 SRCNN 的输入输出对。构造输入输出对是核心逻辑:先把 HR patch 等比缩小到 scale 分之一,再放大回原尺寸,放大结果就是模型的输入。这里两次 resize 都用Image.BICUBIC,因为 SRCNN 的训练假设就是“输入图来自双三次插值放大”,如果你在推理时用了别的插值方式,输入分布会不一致,性能会打折扣。
TF.to_tensor会把 PIL 图像自动归一化到 [0, 1],这一步非常关键。如果你保留 PIL 的 uint8 范围直接喂进网络,MSE 的数值会被放大接近一个数量级,训练很容易不稳定。训练模式下patch_size=96是常用配置,保证裁剪窗口能被常见 scale 因子 2、3、4 整除;裁剪起始位置用随机整数,让同一个 epoch 内每次看到的 patch 都不完全一样。验证模式下我直接返回整张 Y 通道图,不做裁剪,这样最后评估时能看到整图效果。
这里有一个隐藏坑:如果原始 HR 图的宽高不是 scale 的整数倍,第一次 resize 向下取整后,再放大回来尺寸可能和原 patch 差 1 个像素。我在代码里通过hr_patch.width // self.scale保证缩小时的像素数是整数,实际训练前最好统一把训练集图片中心裁剪成 scale 的整数倍尺寸,避免边界出现半像素错位。
3.3 训练主循环:优化器、学习率调度与验证 PSNR
import torch import torch.optim as optim from torch.utils.data import DataLoader model = SRCNN(in_channels=1) model.init_weights() dataset = SRCNNDataset('./data/hr', patch_size=96, scale=2, train=True) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=4) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 50], gamma=0.1) for epoch in range(60): model.train() running_loss = 0.0 for lr, hr in loader: optimizer.zero_grad() output = model(lr) loss = criterion(output, hr) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() avg_loss = running_loss / len(loader) print(f'epoch {epoch + 1:03d}, loss: {avg_loss:.6f}')训练循环我用的是 Adam + 1e-4 学习率。MSE 作为损失函数,优化器负责把梯度更新到参数上。MultiStepLR在 30 和 50 个 epoch 时把学习率降为原来的十分之一,这种分段衰减在超分任务里几乎总是能带来 PSNR 的明显提升,因为后期模型进入精细调整阶段,小学习率能避免在最优解附近震荡。
如果你做的是严格论文复现,可以把优化器换成 SGD + momentum=0.9,学习率从 1e-2 起步,手动做分段衰减,这样需要更多 epoch 才能收敛,但最终指标通常略好于 Adam。我的经验是:先用 Adam 快速确认数据和代码没有问题,拿到一组合理结果后,再决定要不要花时间做 SGD 严格复现。训练时最好每个 epoch 保存一次 checkpoint,第 5 章会讲具体做法。
4. SRCNN 训练中的常见问题与排查:5 个高频翻车点
训练 SRCNN 踩坑比想象中频繁,而且很多坑不是因为模型结构,而是出在预处理、评估口径这些边边角角。我按实际出现频率整理了 5 条,每一条都按“现象 → 原因 → 解决”来写,你可以直接对照排查。
4.1 现象:loss 正常下降,验证 PSNR 却比初始还低
训练 loss 从 0.01 降到 0.002,看起来一切正常,但验证集上算出来的 PSNR 只有 10dB 出头,甚至不如直接把低分辨率图双三次放大。这个问题我遇到不止一次,最后基本都指向同一个原因:预处理不一致。
训练时数据经过TF.to_tensor被归一化到 [0, 1],输出自然也在 [0, 1] 附近。但验证的时候如果你用另一套读取逻辑,比如直接读 PIL 图像、没有归一化,或者在计算 PSNR 时把 [0, 1] 范围内的数据当成了 [0, 255] 范围,算出来的 PSNR 就会严重偏低。另一个常见原因是模型输出没有 clamp,偶尔预测出负值或大于 1 的值,MSE 会被这些离群点拉大。解决方法是:训练和验证共用一个预处理函数,算 PSNR 前先对输出做clamp(0, 1),并且明确你的数据范围到底是 [0, 1] 还是 [0, 255],全程统一。
4.2 现象:loss 不降或者一开始就变成 NaN
loss 变成 NaN 是新手最容易慌的场景。SRCNN 只有三层,结构上几乎不可能出现梯度消失,NaN 的根源基本是梯度爆炸。最常见的原因是输入没有归一化:把 uint8 的 0~255 数据直接喂给网络,MSE 的量级被放大几万倍,回传梯度也很大,Adam 在这种尺度下根本稳不住。loss 完全不动则是另一个极端,一般是学习率设得太小,或者某个卷积层的 bias 初始化不合理导致输出恒为常数。
排查顺序我一般这样走:先打印输入 tensor 的min()和max(),确认数据范围在 [0, 1];再把学习率从 1e-5 开始小步试,观察到 loss 有下降趋势后逐渐调大;如果还不行,把 Adam 换成 SGD 看一眼 loss 曲线,验证是优化器问题还是数据问题。这招在定位问题上很管用,返工成本也低。
4.3 现象:训练集 PSNR 高,验证集纹丝不动
训练集上的 PSNR 已经到 31dB,验证集却一直在 28dB 附近徘徊,这是典型的过拟合信号。SRCNN 参数量虽然不大,但如果你手里的训练图像有限,随便裁几个 patch 也很容易把训练集背下来。还有一个隐蔽的数据泄漏问题:有人图省事,把同一张图既当训练集又当验证集,或者训练集合验证集来自同一视频的不同帧,场景高度相似,看起来验证集效果不错,实际上模型根本没有泛化能力。
解决思路有三条:一是扩展数据增强,水平翻转之外再加上旋转 90 度倍数,甚至随机多尺度采样;二是训练集和验证集严格分开,验证集固定为一个小集合,比如公开的 5 张测试图,保证和训练图没有任何重叠;三是每个 epoch 重采样 patch,不要每次都用固定位置裁剪,增加数据多样性。做完这三步,验证集 PSNR 通常会有 0.3~0.5dB 的回涨。
4.4 现象:把 MSE 换成 L1,验证 PSNR 反而降了
不少做超分的人喜欢上来就换 L1 损失,理由是“大家都说 L1 保边缘”。但这在 SRCNN 上是一个很常见的翻车点。L1 优化的是绝对误差中位数,和 PSNR 依据的均方误差并不等价,训练到收敛后 PSNR 比 MSE 低 0.1~0.3dB 是很正常的现象。
原因在于 PSNR 定义里包含平方项,对大的残差更敏感,而 L1 不惩罚大残差像 MSE 那么狠。如果你是复现论文数字,老老实实用 MSE;如果追求视觉锐利度,我会建议先跑通 MSE 版本做基准,再单独对比 L1 效果,而不是一开始就换。做这一类对比时,尽量保持学习率、epoch 数、数据增强完全一致,否则你分不清是损失函数的影响还是训练配置的影响。
4.5 现象:看起来一样的代码,别人 PSNR 比我高 0.5dB
这是最让人上火的情况:模型定义一样,数据一样,训练配置看着也一样,但别人的结果就是比你高 0.5dB。我排查过很多次,结论是评估口径差异占了一大半。有人算 Y 通道的 PSNR,有人算 RGB 三通道平均;有人把图像四周边界裁掉几个像素再算,有人直接算整图;还有人 resize 用的插值方式不同,PIL 的双三次和某些数值库的双三次权值不完全一样,输入分布就有微小差异。
解决方法是把评估口径写死在实验记录里:通道是 Y 还是 RGB、shave 几个像素、resize 用什么插值、数据范围是 [0,1] 还是 [0,255]。做模型对比时先统一口径再谈好坏,否则那 0.5dB 可能根本不是模型带来的,而是评估方法带来的。
5. 让 SRCNN 真正可用:参数调优、PSNR 评估与推理落地
模型训练起来只是第一步,真正让 SRCNN 变成一个可复用的工具,还需要解决参数选择、评估口径和推理流程这三个问题。这一章给出的都是我在实际项目中沉淀下来的参数组合和处理习惯。
5.1 必调参数清单:lr、patch、batch 与 epoch 的搭配
组合参数时,我一般会先固定 patch_size=96、batch_size=16,用 Adam 找一个能稳定收敛的学习率,再考虑要不要切到 SGD 做精细复现。下面这张表是我的推荐起点,你可以按自己的显存和数据量调整。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam,lr=1e-4 | 快速收敛,适合快速验证 |
| 优化器(复现) | SGD,momentum=0.9,lr=1e-2 起步 | 需要手工分段衰减,收敛更慢但指标略高 |
| batch_size | 16 | 显存不足时降到 8,对 SRCNN 影响不大 |
| patch_size | 96 | scale=2 够用;scale=4 建议提到 128 |
| epoch | 60~100(Adam) | SGD 严格复现建议 200+ |
| 学习率调度 | MultiStepLR,milestones=[30, 50] or [50, 75],gamma=0.1 | 后期小学习率精调 |
| weight_decay | 0 或 1e-5 | 小网络默认 0 即可 |
| 数据增强 | 水平翻转 + 随机裁剪 | 旋转 90 度可加,提升有限 |
参数之间的依赖关系要注意:scale 越大,patch_size 也要越大,否则下采样后 patch 里包含的原始信息太少,模型学不到足够的纹理结构。显存不够时优先减 batch_size,不要减 patch_size,因为 patch 尺寸直接决定了模型的感受野和重建质量。
5.2 正确计算 PSNR:Y 通道、边界剔除与多图平均
import torch def psnr(pred, target, shave_border: int = 4, data_range: float = 1.0): pred = pred.clamp(0, data_range) target = target.clamp(0, data_range) diff = (pred - target).abs() if shave_border > 0: diff = diff[..., shave_border:-shave_border, shave_border:-shave_border] mse = diff.pow(2).mean() return 10 * torch.log10(data_range ** 2 / mse)这个 PSNR 函数按照超分任务的习惯做了三件事:先把预测值 clamp 到数据范围内,避免离群输出影响;再按shave_border去掉四周边界像素;最后计算 MSE 并转成 dB 值。边界剔除是超分评估的老传统,因为卷积 padding 在边界处会引入不可靠的像素值,去掉边界后指标更接近真实重建质量。
多图平均时要特别注意:先对每张图单独算 PSNR,再取平均,而不是把多张图合并成一个 tensor 后一次性计算。原因是不同图像的亮度范围可能不同,混在一起算会得到一个没有意义的全局均方误差。我在项目里习惯把验证结果记录成表格,注明通道类型、shave 数量和插值方式,这样后续任何一次改动都能追溯到具体原因。
5.3 checkpoint 与推理:训练产物怎么变成能用的图
import numpy as np import torch torch.save(model.state_dict(), 'srcnn_scale2.pt') model = SRCNN(in_channels=1) model.load_state_dict(torch.load('srcnn_scale2.pt', weights_only=True)) model.eval() def inference(model, lr_pil, device='cpu'): lr_t = TF.to_tensor(lr_pil).unsqueeze(0).to(device) model.to(device) with torch.no_grad(): sr_t = model(lr_t) sr_t = sr_t.squeeze(0).clamp(0, 1).permute(1, 2, 0).numpy() return (sr_t * 255).astype(np.uint8)保存 checkpoint 时只保存state_dict就够了,不需要保存整个模型对象,省空间也避免版本兼容问题。加载时先重新实例化一个同结构的 SRCNN,再load_state_dict。推理时model.eval()和torch.no_grad()必须同时出现,前者关掉 dropout 和 BN 的训练行为,后者关闭自动求导图,后者对显存和数据速度都有明显帮助。
推理输出需要在最后做一个后处理链:clamp 到 [0, 1]、转成 HWC 排列、乘 255、转 uint8。如果你做的是 Y 通道版,别忘了把原图的 CbCr 通道做双三次插值放大后和重建的 Y 通道拼回去,再转 RGB。这个步骤漏掉的话,输出的彩色图像颜色会整体偏色,属于超分新手最容易忽略的“最后一公里”问题。
6. 把 SRCNN 往上抬一层:验证集、可视化与多尺度评测
SRCNN 跑通之后,接下来的工作重点不再是调网络结构,而是建立一套可靠的实验习惯。我吃过最大的亏就是验证集不规范:曾经为了省时间,把训练集里抽了几张图当验证,训练了 100 个 epoch 后验证 PSNR 虚高,结果换到真实图像上效果明显变差,白白浪费了一个星期。教训只有一句话:验证集和训练集严格分开,哪怕只是快速验证也绝不混用。
我现在的固定流程是三个步骤。第一步,准备一个固定的小验证集,包含 5 张左右和训练集不重叠的图像,每次实验都用同一批图、同一个 shave 值、同一种插值方式,把 PSNR 记进实验表格;第二步,训练到一半时拉出 LR、SR、HR 三张图并排可视化,重点看边缘和纹理区域,因为 PSNR 高了不代表细节真实,有时候只是整体亮度更接近,肉眼对比能让你发现指标看不出来的模糊;第三步,验证多尺度性能,SRCNN 训练时用的 scale=2,不代表它对 scale=3 的输入也有同样好的效果,需要按 scale 分别训练模型。
把这个流程跑熟之后,SRCNN 就不再是一个玩具项目,而是你手里最稳定的超分 baseline。遇到更强的模型,你可以用同一套评估流程去对比,用一个已调通的数据管线去支持后续实验。希望这套方法能帮你在超分路上少走一段弯路。
本文还有配套的精品资源,点击获取