☰
从SRCNN三层卷积跑通图像超分:PyTorch实战与PSNR评估避坑
2026/10/26 2:06:37 网站建设 项目流程

简介:这份基于 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×91644ReLU5248
conv21×164320ReLU2080
conv35×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_channels=1): super().__init__() # 第一层:9x9 大核,padding=4 保证 H/W 不变 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) self._init_weights() def _init_weights(self): # He 初始化,适配 ReLU;不初始化容易在 lr=1e-4 时前期 loss 抖动 for m in [self.conv1, self.conv2, self.conv3]: nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") 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 的核尺寸绑定的经验配置,改了要先确认显存和收敛速度。inplace=True的 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, scale=3, patch_size=33, repeat=20): 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_size=33是原始配置,比 13 的感受野大不少,能保证每个 patch 内部有足够多的有效像素参与损失。scale决定退化强度,2/3/4 各训一个模型是最常见的做法。repeat=20在小数据集(几十到几百张图)上是必须的,否则一个 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", scale=3, patch_size=33, repeat=20) loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4, drop_last=True) model = SRCNN(num_channels=1).to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.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(f"epoch {epoch:03d} | mse {avg:.6f} | lr {scheduler.get_last_lr()[0]:.2e}")

逻辑说明:drop_last=True很重要,最后一个不完整的 batch 如果只有一个样本,BN 类层会出问题,虽然 SRCNN 没有 BN,但保持这个习惯能避免你以后换模型时踩坑。scheduler.step()放在 epoch 循环里,不是 batch 里——步长衰减的粒度是轮次,放错了学习率会在几分钟内掉到 1e-8,训练直接停滞。

参数说明:batch_size=64对应 33×33 的 patch,显存占用很小,可以往 128 加。step_size=30, gamma=0.1的意思是每 30 轮学习率乘 0.1,60 轮里衰减两次,这个节奏适合小数据集。如果你的 loss 在 20 轮左右就基本不动了,把总轮次降到 40,step_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, scale=3, crop_border=3): 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 的像素变成 51,PSNR 直接崩掉。crop_border取 scale 是因为放大倍数越大,边界误差影响的范围越宽。

参数说明:crop_border和训练时的 padding 有关,padding=4 的话理论边界影响范围是 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, scale=3): # 输入均为 HxW 的单通道 uint8,已裁掉边界 psnr = peak_signal_noise_ratio(hr_uint8, sr_uint8, data_range=255) ssim = structural_similarity(hr_uint8, sr_uint8, data_range=255) return psnr, ssim

逻辑说明:data_range=255不能省。skimage 的默认行为会根据输入 dtype 推断范围,但如果你传进去的是 float 且值域是 0-1,它可能按 0-1 处理再和 0-255 的结果混着比,误差直接是 48dB 量级。统一转成 uint8 再算最稳。

参数说明:SSIM 用默认的 7×7 窗口和 K1=0.01、K2=0.03 就行,改了就和公开结果对不上。gaussian_weights保持 False,除非你明确在复现某篇用高斯窗的论文。

4.3 评估脚本与结果落盘

import os from PIL import Image def eval_folder(model, hr_dir, scale=3, device="cuda"): 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, scale=scale, crop_border=scale) 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(f"mean 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 只有几个 batch,60 轮下来总迭代次数不到一千次。三层卷积虽然小,也需要几万次迭代才能把权重从随机初始化推到有用状态。日志上 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 或 64;crop_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 的裁剪后形状、打印两者的值域,三行日志对上了才开始训。这套习惯后来帮我省下的时间,比调任何超参数都多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询