简介: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训练图 | 数量不必等于A |
| data/testA | 固定验证A | 20到50张,不参与训练 |
| data/testB | 固定验证B | 用来看反向翻译 |
A→B方向训练一个生成器G_AB,B→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, channels=3, expand_animations=False) img = tf.image.resize( img, [image_size, image_size], method=tf.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, training=True): ds = tf.data.Dataset.from_tensor_slices(files) if training: # 数据少时不要硬写10000,否则会反复告警 ds = ds.shuffle(min(len(files), 1000), seed=42, reshuffle_each_iteration=True) ds = ds.map(lambda p: load_image(p, image_size), num_parallel_calls=AUTOTUNE) if not training: ds = ds.cache() ds = ds.batch(batch_size, drop_remainder=True) 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_size=256, batch_size=1, training=True) ds_B = make_dataset(files_B, image_size=256, batch_size=1, training=True) 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起步,显存够再上256;batch_size=1是CycleGAN常见起点,因为一个训练步要跑四个生成前向和四个判别前向,batch开大很容易爆显存。drop_remainder=True让每个batch形状固定,图模式编译更稳。shuffle的buffer取min(len(files), 1000),小数据集不要写死10000。num_parallel_calls=AUTOTUNE在机械硬盘上可能抢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 生成器和 PatchGAN
CycleGAN的模型结构不算复杂,难在接口边界:生成器输出什么值域、判别器输出logits还是概率、归一化层在batch=1时用什么。Keras实现里我优先用函数式API搭生成器和判别器,因为结构固定,保存和加载省心;训练步用tf.keras.Model子类化,因为要手动控制两个优化器。两者混用没问题,关键是别在子类化模型里再包一层函数式模型导致变量名混乱。
3.1 生成器为什么用反射填充和残差块
生成器常见结构是:反射填充、7x7卷积、两次stride=2下采样、若干残差块、两次转置卷积上采样、7x7卷积输出tanh。反射填充的作用是减少边缘伪影,普通零填充在图像边界会产生黑边,循环损失会把黑边带回原图。残差块数量按分辨率调整,256输入常用6或9个,128输入用4到6个。通道数ngf=64是常见起点,显存紧就降到32,但别低于16,否则纹理学不出来。
归一化层在小batch下比BatchNorm稳。CycleGAN常用InstanceNorm,Keras里如果没有现成的,可以用GroupNorm近似,组数取8,要求通道数能被8整除。下面代码里用GroupNormalization,如果你的环境没有这个层,换成一个自定义InstanceNorm层,接口保持一样。
import tensorflow as tf from tensorflow.keras import layers, Model def reflection_pad(x, pad=3): return tf.pad(x, [[0, 0], [pad, pad], [pad, pad], [0, 0]], mode="REFLECT") def res_block(x, filters): shortcut = x x = reflection_pad(x, 1) x = layers.Conv2D(filters, 3, strides=1, padding="valid", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(x) x = layers.Activation("relu")(x) x = reflection_pad(x, 1) x = layers.Conv2D(filters, 3, strides=1, padding="valid", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(x) return layers.Add()([shortcut, x]) def build_generator(image_size=256, ngf=64, n_blocks=6): inputs = layers.Input(shape=(image_size, image_size, 3)) x = reflection_pad(inputs, 3) x = layers.Conv2D(ngf, 7, strides=1, padding="valid", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(x) x = layers.Activation("relu")(x) # 两次下采样,256 -> 64 for mult in (2, 4): x = layers.Conv2D(ngf * mult, 3, strides=2, padding="same", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(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, strides=2, padding="same", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(x) x = layers.Activation("relu")(x) x = reflection_pad(x, 3) x = layers.Conv2D(3, 7, strides=1, padding="valid", activation="tanh")(x) return Model(inputs, x, name="G_AB")ngf=64控制第一层通道,后面依次乘2、乘4;n_blocks=6对应256输入,显存不够先降ngf再降块数。use_bias=False是因为后面接了归一化层,偏置会被抵消。最后一层tanh把输出压到[-1,1],和训练数据的值域对齐。转置卷积容易出棋盘伪影,如果生成图有明显网格,把上采样换成ResizeNearest加普通卷积,或者检查转置卷积的stride和kernel是否匹配。
3.2 判别器为什么用 PatchGAN 且输出 logits
判别器用PatchGAN,输出一个N×N的patch,每个位置判断局部真假,而不是整图一个分数。这样参数少,关注高频纹理,对大图更友好。256输入经过四次stride=2,输出大约16×16,每个点对应原图约70×70的感受野。最后一层输出1通道logits,不加sigmoid。损失函数用MSE时直接对logits算,等价于LSGAN;如果加sigmoid再算MSE,梯度会变平,训练前期判别器推不动。
def build_discriminator(image_size=256, ndf=64): inputs = layers.Input(shape=(image_size, image_size, 3)) x = layers.Conv2D(ndf, 4, strides=2, padding="same")(inputs) x = layers.LeakyReLU(0.2)(x) for mult in (2, 4, 8): x = layers.Conv2D(ndf * mult, 4, strides=2, padding="same", use_bias=False)(x) x = layers.GroupNormalization(groups=8)(x) x = layers.LeakyReLU(0.2)(x) # 输出patch logits,损失函数内部处理真假标签 x = layers.Conv2D(1, 4, strides=1, padding="same")(x) return Model(inputs, x, name="D_A")ndf=64是常见起点,判别器太强时先降到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 forward:
G_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_cycle=10、lambda_identity=0.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_cycle=10.0, lambda_identity=0.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_1=0.5) self.d_optimizer = tf.keras.optimizers.Adam(2e-4, beta_1=0.5) @tf.function def train_step(self, batch): real_A, real_B = batch with tf.GradientTape(persistent=True) as tape: fake_B = self.G_AB(real_A, training=True) cycled_A = self.G_BA(fake_B, training=True) fake_A = self.G_BA(real_B, training=True) cycled_B = self.G_AB(fake_A, training=True) # 身份损失:输入已经是目标域时尽量不变,抑制颜色漂移 same_B = self.G_AB(real_B, training=True) same_A = self.G_BA(real_A, training=True) # 判别器损失:真标签1,假标签0,MSE即LSGAN dA_real = self.D_A(real_A, training=True) dA_fake = self.D_A(fake_A, training=True) dB_real = self.D_B(real_B, training=True) dB_fake = self.D_B(fake_B, training=True) 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_cycle=10.0让循环损失主导,保证内容结构不丢;lambda_identity=0.5是辅助项,数据域差异大时可以降到0.1,差异小可以加到1.0。学习率2e-4、beta_1=0.5是GAN常用配置,beta_1别用默认0.9,动量太大会让判别器更新滞后。persistent=True是因为要对同一个tape求两次梯度,求完记得del tape释放。
4.2 两个优化器和更新顺序
代码里先更新生成器再更新判别器,判别器的梯度基于更新前的假图。这个顺序不是唯一解,先更新判别器也能跑,但同一个训练步里两个都更新,不要一个step只更新一个。常见翻车是只更新判别器或只更新生成器,loss曲线看起来正常,生成结果完全不变。另一个边界是判别器太强:d_loss迅速接近0,g_adv飙升,生成图出现重复纹理。解决方法是降低判别器学习率到1e-4,或者给判别器加一点输入噪声,但噪声别太大,0.1以内。
训练循环手动写,不用fit,因为fit的默认循环不适合两个优化器:
trainer = CycleGANTrainer(G_AB, G_BA, D_A, D_B, lambda_cycle=10.0, lambda_identity=0.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_size,ds.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(f"ckpt/G_AB_epoch{epoch}.weights.h5") G_BA.save_weights(f"ckpt/G_BA_epoch{epoch}.weights.h5")保存前确认目录存在,权重格式和加载环境一致。推理脚本只依赖生成器,别把训练器的整包checkpoint带过去。
5. 避坑与排查:Keras 版 CycleGAN 最常见的五类翻车记录
CycleGAN的训练动态比普通监督模型难读,loss好看不代表结果能用,loss难看也不一定马上停。下面五类是我在Keras实现里反复见到的翻车,按现象、原因、解决拆开写,方便对照排查。
5.1 显存、形状和值域的排查
现象一:第一个step就报ResourceExhausted,或者训练几分钟后OOM。原因通常是256×256、batch=1看着不大,但一个训练步要保留四个生成前向、四个判别前向和持久tape的中间张量,峰值显存比推理高好几倍。解决顺序是先把image_size降到128,再把ngf从64降到32,n_blocks从6降到4;还不行就关掉persistent=True,改成两次独立tape,代价是前向多跑一遍。混合精度可以试,但生成器最后一层tanh和损失里的MSE对数值范围敏感,先小图验证再加。
现象二:解码时报通道数错误,或者生成图偏色。原因多是输入里有RGBA、灰度图或CMYK JPEG,decode_image(channels=3)对RGBA会丢alpha,对灰度会复制三通道,但某些带透明背景的PNG在resize后边缘会出现黑边。解决是训练前统一转RGB,用脚本批量检查tf.shape,发现4通道就转3通道;透明背景图要么合成到白底,要么直接剔除。不要指望数据管道里加一个channels=3就万事大吉。
现象三:生成图整体灰暗或过曝,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_size,256训练的模型用128推理,输出会糊成一片。从那以后我每次加载权重都先跑一张固定验证图,和训练日志里的图对一眼,再批量推理。
注意:判别器输出是logits,推理时不要对它加sigmoid再判断真假,PatchGAN的分数是局部patch,不是整图概率。
6. 验收与进阶:固定验证集、循环重建误差和滑动窗口推理
训练loss只能说明优化在动,不能说明模型能用。我一般固定20到50张验证图,每5个epoch跑一次,存三列:原图A、生成B、重建A。先看重建误差,再看翻译效果。重建误差突然升高,说明循环分支崩了,先回滚到上一个checkpoint;翻译图局部糊但重建正常,说明对抗部分不够,调判别器学习率或身份损失。
| 检查项 | 怎么看 | 异常处理 |
|---|---|---|
| 循环重建L1 | 固定验证 |
本文还有配套的精品资源,点击获取