做超分辨率最痛苦的时刻,不是模型不收敛,而是你费尽心思调出来的网络,把人脸超分成了“塑料皮肤”,把文字边缘超分成了毛刺。纯MSE损失训出来的模型,PSNR数值高得好看,可放大一看,细节全被抹平了。SRGAN就是冲着这个痛点来的——它不满足于“像素挨得近”,而是要让生成的高分辨率图像在肉眼观感上真正接近原图。这篇重制版系列的第4篇,我会从零开始,用Pytorch把SRGAN的完整训练链路搭起来,包括生成器结构、判别器设计、三层损失函数的拼配逻辑、数据配对方式,以及我在实际训练中踩过的那些坑。文章只聊能落地的东西:每个关键模块的Pytorch实现、参数为什么要这么设、训练过程怎么判断好坏。适合已经会写基础CNN、开始接触GAN但还没真正端到端训过生成式模型的读者。1. 超分任务里,为什么折腾一圈还是要上GAN很多新手会问:超分辨率不是有双三次插值吗,不是有SRCNN、ESPCN这些轻量网络吗,为什么SRGAN非要引入生成对抗网络?答案很简单:你想要的不是“像素误差最小”,而是“看起来像真的”。这两个目标在数学上并不等价。1.1 传统插值和CNN超分的局限双三次插值的本质是在低分辨率像素之间做平滑的曲面拟合,它只能利用周围有限像素的信息。放大4倍之后,边缘是虚的,纹理是糊的,文字边缘会带一圈灰边。CNN超分模型(比如SRCNN)虽然有可学习的卷积核,但如果只用MSE作为损失函数,网络会倾向于输出“所有可能的高频细节的平均值”——因为像素误差最小的解,在统计上就是模糊解。这就是为什么纯MSE模型训完后,PSNR值高,放大看却像隔了一层毛玻璃。普通CNN超分模型面临的这个天花板,本质是损失函数的信息瓶颈。MSE在每个像素上独立计算误差,它根本不知道什么是“皮肤质感”、什么是“头发丝边缘”。于是模型的策略就是:实在猜不准的高频信息,干脆全部模糊化,这样误差反而小。能不能让模型去学“什么样的高频细节是合理的”?这就是生成对抗网络要回答的问题。1.2 SRGAN给出的解法:把“像不像高清大图”的判断交给网络SRGAN的思路是训练一个判别器网络,让它去区分“真实的高分辨率图像”和“生成器放大的图像”。生成器要想骗过判别器,就必须产出足够逼真的高频细节。这里有个很关键的认知升级:生成器的目标不再是最小化像素误差,而是最小化“被判别器识破的概率”。换句话说,生成器被逼着去“生成”纹理,而不是“回避”纹理。但直接上GAN有个副作用:生成器可能为了骗过判别器而编造出原图不存在的结构,导致图像失真。所以SRGAN没有抛弃像素级别的约束,而是把它和对抗损失、感知损失组合起来用。你可以把这三者理解成一个团队:像素损失负责“大方向别跑偏”,感知损失负责“高层语义特征要像”,对抗损失负责“纹理细节要真实”。1.3 本系列搭建的目标与预期效果这篇重制版要搭建的SRGAN平台,核心是4倍放大。输入一张96x96的低分辨率图,输出一张384x384的高分辨率图。训练数据我会用高分辨率图像先裁切、再降采样,构成低分辨率和高分辨率的配对。整个平台在单张GPU上就能训练,不需要分布式,也不需要特别大的batch。我这边的预期效果是:经SRGAN处理后的图,人眼看着比双三次插值锐利得多,皮肤和布料纹理有真实的颗粒感,而不是像素涂抹。PSNR相比纯MSE模型可能略低,但主观观感要好一个档次。这一点后面会专门解释为什么PSNR和观感会背离。2. 搭建SRGAN前必须搞懂的三个网络组件SRGAN的网络结构并不复杂,核心组件就三个:生成器G、判别器D、预训练VGG特征提取器。搞清楚每个组件为什么这么设计,比背代码重要得多。2.1 生成器:残差块堆叠 + PixelShuffle上采样生成器整体结构分三段:入口卷积、16个残差块、上采样出口。这是SRGAN原论文的经典配置,没有特别花哨的技巧。入口是一个3x3卷积,把3通道输入映射到64通道特征图。中间16个残差块,每个残差块包含两个3x3卷积,卷积之间用BatchNorm,激活函数用ReLU。残差块的用途是让网络在加深的同时保持梯度稳定——如果只用普通卷积堆叠,深层网络的梯度衰减会让特征学习失效。上采样部分是整个生成器设计里最值得讲的地方。SRGAN没有用转置卷积,而是用PixelShuffle(亚像素卷积)。这里要解释清楚为什么:转置卷积有个臭名昭著的问题,叫“棋盘伪影”——当卷积核尺寸不能被步长整除时,输出图像的某些位置会周期性出现亮度不均匀。PixelShuffle的思路是把通道数翻倍,然后重新排列成更高分辨率的图像。比如要2倍放大,就把64通道换成256通道,然后按特定顺序重排成128x128x64的图像。Pytorch里PixelShuffle已经有现成实现,直接调nn.PixelShuffle(2)就行。我贴一下生成器关键代码:import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, channels=64): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, 1, 1) self.bn1 = nn.BatchNorm2d(channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(channels, channels, 3, 1, 1) self.bn2 = nn.BatchNorm2d(channels) def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return out + identity class Generator(nn.Module): def __init__(self, num_resblocks=16, upscale_factor=4): super().__init__() self.entry = nn.Sequential( nn.Conv2d(3, 64, 9, 1, 4), nn.PReLU() ) resblocks = [ResidualBlock(64) for _ in range(num_resblocks)] self.body = nn.Sequential(*resblocks) self.tail = nn.Sequential( nn.Conv2d(64, 64, 3, 1, 1), nn.BatchNorm2d(64) ) # 4倍放大需要两个PixelShuffle(2) upsamplers = [] for _ in range(upscale_factor // 2): upsamplers.append(nn.Conv2d(64, 64 * 2, 3, 1, 1)) upsamplers.append(nn.PixelShuffle(2)) upsamplers.append(nn.PReLU()) self.upsampler = nn.Sequential(*upsamplers) self.output = nn.Conv2d(64, 3, 9, 1, 4) def forward(self, x): feat = self.entry(x) out = self.tail(self.body(feat)) + feat out = self.upsampler(out) return self.output(out)这里有个细节:self.tail的输出要和self.entry的特征做残差连接,这叫全局残差学习。因为超分任务里,低分辨率图和高分辨率图共享大量的低频信息(比如颜色、整体布局),全局残差让网络只需要学“缺失的高频细节”,大大降低学习难度。2.2 判别器:用VGG风格卷积块判断真假判别器采用VGG风格的卷积块设计:卷积层加BatchNorm加LeakyReLU,通道数从64逐层翻倍到512,特征图尺寸逐步缩小,最后通过全连接层输出一个介于0和1之间的真伪概率。判别器的作用不是识别“图像的语义内容”,而是捕捉“高频细节的统计特征”。真实高清图的高频信息是有自然统计规律的,而生成器早期输出的高频是涂抹的、平滑的、重复的,判别器很容易区分。随着训练推进,生成器产出的纹理越来越接近真实分布,判别器的准确率会逐渐向50%逼近,这就是对抗训练进入均衡态的标志。class Discriminator(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.LeakyReLU(0.2, inplace=True), self._block(64, 64, stride=2), self._block(64, 128, stride=1), self._block(128, 128, stride=2), self._block(128, 256, stride=1), self._block(256, 256, stride=2), self._block(256, 512, stride=1), self._block(512, 512, stride=2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, 1024), nn.LeakyReLU(0.2, inplace=True), nn.Linear(1024, 1) ) def _block(self, in_ch, out_ch, stride): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, stride, 1), nn.BatchNorm2d(out_ch), nn.LeakyReLU(0.2, inplace=True) ) def forward(self, x): return self.classifier(self.features(x))注意:判别器里所有激活都用LeakyReLU,负数区间保留一个小斜率,避免梯度直接消失。GAN的判别器如果梯度太弱,生成器得不到有效的反馈信号。2.3 预训练VGG与特征提取:感知损失的基础感知损失是SRGAN另一大支柱。做法很简单:把生成图像和高分辨率原图都送入一个在ImageNet上预训练好的VGG19网络,取中间某一层(常用relu5_4或relu2_2)的特征图,计算两者之间的欧氏距离。这个距离不是像素级的,而是语义特征级的。为什么要这么做?因为人眼判断图像相似,不是逐像素比对,而是看边缘、纹理、结构是否一致。VGG网络在分类任务中被迫学会提取这些高层语义特征,所以它的中间层输出天然就是一个“感知相似度度量空间”。在像素空间差0.1可能感知上完全不同,但在VGG特征空间差0.1可能感知上几乎一致。这也是感知损失能让图像“看起来像”的数学基础。Pytorch里加载预训练VGG19很方便:from torchvision.models import vgg19 class PerceptualLoss(nn.Module): def __init__(self, feature_layer=35): super().__init__() vgg = vgg19(pretrained=True) # 保留到第35层,即relu5_4 self.feature_extractor = nn.Sequential(*list(vgg.features.children())[:feature_layer + 1]) for param in self.feature_extractor.parameters(): param.requires_grad = False self.mse = nn.MSELoss() def forward(self, pred, target): pred_feat = self.feature_extractor(pred) target_feat = self.feature_extractor(target) return self.mse(pred_feat, target_feat)这里有个容易踩的坑:VGG要求输入做特定的归一化,均值和标准差要匹配它在ImageNet上训练时的统计量。如果不做这个归一化就输入VGG,特征提取效果会很差。后面踩坑部分我会专门提。3. 损失函数的三层拼配:这是SRGAN能出效果的灵魂模型结构只是骨架,SRGAN真正厉害的地方在损失函数。把生成器的总损失拆开看,是三个部分按权重组合:L_G = L_content + 1e-3 * L_adversarial + 2e-2 * L_perceptual。权重的设定我会给解释,建议不要大改。3.1 像素损失:保证颜色和基本结构不走样最简单的nn.MSELoss()或者L1损失,直接在生成图像和真实高分辨率图像之间计算。我这边实测下来,L1损失比MSE在边缘上稍微锐利一点,因为L1对异常像素的惩罚不那么激进,不会为了一个像素硬生生把反差抹平。但原论文用的是MSE,所以保留MSE也说得过去,差别在实际观感上不太大。像素损失在训练的前期阶段尤其重要。刚初始化完的生成器输出基本是噪声,这时候如果直接上对抗损失,判别器能瞬间把生成器打得找不到方向。先用像素损失把生成器训到“能看”的水平,是后面所有训练的前提。3.2 感知损失:在高维特征空间里拉近距离感知损失的权重设为2e-2,取VGG19的relu5_4层特征。我的体会是特征层不能选太浅(比如relu2_2),太浅的特征图分辨率高,仍然偏向像素比较;也不能选太深(比如relu5_1之后),太深的特征过于语义化,一张猫的照片和一个狗的素描在深层特征上可能很接近,图像内容对不上会有问题。relu5_4是原论文验证过的平衡点。感知损失的具体实现我在2.3节已经给过代码。需要补充的是,VGG特征提取器的输入要归一化到ImageNet的统计范围,即每个通道减去[0.485, 0.456, 0.406]再除以[0.229, 0.224, 0.225]。实测跳过了这一步,感知损失的值会偏高,训出来的图偏灰。3.3 对抗损失:逼生成器把纹理细节“脑补”出来对抗损失用的是BCE损失,关键在于目标标签的设计。判别器要区分真实高清图和生成图,所以判别器的训练目标有两部分:真实图应该被判为1,生成图应该被判为0。生成器则要反过来——希望判别器把生成图判为1。Pytorch里的实现,生成器的对抗损失通常是:bce = nn.BCEWithLogitsLoss() # 生成器希望判别器把生成图认出为真,所以标签全置1 g_adv_loss = bce(d_out_for_fake, torch.ones_like(d_out_for_fake))对抗损失的权重是1e-3,这是原论文调出来的数值。这个权重很小,因为对抗损失只是“调味剂”,像素损失和感知损失才是“主菜”。如果对抗损失权重太大,图像会失真,出现乱七八糟的高频纹理。后来ESRGAN把对抗损失换成了相对论版本,效果更好,但那是后话。3.4 损失权重与训练节奏训练节奏这块,我很建议分阶段走。第一阶段:冻结判别器,只用像素损失和感知损失训练生成器,训练几十个epoch,让生成器先把整体结构做对。第二阶段:解冻判别器,加入对抗损失,三者联合训练,这是真正进入对抗博弈的阶段。为什么要分阶段?如果一开始就三者齐上,判别器训得太好,生成器梯度像噪声,残差块学不到有效特征。我见过很多新手在这里翻车:loss曲线往下走,但生成图像一直是糊的,就是因为生成器从来没得到稳定有效的梯度信号。4. 数据准备与训练流程:从图片到可推理的权重文件数据准备是SRGAN训练里最容易被低估的一环。大多数NTIRE竞赛类数据集里的高分辨率图像边长都在1024像素以上,你不能直接把整张图塞进网络,显存扛不住,也没必要,因为超分任务的训练本质是学习局部纹理的重建规律。4.1 数据集的构建思路:高分辨率图切块 + 降采样配对我的做法是先把所有高清图切成384x384的小块,然后通过4倍降采样得到96x96的低分辨率图,构成配对样本。为什么要用384而不是更大的尺寸?因为训练时batch size如果有16,输入是96x96,经过生成器放大到384x384,再进判别器,显存压力主要在判别器这边。384x384近似是个甜点值,再大显存会很紧张。降采样建议用双三次插值加抗锯齿。实际操作可以用torch.nn.functional.interpolate(scale_factor=0.25, mode='bicubic', align_corners=False)。注意在降采样之前最好先做一次轻度的高斯模糊,模拟真实场景下低分辨率图像的高频信息损失。这个细节能显著提升模型在真实低清图上的表现——因为真实世界的低分辨率图不是简单插值缩小,而是传感器采样的结果,天然带有模糊和噪声。数据文件组织成最简单的目录结构就行:from torch.utils.data import Dataset, DataLoader from PIL import Image import os class SRDataset(Dataset): def __init__(self, hr_dir, scale=4): self.hr_paths = [os.path.join(hr_dir, f) for f in os.listdir(hr_dir)] self.scale = scale def __len__(self): return len(self.hr_paths) def __getitem__(self, idx): hr = Image.open(self.hr_paths[idx]).convert('RGB') # 随机裁剪384x384 w, h = hr.size x = random.randint(0, w - 384) y = random.randint(0, h - 384) hr_crop = hr.crop((x, y, x + 384, y + 384)) # 降采样得到低分辨率图 lr = hr_crop.resize((384 // self.scale, 384 // self.scale), Image.BICUBIC) # 转Tensor、归一化 hr_tensor = transforms.ToTensor()(hr_crop) lr_tensor = transforms.ToTensor()(lr) return lr_tensor, hr_tensor训练时每个epoch对同一张图随机裁不同位置,相当于天然数据增强。我实测下来,不额外做翻转和旋转增强问题也不大,因为超分任务对旋转比较敏感,随机裁剪已经提供了足够的样本多样性。4.2 训练参数与显存优化优化器我选Adam,生成器和判别器分开设置学习率。生成器的学习率建议1e-4,判别器建议5e-5——判别器比生成器容易学,给它更小的学习率能避免它“跑太快”。beta参数保持默认:(0.9, 0.999)。显存优化方面,我踩过一次教训:batch size设32,直接用384x384的真实高分辨率图做输入,结果判别器前向传播直接OOM。后来把batch size降到16,才跑起来。如果你的显卡显存小于等于8GB,建议batch size用8或者4,同时配合梯度累积。梯度累积的实现很简单:正常计算loss,然后loss.backward(),但不立刻optimizer.step(),每隔几个step才更新一次参数。例如累积步数为4,batch size为4,等效batch size是16,显存却只有4份的占用。还有一个训练细节:先用小分辨率预热,比如先用128x128的高分辨率图训练10个epoch,再切到384x384。这样可以快速检验整套流程是否跑得通,避免一开始就卡在显存或维度报错上。4.3 完整训练流程与检查点保存训练循环的骨架很直接:for epoch in range(start_epoch, total_epochs): for i, (lr, hr) in enumerate(train_loader): lr, hr = lr.to(device), hr.to(device) # 生成器输出 sr = generator(lr) # 判别器更新 optimizer_D.zero_grad() d_real = discriminator(hr) d_fake = discriminator(sr.detach()) d_loss = bce(d_real, real_labels) + bce(d_fake, fake_labels) d_loss.backward() optimizer_D.step() # 生成器更新 optimizer_G.zero_grad() d_fake = discriminator(sr) g_adv = bce(d_fake, real_labels) g_pixel = l1_loss(sr, hr) g_percep = perceptual_loss(sr, hr) g_loss = g_pixel + 2e-2 * g_percep + 1e-3 * g_adv g_loss.backward() optimizer_G.step()检查点保存不能只存模型权重,建议把优化器状态、epoch、学习率都打包存起来,方便中断后恢复训练。保存格式我习惯用一个字典:torch.save({ 'generator': generator.state_dict(), 'discriminator': discriminator.state_dict(), 'optimizer_G': optimizer_G.state_dict(), 'optimizer_D': optimizer_D.state_dict(), 'epoch': epoch, 'best_psnr': best_psnr, }, f'checkpoint_{epoch}.pth')每隔几个epoch在验证集上跑一次,保存验证指标最高的权重,这是防止后期过拟合导致模型变差的常用手段。5. 实测效果与踩坑记录:训练不起来才是常态这个项目我从零开始到跑通,大概折腾了三周。中间踩的坑一个比一个典型,我把完整的排查链路写下来,你照着走能省好几周。5.1 判别器过强导致生成器“摆烂”第一个大坑是训练不稳定。前10个epoch还挺正常,到了后面生成器loss开始震荡,输出图像出现明显的颜色偏绿。排查下来发现是判别器学得太快,已经能把所有生成图都识破,生成器的对抗梯度几乎为0,于是生成器只能依赖像素损失硬撑着,最终输出向“平均色”靠拢。解决的完整链路是这样的:先把判别器的学习率从1e-4降到5e-5,同时把判别器每训练1次改为每训练2次生成器才对判别器更新1次。再把判别器最后一层之前加了一个nn.Dropout(0.2),降低判别器的过拟合。三个措施叠加之后,训练曲线才稳定下来。另外一个有效的小技巧是标签平滑:判别器的真实标签不用严格的1,而是0.9到1.0之间的随机值;假标签用0到0.1之间的随机值。这让判别器不会过度自信,给生成器留出生长的空间。5.2 棋盘伪影:先检查上采样实现,再考虑数据侧训练稳定之后,我在第30个epoch左右发现输出图像有轻微的棋盘格纹理,尤其在平坦区域很明显。第一反应是怀疑PixelShuffle写错了。排查下来代码没问题,问题出在输入数据的归一化方式上——我的低分辨率图像直接从0到255归一化到-1到1,但高分辨率图没有做同样的处理,两边数值范围不一致,导致生成器学到的映射出现了周期性偏差。把高分辨率图和低分辨率图统一用(x / 127.5) - 1归一化到[-1, 1]之后,棋盘伪影消失了。这个坑比较隐蔽,建议你检查数据预处理时不要只看生成器输出,而是先确认你的训练对(lr, hr)数值分布是否一致。5.3 PSNR高不代表观感好:评价指标的选择训练后期我在验证集上观察到:PSNR在第40个epoch左右达到峰值,之后就缓慢下降。如果只盯着PSNR,你会觉得“模型退化了”。实际上输出图像的观感在持续变好——皮肤的毛孔、树叶的脉络、织物纹理越来越细腻。这是GAN超分的典型现象:PSNR和主观质量并不严格相关。我建议校验模型效果时,除了PSNR和SSIM这两个传统指标,至少加一个主观对比:把同一条验证图上双三次插值、纯MSE模型、SRGAN模型的输出并排放大对比。真实地看一眼皮肤纹理、边缘振铃、细节真实感,比任何数字都直观。如果观感提升了,PSNR掉一点完全不用慌。5.4 推理与部署阶段:别忽略预处理对齐模型训练完,推理阶段最容易出的问题就是预处理不一致。训练时低分辨率图是用双三次插值直接缩小,推理时如果你的输入是从手机拍的现有低清图,它的降质过程和训练数据不一样,效果会打折扣。这种情况我常用的应对:推理前先对输入图做一次轻微锐化,把边缘信息补一点,再送进网络。另外,如果你的输入图不是4的倍数尺寸,建议先外围填充到4的倍数,推理后再裁回原尺寸。否则BatchNorm层或PixelShuffle层容易在边界出现artifact。Pytorch中可以用torch.nn.functional.pad做反射填充,比零填充效果好。最后分享一个实际训练时的小习惯:我在整个训练过程中,每5个epoch就把验证集上的一组样图导出到本地,按epoch编号排列好。这么做的好处是你回头翻历史结果时,能直观地看到模型能力变化的轨迹——哪个epoch开始纹理变得真实,哪个epoch开始颜色偏移,这种时间线上的视觉反馈比盯着一堆loss曲线可靠得多。SRGAN的艺术性就在这里,它不只是堆网络层,更多的是在“约束”和“自由”之间反复试出来的平衡感。
做超分辨率最痛苦的时刻,不是模型不收敛,而是你费尽心思调出来的网络,把人脸超分成了“塑料皮肤”,把文字边缘超分成了毛刺。纯MSE损失训出来的模型,PSNR数值高得好看,可放大一看,细节全被抹平了。SRGAN就是冲着这个痛点来的——它不满足于“像素挨得近”,而是要让生成的高分辨率图像在肉眼观感上真正接近原图。这篇重制版系列的第4篇,我会从零开始,用Pytorch把SRGAN的完整训练链路搭起来,包括生成器结构、判别器设计、三层损失函数的拼配逻辑、数据配对方式,以及我在实际训练中踩过的那些坑。文章只聊能落地的东西:每个关键模块的Pytorch实现、参数为什么要这么设、训练过程怎么判断好坏。适合已经会写基础CNN、开始接触GAN但还没真正端到端训过生成式模型的读者。1. 超分任务里,为什么折腾一圈还是要上GAN很多新手会问:超分辨率不是有双三次插值吗,不是有SRCNN、ESPCN这些轻量网络吗,为什么SRGAN非要引入生成对抗网络?答案很简单:你想要的不是“像素误差最小”,而是“看起来像真的”。这两个目标在数学上并不等价。1.1 传统插值和CNN超分的局限双三次插值的本质是在低分辨率像素之间做平滑的曲面拟合,它只能利用周围有限像素的信息。放大4倍之后,边缘是虚的,纹理是糊的,文字边缘会带一圈灰边。CNN超分模型(比如SRCNN)虽然有可学习的卷积核,但如果只用MSE作为损失函数,网络会倾向于输出“所有可能的高频细节的平均值”——因为像素误差最小的解,在统计上就是模糊解。这就是为什么纯MSE模型训完后,PSNR值高,放大看却像隔了一层毛玻璃。普通CNN超分模型面临的这个天花板,本质是损失函数的信息瓶颈。MSE在每个像素上独立计算误差,它根本不知道什么是“皮肤质感”、什么是“头发丝边缘”。于是模型的策略就是:实在猜不准的高频信息,干脆全部模糊化,这样误差反而小。能不能让模型去学“什么样的高频细节是合理的”?这就是生成对抗网络要回答的问题。1.2 SRGAN给出的解法:把“像不像高清大图”的判断交给网络SRGAN的思路是训练一个判别器网络,让它去区分“真实的高分辨率图像”和“生成器放大的图像”。生成器要想骗过判别器,就必须产出足够逼真的高频细节。这里有个很关键的认知升级:生成器的目标不再是最小化像素误差,而是最小化“被判别器识破的概率”。换句话说,生成器被逼着去“生成”纹理,而不是“回避”纹理。但直接上GAN有个副作用:生成器可能为了骗过判别器而编造出原图不存在的结构,导致图像失真。所以SRGAN没有抛弃像素级别的约束,而是把它和对抗损失、感知损失组合起来用。你可以把这三者理解成一个团队:像素损失负责“大方向别跑偏”,感知损失负责“高层语义特征要像”,对抗损失负责“纹理细节要真实”。1.3 本系列搭建的目标与预期效果这篇重制版要搭建的SRGAN平台,核心是4倍放大。输入一张96x96的低分辨率图,输出一张384x384的高分辨率图。训练数据我会用高分辨率图像先裁切、再降采样,构成低分辨率和高分辨率的配对。整个平台在单张GPU上就能训练,不需要分布式,也不需要特别大的batch。我这边的预期效果是:经SRGAN处理后的图,人眼看着比双三次插值锐利得多,皮肤和布料纹理有真实的颗粒感,而不是像素涂抹。PSNR相比纯MSE模型可能略低,但主观观感要好一个档次。这一点后面会专门解释为什么PSNR和观感会背离。2. 搭建SRGAN前必须搞懂的三个网络组件SRGAN的网络结构并不复杂,核心组件就三个:生成器G、判别器D、预训练VGG特征提取器。搞清楚每个组件为什么这么设计,比背代码重要得多。2.1 生成器:残差块堆叠 + PixelShuffle上采样生成器整体结构分三段:入口卷积、16个残差块、上采样出口。这是SRGAN原论文的经典配置,没有特别花哨的技巧。入口是一个3x3卷积,把3通道输入映射到64通道特征图。中间16个残差块,每个残差块包含两个3x3卷积,卷积之间用BatchNorm,激活函数用ReLU。残差块的用途是让网络在加深的同时保持梯度稳定——如果只用普通卷积堆叠,深层网络的梯度衰减会让特征学习失效。上采样部分是整个生成器设计里最值得讲的地方。SRGAN没有用转置卷积,而是用PixelShuffle(亚像素卷积)。这里要解释清楚为什么:转置卷积有个臭名昭著的问题,叫“棋盘伪影”——当卷积核尺寸不能被步长整除时,输出图像的某些位置会周期性出现亮度不均匀。PixelShuffle的思路是把通道数翻倍,然后重新排列成更高分辨率的图像。比如要2倍放大,就把64通道换成256通道,然后按特定顺序重排成128x128x64的图像。Pytorch里PixelShuffle已经有现成实现,直接调nn.PixelShuffle(2)就行。我贴一下生成器关键代码:import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, channels=64): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, 1, 1) self.bn1 = nn.BatchNorm2d(channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(channels, channels, 3, 1, 1) self.bn2 = nn.BatchNorm2d(channels) def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) return out + identity class Generator(nn.Module): def __init__(self, num_resblocks=16, upscale_factor=4): super().__init__() self.entry = nn.Sequential( nn.Conv2d(3, 64, 9, 1, 4), nn.PReLU() ) resblocks = [ResidualBlock(64) for _ in range(num_resblocks)] self.body = nn.Sequential(*resblocks) self.tail = nn.Sequential( nn.Conv2d(64, 64, 3, 1, 1), nn.BatchNorm2d(64) ) # 4倍放大需要两个PixelShuffle(2) upsamplers = [] for _ in range(upscale_factor // 2): upsamplers.append(nn.Conv2d(64, 64 * 2, 3, 1, 1)) upsamplers.append(nn.PixelShuffle(2)) upsamplers.append(nn.PReLU()) self.upsampler = nn.Sequential(*upsamplers) self.output = nn.Conv2d(64, 3, 9, 1, 4) def forward(self, x): feat = self.entry(x) out = self.tail(self.body(feat)) + feat out = self.upsampler(out) return self.output(out)这里有个细节:self.tail的输出要和self.entry的特征做残差连接,这叫全局残差学习。因为超分任务里,低分辨率图和高分辨率图共享大量的低频信息(比如颜色、整体布局),全局残差让网络只需要学“缺失的高频细节”,大大降低学习难度。2.2 判别器:用VGG风格卷积块判断真假判别器采用VGG风格的卷积块设计:卷积层加BatchNorm加LeakyReLU,通道数从64逐层翻倍到512,特征图尺寸逐步缩小,最后通过全连接层输出一个介于0和1之间的真伪概率。判别器的作用不是识别“图像的语义内容”,而是捕捉“高频细节的统计特征”。真实高清图的高频信息是有自然统计规律的,而生成器早期输出的高频是涂抹的、平滑的、重复的,判别器很容易区分。随着训练推进,生成器产出的纹理越来越接近真实分布,判别器的准确率会逐渐向50%逼近,这就是对抗训练进入均衡态的标志。class Discriminator(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.LeakyReLU(0.2, inplace=True), self._block(64, 64, stride=2), self._block(64, 128, stride=1), self._block(128, 128, stride=2), self._block(128, 256, stride=1), self._block(256, 256, stride=2), self._block(256, 512, stride=1), self._block(512, 512, stride=2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, 1024), nn.LeakyReLU(0.2, inplace=True), nn.Linear(1024, 1) ) def _block(self, in_ch, out_ch, stride): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, stride, 1), nn.BatchNorm2d(out_ch), nn.LeakyReLU(0.2, inplace=True) ) def forward(self, x): return self.classifier(self.features(x))注意:判别器里所有激活都用LeakyReLU,负数区间保留一个小斜率,避免梯度直接消失。GAN的判别器如果梯度太弱,生成器得不到有效的反馈信号。2.3 预训练VGG与特征提取:感知损失的基础感知损失是SRGAN另一大支柱。做法很简单:把生成图像和高分辨率原图都送入一个在ImageNet上预训练好的VGG19网络,取中间某一层(常用relu5_4或relu2_2)的特征图,计算两者之间的欧氏距离。这个距离不是像素级的,而是语义特征级的。为什么要这么做?因为人眼判断图像相似,不是逐像素比对,而是看边缘、纹理、结构是否一致。VGG网络在分类任务中被迫学会提取这些高层语义特征,所以它的中间层输出天然就是一个“感知相似度度量空间”。在像素空间差0.1可能感知上完全不同,但在VGG特征空间差0.1可能感知上几乎一致。这也是感知损失能让图像“看起来像”的数学基础。Pytorch里加载预训练VGG19很方便:from torchvision.models import vgg19 class PerceptualLoss(nn.Module): def __init__(self, feature_layer=35): super().__init__() vgg = vgg19(pretrained=True) # 保留到第35层,即relu5_4 self.feature_extractor = nn.Sequential(*list(vgg.features.children())[:feature_layer + 1]) for param in self.feature_extractor.parameters(): param.requires_grad = False self.mse = nn.MSELoss() def forward(self, pred, target): pred_feat = self.feature_extractor(pred) target_feat = self.feature_extractor(target) return self.mse(pred_feat, target_feat)这里有个容易踩的坑:VGG要求输入做特定的归一化,均值和标准差要匹配它在ImageNet上训练时的统计量。如果不做这个归一化就输入VGG,特征提取效果会很差。后面踩坑部分我会专门提。3. 损失函数的三层拼配:这是SRGAN能出效果的灵魂模型结构只是骨架,SRGAN真正厉害的地方在损失函数。把生成器的总损失拆开看,是三个部分按权重组合:L_G = L_content + 1e-3 * L_adversarial + 2e-2 * L_perceptual。权重的设定我会给解释,建议不要大改。3.1 像素损失:保证颜色和基本结构不走样最简单的nn.MSELoss()或者L1损失,直接在生成图像和真实高分辨率图像之间计算。我这边实测下来,L1损失比MSE在边缘上稍微锐利一点,因为L1对异常像素的惩罚不那么激进,不会为了一个像素硬生生把反差抹平。但原论文用的是MSE,所以保留MSE也说得过去,差别在实际观感上不太大。像素损失在训练的前期阶段尤其重要。刚初始化完的生成器输出基本是噪声,这时候如果直接上对抗损失,判别器能瞬间把生成器打得找不到方向。先用像素损失把生成器训到“能看”的水平,是后面所有训练的前提。3.2 感知损失:在高维特征空间里拉近距离感知损失的权重设为2e-2,取VGG19的relu5_4层特征。我的体会是特征层不能选太浅(比如relu2_2),太浅的特征图分辨率高,仍然偏向像素比较;也不能选太深(比如relu5_1之后),太深的特征过于语义化,一张猫的照片和一个狗的素描在深层特征上可能很接近,图像内容对不上会有问题。relu5_4是原论文验证过的平衡点。感知损失的具体实现我在2.3节已经给过代码。需要补充的是,VGG特征提取器的输入要归一化到ImageNet的统计范围,即每个通道减去[0.485, 0.456, 0.406]再除以[0.229, 0.224, 0.225]。实测跳过了这一步,感知损失的值会偏高,训出来的图偏灰。3.3 对抗损失:逼生成器把纹理细节“脑补”出来对抗损失用的是BCE损失,关键在于目标标签的设计。判别器要区分真实高清图和生成图,所以判别器的训练目标有两部分:真实图应该被判为1,生成图应该被判为0。生成器则要反过来——希望判别器把生成图判为1。Pytorch里的实现,生成器的对抗损失通常是:bce = nn.BCEWithLogitsLoss() # 生成器希望判别器把生成图认出为真,所以标签全置1 g_adv_loss = bce(d_out_for_fake, torch.ones_like(d_out_for_fake))对抗损失的权重是1e-3,这是原论文调出来的数值。这个权重很小,因为对抗损失只是“调味剂”,像素损失和感知损失才是“主菜”。如果对抗损失权重太大,图像会失真,出现乱七八糟的高频纹理。后来ESRGAN把对抗损失换成了相对论版本,效果更好,但那是后话。3.4 损失权重与训练节奏训练节奏这块,我很建议分阶段走。第一阶段:冻结判别器,只用像素损失和感知损失训练生成器,训练几十个epoch,让生成器先把整体结构做对。第二阶段:解冻判别器,加入对抗损失,三者联合训练,这是真正进入对抗博弈的阶段。为什么要分阶段?如果一开始就三者齐上,判别器训得太好,生成器梯度像噪声,残差块学不到有效特征。我见过很多新手在这里翻车:loss曲线往下走,但生成图像一直是糊的,就是因为生成器从来没得到稳定有效的梯度信号。4. 数据准备与训练流程:从图片到可推理的权重文件数据准备是SRGAN训练里最容易被低估的一环。大多数NTIRE竞赛类数据集里的高分辨率图像边长都在1024像素以上,你不能直接把整张图塞进网络,显存扛不住,也没必要,因为超分任务的训练本质是学习局部纹理的重建规律。4.1 数据集的构建思路:高分辨率图切块 + 降采样配对我的做法是先把所有高清图切成384x384的小块,然后通过4倍降采样得到96x96的低分辨率图,构成配对样本。为什么要用384而不是更大的尺寸?因为训练时batch size如果有16,输入是96x96,经过生成器放大到384x384,再进判别器,显存压力主要在判别器这边。384x384近似是个甜点值,再大显存会很紧张。降采样建议用双三次插值加抗锯齿。实际操作可以用torch.nn.functional.interpolate(scale_factor=0.25, mode='bicubic', align_corners=False)。注意在降采样之前最好先做一次轻度的高斯模糊,模拟真实场景下低分辨率图像的高频信息损失。这个细节能显著提升模型在真实低清图上的表现——因为真实世界的低分辨率图不是简单插值缩小,而是传感器采样的结果,天然带有模糊和噪声。数据文件组织成最简单的目录结构就行:from torch.utils.data import Dataset, DataLoader from PIL import Image import os class SRDataset(Dataset): def __init__(self, hr_dir, scale=4): self.hr_paths = [os.path.join(hr_dir, f) for f in os.listdir(hr_dir)] self.scale = scale def __len__(self): return len(self.hr_paths) def __getitem__(self, idx): hr = Image.open(self.hr_paths[idx]).convert('RGB') # 随机裁剪384x384 w, h = hr.size x = random.randint(0, w - 384) y = random.randint(0, h - 384) hr_crop = hr.crop((x, y, x + 384, y + 384)) # 降采样得到低分辨率图 lr = hr_crop.resize((384 // self.scale, 384 // self.scale), Image.BICUBIC) # 转Tensor、归一化 hr_tensor = transforms.ToTensor()(hr_crop) lr_tensor = transforms.ToTensor()(lr) return lr_tensor, hr_tensor训练时每个epoch对同一张图随机裁不同位置,相当于天然数据增强。我实测下来,不额外做翻转和旋转增强问题也不大,因为超分任务对旋转比较敏感,随机裁剪已经提供了足够的样本多样性。4.2 训练参数与显存优化优化器我选Adam,生成器和判别器分开设置学习率。生成器的学习率建议1e-4,判别器建议5e-5——判别器比生成器容易学,给它更小的学习率能避免它“跑太快”。beta参数保持默认:(0.9, 0.999)。显存优化方面,我踩过一次教训:batch size设32,直接用384x384的真实高分辨率图做输入,结果判别器前向传播直接OOM。后来把batch size降到16,才跑起来。如果你的显卡显存小于等于8GB,建议batch size用8或者4,同时配合梯度累积。梯度累积的实现很简单:正常计算loss,然后loss.backward(),但不立刻optimizer.step(),每隔几个step才更新一次参数。例如累积步数为4,batch size为4,等效batch size是16,显存却只有4份的占用。还有一个训练细节:先用小分辨率预热,比如先用128x128的高分辨率图训练10个epoch,再切到384x384。这样可以快速检验整套流程是否跑得通,避免一开始就卡在显存或维度报错上。4.3 完整训练流程与检查点保存训练循环的骨架很直接:for epoch in range(start_epoch, total_epochs): for i, (lr, hr) in enumerate(train_loader): lr, hr = lr.to(device), hr.to(device) # 生成器输出 sr = generator(lr) # 判别器更新 optimizer_D.zero_grad() d_real = discriminator(hr) d_fake = discriminator(sr.detach()) d_loss = bce(d_real, real_labels) + bce(d_fake, fake_labels) d_loss.backward() optimizer_D.step() # 生成器更新 optimizer_G.zero_grad() d_fake = discriminator(sr) g_adv = bce(d_fake, real_labels) g_pixel = l1_loss(sr, hr) g_percep = perceptual_loss(sr, hr) g_loss = g_pixel + 2e-2 * g_percep + 1e-3 * g_adv g_loss.backward() optimizer_G.step()检查点保存不能只存模型权重,建议把优化器状态、epoch、学习率都打包存起来,方便中断后恢复训练。保存格式我习惯用一个字典:torch.save({ 'generator': generator.state_dict(), 'discriminator': discriminator.state_dict(), 'optimizer_G': optimizer_G.state_dict(), 'optimizer_D': optimizer_D.state_dict(), 'epoch': epoch, 'best_psnr': best_psnr, }, f'checkpoint_{epoch}.pth')每隔几个epoch在验证集上跑一次,保存验证指标最高的权重,这是防止后期过拟合导致模型变差的常用手段。5. 实测效果与踩坑记录:训练不起来才是常态这个项目我从零开始到跑通,大概折腾了三周。中间踩的坑一个比一个典型,我把完整的排查链路写下来,你照着走能省好几周。5.1 判别器过强导致生成器“摆烂”第一个大坑是训练不稳定。前10个epoch还挺正常,到了后面生成器loss开始震荡,输出图像出现明显的颜色偏绿。排查下来发现是判别器学得太快,已经能把所有生成图都识破,生成器的对抗梯度几乎为0,于是生成器只能依赖像素损失硬撑着,最终输出向“平均色”靠拢。解决的完整链路是这样的:先把判别器的学习率从1e-4降到5e-5,同时把判别器每训练1次改为每训练2次生成器才对判别器更新1次。再把判别器最后一层之前加了一个nn.Dropout(0.2),降低判别器的过拟合。三个措施叠加之后,训练曲线才稳定下来。另外一个有效的小技巧是标签平滑:判别器的真实标签不用严格的1,而是0.9到1.0之间的随机值;假标签用0到0.1之间的随机值。这让判别器不会过度自信,给生成器留出生长的空间。5.2 棋盘伪影:先检查上采样实现,再考虑数据侧训练稳定之后,我在第30个epoch左右发现输出图像有轻微的棋盘格纹理,尤其在平坦区域很明显。第一反应是怀疑PixelShuffle写错了。排查下来代码没问题,问题出在输入数据的归一化方式上——我的低分辨率图像直接从0到255归一化到-1到1,但高分辨率图没有做同样的处理,两边数值范围不一致,导致生成器学到的映射出现了周期性偏差。把高分辨率图和低分辨率图统一用(x / 127.5) - 1归一化到[-1, 1]之后,棋盘伪影消失了。这个坑比较隐蔽,建议你检查数据预处理时不要只看生成器输出,而是先确认你的训练对(lr, hr)数值分布是否一致。5.3 PSNR高不代表观感好:评价指标的选择训练后期我在验证集上观察到:PSNR在第40个epoch左右达到峰值,之后就缓慢下降。如果只盯着PSNR,你会觉得“模型退化了”。实际上输出图像的观感在持续变好——皮肤的毛孔、树叶的脉络、织物纹理越来越细腻。这是GAN超分的典型现象:PSNR和主观质量并不严格相关。我建议校验模型效果时,除了PSNR和SSIM这两个传统指标,至少加一个主观对比:把同一条验证图上双三次插值、纯MSE模型、SRGAN模型的输出并排放大对比。真实地看一眼皮肤纹理、边缘振铃、细节真实感,比任何数字都直观。如果观感提升了,PSNR掉一点完全不用慌。5.4 推理与部署阶段:别忽略预处理对齐模型训练完,推理阶段最容易出的问题就是预处理不一致。训练时低分辨率图是用双三次插值直接缩小,推理时如果你的输入是从手机拍的现有低清图,它的降质过程和训练数据不一样,效果会打折扣。这种情况我常用的应对:推理前先对输入图做一次轻微锐化,把边缘信息补一点,再送进网络。另外,如果你的输入图不是4的倍数尺寸,建议先外围填充到4的倍数,推理后再裁回原尺寸。否则BatchNorm层或PixelShuffle层容易在边界出现artifact。Pytorch中可以用torch.nn.functional.pad做反射填充,比零填充效果好。最后分享一个实际训练时的小习惯:我在整个训练过程中,每5个epoch就把验证集上的一组样图导出到本地,按epoch编号排列好。这么做的好处是你回头翻历史结果时,能直观地看到模型能力变化的轨迹——哪个epoch开始纹理变得真实,哪个epoch开始颜色偏移,这种时间线上的视觉反馈比盯着一堆loss曲线可靠得多。SRGAN的艺术性就在这里,它不只是堆网络层,更多的是在“约束”和“自由”之间反复试出来的平衡感。