
简介这份资源是面向深度学习入门者与图像生成爱好者的Tensorflow实战项目围绕WGAN动漫头像生成展开帮助读者理解生成对抗网络从理论到落地的完整流程。压缩包共23个文件约122KB以8个Python源码文件为核心涵盖模型构建、训练与测试脚本另含7个XML配置、2个vsdx图形文件、2个PNG示意图及gitignore、iml、txt等辅助文件结构清晰便于按模块查阅。目前已有315人学习下载适合希望动手复现WGAN的开发者参考。项目重点呈现生成器与判别器的搭建思路、Wasserstein距离损失的应用以及数据预处理、训练调参和结果展示等环节读者可据此掌握动漫头像生成的关键实现并在此基础上调整网络结构与超参数完成自己的图像生成实验。1. 从一堆噪声到一张动漫脸WGAN 到底解决了什么问题如果你跑过最原始的 GAN 去生成动漫头像大概率见过这种场面判别器一路碾压生成器梯度消失最后输出一片灰蒙蒙的噪点训练日志里 d_loss 趋近于 0g_loss 却纹丝不动。这不是你参数调错了而是原始 GAN 的损失函数本身在用 JS 散度衡量两个分布当真实分布和生成分布几乎没有重叠时梯度就没了。WGAN 换成 Wasserstein 距离配合权重裁剪或梯度惩罚把「有没有重叠」这个问题绕开了训练稳定性提升非常明显。这篇要讲的就是基于 Tensorflow 的 WGAN 动漫头像生成实战从数据准备、模型搭建、训练循环到源码结构一步步拆开。适合两类人一类是刚学完 GAN 基础、想找一个能真正跑出结果的练手项目另一类是想把生成模型落到具体场景、需要一套可复现代码骨架的工程师。动漫头像这个数据集尺寸小、风格统一、训练成本低是验证 WGAN 是否跑通的最佳试验田。下面所有代码都基于 Tensorflow 2.x 的 Keras 接口不依赖额外的高层封装库。2. 数据管道与 WGAN 网络结构先把输入和输出对齐2.1 动漫头像数据集的获取与预处理常见做法是从公开的动漫头像数据集中取 64×64 或 128×128 的裁剪版本大约几万张。我一般会先统一尺寸再归一化到 [-1, 1]因为 WGAN 的生成器最后一层用 tanh 激活输出范围必须和输入对齐否则判别器一开始就能靠数值范围区分真假训练直接翻车。import tensorflow as tf import os IMG_SIZE 64 BATCH_SIZE 64 BUFFER_SIZE 60000 def load_and_preprocess(path): # 读取图片并解码为 RGB 三通道 img tf.io.read_file(path) img tf.image.decode_jpeg(img, channels3) # 统一缩放到固定尺寸避免不同来源图片尺寸不一致 img tf.image.resize(img, [IMG_SIZE, IMG_SIZE]) # 归一化到 [-1, 1]与生成器 tanh 输出范围匹配 img (tf.cast(img, tf.float32) - 127.5) / 127.5 return img def build_dataset(data_dir): # 收集目录下所有图片路径 paths [os.path.join(data_dir, f) for f in os.listdir(data_dir) if f.lower().endswith((.jpg, .png, .jpeg))] ds tf.data.Dataset.from_tensor_slices(paths) ds ds.map(load_and_preprocess, num_parallel_callstf.data.AUTOTUNE) ds ds.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainderTrue) # 预取数据避免 GPU 等 IO return ds.prefetch(tf.data.AUTOTUNE)这段代码里三个参数最关键IMG_SIZE决定后续所有卷积层的特征图尺寸改它就要同步改网络结构BATCH_SIZE在 64 左右比较稳太小梯度噪声大太大显存吃紧drop_remainderTrue是为了避免最后一个不完整 batch 在 BatchNormalization 时出问题。num_parallel_calls和prefetch是性能开关数据量大的时候不加这两个GPU 利用率可能只有一半。2.2 生成器与判别器的层设计取舍WGAN 的生成器和判别器结构本身没有强制规定但有几个经验性的取舍。生成器用转置卷积逐级放大从 100 维噪声到 64×64×3判别器用步长卷积逐级下采样最后输出一个标量分数。注意 WGAN 的判别器不接 sigmoid输出的是实数分数这是和原始 GAN 最直观的区别。from tensorflow.keras import layers, Model LATENT_DIM 100 def build_generator(): model tf.keras.Sequential([ # 输入噪声向量重塑为 1x1x100 的特征图 layers.Input(shape(LATENT_DIM,)), layers.Dense(4 * 4 * 256, use_biasFalse), layers.BatchNormalization(), layers.LeakyReLU(0.2), layers.Reshape((4, 4, 256)), # 4x4 - 8x8 layers.Conv2DTranspose(128, 4, strides2, paddingsame, use_biasFalse), layers.BatchNormalization(), layers.LeakyReLU(0.2), # 8x8 - 16x16 layers.Conv2DTranspose(64, 4, strides2, paddingsame, use_biasFalse), layers.BatchNormalization(), layers.LeakyReLU(0.2), # 16x16 - 32x32 layers.Conv2DTranspose(32, 4, strides2, paddingsame, use_biasFalse), layers.BatchNormalization(), layers.LeakyReLU(0.2), # 32x32 - 64x64最后一层用 tanh 输出 [-1,1] layers.Conv2DTranspose(3, 4, strides2, paddingsame, activationtanh) ]) return model def build_critic(): model tf.keras.Sequential([ layers.Input(shape(IMG_SIZE, IMG_SIZE, 3)), layers.Conv2D(32, 4, strides2, paddingsame), layers.LeakyReLU(0.2), layers.Conv2D(64, 4, strides2, paddingsame), layers.LeakyReLU(0.2), layers.Conv2D(128, 4, strides2, paddingsame), layers.LeakyReLU(0.2), layers.Conv2D(256, 4, strides2, paddingsame), layers.LeakyReLU(0.2), # 展平后输出一个分数不加 sigmoid layers.Flatten(), layers.Dense(1) ]) return model生成器里LeakyReLU(0.2)的斜率是常见默认值低于 0.1 容易导致梯度太小高于 0.3 训练会不稳定。判别器WGAN 里叫 critic每一层都不加 BatchNormalization这是 WGAN 原论文的建议因为 BN 会让每个样本依赖同 batch 其他样本破坏 Wasserstein 距离的独立性假设。如果你发现判别器太强可以把它的学习率调低或者减少一层卷积。2.3 用梯度惩罚替代权重裁剪原始 WGAN 用权重裁剪把判别器参数限制在 [-0.01, 0.01]但这个范围很难调太小梯度消失太大约束失效。WGAN-GP 改用梯度惩罚在真实样本和生成样本之间插值惩罚判别器在该点梯度偏离 1 的程度。这是目前更主流的做法。def gradient_penalty(critic, real, fake): batch_size tf.shape(real)[0] # 在真实和生成样本之间随机插值 alpha tf.random.uniform([batch_size, 1, 1, 1], 0.0, 1.0) interpolated alpha * real (1 - alpha) * fake with tf.GradientTape() as tape: tape.watch(interpolated) pred critic(interpolated, trainingTrue) # 计算判别器对插值样本的梯度 grads tape.gradient(pred, interpolated) # 梯度 L2 范数应接近 1 norms tf.sqrt(tf.reduce_sum(tf.square(grads), axis[1, 2, 3]) 1e-8) return tf.reduce_mean(tf.square(norms - 1.0))alpha的采样维度要和图片张量对齐[batch_size, 1, 1, 1]才能广播到每张图的每个像素。1e-8是防止开方时梯度爆炸的后悔药不加这个偶尔会出 NaN。梯度惩罚系数一般设 10这个值在多数数据集上都比较稳低于 1 约束太弱高于 100 会压制判别器学习。3. 训练循环与损失函数把 WGAN 真正跑起来3.1 判别器与生成器的交替训练节奏WGAN 的一个关键点是判别器每步训练多次通常 5 次生成器训练 1 次。这是因为 Wasserstein 距离要求判别器足够接近最优才能给出有意义的梯度。如果两者同步训练判别器欠拟合生成器拿到的梯度方向就是错的。import numpy as np EPOCHS 200 N_CRITIC 5 LAMBDA_GP 10.0 gen build_generator() critic build_critic() gen_optimizer tf.keras.optimizers.Adam(1e-4, beta_10.5, beta_20.9) critic_optimizer tf.keras.optimizers.Adam(1e-4, beta_10.5, beta_20.9) tf.function def train_step(real_images): batch_size tf.shape(real_images)[0] # 判别器训练 N_CRITIC 次 for _ in range(N_CRITIC): noise tf.random.normal([batch_size, LATENT_DIM]) with tf.GradientTape() as tape: fake_images gen(noise, trainingTrue) real_score critic(real_images, trainingTrue) fake_score critic(fake_images, trainingTrue) # WGAN 损失最大化真实分减生成分 w_loss tf.reduce_mean(fake_score) - tf.reduce_mean(real_score) gp gradient_penalty(critic, real_images, fake_images) critic_loss w_loss LAMBDA_GP * gp critic_grads tape.gradient(critic_loss, critic.trainable_variables) critic_optimizer.apply_gradients(zip(critic_grads, critic.trainable_variables)) # 生成器训练 1 次 noise tf.random.normal([batch_size, LATENT_DIM]) with tf.GradientTape() as tape: fake_images gen(noise, trainingTrue) fake_score critic(fake_images, trainingTrue) # 生成器希望判别器给假图高分 gen_loss -tf.reduce_mean(fake_score) gen_grads tape.gradient(gen_loss, gen.trainable_variables) gen_optimizer.apply_gradients(zip(gen_grads, gen.trainable_variables)) return critic_loss, gen_lossAdam 的beta_10.5是 GAN 训练的常见设置默认的 0.9 会让动量累积过大导致训练震荡。beta_20.9比默认的 0.999 响应更快适合这种非平稳的对抗训练。N_CRITIC5不是绝对的如果你的判别器很弱可以降到 3如果生成器明显跟不上可以加到 7 试试。3.2 训练过程中的监控指标与保存策略WGAN 的好处之一是损失值有实际含义critic_loss 越小说明真实分和生成分差距越大判别器越强gen_loss 是负的 fake_score它上升说明生成器在进步。但这两个值不能单独看要结合生成图片的目视检查。def train(dataset, epochs): for epoch in range(epochs): for real_batch in dataset: c_loss, g_loss train_step(real_batch) # 每个 epoch 保存一次生成样本方便追踪效果 if (epoch 1) % 10 0: noise tf.random.normal([16, LATENT_DIM]) samples gen(noise, trainingFalse) save_image_grid(samples, fsamples/epoch_{epoch1}.png) gen.save_weights(fcheckpoints/gen_{epoch1}.h5) critic.save_weights(fcheckpoints/critic_{epoch1}.h5) print(fEpoch {epoch1}, C_loss: {c_loss:.4f}, G_loss: {g_loss:.4f})保存策略上我一般每 10 个 epoch 存一次权重和样本图。不要只存最终模型因为 WGAN 后期可能出现模式崩溃某个中间 checkpoint 的效果反而更好。样本图用 4×4 网格能直观看出生成器是否只输出少数几种脸型。如果连续几个 epoch 的样本图几乎一样说明生成器已经停止学习需要检查判别器是不是太强了。3.3 从噪声到头像的推理代码训练完之后推理就是一行的事采样噪声过生成器反归一化回 [0, 255]。def generate_avatars(num16): noise tf.random.normal([num, LATENT_DIM]) generated gen(noise, trainingFalse) # 从 [-1,1] 还原到 [0,255] generated (generated 1.0) * 127.5 return tf.cast(generated, tf.uint8) # 加载训练好的权重后直接调用 gen.load_weights(checkpoints/gen_200.h5) avatars generate_avatars(16)这里注意trainingFalse必须显式传否则 BatchNormalization 会用当前 batch 的统计量单张推理时结果会飘。反归一化的公式要和预处理严格对应(x 1) * 127.5对应(x - 127.5) / 127.5写反了图片会全黑或全白。4. 避坑与排查WGAN 训练中最容易翻车的五个地方4.1 生成器输出全是同一张脸现象样本图里 16 张头像几乎一模一样或者只有两三种变化。原因判别器太强生成器找到了一个能骗过判别器的「万能脸」然后就不再探索其他模式。解决降低判别器学习率到生成器的 1/2或者把N_CRITIC从 5 降到 3给生成器更多更新机会。也可以在生成器损失里加一个小的多样性正则但优先调训练节奏。4.2 损失值突然变成 NaN现象训练到某个 epochcritic_loss 或 gen_loss 变成 nan之后再也恢复不了。原因梯度惩罚里的开方操作在梯度为 0 时产生数值问题或者学习率太大导致参数爆炸。解决在开方前加1e-8把学习率从 1e-4 降到 5e-5并在优化器上开全局梯度裁剪clipnorm1.0。如果已经出现 NaN只能从最近的 checkpoint 重启。4.3 判别器损失一直下降但图片不改善现象critic_loss 从 -10 一路降到 -50看起来判别器越来越强但生成图片始终是模糊色块。原因判别器过强生成器梯度消失Wasserstein 距离虽然理论上不会消失但实际数值精度下梯度已经小到无法更新。解决给判别器加 dropout 或者减少一层也可以把梯度惩罚系数从 10 降到 5削弱判别器的 Lipschitz 约束。4.4 显存溢出在训练中途才出现现象前几个 epoch 正常跑到一半报 OOM。原因tf.function在追踪新形状时会重新编译计算图如果 batch 里混入了不同尺寸的图片每次都会新建图。解决确保预处理阶段所有图片尺寸严格一致drop_remainderTrue必须加避免最后一个不完整 batch 触发重追踪。另外tf.function里的 Python 循环次数N_CRITIC要是常量不要用动态值。4.5 加载权重后推理结果和训练时不一样现象训练时保存的样本图很好看加载权重重新推理却是一团糟。原因生成器里的 BatchNormalization 在推理时用的移动平均统计量没有正确恢复或者保存权重时只存了生成器没存判别器导致 BN 层的滑动统计丢失。解决用model.save_weights和load_weights成对操作确保生成器和判别器都保存。如果还是不对检查推理时是否传了trainingFalse。5. 进阶技巧用条件注入和插值让生成更可控5.1 给 WGAN 加条件标签生成指定风格无条件 WGAN 只能随机出图如果你想控制发色、性别或者表情需要把条件信息注入生成器和判别器。常见做法是把标签做 embedding 后拼接到噪声向量上判别器则在输入图片的同时接收标签 embedding。def build_conditional_generator(num_classes): noise_input layers.Input(shape(LATENT_DIM,)) label_input layers.Input(shape(1,), dtypeint32) # 标签 embedding 后与噪声拼接 label_embedding layers.Embedding(num_classes, LATENT_DIM)(label_input) label_embedding layers.Flatten()(label_embedding) x layers.Concatenate()([noise_input, label_embedding]) x layers.Dense(4 * 4 * 256, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.LeakyReLU(0.2)(x) x layers.Reshape((4, 4, 256))(x) # 后续转置卷积层与无条件版本一致 # ... 省略重复层 return Model([noise_input, label_input], x)标签 embedding 的维度设成和噪声一样是 100这样拼接后是 200 维第一层 Dense 的输入要同步改成 200。条件 WGAN 的训练循环和无条件几乎一样只是每次要同时传入图片和标签。注意标签要 one-hot 还是整数取决于 Embedding 层这里用整数索引更省内存。5.2 用潜在空间插值检查模式覆盖判断生成器是否覆盖了足够多的模式一个简单方法是取两个随机噪声在它们之间做线性插值生成一系列图片。如果中间帧出现明显不自然的跳变或者重复说明潜在空间有空洞。def interpolate(z1, z2, steps10): # 在两点之间线性插值 alphas np.linspace(0, 1, steps) vectors [a * z1 (1 - a) * z2 for a in alphas] vectors tf.stack(vectors) images gen(vectors, trainingFalse) return (images 1.0) * 127.5插值步数一般取 10 到 20太少看不出过渡太多意义不大。如果插值中间出现完全无关的脸说明生成器在潜在空间里是分段的训练还不够充分。这个检查比看损失曲线直观得多我一般每 20 个 epoch 做一次。5.3 一个我踩过的坑不要过早调低学习率早期训练时我习惯性地加了学习率衰减结果 WGAN 在 50 个 epoch 后就停滞了。后来发现 WGAN 的对抗训练本身需要持续的学习率来维持判别器和生成器的动态平衡过早衰减会让判别器先固化生成器再也追不上。现在我的习惯是前 150 个 epoch 保持 1e-4 不变之后如果样本图不再变化再考虑降到 5e-5 跑最后 50 个 epoch。这个节奏在动漫头像数据集上比较稳换到其他数据集可能需要微调但「先恒定后衰减」这个原则比一上来就衰减要靠谱得多。希望帮到你。本文还有配套的精品资源点击获取