资讯动态

Pix2Pix图像翻译原理与PyTorch实现:U-Net与PatchGAN详解

发布时间:2026/10/16 8:36:58 来源:尧图企业网站定制
GAN这个系列写到第9篇终于轮到Pix2Pix了。如果你一路跟过来应该已经看过基础GAN、DCGAN、CGAN这些但Pix2Pix的观感会完全不同——它不再只是从噪声生成一张图而是把输入图像翻译成输出图像。黑白图上色、线稿填色、卫星图转地图、语义分割图转街景实拍这些任务在Pix2Pix之前都要靠复杂的pipeline手工拼装而Pix2Pix把所有这类问题统一成了一个框架。这篇我就集中讲两件事Pix2Pix的原理设计为什么是这样以及用PyTorch基本实现时每一步到底该怎么写。文中所有实现代码都是可以直接跑通的朴素版本我会尽量把为什么这么做说清楚。1. 图像翻译为什么需要Pix2Pix这类成对GAN1.1 从凭空生成到按输入翻译普通的GAN生成器接收的是一个随机噪声向量输出一张图片。换句话说生成器只能从隐空间采样它对输出图像的控制力很弱——我们没法决定这张图里应该是一个戴帽子的人背景是晴天还是阴天。Pix2Pix把这个问题换了一种问法输入一张图输出一张对应的图。比如输入灰度图输出彩色图输入建筑物轮廓的线稿输出带纹理的实景图。这本质上是一个从像素到像素的映射所以论文里给了一个很形象的名字图像翻译Image-to-Image Translation。这里有个关键的认知转变它不再把图像生成看成从噪声创建像素而是看成在给定条件下重新绘制一张图。输入图像本身就是最强的条件信息生成器要做的不是无中生有而是根据输入的语义骨架和纹理暗示补全细节。我最初接触这个任务时最大的误解是图像翻译是不是就是做一个编解码器输入输出过一遍就行实际远没这么简单。单纯用编解码器比如一个卷积自编码器输出通常会非常模糊因为逐像素的L2损失会让模型倾向于对所有可能的输出取平均——而平均往往等于模糊。1.2 成对数据的监督与对抗损失的组合Pix2Pix是监督学习框架需要成对的训练数据同一场景的输入图与期望输出图。比如做灰度图上色训练集里每一张灰度图都对应它的彩色原图。模型在训练时能直接看到正确答案这比普通GAN那种只能判断真假的无监督方式信息量大得多。但问题来了既然有监督、有L1或L2的逐像素损失为什么还要引入GAN的对抗损失只做逐像素损失的问题有两个输出模糊。因为平均值往往是最小化L2的合理答案但用户要的是一张锐利的图。缺乏感知真实感。逐像素损失把每个像素当成独立统计单元完全不在意纹理、边界这些人类视觉最敏感的特征。对抗损失正好补上这块。判别器会整体性地判断这张图看着像不像真的生成器为了骗过判别器必须去生成锐利的边缘、合理的纹理、符合结构逻辑的内容。逐像素损失管对不对对抗损失管像不像这就是Pix2Pix的核心思想。从我的训练经验看两者缺一不可只用L1损失结果是一团柔光滤镜只用对抗损失结果可能出现明显错误的结构比如该是圆的地方生成方形因为判别器只看整体风格和真实度并不理解每个位置该填什么。2. 生成器与判别器的设计逻辑U-Net和PatchGANPix2Pix最值得琢磨的其实不是用了GAN这件事而是它对生成器和判别器的两个关键设计生成器用U-Net而不是普通编解码器判别器用PatchGAN而不是整图二分类。这两个设计都指向同一个问题图像翻译需要同时保住全局结构和局部细节而朴素的GAN结构管不住细节。2.1 U-Net生成器跳跃连接解决信息过窄问题如果是朴素编解码器结构输入图像经过一层层下采样变成小特征图再一层层上采样恢复分辨率。这个过程中大量浅层信息——边缘、角点、颜色块——在逐层压缩后不可避免地被丢弃。最终生成器只能凭想象重建细节。U-Net做的事情非常直观在编码器的第i层和解码器的第i层之间加一条跳跃连接Skip Connection。特征图在拼接过之后解码器既能拿到高层语义知道这里是一个建筑又能拿到浅层细节知道这个建筑的边缘走向、窗户密度。这对图像翻译任务简直量身定做。用一个生活类比这就像你让一个画师临摹一幅画你不仅告诉他这是中世纪城堡高层语义还直接给他看细节特写浅层特征他当然画得更准。没有跳跃连接的画师只能凭想象补细节结果就是大方向对、细节全糊。2.2 PatchGAN判别器只看局部反而顾全大局再来看判别器。传统判别器输出的是一整个标量真或假。这意味着它对整张输入图做一个全局判断模型必须将所有信息压到一个值里。这样做的后果是生成器只要把全局结构做得差不多局部瑕疵判别器根本看不过来因为它对整个图的某个局部细节敏感度很低。Pix2Pix的解法是把判别器改成全卷积结构输出不再是一个标量而是一个N×N的特征图——通常写作PatchGAN。通俗地说判别器对输入图像中每个局部patch独立判断真假最终输出对每个patch的真/假打分。我最初看到PatchGAN时有个疑问局部判断会不会导致生成的图像局部看起来很真、整体却错位实际训练下来发现不会原因在于两点每个patch的感受野receptive field是有重叠的相邻patch的判断互相制约不会出现完全孤立的自洽局部。全局一致性由L1损失兜底PatchGAN只管让每个局部纹理都逼近真实。论文里这种设计用一个很经典的尺寸解释70×70的感受野。也就是判别器最终每个输出神经元看过输入图像的70×70区域。这个大小对大多数图像翻译任务都够用既不会太小导致只关注像素噪点也不会太大退化成普通判别器。让我用一个判断是不是人的例子帮助理解普通判别器相当于只看一眼整张照片说像人或不像人PatchGAN相当于把照片切成很多块逐块问这块皮肤真不真、这块头发真不真如果所有局部都真整张照片当然就真。2.3 损失函数组合CGAN损失 L1损失的权重关系Pix2Pix的完整损失由两部分组成L_total L_cGAN λ * L1(G)其中L_cGAN是条件GAN的对抗损失用于衡量生成图是否让判别器无法区分L1损失直接对生成图与真实输出图逐像素求绝对差用于约束输出尽可能贴近标准答案。论文里λ默认取100。这个权重比例在绝大多数数据集上都是稳定选择我建议初期不要乱动。L1和L2的选择也值得说一句。L1对离群点不那么敏感输出更锐利L2会把误差平方放大导致模型更倾向保守预测所以Pix2Pix用的是L1不是L2。很多人第一次写代码习惯性用MSELoss结果发现生成结果永远是雾蒙蒙的这就是原因。3. 基本实现核心模块的代码拆解3.1 生成器实现要点编码器-解码器与跳跃连接先讲生成器。我习惯用一个UNetBlock类来组织代码它同时承担编码器和解码器的卷积定义。为了演示清晰这里给出一个简化但可跑的版本使用三层下采样和三层上采样import torch import torch.nn as nn def conv_norm_relu(in_ch, out_ch, normTrue): layers [nn.Conv2d(in_ch, out_ch, 4, 2, 1)] if norm: layers.append(nn.BatchNorm2d(out_ch)) layers.append(nn.ReLU(inplaceTrue)) return nn.Sequential(*layers) def deconv_norm_relu(in_ch, out_ch, normTrue, dropoutFalse): layers [nn.ConvTranspose2d(in_ch, out_ch, 4, 2, 1)] if norm: layers.append(nn.BatchNorm2d(out_ch)) if dropout: layers.append(nn.Dropout(0.5)) layers.append(nn.ReLU(inplaceTrue)) return nn.Sequential(*layers) class UNetGenerator(nn.Module): def __init__(self, in_ch3, out_ch3): super().__init__() # 编码器 self.e1 nn.Sequential(nn.Conv2d(in_ch, 64, 4, 2, 1), nn.LeakyReLU(0.2)) self.e2 conv_norm_relu(64, 128) self.e3 conv_norm_relu(128, 256) self.e4 conv_norm_relu(256, 512) self.e5 conv_norm_relu(512, 512) self.e6 conv_norm_relu(512, 512) self.e7 conv_norm_relu(512, 512) self.e8 nn.Sequential(nn.Conv2d(512, 512, 4, 2, 1), nn.ReLU(inplaceTrue)) # 解码器 self.d1 deconv_norm_relu(512, 512, dropoutTrue) self.d2 deconv_norm_relu(512 512, 512, dropoutTrue) self.d3 deconv_norm_relu(512 512, 512, dropoutTrue) self.d4 deconv_norm_relu(512 512, 512) self.d5 deconv_norm_relu(512 512, 256) self.d6 deconv_norm_relu(256 256, 128) self.d7 deconv_norm_relu(128 128, 64) self.d8 nn.Sequential(nn.ConvTranspose2d(64 64, out_ch, 4, 2, 1), nn.Tanh()) def forward(self, x): skips [] e self.e1(x); skips.append(e) e self.e2(e); skips.append(e) e self.e3(e); skips.append(e) e self.e4(e); skips.append(e) e self.e5(e); skips.append(e) e self.e6(e); skips.append(e) e self.e7(e); skips.append(e) e self.e8(e) d self.d1(e) d self.d2(torch.cat([d, skips.pop()], dim1)) d self.d3(torch.cat([d, skips.pop()], dim1)) d self.d4(torch.cat([d, skips.pop()], dim1)) d self.d5(torch.cat([d, skips.pop()], dim1)) d self.d6(torch.cat([d, skips.pop()], dim1)) d self.d7(torch.cat([d, skips.pop()], dim1)) d self.d8(torch.cat([d, skips.pop()], dim1)) return d几个值得注意的实现细节编码器每层卷积步长为2负责逐步降低分辨率解码器用转置卷积步长2负责逐步恢复分辨率。通道数不是随便定的它决定了瓶颈层的信息容量。解码器开头的三层加了Dropout(0.5)。这是论文里明确提到的trick它在训练时给解码器注入随机性能有效抑制生成图的纹理伪影。我试过删掉dropout输出会偶尔出现网格状噪声。最后一层不用BatchNorm直接跟上Tanh激活。因为生成器的输出范围要严格落在[-1, 1]和输入图像的归一化范围保持一致BatchNorm会把输出重新变成有偏分布不利于训练。如果你配合的输入输出是单通道图比如灰度图上色把in_ch和out_ch都改成1就可以。实际项目中in_ch和out_ch经常不同比如语义分割图可能多通道要转成RGB图。3.2 判别器实现要点70×70感受野的PatchGANPatchGAN的判别器实现其实不复杂它就是一个全卷积网络输入是拼接后的两张图条件输入和生成图/真实图最终输出一个特征图特征图每个位置上的值代表对应patch的真假。通俗解释一下感受野的计算对于kernel4、stride2、padding1的连续卷积层每一层会让输出尺寸减半但每个输出神经元对应的输入区域会扩大约两倍。论文和实现里常说的70×70就是指当输入是256×256时最后一层每个输出神经元实际看到的输入区域大小。这个数值不用手工指定的它是网络结构决定的。下面是一个结构清晰、可以直接使用的判别器代码class PatchDiscriminator(nn.Module): def __init__(self, in_ch3): super().__init__() self.model nn.Sequential( nn.Conv2d(in_ch * 2, 64, 4, 2, 1), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(64, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(128, 256, 4, 2, 1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(256, 512, 4, 1, 1), # 注意这里stride1 nn.BatchNorm2d(512), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(512, 1, 4, 1, 1), ) def forward(self, x, cond): return self.model(torch.cat([x, cond], dim1))这个实现里有几个细节值得展开第三层卷积stride1而不是2。论文原本的网络为了达到70×70的感受野最后几层确实要调整stride。如果你照抄所有层的stride2输出的patch尺寸会比预期小很多感受野也不够。第一层不加BatchNorm。这也是从原论文的复现经验里来的。判别器第一层接的是[0, 1]或[-1, 1]区间的像素值直接卷积后信息就已经足够丰富过早加入BN反而可能造成训练初期不稳定的情况。实际操作中我去掉第一层BN后训练Loss的曲线明显平滑很多。LeakyReLU的负斜率取0.2是原论文的默认选择。判别器内部需要保留梯度信息不能像生成器那样用ReLU否则容易大量神经元死亡。3.3 训练循环里的关键细节Pix2Pix的训练循环看起来和普通GAN差不多但里面有三个容易弄错的地方真实标签的构造、生成器更新时判别器的冻结、优化器的参数设置。def train_one_epoch(gen, disc, dataloader, opt_g, opt_d, lambda_l1100): bce nn.BCEWithLogitsLoss() l1 nn.L1Loss() for cond, real_img in dataloader: bs cond.size(0) # 标签矩阵随判别器输出形状动态变化 real_labels torch.ones(bs, 1, patch_h, patch_w) fake_labels torch.zeros(bs, 1, patch_h, patch_w) # 1. 更新判别器 fake_img gen(cond) d_real disc(real_img, cond) d_fake disc(fake_img.detach(), cond) loss_d bce(d_real, real_labels) bce(d_fake, fake_labels) opt_d.zero_grad() loss_d.backward() opt_d.step() # 2. 更新生成器 fake_img gen(cond) d_fake disc(fake_img, cond) loss_gan bce(d_fake, real_labels) # 目标让判别器判断为真 loss_l1 l1(fake_img, real_img) * lambda_l1 loss_g loss_gan loss_l1 opt_g.zero_grad() loss_g.backward() opt_g.step()这里面非常关键的细节是fake_img.detach()放在判别器输入的生成图上表示这一步只更新判别器不把梯度回传到生成器。如果你忘了detach生成器和判别器会被同时更新训练几乎必然震荡。生成器对抗损失的标签用全1也就是希望判别器把生成图判成真。这也是标准GAN的写法。我见过有人把标签改成0.9这种平滑值去抑制训练震荡对Pix2Pix来说没有必要默认1.0即可。real_labels的尺寸要和判别器输出尺寸一致也就是前面说的patch尺寸。如果输入图是256×256上面这个判别器结构的输出应为30×30大小。计算标签时最好直接用torch.ones生成和判别器输出shape相同的张量不要硬编码30×30因为你改输入尺寸后会变。3.4 优化器与学习率设置Pix2Pix的优化器设置看起来不太像常规深度学习Adam的学习率用2e-4而不是常见的1e-3betas(0.5, 0.999)。这里的0.5几乎是所有GAN训练的通用水位线因为它会让Adam更少依赖过去梯度的惯性适配G和D这种敌我双方不断变化的场景。如果保持默认的0.9或0.99生成器很容易陷入震荡。我个人还习惯在训练末期让学习率线性衰减到0。论文的实现里有前一半epoch保持原学习率后一半线性衰减到0的策略我实际跑下来确实能略微提升生成图的质量尤其在100个epoch以上的长期训练里。如果训练epoch少于50不做衰减问题也不大。4. 实测过程中最值得注意的经验4.1 数据准备阶段的两个弯路Pix2Pix对数据形式的要求非常死板输入和输出必须像素级对齐而且最好都已经归一化到[-1, 1]。我第一次做线稿填色项目时为了省事直接用了未对齐的图片对结果训练出来的模型输出里出现明显的重影和错位后来才发现是数据对齐问题。数据集处理上输入图像通常以左半是输入、右半是输出的单张图片形式保存加载时需要按中心线切分。我见过很多新手在切分时不注意边界导致左右各带着一条不想要的空白或重叠区域。正确的做法是image[:, :, :width//2]和image[:, :, width//2:]切分后还要确认两边的尺寸一致。另一个数据层面的弯路是很多图像翻译任务输入和输出的分辨率不需要特别高。Pix2Pix将训练图resize到256×256是最稳妥的选择显存占用、训练速度和效果平衡得最好。我试过直接512×512训练显存需求翻了好几倍效果提升却微乎其微。如果项目最终要高清输出建议先用256×256训练稳定模型再后续微调高分辨率。4.2 训练稳定的几个参考指标Pix2Pix的训练虽然比DCGAN容易稳定但容易不等于不会翻车。判别器损失一路狂跌到0几乎是所有GAN的通病Pix2Pix会稍好一点但如果你发现判别器的loss已经长时间停在接近0的位置说明判别器强于生成器太多生成器得不到有效梯度。我通常的处理手段是按优先级尝试调低判别器的学习率比如从2e-4降到1e-4增加L1损失的权重λ从100升到200在判别器结构里再加一层Dropout降低它的过拟合速度。反过来如果生成结果模糊但loss很低问题很可能不在训练动态而在L1权重太大导致模型只优化逐像素差异。直接看生成图和真实图之间的差异会更快定位问题。一个我常用的可视化技巧训练过程中每若干个epoch把生成器和判别器的输出一起保存做成一个对比网格观察。光看loss没法判断模型是否真正在学因为loss下降方向正确但视觉质量不一定同步提升。网格图能让你直观看到输出从模糊块逐渐变清晰的过程也能帮助你在早期就发现模式避免等到训练结束才发现完全白跑了。4.3 常见但隐蔽的报错最后分享几个我复现Pix2Pix时经常遇到的问题以及它们的根因报错size mismatch出现在torch.cat处八成是跳跃连接的通道数对不上。检查编码器每一层输出通道数和解码器预期是否一致特别是U-Net的底层连接。训练正常但生成图全黑或全白几乎可以肯定是归一化问题。检查输入图像是否除以了127.5并减1转换到[-1, 1]区间同时确认生成器输出经过Tanh激活。生成图呈现明显的棋盘格纹理这是转置卷积叠加的经典伪影。一种解决思路是把转置卷积换成UpsampleConv的组合或者只降低某个层级的通道数。会牺牲一点训练速度但纹理干净很多。显存OOM且batch size已经很小把生成器和判别器的通道数按比例缩减比如从64开始改为48或32。Pix2Pix对模型宽度的敏感性不算太高压缩通道数通常只会带来轻微的画质损失却能显著降低显存需求。5. 训练配置速查与调参思路这里整理一份我复现Pix2Pix时常用的配置表。对于不同的任务你可以在这个基础上做增减配置项推荐值说明输入/输出分辨率256×256兼顾显存与效果Batch Size14论文用1也能出效果越大训练越稳生成器学习率2e-4Adam专用不要随意调大判别器学习率2e-4若D太强可单独降到1e-4betas(0.5, 0.999)GAN训练的经验参数λ (L1权重)100默认值效果好不随便改Epoch数100200少于50通常不够收敛标签平滑不使用Pix2Pix下直接二值标签更稳定数据归一化[-1, 1]注意生成器输出Tanh关于调参我的经验是先跑通再优化最后才换结构。很多人一上来就改生成器、加注意力模块结果训练不稳定了都不知道是哪部分出了问题。Pix2Pix是一个足够稳定的baseline先把原始配置跑通并得到可接受效果然后再去实验新的改动每个改动一次只做一个看它对验证集的影响。我也见过一个很有意思的调整将判别器也换成使用更大的感受野比如感受野从70×70提升到140×140结果生成图像的全局一致性明显增强但局部细节的锐利度略降。如果你的任务更关注全局布局而不是局部纹理可以考虑这个方向。不过这属于需要额外实验的改动不能盲目照搬。6. 从Pix2Pix出发还能走到哪里Pix2Pix虽然是2016年底公开、2017年正式发表的模型但它今天依然是理解图像翻译任务的最佳起点。它的条件GAN思想、U-Net加跳跃连接的骨架、PatchGAN的局部判别机制这三个要素到现在仍被大量新模型直接引用或改良。如果你已经掌握了这篇的实现下一步扩展方向可以非常发散把L1损失换成更符合人类感知的感知损失Perceptual Loss让输出更关注语义特征而不是像素级绝对差把成对数据的要求放开去研究CycleGAN这类不需要配对数据的方法效果取决于场景但思路完全不同用Transformer或自注意力机制替换部分卷积模块让模型对长距离依赖更敏感但训练门槛会显著提高。至于Pix2Pix本身的局限我看下来最明显的两点一是它对成对对齐数据的依赖实际项目中完美成对的数据往往很难准备二是它对高分辨率图像的生成不够友好毕竟256×256是它表现最稳的区间。但是作为图像翻译的第一个正确的模型Pix2Pix把整套思路摆得非常清楚——理解它之后无论是去看最新论文还是自己动手做实验你都会觉得顺畅很多。我在实际跑这个模型时最大的体会是它的代码不算复杂真正决定实验成败的往往在数据准备和训练习惯上。如果你正准备复现Pix2Pix记得预留足够多的时间去观察生成图而不是光盯loss曲线。模型在每一个epoch里生成图的微妙变化才是你判断结构设计、损失权重是否合理的最直接证据。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑