☰
Keras实现CycleGAN:无配对图像翻译训练与避坑指南
2026/10/26 12:01:55 网站建设 项目流程

简介:CycleGAN的Keras实现配套压缩包面向已具备深度学习基础、希望用Keras复现无监督图像到图像转换的开发者与学习者,重点解决非配对样本下的跨域转换实验需求。包内共7个文件,以4个Python脚本和3个zip数据包为主,脚本覆盖生成器、判别器、损失函数、训练流程、数据加载与预测等核心环节,数据包则提供马到斑马、苹果到橘子等非配对图像集,整体约477.41MB。已有549人学习下载,适合按模块拆解CycleGAN的双生成器与双判别器结构、循环一致性损失及对抗训练机制。读者可据此搭建可运行的实验基线,理解ResNet生成器如何完成风格迁移,并借助预测脚本将模型用于新图像转换,快速排查数据组织、训练稳定性与转换效果等问题。

1. CycleGAN 的 Keras 实现:只有两堆不成对的照片,怎么让马变成斑马

手头有几百张马的照片,还有几百张斑马的照片,一张配对的都没有。这种任务放在监督学习里就是死局,而 CycleGAN 正是为它设计的:两个生成器、两个判别器互相对抗,把 A 域的风格搬到 B 域,再用循环一致性损失把内容拉回来,保证马还是那匹马、只是皮换了。这份 Keras 实现把论文里的四个网络、四组损失和完整的训练循环都落到了 TensorFlow 2.x 的函数式 API 上,没有过度封装,一个文件对应一个职责。它适合两类开发者:刚学完 GAN 基础、需要一个能真正跑起来跑完 200 个 epoch 的完整工程;或者手上有无配对数据(风格迁移、线稿上色、季节互换),需要一个改得动的底座。下面按目录、网络、损失、训练、踩坑、改造的顺序一层层拆开。

2. 工程骨架与选型:从目录结构看这份实现怎么组织两个生成器

拿到一份深度学习工程,先看目录,比先看 README 更快定位问题。CycleGAN 的特殊之处在于它有四个网络(G、F、D_X、D_Y)和三套优化器状态,如果全塞进一个 train.py,改损失函数的时候会非常难受。这份实现的拆法是把「层」「网络」「损失」「数据」各自独立,训练脚本只负责编排。

2.1 目录结构:每个文件只负责一件事

cyclegan-keras/ ├── config.py # 图像尺寸、batch、epoch、损失权重集中在这 ├── data/ │ ├── trainA/ # A 域,比如马 │ └── trainB/ # B 域,比如斑马 ├── models/ │ ├── layers.py # InstanceNorm、残差块、上采样块 │ ├── generator.py # build_generator() │ └── discriminator.py # build_discriminator() ├── losses.py # 对抗 / 循环一致 / identity 三组损失 ├── dataset.py # tf.data 读图、增强、batch ├── train.py # 自定义训练循环 + checkpoint ├── predict.py # 单图 / 目录批量转换 └── outputs/ # 训练中间采样图

config.py单独存在的意义比看上去大。CycleGAN 的调参点很多——lambda_cyc、lambda_id、n_res、学习率衰减拐点——散落在各个文件里,做消融实验时会疯。我一般把这类超参收成一个字典,训练开始时打印一遍并写进日志目录,两周后回看才知道当时跑的是哪套配置。

models/下的三个文件是纯网络定义,不 import 任何数据或损失模块,这样可以单独拿出来在别的项目里复用生成器。layers.py里最关键的是 InstanceNorm 和残差块,这两个东西在 Keras 里没有现成的、行为完全对齐论文的实现,必须自己写。

2.2 为什么用 Keras 而不是直接抄 PyTorch 版本

CycleGAN 的训练循环是「一次前向、四组梯度、三套优化器」,这种结构用model.fit()写会非常别扭。Keras 提供的tf.GradientTape(persistent=True)正好能解决:一次前向传播记下所有中间张量,然后反复调用tape.gradient分别求 G 和两个 D 的梯度,最后del tape释放。这个组合比 PyTorch 的loss.backward(retain_graph=True)语义更清晰,也不会出现「忘了 retain_graph 导致第二次 backward 报错」的经典翻车。

另一个理由是tf.data。CycleGAN 需要从两个完全独立的目录读图、各自 shuffle、各自做随机翻转裁剪,最后 zip 成一对。tf.data.Dataset.list_files+map+zip三行就能拼出来,而且prefetch(AUTOTUNE)会自动用后台线程预取下一批,GPU 利用率比手写 DataLoader 稳。

选 Keras 的代价是自定义层要自己管 build 阶段的权重创建,稍有疏忽就是 shape 报错。所以layers.py里的每个自定义层都老老实实写了build()和call(),没有用 lambda 偷懒。

2.3 依赖与版本:Keras 3 是这条路上最大的变量

# requirements.txt tensorflow==2.15.0 # 默认仍然是 Keras 2.15,兼容性最好 numpy>=1.23 pillow>=9.0 tqdm matplotlib # 只用来画 loss 曲线和采样网格

注意:TensorFlow 2.16 之后默认打包的是 Keras 3,tf.keras.Model的很多行为和 Keras 2 不一样——尤其是自定义层里add_weight的参数名、Model.trainable_variables的顺序、以及混合精度下tf.GradientTape对 loss scale 的处理。要在新版本上跑,稳妥做法是显式pip install tf-keras然后在代码顶部import tf_keras as keras,而不是硬改。

显存方面,256×256、batch=1、残差块 9 个的配置,单卡 8GB 可以跑起来,但persistent=True的 tape 会保留全部中间张量,峰值比普通训练高 30% 左右。显存吃紧就先把分辨率降到 128、n_res改成 6,跑通再加回去——这个顺序比反过来省时间。

3. 网络与损失拆解:残差生成器、PatchGAN 判别器与四种 loss 的代码落地

CycleGAN 的结构图看起来对称漂亮,但真正写代码时最容易出问题的是三个细节:归一化用的是什么、padding 用的是什么、判别器输出的是标量还是分数图。这三个地方错一个,训练也能跑,loss 也会降,只是生成的图会变成灰噪点或者固定色块,而且你不会知道错在哪。

3.1 InstanceNorm 与残差块:不能直接用 BatchNorm 顶替

import tensorflow as tf from tensorflow.keras import layers class InstanceNormalization(layers.Layer): """按单个样本、单个通道做归一化,不跨 batch 统计""" def __init__(self, epsilon=1e-5, **kwargs): super().__init__(**kwargs) self.epsilon = epsilon def build(self, input_shape): c = input_shape[-1] self.scale = self.add_weight(name="scale", shape=(c,), initializer="ones") self.offset = self.add_weight(name="offset", shape=(c,), initializer="zeros") def call(self, x): mean, var = tf.nn.moments(x, axes=[1, 2], keepdims=True) # 只在 H、W 上求 x = (x - mean) / tf.sqrt(var + self.epsilon) return x * self.scale + self.offset def res_block(x, filters, kernel_size=3): """两层卷积 + 跳跃连接,中间夹 InstanceNorm""" y = layers.Conv2D(filters, kernel_size, padding="same", use_bias=False)(x) y = InstanceNormalization()(y) y = layers.Activation("relu")(y) y = layers.Conv2D(filters, kernel_size, padding="same", use_bias=False)(y) y = InstanceNormalization()(y) return layers.Add()([x, y])

tf.nn.moments的axes=[1, 2]是关键,只统计空间维,batch 维(axis 0)完全不参与。这就是 InstanceNorm 和 BatchNorm 的根本区别:风格迁移任务里 batch 通常是 1,用 BatchNorm 等于用当前这张图自带的统计量去归一化自己,训练和推理行为不一致,出图会出现明显的整体色偏。

use_bias=False也不是随手写的。卷积后面紧跟归一化层,归一化的offset会把偏置完全吸收掉,留着只是浪费参数。残差块里没有用padding="same"之外的特殊处理,是因为整体尺寸靠前后端的 stride 控制,中间保持分辨率不变。

3.2 生成器:下采样两次、残差堆叠、上采样两次

def build_generator(img_shape=(256, 256, 3), n_res=9, base=64, name="G"): inp = layers.Input(shape=img_shape, name="src") # 首层 7x7 大卷积核,感受野大,负责纹理基调 x = layers.Conv2D(base, 7, padding="same", use_bias=False)(inp) x = InstanceNormalization()(x) x = layers.Activation("relu")(x) # 两次 stride=2 下采样,256 -> 64 for f in (base * 2, base * 4): x = layers.Conv2D(f, 3, strides=2, padding="same", use_bias=False)(x) x = InstanceNormalization()(x) x = layers.Activation("relu")(x) # 残差块堆叠,256 尺寸用 9 个,128 用 6 个 for _ in range(n_res): x = res_block(x, base * 4) # 两次最近邻上采样 + 卷积还原分辨率 for f in (base * 2, base): x = layers.UpSampling2D(2, interpolation="nearest")(x) x = layers.Conv2D(f, 3, padding="same", use_bias=False)(x) x = InstanceNormalization()(x) x = layers.Activation("relu")(x) # 输出层用 tanh,值域 [-1, 1],和后面数据预处理的归一化范围对齐 out = layers.Conv2D(img_shape[-1], 7, padding="same", activation="tanh")(x) return tf.keras.Model(inp, out, name=name)

n_res=9对应 256×256,是论文里给高分辨率任务的配置;128×128 用 6 个就够,再堆下去只会让显存爆掉而不会涨点。第一层用 7×7 大核而不是 3×3,是为了在进入残差堆之前就把感受野撑开,后面的残差块只负责局部纹理修改。

输出层activation="tanh"决定了整个数据管道的归一化方式必须是(x / 127.5) - 1,落在 [-1, 1]。这一条前后必须严格对齐,很多「生成图整体发灰」的问题源头就在这——读图时用了rescale=1./255归一化到 [0, 1],tanh 输出却是 [-1, 1],还原的时候又乘了 255,结果就是所有像素被压到 0 到 127 之间。

上采样用的是UpSampling2D(interpolation="nearest")加一次普通卷积,而不是Conv2DTranspose。后者在 stride 大于 1 时容易产生棋盘格伪影,尤其是训练早期。最近邻插值本身会产生块状感,但紧跟着的卷积会把它抹平,实测比转置卷积稳。

3.3 判别器:PatchGAN 输出的是 30×30 的分数图

def build_discriminator(img_shape=(256, 256, 3), base=64, n_layers=3, name="D"): inp = layers.Input(shape=img_shape) x = layers.Conv2D(base, 4, strides=2, padding="same")(inp) x = layers.LeakyReLU(0.2)(x) for i in range(1, n_layers + 1): mult = min(2 ** i, 8) # 通道倍数封顶 8 stride = 2 if i < n_layers else 1 # 最后一层 stride 回到 1 x = layers.Conv2D(base * mult, 4, strides=stride, padding="same", use_bias=False)(x) x = InstanceNormalization()(x) x = layers.LeakyReLU(0.2)(x) # 输出单通道分数图,不加 sigmoid,配合 LSGAN 的 MSE 损失 out = layers.Conv2D(1, 4, padding="same")(x) return tf.keras.Model(inp, out, name=name)

判别器不输出一个标量,而是输出一张 30×30 的分数图,每个位置对应原图上一个 70×70 的 patch 是真是假。这叫 PatchGAN,好处是参数量小、对高频纹理敏感,而且天然适配任意尺寸输入(全卷积结构,不依赖 Flatten + Dense)。对于风格迁移这类「局部纹理是否像目标域」的判断,patch 级别的监督比整图级别更有效。

n_layers=3意味着三次 stride=2 的下采样,感受野正好到 70×70。要处理更大尺寸或者想让判别器看得更远,把n_layers加到 4 就行,通道倍数有min(2**i, 8)封顶,不会无限涨。

输出层没有activation="sigmoid",这一点非常关键——如果加了 sigmoid,配合后面的 MSE 损失就等于在做最小二乘 GAN 的变体,但梯度尺度会完全不同,训练会变得极其敏感。判别器输出 logits、损失函数内部自己决定怎么用,是更清晰的分工。

3.4 四组损失:对抗、循环一致、identity

def d_loss(real_logits, fake_logits): """LSGAN 判别器损失:真样本往 1 拉,假样本往 0 拉""" real_loss = tf.reduce_mean(tf.square(real_logits - 1.0)) fake_loss = tf.reduce_mean(tf.square(fake_logits - 0.0)) return 0.5 * (real_loss + fake_loss) def g_adv_loss(fake_logits): """生成器希望判别器把假样本判成真""" return tf.reduce_mean(tf.square(fake_logits - 1.0)) def cycle_loss(real, cycled, lam=10.0): """A -> B -> A' 应该和 A 一样,L1 比 L2 更不容易糊""" return lam * tf.reduce_mean(tf.abs(real - cycled)) def identity_loss(real, same, lam=5.0): """G(Y) 应该约等于 Y,约束颜色分布不要乱飘""" return lam * 0.5 * tf.reduce_mean(tf.abs(real - same))

对抗损失用的是最小二乘形式(MSE)而不是交叉熵。原因是 LSGAN 的梯度在判别器已经很强的时候不会像 BCE 那样迅速饱和,生成器不至于「学不动」。代价是 loss 数值的量级和 BCE 完全不同,看曲线的时候不能拿论文里的数值对照,只能看趋势。

lam=10的循环一致性权重是论文给的默认值,实际项目里这个值偏保守——如果你的两个域差异非常大(比如卫星图转地图),循环一致性权重可以适当调小到 5,让生成器有更多自由度去改纹理;反过来如果只是换色调,调到 15 会更稳。

identity 损失不是所有任务都需要。马转斑马这种任务里它是可选甚至有害的(马的条纹不该被保留),但照片转油画、夏天转冬天这类任务里它能显著稳住色调,lam=5是常见起点。

4. 训练链路:从 tf.data 读图到 checkpoint 落盘的完整脚本

网络写完了只是搭好骨架,真正决定这份实现能不能跑出结果的,是数据管道和训练循环里的几个顺序问题:谁先更新、梯度对哪些变量求、checkpoint 里存了什么。

4.1 数据管道:两个独立目录怎么 zip 成一对

def load_image(path, size=256): img = tf.io.read_file(path) img = tf.image.decode_jpeg(img, channels=3) # 先放大再随机裁,比直接 resize 保留了更多原始纹理 img = tf.image.resize(img, [size + 30, size + 30], method="nearest") img = tf.image.random_crop(img, [size, size, 3]) img = tf.image.random_flip_left_right(img) img = (tf.cast(img, tf.float32) / 127.5) - 1.0 # [-1, 1],对齐 tanh return img def make_dataset(pattern, batch_size=1): ds = tf.data.Dataset.list_files(pattern, shuffle=True) ds = ds.map(load_image, num_parallel_calls=tf.data.AUTOTUNE) ds = ds.shuffle(256).batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds ds_A = make_dataset("data/trainA/*.jpg") ds_B = make_dataset("data/trainB/*.jpg") train_ds = tf.data.Dataset.zip((ds_A, ds_B))

resize之后再random_crop这个顺序不能反。先 resize 到 286 再裁回 256,相当于做了一次轻微的随机平移加缩放,比直接 resize 到 256 的增强效果更好,也不容易让网络记住固定的构图。

shuffle(256)的 256 是缓冲区大小,不是 batch size。这个值小于数据集规模时,shuffle 的随机性是不完整的——如果你的数据集只有 300 张,把缓冲区设成 1000 会更彻底。两个域的数据量差距大时,zip会在短的那个耗尽时停止,所以每轮 epoch 的步数由较小数据集决定,这是正常行为,不用去补齐。

4.2 训练步:一次前向、四组梯度、三套优化器

@tf.function def train_step(real_x, real_y): with tf.GradientTape(persistent=True) as tape: fake_y = G(real_x, training=True) cycled_x = F(fake_y, training=True) fake_x = F(real_y, training=True) cycled_y = G(fake_x, training=True) same_x = F(real_x, training=True) # identity: F(A) ≈ A same_y = G(real_y, training=True) # identity: G(B) ≈ B d_real_x = D_X(real_x, training=True) d_fake_x = D_X(fake_x, training=True) d_real_y = D_Y(real_y, training=True) d_fake_y = D_Y(fake_y, training=True) g_total = (g_adv_loss(d_fake_y) + g_adv_loss(d_fake_x) + cycle_loss(real_x, cycled_x) + cycle_loss(real_y, cycled_y) + identity_loss(real_x, same_x) + identity_loss(real_y, same_y)) dx_total = d_loss(d_real_x, d_fake_x) dy_total = d_loss(d_real_y, d_fake_y) g_vars = G.trainable_variables + F.trainable_variables dx_vars = D_X.trainable_variables dy_vars = D_Y.trainable_variables g_grads = tape.gradient(g_total, g_vars) dx_grads = tape.gradient(dx_total, dx_vars) dy_grads = tape.gradient(dy_total, dy_vars) del tape # persistent tape 必须手动释放 g_opt.apply_gradients(zip(g_grads, g_vars)) dx_opt.apply_gradients(zip(dx_grads, dx_vars)) dy_opt.apply_gradients(zip(dy_grads, dy_vars)) return g_total, dx_total, dy_total

这里最容易搞混的是「生成器损失里包含了判别器的前向」。d_fake_y = D_Y(fake_y)这一步在计算图里既连到 D_Y 的参数,也连到 G 的参数,但因为求梯度时只把g_vars传给tape.gradient,D_Y 的权重不会被更新。这就是tape.gradient传变量列表的意义——它只对你关心的那部分求导,剩下的自动忽略。

persistent=True的代价是中间张量不会被自动回收,必须del tape。忘了这一行,训练几十步之后就是显存 OOM,而且报错位置和真正原因对不上,属于典型的黑匣子问题。

更新顺序上,三套优化器在同一个 step 里依次 apply,G 和 D 用的是同一批前向结果。这比先更新 D 再重新前向算 G 的做法省一次前向,代价是 G 看到的是「旧 D」的梯度,实践中影响不大。

4.3 超参数与 checkpoint:别只存权重

参数默认值说明
img_size256降到 128 训练快 3 倍,细节明显变糊
batch_size1论文设置,显存友好,BN 已换成 IN 所以不受影响
lr2e-4Adam,beta_1=0.5,beta_2=0.999
lambda_cyc10循环一致性,域差异大时降到 5
lambda_id5identity,纯换风格任务必开
n_res9256 用 9,128 用 6
epochs200前 100 保持 lr,后 100 线性衰减到 0
def lr_schedule(epoch, total=200, lr_init=2e-4): if epoch < total // 2: return lr_init ratio = (epoch - total // 2) / (total // 2) return lr_init * (1.0 - ratio) ckpt = tf.train.Checkpoint(G=G, F=F, D_X=D_X, D_Y=D_Y, g_opt=g_opt, dx_opt=dx_opt, dy_opt=dy_opt) manager = tf.train.CheckpointManager(ckpt, "./ckpt/cyclegan", max_to_keep=3)

checkpoint 里必须带上三个优化器。Adam 内部维护着一阶和二阶动量,只存权重不存优化器状态,恢复训练后的头几百步 loss 会突然跳一下,然后花很久才回到原来的轨迹——这个现象很像「模型崩了」,实际只是动量被清零了。

学习率衰减用前一半恒定、后一半线性降到 0 的策略,比余弦退火在这个任务上更稳,因为 GAN 的训练本身就不平滑,余弦退火在后期降得太快,容易停在还没收敛的位置。

4.4 推理脚本:三行代码,但归一化要对齐

from PIL import Image def translate(model, img_path, out_path, size=256): img = load_image(img_path, size=size) # 复用训练时的预处理 pred = model(img[tf.newaxis, ...], training=False)[0] pred = tf.cast((pred + 1.0) * 127.5, tf.uint8).numpy() Image.fromarray(pred).save(out_path)

推理必须复用训练时的load_image,包括(x / 127.5) - 1这一步和尺寸。自己另写一份预处理是踩坑高发区,尤其是用keras.utils.load_img直接拿到 [0, 255] 的数组就喂进去,模型输出的会是一片噪声,但代码不报错。还原时的(pred + 1.0) * 127.5对应 tanh 的 [-1, 1],少乘或多除一次都会让整张图偏暗或过曝。

5. 避坑与排查:训练不收敛时先看这五个地方

CycleGAN 的坑有个共同特点——程序不报错,loss 也在降,但图就是不对。下面五条是这类问题里复现率最高的,按「先看现象、再找原因、最后改法」的顺序排。

5.1 生成图是灰噪点或纯色块,loss 却一直在降

现象:训练几十个 epoch 后,采样图始终是灰色噪点、或者整片单一颜色,但g_total和d_loss的曲线看起来很正常,甚至有下降趋势。

原因:判别器输出接了 sigmoid,损失却用的 MSE。这时判别器的输出被压在 (0, 1) 区间,tf.square(real_logits - 1.0)的最大值也就 1,梯度极小,D 学得极慢;G 拿到的对抗梯度几乎为零,只剩循环一致性在推,最后就退化成「输出平均值」这个平凡解。

解决:判别器最后一层去掉激活函数,直接输出 logits;或者保留 sigmoid 但把损失换成BinaryCrossentropy(from_logits=False)。两者选一,不要混。改完之后 D 的 loss 应该在 0.2 到 0.5 之间波动,而不是一路贴着 0.25 不动。

5.2 输出图上有明显的棋盘格纹理

现象:放大看生成图,能看到规则的网格状明暗交替,尤其在平坦区域(天空、墙面)最明显。

原因:上采样用了Conv2DTranspose且 stride=2、kernel 不能被 stride 整除,卷积核在重叠区域累加不均匀。这不是训练不充分,是结构性的。

解决:换成UpSampling2D(interpolation="nearest")加一次普通 3×3 卷积,也就是 3.2 节里的写法。如果一定要用转置卷积,把 kernel_size 设成 stride 的整数倍(4 配 2),并在后面接一个 3×3 卷积做平滑。这条改完立刻见效,不用重训。

5.3 batch=1 还是 OOM,报错指向 tape.gradient

现象:8GB 显卡跑 256×256、n_res=9,第一个 step 就 OOM,堆栈指向tape.gradient或apply_gradients。

原因:persistent=True的 tape 会把整张计算图的所有中间张量都留住,包括两个生成器的所有残差块激活值、两个判别器的所有特征图。这些加起来是普通训练的 2 到 3 倍。再加上zip两路数据同时驻留显存。

解决:按代价从低到高依次试——先把n_res从 9 降到 6;再把分辨率降到 128;再打开混合精度tf.keras.mixed_precision.set_global_policy("mixed_float16"),注意把优化器包一层LossScaleOptimizer;最后才是减base通道数。混合精度在 GAN 上要小心,判别器的 logits 用 float16 会掉精度,输出层记得dtype="float32"。

5.4 循环一致性 loss 降不下去,生成器只加了一层色偏

现象:cycle_loss卡在 0.15 左右不再下降,生成的图看起来只是原图调了个色,形状完全没变。

原因:lambda_cyc设得太小,或者 identity 损失权重太大。identity 损失的本意是「G(B) 约等于 B」,如果权重过高,G 会倾向于学成恒等映射,因为这样 identity loss 直接为 0,而对抗损失和循环损失的梯度被压过去了。

解决:先把lambda_id从 5 降到 1 甚至关掉,观察cycle_loss是否开始下降、生成图是否开始出现结构变化。如果还不行,把lambda_cyc从 10 提到 15。反过来,如果你的任务确实只需要换色调(比如季节转换),那「只加色偏」可能正是想要的结果,别急着调。

5.5 从 checkpoint 恢复后 loss 突然跳变

现象:训练到第 80 个 epoch 中断,从 checkpoint 恢复后 loss 从 0.3 跳到 0.9,之后几十个 epoch 都回不到原来的轨迹。

原因:tf.train.Checkpoint里只存了四个网络的权重,没存三个优化器。Adam 的动量被清零,恢复后第一步的更新方向等于从头开始,而权重已经在了一个比较精细的位置,这一大步直接把模型踹出了局部最优。

解决:把g_opt、dx_opt、dy_opt一起放进Checkpoint构造参数里。另外学习率的当前值如果是在 Python 侧用变量维护的,也要一起存;用optimizer.learning_rate.assign()修改的是优化器内部变量,会跟着 optimizer 一起被 checkpoint 保存,所以推荐这种写法而不是在外部传参。

6. 二次开发:换数据集、调损失权重和加注意力的三条改造路径

拿到这份实现之后,最常见的三种改法分别对应不同的改动量和风险,先想清楚要动哪一层,比直接开改省事。

改造目标要改的文件改动量主要风险
换自己的数据集data/目录 +config.py极小两域数据量差距过大导致 zip 提前耗尽
调损失权重 / 换损失形式losses.py+config.py小权重失衡,某一项压制其余项
生成器换 U-Net 或加注意力models/generator.py中参数量上涨导致 OOM,跳跃连接与 IN 顺序冲突
扩到多域(一个 G 管 N 个域)结构级重构大判别器数量翻倍,训练时长线性增长

换数据集这条最安全,只要保证两个目录里都是 RGB 三通道、格式统一,其余什么都不用动。有个小技巧:先用各 50 张图跑 20 个 epoch,看能不能过拟合出明显的风格迁移效果。如果 50 张都学不出东西,说明是代码或超参问题,不是数据量问题——这个验证步骤我每次都走,能省掉大量「以为是数据不够」的无效等待。

调损失是最容易出成果也最容易翻车的地方。改losses.py里的权重时,一次只动一个参数,并且把改动写进日志目录名,比如exp_lid1_cyc15。同时改三个权重,最后效果好你也不知道是哪个起的作用,效果差更不知道该回退哪个。

生成器换 U-Net 是个常见需求,因为 U-Net 的跳跃连接能把浅层纹理直接送到输出,对小数据集更友好。改的关键在于编码器和解码器之间的 skip connection 要接在 InstanceNorm 之后、激活之前,否则归一化的统计量会把浅层特征的整体分布拉偏。加注意力(比如在残差块里插一个通道注意力)则是另一条路,代价是参数量上涨 10% 到 20%,在 128 分辨率下可以接受,256 下要先算显存。

改造后的验证方法不要只看 loss 曲线。我一般固定三张测试图(一张典型、一张极端、一张边缘案例),每 10 个 epoch 生成一次拼成网格存到outputs/,训练结束后直接翻图片序列,比看曲线直观得多。曲线好看但图糊的情况太多了。

说个自己的教训:早期做这类项目,我总是先改结构再加功能,结果每次出问题都要在「是结构改坏了还是数据有问题」之间反复横跳。从那以后我每次动models/之前,都强制先用原始结构在当前数据集上跑一个 20 epoch 的基线,把 loss 和采样图存下来,改完之后对照着看。这个习惯让我少走了很多弯路,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询