1. 项目整体设计与思路拆解
“基于深度学习的图像超分辨率重建”这个题目,每年毕业季都会被大量同学选中,原因很直接:效果肉眼可见、答辩好讲、Demo 演示直观。但真正动手做的时候,绝大多数人卡在同一个地方——不知道从哪一层开始拆。我见过太多项目最后变成“把一张图丢进某个现成模型跑一遍,截个图放进论文”,这种做法的风险在于答辩老师随口问一句“你的上采样为什么放在后面而不是前面”就直接崩盘。所以这篇内容我不打算给你一份能跑的代码就完事,而是把整个项目的设计脉络、每个模块为什么这么选、参数怎么定、坑在哪里,全部摊开讲清楚。
先说清楚这个技术是什么。图像超分辨率重建(Super-Resolution,简称 SR),本质是一个病态逆问题:给一张低分辨率图像(LR),恢复出高分辨率图像(HR)。为什么叫病态?因为从 LR 到 HR 的映射不是唯一的——同一张低清图,理论上可以对应无数张高清图。传统方法比如双三次插值(Bicubic),思路是“猜像素”,用周围已知像素做加权平均,结果是边缘糊、纹理丢、放大后有明显的涂抹感。深度学习方法的思路完全不同:它不猜,它从大量高清-低清配对样本里学一个映射函数,让网络记住“这种纹理在放大后应该长什么样”。这是本质区别,也是这个题目值得做的原因。
适合谁来参考这份内容?三类人:一是正在做相关毕设、需要把方案定下来的同学;二是想从零跑通一个完整图像复原 pipeline、但不想被论文公式劝退的开发者;三是对底层视觉感兴趣、想搞清楚 SRCNN 到 ESRGAN 这条演进线到底在演进什么的人。我下面所有的取舍,都按“毕设可落地、能讲清原理、效果拿得出手”这三个标准来定,不追求 SOTA,追求你自己能完全掌控。
1.1 为什么选“深度学习”而不是传统插值
这里必须先把选型逻辑讲透,因为这是答辩第一个会问的问题。传统插值方法的上采样核是固定的,Lanczos、Bicubic 这些核函数在设计时就假设了图像的局部平滑性,它们对自然图像的低频部分表现还行,但高频部分——也就是边缘和细节——会系统性丢失。你可以把插值理解成“用有限的已知信息做最优的平均”,而平均这个操作天生就会抹掉突变。
深度学习方法把这件事变成了数据驱动的函数拟合。以最经典的 SRCNN 为例,它只用了三层卷积:第一层做特征提取(9×9 卷积核,64 通道),第二层做非线性映射(1×1 或 5×5,32 通道),第三层做重建(5×5,输出通道数等于图像通道数)。就这么个结构,在当年就显著超过了 Bicubic。原因不难理解:卷积核的权重是从数据里学出来的,它学到的不是“平均”,而是“如何从邻域纹理推断出缺失的高频”。
我个人的经验判断是:如果你的毕设需要有一个明确的“方法对比”章节,那传统插值一定要做,但只作为 Baseline,不要花太多篇幅。用 Bicubic、双线性、最近邻三种各跑一遍,PSNR 通常比深度模型低 2~4 dB,这个差距在论文里足够有说服力,而且实现只需一行cv2.resize,性价比极高。
1.2 网络结构选型的三个梯度
这是我建议你在开题时就定死的东西。按难度和效果,我把可选方案分成三档,你对号入座:
| 档位 | 代表模型 | 参数量级 | 训练难度 | 适合场景 |
|---|---|---|---|---|
| 入门档 | SRCNN / FSRCNN | 万级到十万级 | 单卡几小时可收敛 | 时间紧、算力少、只需跑通流程 |
| 主流档 | SRResNet / EDSR-lite / RCAN-lite | 百万到千万级 | 单卡 1~3 天 | 有 GPU、要求 PSNR 指标好看 |
| 进阶档 | SRGAN / ESRGAN | 千万级以上 | 需要调参、需对抗训练 | 追求视觉观感、论文要有亮点 |
入门档的价值被严重低估了。很多人一上来就想跑 ESRGAN,结果卡在显存不足、训练不收敛、GAN 训练崩掉这三个连环坑里,最后连一组能看的对比图都产不出来。SRCNN 虽然老,但它的训练流程极其干净:数据准备、前向、L1/L2 损失、反向、验证,五步闭环。你把它完整跑通一遍,对 LR-HR 配对、PSNR 计算、学习率衰减这些东西的理解会比看十篇论文都扎实。
主流档里我特别推荐EDSR 的精简版。原版 EDSR 有 43 个残差块、256 通道,参数量 4300 万,普通显卡根本扛不住。把残差块降到 8~16 个、通道数降到 64,参数量能压到 100 万以内,PSNR 依然能比 SRCNN 高 0.5 dB 以上。这个“精简”动作本身就是论文里可以写的一段:说明你在算力约束下做了怎样的结构裁剪,裁剪的依据是什么(比如去掉 BN 层,因为 EDSR 论文明确指出 BN 会破坏图像的颜色分布一致性)。这种取舍写出来是加分项。
进阶档的 SRGAN 系列,核心卖点不在 PSNR 而在感知质量。它用感知损失(Perceptual Loss,基于预训练 VGG 的特征距离)加对抗损失,让输出看起来更“锐”。代价是 PSNR 往往反而下降,因为 GAN 会生成一些原始图像里不存在的纹理细节。这个 trade-off 一定要在论文里讲清楚,否则老师看到你 PSNR 不如 EDSR 会以为你做错了。
1.3 上采样策略:前置还是后置
这是整个项目里最容易被忽略、但最影响显存占用的设计点。三种主流策略:
前置上采样(Pre-upsampling):先用 Bicubic 把 LR 放大到目标尺寸,再送进网络。SRCNN 用的就是这个。优点是网络内部只需要做特征精修,逻辑简单;缺点是网络全程在高分辨率尺寸上做卷积,计算量和显存占用是后置方案的 4 倍(2 倍放大)到 16 倍(4 倍放大)。我在 4 倍放大、128×128 的 patch 上实测过,前置方案单 batch 显存占用接近后置的 3.8 倍,这个数字很直观地解释了为什么它被淘汰。
后置上采样(Post-upsampling):网络全程在 LR 尺寸上运算,最后用一层 PixelShuffle(亚像素卷积)一次性放大。FSRCNN、ESPCN、ESRGAN 都是这个思路。PixelShuffle 的原理值得讲:它把通道维度的数据重排到空间维度。假设你要做 4 倍放大,最后输出 64 通道,PixelShuffle 会把这 64 通道按 4×4 的排布拆开,重组成 16 倍像素面积。数学上等价于C×r² → C通道、空间H×W → rH×rW的重排。这层的参数量是零,纯操作,所以既省参数又省计算。
渐进式上采样(Progressive):LapSRN 的做法,分多个阶段逐步放大 2 倍。适合 8 倍这种超大倍率,毕设里基本用不上。
我的建议很明确:除非你要复现 SRCNN 做对比,否则一律用后置上采样。毕设的算力预算经不起前置方案的消耗,而且后置方案的思路在论文里更好写——“在低分辨率空间做特征提取,既降低了计算复杂度,又避免了前置插值引入的伪影”,这句话是有实打实的实验支撑的。
2. 数据准备与核心细节解析
数据这块是整个项目里最脏最累、但决定成败的环节。我见过太多项目模型结构抄得很漂亮,结果训练出来 PS NR 上不去、测试图全是噪点,一查发现训练集就 200 张图,而且 LR 是用 bicubic 下采样后又 JPEG 压缩过的,跟测试集的退化方式完全不一致。数据没对齐,模型再强也白搭。
2.1 训练集的获取与筛选
常用数据集有三类,我按推荐顺序排:
- DIV2K:1000 张 2K 分辨率的高质量自然图像,800 训练 / 100 验证 / 100 测试。这是做 SR 的标准选择,图像质量高、内容多样,几乎所有论文都用它。文件总量约 3.3 GB,下载下来解压即可用。
- Set5 / Set14 / BSD100 / Urban100:这几个是测试集,不用于训练。Set5 只有 5 张图,但它是 SR 领域的“黄金标准”,论文里不加 Set5 的 PSNR 会被认为不完整。Urban100 全是建筑纹理,特别能暴露模型在规则重复纹理上的表现差异,值得单独跑一组。
- 自建数据集:如果毕设要做特定领域(比如医学影像、卫星图、老照片修复),就得自己爬/采集。这时候一定要保证图像本身是高清的,不能用网上的压缩图当 HR,因为压缩伪影会被模型当成真实纹理学进去。
筛选原则我总结成三条,都是从踩坑里来的:第一,剔除有明显压缩块效应、模糊、过曝的图,宁可少一百张也不要脏数据;第二,保证内容多样性,人物、建筑、自然、文字都要有,纯风景数据集训出来的模型在人脸测试图上会烂得一塌糊涂;第三,统一色彩空间,RGB 三通道训练是最简单的,但如果你发现模型出现色偏,把训练和测试都转到 YCbCr、只对 Y 通道做超分、色度通道用双三次插值补上,这个技巧能显著压低色偏,代价是代码多几十行。
2.2 LR-HR 配对生成:最容易做错的一步
这一步必须讲透,因为它决定了你的模型学到的退化模型是什么。标准做法是:
import cv2 import numpy as np from glob import glob import os def generate_lr_pairs(hr_dir, lr_dir, scale=4, patch_size=96): os.makedirs(lr_dir, exist_ok=True) for hr_path in glob(os.path.join(hr_dir, '*.png')): hr = cv2.imread(hr_path, cv2.IMREAD_COLOR) if hr is None: continue # 关键:用 bicubic 下采样,与测试集保持一致 h, w = hr.shape[:2] lr = cv2.resize(hr, (w // scale, h // scale), interpolation=cv2.INTER_CUBIC) cv2.imwrite(os.path.join(lr_dir, os.path.basename(hr_path)), lr)看起来简单,但有三个隐藏坑:
坑一:插值方式不对称。训练用 bicubic 下采样,测试如果也用 bicubic,那你的模型其实是“在学 bicubic 的逆”,这个设定叫做非盲超分,是学术界的标准设定。但如果你测试时用手册里那种自带退化的真实低清图,分布完全不匹配,效果会断崖式下跌。所以论文里一定要明确写“采用 bicubic 下采样退化”,这是诚实也是规范。
坑二:下采样后尺寸不是整数倍。用w // scale会丢几个像素,导致 LR 上采样回去后跟 HR 尺寸差 1~2 像素,计算 PSNR 时直接报错或者错位。规范做法是先把 HR 中心裁剪成 scale 的整数倍:h = h - h % scale、w = w - w % scale,再下采样。这个细节看起来小,但不做的话你在验证阶段会浪费一整个晚上的时间去 debug。
坑三:PNG 和 JPEG 的选择。中间产物一律存 PNG,因为 JPEG 是有损的,会引入额外伪影污染配对关系。我见过有人图省事全部存 jpg,结果模型在平整区域产生奇怪的块状纹理,查了两天才查到这儿。
至于退化增强,如果想让模型更鲁棒,可以在标准 bicubic 之外再加一组高斯模糊 + 噪声的退化路径,训练时随机二选一。这属于“盲超分”的思路,会让论文更有深度,但 PSNR 指标会略降,需要你自己权衡。
2.3 训练 patch 的裁剪策略
不能把整张 2K 图直接喂进网络,显存撑不住,而且全图训练的效率很低。标准做法是裁 patch:
- HR patch 尺寸:96 或 128。太小(如 48)会导致卷积上下文不足,感受野覆盖不到足够信息;太大显存爆。以 4 倍放大为例,HR 128 对应 LR 32,LR 32 经过 16 个残差块的网络,3 层 64 通道的卷积,感受野大约能覆盖到 40~50 像素,勉强够用。
- 每个 epoch 的 patch 数量:从每张图上随机裁 8~16 个,DIV2K 的 800 张图就是 6400~12800 个 patch。
- 随机翻转与旋转:水平翻转、垂直翻转、90/180/270 旋转,四种操作组合出 8 倍数据增强。这个操作对 SR 是完全无损的,因为图像旋转不改变退化关系,所以一定要开。
这里有个实操心得:裁剪时用随机位置,但每个 epoch 重新裁一次,不要裁一次存盘反复用。前者相当于无限的样本空间,后者容易过拟合到固定的局部区域。实现上用 Dataset 的__getitem__里实时裁,配合 DataLoader 的多进程,速度完全跟得上。
3. 实操过程与核心环节实现
这一节给你一条能直接走通的路径。我按“能跑起来 → 能训出效果 → 能拿出数据”三步走,每步都给出关键代码和参数,你照着改就行。
3.1 环境搭建与依赖确认
环境这块不复杂,但有版本坑。我的建议:
# 环境隔离,避免污染 conda create -n sr_env python=3.8 -y conda activate sr_env # PyTorch 按你的 CUDA 版本装,这里以 CUDA 11.3 为例 pip install torch==1.10.2 torchvision==0.11.3 -f https://download.pytorch.org/whl/cu113/torch_stable.html # 其余依赖 pip install opencv-python numpy scikit-image tqdm tensorboard Pillow几个必须注意的点:scikit-image里的skimage.metrics.peak_signal_noise_ratio和structural_similarity是算指标的标配,但它默认的 data_range 是 1.0,如果你的图像是 uint8 [0,255] 范围,必须显式传data_range=255,否则算出来的 PSNR 会离谱。这个坑我踩过,输出 200 多 dB 的时候还以为自己训出了奇迹。
Python 版本建议 3.8~3.9,太新的话某些库的轮子还没跟上。PyTorch 版本不必追新,1.10 之后的版本对这个任务没有实质差别,反而是老版本资料多、报错好查。
3.2 模型定义:从零写一个 EDSR-lite
我贴一份我常用的精简版结构,带详细注释。这个结构在 4 倍放大下有约 130 万参数,单张 11 GB 显卡训练无压力:
import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, n_feats=64): super().__init__() # EDSR 的关键设计:去掉 BN 层 self.body = nn.Sequential( nn.Conv2d(n_feats, n_feats, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(n_feats, n_feats, 3, padding=1) ) def forward(self, x): # 局部残差连接,缓解深层网络的梯度消失 return x + self.body(x) class EDSRLite(nn.Module): def __init__(self, scale=4, n_resblocks=12, n_feats=64, in_ch=3, out_ch=3): super().__init__() self.scale = scale # 浅层特征提取 self.head = nn.Conv2d(in_ch, n_feats, 3, padding=1) # 深层特征提取:堆叠残差块 self.body = nn.Sequential(*[ResidualBlock(n_feats) for _ in range(n_resblocks)]) self.body_conv = nn.Conv2d(n_feats, n_feats, 3, padding=1) # 上采样:PixelShuffle,倍数由 scale 决定 self.up = nn.Sequential( nn.Conv2d(n_feats, out_ch * (scale ** 2), 3, padding=1), nn.PixelShuffle(scale) ) def forward(self, x): f = self.head(x) res = self.body(f) res = self.body_conv(res) # 全局残差:把浅层特征直接加到深层特征上 f = f + res return self.up(f)这段代码里有三个设计点必须能讲清楚,答辩必问:
为什么去掉 BN?BN 在图像分类任务里能加速收敛、稳定训练,因为它对每层的激活做归一化。但 EDSR 的论文里明确指出,BN 会破坏图像的颜色分布一致性——它把每个通道的特征都拉到零均值单位方差,而图像重建任务对绝对幅值很敏感,归一化之后输入和输出的颜色关系就断掉了。去掉 BN 之后,模型的颜色还原明显更准,同时还能省下约 20% 的显存。
为什么用局部 + 全局双重残差?局部残差(x + body(x))解决的是深层网络的梯度流动问题,让梯度能绕过卷积层直接回传;全局残差(f + res)解决的是低频信息复用问题——LR 里本来就有的低频结构,没必要让深层网络重新学一遍,直接加过去更高效。这个设计直接影响到训练初期的收敛速度,我做过对比,去掉全局残差的话前 20 个 epoch 的 loss 下降会慢大概一半。
为什么最后才上采样?前面说过了,全流程在 LR 空间运算,最后 PixelShuffle 一次性放大。你可以算一笔账:128×128 的输入,如果用前置上采样先放大到 512×512,中间 12 个残差块的每一层卷积都是在 512×512 上做的,计算量是后置方案的 16 倍。这个数量级的差距,直接决定你能不能在毕设周期内训完。
3.3 损失函数与评价指标
损失函数的选择直接决定输出风格,这是我觉得最有意思的一块。
L1 损失:|pred - gt|的绝对值之和。梯度稳定,对异常值不敏感,训出来的结果偏平滑但 PSNR 高,是目前 SR 任务的最优选择之一。
L2 损失:(pred - gt)²的平方和,等价于 MSE。早期 SRCNN 用的这个,但它对大误差惩罚过重,导致模型倾向于输出“平均值”,边缘容易糊。如果你的模型训出来偏软,第一件事就是把 L2 换成 L1。
感知损失:把预测图和真值图分别过一遍预训练的 VGG19,取某几层的特征图算 L2 距离。它优化的不是像素级相似,而是“特征级相似”,视觉上更自然。
对抗损失(GAN 用):让判别器区分真假,生成器要骗过判别器。这是 ESRGAN 的核心,能逼出高频细节,但训练极不稳定。
针对毕设,我的建议是L1 + 可选感知损失。损失写起来很简单:
import torch.nn as nn class CharbonnierLoss(nn.Module): # L1 的平滑版本,在 0 附近可导,训练更稳 def __init__(self, eps=1e-6): super().__init__() self.eps = eps def forward(self, pred, target): diff = pred - target return torch.mean(torch.sqrt(diff * diff + self.eps))Charbonnier 是 L1 的改良版,在梯度上更平滑,我实测下来比纯 L1 收敛曲线更好看,论文里写出来也显得有讲究。
评价指标上,PSNR 和 SSIM 两个必出。PSNR 走的是像素误差的 log 换算,公式是20 * log10(MAX / sqrt(MSE)),MAX 一般取 255。SSIM 衡量的是亮度、对比度、结构的综合相似度,取值 0~1,比 PSNR 更贴近人眼感受。
但这里必须提醒一句:PSNR 高的模型视觉上不一定好看。我在做 4 倍放大时对比过,某个 L1 训出来的模型 PSNR 比 GAN 版本高 1.2 dB,但眼睛一看,GAN 版本的建筑边缘清晰得多,L1 版本像蒙了层雾。这就是所谓的“PSNR 与感知质量的不一致”,论文里写一段这个讨论,能明显体现你的思考深度。
3.4 训练循环与关键超参
训练循环是标准模板,重点在超参的设定逻辑:
import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for lr_imgs, hr_imgs in loader: lr_imgs = lr_imgs.to(device, non_blocking=True) hr_imgs = hr_imgs.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) preds = model(lr_imgs).clamp(0, 1) # 输出限幅,防止异常值 loss = criterion(preds, hr_imgs) loss.backward() optimizer.step() total_loss += loss.item() * lr_imgs.size(0) return total_loss / len(loader.dataset) # 主配置 config = { 'scale': 4, 'batch_size': 16, 'lr': 1e-4, 'epochs': 200, 'patch_size': 96, } optimizer = torch.optim.Adam(model.parameters(), lr=config['lr'], betas=(0.9, 0.999)) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=60, gamma=0.5)超参的设定依据要能说出来:
batch_size=16。32 也可以,但要看显存。batch 太小(如 4)会导致 BN(虽然这里没有)和梯度的方差大,训练震荡;batch 太大在小数据集上容易过拟合。16 是个稳的中间值,11 GB 显存跑 128 patch 完全够。
初始学习率 1e-4。这是 Adam 在 SR 任务上的经验值。用 1e-3 会发散或者 loss 震荡,用 1e-5 收敛太慢,200 epoch 训不完。我在 SRCNN 上试过 1e-3,前 30 个 epoch loss 一直上下跳,换成 1e-4 后立刻就稳了。
StepLR 的 60/0.5 设定。意思每 60 个 epoch 学习率减半,200 epoch 一共衰减 3 次。这个节奏跟经验上的收敛曲线匹配:前 60 个 epoch 快降,中间 60 个 epoch 精修,最后 60 个 epoch 微调。也可以换 CosineAnnealing,效果差不多,但 StepLR 更直观、好解释。
输出限幅.clamp(0, 1)。这一步很容易忘,但很重要。模型的输出理论上应该在 [0,1],但训练不稳定时会冒出负值或超过 1 的值,如果不限幅,这些值会污染 loss 和 PSNR 计算,还会让生成的图出现亮点。加上这一行,白点问题基本消失。
3.5 验证、保存与推理
验证阶段要做三件事:算 PSNR/SSIM、存可视化对比图、保存最优权重。
import numpy as np def evaluate(model, val_loader, device, data_range=1.0): model.eval() psnr_list, ssim_list = [], [] with torch.no_grad(): for lr_imgs, hr_imgs in val_loader: lr_imgs, hr_imgs = lr_imgs.to(device), hr_imgs.to(device) preds = model(lr_imgs).clamp(0, 1) # 逐张计算,避免 batch 内平均导致的偏差 for p, g in zip(preds, hr_imgs): p_np = p.cpu().numpy().transpose(1, 2, 0) g_np = g.cpu().numpy().transpose(1, 2, 0) psnr_list.append(psnr(p_np, g_np, data_range=data_range)) ssim_list.append(ssim(g_np, p_np, multichannel=True, data_range=data_range)) return np.mean(psnr_list), np.mean(ssim_list)必须提醒的两个细节:一,验证时要逐张算指标再取平均,不要整个 batch 混在一起算。因为 PSNR 是 log 尺度的量,先平均 MSE 再换算,和先换算再平均,结果会不一样,后者才是论文里的标准做法。二,推理时要处理尺寸对齐。如果 LR 的尺寸不是 scale 的整数倍,PixelShuffle 放大后的尺寸可能和预期差 1 像素,稳妥的做法是推理前把 LR 的边缘 padding 到整数倍,推理后裁掉多余部分。
保存策略上,我建议同时保存 last 和 best。best 按验证集 PSNR 挑,last 留着做断点续训。另外每 10 个 epoch 存一次 checkpoint,训练中断了不至于从头再来——这个习惯救过我至少三次。
3.6 对比实验的设计
毕设要出彩,对比实验比模型本身更重要。我建议至少做四组:
| 实验组 | 内容 | 目的 |
|---|---|---|
| 基线对比 | Bicubic / 双线性 / SRCNN / 你的模型 | 证明深度方法优于传统,你的优于基础深度方法 |
| 消融实验 | 去掉残差块 / 去掉全局残差 / 换 L2 损失 | 证明每个模块都有贡献 |
| 倍率对比 | 2× / 3× / 4× 三档 | 证明方法的泛化性,4× 通常最难 |
| 视觉对比 | 局部放大裁剪图 | 答辩时最有冲击力的材料 |
消融实验的表格一定要做,这是低成本高回报的加分项。拿掉全局残差后 PSNR 掉 0.15 dB,拿掉 L1 换 L2 后掉 0.2 dB,这种数字虽然小,但能明确说明“每个设计都不是随便加的”。视觉对比图的制作技巧我后面会讲。
4. 常见问题与排查技巧实录
这一节是整篇里最“只可意会”的部分,全是我和同组同学、以及帮别人看代码时真实碰到的问题。按发生频率从高到低排。
4.1 训出来全是噪点或方格
这是最高频的问题,症状是输出图像上布满规则的网格纹理或彩色噪点。原因基本只有一个:PNG 数据在读取和归一化时出了错。
排查顺序我固定成三步。第一步,检查归一化。如果用ToTensor(),它自动把 [0,255] uint8 转成 [0,1] float,这时候模型输出也在 [0,1],可视化时要乘 255 再转 uint8。如果用的是cv2.imread后手写img.astype(np.float32) / 255.0,那要确认除的是 255 而不是 256——我见过有人写/ 256,颜色整体偏暗,模型怎么训都是灰的。
第二步,检查通道顺序。cv2.imread读出来是 BGR,PyTorch 的ToTensor不会帮你转 RGB。如果你的真值是 RGB 而预测是 BGR 顺序,PSNR 会低得离谱,且输出的图颜色诡异(红蓝互换)。修法就一行:cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。
第三步,检查数据加载是否用了num_workers > 0但自定义 Dataset 里有共享状态。多进程下如果有可变对象被多个 worker 修改,可能读到错位的样本,表现为间歇性的乱数据。快速验证法是把num_workers设成 0 跑几个 batch,如果问题消失,就是这里的问题。
还有一种可能是学习率太大导致发散。判断依据:看 loss 曲线,如果前几个 epoch 就跳到 nan 或者剧烈震荡,基本就是 lr 太大或者数据里有 nan。修法是降到 1e-4 并加梯度裁剪:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10)。
4.2 PSNR 卡在 25 dB 上不去
4 倍放大下,DIV2K 训练、Set5 测试,正常水平应该在 28~32 dB 区间(视模型而定)。如果卡在 25 附近死活不动,我按可能性排了一张表:
| 现象 | 可能原因 | 验证方法 | 修法 |
|---|---|---|---|
| 训练 loss 还在降,验证 PSNR 不涨 | 过拟合 | 对比训练/验证 loss 曲线 | 加数据增强、减参数量、加 weight decay |
| 训练 loss 也不降 | 学习率或结构问题 | 单 batch 过拟合测试 | 调 lr、检查残差连接是否有断 |
| 验证 PSNR 波动大 | 验证集太小 | 看每次验证是否用的同一批图 | 固定验证集、增大验证样本 |
| 前期涨后期掉 | 学习率没衰减或衰减太晚 | 看 lr 曲线 | 提前衰减、加早停 |
单 batch 过拟合测试是排查网络结构问题的黄金手段,很多人不知道。做法:取一个 batch(比如 8 张图),让模型在这 8 张图上反复训 500 次,如果 loss 能降到接近 0、输出接近真值,说明网络结构和前向反向都没问题,问题在数据或超参;如果降不下去,说明结构里有 bug(比如短路了、激活函数用错、输出没接上)。这个测试十分钟就能做完,比盲改三天强。
还有一个隐蔽的原因是训练集和验证集的退化方式不一致。如果你的验证图是从论文作者提供的 LR 包里拿的,而那个包是用 MATLAB 的 imresize 生成的(MATLAB 的 bicubic 和 OpenCV 的实现有细微差异),那指标会系统性偏低。修法是自己用 OpenCV 重新生成一套 LR 验证集,保证和训练同源。
4.3 显存不够的三种解法
显存是毕设最硬的约束。我按优先级给三个方案:
方案一:减 patch 尺寸。从 128 降到 96 甚至 64,显存占用大致按面积下降,128 降到 96 能省约 44%。代价是感受野变小,但 64 以上的 patch 对 4 倍放大通常还够用。
方案二:梯度累积。想保持等效 batch_size 又不爆显存,可以每 4 个 batch 才optimizer.step()一次:
accum_steps = 4 for i, (lr_imgs, hr_imgs) in enumerate(loader): preds = model(lr_imgs.to(device)) loss = criterion(preds, hr_imgs.to(device)) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()注意 loss 要除以累积步数,否则梯度会放大 4 倍。这个技巧等价于 batch 16 但只占 batch 4 的显存,非常实用。
方案三:混合精度训练(AMP)。用torch.cuda.amp,显存能省 30~50%,速度还快一点:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): preds = model(lr_imgs) loss = criterion(preds, hr_imgs) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()但 AMP 在 SR 任务上有个坑:PSNR 可能轻微下降。原因是半精度浮点的动态范围有限,某些小的像素差值会被截断。如果指标要求严格,建议验证时关掉 autocast,用全精度算指标。
4.4 输出偏色与过度平滑
偏色的典型表现是重建图整体偏黄或偏蓝,在纯色区域尤其明显。根本原因一般是 BN 层或者训练数据色彩分布不均。修法优先级:一是去掉 BN(如前面所述);二是检查训练集是否偏向某种色调(比如全是夕阳照);三是在 YCbCr 空间只超分 Y 通道,色度通道用插值补齐,这个做法能直接消灭色偏,因为色度信息根本没经过网络。
过度平滑指的是边缘软、纹理像被抹开。这个是 L2/L1 损失的通病,解法有三:换成感知损失、加 GAN 分支、或者单纯在推理时叠一点锐化(不推荐,属于作弊)。我个人的建议是:如果毕设要求 PSNR,就接受平滑,在论文里说明“这是像素级损失的固有特性”;如果要求视觉效果,就上 GAN,同时坦白 PSNR 的下降。两条路都站得住脚,最怕的是既想要 PSNR 又要锐度还不肯改损失函数。
4.5 答辩时最容易被追问的五个点
我把这五个点列出来,你提前把答案想好,能省很多临场慌乱:
“你的方法和 SRCNN 有什么本质区别?”答:SRCNN 是前置上采样、三层卷积、L2 损失;我的是后置上采样、残差结构、L1 损失。前者计算冗余且深层能力弱,后者在同等算力下能堆更深的网络。
“PSNR 高就代表人眼看着好吗?”答:不一定。PSNR 是像素误差的统计量,对结构性失真不敏感。这也是为什么我要同时报 SSIM 并附视觉对比图。
“为什么用 bicubic 下采样?真实场景的低清图不是这么来的。”答:这是学术界的标准设定(非盲超分),便于公平对比。如果要处理真实退化,需要引入盲超分或退化建模,这是本工作的局限和后续方向。
“参数量才 100 多万,是不是太简单了?”答:结构精简是刻意的取舍,目的是在有限算力下训到收敛,并且消融实验证明每个模块都有贡献。参数量不是目的,效果和可解释性才是。
“如果给你更多算力你会怎么做?”答:加残差块到 32 个、通道到 128,或者引入通道注意力(RCAN 的思路),再试 GAN 分支做感知质量对比。这个回答既诚实又显得你有规划。
4.6 几条不那么正经但很有用的经验
最后分享几个不太上得了论文、但实操中真的省事的小技巧。
验证集别用太多图。Set5 就 5 张,一次验证 10 秒,能让你每 5 个 epoch 就验一次,及时看到趋势。如果用 Urban100 的 100 张图,每次验证几十秒,训练节奏全乱了。我的做法是训练中只跑 Set5,最终出表时再跑全套。
TensorBoard 一定要开。不是为了好看,而是 loss 曲线、学习率曲线、验证指标曲线放在一起看,能定位很多问题。比如你发现 loss 在降但 PSNR 在降,说明是过拟合;loss 平台期正好对应学习率衰减点,说明衰减节奏是对的。
对比图的做法有讲究。不要整图并排,要裁一个 64×64 的局部区域放大 4 倍展示,选有细密纹理的地方(比如建筑的窗格、动物的毛发、文字边缘)。整图对比在投影仪上根本看不出差别,局部放大才有冲击力。裁的时候在图上画个红框标明位置,这个小细节能明显提升答辩观感。
留一份“退化版”的结果备用。有时候模型在 bicubic 设定下指标漂亮,但老师随手拿手机拍的一张模糊图丢给你测,效果惨不忍睹。如果你提前准备了一组在“模糊+噪声”退化下微调过的权重,现场就能接住。这属于加分项,成本不高,收益很大。
训练日志一定要存文件。除了 TensorBoard,用logging把每个 epoch 的 loss、lr、PSNR、SSIM 写成 txt。答辩前写论文的时候,这些数字就是你的素材库,比回头翻终端记录靠谱一百倍。