ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch实战CycleGAN:从零实现非配对图像风格迁移

PyTorch实战CycleGAN:从零实现非配对图像风格迁移

简介:CycleGAN的PyTorch实现,面向计算机视觉与生成对抗网络方向的学习者,用于完成无配对图像到图像的转换任务,例如苹果与橙子互转、风格迁移等。资源包共18个文件,以7个Python脚本为核心,涵盖模型定义、训练器、数据工具函数等完整代码;另含4个pt预训练权重,可直接进行推理或继续训练;2张损失曲线图帮助观察生成器与判别器的收敛过程;2张转换效果示例图可用于效果对照;还有1个ipynb演示笔记和项目说明文档,便于快速上手;压缩包总大小仅7.69MB,目录组织清晰,适合在本地复现和二次开发。目前已有3004人学习下载。借助预训练权重与代码,读者可快速搭建环境,体验从数据加载到图像转换的完整流程;同时可基于现有实现调整循环一致性损失、替换生成器骨干或数据集,进一步探索域适应、图像编辑等应用;从模型初始化、损失计算到权重保存的完整链路均有清晰实现,适合具备PyTorch基础、希望深入理解CycleGAN内部机制的中高级开发者。

1. CycleGAN是什么:先搞懂这个项目解决了什么问题

如果你手上有一批没有配对样本的图像,比如一千张夏天的街景和一千张冬天的街景,它们之间不存在任何一张“同机位同角度”的对应关系,但你又想把夏天的照片整体转换成冬天的质感,CycleGAN 就是目前最顺手的解法。这个项目用 PyTorch 从零实现了 CycleGAN 的完整训练链路:两个生成器、两个判别器、循环一致性损失、身份损失,以及一套可以直接跑起来的命令行工具。你只需要把数据整理成两个文件夹,就能训练出一个把 A 域转换成 B 域的模型,反过来也能生成。它适合两类人:一类是想快速复现非配对图像转换论文、做基线对比的实验型开发者;另一类是准备在真实业务里做风格迁移,需要自己动手换网络结构、调损失权重的工程型开发者。这个项目把论文里的公式变成了可读的 Python 代码,跑通它,你就理解了 CycleGAN 的每一个关键决策。

2. 技术栈与代码结构:一个 PyTorch 工程模板的完整拆解

2.1 依赖清单:这套代码踩过的坑

项目的技术栈非常轻,核心依赖就是 PyTorch 和 torchvision,再加 Pillow、numpy 这些图像处理必备库。没有引入任何重型框架,也没有用 HuggingFace 之类的抽象层,所有模型、损失、训练循环都写在原生 PyTorch 里,这样二次开发时不会被框架约束。

我一般用这个版本的依赖组合:

python>=3.8 torch>=1.9.0 torchvision>=0.10.0 numpy Pillow tqdm tensorboard>=2.5.0

为什么要求这么低?因为 CycleGAN 本身的网络结构不挑卡,显存吃紧的时候甚至可以用 CPU 跑通一个小轮次,只是速度感人。Pytorch 1.9 以上的InstanceNorm2d接口稳定,后续版本没有破坏性变更,所以这个下限足够。如果你用的是最新版 PyTorch,直接装最新的 torchvision 也没问题,代码里没有用到会被废弃的 API。

tensorboard 不是训练必需,但建议装上,后面讲可视化时会用到。如果你不想装,把--use_tensorboard参数关掉即可,代码里做了兼容处理。

2.2 目录结构:每个文件负责什么

拿到源码包后,第一件事就是看目录。这个项目没有把什么train.py、test.py全堆在根目录,而是按模块划分,我非常喜欢这种结构:

CycleGAN/ ├── main.py # 入口脚本,解析参数并启动训练/测试 ├── models/ │ ├── __init__.py # 模型统一导入 │ ├── generator.py # 生成器:ResNet9 + 残差块 │ └── discriminator.py # 判别器:PatchGAN ├── data/ │ ├── __init__.py │ └── dataset.py # 自定义 Dataset,读取两个域 ├── utils/ │ ├── __init__.py │ ├── loss.py # 损失函数封装 │ ├── visualization.py # TensorBoard 图像记录 │ └── utils.py # checkpoint 保存/加载、评分 ├── checkpoints/ # 模型权重保存目录 └── datasets/ ├── trainA/ # A 域训练图片 ├── trainB/ # B 域训练图片 ├── testA/ # A 域测试图片 └── testB/ # B 域测试图片

这个结构对应了 CycleGAN 的两大核心模块:生成器和判别器分开文件,训练入口只做组合,数据加载单独放,这样任何时候替换数据集都不需要动模型代码。checkpoints目录会自动生成,不需要手动建,main.py启动时会检查。

utils/loss.py值得单独提一下,它把 GAN 损失、循环损失、身份损失都封装成函数,避免了在训练循环里重复写同样的 MSE 和 L1 公式。你在改实验时,直接改这一个文件就够了,不用绕到训练循环里到处找。

2.3 数据加载:不用 ImageFolder,自己写 Dataset

原版的 CycleGAN 数据加载有一个关键点:图像不需要配对,但需要一个 Dataset 能够同时返回 A 域和 B 域的图像。torchvision.datasets.ImageFolder只能加载一个域,而且会自动按文件夹分类,不适合这里的使用方式。所以项目里的data/dataset.py自己继承了torch.utils.data.Dataset。

看关键代码:

# data/dataset.py import os from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class CycleGANDataset(Dataset): def __init__(self, root_a, root_b, img_size=256): self.files_a = sorted(os.listdir(root_a)) self.files_b = sorted(os.listdir(root_b)) self.root_a = root_a self.root_b = root_b self.transform = transforms.Compose([ transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) def __len__(self): return max(len(self.files_a), len(self.files_b)) def __getitem__(self, index): name_a = self.files_a[index % len(self.files_a)] name_b = self.files_b[index % len(self.files_b)] img_a = Image.open(os.path.join(self.root_a, name_a)).convert('RGB') img_b = Image.open(os.path.join(self.root_b, name_b)).convert('RGB') return self.transform(img_a), self.transform(img_b)

逻辑说明:__len__返回两个目录中较大的那个长度,配合取模操作,让数量少的域也能被完整遍历到,不会因为其中一个域只有 100 张图而让另一个域只被用 100 次。__getitem__每次按当前 index 分别从两个域各取一张图,注意这里用的是同一个 index 加上各自长度的取模,所以 A 域和 B 域的样本在每次迭代时是随机组合的,完全不需要配对关系。

参数说明:root_a和root_b是 A、B 两个域的图片文件夹路径;img_size默认 256,这是 CycleGAN 论文中的标准分辨率。归一化用了mean=0.5, std=0.5,会把像素从[0,1]映射到[-1,1],这和生成器输出层的Tanh激活函数是对应的。使用Image.open后调用.convert('RGB')是为了去掉 PNG 的透明通道,也可以避免灰度图混入时导致通道数不一致。

如果你需要做数据增强,可以在这个transform里插入RandomHorizontalFlip,但原版 CycleGAN 只在训练时对单张图做水平翻转,这个项目把增强参数也开放给了命令行,后面会看到。

3. 核心模型搭建:生成器、判别器和损失函数的完整实现

3.1 生成器:ResNet9 残差块与反射填充

CycleGAN 的生成器是一个编码器-转换器-解码器结构。输入是 3 通道的 256x256 图像,先用两层卷积下采样到 64x64,然后经过 9 个残差块进行域转换,再用两层转置卷积上采样回 256x256。这里最关键的是残差块内部,它用了ReflectionPad2d而不是普通零填充,这是图像风格迁移的一个经典血泪经验。

看models/generator.py里的残差块实现:

# models/generator.py import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels): super().__init__() self.block = nn.Sequential( nn.ReflectionPad2d(1), nn.Conv2d(in_channels, in_channels, kernel_size=3, bias=False), nn.InstanceNorm2d(in_channels), nn.ReLU(inplace=True), nn.ReflectionPad2d(1), nn.Conv2d(in_channels, in_channels, kernel_size=3, bias=False), nn.InstanceNorm2d(in_channels), ) def forward(self, x): return x + self.block(x)

逻辑说明:残差块内部是两个卷积,每个卷积前垫一层反射填充。ReflectionPad2d(1)会把边界的像素镜像反射到外面,比如图像最左一列像素会出现在新矩阵的最左外侧。相比ZeroPad2d,反射填充不会在边缘引入黑色伪影,这对图像转换任务很重要。第二个卷积层的输出self.block(x)和输入x相加,形成恒等映射路径。残差块不改变通道数和空间尺寸,所以可以串联多个而不出维度问题。

参数说明:in_channels是残差块的通道数,在标准 CycleGAN 中,编码器下采样后通道数变成 256,所以 ResNet 块处理的就是 256 通道。卷积层设置bias=False,因为它后面紧跟InstanceNorm2d,归一化层会做平移操作,偏置会被屏蔽,留着反而浪费参数。使用InstanceNorm2d而不是BatchNorm2d是因为 CycleGAN 通常使用较小的 batch size,甚至经常是 batch size=1,BN 在单样本时统计均值方差没有意义,而 InstanceNorm 对单张图归一化,效果更稳定。

生成器的主体就是用一个nn.ModuleList把这 9 个残差块串起来,然后在forward里依次调用。注意生成器输入的值域是[-1,1],输出层用了nn.Tanh(),把输出也压到[-1,1],和判别器的输入范围保持一致。

3.2 判别器:70x70 PatchGAN 的秘密

判别器不是输出一个 0 到 1 的标量,而是输出一个 NxN 的特征图,每个点对应输入图像上一个 70x70 的感受野区域。这个设计叫 PatchGAN,它的优势是参数少、更关注局部纹理,同时能逼着生成器把每个局部区域都做得逼真,而不是只求整体分布像。

看models/discriminator.py代码:

# models/discriminator.py import torch.nn as nn class PatchDiscriminator(nn.Module): def __init__(self, in_channels=3): super().__init__() self.model = nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.InstanceNorm2d(128), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(128, 256, kernel_size=4, stride=2, padding=1), nn.InstanceNorm2d(256), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(256, 1, kernel_size=4, stride=1, padding=1), ) def forward(self, x): return self.model(x)

逻辑说明:输入是 256x256 的 3 通道图像,经过三组 stride=2 的卷积后,空间尺寸逐层减半:256 -> 128 -> 64 -> 32。最后一层卷积的 stride 改为 1,输出通道数为 1,最终得到一个 31x31 的特征图。这个特征图上的每个像素都对应原图的一个局部区域,判别器训练的目标是让所有真实图像对应的像素接近 1,生成的图像对应的像素接近 0。因为判别器只看局部,所以它能指导生成器提高高频纹理的真实度。

参数说明:卷积核统一用kernel_size=4,这是 DCGAN 之后常用的配置,配合padding=1能精确控制尺寸变化。LeakyReLU(0.2)的负斜率是 0.2,避免 ReLU 把负值全部置零导致梯度消失。每个卷积后都接InstanceNorm2d,只有第一层不加,这是稳定训练的一个常见约定——对原始输入做归一化容易破坏数据分布。倒数第二层和最后一层之间没有归一化,直接输出 logits,这个 logits 会传给 MSE 损失。

这里有个易错点:判别器的输出不是经过 Sigmoid 的概率,而是裸的 logits,所以后面计算损失时用的MSE_loss直接拿输出和目标值比,不能再过 Sigmoid,否则梯度会不稳定,很多新手在这里翻车。

3.3 损失函数:三驾马车的平衡

CycleGAN 的损失由三部分组成:对抗损失、循环一致性损失、身份损失。对抗损失用 LSGAN 的 MSE 形式,循环一致性损失用 L1,身份损失也用 L1。看utils/loss.py的封装:

# utils/loss.py import torch import torch.nn.functional as F def gan_loss(real_pred, fake_pred, is_discriminator=True): """计算 LSGAN 损失""" if is_discriminator: real_loss = F.mse_loss(real_pred, torch.ones_like(real_pred)) fake_loss = F.mse_loss(fake_pred, torch.zeros_like(fake_pred)) return (real_loss + fake_loss) * 0.5 else: return F.mse_loss(fake_pred, torch.ones_like(fake_pred)) def cycle_loss(rec_a, real_a, rec_b, real_b): return F.l1_loss(rec_a, real_a) + F.l1_loss(rec_b, real_b) def identity_loss(fake_b, real_b, fake_a, real_a): return F.l1_loss(fake_b, real_b) + F.l1_loss(fake_a, real_a)

逻辑说明:gan_loss在判别器模式下,把真实图像判别器输出尽可能逼近 1,把生成图像输出尽可能逼近 0,并取平均;在生成器模式下,只计算生成图像输出逼近 1 的损失,即生成器想让判别器错判。cycle_loss计算 A 域图经过 G 和 F 再回到 A 域的差异,以及 B 域图经过 F 和 G 再回到 B 域的差异。L1 损失比 L2 更尖锐,能保留更多边缘信息,所以在循环一致性上选 L1。identity_loss是可选的,它的作用是让生成器在输入已经属于目标域时尽量别改,比如输入一张冬天的图,经过冬天到夏天再回来的网络时,应该尽量保持原样。这个损失权重要调小,否则生成器太平庸。

为什么循环一致性损失能解决非配对问题?因为两个生成器互为逆映射。G 把 A 变 B,F 把 B 变 A,如果 G(A) 再经过 F 能还原出 A,就说明 G 没有随意发挥,它学会的映射是受约束的。这就是 CycleGAN 和单纯 GAN 的本质区别。

在main.py中,这些损失按权重组合成总生成器损失:

loss_G = loss_gan_AB + loss_gan_BA + 10.0 * loss_cycle + 0.5 * loss_identity

权重 10 和 0.5 是论文里的默认值,项目也做成了命令行参数。这个权重对训练稳定性影响极大,后面避坑里会单独展开。

4. 训练流程与关键参数:从命令行开始跑通

4.1 入口 main.py:从参数到模型初始化

项目的入口是main.py,它用标准库argparse解析参数。整个训练脚本只需要一行命令就能启动,所有可调项全暴露出来。

python main.py --mode train --root_a ./datasets/trainA --root_b ./datasets/trainB --epochs 200 --batch_size 1 --image_size 256

这句命令的含义:指定 A 域训练图片在datasets/trainA目录,B 域在datasets/trainB,训练 200 个 epoch,batch size 为 1,图片缩放到 256x256。如果你显存不够,可以把image_size调成 128,训练速度会快很多,但生成图像细节会有所损失。

模型初始化部分,main.py会根据参数分别构建两个生成器和两个判别器:

# main.py 初始化片段 from models.generator import ResnetGenerator from models.discriminator import PatchDiscriminator gen_ab = ResnetGenerator(num_resblocks=9) gen_ba = ResnetGenerator(num_resblocks=9) dis_a = PatchDiscriminator(in_channels=3) dis_b = PatchDiscriminator(in_channels=3)

ResnetGenerator内部会自动判断num_resblocks,9 个块是论文原版配置,如果改成 6 个可以加快训练但效果略降。两个生成器结构完全一样,但权值不相干;两个判别器也完全一样。这里有一个容易忽略的设计:gen_ab负责 A 到 B,gen_ba负责 B 到 A,命名上严格区分,后面对称更新时就不会晕。

初始化之后,代码会检查checkpoints目录下是否存在已有权重,如果有就会自动加载,实现断点续训。

4.2 训练循环:谁先更新谁

训练循环是整个项目最值得细看的部分,因为它决定了两套网络能否稳定博弈。CycleGAN 里生成器和判别器不是同时更新,而是每轮先更新生成器,再更新判别器,并且判别器更新时使用detach()切断生成器的梯度。看核心代码:

# main.py 训练循环简化版 for epoch in range(start_epoch, epochs): for i, (real_a, real_b) in enumerate(train_loader): # 第一步:更新生成器 fake_b = gen_ab(real_a) fake_a = gen_ba(real_b) rec_a = gen_ba(fake_b) rec_b = gen_ab(fake_a) id_b = gen_ab(real_b) id_a = gen_ba(real_a) loss_gan_ab = gan_loss(dis_b(real_b), dis_b(fake_b), is_discriminator=False) loss_gan_ba = gan_loss(dis_a(real_a), dis_a(fake_a), is_discriminator=False) loss_cycle = 10.0 * cycle_loss(rec_a, real_a, rec_b, real_b) loss_id = 0.5 * identity_loss(id_b, real_b, id_a, real_a) loss_G = loss_gan_ab + loss_gan_ba + loss_cycle + loss_id gen_optimizer.zero_grad() loss_G.backward() gen_optimizer.step() # 第二步:更新判别器 fake_b_det = fake_b.detach() fake_a_det = fake_a.detach() loss_dis_a = gan_loss(dis_a(real_a), dis_a(fake_a_det), is_discriminator=True) loss_dis_b = gan_loss(dis_b(real_b), dis_b(fake_b_det), is_discriminator=True) loss_D = (loss_dis_a + loss_dis_b) * 0.5 dis_optimizer.zero_grad() loss_D.backward() dis_optimizer.step()

逻辑说明:生成器更新时,输入真实图像real_a和real_b,前向得到fake_b、fake_a,再各自送入反向生成器得到重构图像rec_a、rec_b。循环一致性损失要求rec_a逼近real_a,rec_b逼近real_b。身份损失要求gen_ab(real_b)逼近real_b,gen_ba(real_a)逼近real_a。这代代码一次性算了生成器的所有损失,然后反向传播更新两个生成器的共享优化器。

到判别器更新时,fake_b和fake_a已经由生成器计算出来了,但必须.detach()让梯度不会流向生成器。如果不 detach,判别器更新时就会顺带更新生成器参数,导致两把刀在同一个回合里互相砍,训练很容易炸掉。这也是一个常见翻车点。

参数说明:gen_optimizer和dis_optimizer都是 Adam,学习率默认 2e-4,beta1=0.5。GAN 类任务常用 beta1=0.5 而不是默认的 0.9,这样能减小动量影响,让判别器更快响应生成器的变化。优化器在初始化后会对所有参数管理,不需要单独给每个网络分配一个优化器,两个生成器共用一个、两个判别器共用一个,能减小收敛震荡。

4.3 学习率调度与 checkpoint 保存

训练到一半时,图像质量往往还不理想。CycleGAN 原版采用线性衰减学习率的策略:前 100 个 epoch 保持 2e-4,后 100 个 epoch 线性降到 0。这个动作通过torch.optim.lr_scheduler.LambdaLR实现:

# main.py 调度器配置 def lambda_rule(epoch): num_epochs = 200 decay_start = 100 return 1.0 if epoch < decay_start else 1.0 - (epoch - decay_start) / (num_epochs - decay_start) scheduler_gen = torch.optim.lr_scheduler.LambdaLR(gen_optimizer, lr_lambda=lambda_rule) scheduler_dis = torch.optim.lr_scheduler.LambdaLR(dis_optimizer, lr_lambda=lambda_rule)

epoch从 0 开始计数,所以前 100 个 epoch 学习率不变,从第 100 个 epoch 开始线性下降,到第 199 个 epoch 正好降成 0。这样做的好处是后期学习率小,能让生成器在稳定的判别器下精细调整纹理,而不是大步跨越把权重踢到坏区域。

checkpoint 保存也写在训练循环里,每 5 个 epoch 自动保存一次到checkpoints目录。保存的字典里同时包含两个生成器、两个判别器的state_dict,还有优化器状态和当前 epoch 数,这样恢复训练时不会丢失动量参数:

# main.py checkpoint 保存片段 torch.save({ 'gen_ab': gen_ab.state_dict(), 'gen_ba': gen_ba.state_dict(), 'dis_a': dis_a.state_dict(), 'dis_b': dis_b.state_dict(), 'gen_optimizer': gen_optimizer.state_dict(), 'dis_optimizer': dis_optimizer.state_dict(), 'epoch': epoch + 1, }, 'checkpoints/latest.pt')

加载时只需要把torch.load返回的字典逐项load_state_dict,然后把epoch作为起始 epoch 继续循环。注意 PyTorch 默认在 GPU 环境下保存的张量会带着设备信息,如果你换卡训练,加载时要加map_location='cpu'再转移到新设备,否则容易报错。

5. 避坑指南:CycleGAN 训练中的常见问题排查

5.1 训练不收敛,损失振荡严重

现象:训练几十个 epoch 后,生成器损失和判别器损失来回横跳,生成的图像忽好忽坏,甚至出现噪点。

原因:最常见的是学习率太高,或者判别器更新步数比生成器少,导致判别器跟不上生成器的变化节奏。另一个原因是输入图像的归一化不一致,比如只对 A 域做了标准化,B 域还是[0,1]范围,判别器会直接把亮度当成区分信号,导致训练震荡。

解决:先把学习率降到 1e-4,同时确认两个域都用Normalize(mean=0.5, std=0.5),让输入落在[-1,1]。然后观察每个 step 里两个损失的数量级,如果判别器损失长期低于生成器损失,说明判别器太强,可以降低判别器相关权重,或把判别器更新次数从 1 改成每 2 个 step 更新一次(项目支持--n_critic参数)。

5.2 生成图像发糊,纹理细节全部丢失

现象:生成的图像整体布局正确,但表面像蒙了一层雾,边缘模糊。

原因:循环一致性损失权重太高,模型为了满足还原重构而选择保守的映射,输出趋近于均值。另外,生成器最后一层用nn.Tanh()输出,如果你在损失计算时又对输出做了一次sigmoid或归一化,会让梯度变得非常小,生成器学不动细节。

解决:把--lambda_cycle从 10 降到 5,适当降低重构约束;同时检查损失函数代码,确认生成器输出直接参与 MSE,没有额外激活。我一般还会把判别器的感受野调大一点,用--patch_size参数控制,比如从 70 改成 1,即判别器只看整体,这会牺牲局部纹理但能恢复宏观结构。如果模糊严重,优先怀疑身份损失权重,把它从 0.5 降到 0.2。

5.3 生成器“罢工”,所有输出都变成同一张图

现象:训练到中后期,无论输入什么图像,生成器输出几乎相同,而且循环一致性损失一直卡住不降。

原因:这是 GAN 训练里经典的 mode collapse,生成器发现输出一个“万能图”就能骗过判别器,就不再学习输入变化。根本原因是判别器能力太弱,或者对抗损失占主导,生成器走捷径。

解决:先停掉判别器的 InstanceNorm,或者把判别器的通道数减半,让判别器变弱,迫使生成器必须提供更丰富的信息来应对。同时把循环一致性权重调大,它是对抗损失的“正则项”,能有效阻止生成器忽略输入。项目里通过--lambda_identity也能略加帮助,但我发现最有效的是把 batch size 从 1 提到 4,让生成器在同一个 batch 内看到多个不同样本,天然不会坍缩到单点。

5.4 显存溢出,训练直接中断

现象:启动训练十几秒后,程序报CUDA out of memory。

原因:256x256 的输入经过两个生成器和两个判别器,中间还要保存多份特征图用于反向传播,显存占用轻松超过 8GB。分辨率越高,通道数越多,开销呈平方级上涨。

解决:第一选择是把--image_size降到 128,这在很多风格迁移任务里已经足够,训练结束再用 256 微调。第二选择是打开--grad_accumulation功能,通过梯度累积模拟更大的 batch 来减少显存峰值。项目在训练循环里实现了 accumulate 变量,每攒到--accumulation_steps次才做一次优化器 step。如果还不行,把判别器的通道数从 64 改成 32,显存占用接近减半,代价是判别力弱一点。

5.5 数据量太少,过拟合严重

现象:训练集每个域只有几十张图,训练后期生成器把训练图像原样背下来,测试集上效果一塌糊涂。

原因:CycleGAN 是很吃数据的模型,原版论文每个域用了上千张图片。数据少时,生成器太容易记住有限样本,尤其是循环一致性损失会加剧这种行为。

解决:项目内置了随机水平翻转和随机裁剪增强,打开--preprocess=flip_crop会让每张图每次迭代都被随机裁剪出一个 256x256 区域,相当于数据量放大几倍。如果还不行,就降低生成器残差块数量,比如从 9 改成 6,减少模型容量;同时把--lambda_cycle降到 5,避免过强的重构约束逼着模型死记硬背。这种问题没有完美解,最靠谱的还是多去收集图像,哪怕不配对都比全靠增强硬撑强。

6. 二次开发技巧:换数据集、调损失、加可视化

6.1 把自己的业务图替换进去

假设你现在要把工程图纸里的手绘草图转换成彩色渲染图。只需要准备两个文件夹:A放草图,B放渲染图。图片不必一一对应,但要做到内容域一致,比如所有草图和渲染图都应该显示同一个种类的椅子,否则模型学不到稳定的映射关系。目录组织好后,直接执行训练命令,如果发现生成图偏色,先调--lambda_identity;如果发现结构不对,调--lambda_cycle。这是最快见效的改造。

6.2 生成器换成 UNet 或减少残差块

如果任务很接近配对的图像转换,或者两个域的差异只是颜色、纹理,9 个残差块有点重。在models/generator.py里加一个UnetGenerator类,编码器用四层下采样,解码器对应四层上采样,中间用跳跃连接。修改时注意:UNet 的输入和输出尺寸要一致,跳跃连接需要把编码器层和下采样层配对保存。改完之后在main.py的初始化处加一个--generator_type参数,默认resnet,传unet时走新分支。如果你不想动文件,把残差块数量改成 6 也能加速 30% 左右,很多颜色偏移任务用 6 块就够了。

6.3 用 TensorBoard 盯住中间产物

训练不看中间图像等于闭着眼开车。项目在utils/visualization.py里已经写好了一个log_images函数,它会把real_a、fake_b、rec_a、real_b、fake_a、rec_b六张图拼成网格,写到 TensorBoard:

# utils/visualization.py from torchvision.utils import make_grid from torch.utils.tensorboard import SummaryWriter def log_images(writer, tag, real_a, fake_b, rec_a, real_b, fake_a, rec_b, step): images = { 'real_a': real_a, 'fake_b': fake_b, 'rec_a': rec_a, 'real_b': real_b, 'fake_a': fake_a, 'rec_b': rec_b, } for name, im in images.items(): writer.add_image(f'{tag}/{name}', make_grid(im.clamp(-1, 1).detach().cpu(), nrow=4), step)

调试时我还会额外保存gen_ab(real_b)和gen_ba(real_a)的身份输出,这样能直观看到身份损失到底有没有起作用。如果发现fake_b和real_a看起来几乎一样,而rec_a严重偏差,说明循环一致性权重过大;如果fake_b清晰度高但内容完全不对,则要加循环权重。这些都可以靠 TensorBoard 曲线来定位。

从那以后我每次换数据集训练,都会强制走一遍同样的流程:先跑 5 个 epoch 看图像变化幅度,确认生成器在动,再跑 50 个 epoch 观察损失曲线,最后再根据可视化微调权重。这套习惯帮我避开了好几轮“损失看着在降,图像其实不学”的假收敛。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进