简介CycleGAN的Keras实现配套代码包面向已具备深度学习基础、希望亲手复现无配对图像转换的开发者与学习者围绕生成器与判别器双网络结构、循环一致性损失及交替训练流程给出可运行范例可服务于风格迁移、季节转换等创意实验场景。包内共7个文件以4个Python源码为主涵盖模型构建、数据加载与预测推理等环节另附3个zip格式的非配对图像数据集覆盖马与斑马、苹果与橘子、莫奈与照片等常见转换任务整体约477.41MB解压后可按模块查阅与调试。已有549人学习下载说明其在同类教程中具有一定参考度。读者可据此理解两个生成器G、F与两个判别器D_X、D_Y的协同关系掌握对抗损失与循环一致性损失的实现方式并借助现成数据加载与预测脚本快速完成训练、测试和效果验证减少从零搭建模型的试错成本。1. 非配对图像翻译先跑通再调参CycleGAN的Keras实现拆解手里只有两组没有像素对齐的图片却想让马变斑马、油彩变照片、白天变黑夜配对监督的数据集往往凑不出来。CycleGAN的Keras实现把两个生成器、两个判别器和循环一致性损失收进一个可读的python工程适合已经懂一点深度学习、想用Keras快速搭GAN基线的人。它不承诺开箱出论文级效果但训练循环、权重保存和推理入口都能直接改。下面按数据、模型、损失、避坑、验证五块拆重点放在能复现的参数和容易翻车的地方。2. 非配对数据怎么喂目录结构、tf.data 管线和归一化参数CycleGAN的“非配对”不是随便抓图。两个域要各自有足够多样性A域和B域不能混入同一张图否则循环一致性会学到捷径。数据管道的第一目标是让A、B独立采样第二目标是固定值域和形状。很多训练失败不是模型问题而是数据在背后换了尺度。血泪经验先把一个batch还原成图片看一眼比调一下午损失函数有用。2.1 目录结构与双域采样我一般把数据按方向拆成四个目录训练和验证分开验证集从第一天就固定下来后面每几个epoch跑一次避免被训练过程中的单张好看结果骗到。路径用途注意data/trainA源域A训练图只放A域不要混B域data/trainB目标域B训练图数量不必等于Adata/testA固定验证A20到50张不参与训练data/testB固定验证B用来看反向翻译A→B方向训练一个生成器G_ABB→A方向训练G_BA。两个域的样本数不要求相等但每个batch里的A和B不要求同源。常见错误是用配对Dataset把A和B按索引绑死这样循环损失会退化成重建损失模型学不到跨域映射。常见做法是两个域各自shuffle、各自repeat再zip。zip之后如果某个域先耗尽训练步会卡住或者反复取同一批所以repeat要放在zip之前。2.2 tf.data 管线的代码与参数下面这段是双域读取的最小实现重点看值域映射和repeat的位置import glob import tensorflow as tf AUTOTUNE tf.data.AUTOTUNE def load_image(path, image_size): img tf.io.read_file(path) # 统一解码为3通道RGBA和灰度图都在这里被拉齐 img tf.io.decode_image(img, channels3, expand_animationsFalse) img tf.image.resize( img, [image_size, image_size], methodtf.image.ResizeMethod.BICUBIC ) img tf.cast(img, tf.float32) # 映射到[-1, 1]与生成器最后一层tanh对齐 img (img / 127.5) - 1.0 return img def make_dataset(files, image_size, batch_size, trainingTrue): ds tf.data.Dataset.from_tensor_slices(files) if training: # 数据少时不要硬写10000否则会反复告警 ds ds.shuffle(min(len(files), 1000), seed42, reshuffle_each_iterationTrue) ds ds.map(lambda p: load_image(p, image_size), num_parallel_callsAUTOTUNE) if not training: ds ds.cache() ds ds.batch(batch_size, drop_remainderTrue) ds ds.prefetch(AUTOTUNE) if training: # 每个域各自repeat避免zip被短域截断 ds ds.repeat() return ds files_A sorted(glob.glob(data/trainA/*.jpg)) files_B sorted(glob.glob(data/trainB/*.jpg)) ds_A make_dataset(files_A, image_size256, batch_size1, trainingTrue) ds_B make_dataset(files_B, image_size256, batch_size1, trainingTrue) ds tf.data.Dataset.zip((ds_A, ds_B))load_image里的值域映射是硬约束生成器输出tanh判别器看到的真实图也必须在[-1,1]。如果这里用[0,1]而生成器tanh训练前期判别器会靠亮度差轻松分辨真假表现是D loss迅速下降、G adv loss居高不下最后生成图整体灰暗或过曝。image_size先从128或192起步显存够再上256batch_size1是CycleGAN常见起点因为一个训练步要跑四个生成前向和四个判别前向batch开大很容易爆显存。drop_remainderTrue让每个batch形状固定图模式编译更稳。shuffle的buffer取min(len(files), 1000)小数据集不要写死10000。num_parallel_callsAUTOTUNE在机械硬盘上可能抢IO如果发现GPU利用率忽高忽低可以降到4。提示训练前先抽一个batch还原成图片确认A/B值域在[-1,1]、通道为3、尺寸一致再启动长训练。检查代码不用复杂一行就够for a, b in ds.take(1): print(a.shape, a.dtype, tf.reduce_min(a).numpy(), tf.reduce_max(a).numpy()) print(b.shape, b.dtype, tf.reduce_min(b).numpy(), tf.reduce_max(b).numpy())如果min/max不是接近-1和1先查解码和归一化不要急着改模型。如果shape出现None说明drop_remainder没生效或者某个域没有repeat。如果dtype是uint8说明归一化之前就被截断了。2.3 预处理和增强的边界随机裁剪、随机翻转、颜色抖动在分类任务里很常见搬到CycleGAN要小心。随机裁剪会改变图像内容边界如果A域和B域的构图差异很大裁剪后可能把目标物体裁掉循环损失会惩罚这种裁剪表现是重建图边缘出现奇怪的补丁。我一般只做resize不做随机裁剪如果非要裁剪先缩放再中心裁剪训练和推理用同一套几何变换。水平翻转对自然图像通常安全但文字、医学影像、左右有语义的工业图不要无脑加。颜色抖动更要谨慎CycleGAN本身就在学颜色映射输入颜色被随机扰动后生成器会同时学“颜色不变性”和“跨域映射”训练目标会打架。常见做法是只对A域做轻微亮度调整或者干脆不加颜色增强。另一个坑是tf.image.per_image_standardization它会把每张图的对比度拉平循环损失会倾向于学灰度重建生成结果发灰。归一化统一用(img / 127.5) - 1.0不要混用ImageNet均值方差。数据量方面每个域几百张能跑出可看的结果但多样性比数量更重要。A域如果全是同一场景G_AB会过拟合到背景换一张图就崩。训练前按文件名随机抽20张拼成网格看一眼比看loss曲线更直接。3. 生成器与判别器怎么搭Keras 函数式 API 下的 ResNet 生成器和 PatchGANCycleGAN的模型结构不算复杂难在接口边界生成器输出什么值域、判别器输出logits还是概率、归一化层在batch1时用什么。Keras实现里我优先用函数式API搭生成器和判别器因为结构固定保存和加载省心训练步用tf.keras.Model子类化因为要手动控制两个优化器。两者混用没问题关键是别在子类化模型里再包一层函数式模型导致变量名混乱。3.1 生成器为什么用反射填充和残差块生成器常见结构是反射填充、7x7卷积、两次stride2下采样、若干残差块、两次转置卷积上采样、7x7卷积输出tanh。反射填充的作用是减少边缘伪影普通零填充在图像边界会产生黑边循环损失会把黑边带回原图。残差块数量按分辨率调整256输入常用6或9个128输入用4到6个。通道数ngf64是常见起点显存紧就降到32但别低于16否则纹理学不出来。归一化层在小batch下比BatchNorm稳。CycleGAN常用InstanceNormKeras里如果没有现成的可以用GroupNorm近似组数取8要求通道数能被8整除。下面代码里用GroupNormalization如果你的环境没有这个层换成一个自定义InstanceNorm层接口保持一样。import tensorflow as tf from tensorflow.keras import layers, Model def reflection_pad(x, pad3): return tf.pad(x, [[0, 0], [pad, pad], [pad, pad], [0, 0]], modeREFLECT) def res_block(x, filters): shortcut x x reflection_pad(x, 1) x layers.Conv2D(filters, 3, strides1, paddingvalid, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) x layers.Activation(relu)(x) x reflection_pad(x, 1) x layers.Conv2D(filters, 3, strides1, paddingvalid, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) return layers.Add()([shortcut, x]) def build_generator(image_size256, ngf64, n_blocks6): inputs layers.Input(shape(image_size, image_size, 3)) x reflection_pad(inputs, 3) x layers.Conv2D(ngf, 7, strides1, paddingvalid, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) x layers.Activation(relu)(x) # 两次下采样256 - 64 for mult in (2, 4): x layers.Conv2D(ngf * mult, 3, strides2, paddingsame, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) x layers.Activation(relu)(x) # 残差块堆叠256分辨率常用6追求容量可以到9 for _ in range(n_blocks): x res_block(x, ngf * 4) # 两次上采样64 - 256 for mult in (2, 1): x layers.Conv2DTranspose(ngf * mult, 3, strides2, paddingsame, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) x layers.Activation(relu)(x) x reflection_pad(x, 3) x layers.Conv2D(3, 7, strides1, paddingvalid, activationtanh)(x) return Model(inputs, x, nameG_AB)ngf64控制第一层通道后面依次乘2、乘4n_blocks6对应256输入显存不够先降ngf再降块数。use_biasFalse是因为后面接了归一化层偏置会被抵消。最后一层tanh把输出压到[-1,1]和训练数据的值域对齐。转置卷积容易出棋盘伪影如果生成图有明显网格把上采样换成ResizeNearest加普通卷积或者检查转置卷积的stride和kernel是否匹配。3.2 判别器为什么用 PatchGAN 且输出 logits判别器用PatchGAN输出一个N×N的patch每个位置判断局部真假而不是整图一个分数。这样参数少关注高频纹理对大图更友好。256输入经过四次stride2输出大约16×16每个点对应原图约70×70的感受野。最后一层输出1通道logits不加sigmoid。损失函数用MSE时直接对logits算等价于LSGAN如果加sigmoid再算MSE梯度会变平训练前期判别器推不动。def build_discriminator(image_size256, ndf64): inputs layers.Input(shape(image_size, image_size, 3)) x layers.Conv2D(ndf, 4, strides2, paddingsame)(inputs) x layers.LeakyReLU(0.2)(x) for mult in (2, 4, 8): x layers.Conv2D(ndf * mult, 4, strides2, paddingsame, use_biasFalse)(x) x layers.GroupNormalization(groups8)(x) x layers.LeakyReLU(0.2)(x) # 输出patch logits损失函数内部处理真假标签 x layers.Conv2D(1, 4, strides1, paddingsame)(x) return Model(inputs, x, nameD_A)ndf64是常见起点判别器太强时先降到32或者把最后两层通道减半。LeakyReLU(0.2)是GAN里的常用斜率别改成0否则梯度容易死。输出层不加激活后面用MeanSquaredError对ones_like和zeros_like算损失。判别器输出形状可以用一行代码验证D_A build_discriminator(256, 64) print(D_A.output_shape) # 期望 (None, 16, 16, 1) 左右如果输出是(None, 1, 1, 1)说明下采样次数多了patch太小判别器只关注全局纹理细节会丢。如果输出是(None, 256, 256, 1)检查stride是不是写成了1。3.3 函数式 API 与训练步子类化的接口边界生成器和判别器用函数式API训练器用tf.keras.Model子类化。子类化模型里持有四个子模型变量名会带前缀保存权重时要注意。我一般不给训练器存整包权重而是分别存G_AB、G_BA、D_A、D_B的权重推理时只加载生成器少一层变量名映射就少一个坑。函数式模型在load_weights之前需要先build最稳的做法是跑一次dummy forwardG_AB build_generator(256, 64, 6) G_AB.build((None, 256, 256, 3)) G_AB.load_weights(ckpt/G_AB.weights.h5)不同Keras发行版的权重格式有差异训练和推理用同一套环境别在A环境存、B环境加载。判别器只在训练时用推理脚本里不需要加载省显存也省事。4. 损失函数与训练循环对抗损失、循环一致性和身份损失的配比CycleGAN的损失由三块组成对抗损失让生成图像目标域循环一致性损失让A→B→A能回来身份损失让生成器在输入已经是目标域时尽量不改。三项权重不是玄学lambda_cycle10、lambda_identity0.5是常见起点。对抗损失用LSGAN的MSE比原始GAN的交叉熵稳判别器输出logits标签用1和0。4.1 四个前向与三项损失的权重一个训练步里要跑四个生成前向fake_B G_AB(real_A)、cycled_A G_BA(fake_B)、fake_A G_BA(real_B)、cycled_B G_AB(fake_A)再加两个身份前向G_AB(real_B)和G_BA(real_A)。循环损失用L1比L2保留边缘。身份损失也用L1权重别开大0.5足够开到5以上模型会倾向于恒等映射跨域变化变小。对抗损失对两个判别器的输出各算一次生成器希望假图被判为真。class CycleGANTrainer(tf.keras.Model): def __init__(self, G_AB, G_BA, D_A, D_B, lambda_cycle10.0, lambda_identity0.5): super().__init__() self.G_AB G_AB self.G_BA G_BA self.D_A D_A self.D_B D_B self.lambda_cycle lambda_cycle self.lambda_identity lambda_identity self.mse tf.keras.losses.MeanSquaredError() self.g_optimizer tf.keras.optimizers.Adam(2e-4, beta_10.5) self.d_optimizer tf.keras.optimizers.Adam(2e-4, beta_10.5) tf.function def train_step(self, batch): real_A, real_B batch with tf.GradientTape(persistentTrue) as tape: fake_B self.G_AB(real_A, trainingTrue) cycled_A self.G_BA(fake_B, trainingTrue) fake_A self.G_BA(real_B, trainingTrue) cycled_B self.G_AB(fake_A, trainingTrue) # 身份损失输入已经是目标域时尽量不变抑制颜色漂移 same_B self.G_AB(real_B, trainingTrue) same_A self.G_BA(real_A, trainingTrue) # 判别器损失真标签1假标签0MSE即LSGAN dA_real self.D_A(real_A, trainingTrue) dA_fake self.D_A(fake_A, trainingTrue) dB_real self.D_B(real_B, trainingTrue) dB_fake self.D_B(fake_B, trainingTrue) d_loss_A self.mse(tf.ones_like(dA_real), dA_real) \ self.mse(tf.zeros_like(dA_fake), dA_fake) d_loss_B self.mse(tf.ones_like(dB_real), dB_real) \ self.mse(tf.zeros_like(dB_fake), dB_fake) d_loss 0.5 * (d_loss_A d_loss_B) # 生成器损失对抗 循环一致性 身份 g_adv self.mse(tf.ones_like(dA_fake), dA_fake) \ self.mse(tf.ones_like(dB_fake), dB_fake) g_cycle tf.reduce_mean(tf.abs(real_A - cycled_A)) \ tf.reduce_mean(tf.abs(real_B - cycled_B)) g_id tf.reduce_mean(tf.abs(real_B - same_B)) \ tf.reduce_mean(tf.abs(real_A - same_A)) g_loss g_adv self.lambda_cycle * g_cycle \ self.lambda_identity * g_id g_vars self.G_AB.trainable_variables self.G_BA.trainable_variables d_vars self.D_A.trainable_variables self.D_B.trainable_variables self.g_optimizer.apply_gradients(zip(tape.gradient(g_loss, g_vars), g_vars)) self.d_optimizer.apply_gradients(zip(tape.gradient(d_loss, d_vars), d_vars)) del tape return {g_loss: g_loss, d_loss: d_loss, g_adv: g_adv, g_cycle: g_cycle}lambda_cycle10.0让循环损失主导保证内容结构不丢lambda_identity0.5是辅助项数据域差异大时可以降到0.1差异小可以加到1.0。学习率2e-4、beta_10.5是GAN常用配置beta_1别用默认0.9动量太大会让判别器更新滞后。persistentTrue是因为要对同一个tape求两次梯度求完记得del tape释放。4.2 两个优化器和更新顺序代码里先更新生成器再更新判别器判别器的梯度基于更新前的假图。这个顺序不是唯一解先更新判别器也能跑但同一个训练步里两个都更新不要一个step只更新一个。常见翻车是只更新判别器或只更新生成器loss曲线看起来正常生成结果完全不变。另一个边界是判别器太强d_loss迅速接近0g_adv飙升生成图出现重复纹理。解决方法是降低判别器学习率到1e-4或者给判别器加一点输入噪声但噪声别太大0.1以内。训练循环手动写不用fit因为fit的默认循环不适合两个优化器trainer CycleGANTrainer(G_AB, G_BA, D_A, D_B, lambda_cycle10.0, lambda_identity0.5) steps_per_epoch max(len(files_A), len(files_B)) // 1 for epoch in range(50): for step, batch in enumerate(ds.take(steps_per_epoch)): logs trainer.train_step(batch) if step % 100 0: print(epoch, step, {k: float(v) for k, v in logs.items()})steps_per_epoch按较大域样本数除以batch_sizeds.take控制一个epoch的步数。打印频率100步一次太密会拖慢训练。正常形态是g_cycle前几个epoch快速下降然后平稳g_adv在0.2到0.5之间波动d_loss不会到0。如果g_cycle降到很低但生成图几乎不变检查身份损失是不是太大或者数据A/B本身太像。4.3 日志、保存与恢复每5个epoch保存一次生成器权重判别器权重可以隔久一点。保存路径固定文件名带epoch别覆盖唯一一份。恢复训练时先build模型再load优化器状态如果没保存学习率会从初始值重新开始前几个step loss会跳一下正常。日志里至少记g_loss、d_loss、g_adv、g_cycle、学习率和当前epoch。只看总loss没用g_loss下降可能是循环损失降了对抗部分崩了。if epoch % 5 0: G_AB.save_weights(fckpt/G_AB_epoch{epoch}.weights.h5) G_BA.save_weights(fckpt/G_BA_epoch{epoch}.weights.h5)保存前确认目录存在权重格式和加载环境一致。推理脚本只依赖生成器别把训练器的整包checkpoint带过去。5. 避坑与排查Keras 版 CycleGAN 最常见的五类翻车记录CycleGAN的训练动态比普通监督模型难读loss好看不代表结果能用loss难看也不一定马上停。下面五类是我在Keras实现里反复见到的翻车按现象、原因、解决拆开写方便对照排查。5.1 显存、形状和值域的排查现象一第一个step就报ResourceExhausted或者训练几分钟后OOM。原因通常是256×256、batch1看着不大但一个训练步要保留四个生成前向、四个判别前向和持久tape的中间张量峰值显存比推理高好几倍。解决顺序是先把image_size降到128再把ngf从64降到32n_blocks从6降到4还不行就关掉persistentTrue改成两次独立tape代价是前向多跑一遍。混合精度可以试但生成器最后一层tanh和损失里的MSE对数值范围敏感先小图验证再加。现象二解码时报通道数错误或者生成图偏色。原因多是输入里有RGBA、灰度图或CMYK JPEGdecode_image(channels3)对RGBA会丢alpha对灰度会复制三通道但某些带透明背景的PNG在resize后边缘会出现黑边。解决是训练前统一转RGB用脚本批量检查tf.shape发现4通道就转3通道透明背景图要么合成到白底要么直接剔除。不要指望数据管道里加一个channels3就万事大吉。现象三生成图整体灰暗或过曝D loss震荡。原因几乎都是值域错位真实图在[0,1]生成器tanh在[-1,1]或者反过来。判别器会靠整体亮度分辨真假生成器学不到纹理。解决是把归一化写死成(img / 127.5) - 1.0推理时输出用(x 1.0) * 127.5还原训练前抽batch打印min/max。不要在一个脚本里混用两套归一化。5.2 训练动态和权重加载的排查现象四生成器输出几乎相同的图g_cycle很低但g_adv很高。这是模式崩溃的典型形态。原因可能是判别器过强或者循环损失权重太大模型发现输出一张平均图就能降低循环误差。解决是先降低判别器学习率到1e-4或者把lambda_cycle从10降到5同时检查B域数据是否多样性不足。身份损失这时候不要加加了会让模型更倾向于保守输出。如果训练了20个epoch还是同一张图换一组数据增强或者重新初始化别硬撑。现象五推理加载权重后输出全黑、噪声或者和训练时完全不一样。原因多是权重错位保存的是训练器整包checkpoint变量名带g_ab前缀直接load到单独的G_AB上Keras可能部分匹配不报错。解决是训练时单独保存生成器权重加载前先build一次加载后跑一个dummy输入对比输出。还有一个隐蔽原因是推理脚本用了不同的归一化或不同的image_size256训练的模型用128推理输出会糊成一片。从那以后我每次加载权重都先跑一张固定验证图和训练日志里的图对一眼再批量推理。注意判别器输出是logits推理时不要对它加sigmoid再判断真假PatchGAN的分数是局部patch不是整图概率。6. 验收与进阶固定验证集、循环重建误差和滑动窗口推理训练loss只能说明优化在动不能说明模型能用。我一般固定20到50张验证图每5个epoch跑一次存三列原图A、生成B、重建A。先看重建误差再看翻译效果。重建误差突然升高说明循环分支崩了先回滚到上一个checkpoint翻译图局部糊但重建正常说明对抗部分不够调判别器学习率或身份损失。检查项怎么看异常处理循环重建L1固定验证本文还有配套的精品资源点击获取