SRCNN图像超分实战:三层卷积、数据管线与PSNR对齐
简介:面向PyTorch初学者与图像超分入门者的SRCNN实战包,围绕经典超分辨率模型SRCNN给出可直接运行的完整实现,适合想从零理解超分流程、复现论文思路并观察重建效果的学习场景。压缩包共28个文件、约74.6MB,包含6个py源码用于数据准备、模型定义、训练与测试,7个pth权重便于加载预训练结果,另有bmp对比图、h5数据集、md说明和txt运行记录等,覆盖训练、推理与结果查看环节。已有2978人学习,说明其作为入门案例具有一定参考价值。读者可据此梳理数据预处理、模型搭建、参数配置与测试评估的完整链路,并通过现成权重和超分/双三次对比图直观比较效果;按说明调整num_workers等参数后即可跑通,适合作为课程实验、复现练习和排错参照。
1. 三层卷积跑出 0.3dB:SRCNN 到今天还值得亲手写一遍
前阵子有人问我,图像超分现在都被扩散模型占领了,SRCNN 这种三层卷积的老结构还写它干嘛。我的回答很直接:正因为 SRCNN 只有三层、参数不到一万,才适合拿来把“超分辨率”这条链路上每一步都摊开看清楚。它做的事朴素到极致——输入一张低分辨率图,经过三层卷积,输出高分辨率图。但围绕它要处理的问题一个不少:上采样放在网络前还是网络后、训练样本怎么切 patch、为什么只在 Y 通道上算损失、PSNR 为什么跟论文对不上。把这几个问题走通,再去看 RCAN、SwinIR,你会发现主干换了,工程上的坑一个没少。这篇把我拆这份 PyTorch 实现时踩过的地方、参数取值的理由、能直接抄的代码都摆出来,适合刚接触图像超分想跑通第一条基线的人,也适合带学生做课设、需要一份能复现的参照实现的同行。
2. 三层卷积的结构账:9-1-5 里每个数字是怎么来的
SRCNN 的结构图第一次看会觉得过于简单,三层卷积就完事了。但真正动手写的时候,第一个问题就来了:卷积核为什么是 9、1、5,通道数为什么是 64、32、1。这些数字不是拍脑袋定的,每一个都对应着论文里的一句话解释,理解之后你才知道哪些能改、哪些改了会掉点。
2.1 感受野与非线性的分工
SRCNN 把超分拆成三步:patch 提取、非线性映射、重建。
第一层 9×9 卷积负责提取局部纹理,9×9 的感受野在倍率 2 到 4 的场景下足够覆盖一个完整的边缘结构;第二层 1×1 卷积做的是跨通道的非线性组合,它不扩大感受野,只增加表达能力,这也是为什么它能用极低的参数代价把 64 维映射到 32 维;第三层 5×5 卷积负责把特征图重建成图像,核比第一层小,是因为重建阶段更依赖中心区域的加权,过大的核反而引入模糊。
三层串起来的有效感受野是 9+1+5-2=13 像素。这个数字很关键,它决定了训练时 patch 不能切得比 13 还小,否则网络看到的信息还不足以推断出一个像素的颜色,训出来的东西只会是均值模糊。
参数量我实际数过一遍:
| 层 | 卷积核 | 输入通道 | 输出通道 | 参数量 |
|---|---|---|---|---|
| conv1 | 9×9 | 1 | 64 | 5248 |
| conv2 | 1×1 | 64 | 32 | 2080 |
| conv3 | 5×5 | 32 | 1 | 801 |
| 合计 | 8129 |
八千出头的参数,放在今天是几乎可以忽略的体量。但也正因为小,它在小数据集上不容易过拟合,用普通显卡几十分钟就能跑完一轮完整训练,拿来验证数据管线是否写对非常合适。
2.2 用 PyTorch 把三层写出来
下面这份实现用的是 pre-upsampling 版本,也就是先把低分辨率图用双三次插值放大到目标尺寸,再送进网络。padding 的取值让每层输出尺寸和输入保持一致,这样网络输出和 GT 就能直接算 loss,不需要额外裁剪对齐。
import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels=1): super().__init__() # 第一层:9x9 提特征,padding=4 保证尺寸不变 self.conv1 = nn.Conv2d(num_channels, 64, kernel_size=9, padding=4) # 第二层:1x1 做通道间的非线性映射,不改变空间尺寸 self.conv2 = nn.Conv2d(64, 32, kernel_size=1, padding=0) # 第三层:5x5 重建回单通道,padding=2 同样保持尺寸 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 if __name__ == "__main__": model = SRCNN() dummy = torch.randn(1, 1, 64, 64) print(model(dummy).shape) # torch.Size([1, 1, 64, 64]) print(sum(p.numel() for p in model.parameters())) # 8129这段代码有几个细节值得单独说。第一层和第三层的 padding 分别是 4 和 2,正好等于 (kernel-1)/2,所以特征图尺寸全程不变,输入 64×64 出来还是 64×64,省掉了输出裁剪那一步。最后一层不加 ReLU 是有意的,重建层的输出是像素强度,被激活函数截断在非负区间会让暗部细节全部丢失,这也是新手最容易犯的错之一。num_channels默认取 1,因为我们只训练亮度通道,后面讲数据处理时会解释为什么。
2.3 上采样位置决定了计算量和效果
pre-upsampling 的意思是网络吃的是已经放大过的图,代价是计算量随放大倍率平方增长——4 倍超分时,第一层卷积要在 16 倍于原图的像素上滑动。后来的 FSRCNN、ESPCN 改成了 post-upsampling,网络在低分辨率空间做特征提取,最后再用亚像素卷积或转置卷积一次性放大,计算量小一个数量级。
那为什么这份实现还坚持 pre-upsampling?因为 SRCNN 论文的结论是建立在 pre 上的,论文里报告的 PSNR 也都是这个配置。你要拿它跟论文数字对齐,就得用同样的结构。如果只是想要一个能跑的超分基线、追求推理速度,那换成 post 更划算,但那就不是 SRCNN 了,评测数字也不能直接比。
我的建议是:第一版严格按 pre 写,跑通、对齐数字、把数据管线验证正确;等你确认整条链路没问题,再考虑把上采样挪到后面做加速实验。顺序反过来,一旦数字不对,你分不清是结构改动的问题还是数据管线的问题。
3. 数据管道:一张高清图怎么造出成对样本
SRCNN 的训练数据不需要额外标注,高清图自己就是 GT,低分辨率版本靠退化生成。听起来简单,但退化方式选错、patch 切法不对,模型照样能训到 loss 很低而 PSNR 不动。这一章把从原图到 batch 的每一步拆开讲。
3.1 退化建模:双三次下采样再上采样
训练时的输入不能是随便缩小的图。标准做法是:从高清图裁一块 patch,用双三次插值把它缩小 scale 倍,再用同样的插值放大回原尺寸。这样得到的输入和 GT 尺寸一致,内容上是 GT 的低频近似,网络要学的就是补回丢掉的高频。
这里有个容易被忽略的点:缩小的时候要开 antialias。PyTorch 的interpolate和 torchvision 的resize在新版本里都支持这个参数,不开的话缩小过程会产生混叠,输入里混进高频噪声,网络会去学这些假信号,最终输出带振铃。
import torchvision.transforms.functional as TF def degrade(hr_y, scale): """输入 [1,H,W] 的 Y 通道张量,返回同尺寸的低质输入""" h, w = hr_y.shape[-2:] lr_small = TF.resize( hr_y, (h // scale, w // scale), interpolation=TF.InterpolationMode.BICUBIC, antialias=True, # 关键:缩小必须抗混叠 ) lr_up = TF.resize( lr_small, (h, w), interpolation=TF.InterpolationMode.BICUBIC, antialias=True, ) return lr_upantialias=True是这段代码里唯一不能省的参数。另外注意两次 resize 用的是同一种插值方式,训练和测试必须保持一致,否则模型学到的退化分布和实际遇到的分布对不上,PSNR 会莫名其妙掉一截。
3.2 Dataset 与 DataLoader 的落地写法
数据集类要做四件事:读图、转色彩空间、随机裁 patch、生成退化输入。用随机裁剪而不是固定切分,是为了让同一张图在不同 epoch 里贡献不同的局部样本,等效于扩充数据。
import random import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms.functional as TF class SRDataset(Dataset): def __init__(self, hr_dir, scale=2, patch_size=96, repeat=20): self.files = sorted(Path(hr_dir).glob("*.png")) self.scale = scale self.patch_size = patch_size self.repeat = repeat # 每张图重复采样次数 def __len__(self): return len(self.files) * self.repeat def __getitem__(self, idx): path = self.files[idx % len(self.files)] img = Image.open(path).convert("YCbCr") w, h = img.size ps = self.patch_size # 图太小先放大,保证能裁出完整 patch if w < ps or h < ps: img = TF.resize(img, (max(ps, h), max(ps, w))) w, h = img.size x = random.randint(0, w - ps) y = random.randint(0, h - ps) patch = TF.crop(img, y, x, ps, ps) hr_y = TF.to_tensor(patch)[0:1] # 只取 Y 通道,范围 0~1 lr_y = degrade(hr_y, self.scale) return lr_y, hr_yrepeat=20这个参数决定了每个 epoch 里一张图被采样多少次。patch_size 取 96 是我试下来比较稳的值:太小(比如 32)时网络感受野 13 占的比例过高,边缘效应明显,训出来的结果偏平滑;太大(比如 192)时单张图能采到的独立样本变少,一个 epoch 内的梯度多样性不够。96 在两者之间,配合 repeat=20,几百张图就能撑起一个 epoch。
交给 DataLoader 时把num_workers设成 4 到 8,pin_memory=True,如果显存紧张就把 batch_size 压到 16。patch 是 96×96 单通道,一个 batch 16 张也就几 MB,瓶颈通常在解码图片而不是显存。
3.3 为什么只训 Y 通道
RGB 三个通道一起训是可行的,论文早期版本也这么干过,但效果不如只训亮度。原因是人眼对亮度细节的敏感度远高于色度,超分带来的高频恢复主要作用在 Y 上,Cb、Cr 两个通道即使直接用双三次放大,肉眼看不出差别。
工程上的收益更明显:单通道意味着第一层卷积的输入通道是 1,参数量直接砍到三分之一,训练速度也快。推理时的做法是把输入图转到 YCbCr,Y 通道过网络,Cb、Cr 用双三次放大到目标尺寸,三通道拼回去再转回 RGB。
注意:拼回 RGB 之后一定要把像素值 clip 到 0~255 再转 uint8。网络输出是浮点,可能超出范围,不裁剪会出现色斑。
4. 训练循环与收敛判断:MSE 之外还要盯什么
结构和数据都就位之后,训练本身反而是最短的一段代码。但短不代表简单,学习率、损失函数、验证时机这几个参数选错,表现是 loss 曲线看着很漂亮,实际输出一片模糊。
4.1 损失函数与优化器的取值
SRCNN 用的是像素级 MSE,对应 PSNR 这个评价指标。这个选择在今天看有争议——MSE 优化的是平均值,天然倾向于输出平滑结果,细节纹理会被抹掉。但既然目标是复现论文数字,就得用 MSE,换成 L1 或者感知损失,PSNR 反而会降,这是指标本身的性质决定的,不是模型变差了。
优化器方面,论文用的是 SGD,学习率 1e-4 左右,动量 0.9。我实际跑下来 Adam 收敛更快,前几个 epoch 的 loss 下降明显更陡,但最终收敛到的 PSNR 略低于调好学习率的 SGD。如果你的目标是快速看到一个能用的结果,Adam + 1e-4 更省事;要复现论文曲线,就老老实实用 SGD,并且做好训练几百个 epoch 的准备。
from torch import nn, optim model = SRCNN(num_channels=1).to(device) criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=1e-4, betas=(0.9, 0.999)) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5)StepLR每 30 个 epoch 把学习率减半,是给后面收敛平台期准备的。MSE 在接近最优点时梯度很小,学习率不降容易在附近来回震荡,loss 曲线出现毛刺。
4.2 训练循环与日志
一个 epoch 的训练循环没什么花活,关键是每个 batch 之后把 loss 累加正确,用样本数加权而不是简单平均 batch loss,否则最后一个不满的 batch 会拉偏统计。
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, total_n = 0.0, 0 for lr_img, hr_img in loader: lr_img = lr_img.to(device, non_blocking=True) hr_img = hr_img.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) pred = model(lr_img) loss = criterion(pred, hr_img) loss.backward() optimizer.step() total_loss += loss.item() * lr_img.size(0) # 按样本数加权 total_n += lr_img.size(0) return total_loss / total_nset_to_none=True比默认的置零更省一点显存,在 patch 较小、batch 较多的情况下差别可感知。non_blocking=True配合pin_memory能让数据拷贝和计算重叠,这一项在数据加载不是瓶颈时收益有限,但加上没坏处。
验证循环要单独写,务必加torch.no_grad(),并且调用model.eval()。BatchNorm 在这个结构里没有,但eval()仍然要养成习惯,后面换结构时不会翻车。
4.3 收敛判断:别只看 loss
训练到十几个 epoch 时,train loss 通常会降到 1e-3 以下,但这时候模型输出往往还是糊的。判断收敛要看验证集上的 PSNR,而不是训练 loss。
我的做法是每 5 个 epoch 在固定的几张验证图上算一次 PSNR,和 bicubic 基线对比。正常情况下,倍率 2 时 SRCNN 应该比 bicubic 高 0.3 到 0.8dB,倍率 4 时差距会小一些,在 0.1 到 0.4dB 之间。如果你的结果一直贴着 bicubic 甚至更低,基本可以断定是数据管线或评测对齐出了问题,继续训下去也不会好转。
另一个信号是验证 PSNR 开始波动但不再上升,这时候把学习率降一档再跑十几个 epoch,通常能再挤出零点零几 dB,之后就是纯过拟合了。
5. 避坑与排查:五个让 PSNR 原地踏步的坑
这套结构简单到几乎不会报错,但正因为它简单,出问题时没有任何异常提示,你只会看到 PSNR 数字不动。下面五条是我和身边人实际踩过的,每条都按现象、原因、解决写清楚。
5.1 数据侧的坑
现象:训练 loss 一路降到很低,验证 PSNR 始终贴着 bicubic,甚至低 0.2dB。
原因大概率是训练和验证的退化方式不一致。训练时用了antialias=True的双三次下采样,验证时为了省事直接cv2.resize或者别的插值,两者的低频分布不同,模型学到的映射在验证集上不成立。
解决:把退化函数抽成一个独立模块,训练、验证、推理全部调用同一份代码,参数只传 scale。别在两个文件里各写一遍 resize,迟早会写成两个版本。
现象:输出图的暗部细节全部消失,整体偏灰。
原因是最后一层卷积后面误加了 ReLU。像素强度是 0 到 1 的连续值,ReLU 把负值全部截零,暗部直接压平。
解决:确认conv3后面没有任何激活函数。如果已经把 ReLU 写进去了,改掉重新训,不要试图用后处理补偿。
现象:验证集 PSNR 高得离谱,2 倍超分能到 40dB 以上。
这是数据泄漏的典型表现。用随机 patch 划分时,同一张高清图裁出来的不同 patch 可能同时落进训练集和验证集,模型等于在背答案。
解决:按图片划分,而不是按 patch。训练集和验证集的图片文件名列表必须完全不相交,验证集用整图而不是 patch 来评。
5.2 训练与评测侧的坑
现象:输出的彩色图边缘出现紫边或绿边。
原因在推理阶段的通道拼接。Y 通道过网络后是超分结果,Cb、Cr 如果直接用最近邻放大,色度分辨率的错位会在高对比边缘处显出来,转回 RGB 就成了彩边。
解决:Cb、Cr 统一用双三次放大到目标尺寸,三通道拼接后再做一次 0 到 255 的 clip,最后转 uint8。有条件的话,边缘区域可以对色度做一次轻微的引导滤波抑制溢出。
现象:PSNR 比论文低 1dB 以上,怎么调参都追不回来。
先检查评测时的边界裁剪。pre-upsampling 结构虽然是同尺寸输入输出,但卷积的 padding 在边界处填的是零,边缘一圈像素的输出质量明显差于中心。评测时如果不裁掉边界,整体 PSNR 会被拉低。
解决:评测时按scale倍裁掉四周若干像素再算 PSNR,标准的做法是裁掉 scale 个像素。裁剪量必须固定,训练、验证、报告数字用同一个值。
提示:这几个坑的共同点是都不报错。养成习惯,每改一次数据管线,先在小样本上跑一遍可视化,肉眼看一眼输入和 GT 是不是对的,比盯着 loss 曲线有效得多。
6. 进阶验证:把 PSNR 算对,把大图跑起来
写到这里,模型能训、能出图,但还有两件事值得做扎实,否则你报出去的每个数字都站不住脚。
第一件事是把 PSNR 脚本写规范。别用现成的库函数直接算,先确认三件事:两图是否都在 0 到 1 之间、是否裁了边界、是否只在 Y 通道上算。下面这段是我固定用的版本。
import torch import math def psnr(pred, target, crop=0, max_val=1.0): """pred/target: [B,1,H,W] 张量,crop 为四周裁剪像素数""" if crop > 0: pred = pred[..., crop:-crop, crop:-crop] target = target[..., crop:-crop, crop:-crop] mse = torch.mean((pred - target) ** 2).item() if mse == 0: return float("inf") return 10 * math.log10(max_val ** 2 / mse)crop参数就是前面说的边界裁剪,2 倍超分时传 2,4 倍传 4。max_val保持 1.0,因为我们全程在 0 到 1 的浮点域里算,不要混用 255 的域,否则 PSNR 会凭空多出 48dB。
第二件事是处理大图。pre-upsampling 结构在 4 倍超分时,一张 1080p 的图放大到 4K 之后送进第一层 9×9 卷积,中间特征图的显存占用会瞬间吃掉几个 G。做法是切块推理,块之间留重叠,最后只取每块的中心区域拼回去。
@torch.no_grad() def infer_tiled(model, img, tile=256, overlap=32): """img: [1,1,H,W],超过 tile 时切块推理""" _, _, h, w = img.shape if h <= tile and w <= tile: return model(img) out = torch.zeros_like(img) step = tile - overlap for y in range(0, h, step): for x in range(0, w, step): y2, x2 = min(y + tile, h), min(x + tile, w) y1, x1 = max(0, y2 - tile), max(0, x2 - tile) patch = model(img[..., y1:y2, x1:x2]) # 只保留中心区域,避免拼缝 cy1 = y1 + overlap // 2 if y1 > 0 else y1 cy2 = y2 - overlap // 2 if y2 < h else y2 cx1 = x1 + overlap // 2 if x1 > 0 else x1 cx2 = x2 - overlap // 2 if x2 < w else x2 out[..., cy1:cy2, cx1:cx2] = patch[..., cy1 - y1:cy2 - y1, cx1 - x1:cx2 - x1] return outoverlap取 32 是个经验值,大致等于感受野 13 的两倍多,边缘效应能被完全吃掉。块大小 256 是显存和速度的折中,显存充裕的话提到 512 会更快,但重叠区域的浪费也更多。
最后提醒一个软件工程习惯:训练脚本里把随机种子固定下来,torch.manual_seed、random.seed、numpy.random.seed三处都要设。我早年在复现一个超分结果时,因为漏设了 DataLoader 的 worker 种子,两次跑出来的 PSNR 差了 0.15dB,排查了整整两天才发现是随机性,不是代码 bug。从那以后我每次开新实验,第一个动作就是把种子和依赖版本写进配置文件,跑完把最优权重的 hash 记下来,比事后回忆靠谱得多。希望帮到你。
本文还有配套的精品资源,点击获取