
简介这份资源是CycleGAN的Keras实现代码包面向具备一定深度学习基础、希望上手无监督图像到图像转换的开发者与研究者。CycleGAN通过两个生成器与两个判别器的对抗博弈配合循环一致性损失在无需成对样本的条件下完成跨域风格迁移可应用于风格迁移、季节转换等场景。压缩包共7个文件包含4个Python脚本与3个数据压缩包整体约477MB脚本分别承担模型构建、数据加载、预测推理与残差网络生成器定义等职责数据包则提供马到斑马、苹果到橘子、莫奈到照片等经典非配对图像集便于直接复现实验。目前已有548人学习下载。借助这份实现读者可以理清生成器、判别器、对抗损失与循环一致性损失的代码组织方式掌握非配对数据的加载与预处理流程并基于预测脚本将训练好的模型迁移到自己的图像转换任务中。1. 拆开一个 CycleGAN 的 Keras 实现它到底能干什么手里这份资源是一个用 Keras 写的 CycleGAN 实现。CycleGAN 解决的是一个很具体的问题你手上有两堆不成对的图片比如一堆是普通照片、一堆是某位画师的风景画它们之间没有一一对应的关系但你想让模型学会“把照片变成画风”或者反过来。传统监督学习在这里直接歇菜因为没有配对数据。CycleGAN 用两个生成器和两个判别器靠循环一致性损失把这件事硬生生做成了。这份代码适合两类人一类是想跑通第一个图像到图像翻译 Demo 的深度学习新手另一类是准备在 Keras 上做二次开发、换数据集、调损失权重的熟手。它不依赖复杂的工程框架核心逻辑集中读起来不绕。下面从目录结构、核心模块、训练流程到改造思路一层层拆。2. 工程结构与技术选型为什么是 Keras 而不是 PyTorch2.1 目录布局与文件职责拿到源码包后先别急着跑。我一般会先扫一遍目录确认入口在哪、配置在哪、数据往哪放。这个项目的典型结构如下cyclegan-keras/ ├── data/ │ ├── trainA/ # A域训练图 │ └── trainB/ # B域训练图 ├── models/ │ ├── generator.py # 生成器定义 │ ├── discriminator.py # 判别器定义 │ └── cyclegan.py # 组合模型与训练逻辑 ├── utils/ │ ├── data_loader.py # 数据加载与预处理 │ └── visualize.py # 训练过程可视化 ├── config.py # 超参数集中管理 ├── train.py # 训练入口 └── test.py # 推理入口这个布局的好处是职责清晰models/只放网络结构utils/只放工具函数config.py把所有超参数收口。改实验的时候只动config.py不用满项目找参数。常见做法是把config.py写成一个类或字典训练脚本直接from config import cfg就能拿到所有配置。2.2 技术选型Keras TensorFlow 后端的取舍选 Keras 而不是 PyTorch 来做 CycleGAN核心原因在于 Keras 的Model类和compile/fit接口对新手更友好。CycleGAN 的训练不是标准的fit流程因为有两个生成器、两个判别器、多组损失需要自定义训练循环。但 Keras 仍然可以用train_on_batch或者tf.GradientTape来写灵活度够用。这个项目大概率用的是 TensorFlow 2.x 作为后端因为 TF 1.x 的静态图写自定义训练循环很痛苦。如果你本地是 TF 2.x 环境直接跑问题不大。依赖清单通常包括tensorflow2.4 numpy pillow matplotlib tqdm注意如果你的显卡驱动和 CUDA 版本不匹配TensorFlow 会在启动时报Could not load dynamic library cudart64_xxx.dll。这不是代码问题是环境问题。先用tf.config.list_physical_devices(GPU)确认 GPU 是否被识别。2.3 数据管线的设计CycleGAN 的数据加载比普通分类任务多一步它需要同时从 A 域和 B 域各取一批图而且这两批图不需要对应。data_loader.py里通常会写一个生成器函数用tf.data.Dataset或者简单的ImageDataGenerator来批量出图。import tensorflow as tf def load_image(path, image_size256): 读取单张图片并做归一化到 [-1, 1] image tf.io.read_file(path) image tf.image.decode_jpeg(image, channels3) image tf.image.resize(image, [image_size, image_size]) # CycleGAN 常用 tanh 输出所以输入归一化到 [-1, 1] image (tf.cast(image, tf.float32) / 127.5) - 1.0 return image def build_dataset(image_paths, batch_size1, image_size256): 从文件路径列表构建 tf.data.Dataset ds tf.data.Dataset.from_tensor_slices(image_paths) ds ds.map(lambda x: load_image(x, image_size), num_parallel_callstf.data.AUTOTUNE) ds ds.shuffle(buffer_size1000).batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds这里有几个参数值得注意。image_size默认 256是因为 CycleGAN 原论文用的就是 256×256显存占用和生成质量比较平衡。batch_size设成 1 不是偷懒原论文就是 batch size 1 训练的因为判别器用的是 PatchGAN每张图会产生一个 patch 矩阵而不是单个标量。归一化到[-1, 1]是为了配合生成器最后一层的tanh激活函数这个对应关系如果搞错训练会直接崩。3. 核心模块拆解生成器、判别器与循环一致性损失3.1 生成器ResNet 风格的编码器-解码器CycleGAN 的生成器不是简单的 U-Net而是带残差块的编码器-解码器结构。编码器做两次步长为 2 的卷积降采样然后接若干个残差块最后解码器用转置卷积或上采样恢复分辨率。from tensorflow.keras import layers, Model def residual_block(x, filters256): 一个标准的残差块两层卷积 跳跃连接 shortcut x x layers.Conv2D(filters, 3, paddingsame)(x) x layers.InstanceNormalization()(x) # 注意不是 BatchNorm x layers.Activation(relu)(x) x layers.Conv2D(filters, 3, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Add()([shortcut, x]) return x def build_generator(image_size256, num_res_blocks9): 构建生成器编码 - 残差 - 解码 inputs layers.Input(shape(image_size, image_size, 3)) # 编码器两次降采样 x layers.Conv2D(64, 7, paddingsame)(inputs) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) x layers.Conv2D(128, 3, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) x layers.Conv2D(256, 3, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) # 残差块堆叠 for _ in range(num_res_blocks): x residual_block(x) # 解码器两次上采样 x layers.Conv2DTranspose(128, 3, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) x layers.Conv2DTranspose(64, 3, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) # 输出层tanh 把值压到 [-1, 1] outputs layers.Conv2D(3, 7, paddingsame, activationtanh)(x) return Model(inputs, outputs, namegenerator)这段代码里最关键的一个选择是InstanceNormalization而不是BatchNorm。图像风格迁移任务里每张图的风格是独立的BatchNorm 会把一个 batch 内的统计量混在一起导致风格信息被稀释。InstanceNorm 对每张图单独做归一化风格迁移效果明显更好。这是血泪经验换成 BatchNorm 后生成图会发灰、对比度下降。num_res_blocks默认 9这是原论文对 256×256 输入的设置。如果你把image_size降到 128残差块可以减到 6如果升到 512建议加到 12 以上否则感受野不够大尺度结构会崩。3.2 判别器PatchGAN 的设计逻辑判别器不输出一个标量判断真假而是输出一个 N×N 的 patch 矩阵每个 patch 判断对应感受野的真假。这样做的好处是参数量小、训练稳定而且能捕捉局部纹理。def build_discriminator(image_size256): PatchGAN 判别器输出 patch 级别的真假判断 inputs layers.Input(shape(image_size, image_size, 3)) x layers.Conv2D(64, 4, strides2, paddingsame)(inputs) x layers.LeakyReLU(0.2)(x) x layers.Conv2D(128, 4, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.LeakyReLU(0.2)(x) x layers.Conv2D(256, 4, strides2, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.LeakyReLU(0.2)(x) # 最后一层输出单通道 patch 矩阵 outputs layers.Conv2D(1, 4, paddingsame)(x) return Model(inputs, outputs, namediscriminator)判别器里用LeakyReLU(0.2)而不是普通 ReLU是因为 GAN 训练中判别器容易“死掉”——如果所有负输入都被压成 0梯度就断了。LeakyReLU 给负半轴留了 0.2 的斜率梯度能传回去。这个 0.2 是经验值调到 0.1 或 0.3 也能跑但 0.2 最稳。3.3 循环一致性损失CycleGAN 的灵魂CycleGAN 能成立全靠循环一致性损失。逻辑是A 域的图经过生成器 G 变成 B 域的假图再经过生成器 F 变回 A 域应该和原图差不多。反过来也一样。def cycle_consistency_loss(real_a, real_b, gen_ab, gen_ba, lambda_cycle10.0): 循环一致性损失A-B-A 和 B-A-B 的重建误差 # G: A - B, F: B - A fake_b gen_ab(real_a) cycled_a gen_ba(fake_b) fake_a gen_ba(real_b) cycled_b gen_ab(fake_a) # 用 L1 而不是 L2L1 生成的图更锐利 forward_loss tf.reduce_mean(tf.abs(real_a - cycled_a)) backward_loss tf.reduce_mean(tf.abs(real_b - cycled_b)) return lambda_cycle * (forward_loss backward_loss)lambda_cycle默认 10.0这是原论文的推荐值。这个权重控制循环一致性损失在总损失里的占比。调太低生成的图会偏离输入太远内容保不住调太高风格迁移效果会变弱因为模型更倾向于“原样重建”。我一般会在 5 到 20 之间试10 是最常用的起点。损失函数用 L1 而不是 L2是因为 L2 对误差取平方大误差惩罚重、小误差惩罚轻生成结果偏模糊。L1 对误差线性惩罚边缘更锐利。这是图像生成任务里的常见做法。4. 训练流程与关键参数从零跑通一次完整训练4.1 训练循环的四个优化目标CycleGAN 的训练循环比普通 GAN 复杂因为要同时更新四个网络生成器 G、生成器 F、判别器 D_A、判别器 D_B。每个 batch 的更新顺序不能乱。import tensorflow as tf from config import cfg # 初始化四个网络和优化器 gen_ab build_generator(cfg.image_size, cfg.num_res_blocks) gen_ba build_generator(cfg.image_size, cfg.num_res_blocks) disc_a build_discriminator(cfg.image_size) disc_b build_discriminator(cfg.image_size) gen_ab_opt tf.keras.optimizers.Adam(2e-4, beta_10.5) gen_ba_opt tf.keras.optimizers.Adam(2e-4, beta_10.5) disc_a_opt tf.keras.optimizers.Adam(2e-4, beta_10.5) disc_b_opt tf.keras.optimizers.Adam(2e-4, beta_10.5) bce tf.keras.losses.BinaryCrossentropy(from_logitsTrue) tf.function def train_step(real_a, real_b): with tf.GradientTape(persistentTrue) as tape: # 生成假图 fake_b gen_ab(real_a, trainingTrue) fake_a gen_ba(real_b, trainingTrue) # 循环重建 cycled_a gen_ba(fake_b, trainingTrue) cycled_b gen_ab(fake_a, trainingTrue) # 判别器输出 disc_real_a disc_a(real_a, trainingTrue) disc_fake_a disc_a(fake_a, trainingTrue) disc_real_b disc_b(real_b, trainingTrue) disc_fake_b disc_b(fake_b, trainingTrue) # 对抗损失 gen_ab_loss bce(tf.ones_like(disc_fake_b), disc_fake_b) gen_ba_loss bce(tf.ones_like(disc_fake_a), disc_fake_a) # 循环一致性损失 cycle_loss cycle_consistency_loss( real_a, real_b, gen_ab, gen_ba, cfg.lambda_cycle) # 总生成器损失 total_gen_loss gen_ab_loss gen_ba_loss cycle_loss # 判别器损失 disc_a_loss (bce(tf.ones_like(disc_real_a), disc_real_a) bce(tf.zeros_like(disc_fake_a), disc_fake_a)) * 0.5 disc_b_loss (bce(tf.ones_like(disc_real_b), disc_real_b) bce(tf.zeros_like(disc_fake_b), disc_fake_b)) * 0.5 # 分别更新四个网络 gen_ab_grads tape.gradient(total_gen_loss, gen_ab.trainable_variables) gen_ba_grads tape.gradient(total_gen_loss, gen_ba.trainable_variables) disc_a_grads tape.gradient(disc_a_loss, disc_a.trainable_variables) disc_b_grads tape.gradient(disc_b_loss, disc_b.trainable_variables) gen_ab_opt.apply_gradients(zip(gen_ab_grads, gen_ab.trainable_variables)) gen_ba_opt.apply_gradients(zip(gen_ba_grads, gen_ba.trainable_variables)) disc_a_opt.apply_gradients(zip(disc_a_grads, disc_a.trainable_variables)) disc_b_opt.apply_gradients(zip(disc_b_grads, disc_b.trainable_variables)) return total_gen_loss, disc_a_loss, disc_b_loss这段代码有几个容易翻车的地方。第一from_logitsTrue必须和判别器最后一层不加 sigmoid 对应。如果判别器输出层加了 sigmoid这里就要改成False否则损失计算会出错。第二persistentTrue是因为要对同一个 tape 求两次梯度生成器和判别器各一次不加这个参数第二次调用tape.gradient会报错。第三Adam 的beta_1设成 0.5 而不是默认的 0.9这是 GAN 训练的惯例动量太大会导致训练震荡。4.2 学习率与迭代次数的实际设置学习率 2e-4 是 CycleGAN 的标配四个网络都用这个值。迭代次数取决于数据集大小通常每个 epoch 遍历一遍 A 域和 B 域的所有图。如果 A 域有 500 张图、B 域有 300 张图一个 epoch 的步数取两者中较大的那个不足的域循环采样。# config.py 中的典型配置 class Config: image_size 256 num_res_blocks 9 batch_size 1 lambda_cycle 10.0 lambda_identity 0.5 # 身份损失权重 learning_rate 2e-4 beta_1 0.5 epochs 200 save_interval 10 # 每 10 个 epoch 存一次模型lambda_identity是身份损失权重控制生成器在输入已经是目标域风格时保持原样的程度。这个损失不是必须的但加上之后训练更稳定尤其是颜色偏移问题会明显改善。0.5 是一个保守值调到 1.0 也可以但太高会限制风格迁移的幅度。4.3 训练过程监控与模型保存训练过程中需要定期保存生成器的输出图否则你只能看到损失数字不知道模型到底学到了什么。visualize.py里通常会写一个函数每个 epoch 结束后拿几张固定测试图跑一遍生成器拼成网格保存。import matplotlib.pyplot as plt def save_sample_images(gen_ab, gen_ba, test_a, test_b, epoch, output_dir): 保存 A-B 和 B-A 的生成结果 fake_b gen_ab(test_a, trainingFalse) fake_a gen_ba(test_b, trainingFalse) # 反归一化到 [0, 1] 才能正常显示 fake_b (fake_b 1.0) / 2.0 fake_a (fake_a 1.0) / 2.0 fig, axes plt.subplots(2, 4, figsize(16, 8)) for i in range(4): axes[0][i].imshow((test_a[i] 1.0) / 2.0) axes[0][i].set_title(Real A) axes[0][i].axis(off) axes[1][i].imshow(fake_b[i]) axes[1][i].set_title(Fake B) axes[1][i].axis(off) plt.savefig(f{output_dir}/epoch_{epoch:03d}.png) plt.close()模型保存用tf.train.Checkpoint或者 Keras 的model.save_weights都行。我一般会同时保存生成器和判别器的权重因为如果要恢复训练判别器的状态也需要。只存生成器的话恢复训练时判别器从随机初始化开始前几个 epoch 的对抗损失会突然跳变。5. 避坑与常见问题训练不收敛、显存爆炸、生成图发灰5.1 生成器输出全黑或全白现象训练几十个 epoch 后生成器输出的图要么全黑要么全白损失值不再下降。原因最常见的原因是输入归一化范围搞错了。如果输入图归一化到[0, 1]但生成器最后一层是tanh输出[-1, 1]判别器收到的真假图分布不一致训练会崩。另一个原因是学习率太高生成器一步跳太远直接进入饱和区。解决检查load_image里的归一化公式确认是(image / 127.5) - 1.0。然后把学习率降到 1e-4 试几个 epoch如果损失开始下降再慢慢加回去。5.2 显存不足OOM现象启动训练后报ResourceExhaustedError: OOM when allocating tensor。原因image_size设成 256 时生成器的残差块有 9 个每个残差块两层 256 通道的卷积显存占用不小。如果显卡只有 6GB 或 8GBbatch size 又大于 1很容易爆。解决把batch_size固定为 1image_size降到 128num_res_blocks降到 6。如果还是不够把生成器的通道数从 64/128/256 降到 32/64/128。这些改动会损失一些生成质量但至少能跑起来。5.3 生成图发灰、对比度低现象生成的图看起来像蒙了一层灰颜色不鲜艳。原因大概率是归一化层用错了。如果在生成器里用了BatchNormalization而不是InstanceNormalization一个 batch 内的统计量会互相干扰风格信息被平均掉。解决全局搜索BatchNormalization全部替换成InstanceNormalization。如果用的是 TensorFlow 2.xtf.keras.layers.InstanceNormalization可能不在标准层里需要自己实现或从tensorflow_addons导入。5.4 判别器损失迅速降到 0现象训练开始几个 step 后判别器损失就接近 0生成器损失飙升。原因判别器太强了生成器完全跟不上。这在 GAN 训练里叫“判别器碾压”。可能是判别器的学习率比生成器高或者判别器的参数量远大于生成器。解决把判别器的学习率降到生成器的一半或者给判别器加 dropout。另一个办法是每更新两次生成器才更新一次判别器让生成器有更多机会追赶。5.5 循环一致性损失不下降现象对抗损失在波动但循环一致性损失一直停在很高的值。原因lambda_cycle设得太低模型只顾着骗判别器不管重建质量。或者生成器的容量不够学不会恒等映射。解决把lambda_cycle从 10 加到 20 试试。如果还不行检查生成器的残差块数量是否够256×256 输入至少需要 9 个残差块才能覆盖足够的感受野。6. 二次开发与改造换数据集、调损失、加注意力机制6.1 换数据集的完整步骤这份代码默认的数据目录是data/trainA和data/trainB。换数据集时把两个域的图分别放进这两个目录即可不需要配对。图片格式支持 jpg 和 png尺寸不要求一致load_image会统一 resize 到image_size。有一个细节要注意两个域的图片数量不需要相等。如果 A 域 1000 张、B 域 200 张训练时每个 epoch 的步数按 1000 算B 域的图会被重复采样。这不会导致问题但 B 域的判别器可能见过同一张图很多次容易过拟合。我一般会尽量让两个域的图片数量在一个量级内。6.2 调整损失权重的实验方法CycleGAN 的总损失由三部分组成对抗损失、循环一致性损失、身份损失。三个权重分别在config.py里控制。做实验时我习惯固定两个、只调一个否则变量太多根本不知道是哪个起了作用。参数默认值调大效果调小效果lambda_cycle10.0重建更准风格迁移变弱风格迁移更强内容可能崩lambda_identity0.5颜色偏移减少风格变化受限风格变化更自由颜色可能偏learning_rate2e-4训练更快容易震荡训练稳定收敛慢调参时建议每次只动一个跑 20 个 epoch 看生成图的变化。如果 20 个 epoch 还看不出趋势说明学习率太低或者模型容量不够。6.3 加注意力机制的改造思路如果想让生成器更关注关键区域比如人脸、建筑边缘可以在残差块里加一个通道注意力模块。改造的位置在residual_block函数里在第二层卷积之后、跳跃连接之前插入。def channel_attention(x, reduction16): 通道注意力学习每个通道的重要性权重 channels x.shape[-1] # 全局平均池化 - 两层全连接 - sigmoid gap layers.GlobalAveragePooling2D()(x) fc1 layers.Dense(channels // reduction, activationrelu)(gap) fc2 layers.Dense(channels, activationsigmoid)(fc1) # 重塑成 (1, 1, channels) 再乘回原特征 scale layers.Reshape((1, 1, channels))(fc2) return layers.Multiply()([x, scale]) def residual_block_with_attention(x, filters256): shortcut x x layers.Conv2D(filters, 3, paddingsame)(x) x layers.InstanceNormalization()(x) x layers.Activation(relu)(x) x layers.Conv2D(filters, 3, paddingsame)(x) x layers.InstanceNormalization()(x) x channel_attention(x) # 插入注意力 x layers.Add()([shortcut, x]) return x这个注意力模块的参数量很小reduction16时只增加几千个参数但能让生成器更聚焦于有意义的区域。加完之后训练时间会增加大约 10% 到 15%显存占用增加不多。如果发现训练不稳定把reduction调到 8 或 32 试试。6.4 推理脚本的改造test.py默认是加载训练好的生成器对单张图做转换。如果要做批量推理把输入改成目录遍历输出保存到指定文件夹。import os import numpy as np from PIL import Image def batch_inference(gen_ab, input_dir, output_dir, image_size256): 批量把 A 域图片转成 B 域风格 os.makedirs(output_dir, exist_okTrue) for fname in os.listdir(input_dir): if not fname.lower().endswith((.jpg, .png)): continue img Image.open(os.path.join(input_dir, fname)).resize( (image_size, image_size)) arr np.array(img, dtypenp.float32) / 127.5 - 1.0 arr np.expand_dims(arr, axis0) fake gen_ab(arr, trainingFalse)[0].numpy() fake ((fake 1.0) * 127.5).clip(0, 255).astype(np.uint8) Image.fromarray(fake).save(os.path.join(output_dir, fname))批量推理时要注意显存如果图片很多不要一次性全部加载到内存。上面的写法是逐张处理速度慢但显存占用恒定。如果要加速可以攒一个 batch 再送进生成器但 batch size 不要超过 4否则 256×256 的图很容易爆显存。6.5 一个我踩过的坑有一次我把lambda_cycle从 10 调到 50想让重建更准。结果训练了 30 个 epoch生成图确实和输入很像但风格完全没变——模型学会了恒等映射直接跳过生成器。从那以后我每次调损失权重都会先跑 5 个 epoch 看生成图有没有风格变化再决定要不要继续。如果 5 个 epoch 后生成图和输入几乎一样说明循环一致性权重太高了得往下调。希望这份拆解能帮你把 CycleGAN 跑起来并且在改的时候少走点弯路。本文还有配套的精品资源点击获取