简介:面向深度学习入门者与图像超分初学者的SRCNN PyTorch实现包,围绕经典超分辨率模型的搭建、训练与测试流程组织内容,适合希望从零理解卷积网络在图像重建中应用的人群,对小白较为友好,并提供可运行工程。压缩包共28个文件、约74.6MB,包含6个py脚本、7个pth权重、2个h5数据集文件,以及9张bmp结果图、1张png示意图和md、txt说明文档,覆盖模型代码、数据准备、预训练参数与可视化结果等部分。已有2978人学习下载,可作为课程实验、毕业设计原型或自学练手素材。读者可据此了解SRCNN的网络结构、数据组织方式与训练测试入口,借助权重和对比图观察双三次插值与超分输出的差异,并参考说明文档排查环境与进程数等参数设置问题,减少从零搭框架的成本。
1. 从一张糊掉的截图说起:SRCNN 到底动了哪一步
某开发者手上有一批低分辨率的产品图,想放大两倍放进详情页。双三次插值跑完,边缘发虚、细文字糊成一片,放大带来的“软”几乎没法看。SRCNN 就是在这个位置切进来的:它不改「先放大」这一步,放大依旧交给双三次,而是在放大后的那张软图上再叠三层卷积,把高频细节重建回来。这也是超分(SR)里最经典的 baseline。适合谁:想搞清超分模型演进起点的、显存不多想先跑通一个 demo 的、需要一个便宜对照组来验证自己新模型的。整套实现的权重就八千多个,单卡入门级显卡几分钟能跑一轮,翻车成本很低。
2. 三层卷积的账:9-1-5 结构为什么能顶住超分
2.1 稀疏编码那条手工流水线,被折叠成了三层 CNN
SRCNN 出现之前,图像超分的主流路线是稀疏编码:把 LR 图切成重叠 patch,每个 patch 到学好的低分辨率字典里求一组稀疏系数,再用高分辨率字典把系数还原成 HR patch,最后把重叠 patch 按权重叠回去。整条流程有三个明确组件——patch 提取与表示、非线性映射、重建,彼此割裂,字典和系数还得分开优化。
SRCNN 干的事情说穿了就是:把这三步各换成一个卷积层,端到端一起训。第一层 9×9 卷积对应 patch 提取与特征表示,输出的 64 个通道就是每个位置的“表示向量”;第二层 1×1 卷积对应非线性映射,1×1 在空间上不跨像素,本质是对每个位置独立做全连接,把 64 维压成 32 维,承接的正是稀疏编码里那一步非线性变换;第三层 5×5 卷积对应重建,把 32 张特征图加权叠回图像空间。前两层带 ReLU,第三层不加,因为重建输出要能取负值——这点后面会专门踩一次坑。
理解了这层映射关系,参数就不需要死记了:层数少是因为原始流水线只有这三个阶段,核尺寸大是因为字典的“作用域”本来就覆盖一个 patch,9×9 和 5×5 就是那个作用域的近似。后面 FSRCNN 把 9×9 拆成 5×5 加 1×1、把上采样挪到网络末尾,本质上都是在动这三个组件的位置和形状。
2.2 感受野与参数量:13×13 的窗口,8129 个权重
三个卷积层堆下来,每个输出像素实际看到的输入范围是 13×13,算法是 1 + (9−1) + (1−1) + (5−1)。也就是说重建一个像素时,网络能参考它周围 13×13 邻域的信息,这个窗口比常用的 3×3 小核堆叠要大,在放大倍数为 2~4 时基本够覆盖一个笔画或一条边缘的上下文。
按 Y 通道单通道输入输出算,三层的参数账如下:
| 层 | 核尺寸 | 输入→输出通道 | 参数量 | 激活 |
|---|---|---|---|---|
| conv1 | 9×9 | 1→64 | 9×9×1×64 + 64 = 5248 | ReLU |
| conv2 | 1×1 | 64→32 | 1×1×64×32 + 32 = 2080 | ReLU |
| conv3 | 5×5 | 32→1 | 5×5×32×1 + 1 = 801 | 无 |
| 合计 | 8129 |
如果走 RGB 三通道直训,conv1 变成 9×9×3×64 + 64 = 15616,conv3 变成 5×5×32×3 + 3 = 2403,总量涨到 20099,是单通道方案的 2.5 倍左右。推理开销也按每像素乘加数算:单通道是 9×9×64 + 64×32 + 32×5×5 = 8032 MACs/像素,一张 500×500 的图大约 2 GFLOPs 量级(乘加算两次),放到今天的 GPU 上基本是免费。所以「SRCNN 慢」这个印象主要来自原论文那个不走 padding、逐 patch 推理的实现,不是模型本身的问题。
2.3 PyTorch 骨架与 padding 的取法
原论文为了严格对齐卷积输出尺寸,是先把大图切成小块送进去的。实际复现时更常见的做法是给每层补 padding,让输出尺寸与输入完全一致,训练和推理都省事:
import torch import torch.nn as nn class SRCNN(nn.Module): def __init__(self, num_channels=1): super().__init__() # padding = kernel // 2,保证 H、W 不变 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) self._init_weights() def _init_weights(self): # 前两层接 ReLU,用 He 初始化 for m in (self.conv1, self.conv2): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') nn.init.zeros_(m.bias) # 最后一层无激活,方差压小,避免初始输出幅度过大 nn.init.normal_(self.conv3.weight, mean=0.0, std=1e-3) nn.init.zeros_(self.conv3.bias) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) return self.conv3(x) # 不能加激活,否则输出的负值被截断逻辑说明:padding=4对应 9//2,padding=2对应 5//2,这样三层都不改变特征图尺寸,网络的输入和输出可以逐像素做 MSE,训练侧不用做任何裁剪对齐。num_channels在 Y 通道方案里填 1,模型直接吃灰度图;RGB 方案填 3,但要先想清楚色度怎么处理,这一点在第 5 章会展开。
参数说明:kaiming_normal_的mode='fan_out'是按输出通道数缩放方差,配合 ReLU 是标准做法;第三层不用 He,是因为它后面没有激活函数,用大方差会让初始输出幅度远超 [0,1] 区间,前期 loss 会剧烈震荡。.conv3的std=1e-3是个经验值,换成 1e-2 也能收敛,只是前几十步 loss 曲线不那么好看。
写完先打印一下尺寸和参数量做自检:
m = SRCNN(num_channels=1) x = torch.randn(2, 1, 64, 64) print(m(x).shape) # torch.Size([2, 1, 64, 64]) print(sum(p.numel() for p in m.parameters())) # 8129尺寸对得上、参数量等于 8129,说明结构没写错。这两个数字记住了,后面排查时会反复用到。
3. 数据管线:从 HR 图造出成对样本的三个关键抉择
3.1 双三次下采样、scale 因子与 YCbCr 通道取舍
SRCNN 的训练样本是这样造出来的:从一张 HR 图里裁一个 patch,用双三次插值把它缩小 scale 倍得到 LR,再把这张 LR 用双三次插值放大回原尺寸,作为网络的输入,而原始 patch 就是标签。注意这里的关键点——网络学的是「从双三次放大图到 HR 图」的残差式补偿,不是「从 LR 直接到 HR」。这句理解错了,后面所有输入输出尺寸都会对不上。
scale 因子的选择直接影响任务难度。scale=2 时双三次放大已经相当接近 HR,网络只需要补一点点细节,PSNR 提升空间小但容易训;scale=4 时放大图已经糊得明显,提升空间大,但 SRCNN 这种浅层结构能补回来的也有限,指标会显得难看。新手建议先跑 scale=2 建立基线,再往 3、4 推。
通道处理是第二个抉择。人眼对亮度(Y)的敏感度远高于色度(Cb、Cr),所以超分领域的常规做法是:转 YCbCr,只对 Y 通道训练和推理,Cb、Cr 直接双三次放大再拼回去。SRCNN 原论文也是这么做的。这样做有两个好处:模型参数砍掉三分之二,训练快;色度不参与训练,天然规避了色彩漂移。代价是处理链路里多一次颜色空间转换,如果转换用的库和计算 PSNR 用的库不一致,指标会有微妙的偏差,这个坑在第 5 章单列。
3.2 Dataset 与 DataLoader 的落地写法
数据集类要做的事就三件:读图、转 Y 通道、裁 patch 造对。下面这版是能直接跑的:
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class SRPatchDataset(Dataset): """从 HR 图目录构造 (双三次放大图, HR) 成对样本,只使用 Y 通道。""" def __init__(self, hr_dir, scale=2, patch_size=96, repeat=24, augment=True): self.files = sorted( os.path.join(hr_dir, f) for f in os.listdir(hr_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')) ) assert self.files, f'{hr_dir} 下没有找到图像' assert patch_size % scale == 0, 'patch_size 必须能被 scale 整除' self.scale = scale self.patch_size = patch_size self.repeat = repeat # 每张图重复采样次数,撑大 epoch self.augment = augment def __len__(self): return len(self.files) * self.repeat def __getitem__(self, idx): path = self.files[idx % len(self.files)] img = cv2.imread(path, cv2.IMREAD_COLOR) # BGR, uint8 y = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)[:, :, 0] # 只取 Y 通道 y = y.astype(np.float32) / 255.0 p = self.patch_size h, w = y.shape if h < p or w < p: # 小图先放大到可裁 y = cv2.resize(y, (max(p, w), max(p, h)), interpolation=cv2.INTER_CUBIC) h, w = y.shape top = np.random.randint(0, h - p + 1) left = np.random.randint(0, w - p + 1) hr = y[top:top + p, left:left + p] if self.augment: if np.random.rand() < 0.5: hr = hr[:, ::-1] if np.random.rand() < 0.5: hr = hr[::-1, :] hr = np.ascontiguousarray(hr) # 关键:先下采样再上采样,得到与 hr 同尺寸的网络输入 small = cv2.resize(hr, (p // self.scale, p // self.scale), interpolation=cv2.INTER_CUBIC) lr_up = cv2.resize(small, (p, p), interpolation=cv2.INTER_CUBIC) return (torch.from_numpy(lr_up).unsqueeze(0), torch.from_numpy(hr).unsqueeze(0))逻辑说明:__getitem__里做了两次 resize,第一次缩到p // scale,第二次放回p,这两步不能省也不能合并——第一次是模拟低分辨率退化,第二次才是网络的真实输入。翻转增强只做水平和垂直镜像,不做 90 度旋转,因为自然图像有明确的重力方向,旋转会引入不真实样本;如果你的数据集全是俯拍或航拍图,旋转可以打开。
参数说明:patch_size=96是个折中,太小则每个 batch 覆盖的内容少、收敛慢,太大则单图能采的样本数少、容易过拟合;scale必须整除patch_size,否则第二次 resize 的目标尺寸不是整数,代码会直接抛异常。repeat=24表示每张 HR 图在一个 epoch 里被采 24 次,图像数量少于几百张时这个值要拉高,否则一个 epoch 的步数太少,学习率调度会失去意义。
3.3 损失、优化器与两段式学习率
损失函数没有悬念,用 MSE。超分领域后来普遍用 L1 换更好的感知质量,但 SRCNN 时代 MSE 就是标配,而且 MSE 直接对应 PSNR 这个评价指标,训练目标和汇报指标是一致的,省得解释。
优化器用 SGD + momentum=0.9 是原论文的配置,但原论文给前两层和最后一层设了不同的学习率:前两层 1e-4,最后一层 1e-5。这个设置的动机是最后一层直接输出像素值,梯度幅度大,学习率高了容易在 [0,1] 边界附近震荡。复现时第一版建议照抄,跑通之后再尝试统一学习率或者换 Adam 做对比。
optimizer = torch.optim.SGD([ {'params': model.conv1.parameters(), 'lr': 1e-4}, {'params': model.conv2.parameters(), 'lr': 1e-4}, {'params': model.conv3.parameters(), 'lr': 1e-5}, ], momentum=0.9, weight_decay=0) # 每 30 个 epoch 学习率减半,跑 80 个 epoch scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5)逻辑说明:参数分组(param groups)是 PyTorch 给不同层设不同超参的标准写法,scheduler.step()会对所有组统一缩放,所以前两层和第三层始终维持 10:1 的比例,不会因为衰减而打乱。
参数说明:weight_decay=0是刻意的,SRCNN 参数量小,正则化过头反而欠拟合;如果你的训练集只有几十张图并且指标在训练集上远高于验证集,再考虑加 1e-5 量级的权重衰减。step_size=30、gamma=0.5这组值跑 80 个 epoch 刚好衰减两次,属于保守配置,想快一点可以把step_size降到 15,但要注意别衰减太早导致收敛不完全。
4. 训练循环与评估:PSNR 到底怎么算才不虚高
4.1 训练循环骨架与断点续训
训练循环本身没什么花样,但有几个细节决定了这轮训练是不是可复现的:
import torch import torch.nn as nn from torch.utils.data import DataLoader device = 'cuda' if torch.cuda.is_available() else 'cpu' train_set = SRPatchDataset('data/train_hr', scale=2, patch_size=96, repeat=24) train_loader = DataLoader(train_set, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) model = SRCNN(num_channels=1).to(device) criterion = nn.MSELoss() optimizer = torch.optim.SGD([ {'params': model.conv1.parameters(), 'lr': 1e-4}, {'params': model.conv2.parameters(), 'lr': 1e-4}, {'params': model.conv3.parameters(), 'lr': 1e-5}, ], momentum=0.9, weight_decay=0) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5) for epoch in range(80): model.train() running = 0.0 for lr_img, hr_img in train_loader: lr_img = lr_img.to(device, non_blocking=True) hr_img = hr_img.to(device, non_blocking=True) pred = model(lr_img) loss = criterion(pred, hr_img) optimizer.zero_grad(set_to_none=True) loss.backward() # 浅层网络梯度偶尔会爆,加一道保险 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() running += loss.item() * lr_img.size(0) scheduler.step() avg = running / len(train_set) print(f'epoch {epoch+1:03d} | mse {avg:.6f} | lr {optimizer.param_groups[0]["lr"]:.1e}') # 每个 epoch 存一次,方便回溯是哪一轮开始指标掉头的 torch.save({ 'epoch': epoch, 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'scheduler': scheduler.state_dict(), }, 'srcnn_ckpt.pth')逻辑说明:zero_grad(set_to_none=True)比默认的zero_grad()少一次写零操作,在浅层小模型上影响不大,但养成习惯没坏处。clip_grad_norm_的max_norm=1.0对 SRCNN 来说基本不会触发,但一旦数据里有异常像素(比如全黑图触发的除零式梯度),没这行会直接训飞。checkpoint 里连 optimizer 和 scheduler 一起存,是为了断点续训时学习率能接上——只存模型权重、重开训练时学习率从 1e-4 重新开始,前几十个 epoch 等于白跑。
参数说明:batch_size=32是按 96×96 patch、单通道、8 GB 显存估算的,实际占用不到 1 GB,可以往 64 推。num_workers=4要看机器 CPU 核数,设太大会因为每个 worker 都持有 OpenCV 的独立上下文而变慢,4 到 8 是常见区间。
4.2 PSNR 的四个虚高来源
PSNR 的计算公式很简单,10 * log10(MAX² / MSE),实操里出错的从来不是公式本身,而是数据准备阶段:
import torch def psnr_y(pred, target, data_range=1.0): """pred/target 都是 (N,1,H,W) 的 float tensor,范围 [0, data_range]""" pred = pred.clamp(0.0, data_range) # 必须先裁剪,否则越界值会污染 MSE mse = torch.mean((pred - target) ** 2).item() if mse == 0: return float('inf') return 10.0 * torch.log10(torch.tensor(data_range ** 2 / mse)).item()第一个坑是没做 clamp。网络输出可以超过 1 也可以为负,这些越界值会拉高 MSE,让 PSNR 偏低;如果越界值恰好和 target 同向偏移,又会反过来让 MSE 变小,PSNR 虚高。不规范的做法是两个方向都存在,导致同样的模型在不同脚本里报出相差零点几 dB 的数字。
第二个坑是在 uint8 上算 PSNR。先把浮点结果乘 255 四舍五入成 uint8,再算 MSE,会丢掉小于 1/255 的误差项,scale=2 这种小倍率任务上差值能到 0.1~0.3 dB。一致的约定是:模型输出保持 float、clamp 到 [0,1]、在这个精度上算 MSE,只有 SSIM 计算才转成 uint8,因为不少 SSIM 实现要求整数输入。
第三个坑是通道口径不统一。用 Y 通道训练却拿 RGB 三通道的平均 PSNR 去汇报,两组数字根本不是一个东西,跨论文对比时会凭空多出 1~2 dB。汇报时写清楚「Y 通道、data_range=1.0、是否裁边」,把口径固定下来。
第四个坑是拿 bicubic 上采样结果当预测值去算。如果你的网络输入就是 bicubic 放大图,而预测分支某条路径直接输出了输入,PSNR 会非常接近 bicubic 基线,看起来“没学坏”其实“也没学到”。验证方法是把 bicubic 基线单独算一遍,和模型结果并排打印,差值小于 0.1 dB 说明网络基本在摆烂。
4.3 SSIM 的边界与权值初始化的诊断
SSIM 的计算建议直接用成熟实现,不要手写:
from skimage.metrics import structural_similarity as ssim import numpy as np def ssim_y(pred, target): p = (pred.clamp(0, 1)[0, 0].cpu().numpy() * 255).round().astype(np.uint8) t = (target[0, 0].cpu().numpy() * 255).round().astype(np.uint8) return ssim(p, t, data_range=255, win_size=11, gaussian_weights=True, sigma=1.5, use_sample_covariance=False)参数说明:win_size=11、sigma=1.5是 SSIM 的经典配置,和多数论文口径一致;use_sample_covariance=False也是为对齐论文口径,设成 True 会改变 0.001 量级的数值,跨论文比较时要注意。data_range=255配 uint8 输入,如果传浮点输入就要改成 1.0,两者混用会得到负数或大于 1 的荒谬结果。
收敛诊断方面,SRCNN 只要初始化对了,loss 曲线应该在前 2000 步内快速下降,然后在 1e-3 量级缓慢磨。如果看到 loss 在前 100 步就跳到 0.5 以上并持续震荡,八成是第三层初始化方差太大;如果 loss 平稳但一直下不去,先查第一层有没有被正确初始化——把conv1.weight.std()打印出来,He 初始化下应该在 0.03~0.05 之间,如果接近 0 说明初始化被后面的代码覆盖了。
5. 排查:SRCNN 复现里最容易翻车的五个点
5.1 输出尺寸比输入少一圈
现象:训练能跑通,但计算 loss 时pred和hr形状对不上,或者推理出来的图比原图小一圈。
原因:忘了给卷积层加 padding,或者 padding 算错了。9×9 不加 padding 会缩 8 像素,5×5 会缩 4 像素,三层叠起来输出比输入小 12 像素,正好是感受野减一。原论文的 patch 化推理就是这么干的,但现代实现基本都是全图进全图出。
解决:确认每层 padding 等于kernel_size // 2,即 conv1 加 4、conv2 加 0、conv3 加 2。写个assert pred.shape == hr.shape放在 loss 前面,比事后对着报错猜要快得多。
5.2 RGB 直训导致色彩漂移
现象:用 RGB 三通道直接训完,放大后的图像整体偏色,天空发紫、肤色发红,而且换一张图偏的方向还不一样。
原因:三个通道共享同一组卷积核,网络没有能力学出「亮度通道更该被重建、色度通道更该被平滑」这种差异化策略,训练数据里色度分布的方差又远大于亮度,梯度被色度主导,亮度反而学不好。同时 MSE 在 RGB 空间里对色度误差的惩罚被放大,进一步把网络往错误方向推。
解决:回到 YCbCr 分开处理。训练和推理都只让网络碰 Y 通道,Cb、Cr 用双三次插值放大到目标尺寸,最后cv2.cvtColor拼回 BGR。如果确实需要 RGB 端到端,至少把第一层的输入通道从 3 改成 1 再用 1×1 卷积扩展,让网络自己学通道加权,别让 9×9 卷积直接吃三通道。
5.3 PSNR 很高但肉眼看着糊
现象:验证集 PSNR 比 bicubic 基线高 1 dB 以上,但把结果图放大看,边缘和纹理并没有变锐,甚至有点“塑料感”。
原因:MSE 优化的目标是逐像素平均误差最小,对高频的惩罚权重被低频稀释了,网络学到的是「把所有像素往均值拉」而不是「把边缘推回去」。这不是训练出错,是 MSE 这个损失函数本身的取向。SRCNN 这类浅层模型在高倍率下尤其明显。
解决:汇报指标时同时报 PSNR 和 SSIM,SSIM 对结构变化更敏感,能看出网络是不是真的补了结构;肉眼看图要看局部裁剪(比如文字边缘、栏杆、头发),整图缩略图根本看不出差别。想改善观感就得换损失,L1 或者加感知损失,但那已经超出 SRCNN 的范畴了,作为 baseline 保留 MSE 就行。
5.4 最后一层加了 ReLU,输出全是非负
现象:loss 下降到一个平台后就死活下不去,验证集 PSNR 卡在某个值附近震荡,输出的图像整体偏亮。
原因:第三层后面误加了 ReLU,所有负值被截成 0。重建层输出的是像素值,负值代表「比均值暗」,被截断后网络只能往亮的方向补偿,暗部细节全丢。而且 ReLU 在 0 处的梯度是 0,一旦某个位置的输入落到负半轴,梯度就再也回不来了,这部分权重等于死掉。
解决:第三层绝不能加激活。前两层加 ReLU 是为了非线性,第三层是线性重建,加激活就把模型变成了只能表达非负函数的受限模型。改完之后 loss 应该能再往下走一截,暗部细节也会回来。
5.5 显存爆在大图上
现象:训练时 batch 设 32 没问题,一换到测试集整图推理就 OOM,明明只跑一张图。
原因:显存占用和像素数成正比,和 batch 关系不大。训练时 96×96 的 patch 只有 9216 个像素,一张 4000×3000 的图有 1200 万像素,是第一层的 64 个通道、9×9 卷积核全部展开,中间特征图能到几个 GB。
解决:推理必须分块。把大图切成 192×192 或 256×256 的 tile,边缘留 16 像素重叠,逐块过网络再按权重拼回去,具体写法见下一章。另一个思路是torch.no_grad()加model.half()半精度推理,显存直接减半,SRCNN 这种小模型对精度不敏感,PSNR 差异在 0.01 dB 量级。
6. 推理与延伸:把 SRCNN 接到实际流程上
6.1 大图分块与重叠拼接
直接整图送进网络的写法在 demo 上图能跑,图上到 2K 以上就会翻车。分块推理的关键是重叠区域要加权融合,否则块与块之间会出现明显的接缝。用汉宁窗做权重是最省事的方案:
import numpy as np import torch @torch.no_grad() def infer_tiled(model, y_img, patch=192, overlap=16, device='cuda'): """y_img: HxW float32 [0,1];返回同尺寸 Y 通道重建结果""" model.eval() h, w = y_img.shape out = np.zeros_like(y_img, dtype=np.float32) weight = np.zeros_like(y_img, dtype=np.float32) def win(n): # 汉宁窗 + 极小值,避免边缘权重为 0 导致除零 return np.hanning(n).astype(np.float32) + 1e-3 stride = patch - overlap for i in range(0, h, stride): for j in range(0, w, stride): i1, j1 = min(i + patch, h), min(j + patch, w) i0, j0 = max(0, i1 - patch), max(0, j1 - patch) tile = y_img[i0:i1, j0:j1] t = torch.from_numpy(tile).unsqueeze(0).unsqueeze(0).to(device) pred = model(t).clamp(0, 1).cpu().numpy()[0, 0] wgt = win(pred.shape[0])[:, None] * win(pred.shape[1])[None, :] out[i0:i1, j0:j1] += pred * wgt weight[i0:i1, j0:j1] += wgt return out / np.maximum(weight, 1e-8)逻辑说明:i0 = max(0, i1 - patch)这行保证最后一块紧贴边界而不是越界,代价是最后一块和倒数第二块的重叠会比overlap大,融合时靠权重归一化自动处理。汉宁窗在块中心权重接近 1、在块边缘衰减到接近 0,两块重叠区域的权重互补,加权平均后接缝不可见。
参数说明:patch=192是按 8 GB 显存在 scale=4 下估算的,显存宽裕可以拉到 256 或 384,越大接缝越少但显存占用按平方涨;overlap至少要大于感受野的一半,也就是 7 像素以上,取 16 是留了余量。如果忘了model.eval(),BatchNorm 的统计量会按每个 tile 单独更新,同一张图不同块的输出风格会不一致,这种 bug 肉眼很难定位。
6.2 和后续模型的对照:什么时候该换掉 SRCNN
SRCNN 作为 baseline 的价值在于它足够简单,简单到任何异常都能归因到具体某一行。但它有两个绕不过去的结构性限制:上采样前置意味着所有卷积都在高分辨率特征图上算,计算量随放大倍数的平方增长;三层结构感受野固定 13×13,对高倍率重建的上下文不够。
| 模型 | 上采样位置 | 参数量量级 | 相对 SRCNN 的变化 |
|---|---|---|---|
| SRCNN | 前置(双三次) | 8K | 三层直筒,MSE 端到端 |
| FSRCNN | 后置 | 十余 K | 末端用反卷积放大,计算量前移 |
| ESPCN | 后置 | 更小 | 亚像素卷积,实时性更好 |
| VDSR | 前置 | 数十万 | 20 层 + 全局残差,指标明显更高 |
| EDSR | 后置 | 百万级 | 残差块堆叠,去掉 BN,指标强但慢 |
选型逻辑很直接:要实时、要嵌到端侧,往 ESPCN 方向走;只要指标、算力不限,VDSR 起步;想理解超分模型演进,SRCNN 到 FSRCNN 这一步是必看的——上采样位置从网络前挪到网络后,带来的计算量变化是数量级的,这个道理后面所有模型都在用。
6.3 一个验收入口
复现 SRCNN 的过程中我踩过最深的坑不是结构写错,是「以为跑通了实际上没学到东西」。后来形成一个习惯:每训完一个 baseline,先在标准测试集(Set5、Set14 这类通用超分评测集)上把 bicubic、模型输出、HR 三张图并排裁同一块局部存下来,人工扫一眼,再对齐打印 PSNR 和 SSIM。这一步花不到五分钟,但能在指标看起来正常的情况下截住「网络只是复读了输入」这类隐性失败。指标是黑匣子,局部裁剪图才是后悔药。从那以后每次开新数据集前,我都强制先把这三张图跑一遍再动超参调优,希望帮到你。
本文还有配套的精品资源,点击获取