简介:这份资源面向图像超分入门者与深度学习初学者,用PyTorch完整复现经典SRCNN模型,解决从理论到跑通代码之间的落地问题。包内共28个文件,以6个Python脚本、7个pth权重、9个bmp图像及2个h5数据文件为主,涵盖数据准备、模型定义、训练与测试全流程,压缩包约74.6MB,解压后按README说明即可运行train或test,参数num_works建议改为0以适配普通电脑。资源自带数据集与训练结果,包含多组已训练权重和Set5、91-image等基准数据,方便直接对比双三次插值与SRCNN的放大效果,观察butterfly、zebra等样例的细节差异。目前已有2977人学习下载,适合想快速上手超分实验、理解卷积网络重建流程的读者参考。
1. 为什么 2024 年还要手撸一遍 SRCNN:一个被低估的基线
如果你最近在找图像超分的入门项目,大概率会先刷到 Real-ESRGAN、SwinIR、HAT 这些名字,动辄几十 GB 显存、几百万参数、训练几天起步。但真正让一个新手把「超分」这件事从黑匣子变成白盒子的,反而是那个只有三层卷积、参数不到 7 万的 SRCNN。我见过太多人一上来就 clone 大模型仓库,结果卡在数据预处理、PSNR 计算、上采样位置这些基础环节上,连 loss 为什么不降都说不清。SRCNN 的价值不在于效果多好,而在于它把超分的完整链路——低分辨率生成、Y 通道处理、双三次上采样、卷积重建、PSNR/SSIM 评估——全部暴露在你面前,每一行代码你都能改、都能验证。这份 PyTorch 实现适合两类人:一是想搞懂超分底层原理再往上走的初学者,二是需要快速搭一个可复现基线做对比实验的从业者。下面我按「先跑通、再拆解、后调优」的顺序,把这份资源拆开讲透。
2. 环境与数据准备:从零把 SRCNN 跑起来
2.1 依赖版本与目录结构
SRCNN 本身对 PyTorch 版本不敏感,但图像读写和评估指标这块容易出玄学问题。我一般锁定下面这套组合,实测在单卡 8GB 显存上跑 91-image 数据集毫无压力:
# 建议 Python 3.8+,避免 torchvision 与 PIL 的兼容坑 pip install torch==1.13.1 torchvision==0.14.1 pip install numpy==1.24.3 opencv-python==4.8.0.74 pip install scikit-image==0.21.0 tqdm==4.65.0目录结构建议按下面这样组织,后面所有脚本都基于这个相对路径,换机器不用改代码:
srcnn_project/ ├── data/ │ ├── train/ # 91 张训练原图 │ ├── val/ # Set5 或 Set14 验证集 │ └── test/ # 测试图 ├── checkpoints/ # 模型权重 ├── outputs/ # 超分结果图 ├── datasets.py ├── model.py ├── train.py ├── test.py └── utils.py提示:训练集原图必须是高分辨率图,SRCNN 的训练样本是「双三次下采样再上采样」得到的低分图与原始高分图配对,不要直接把网上下载的缩略图当原图用。
2.2 数据配对逻辑:为什么 SRCNN 要在 YCbCr 的 Y 通道上做
这是新手最容易翻车的地方。SRCNN 论文明确只在 YCbCr 色彩空间的 Y 通道上训练和推理,因为人眼对亮度更敏感,色度通道用双三次插值补回来就够了。如果你直接在 RGB 三通道上训,PSNR 会低 1~2dB,而且颜色容易偏。
import cv2 import numpy as np def prepare_pair(img_path, scale=3): """读取原图,生成 LR-HR 训练对,只在 Y 通道上操作""" img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb) y_channel = img[:, :, 0] # 只取 Y 通道 h, w = y_channel.shape # 裁剪成 scale 的整数倍,避免尺寸不匹配 h = h - h % scale w = w - w % scale hr = y_channel[:h, :w] # 双三次下采样得到 LR,再双三次上采样回 HR 尺寸 lr = cv2.resize(hr, (w // scale, h // scale), interpolation=cv2.INTER_CUBIC) lr_up = cv2.resize(lr, (w, h), interpolation=cv2.INTER_CUBIC) return lr_up.astype(np.float32) / 255.0, hr.astype(np.float32) / 255.0这段代码有三个关键点:第一,INTER_CUBIC下采样和上采样必须用同一种插值,否则模型学到的映射关系会错位;第二,裁剪成 scale 整数倍是为了避免 resize 后尺寸对不上,报错信息通常是size mismatch;第三,归一化到 [0,1] 而不是 [-1,1],因为 SRCNN 输出层没有 tanh,用 [0,1] 收敛更稳。
2.3 训练脚本的四个必调参数
训练脚本本身不长,但下面四个参数决定了你能不能复现论文效果:
| 参数 | 推荐值 | 作用 | 调错后果 |
|---|---|---|---|
| batch_size | 64 | 每次迭代样本数 | 太小 loss 震荡,太大显存爆 |
| lr | 1e-4 | 初始学习率 | 1e-3 直接发散,1e-5 收敛极慢 |
| epochs | 200 | 训练轮数 | 少于 100 轮 PSNR 上不去 |
| patch_size | 32 | 训练 patch 边长 | 太小感受野不足,太大过拟合 |
# train.py 核心片段 for epoch in range(epochs): model.train() for lr_patch, hr_patch in train_loader: lr_patch = lr_patch.to(device) hr_patch = hr_patch.to(device) pred = model(lr_patch) loss = criterion(pred, hr_patch) # 默认 MSE optimizer.zero_grad() loss.backward() optimizer.step()损失函数用nn.MSELoss()就行,SRCNN 论文用的就是 MSE,对应 PSNR 指标。有人换成 L1 会发现 PSNR 略降但视觉更锐利,这是正常现象,看你优化目标是什么。
3. 模型结构拆解:三层卷积到底在学什么
3.1 从 patch 提取到非线性映射再到重建
SRCNN 的结构可以用一句话概括:conv(9x9) -> ReLU -> conv(1x1) -> ReLU -> conv(5x5)。三层各司其职:
- 第一层 9x9 卷积:相当于对每个像素提取 64 维的局部特征,感受野覆盖 9x9 邻域,负责捕捉纹理和边缘。
- 第二层 1x1 卷积:做非线性映射,把 64 维特征压到 32 维,本质是在特征空间做降维和变换。
- 第三层 5x5 卷积:把 32 维特征重建成单通道输出,完成像素级重建。
import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels=1): super(SRCNN, self).__init__() self.conv1 = nn.Conv2d(num_channels, 64, kernel_size=9, padding=4) self.conv2 = nn.Conv2d(64, 32, kernel_size=1, padding=0) self.conv3 = nn.Conv2d(32, num_channels, kernel_size=5, padding=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注意 padding 的设置:9x9 卷积 padding=4,5x5 卷积 padding=2,这样输出尺寸和输入完全一致。如果你忘了设 padding,输出会比输入小一圈,训练时 loss 计算直接报尺寸不匹配。num_channels=1是因为我们只在 Y 通道上操作,如果你想做 RGB 三通道版本,改成 3 即可,但效果不一定更好。
3.2 参数量与感受野的权衡
SRCNN 总参数量约 6.8 万,感受野是 9+1+5-2=13,也就是每个输出像素依赖输入 13x13 邻域。这个设计在当年是精心权衡的结果:感受野太小重建不出高频细节,太大参数量暴涨且容易过拟合。对比一下,VDSR 用 20 层 3x3 卷积堆到感受野 41,参数量 66 万,PSNR 提升约 0.5dB。所以 SRCNN 的定位很明确——用最小代价验证「深度学习能做超分」这件事。
注意:不要试图通过加深 SRCNN 来提升效果,三层以上不加残差连接会出现梯度消失,loss 卡在 0.003 附近不降,这是血泪经验。
3.3 上采样位置:为什么 SRCNN 选择「先上采样再卷积」
SRCNN 的一个历史局限是它在网络输入前就把 LR 双三次上采样到 HR 尺寸,导致所有卷积都在高分辨率上计算,计算量是 FSRCNN 的 10 倍以上。但这也是它适合入门的原因——你不需要理解反卷积、亚像素卷积这些复杂操作,所有张量尺寸从头到尾一致,调试起来直观。
# 推理阶段:先上采样,再送进网络 def inference(model, lr_img, scale=3): h, w = lr_img.shape lr_up = cv2.resize(lr_img, (w * scale, h * scale), interpolation=cv2.INTER_CUBIC) lr_up = lr_up.astype(np.float32) / 255.0 tensor = torch.from_numpy(lr_up).unsqueeze(0).unsqueeze(0) with torch.no_grad(): output = model(tensor.to(device)) return output.squeeze().cpu().numpy()如果你后面要迁移到 FSRCNN 或 ESPCN,上采样位置就要挪到网络末端,这是架构演进的关键分水岭。
4. 训练与评估:PSNR 上不去的排查清单
4.1 PSNR 和 SSIM 的正确计算方式
评估指标算错是另一个高频翻车点。PSNR 必须在 Y 通道上算,且要先把像素值还原到 [0,255]:
from skimage.metrics import peak_signal_noise_ratio as psnr from skimage.metrics import structural_similarity as ssim def evaluate(hr, sr): """hr 和 sr 都是 [0,1] 的 float32 Y 通道图""" hr_uint8 = (hr * 255).clip(0, 255).astype(np.uint8) sr_uint8 = (sr * 255).clip(0, 255).astype(np.uint8) p = psnr(hr_uint8, sr_uint8, data_range=255) s = ssim(hr_uint8, sr_uint8, data_range=255) return p, sdata_range=255这个参数不写,skimage 会按输入数据类型推断,uint8 默认 255 没问题,但如果你传 float 进去就会算错。另外 SSIM 的win_size默认 7,图像小于 7x7 会报错,测试集里如果有极小图要单独处理。
4.2 训练曲线诊断:loss 不降的三种可能
第一种,学习率太大。现象是 loss 在前几个 epoch 直接飙到 NaN 或 0.1 以上震荡。解决是把 lr 从 1e-3 降到 1e-4,或者加torch.optim.lr_scheduler.StepLR每 50 轮衰减 0.5。
第二种,数据配对错位。现象是 loss 能降但 PSNR 始终在 20dB 以下。原因通常是 LR 和 HR 没有严格对应,比如下采样用了INTER_LINEAR而上采样用了INTER_CUBIC。解决是统一插值方式,并在__getitem__里打印一对样本的尺寸确认。
第三种,归一化不一致。现象是输出图整体偏灰或偏白。原因是训练时归一化到 [0,1],推理时忘了除 255。解决是把归一化写成一个函数,训练和推理都调用同一个。
4.3 验证集选择的讲究
Set5 只有 5 张图,跑一次评估不到 10 秒,适合每 10 个 epoch 快速看一眼趋势。Set14 有 14 张,更稳定但慢一些。我一般训练时用 Set5 监控,最终报告用 Set14 或 BSD100。注意验证集不能参与训练,哪怕只是拿来调学习率也不行,否则 PSNR 会虚高 0.3dB 左右。
5. 避坑与常见问题:那些文档不会写的细节
5.1 显存溢出但 batch_size 已经调到 1
现象:CUDA out of memory,但 batch_size 已经是 1。原因通常是验证阶段没有加torch.no_grad(),PyTorch 默认会构建计算图,显存占用是训练时的 2 倍。解决是在验证和推理代码块外面包一层with torch.no_grad():。
5.2 输出图像有棋盘格伪影
现象:超分结果在平坦区域出现规律性网格。原因一般是上采样用了INTER_NEAREST或者反卷积 stride 设置不当。SRCNN 用双三次上采样不会出现这个问题,如果你改成了反卷积版本,把 kernel_size 设为 stride 的整数倍能缓解。
5.3 PSNR 比论文低 1dB 以上
现象:复现结果 30.2dB,论文写 32.5dB。原因有三个可能:训练轮数不够(论文训了 8 小时,你训了 1 小时)、训练集不同(论文用 91-image + ImageNet 子集)、评估时没裁边。SRCNN 论文评估时会裁掉 scale 像素的边界,因为边界像素没有完整感受野。加上裁边后 PSNR 通常能涨 0.5~1dB。
5.4 模型保存后加载报 key 不匹配
现象:Missing key(s) in state_dict。原因是保存时用了torch.save(model, path)整个模型序列化,加载时类定义变了就报错。正确做法是只保存参数:torch.save(model.state_dict(), path),加载时先实例化模型再load_state_dict。
5.5 训练到一半 loss 突然变成 NaN
现象:前 50 轮正常,第 51 轮 loss 变 NaN。原因通常是某张训练图里有纯黑或纯白区域,梯度爆炸。解决是在数据加载时加一句if hr.std() < 1e-6: continue跳过低方差图,或者加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。
6. 进阶技巧:把 SRCNN 当基线做对比实验
跑通 SRCNN 之后,最有价值的用法是把它当基线,验证你对超分其他环节的理解。我一般会做三组对照:第一组改上采样位置,把双三次上采样挪到网络末端,用nn.PixelShuffle替代,参数量不变但计算量降一个数量级;第二组改损失函数,MSE 换 Charbonnier Loss,观察 PSNR 和视觉锐度的 trade-off;第三组改数据增强,加随机翻转和旋转,看小数据集上过拟合是否缓解。
# 用 PixelShuffle 替换前置上采样的 FSRCNN 风格改动 class SRCNNPostUp(nn.Module): def __init__(self, scale=3): super().__init__() self.conv1 = nn.Conv2d(1, 64, 5, padding=2) self.conv2 = nn.Conv2d(64, 32, 1) self.conv3 = nn.Conv2d(32, scale**2, 3, padding=1) self.pixel_shuffle = nn.PixelShuffle(scale) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) x = self.pixel_shuffle(self.conv3(x)) return x这个改动让网络在 LR 空间做卷积,最后一步才放大,推理速度提升约 8 倍,PSNR 基本持平。验证方法很简单:同一张测试图,分别用两个模型跑一遍,用time.time()卡推理耗时,用evaluate算 PSNR,数据说话。
还有一个容易被忽略的技巧是权重初始化。SRCNN 论文用的是高斯初始化 std=0.001,如果你用 PyTorch 默认的 Kaiming 初始化,前 20 轮 loss 会降得更快,但最终 PSNR 可能低 0.1dB。我一般手动加一层初始化:
def weights_init(m): if isinstance(m, nn.Conv2d): nn.init.normal_(m.weight, mean=0.0, std=0.001) nn.init.constant_(m.bias, 0.0) model.apply(weights_init)从那以后我每次复现超分模型,都会先跑一遍 SRCNN 基线,确认数据管道、评估指标、训练循环这三块没问题,再往上叠复杂结构。很多所谓的「模型不 work」,其实卡在数据配对或 PSNR 计算上,跟网络结构一点关系都没有。希望帮到你。
本文还有配套的精品资源,点击获取