PyTorch从零搭建SRGAN:生成器、判别器与三层损失实战

发布时间:2026/10/20 20:30:13
PyTorch从零搭建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, channels64): super().__init__() self.conv1 nn.Conv2d(channels, channels, 3, 1, 1) self.bn1 nn.BatchNorm2d(channels) self.relu nn.ReLU(inplaceTrue) 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_resblocks16, upscale_factor4): 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, inplaceTrue), self._block(64, 64, stride2), self._block(64, 128, stride1), self._block(128, 128, stride2), self._block(128, 256, stride1), self._block(256, 256, stride2), self._block(256, 512, stride1), self._block(512, 512, stride2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, 1024), nn.LeakyReLU(0.2, inplaceTrue), 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, inplaceTrue) ) 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_layer35): super().__init__() vgg vgg19(pretrainedTrue) # 保留到第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_factor0.25, modebicubic, align_cornersFalse)。注意在降采样之前最好先做一次轻度的高斯模糊模拟真实场景下低分辨率图像的高频信息损失。这个细节能显著提升模型在真实低清图上的表现——因为真实世界的低分辨率图不是简单插值缩小而是传感器采样的结果天然带有模糊和噪声。数据文件组织成最简单的目录结构就行from torch.utils.data import Dataset, DataLoader from PIL import Image import os class SRDataset(Dataset): def __init__(self, hr_dir, scale4): 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才更新一次参数。例如累积步数为4batch 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, }, fcheckpoint_{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的艺术性就在这里它不只是堆网络层更多的是在“约束”和“自由”之间反复试出来的平衡感。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询