资讯动态

CycleGAN Keras无配对图像翻译实现与训练避坑

发布时间:2026/10/26 3:41:00 来源:尧图企业网站定制
简介这份面向深度学习开发者与图像生成爱好者的CycleGAN Keras实现资料包聚焦无配对样本条件下的图像到图像转换任务可用于风格迁移、季节转换等跨域实验。压缩包共7个文件以4个Python脚本和3个压缩数据集为主整体约477.41MB其中Python脚本覆盖生成器与判别器构建、数据加载、训练调度及预测流程压缩数据集则提供马与斑马、苹果与橙子、莫奈与照片等域转换素材便于直接开展实验。已有549人学习下载适合具备一定生成对抗网络与Keras基础、希望从代码层面理解CycleGAN的中级读者。通过这份资料读者可对照实现两个生成器与两个判别器的交替优化理解循环一致性损失与对抗损失如何共同约束转换的可逆性和真实感并借助数据加载与预测代码完成从环境准备到结果查看的完整实践为迁移到自有非配对图像集提供可复用的网络结构、训练思路与排错参考。1. 先看清楚这套 CycleGAN 的 Keras 实现到底解决什么问题手上有一批甲方发来的线稿还有一批自己拍的照片两边没有一一对应关系却想让模型学会两边风格互转——这种无配对训练的需求正是 CycleGAN 的 Keras 实现要啃下来的场景。它用 Python 加深度学习框架搭起两组生成器和两组判别器G 负责 A 到 BF 负责 B 到 A靠循环一致性把两张图来回翻译再比对从而在缺少配对样本的情况下学到一个像样的映射。生成对抗网络这套东西写起来不难难的是写对尤其是四组参数的更新顺序和归一化层的选择。适合已经能跑通普通 GAN、想拿下图像翻译落地的开发者也适合要在这个骨架上做二次改造的人。2. 项目骨架从目录分工到 Keras 后端选型动手读代码之前先得知道哪块该看。这套实现刻意把模型和训练拆开理由很实际——生成器和判别器要能单独被 import 去跑推理训练循环要能随便改成多卡或者换成别的损失。2.1 目录结构与各文件职责一份典型布局长这样源码包基本是这个形状cyclegan-keras/ ├── data/ │ ├── trainA/ # 域 A 的无配对图片放几百张就够开跑 │ └── trainB/ # 域 B数量不必和 trainA 相等 ├── models/ │ ├── layers.py # 实例归一化、反射填充这些自定义层 │ ├── generator.py # 生成器 G / F结构完全一样只是权重不同 │ ├── discriminator.py # 判别器 D_A / D_BPatchGAN │ └── losses.py # 对抗损失、循环损失、身份损失统一收口 ├── train.py # 主训练脚本dataset 循环 存档 ├── infer.py # 单图/目录推理导出翻译结果 ├── config.py # 超参集中管理避免散落在脚本里 └── requirements.txtmodels/里四个文件各管一摊这个切分不是强迫症。layers.py里的实例归一化会被生成器和判别器同时用到抽出来才不会两份实现悄悄跑偏losses.py把三个损失函数收在一起是为了后面调lambda权重时只改一个地方。config.py用 dataclass 存超参训练脚本和推理脚本都读它防止训练时image_size256、推理时忘了改回 128 这种低级事故。data/trainA和data/trainB不要求文件名对应、也不要求数量相等这是 CycleGAN 和 pix2pix 最根本的差别。但要注意两个目录里的图内容必须真属于两个域混进去几张风格不一致的图模型学出来的东西就会往那个方向偏而且这种偏是看不出来的只能靠生成样本肉眼验收。2.2 为什么用子类化 Model 而不是 Sequential生成器是单入单出用 Sequential 堆也能堆出来。真正逼着这套实现走函数式 API 或子类化的是判别器之外的部分训练时一个 step 里要同时算出fake_b、fake_a、rec_a、rec_b中间结果得在多个损失之间共享Sequential 没办法表达这种分叉。生成器和判别器本身用函数式 API 组装成tf.keras.Model训练器再包一层子类化 Model好处是train_step可以被tf.function编译同时model.save()能把优化器状态一起带走。血泪经验是别把两个生成器硬塞进一个 Model 的两个输出——看着省事等到要冻结其中一个调学习率的时候就难受了。# train.py 片段四个网络各自独立训练器只做聚合 G build_generator(nameG_A2B) F build_generator(nameF_B2A) D_A build_discriminator(nameD_A) D_B build_discriminator(nameD_B) trainer CycleGANTrainer(G, F, D_A, D_B, lambda_cycle10.0, lambda_id5.0) trainer.compile( g_opttf.keras.optimizers.Adam(2e-4, beta_10.5), d_opttf.keras.optimizers.Adam(2e-4, beta_10.5), loss_fnCycleGANLoss(modelsgan), )这段的角色分配要拎清楚G和F是两个方向相反的生成器输入输出形状相同但权重不共享D_A只判断这张图是不是来自域 AD_B同理。compile里传了两个优化器因为生成器和判别器要分开更新共用一个优化器会让梯度应用顺序变得不可控。Adam(2e-4, beta_10.5)是这套结构的常见起手式beta_1取 0.5 而不是默认的 0.9是为了让判别器的动量小一点别在真假样本之间来回甩。2.3 依赖版本与 GPU 环境确认requirements.txt里最小集合是tensorflow2.9、numpy、pillow、tqdm、matplotlib。Keras 在 2.4 之后已经并进 TF所以不需要单独装keras包反而是同时装了两个容易出问题——import 到的可能不是同一个后端报错信息还指向莫名其妙的位置。跑之前先确认框架能不能看到显卡python -c import tensorflow as tf; print(tf.__version__); print(tf.config.list_physical_devices(GPU))第一行输出决定你用不用得上混合精度第二行如果返回空列表说明驱动或 CUDA 版本不匹配这时候先别急着改代码把环境理顺再说。显存不够的情况下config.py里的batch_size降到 1 也能跑——CycleGAN 原论文本身就是 batch1实例归一化正是为这个场景准备的。显存开到 12G 以上可以试着把batch_size提到 4但循环一致性损失的数值尺度会变lambda_cycle要跟着重调。3. 生成器与判别器两组网络的 Keras 写法这一段是整套代码的心脏。生成器决定输出画质上限判别器决定训练能不能稳住两边的结构都不能想当然地写。3.1 生成器7×7 主干加 6 个残差块生成器的思路是先压后放把 256×256 的图压到 64×64在低分辨率上做 6 次残差变换再放回 256×256。压得下去是因为风格迁移改的是纹理和色调不是物体的空间位置低分辨率下算力花得值。# models/generator.py import tensorflow as tf from tensorflow.keras import layers from .layers import InstanceNormalization, reflection_pad def res_block(x, filters256): 残差块两条 3x3 卷积 一条恒等捷径 shortcut x x layers.Conv2D(filters, 3, paddingsame, use_biasFalse)(x) x InstanceNormalization()(x) x layers.ReLU()(x) x layers.Conv2D(filters, 3, paddingsame, use_biasFalse)(x) x InstanceNormalization()(x) return layers.Add()([shortcut, x]) # 逐元素相加不改通道数 def build_generator(nameG, num_res6): inp layers.Input(shape(256, 256, 3), nameimage_in) # 1) 7x7 主干反射填充避免边缘出现黑边 x reflection_pad(inp, 3) x layers.Conv2D(64, 7, strides1, use_biasFalse)(x) x InstanceNormalization()(x) x layers.ReLU()(x) # 2) 两次下采样通道 64 - 128 - 256 for f in (128, 256): x layers.Conv2D(f, 3, strides2, paddingsame, use_biasFalse)(x) x InstanceNormalization()(x) x layers.ReLU()(x) # 3) 6 个残差块只在这一层做特征变换 for _ in range(num_res): x res_block(x, 256) # 4) 两次上采样用最近邻 卷积避开创面棋盘格 for f in (128, 64): x layers.UpSampling2D(size2, interpolationnearest)(x) x layers.Conv2D(f, 3, paddingsame, use_biasFalse)(x) x InstanceNormalization()(x) x layers.ReLU()(x) # 5) 回到 3 通道tanh 把输出压到 [-1, 1] x reflection_pad(x, 3) out layers.Conv2D(3, 7, activationtanh, nameimage_out)(x) return tf.keras.Model(inp, out, namename)逐段看第一步的reflection_pad(inp, 3)是给 7×7 卷积做前置填充用镜像反射而不是补零因为补零会在图像四周引入一圈恒定值生成结果的边缘会明显发暗或发灰。num_res6对应 256×256 输入如果改成 128×128残差块数量降到 3 就够了网络容量和输入分辨率要匹配。上采样用UpSampling2D(interpolationnearest)加卷积而不是Conv2DTranspose原因是转置卷积的卷积核在重叠区域叠加不均匀输出会出现棋盘状纹理这个坑在很多复现里都能看到。最后一层activationtanh决定了输出范围是 [-1, 1]这一条必须和数据处理端的归一化对齐否则推理时会整体偏色——第四章和第五章都会再提这件事。3.2 判别器PatchGAN 输出 30×30 的分数图判别器不是输出一个 0 到 1 的标量而是输出一张 30×30 的分数图每个位置对应原图上一个约 70×70 的感受野。这么做的代价是判别器输出不再是单一概率收益是它对局部纹理更敏感生成的图不会出现整体看着像、细节一片糊的情况。# models/discriminator.py import tensorflow as tf from tensorflow.keras import layers from .layers import InstanceNormalization def build_discriminator(nameD): inp layers.Input(shape(256, 256, 3), nameimage_in) # 第一层不加归一化直接 4x4 stride2 下采样 x layers.Conv2D(64, 4, strides2, paddingsame)(inp) x layers.LeakyReLU(0.2)(x) # 继续下采样通道 64 - 128 - 256 for f in (128, 256): x layers.Conv2D(f, 4, strides2, paddingsame, use_biasFalse)(x) x InstanceNormalization()(x) x layers.LeakyReLU(0.2)(x) # 最后一层 stride1 输出单通道分数图不加激活 out layers.Conv2D(1, 4, paddingsame, namepatch_score)(x) return tf.keras.Model(inp, out, namename)LeakyReLU(0.2)的负斜率给 0.2是这套结构的常规取值目的是让判别器在假样本区域也能回传梯度不至于整片死掉。最后一层故意不加 sigmoid输出的是 logits损失函数那边用sigmoid_cross_entropy_with_logits或者 LSGAN 的平方误差来算数值上更稳。如果你抄来的实现最后一层带了activationsigmoid同时又用 LSGAN 损失那两套东西是打架的训练会明显发飘。输出形状是(batch, 30, 30, 1)None的 batch 维度表示可以吃任意 batch size。这个张量在损失计算时必须先reduce_mean成标量忘了这一步广播会让损失数值凭空放大 900 倍学习率看起来就像设错了一样。3.3 实例归一化与反射填充两处容易被抄错的细节这两块单独拎出来讲因为它们是复现结果和论文差距最大的两个来源。# models/layers.py import tensorflow as tf from tensorflow.keras import layers class InstanceNormalization(layers.Layer): 按单张图、单通道做归一化和 batch 大小无关 def __init__(self, epsilon1e-5, **kwargs): super().__init__(**kwargs) self.epsilon epsilon def build(self, input_shape): channels input_shape[-1] self.gamma self.add_weight(namegamma, shape(channels,), initializerones) self.beta self.add_weight(namebeta, shape(channels,), initializerzeros) super().build(input_shape) def call(self, x): # 只用空间维度 H、W 求均值方差batch 与 channel 维度都保留 mean, var tf.nn.moments(x, axes[1, 2], keepdimsTrue) return self.gamma * (x - mean) / tf.sqrt(var self.epsilon) self.beta def reflection_pad(x, pad): 镜像填充替代 Conv2D 的 paddingsame return layers.Lambda( lambda t: tf.pad(t, [[0, 0], [pad, pad], [pad, pad], [0, 0]], modeREFLECT), namefreflect_{pad} )(x)InstanceNormalization的关键在axes[1, 2]也就是只在 H 和 W 上统计不跨 batch。原论文用 batch1 训练BatchNorm 在 batch1 时方差估计基本失效训练会明显抖动所以必须换成实例归一化。gamma和beta是可学习参数别漏了有些简化实现直接省掉生成图像对比度会变平。reflection_pad用Lambda包装是因为tf.pad不是 Keras 层不能直接串进函数式模型。modeREFLECT要求填充量小于对应维度7×7 卷积配pad3正好不越界如果哪天把卷积核改到 9×9pad就得跟着改成 4否则运行时报错而报错信息只提形状不匹配不会告诉你是因为填充模式。4. 损失函数与训练循环四步更新里最容易写错的地方结构搭好只是万里长征第一步训练循环写不对模型照样能跑完 200 个 epoch 并且什么也学不会还不报错。这一章把损失和更新顺序拆开讲。4.1 三种损失对抗、循环一致、身份三个损失各管一件事对抗损失让生成的图像分布靠近目标域循环损失保证 a→b→a 能回到原图身份损失让生成器在拿到目标域图像时别乱改起稳定色调的作用。# models/losses.py import tensorflow as tf class CycleGANLoss: def __init__(self, modelsgan): self.mode mode def _adv(self, pred, target_is_real): 对抗损失pred 是判别器的分数图 target tf.ones_like(pred) if target_is_real else tf.zeros_like(pred) if self.mode lsgan: return 0.5 * tf.reduce_mean(tf.square(pred - target)) return tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits(labelstarget, logitspred)) def discriminator_loss(self, D_A, real_a, fake_a, D_B, real_b, fake_b): # 每个判别器都要对真样本判真和假样本判假各算一次 loss_a self._adv(D_A(real_a, trainingTrue), True) \ self._adv(D_A(fake_a, trainingTrue), False) loss_b self._adv(D_B(real_b, trainingTrue), True) \ self._adv(D_B(fake_b, trainingTrue), False) return 0.5 * (loss_a loss_b) def generator_loss(self, D_A, D_B, real_a, real_b, fake_a, fake_b, rec_a, rec_b, idt_a, idt_b, lambda_cycle, lambda_id): # 1) 对抗生成器希望判别器把假样本判成真 gan self._adv(D_A(fake_a, trainingTrue), True) \ self._adv(D_B(fake_b, trainingTrue), True) # 2) 循环一致性L1 比 L2 更不容易糊 cyc tf.reduce_mean(tf.abs(rec_a - real_a)) \ tf.reduce_mean(tf.abs(rec_b - real_b)) # 3) 身份F(a) 应该约等于 aG(b) 应该约等于 b idt tf.reduce_mean(tf.abs(idt_a - real_a)) \ tf.reduce_mean(tf.abs(idt_b - real_b)) return gan lambda_cycle * cyc lambda_id * idt_adv里的0.5是 LSGAN 的标准写法把平方误差折半让数值量级和 BCE 接近换损失函数时不用重调学习率。target_is_real控制标签是 1 还是 0判别器损失里真假各算一次再相加漏掉一半是最常见的错误之一——那样判别器只会学会说真对假样本毫无分辨力。循环损失用 L1 而不是 L2是因为 L2 对偏差取平方模型倾向于输出平均化的结果视觉上就是糊。lambda_cycle10.0是原论文的取值循环项比对抗项权重大一个数量级才能压住生成器随便画点什么骗过判别器的冲动。身份损失权重lambda_id常取0.5 * lambda_cycle也就是 5.0这个项在换色、换季节这类任务里作用明显缺了它生成的图容易出现整体色调漂移。4.2 一次 train_step 里四组梯度的更新顺序顺序错了训练曲线照样往下走但收敛到的东西完全不对。正确做法是先更新两个判别器再更新两个生成器且第二步必须用第一步生成的那批假样本。# train.py 片段 tf.function def train_step(self, batch): real_a, real_b batch # ---------- 第一步判别器 ---------- with tf.GradientTape() as tape_d: fake_b self.G(real_a, trainingTrue) fake_a self.F(real_b, trainingTrue) # stop_gradient切掉流回生成器的梯度判别器这一步只动自己 d_loss self.loss_fn.discriminator_loss( self.D_A, real_a, tf.stop_gradient(self.pool_A.query(fake_a)), self.D_B, real_b, tf.stop_gradient(self.pool_B.query(fake_b))) d_vars self.D_A.trainable_variables self.D_B.trainable_variables self.d_opt.apply_gradients(zip(tape_d.gradient(d_loss, d_vars), d_vars)) # ---------- 第二步生成器 ---------- with tf.GradientTape() as tape_g: fake_b self.G(real_a, trainingTrue) fake_a self.F(real_b, trainingTrue) rec_a self.F(fake_b, trainingTrue) # B - A 回到原图 rec_b self.G(fake_a, trainingTrue) # A - B 回到原图 idt_a self.F(real_a, trainingTrue) # 恒等映射 idt_b self.G(real_b, trainingTrue) g_loss self.loss_fn.generator_loss( self.D_A, self.D_B, real_a, real_b, fake_a, fake_b, rec_a, rec_b, idt_a, idt_b, self.lambda_cycle, self.lambda_id) g_vars self.G.trainable_variables self.F.trainable_variables self.g_opt.apply_gradients(zip(tape_g.gradient(g_loss, g_vars), g_vars)) return {d_loss: d_loss, g_loss: g_loss}tf.stop_gradient这一句不能省。判别器在更新时假样本是当成常量看的不然后面apply_gradients只对d_vars求梯度倒也不会错但计算图会把生成器的参数也带进来显存白吃一截。d_vars和g_vars两个列表要显式传别图省事用self.trainable_variables——那样两个网络的参数都会被更新判別器一边学着分辨真假、一边被生成器的梯度拉着走最后谁都学不好。第二步里fake_b和fake_a重新算了一遍而不是复用第一步的结果。这是因为第一步的图已经过了一次stop_gradient梯度链断了重算一遍才能让生成器拿到可回传的信号。这次重算的显存开销就是为什么batch_size开不大。4.3 图像缓冲池与学习率衰减图像缓冲池的作用是降低判别器的振荡。如果每个 step 都拿最新生成的假样本去训判别器判别器很容易被最近这一批带跑生成器也就跟着抖。池子里存 50 张历史生成图一半概率拿旧的、一半概率拿新的判别器看到的样本分布就更平滑。class ImagePool: 存 50 张历史生成图50% 概率用旧图替换当前图 def __init__(self, size50): self.size size self.images [] def query(self, image): if self.size 0: return image if len(self.images) self.size: self.images.append(image) return image if tf.random.uniform(()) 0.5: idx int(tf.random.uniform((), 0, len(self.images))) old self.images[idx] self.images[idx] image return old return image这个类里有个坑self.images是 Python list在tf.function里做 append 会被图模式拦住报的错还很难懂。我一般把池子放在train_step外面用普通的 eager 函数包一层或者干脆用tf.Variable定长数组实现。size50是原始设定数据集小于 500 张时把它调到 10 左右更合适池子太大会让早期生成的烂图反复出现拖慢收敛。学习率也得分段前 100 个 epoch 保持2e-4不变后 100 个 epoch 线性衰减到 0。这个衰减策略对最终画质影响很直接恒定学习率跑到底最后阶段的损失会一直在小幅震荡生成图的纹理细节始终差一口气。def lr_schedule(epoch, total_epochs200, base_lr2e-4): 前 half 恒定后 half 线性衰减到 0 half total_epochs // 2 if epoch half: return base_lr return base_lr * (1.0 - (epoch - half) / float(total_epochs - half)) # 每个 epoch 结束时手动调一次 for epoch in range(total_epochs): for batch in train_ds: train_step(batch) new_lr lr_schedule(epoch, total_epochs) trainer.g_opt.learning_rate.assign(new_lr) trainer.d_opt.learning_rate.assign(new_lr)两个优化器的学习率必须同步改只改生成器的那个判别器后 100 个 epoch 会一直以2e-4猛冲把刚稳定下来的生成器又推乱。4.4 断点续训Checkpoint 里要存什么训练到一半断电是常态存档写不全就得从头再来。tf.train.Checkpoint至少要包含四个网络、两个优化器和当前 epochckpt tf.train.Checkpoint( GG, FF, D_AD_A, D_BD_B, g_opttrainer.g_opt, d_opttrainer.d_opt, epochtf.Variable(0, dtypetf.int64), ) manager tf.train.CheckpointManager(ckpt, ./checkpoints/cyclegan, max_to_keep3) ckpt.restore(manager.latest_checkpoint) # 没有存档时返回 None不会报错优化器状态一定要存Adam的动量和二阶矩如果丢了恢复训练的头几十个 step 会是冷启动损失曲线会突然往上跳一截。epoch存成tf.Variable是为了能跟Checkpoint直接对接用普通 Python int 会存不进去。max_to_keep3是防止磁盘被撑爆CycleGAN 四个网络一存就是几百兆不注意的话跑一晚上能把盘写满。5. 避坑与排查跑起来不收敛时先看这几条下面这些是复现过程中反复撞上的现场按现象、原因、解决整理碰到问题先对着过一遍比漫无目的地改超参快得多。5.1 训练阶段的三个高频现场现象一损失曲线前几十个 step 就发散生成图全是彩色噪点。原因多半是判别器和生成器被一起更新了也就是apply_gradients时用了self.trainable_variables而不是显式拼接的d_vars/g_vars。判别器一边学习分辨真假、一边被生成器的损失拉扯两个目标互相打架梯度范数瞬间失控。解决方式是把两组变量列表打印出来看一眼长度判别器该是 4 个卷积层加 2 个归一化层的参数量生成器那边应该明显更多两个数字对不上就说明拼接写错了。现象二损失看着正常但生成图整体偏色A 域偏暖、B 域偏冷这类固定偏移。原因是身份损失缺席或者lambda_id设得过小。没有身份项约束生成器在拿到一张本来就是目标域的图时仍然会按训练出来的映射去改它色调一点点漂移。解决办法是把lambda_id补到0.5 * lambda_cycle如果是换季、换天气这类色彩差异大的任务可以再往上提到0.8 * lambda_cycle。这条我吃过亏一开始嫌身份损失多余给删了跑出来的结果整张图泛黄还以为是数据的问题。现象三训练几十个 epoch 后生成图基本等于输入原图几乎没变化。判别器太弱了。检查最后一层是不是误加了sigmoid然后配合 LSGAN 损失——两套目标函数叠在一起判别器的输出被压到 0 到 1 的窄区间平方误差梯度极小等于没在训练。另一个常见原因是判别器输出的分数图在算损失前忘了reduce_mean梯度被平均摊薄。改法是把最后一层的激活去掉只输出 logits损失函数里再统一处理标签。现象四跑两个 epoch 就 OOMbatch_size降到 1 还是爆。tf.function里对 Python list 做操作会不断累积计算图节点显存越跑越高典型表现是前几个 step 正常第十几个 step 突然爆。图像缓冲池的append就是重灾区。另一个来源是train_step里反复调用G和F每调一次就新建一段图。处理方式是把池子挪出tf.function以及确认G、F、D_A、D_B是在train_step外面构建好的对象而不是每次调用都重新 build 一遍。5.2 推理与续训阶段的两个现场现象五训练时tensorboard上预览图很漂亮用infer.py跑出来一片灰白。归一化没对齐。训练时读图是img / 127.5 - 1映射到 [-1, 1]推理时如果直接喂img / 255的 [0, 1] 张量生成器的输入分布完全对不上输出自然崩掉。反过来也一样生成器输出是 [-1, 1]保存图片前要(x 1) * 127.5再裁剪到 [0, 255]少了这步会得到一片纯白。这件事我在两个不同项目上各犯过一次从那以后推理脚本的预处理和训练脚本共用同一个函数。现象六断点续训后损失突然抬高要跑很久才回到原来的水平。Checkpoint里没存优化器。Adam的一阶、二阶矩是跟着参数走的权重恢复了但矩估计清零等效于换了个优化器重新开始。检查tf.train.Checkpoint的构造参数里有没有把g_opt、d_opt传进去另外学习率的调度也要一起恢复否则续训的 epoch 从 0 重新算学习率又会跳回2e-4本来已经衰减到很低的值被拉高模型直接被推离收敛点。6. 二次开发换分辨率、加损失、导出推理模型拿到这份骨架之后绝大多数改造集中在三个点输入分辨率、损失项、导出方式。改之前先把config.py里的超参和对应的网络结构对上下面是常改的几处对照。改造点位置改动量需要注意输入改 128×128config.pygenerator.pynum_res调到 3判别器输出分数图会缩到 ~14×14加感知损失losses.py引入预训练特征提取网络权重取 0.1 左右太大会盖过对抗项单方向推理infer.py只加载G不需要F和两个判别器导出 SavedModeltrain.py末尾G.save(export/G)归一化层要能被标准 Keras 加载导出推理模型这一步值得单独说。训练时G是函数式 Model直接G.save(export/G)就能得到 SavedModel但前提是InstanceNormalization和reflection_pad里的 Lambda 层能被正确序列化。Lambda 层在反序列化时需要提供custom_objects稳妥做法是把反射填充也写成带get_config的自定义层而不是用 Lambda 包tf.pad——这样导出的模型在别的服务里加载时不用再手工注册。# infer.py只加载单向生成器跑目录批量翻译 import tensorflow as tf from models.layers import InstanceNormalization from models.generator import build_generator def load_and_translate(src_dir, dst_dir, ckpt_path): # 结构必须和训练时一致否则权重对不上 G build_generator(nameG_A2B) ckpt tf.train.Checkpoint(GG) ckpt.restore(ckpt_path).expect_partial() # 只关心 G其他变量缺失不报错 for path in tf.io.gfile.glob(f{src_dir}/*.jpg): raw tf.io.decode_jpeg(tf.io.read_file(path), channels3) x tf.image.resize(raw, (256, 256)) x tf.cast(x, tf.float32) / 127.5 - 1.0 # 与训练端严格一致 y G(x[None, ...], trainingFalse)[0] y tf.clip_by_value((y 1.0) * 127.5, 0, 255) # 还原到 0-255 tf.io.write_file(path.replace(src_dir, dst_dir), tf.io.encode_jpeg(tf.cast(y, tf.uint8)))expect_partial()这个调用别省。存档里还有F、D_A、D_B和优化器状态只恢复G会触发一大片未恢复变量的告警刷屏之后真正的错误反而被淹掉。trainingFalse也要显式传虽然实例归一化不看这个标志但如果后面把归一化层换成BatchNorm做对比实验漏传会直接用上推理态的滑动统计量结果和训练时对不上。真正要提醒的是别一上来就动损失函数。先在原结构上跑通一次 200 epoch看着生成质量到瓶颈了再去加感知损失或者换损失权重——同时改结构和损失出问题的时候根本分不清是哪一边的锅。我现在的习惯是每次改造前先存一份原版权重做基线改完间隔 20 个 epoch 存一次图和人眼能分辨的基线样本并排比对比只看 loss 曲线靠谱得多。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑