SRCNN图像超分实战:三层卷积、数据管线与PSNR对齐

发布时间:2026/10/25 15:45:22
SRCNN图像超分实战:三层卷积、数据管线与PSNR对齐 简介面向PyTorch初学者与图像超分入门者的SRCNN实战包围绕经典超分辨率模型SRCNN给出可直接运行的完整实现适合想从零理解超分流程、复现论文思路并观察重建效果的学习场景。压缩包共28个文件、约74.6MB包含6个py源码用于数据准备、模型定义、训练与测试7个pth权重便于加载预训练结果另有bmp对比图、h5数据集、md说明和txt运行记录等覆盖训练、推理与结果查看环节。已有2978人学习说明其作为入门案例具有一定参考价值。读者可据此梳理数据预处理、模型搭建、参数配置与测试评估的完整链路并通过现成权重和超分/双三次对比图直观比较效果按说明调整num_workers等参数后即可跑通适合作为课程实验、复现练习和排错参照。1. 三层卷积跑出 0.3dBSRCNN 到今天还值得亲手写一遍前阵子有人问我图像超分现在都被扩散模型占领了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 卷积负责把特征图重建成图像核比第一层小是因为重建阶段更依赖中心区域的加权过大的核反而引入模糊。三层串起来的有效感受野是 915-213 像素。这个数字很关键它决定了训练时 patch 不能切得比 13 还小否则网络看到的信息还不足以推断出一个像素的颜色训出来的东西只会是均值模糊。参数量我实际数过一遍层卷积核输入通道输出通道参数量conv19×91645248conv21×164322080conv35×5321801合计8129八千出头的参数放在今天是几乎可以忽略的体量。但也正因为小它在小数据集上不容易过拟合用普通显卡几十分钟就能跑完一轮完整训练拿来验证数据管线是否写对非常合适。2.2 用 PyTorch 把三层写出来下面这份实现用的是 pre-upsampling 版本也就是先把低分辨率图用双三次插值放大到目标尺寸再送进网络。padding 的取值让每层输出尺寸和输入保持一致这样网络输出和 GT 就能直接算 loss不需要额外裁剪对齐。import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels1): super().__init__() # 第一层9x9 提特征padding4 保证尺寸不变 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) 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), interpolationTF.InterpolationMode.BICUBIC, antialiasTrue, # 关键缩小必须抗混叠 ) lr_up TF.resize( lr_small, (h, w), interpolationTF.InterpolationMode.BICUBIC, antialiasTrue, ) return lr_upantialiasTrue是这段代码里唯一不能省的参数。另外注意两次 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, scale2, patch_size96, repeat20): 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_yrepeat20这个参数决定了每个 epoch 里一张图被采样多少次。patch_size 取 96 是我试下来比较稳的值太小比如 32时网络感受野 13 占的比例过高边缘效应明显训出来的结果偏平滑太大比如 192时单张图能采到的独立样本变少一个 epoch 内的梯度多样性不够。96 在两者之间配合 repeat20几百张图就能撑起一个 epoch。交给 DataLoader 时把num_workers设成 4 到 8pin_memoryTrue如果显存紧张就把 batch_size 压到 16。patch 是 96×96 单通道一个 batch 16 张也就几 MB瓶颈通常在解码图片而不是显存。3.3 为什么只训 Y 通道RGB 三个通道一起训是可行的论文早期版本也这么干过但效果不如只训亮度。原因是人眼对亮度细节的敏感度远高于色度超分带来的高频恢复主要作用在 Y 上Cb、Cr 两个通道即使直接用双三次放大肉眼看不出差别。工程上的收益更明显单通道意味着第一层卷积的输入通道是 1参数量直接砍到三分之一训练速度也快。推理时的做法是把输入图转到 YCbCrY 通道过网络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_channels1).to(device) criterion nn.MSELoss() optimizer optim.Adam(model.parameters(), lr1e-4, betas(0.9, 0.999)) scheduler optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.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_blockingTrue) hr_img hr_img.to(device, non_blockingTrue) optimizer.zero_grad(set_to_noneTrue) 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_noneTrue比默认的置零更省一点显存在 patch 较小、batch 较多的情况下差别可感知。non_blockingTrue配合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。原因大概率是训练和验证的退化方式不一致。训练时用了antialiasTrue的双三次下采样验证时为了省事直接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, crop0, max_val1.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 倍超分时传 24 倍传 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, tile256, overlap32): 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 记下来比事后回忆靠谱得多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询