说起图像超分辨率重建,亚像素卷积网络(ESPCN)几乎是入门必读的一个模型。它发布于2016年前后,核心做法是先让低分辨率图像经过少量卷积层做特征提取,在最后一层输出 r² 个通道,再通过 PixelShuffle 把通道信息重排成高分辨率图像。我这段时间完整复现了 4 倍超分,把原理、训练代码和踩坑记录整理成这篇,适合刚接触超分、准备从轻量模型入手的人,也适合想在生成类模型里用亚像素上采样的同学。
1. ESPCN的定位:为什么超分网络需要它
1.1 超分辨率里的两条老路线
图像超分辨率重建要解决的事情很简单:给一张低分辨率图,预测一张尽可能真实的高分辨率图。早期基于学习的方法走的是“先放大再修图”的路线,最典型的是 SRCNN:先把低分辨率图用双三次插值放大到目标尺寸,再把这个放大后的图像送进卷积网络学残差。这条路线的优点是网络不需要负责上采样,结构简单;缺点也很明显——插值放大之后,所有卷积操作都在高分辨率尺度上做,计算量直接涨了 r² 倍(r 是放大倍数)。如果做 4 倍超分,网络输入面积是低分辨率图的 16 倍,后面的每一层卷积都在这个放大的大图上算,速度和显存都不友好。
ESPCN 换了一个思路:先在低分辨率空间用少量卷积层提取特征,只在网络的最后一层做上采样。上采样不是靠插值,而是靠一个可学习的像素重排操作。这样卷积计算主要发生在低分辨率图上,最后一步重排只是整理通道和空间位置的关系,不需要额外参数。你可以理解成:老办法是先把照片打印到一张大纸上再修细节,ESPCN 是先在原图上把内容看明白,最后一次性排版到高分辨率网格里。
1.2 “亚像素”到底指什么
像素是图像采样的最小单元,所谓亚像素,其实就是在像素内部再细分位置。放大倍率 r=2 时,每个低分辨率像素对应高分辨率图像里一个 2×2 的小块;r=4 时,对应一个 4×4 的小块,也就是 16 个亚像素位置。ESPCN 不直接预测高分辨率坐标,而是让最后一层卷积输出 r² 张和低分辨率图尺寸相同的特征图,每一张特征图负责一个子相位。
举个例子,假设输入是 H×W,放大倍率 r=2。网络最后一层要输出 4 个通道,每个通道尺寸还是 H×W。PixelShuffle 会把通道维度的 4 张图重新排列,变成一个 2H×2W 的输出:通道 0 放到左上角,通道 1 放到右上角,通道 2 放到左下角,通道 3 放到右下角。用 PyTorch 的nn.PixelShuffle做这件事,一行代码就行,但理解它的排列逻辑对排查问题很有帮助。
1.3 为什么说它“高效”
轻量是 ESPCN 最大的标签。以我复现时用的网络为例,输入 3 通道彩色图,放大倍数 r=4,第一层卷积核 5×5 输出 64 个特征图,第二层 3×3 输出 32 个特征图,最后一层 3×3 输出 3×r²=48 个特征图,算上偏置,总参数量大约只有 3.7 万。这个体量放到今天依然算小型网络,CPU 上跑一张小图都很轻松。
计算量方面,因为大部分卷积在低分辨率图上进行,4 倍超分时卷积操作的像素数量只有整图超分方案的大约六分之一。更重要的是,PixelShuffle 这个操作是纯张量重排,没有卷积参数,也没有可学习的插值权重,放大过程不增加额外 FLOPs。这也让 ESPCN 成了早期视频实时超分场景里常用的选择。
2. 网络结构与PyTorch复现细节
2.1 每一层到底在做什么
ESPCN 结构非常直白,就是一个三层卷积加最后的像素重排。我复现时使用的具体配置如下:
| 层 | 输入通道 | 输出通道 | 卷积核 | 作用 |
|---|---|---|---|---|
| Conv1 | 3 | 64 | 5×5 | 提取基础结构特征 |
| Conv2 | 64 | 32 | 3×3 | 组合特征,降低通道冗余 |
| Conv3 | 32 | 3×r² | 3×3 | 生成每个子像素位置的值 |
| PixelShuffle | 3×r² | 3 | 无 | 通道重排到高分辨率网格 |
第一层用较大的 5×5 卷积核,是为了在低分辨率图上尽可能获得足够感受野。低分辨率图本身就小,信息量有限,小卷积核如果堆得不够深,感受野不够,SR 结果很容易糊。第二层降通道到 32,是在控制后面输出层参数量的同时保留足够的特征表达。第三层直接输出 r² 个通道,不加激活函数,因为这一层要生成的是像素值,不是特征响应,加激活函数反而会限制输出范围。
我用的是 5×5、3×3、3×3 的组合。不同实现里也常见全用 5×5 的版本,参数量会到 9 万左右,依然很小。关键是最后一层的输出通道必须严格等于重建通道数 × r × r,否则 PixelShuffle 排不出来。
2.2 PixelShuffle 的排列逻辑
PixelShuffle 做的事情本质上是“通道到空间的周期排列”。PyTorch 的公式是:把形状为(B, C×r², H, W)的张量重排成(B, C, H×r, W×r)。
拿彩色图、r=2 举例,最后一个卷积层输出 3×4=12 个通道。PixelShuffle 会把每个通道按照子相位划分,重新填进高分辨率网格。对于输出图里的某个位置(oh, ow),它对应输入通道的计算方式是:c_input = c_output × r² + (oh % r) × r + (ow % r),对应的输入空间位置是oh // r和ow // r。这个公式看起来绕,但你只需要记住一件事:网络通过最后一层卷积学到的每个输出通道,恰好是高分辨率网格上某个固定相位位置的预测值。
所以 ESPCN 的上采样不是“先插值再做卷积”,而是“卷积直接生成所有子像素位置的值,再一次性摆放整齐”。这也是它叫亚像素卷积的原因。
2.3 模型定义代码
下面是我复现时用的完整模型定义,直接用 PyTorch 自带算子,核心代码很短:
import torch import torch.nn as nn import torch.nn.functional as F class ESPCN(nn.Module): def __init__(self, in_channels=3, out_channels=3, upscale_factor=4): super(ESPCN, self).__init__() r = upscale_factor self.conv1 = nn.Conv2d(in_channels, 64, kernel_size=5, padding=2) self.conv2 = nn.Conv2d(64, 32, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(32, out_channels * r * r, kernel_size=3, padding=1) self.pixel_shuffle = nn.PixelShuffle(r) # 初始化 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') nn.init.zeros_(m.bias) def forward(self, x): x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) x = self.conv3(x) x = self.pixel_shuffle(x) return x注意最后conv3的输出通道数我写的是out_channels * r * r,而不是out_channels。这是初学者最常踩的坑,稍后我会单独讲。padding的选择也要配合卷积核大小,不能让特征图尺寸在中间层意外变小,否则最后 PixelShuffle 之后的空间尺寸会和你预期对不上。
3. 数据准备与训练前处理
3.1 HR-LR 图像对怎么构造
训练超分模型,第一步是准备成对的高分辨率和低分辨率图像。低分辨率图不是随便把高分辨率图缩小就行,超分模型对“退化方式”非常敏感。最常用的做法是:用双三次插值把 HR 图缩小到目标的 r 分之一,得到 LR 图。这个 LR 图就是模型要处理的下采样结果。
实际操作中,我不会一次性把整张图缩小,而是先在 HR 图上随机裁剪一个固定大小的块,比如 128×128,再把它缩小成128 // r大小的 LR 块。这样有几个好处:一是每个训练样本只占很小内存,可以放心做大批量;二是随机裁剪本身天然带了平移不变性的数据增强;三是 HR patch 和 LR patch 的对应关系非常干净,不会出现边界截断导致的信息错位。
如果你的 HR 图不够大,或者尺寸不是 r 的整数倍,很简单,先在训练集筛选阶段把小于指定尺寸的图剔除,或者在读图后用中心裁剪保证长宽能被 r 整除。这个预处理习惯能省掉后面大量尺寸对不齐的麻烦。
3.2 数据增强必须“成对”做
超分任务的数据增强和平常分类任务最大的区别是:HR 和 LR 必须做完全相同的变换。如果只翻转 LR 不翻转 HR,等于把对应关系破坏了。
我自己的 Dataset 实现大概是这样的:
import random from PIL import Image from torch.utils.data import Dataset from torchvision.transforms import ToTensor class SuperResolutionDataset(Dataset): def __init__(self, image_paths, scale=4, hr_size=128): self.image_paths = image_paths self.scale = scale self.hr_size = hr_size def __len__(self): return len(self.image_paths) def __getitem__(self, idx): hr = Image.open(self.image_paths[idx]).convert('RGB') # 滤掉尺寸不够的图,这里假设已经提前筛选过 i = random.randint(0, hr.height - self.hr_size) j = random.randint(0, hr.width - self.hr_size) hr = hr.crop((j, i, j + self.hr_size, i + self.hr_size)) lr_size = self.hr_size // self.scale lr = hr.resize((lr_size, lr_size), Image.BICUBIC) # 成对增强 if random.random() < 0.5: hr = hr.transpose(Image.FLIP_LEFT_RIGHT) lr = lr.transpose(Image.FLIP_LEFT_RIGHT) if random.random() < 0.5: hr = hr.transpose(Image.ROTATE_90) lr = lr.transpose(Image.ROTATE_90) return ToTensor()(lr), ToTensor()(hr)有几件事我在第一批训练数据上就踩了坑。第一,hr.resize必须用Image.BICUBIC,不要用默认的最近邻或者双线性,否则降采样后的 LR 和真实场景不匹配,模型学到的退化偏了。第二,HR 和 LR 的增强必须完全相同,我上面把翻转写在 resize 之后,但如果你把 resize 放在预处理阶段,就要在 Dataset 里先单独读回 LR 和 HR 再做同样的变换,否则又会出现错位。第三,ToTensor会把像素缩放到 [0,1],后面 Loss 和 PSNR 的尺度都要统一按 [0,1] 算,这一点非常容易集体踩坑。
3.3 关于归一化范围的处理
图像任务里常见两种归一化:缩放到 [0,1],或者缩放到 [-1,1]。对 ESPCN 这种直接回归像素值的模型,我建议先把训练控制在 [0,1] 范围内,用ToTensor就行。不要在模型最后加 Softmax 或 Sigmoid,因为像素值回归本质上没有概率含义,Sigmoid 会把输出限制到 (0,1),但也会让梯度变得很小。
训练过程中不要在 forward 里直接clamp输出。虽然推理时我们希望结果在 [0,1],但训练时如果提前 clamp,超出范围的位置梯度会变成 0,网络很难学会修正。正确做法是训练时原样输出算 Loss,推理时再torch.clamp(pred, 0, 1)保证图像显示正常并存图。如果发现最终输出大量超出范围,优先检查学习率和 Loss,而不是急着加 clamp。
4. 训练配置、损失函数与一次完整跑通
4.1 Loss 用 L1 还是 MSE
ESPCN 原论文用的是 MSE,也就是 L2 损失。PyTorch 里对应F.mse_loss。MSE 和 PSNR 的关系很直接,因为 PSNR 本质上就是由 MSE 算出来的,最小化 MSE 理论上也会让 PSNR 提升。但我自己跑下来,L1 损失在多数时候更稳,超分结果不容易出现过平滑。
原因是 L2 对比较大的误差惩罚特别狠,模型为了避免少数像素的大误差,会把预测值往平均值方向推,结果就是图像偏糊。L1 对误差的惩罚更温和,能保留更多高频细节。实际操作中我建议两个都试一下,以验证集 PSNR 为准。至少对 ESPCN 这种轻量网络,L1 和 MSE 的差别你很快就能在验证集上看到。
loss = F.l1_loss(pred, hr_tensor)4.2 优化器和学习率
训练超分模型,Adam 是默认起点,学习率从 1e-4 到 3e-4 都可以。我习惯用 1e-4,配合余弦退火调度器,到训练后期学习率降到接近 0,细节会明显地变得更干净。如果从头到尾用一个固定学习率,前期进度慢,后期又容易在最优值附近震荡。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)batch size 方面,取决于显存。我用的是 HR patch 128、LR patch 32、r=4,batch size 16,显存占用大约 4GB 左右。如果你显存紧张,可以把 HR patch 缩到 96,或者 batch size 降到 8。由于 ESPCN 参数量小,batch size 带来的影响没有大规模分类网络那么敏感,但 batch size 太小会让梯度噪声变大,收敛速度下降。
4.3 训练主循环
下面是一个能直接跑通的最小训练循环,实际用的时候你需要自己接上 DataLoader 和验证逻辑:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ESPCN(upscale_factor=4).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) for epoch in range(100): model.train() total_loss = 0.0 for lr, hr in train_loader: lr = lr.to(device) hr = hr.to(device) pred = model(lr) loss = F.l1_loss(pred, hr) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch {epoch:3d} | loss {total_loss / len(train_loader):.6f}") # 在每个 epoch 结束之后做一次验证,保存最优权重 if val_loader: val_psnr = evaluate(model, val_loader, device) print(f"val psnr {val_psnr:.2f} dB")这个循环里没有梯度裁剪,因为 L1 损失很少出现爆炸。如果你把 Loss 换成 MSE,或把网络加深,就需要注意梯度问题,建议加一句torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)保险。
5. 评估指标:PSNR怎么算才不坑
5.1 在 Y 通道上算 PSNR
PSNR 是超分任务最常见的量化指标,但很多人算出来的数值和预期差很远,问题不在模型,在评估代码。标准超分评估并不是直接在 RGB 三个通道上算 MSE,而是先转换到 YCbCr 色彩空间,只在 Y 亮度通道上计算。原因是人眼对亮度变化更敏感,而且很多经典超分方法都是这么对齐的。
如果你用 RGB 直接算 PSNR,数值通常会比 Y 通道低一些,因为色度通道的高频噪声会拉低 PSNR。这里给出一段简洁的 Y 通道 PSNR 计算代码:
import torch def rgb_to_y(img): # img: [B, 3, H, W], 数值范围 [0,1] r, g, b = img[:, 0:1], img[:, 1:2], img[:, 2:3] y = 0.299 * r + 0.587 * g + 0.114 * b return y def calculate_psnr(pred, gt, max_val=1.0): pred_y = rgb_to_y(pred) gt_y = rgb_to_y(gt) mse = torch.mean((pred_y - gt_y) ** 2) if mse < 1e-12: return float('inf') psnr = 10 * torch.log10(max_val * max_val / mse) return psnr.item()max_val必须和你的数据归一化范围一致。如果模型输入输出都在 [0,1],max_val=1.0。如果你把像素缩放到 [-1,1] 或者使用 0-255 的整数图,max_val要相应改成 2.0 或 255.0。不少人复现超分模型发现 PSNR 莫名其妙低了十几 dB,多半就是这里出了问题。
5.2 评估时的下采样方式也要对齐
训练时用的是双三次插值降采样,评估时也必须用同样的方式生成 LR 图。如果训练用PIL.Image.BICUBIC,测试却用torchvision.transforms.Resize的默认双线性,LR 图本身就变了,模型等于在没见过分布的输入上测试,PSNR 肯定不好看。
我自己习惯在评估脚本里固定一个函数,用 RGB 空间的 PIL 双三次插值生成 LR。这样训练和验证都走同一条数据通路,结果才有可比性。还有一个细节:如果为了省时间在验证阶段直接对整张图推理,而训练时用的是随机裁剪 patch,你最终看到的 PSNR 通常和训练时主观感受一致,但边界部分会有轻微偏弱。追求严谨的评估,可以把图四周裁掉scale到2*scale个像素再算 PSNR,因为边界卷积 padding 引入了无效信息。
5.3 主观效果怎么看
PSNR 只能说明像素层面的接近程度,不能完全代表视觉质量。我复现 ESPCN 时,最直观的验证方式是并排显示三张图:LR 放大图、ESPCN 输出、HR 原图。放大到 4 倍看边缘和纹理区域,你会明显感觉到 ESPCN 输出的边缘比双三次插值更清晰,文字线条更锐利,但可能还是有一点糊,尤其是高频细节多的区域。对一个 3.7 万参数的轻量模型来说,这是正常水平,不要拿它和几十上百兆的大模型比。
我自己用少量数据训练 100 轮时,4 倍超分在验证集上的 PSNR 大约从双三次插值的 26.5 dB 左右涨到 28.2 dB左右。这个数值不算高,因为训练数据少、模型浅,但它足够说明网络真实学到了超分能力,而不是简单复制输入。
6. 复现过程中的典型问题与排查
6.1 最后一层通道数写错
这是最容易出现的问题。提醒一下,最后卷积输出通道数必须是out_channels * r * r。比如目标输出 3 通道、r=4,最后一层输出应该是 48,而不是 3。如果你写成 3,nn.PixelShuffle(4)会直接报错,因为 3 不能被4*4整除;如果你写成 3×4=12,它能跑通但输出的高分辨率图只有 r×r 重排的一部分,最终结果会是花屏或者尺寸不对。排查这一类问题最快的方法是打印model(torch.rand(1,3,32,32)).shape,预期是[1,3,128,128],只要不是这个形状,先回去检查通道数。
6.2 HR 与 LR 尺寸不是严格倍数
训练时如果 HR patch 是 100×100,r=4,LR patch 应该是 25×25。如果 resize 后因为取整变成 24×24,那么 PixelShuffle 输出是 96×96,和 HR 的 100×100 对不上,Loss 会直接报大小不一致。这个问题在整图推理时尤其常见。解决办法很简单,任何裁剪和 resize 前先保证尺寸能被 r 整除,或者对 LR 尺寸取整后反推 HR 尺寸。我一直用的做法是:先裁剪 HR,再算出 LR 尺寸 = HR 尺寸 // r,最后如果 LR 尺寸乘回 r 不等于 HR 尺寸,就调整 HR 尺寸。
6.3 PSNR 数值忽高忽低
模型明明训练正常,Loss 也在下降,但验证 PSNR 波动很大,或者和预期差很多。先别怀疑模型,检查三件事:数据范围是否一致,PSNR 是否在 Y 通道计算,验证集的 LR 下采样方式是否和训练一致。这三个位置每个都能轻松造成 1-3 dB 的偏差。我在第一次跑通 ESPCN 时,就是因为 PSNR 代码里max_val写了 255,但张量范围是 [0,1],导致算出来的 PSNR 整体虚高十几 dB,白白困惑了大半天。
6.4 输出图像有棋盘格
棋盘格在转置卷积任务里很常见,ESPCN 用 PixelShuffle 已经比转置卷积好很多,但也不是完全免疫。如果输出有周期性伪影,常见原因有几个:学习率太高导致网络在细节位置过拟合噪声;训练轮次太少,模型还没学好相位关系;最后一层卷积核过大或过小导致频率响应不自然。我的经验是先降学习率到 1e-4,再把训练轮次加长,观察是否缓解。如果仍然严重,检查训练集是否存在大量重复纹理或压缩噪声,这些都会放大周期伪影。
6.5 训练 Loss 不下降或变 NaN
L1 损失一般不会 NaN,但如果训练数据里出现坏图、全黑图、损坏文件,或者优化器学习率过高,就可能出问题。排查方法是在 DataLoader 返回前检查张量是否包含 NaN:
assert not torch.isnan(lr).any() assert not torch.isnan(hr).any()如果 Loss 完全不下降,优先看输入输出是否全是同一个值。比如你用ToTensor读灰度图却设了convert('RGB'),或者 dataset 里路径拼接错误导致总是读到同一张图,都会让 Loss 原地不动。这类问题靠打印一个 batch 的均值、方差,基本一眼就能看出来。
6.6 显存不够
ESPCN 很小,但 patch 开大了一样爆显存。我用 128 patch、batch 16,单卡 4GB 就够用。如果你在高分辨率大图上做整图推理,可以考虑切成重叠块,分别送进模型,再拼回来;拼图时重叠区域取平均值,能避免边缘拼接痕迹。如果训练时爆显存,优先降 batch,其次降 HR patch,模型参数量本身已经没什么可压缩的了。
7. 从复现到下一步扩展
跑通基础版之后,你可以在这个框架上做很多小改进。比如把第二层和第三层换成更大的感受野,或者给模型加一个全局残差连接,让网络去预测 HR 和 LR 放大图之间的残差而不是直接输出像素值。这会稍微提高收敛速度和最终精度,代价是网络不再是最简结构。另外,如果你要做 8 倍超分,不要试图让最后一层直接输出3×8×8=192个通道,那样最后一层参数会变大,训练难度也增加。更稳妥的做法是分成两个阶段,先做 2 倍超分再做 4 倍超分,或者先用 ESPCN 做 4 倍,再用一个轻量上采样层继续放大。
亚像素卷积现在也不只在超分里用,很多生成模型、语义分割和姿态估计网络都把 PixelShuffle 当成一种可学习的上采样算子。理解 ESPCN,其实是在理解这一类算子的设计逻辑:上采样可以不是插值,而是让网络为每个输出位置生成对应的一组预测值,再通过排列把它们放到正确的位置上。
我自己跑完这个项目最深的体会是,ESPCN 的门槛不在网络本身,而在“尺度对齐”。很多时候只需要把 PSNR 的归一化范围改对、把 HR 和 LR 的尺寸关系理清、把下采样方式统一,效果立刻就好了。如果你的复现卡住了,优先检查三个位置:最后一层通道数、HR/LR 是否严格成比例、评估代码里的max_val和数据范围是否一致。把这条检查清单刷完,ESPCN 基本就能跑通了。