TensorFlow-图像生成实用指南-全-
TensorFlow 图像生成实用指南(全)
原文:
annas-archive.org/md5/0de4765c7da0b44560b86a9f1951b364译者:飞龙
前言
任何足够先进的技术都无法与魔法区分开来。
——亚瑟·C·克拉克
这句话最能描述使用人工****智能(AI)进行图像生成的过程。深度学习——作为人工智能的一个子集——在过去十年中发展迅速。现在,我们可以生成与真实人脸无法区分的人工人脸,并且能将简单的笔触转化为逼真的画作。这些能力大多归功于一种深度神经网络,称为生成对抗网络(GAN)。通过这本动手实践的书籍,你不仅将开发图像生成技能,还能深入理解背后的原理。
本书从使用 TensorFlow 生成图像的基本原理开始,涵盖了变分自编码器和生成对抗网络(GAN)。随着章节的进展,你将学习如何为不同的应用构建模型,包括使用深度伪造技术进行换脸、神经风格迁移、图像到图像的转换、将简单图像转化为逼真的图像等。你还将了解如何以及为什么使用先进的技术,如谱归一化和自注意力层,来构建最先进的深度神经网络,然后再使用这些技术处理面部生成和编辑的高级模型。本书还将介绍照片修复、文本到图像的合成、视频重新定向和神经渲染等内容。在整个书籍中,你将学习如何从零开始在 TensorFlow 2.x 中实现模型,包括 PixelCNN、VAE、DCGAN、WGAN、pix2pix、CycleGAN、StyleGAN、GauGAN 和 BigGAN。
本书结束时,你将熟练掌握 TensorFlow 和图像生成技术。
本书适合的人群
本书面向具有卷积神经网络基础知识的深度学习工程师、从业者和研究人员,旨在帮助你使用 TensorFlow 2.x 学习各种图像生成技术。如果你是图像处理专业人士或计算机视觉工程师,想要探索最先进的架构以改善和增强图像和视频,本书对你也非常有用。为了从本书中获得最大收益,要求你具备 Python 和 TensorFlow 的知识。
如何使用本书
网上有很多教程教授 GAN 的基础知识。然而,这些模型通常较为简单,仅适用于玩具数据集。在另一端,也有一些免费的代码可供生成逼真图像的最先进模型使用。然而,这些代码往往复杂,且缺乏解释,使得初学者很难理解。许多下载代码的“Git 克隆者”根本不知道如何调整模型以使其适用于自己的应用。本书旨在弥补这一差距。
我们将从学习基本原理开始,并立即实现代码来验证它们。你将能够即时看到你的工作成果。构建模型所需的所有代码都展示在一个 Jupyter notebook 中。这是为了让你更容易理解代码流程,并以交互方式修改和测试代码。我相信从头开始编写代码是学习和掌握深度学习的最佳方式。每一章包含一到三个模型,我们将从零开始编写所有这些模型。完成本书后,你不仅会熟悉图像生成,还将成为 TensorFlow 2 的专家。
各章节大致按 GAN 历史的时间顺序排列,每一章的内容可能会建立在前一章的知识基础上。因此,最好按顺序阅读这些章节,特别是前面三章,它们涵盖了基础知识。之后,你可以跳到自己感兴趣的章节。如果在阅读过程中对缩写感到困惑,可以参考最后一章中列出的 GAN 技术总结。
本书内容概览
第一章,使用 TensorFlow 进行图像生成入门,讲解了像素概率的基础知识,并利用它来构建我们第一个生成手写数字的模型。
第二章,变分自编码器,讲解了如何构建变分自编码器(VAE),并使用它来生成和编辑面部图像。
第三章,生成对抗网络,介绍了 GAN 的基本原理,并构建了一个 DCGAN 来生成逼真的图像。然后我们将学习新的对抗性损失函数,以稳定训练过程。
第四章,图像到图像的转换,涵盖了许多模型和有趣的应用。我们将首先实现 pix2pix,将素描转换为逼真的照片。接着我们将使用 CycleGAN 将马变成斑马。最后,我们将使用 BicycleGAN 生成各种鞋子。
第五章,风格迁移,解释了如何从一幅画中提取风格并将其转移到照片上。我们还将学习一些先进的技术,以加速神经风格迁移的运行速度,并将其应用于最前沿的 GANs。
第六章,AI 绘画师,讲解了使用 交互式 GAN(iGAN)作为示例的图像编辑和变换的基本原理。接着我们将构建一个 GauGAN,从简单的分割图生成逼真的建筑外立面。
第七章,高保真面部生成,展示了如何利用风格迁移技术构建 StyleGAN。然而,在此之前,我们将学习如何使用渐进式 GAN 逐步增加网络层。
第八章,图像生成中的自注意力,展示了如何将自注意力机制构建到 自注意力 GAN(SAGAN)和 BigGAN 中,用于条件图像生成。
第九章,视频合成,演示了如何使用自编码器创建深度伪造视频。在此过程中,我们将学习如何使用 OpenCV 和 dlib 进行面部处理。
第十章,前路,回顾并总结了我们所学的生成技术。接着,我们将探讨它们如何作为即将到来的应用的基础,包括文本到图像合成、视频压缩和视频重定向。
为了最大限度地利用本书
读者应具备深度学习训练管道的基本知识,如训练卷积神经网络进行图像分类。本书将主要使用 TensorFlow 2 中的高级 Keras API,这些 API 容易学习。如果你需要刷新或学习 TensorFlow 2,有许多免费的在线教程可供参考,如官方 TensorFlow 网站上的教程:www.tensorflow.org/tutorials/keras/classification。

训练深度神经网络需要大量的计算资源。你可以仅使用 CPU 来训练前几个简单的模型。然而,随着我们进入后续章节,更复杂的模型和数据集,模型训练可能需要几天时间才能看到满意的结果。为了最大限度地利用本书,你应该有 GPU 来加速模型训练时间。也有一些免费的云服务,比如 Google 的 Colab,提供 GPU,你可以在其上上传并运行代码。
如果你使用的是本书的数字版,我们建议你自己输入代码或通过 GitHub 仓库访问代码(链接将在下一节提供)。这样可以帮助你避免复制粘贴代码时可能出现的任何错误。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,链接:github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0。如果代码有更新,它将会在现有的 GitHub 仓库中进行更新。
我们还有其他代码包,来自我们丰富的书籍和视频目录,可以在 github.com/PacktPublishing/ 获取。快来看看吧!
下载彩色图像
我们还提供了一份 PDF 文件,包含本书中使用的截图/图表的彩色图片。你可以在这里下载:static.packt-cdn.com/downloads/9781838826789_ColorImages.pdf。
使用的约定
本书中使用了若干文本约定。
文本中的代码:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和推特用户名。以下是一个例子:“这是通过tf.gather(self.beta, labels)完成的,概念上等同于beta = self.beta[labels],如下所示。”
一段代码如下所示:
attn = tf.matmul(theta, phi, transpose_b=True)attn = tf.nn.softmax(attn)
当我们希望引起你对代码块中特定部分的注意时,相关的行或项会以粗体显示:
self.conv_theta = Conv2D(c//8, 1, padding='same', kernel_constraint=SpectralNorm(), name='Conv_Theta')
任何命令行输入或输出都以以下方式书写:
$ mkdir css
$ cd css
粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中会以这种形式出现。以下是一个例子:“从前面的架构图中,我们可以看到G1的编码器输出与G1的特征连接,并输入到G2的解码器部分以生成高分辨率图像。”
提示或重要事项
以这种方式出现。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但难免会有错误。如果你发现本书中的错误,我们非常感激你能报告给我们。请访问 www.packtpub.com/support/errata,选择你的书籍,点击“勘误表提交表单”链接,并输入相关细节。
copyright@packt.com,并附有相关材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专长,并且有兴趣写作或贡献内容到书籍中,请访问 authors.packtpub.com。
书评
请留下评论。一旦你阅读并使用了本书,为什么不在你购买书籍的站点上留下评论呢?潜在的读者可以参考你的公正意见来做出购买决定,我们 Packt 可以了解你对我们产品的看法,我们的作者也能看到你对他们书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一部分:使用 TensorFlow 生成图像的基础
本节将介绍使用 TensorFlow 生成图像的基础知识,包括概率模型、自编码器和 GANs。通过本节的学习,你将对使用深度神经网络生成图像的原理有一个扎实的理解。
本节包括以下章节:
-
第一章**, 使用 TensorFlow 生成图像入门
-
第二章**, 变分自编码器
-
第三章**, 生成对抗网络
第一章:第一章:使用 TensorFlow 开始图像生成
本书专注于使用 TensorFlow 2 通过无监督学习生成图像和视频。我们假设你已经有使用现代机器学习框架(如 TensorFlow 1)构建图像分类器的经验,尤其是使用卷积神经网络(CNNs)。因此,我们不会涉及深度学习和 CNN 的基础知识。在本书中,我们将主要使用 TensorFlow 2 中的高级 Keras API,学习起来比较简单。不过,我们假设你对图像生成没有任何先验知识,我们会涵盖所有必要的内容,帮助你入门。你首先需要了解的概念是概率分布。
概率分布是机器学习中的基础,尤其在生成模型中尤为重要。别担心,我向你保证,本章不会涉及任何复杂的数学公式。我们将首先学习什么是概率,以及如何在不使用神经网络或复杂算法的情况下,使用它生成面孔。
没错:在只需基本数学和 NumPy 代码的帮助下,你将学习如何创建一个概率生成模型。之后,你将学习如何使用 TensorFlow 2 构建PixelCNN模型来生成手写数字。本章内容丰富,包含很多有用的信息;在跳到其他章节之前,你需要先阅读本章。
本章将覆盖以下主要主题:
-
理解概率
-
使用概率模型生成面孔
-
从零开始构建 PixelCNN 模型
技术要求
代码可以在这里找到:github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter01。
理解概率
你无法避免在任何机器学习文献中遇到概率这个术语,它可能令人困惑,因为在不同的上下文中它有不同的含义。概率通常在数学公式中表示为p,你会在学术论文、教程和博客中到处看到它。尽管它看似是一个容易理解的概念,但实际上却相当混乱。这是因为根据上下文的不同,概率有多种不同的定义和解释。我们将通过一些例子来阐明这一点。在本节中,我们将探讨概率在以下几种情境中的应用:
-
分布
-
信念
概率分布
假设我们想训练一个神经网络来分类猫和狗的图像,并且我们找到了一个数据集,其中包含 600 张狗的图片和 400 张猫的图片。如你所知,在将数据输入神经网络之前,数据需要先进行混洗。否则,如果它在一个小批次中只看到同一标签的图像,网络就会懒惰,认为所有图像都有相同的标签,而不费力去区分它们。如果我们随机抽样数据集,那么概率可以写成如下:
pdata(dog) = 0.6
pdata(cat) = 0.4
这里的概率指的是数据分布。在这个例子中,指的是猫和狗图像数量与数据集中总图像数量的比例。这里的概率是静态的,对于给定的数据集不会变化。
在训练深度神经网络时,数据集通常太大,无法放入一个批次中,我们需要将其分成多个小批次来进行一轮训练。如果数据集经过充分混洗,那么小批次的抽样分布将与数据分布相似。如果数据集不平衡,其中某些类别的标签图像远多于其他类别的图像,那么神经网络可能会倾向于预测它看到更多的图像。这就是一种过拟合的表现。因此,我们可以通过不同的方式抽样数据,给予较少代表的类别更多权重。如果我们希望在抽样中平衡各个类别,那么抽样概率将如下所示:
psample(dog) = 0.5
psample(cat) = 0.5
注:
概率分布 p(x) 是数据点 x 出现的概率。在机器学习中,有两种常用的分布。均匀分布 是每个数据点有相同出现机会的分布;这是人们通常提到随机抽样时所暗示的,没有指定分布类型的情况。高斯分布 是另一种常用的分布。它如此常见,以至于人们也称之为正态分布。概率在中心(均值)处达到峰值,并在两侧逐渐衰减。高斯分布还有一些良好的数学性质,使其成为数学家的最爱。我们将在下一章看到更多内容。
预测置信度
在经历了数百次迭代后,模型终于完成了训练,我迫不及待想要用一张图片来测试这个新模型。模型输出以下概率:
p(dog) = 0.6
p(cat) = 0.4
等等,AI 是在告诉我这个动物是一个 60%狗基因和 40%猫遗传的混合种吗?当然不是!
在这里,概率不再指的是分布;相反,它们告诉我们我们对预测的信心有多大,换句话说,就是我们对输出结果有多强的信任。现在,这不再是通过计数事件的发生来量化的。如果你完全确定某个东西是狗,你可以设定 p(dog) = 1.0 和 p(cat) = 0.0。这就是所谓的贝叶斯概率。
注意
传统的统计方法将概率视为某事件发生的可能性,例如,婴儿出生时的性别概率。关于频率主义和贝叶斯方法哪个更好的问题,在更广泛的统计领域中有过激烈的争论,这超出了本书的范围。然而,贝叶斯方法在深度学习和工程中可能更为重要。它已被用于开发许多重要的算法,包括卡尔曼滤波,用于追踪火箭轨迹。在计算火箭轨迹的投影时,卡尔曼滤波会同时使用全球定位系统(GPS)和速度传感器的数据。这两组数据都有噪声,但 GPS 数据起初不太可靠(即置信度较低),因此在计算中给予其较小的权重。我们在本书中不需要学习贝叶斯定理;理解概率可以视为置信度评分而非频率就足够了。贝叶斯概率最近也被用于搜索深度神经网络的超参数。
我们现在已经澄清了在一般机器学习中常用的两种主要概率类型——分布概率和置信度。从现在开始,我们将假设“概率”指的是概率分布,而不是置信度。接下来,我们将讨论在图像生成中起着至关重要作用的一个分布——像素分布。
像素的联合概率
看看下面的图片——你能判断它们是狗还是猫吗?你认为分类器如何生成置信度评分?

图 1.1 – 一只猫和一只狗的照片
这些照片是狗还是猫呢?嗯,答案显而易见,但同时,这对我们接下来的讨论并不重要。当你看这些照片时,你可能心里想,第一张照片是猫,第二张是狗。我们看的是整张图片,但这并不是计算机所看到的。计算机看到的是像素。
注意
像素是数字图像中最小的空间单元,代表一个单一的颜色。你不可能有一个像素,其中一半是黑色,另一半是白色。最常用的颜色方案是 8 位 RGB,其中一个像素由三种通道组成,分别是 R(红色)、G(绿色)和 B(蓝色)。它们的值范围从 0 到 255(255 为最大强度)。例如,一个黑色像素的值是 [0, 0, 0],而一个白色像素的值是 [255, 255, 255]。
描述图像的像素分布最简单的方法是通过计算具有不同强度级别(从 0 到 255)的像素数量;你可以通过绘制直方图来可视化这一点。在数字摄影中,一个常用的工具是查看单独的 R、G 和 B 通道的直方图,以了解色彩平衡。虽然这能提供一些信息给我们——例如,天空图像很可能有很多蓝色像素,因此直方图可以可靠地告诉我们一些相关信息——但是直方图并不能告诉我们像素之间的关系。换句话说,直方图不包含空间信息,也就是,蓝色像素之间的距离是多少。我们需要一个更好的度量来处理这种情况。
与其说 p(x),其中 x 是整个图像,我们可以将 x 定义为 x1、x2、x3,… xn。现在,p(x)* 可以定义为像素的联合概率 p(x1,x2,x3,… xn),其中 n 是像素的数量,每个像素之间用逗号分隔。
我们将使用以下图像来说明联合概率的含义。以下是三张具有 2 x 2 像素的二进制值图像,其中 0 表示黑色,1 表示白色。我们将左上角的像素称为 x1,右上角的像素称为 x2,左下角的像素称为 x3,右下角的像素称为 x4:

图 1.2 – 具有 2 x 2 像素的图像
我们首先通过计算白色 x1 的数量并将其除以图像的总数来计算 p(x1 = 白色)。然后,我们对 x2 做同样的事情,如下所示:
p(x1 = 白色) = 2 / 3
p(x2 = 白色) = 0 / 3
现在我们说 p(x1) 和 p(x2) 是相互独立的,因为我们分别计算了它们。如果我们计算两个像素都为黑色的联合概率,我们得到如下结果:
p(x1 = 黑色, x2 = 黑色) = 0 / 3
然后,我们可以计算这两个像素的完整联合概率,如下所示:
p(x1 = 黑色, x2 = 白色) = 0 / 3
p(x1 = 白色, x2 = 黑色) = 3 / 3
p(x1 = 白色, x2 = 白色) = 0 / 3
我们需要执行相同的步骤 16 次,以计算完整的联合概率p(x1, x2, x3, x4)。现在,我们可以完全描述像素分布,并使用该分布来计算边际分布,如p(x1, x2, x3)或p(x1)。然而,对于 RGB 值的联合分布,计算所需的步骤会呈指数增长,因为每个像素有 256 x 256 x 256 = 16,777,216 种可能性。这时,深度神经网络就派上用场了。神经网络可以训练来学习像素数据分布Pdata。因此,神经网络就是我们的概率模型Pmodel。
重要提示
本书中将使用的符号如下:大写 X代表数据集,小写 x代表从数据集中采样的图像,带下标 xi 代表像素。
图像生成的目的是生成具有像素分布p(x)的图像,这种分布类似于p(X)。例如,一个橙子图像数据集将具有很高的概率,出现很多靠近彼此分布的橙色像素。因此,在生成图像之前,我们将首先从真实数据pdata(X)中构建一个概率模型pmodel(x)。然后,我们通过从pmodel(x)中采样来生成图像。
使用概率模型生成面孔
好了,数学部分到此为止。现在是时候动手生成你的第一张图像了。在这一部分,我们将学习如何通过从概率模型中采样来生成图像,甚至不需要使用神经网络。
平均面孔
我们将使用由香港中文大学创建的大规模 CelebFaces Attributes(CelebA)数据集(mmlab.ie.cuhk.edu.hk/projects/CelebA.html)。可以直接通过 Python 的tensorflow_datasets模块在ch1_generate_first_image.ipynb Jupyter 笔记本中下载,如以下代码所示:
import tensorflow_datasets as tfds
import matplotlib.pyplot as plt
import numpy as np
ds_train, ds_info = tfds.load('celeb_a', split='test',
shuffle_files=False,
with_info=True)
fig = tfds.show_examples(ds_info, ds_train)
TensorFlow 数据集允许我们通过使用tfds.show_examples() API 来预览一些图像示例。以下是一些男性和女性名人面孔的样本:

图 1.3 – 来自 CelebA 数据集的样本图像
如图所示,每张图像中都有一个名人的面孔。每张图片都是独一无二的,展示了各种性别、姿势、表情和发型;有些人戴眼镜,有些则没有。我们来看看如何利用图像的概率分布帮助我们创造一个新的面孔。我们将使用最简单的统计方法之一——均值,这意味着取图像像素的平均值。更具体地说,我们是通过平均每张图像的xi 来计算新图像的xi。为了加快处理速度,我们将仅使用数据集中的 2,000 个样本来完成这项任务,如下所示:
sample_size = 2000
ds_train = ds_train.batch(sample_size)
features = next(iter(ds_train.take(1)))
sample_images = features['image']
new_image = np.mean(sample_images, axis=0)
plt.imshow(new_image.astype(np.uint8))
哇哦!那就是你生成的第一张图像,效果真棒!我最初以为它会看起来有点像毕加索的画作,但事实证明,平均图像其实相当连贯:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_01_04.jpg)
图 1.4 – 平均面孔
条件概率
CelebA 数据集的最棒之处在于,每张图像都有如下的面部属性标签:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_01_05.jpg)
图 1.5 – CelebA 数据集中按字母顺序排列的 40 个属性
我们将使用这些属性生成一张新图像。假设我们想要生成一张男性图像。我们该怎么做呢?我们不通过计算每张图像的概率,而只使用那些将男性属性设置为true的图像。我们可以这样表达:
p(x | y)
我们称这为在给定 y 条件下 x 的概率,或者更非正式地称为在 y 条件下 x 的概率。这就是男性属性,这个变量不再是一个随机概率;每个样本都会有 男性 属性,我们可以确定每个面孔都属于男性。以下图显示了使用其他属性以及男性属性生成的新平均面孔,例如 男性 + 眼镜 和 男性 + 眼镜 + 胡子 + 微笑。请注意,随着条件的增加,样本数量减少,平均图像也变得更嘈杂:

图 1.6 – 从左到右添加属性。 (a) 男性 (b) 男性 + 眼镜 (c) 男性 + 眼镜 + 胡子 + 微笑
你可以使用 Jupyter notebook 通过不同的属性生成新的面孔,但并不是每种组合都会产生令人满意的结果。以下是一些通过不同属性生成的女性面孔。最右边的图像很有趣。我使用了女性、微笑、眼镜和尖鼻子等属性,但事实证明,具有这些属性的人通常也有波浪形的头发,而这在这个样本中并未包括。可视化是一个有用的工具,可以为你提供数据集的见解:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_01_07.jpg)
图 1.7 – 不同属性的女性面孔
提示
生成图像时,你可以尝试使用中位数,而不是使用平均值,这可能会生成更清晰的图像。只需将 np.mean() 替换为 np.median()。
概率生成模型
我们希望通过图像生成算法实现三个主要目标:
-
生成看起来像给定数据集中的图像。
-
生成各种图像。
-
控制生成的图像。
仅仅通过取图像中像素的均值,我们就展示了如何实现目标 1 和 3。然而,一个限制是我们每种条件下只能生成一张图像。对于一个算法来说,这真的不是很有效,只从数百或数千张训练图像中生成一张图像。
以下图表显示了数据集中一个任意像素的一个颜色通道的分布。图表上的 x 标记是中位数值。当我们使用数据的均值或中位数时,我们总是在采样同一个点,因此结果不会有变化。有没有办法生成多个不同的面孔?有的,我们可以尝试通过从整个像素分布中进行采样来增加生成图像的变化:

图 1.8 – 像素颜色通道的分布
一本机器学习教材可能会要求你首先创建一个概率模型 pmodel,通过计算每个像素的联合概率。但是,由于样本空间非常庞大(记住,一个 RGB 像素可以有 16,777,216 种不同的值),实现起来计算成本非常高。此外,因为这是一本实践书,我们将直接从数据集中提取像素样本。为了在新图像中创建一个 x0 像素,我们通过运行以下代码从数据集中的所有图像的 x0 像素中随机采样:
new_image = np.zeros(sample_images.shape[1:], dtype=np.uint8)
for i in range(h):
for j in range(w):
rand_int = np.random.randint(0, sample_images.shape[0])
new_image[i,j] = sample_images[rand_int,i,j]
图像是通过随机采样生成的。令人失望的是,尽管图像之间有一些变化,但它们之间的差异并不大,而我们的目标之一是能够生成多样化的面孔。此外,与使用均值时相比,图像的噪声明显增多。其原因是像素分布是相互独立的。
例如,对于嘴唇中的一个给定像素,我们可以合理地预计其颜色为粉色或红色,相邻的像素也是如此。然而,由于我们从脸部位于不同位置和姿势的图像中独立采样,这会导致像素之间的颜色不连续,最终产生这种噪声效果:

图 1.9 – 通过随机采样生成的图像
提示
你可能会想,为什么平均面孔看起来比随机采样的更平滑。首先,是因为像素之间的平均距离较小。想象一个随机采样的场景,其中一个像素采样值接近 0,而下一个像素接近 255。这样,这些像素的平均值可能会位于两者之间,因此它们之间的差异会较小。另一方面,图像背景中的像素通常呈现均匀分布;例如,它们可能都是蓝天的一部分,白色墙壁,绿色树叶,等等。由于这些像素均匀分布在颜色谱上,平均值大约是[127, 127, 127],这恰好是灰色。
参数化建模
我们刚才所做的,是使用像素直方图作为我们的pmodel,但这里有一些不足之处。首先,由于样本空间很大,并不是每个可能的颜色都存在于我们的样本分布中。因此,生成的图像永远不会包含数据集中不存在的颜色。例如,我们希望能够生成全谱的肤色,而不仅仅是数据集中存在的某个特定的棕色。如果你尝试在条件下生成面孔,你会发现并不是每种条件的组合都是可能的。例如,对于胡须+鬓角+浓妆+卷发,根本没有一个样本符合这些条件!
其次,随着我们增加数据集的大小或图像分辨率,样本空间会增大。这可以通过使用参数化模型来解决。下图中的垂直条形图显示了 1,000 个随机生成的数字的直方图:

图 1.10 – 高斯直方图和模型
我们可以看到有些条形图没有任何数值。我们可以在数据上拟合一个高斯模型,其中概率密度函数(PDF)以黑线的形式绘制。高斯分布的 PDF 方程如下:

这里,µ 是均值,σ 是标准差。
我们可以看到,PDF 覆盖了直方图的空白,这意味着我们可以为缺失的数字生成概率。这个高斯模型只有两个参数——均值和标准差。
这 1,000 个数字现在可以压缩为仅两个参数,我们可以使用这个模型生成任意数量的样本;我们不再受限于拟合模型的数据。当然,自然图像是复杂的,不能通过简单的模型(如高斯模型)来描述,实际上也不能通过任何数学模型来描述。这就是神经网络发挥作用的地方。现在,我们将使用神经网络作为一个参数化的图像生成模型,其中参数是网络的权重和偏差。
从头开始构建 PixelCNN 模型
深度神经网络生成算法的三大类:
-
生成对抗网络(GANs)
-
变分自编码器(VAEs)
-
自回归模型
变分自编码器(VAE)将在下一章介绍,我们将在一些模型中使用它们。生成对抗网络(GAN)是本书中主要使用的算法,关于它的更多细节将在后续章节中介绍。在这里,我们将介绍较少人知的自回归模型系列,并将在后续章节重点讨论 VAE 和 GAN。尽管在图像生成中不那么常见,自回归仍然是一个活跃的研究领域,DeepMind 的 WaveNet 就使用它来生成逼真的音频。在本节中,我们将介绍自回归模型,并从零开始构建一个PixelCNN模型。
自回归模型
这里的 Auto 意味着 自我,而在机器学习术语中,regress 意味着 预测新值。将它们结合起来,autoregressive 意味着我们使用一个模型基于该模型的历史数据点来预测新的数据点。
让我们回顾一下图像的概率分布 p(x),它是联合像素概率 p(x1, x2, … xn),由于维度较高,难以建模。在这里,我们假设一个像素的值仅依赖于其前一个像素的值。换句话说,一个像素仅依赖于它前面的像素,即 p(xi) = p(xi | xi-1) p(xi-1)。不深入数学细节,我们可以将联合概率近似为条件概率的乘积:
p(x) = p(xn, xn-1, …, x2, x1)
p(x) = p(xn | xn-1)… p(x3 | x2) p(x2 | x1) p(x1)
举个具体的例子,假设我们有一些图像,图像中大约在中心位置只有一个红色的苹果,并且苹果周围有绿色的叶子。换句话说,只有两种颜色:红色和绿色。x1 是左上角的像素,因此 p(x1) 是左上角像素是绿色还是红色的概率。如果 x1 是绿色,那么它右边的像素 p(x2) 也很可能是绿色,因为很可能是更多的叶子。然而,尽管概率较小,它也可能是红色。
随着我们继续,最终会遇到一个红色像素(太好了!我们找到了苹果!)。从那个像素开始,接下来的几个像素也很可能是红色的。现在我们可以看到,这比必须一起考虑所有像素要简单得多。
PixelRNN
PixelRNN 由 Google 收购的 DeepMind 在 2016 年发明。正如 RNN(循环神经网络)所暗示的那样,该模型使用一种称为 长短时记忆(LSTM)的 RNN 来学习图像的分布。它每次读取图像的一行,在 LSTM 中进行一步处理,并用 1D 卷积层处理该行,然后将激活值传递到后续层,预测该行的像素。
由于 LSTM 运行速度较慢,训练和生成样本需要较长时间。因此,它逐渐失去了流行,并且自其诞生以来没有太多改进。因此,我们不会在此过多停留,而是将注意力转向同一篇论文中也被提出的一个变体——PixelCNN。
使用 TensorFlow 2 构建 PixelCNN 模型
PixelCNN 仅由卷积层组成,使其比 PixelRNN 快得多。在这里,我们将实现一个简单的 PixelCNN 模型用于 MNIST。代码可以在 ch1_pixelcnn.ipynb 中找到。
输入和标签
MNIST 由 28 x 28 x 1 的灰度图像组成,展示手写数字。它只有一个通道,使用 256 个灰度级来描绘灰色的阴影:

图 1.11 – MNIST 数字示例
在这个实验中,我们通过将图像转换为仅包含两个可能值的二进制格式来简化问题:0表示黑色,1表示白色。代码如下所示:
def binarize(image, label):
image = tf.cast(image, tf.float32)
image = tf.math.round(image/255.)
return image, tf.cast(image, tf.int32)
该函数需要两个输入——一张图像和一个标签。函数的前两行将图像转换为二进制float32格式,换句话说,就是0.0或1.0。在本教程中,我们不使用标签信息;相反,我们将二进制图像转换为整数并返回。我们不必将其转换为整数,但为了遵循标签使用整数的惯例,我们还是这么做。回顾一下,输入和标签都是 28 x 28 x 1 的二进制 MNIST 图像,唯一不同的是数据类型。
掩膜
与逐行读取的 PixelRNN 不同,PixelCNN 从左到右、从上到下滑动卷积核遍历图像。在执行卷积以预测当前像素时,传统的卷积核能够看到当前输入像素以及周围的像素,包括未来的像素,这打破了我们的条件概率假设。
为了避免这种情况,我们需要确保 CNN 在预测时不会看到它正在预测的像素。换句话说,我们需要确保 CNN 在预测输出像素xi 时,不能看到输入像素xi。
这是通过使用掩膜卷积实现的,其中在执行卷积之前,掩膜被应用到卷积核权重上。下图展示了一个 7 x 7 卷积核的掩膜,其中从中心开始的权重为 0。这阻止了 CNN 看到它正在预测的像素(卷积核的中心)以及所有未来的像素。这被称为类型 A 掩膜,仅应用于输入层。由于第一层已经阻止了中心像素,我们在后续层中不再需要隐藏中心特征。实际上,我们需要将卷积核的中心设置为 1,以便它能够读取来自前一层的特征。这被称为类型 B 掩膜:

图 1.12 – 7 x 7 卷积核掩膜(来源:Aäron van den Oord 等人,2016 年,《使用 PixelCNN 解码器的条件图像生成》,arxiv.org/abs/1606.05328)
接下来,我们将学习如何创建自定义层。
实现自定义层
接下来,我们将为掩膜卷积创建一个自定义层。我们可以通过从基类tf.keras.layers.Layer继承模型子类来在 TensorFlow 中创建自定义层,如下所示。我们将能够像使用其他 Keras 层一样使用它。以下是自定义层类的基本结构:
class MaskedConv2D(tf.keras.layers.Layer):
def __init__(self):
...
def build(self, input_shape):
...
def call(self, inputs):
...
return output
build()将输入张量的形状作为参数,我们将使用这些信息来创建正确形状的变量。此函数只在构建层时运行一次。我们可以通过声明掩膜为非训练变量或常量来创建掩膜,这样 TensorFlow 就会知道它不需要反向传播的梯度:
def build(self, input_shape):
self.w = self.add_weight(shape=[self.kernel,
self.kernel,
input_shape[-1],
self.filters],
initializer='glorot_normal',
trainable=True)
self.b = self.add_weight(shape=(self.filters,),
initializer='zeros',
trainable=True)
mask = np.ones(self.kernel**2, dtype=np.float32)
center = len(mask)//2
mask[center+1:] = 0
if self.mask_type == 'A':
mask[center] = 0
mask = mask.reshape((self.kernel, self.kernel, 1, 1))
self.mask = tf.constant(mask, dtype='float32')
call()是执行计算的前向传播函数。在这个掩蔽卷积层中,我们通过将权重乘以掩膜将下半部分清零,然后使用低级的tf.nn API 执行卷积操作:
def call(self, inputs):
masked_w = tf.math.multiply(self.w, self.mask)
output = tf.nn.conv2d(inputs, masked_w, 1, "SAME") + self.b
return output
提示
tf.keras.layers是一个高级 API,易于使用,无需了解底层细节。然而,有时我们需要使用低级的tf.nn API 来创建自定义函数,这要求我们首先指定或创建需要使用的张量。
网络层
PixelCNN 架构非常简单。经过带有掩膜 A 的第一个 7 x 7 conv2d层后,接下来是几层带有掩膜 B 的残差块(参见下表)。为了保持 28 x 28 的相同特征图尺寸,这些层没有进行下采样;例如,这些层中的最大池化和填充被设置为SAME。然后,顶部特征会被送入两层 1 x 1 的卷积层,最终生成输出,如下截图所示:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_01_13.jpg)
图 1.13 – PixelCNN 架构,展示了层和输出形状
残差块在许多高性能的基于 CNN 的模型中都有应用,并且通过 ResNet 而广为人知,ResNet 是由 Kaiming He 等人在 2015 年发明的。以下图示展示了 PixelCNN 中使用的残差块变种。左侧路径被称为跳跃连接路径,它简单地传递来自前一层的特征。在右侧路径中,有三个连续的卷积层,过滤器大小为 1 x 1、3 x 3 和 1 x 1。该路径优化输入特征的残差,因此得名残差网络:

图 1.14 – 残差块,其中 h 表示滤波器的数量。(来源:Aäron van den Oord 等人,《Pixel Recurrent Neural Networks》)
交叉熵损失
交叉熵损失,也称为对数损失,衡量模型的性能,其中输出的概率在 0 和 1 之间。以下是二元交叉熵损失的公式,其中只有两个类别,标签y可以是 0 或 1,p(x)是模型的预测。公式如下:

让我们来看一个例子,其中标签为 1,第二项为零,第一项为log p(x)的和。公式中的对数是自然对数(loge),但根据惯例,方程中省略了 e 的底数。如果模型确信x属于标签 1,那么log(1)为零。另一方面,如果模型错误地将其猜测为标签 0,并预测x为标签1的概率很低,例如p(x) = 0.1,则-log(p(x))将变为较高的损失2.3。因此,最小化交叉熵损失将最大化模型的准确性。这个损失函数通常用于分类模型,但在生成模型中也非常流行。
在 PixelCNN 中,单个图像像素被用作标签。在我们的二值化 MNIST 数据集中,我们希望预测输出像素是 0 还是 1,这使得该问题成为一个分类问题,交叉熵作为损失函数。
可以有两种输出类型:
-
由于在二值化图像中只能为 0 或 1,我们可以通过使用
sigmoid()简化网络,以预测白色像素的概率,即p(xi=1)。损失函数为二元交叉熵。这就是我们将在 PixelCNN 模型中使用的损失函数。 -
可选地,我们还可以将网络推广以接受灰度或 RGB 图像。我们可以使用
softmax()激活函数为每个(子)像素生成N个概率。对于二值化图像,N将为2;对于灰度图像,N将为256;对于 RGB 图像,N将为3 x 256。如果标签是独热编码,则损失函数为稀疏类别交叉熵或类别交叉熵。
最后,我们现在可以准备编译和训练神经网络。如下面的代码所示,我们对loss和metrics使用二元交叉熵,并将RMSprop作为优化器。有许多不同的优化器可以使用,它们的主要区别在于如何根据过去的统计数据调整个别变量的学习率。有些优化器加速了训练,但可能会导致过冲,无法达到全局最小值。没有一种最好的优化器适用于所有情况,因此建议您尝试不同的优化器。
然而,您将经常看到的两个优化器是Adam和RMSprop。Adam 优化器因其快速学习而在图像生成中非常流行,而 RMSprop 则是谷歌频繁使用的一种优化器,用于生成最先进的模型。
以下是用于编译和拟合pixelcnn模型的代码:
pixelcnn = SimplePixelCnn()
pixelcnn.compile(
loss = tf.keras.losses.BinaryCrossentropy(),
optimizer=tf.keras.optimizers.RMSprop(learning_rate=0.001),
metrics=[ tf.keras.metrics.BinaryCrossentropy()])
pixelcnn.fit(ds_train, epochs = 10, validation_data=ds_test)
接下来,我们将从前述模型生成一张新图像。
样本图像
训练后,我们可以通过以下步骤使用该模型生成新的图像:
-
创建一个与输入图像形状相同的空张量,并用零填充它。将其输入到网络中,并获得p(x1),即第一个像素的概率。
-
从p(x1)中采样,并将样本值赋给输入张量中的像素x1。
-
将输入再次送入网络并执行步骤 2 以处理下一个像素。
-
重复步骤 2 和 3,直到xN 被生成。
自回归模型的一个主要缺点是它比较慢,因为需要逐像素生成,且无法并行化。以下是我们在训练 50 轮后,由简单的 PixelCNN 模型生成的图像。它们看起来还不像标准的数字,但已经开始呈现出手写笔画的形态。现在我们能够从零输入张量生成新的图像,真是太神奇了(也就是说,我们可以从无到有生成图像)。如果通过训练模型更长时间并进行一些超参数调优,您能生成更好的数字吗?

图 1.15 – 由我们的 PixelCNN 模型生成的一些图像
到此为止,我们已经结束了这一章!
总结
哇!我觉得我们在这一章学到了很多内容,从理解像素概率分布到使用它构建概率模型生成图像。我们学会了如何使用 TensorFlow 2 构建自定义层,并利用它们构建自回归 PixelCNN 模型来生成手写数字图像。
在下一章中,我们将学习如何使用变分自编码器(VAE)进行表示学习。这一次,我们将从全新的角度来看待像素。我们将训练一个神经网络来学习面部特征,并进行面部编辑,例如将一个看起来悲伤的女孩转变成一个带着胡子的微笑男士。
第二章:第二章:变分自编码器
在上一章,我们研究了计算机如何将图像看作像素,并为图像生成设计了像素分布的概率模型。然而,这并不是生成图像的最有效方式。我们不是逐像素扫描图像,而是首先查看图像并尝试理解其中的内容。例如,一个女孩坐着,戴着帽子,微笑着。然后我们利用这些信息来绘制一幅肖像。这就是自编码器的工作方式。
在本章中,我们将首先学习如何使用自编码器将像素编码为潜在变量,从这些变量中采样来生成图像。接下来,我们将学习如何调整自编码器,创建一个更强大的模型,称为 变分自编码器(VAE)。最后,我们将训练我们的 VAE 来生成面部图像并进行面部编辑。本章将涵盖以下主题:
-
使用自编码器学习潜在变量
-
变分自编码器
-
使用变分自编码器生成面部图像
-
控制面部特征
技术要求
Jupyter 笔记本和代码可以在 github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter02 找到。
本章使用的笔记本如下:
-
ch2_autoencoder.ipynb -
ch2_vae_mnist.ipynb -
ch2_vae_faces.ipynb
使用自编码器学习潜在变量
自编码器最早是在 1980 年代提出的,发明者之一是 Geoffrey Hinton,他是现代深度学习的奠基人之一。其假设是高维输入空间中存在大量冗余,可以将其压缩为一些低维变量。传统的机器学习技术,如 主成分分析(PCA),就是用于降维的工具。
然而,在图像生成中,我们还希望将低维空间恢复到高维空间。虽然实现方式有所不同,但你可以将其理解为图像压缩过程,其中原始图像被压缩为 JPEG 等文件格式,这种格式体积小、易于存储和传输。然后,计算机可以将 JPEG 恢复为我们可以看到并操作的像素。换句话说,原始像素被压缩为低维的 JPEG 格式,然后恢复为用于显示的高维原始像素。
自编码器是一种 无监督机器学习 技术,不需要标签来训练模型。然而,有些人称其为 自监督 机器学习(auto 在拉丁语中意为 自我),因为我们确实需要使用标签,而这些标签并非经过标注的标签,而是图像本身。
自编码器的基本构建模块是编码器和解码器。编码器负责将高维输入压缩为一些低维的潜在(隐含)变量。尽管从名称上看不太明显,但解码器是将潜在变量转换回高维空间的模块。编码器-解码器架构也应用于其他机器学习任务,例如语义分割,其中神经网络首先学习图像表示,然后生成像素级标签。下图展示了自编码器的一般架构:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_02_01.jpg)
图 2.1 – 一般自编码器架构
在前面的图像中,输入和输出是相同维度的图像,z是低维潜在向量。编码器将输入压缩成z,而解码器则逆转这一过程以生成输出图像。
在审视了整体架构之后,让我们进一步了解编码器的工作原理。
编码器
编码器由多个神经网络层组成,最好的表示方法是使用全连接(密集)层。现在我们将直接构建一个适用于MNIST数据集的编码器,该数据集的维度是 28x28x1\。我们需要设置潜在变量的维度,这是一个一维向量。我们将遵循惯例,称潜在变量为z,如下代码所示。
代码可以在ch2_autoencoder.ipynb中找到:
def Encoder(z_dim):
inputs = layers.Input(shape=[28,28,1])
x = inputs
x = Flatten()(x)
x = Dense(128, activation='relu')(x)
x = Dense(64, activation='relu')(x)
x = Dense(32, activation='relu')(x)
z = Dense(z_dim, activation='relu')(x)
return Model(inputs=inputs, outputs=z, name='encoder')
潜在变量的大小应小于输入维度。它是一个超参数,我们将首先尝试使用 10,这将给我们一个压缩率为2828/10 = 78.4*。
然后,我们将使用三个全连接层,神经元数逐渐减少(128,64,32,最后是10,这是我们的z维度)。我们可以在以下的模型总结中看到,特征大小从784逐渐压缩到网络输出的10:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_02_02.jpg)
图 2.2 – 我们编码器的模型总结
这种网络拓扑结构迫使模型学习哪些是重要的特征,并逐层丢弃不太重要的特征,最终将数据压缩到 10 个最重要的特征。如果仔细想一想,这与CNN分类非常相似,其中特征图的大小随着向上层遍历逐渐减小。特征图是指张量的前两个维度(高度和宽度)。
由于 CNN 在图像输入上更为高效且更适合,我们将使用卷积层来构建编码器。旧的 CNN(如VGG)使用最大池化来进行特征图下采样,但较新的网络通常通过在卷积层中使用步幅为 2 的卷积核来实现这一点。下图演示了使用步幅为 2 的卷积核滑动,从而生成一个特征图,其大小是输入特征图的一半:

图 2.3 – 从左到右,图中展示了使用步幅为 2 的卷积操作
(来源:Vincent Dumoulin, Francesco Visin, “深度学习卷积算术指南” https://www.arxiv-vanity.com/papers/1603.07285/)
在这个例子中,我们将使用四个卷积层,每个层有8个滤波器,并包含一个步幅为2的输入进行下采样,如下所示:
def Encoder(z_dim):
inputs = layers.Input(shape=[28,28,1])
x = inputs
x = Conv2D(filters=8, kernel_size=(3,3), strides=2, padding='same', activation='relu')(x)
x = Conv2D(filters=8, kernel_size=(3,3), strides=1, padding='same', activation='relu')(x)
x = Conv2D(filters=8, kernel_size=(3,3), strides=2, padding='same', activation='relu')(x)
x = Conv2D(filters=8, kernel_size=(3,3), strides=1, padding='same', activation='relu')(x)
x = Flatten()(x)
out = Dense(z_dim, activation='relu')(x)
return Model(inputs=inputs, outputs=out, name='encoder')
在典型的卷积神经网络(CNN)架构中,滤波器的数量会增加,而特征图的大小会减少。然而,我们的目标是减少维度,因此我保持了滤波器大小不变。这对于像 MNIST 这样的简单数据来说已经足够,随着我们进入潜在变量,滤波器大小的变化也是可以的。最后,我们将最后一个卷积层的输出展平,并将其输入到全连接层,以输出我们的潜在变量。
解码器
如果解码器是一个人,他们可能会觉得自己被不公平对待。这是因为解码器做了一半的工作,但只有编码器在名称中占有一席之地。它应该被称为自动编码器-解码器!
解码器的工作本质上是编码器的反向操作,即将低维潜在变量转换为高维输出,以使其看起来像输入图像。解码器中的层不需要按反向顺序看起来像编码器。你可以使用完全不同的层,例如,在编码器中只使用全连接层,在解码器中只使用卷积层。无论如何,我们仍将在解码器中使用卷积层,将特征图从 7x7 上采样到 28x28。以下代码片段展示了解码器的构建:
def Decoder(z_dim):
inputs = layers.Input(shape=[z_dim])
x = inputs
x = Dense(7*7*64, activation='relu')(x)
x = Reshape((7,7,64))(x)
x = Conv2D(filters=64, kernel_size=(3,3), strides=1, padding='same', activation='relu')(x)
x = UpSampling2D((2,2))(x)
x = Conv2D(filters=32, kernel_size=(3,3), strides=1, padding='same', activation='relu')(x)
x = UpSampling2D((2,2))(x)
x = Conv2D(filters=32, kernel_size=(3,3), strides=2, padding='same', activation='relu')(x)
out = Conv2(filters=1, kernel_size=(3,3), strides=1, padding='same', activation='sigmoid')(x)
return Model(inputs=inputs, outputs=out, name='decoder')
第一层是一个全连接层,它接收潜在变量并生成一个大小为[7 x 7 x 卷积层滤波器数量]的张量。与编码器不同,解码器的目标不是减少维度,因此我们可以并且应该使用更多的滤波器,以增加其生成能力。
UpSampling2D通过插值像素来增加分辨率。它是一个仿射变换(线性乘法和加法),因此它可以反向传播,但它使用固定的权重,因此不可训练。另一种流行的上采样方法是使用转置卷积层,该层是可训练的,但可能会在生成的图像中产生棋盘状的伪影。你可以在distill.pub/2016/deconv-checkerboard/查看更多信息。
对于低维图像或当你放大图像时,棋盘效应更为明显。通过使用偶数大小的卷积核(例如,4 而不是更常见的 3),可以减少这种效果。因此,近期的图像生成模型通常不使用转置卷积。我们将在本书的其余部分使用UpSampling2D。以下表格显示了解码器的模型摘要:

图 2.4 – 解码器的模型摘要
提示
设计 CNN 时,了解如何计算卷积层输出张量的形状非常重要。如果使用padding='same',则输出特征图的大小(高度和宽度)将与输入特征图相同。如果改为使用padding='valid',则输出大小可能会略小,具体取决于滤波器核的维度。当输入stride = 2与相同的填充一起使用时,特征图的大小会减半。最后,输出张量的通道数与卷积滤波器的数量相同。例如,如果输入张量的形状是(28,28,1),并通过conv2d(filters=32, strides=2, padding='same'),我们知道输出的形状将是(14,14, 32)。
构建自编码器
现在我们准备将编码器和解码器结合起来创建自编码器。首先,我们分别实例化编码器和解码器。然后,我们将编码器的输出传递给解码器的输入,并使用编码器的输入和解码器的输出来实例化一个Model,如下所示:
z_dim = 10
encoder = Encoder(z_dim)
decoder = Decoder(z_dim)
model_input = encoder.input
model_output = decoder(encoder.output)
autoencoder = Model(model_input, model_output)
深度神经网络看起来可能很复杂且难以构建。然而,我们可以将其拆分成更小的模块或块,然后稍后将它们组合起来。这样整个任务就变得更加可管理!在训练过程中,我们将使用 L2 损失,这通过均方误差(MSE)来实现,用于比较输出和期望结果之间的每个像素。在这个示例中,我添加了一些回调函数,它们将在每个训练周期后被调用,如下所示:
-
使用
ModelCheckpoint(monitor='val_loss')在验证损失低于早期周期时保存模型。 -
使用
EarlyStopping(monitor='val_loss', patience = 10)来提前停止训练,如果验证损失在 10 个周期内没有改善。
生成的图像如下所示:

图 2.5 – 第一行是输入图像,第二行是由自编码器生成的图像
如你所见,第一行是输入图像,第二行是由我们的自编码器生成的图像。我们可以看到生成的图像有些模糊;这可能是因为我们过度压缩了图像,导致在过程中丢失了一些数据。
为了验证我们的怀疑,我们将潜在变量的维度从 10 增加到 100,然后生成输出,结果如下:

图 2.6 – 自编码器生成的图像,z_dim = 100
如你所见,现在生成的图像看起来更加锐利!
从潜在变量生成图像
那么,我们如何使用自编码器呢?让一个 AI 模型将图像转换为更模糊的版本并不太有用。自编码器的最初应用之一是图像去噪,我们将一些噪声添加到输入图像中,并训练模型生成干净的图像。然而,我们更感兴趣的是使用它来生成图像。那么,让我们看看我们如何做到这一点。
现在我们已经有了训练好的自编码器,我们可以忽略编码器,只使用解码器从潜在变量中采样来生成图像(看到了吗?解码器值得更多的认可,因为它在训练完成后仍然需要继续工作)。我们面临的第一个挑战是如何从潜在变量中采样。由于在潜在变量之前的最后一层没有使用任何激活函数,潜在空间是无界的,可以是任何实数浮动值,而且数量多得不可计数!
为了说明这如何起作用,我们将训练另一个自编码器,使用z_dim=2,以便我们可以在二维空间中探索潜在空间。以下图表展示了潜在空间的图:

图 2.7 – 潜在空间的图。Jupyter Notebook 中有彩色版本。
该图是通过将 1,000 个样本输入训练好的编码器,并在散点图上绘制两个潜在变量生成的。右侧的色条表示数字标签的强度。我们可以从这些图中观察到以下几点:
-
潜在变量大致位于–5和+4之间。我们无法知道确切的范围,除非我们绘制出这个图并查看它。这个范围在重新训练模型时可能会发生变化,且样本的分布通常会超出+-10,分布得更加广泛。
-
类别的分布并不均匀。你可以看到左上角和右侧有一些聚类,它们与其他类别分离得很好(请参考 Jupyter Notebook 中的彩色版本)。然而,位于图中心的类别往往较为密集,且彼此重叠。
你可能能在以下图像中更好地看到不均匀性,这些图像是通过以 1.0 的间隔从–5到+5扫描潜在变量生成的:

图 2.8 - 通过扫描两个潜在变量生成的图像
我们可以看到数字 0 和 1 在样本分布中表现得很好,并且它们的绘制效果也很好。对于位于中心的数字情况则不太一样,它们显得模糊,甚至一些数字在样本中完全缺失。这表明,对于这些类别,生成的图像变化非常少,这是一个缺点。
并不完全是坏事。如果你仔细观察,你会发现数字 1 逐渐变形为 7,接着变成 9 和 4,这很有趣!看起来自动编码器已经学到了一些潜在变量之间的关系。可能是外形圆润的数字被映射到潜在空间的右上角,而看起来更像棍棒的数字则位于左侧。这是个好消息!
乐趣
在笔记本中有一个小工具,允许你滑动潜在变量条,进行交互式图像生成。玩得开心!
在接下来的章节中,我们将看到如何使用 VAE 解决潜在空间中的分布问题。
变分自编码器
在自编码器中,解码器是直接从潜在变量中抽取样本的。变分自编码器(VAE),它在 2014 年被发明,区别在于样本是从由潜在变量参数化的分布中抽取的。为了清楚说明,假设我们有一个包含两个潜在变量的自编码器,我们随机抽取样本,得到 0.4 和 1.2 这两个样本。然后,我们将它们发送到解码器生成图像。
在变分自编码器(VAE)中,这些样本并不会直接传递到解码器。相反,它们作为高斯分布的均值和方差被使用,我们从这个分布中抽取样本,然后将它们发送到解码器进行图像生成。由于这是机器学习中最重要的分布之一,因此在创建 VAE 之前,我们先了解一些高斯分布的基本知识。
高斯分布
高斯分布的特征是由两个参数 – 均值和方差定义的。我想我们都对下图中展示的不同钟形曲线很熟悉。标准差越大(方差的平方根),分布越广:

图 2.9 – 不同标准差的高斯分布概率密度函数
我们可以使用
表示单变量高斯分布,其中 µ 是均值,
是标准差。
均值告诉我们峰值在哪里:它是概率密度最高的值,换句话说,就是最常见的值。如果我们要从图像的像素位置 (x, y) 中抽取样本,并且每个 x 和 y 都有不同的高斯分布,那么我们就得到一个多元高斯分布。在这种情况下,它是一个二元分布。
多元高斯分布的数学公式看起来可能相当复杂,因此我不会把它们放在这里。我们只需要知道的是,现在我们将标准差纳入了协方差矩阵。协方差矩阵的对角元素只是各个高斯分布的标准差。其他元素则衡量两个高斯分布之间的协方差,也就是它们之间的相关性。
以下图表展示了没有相关性的二元高斯样本:

图 2.10 – 无相关性的二元高斯分布样本
我们可以看到,当某一维度的标准差从 1 增加到 4 时,只有该维度(y轴)的分布扩展了,而其他维度没有受到影响。在这里,我们说这两个高斯分布是独立同分布(简称iid)。
现在,在第二个例子中,左边的图表显示协方差非零且为正,这意味着当某一维度的密度增加时,另一维度也会跟着增加,它们是相关的。右边的图表显示了负相关:

图 2.11 – 带相关性的二元高斯分布样本
这里有个好消息:在变分自编码器(VAE)中,高斯分布假设是独立同分布(iid),因此不需要协方差矩阵来描述变量之间的相关性。结果,我们只需要n对均值和方差来描述我们的多元高斯分布。我们希望实现的目标是创建一个分布良好的潜在空间,其中不同数据类别的潜在变量分布如下:
-
均匀分布,所以我们可以从中获得更好的变化来进行采样
-
略微重叠以创建连续的过渡
这可以通过以下图表来说明:

图 2.12 – 从多元高斯分布中抽取的四个样本
接下来,我们将学习如何将高斯分布采样融入 VAE 中。
采样潜在变量
当我们训练自编码器时,编码后的潜在变量直接传递给解码器。而在 VAE 中,编码器和解码器之间还有一个额外的采样步骤。编码器生成高斯分布的均值和方差作为潜在变量,我们从中抽取样本并发送到解码器。问题是,采样是不可反向传播的,因此不能进行训练。
反向传播
对于那些不熟悉深度学习基础的读者,神经网络是通过反向传播来训练的。其步骤之一是计算损失函数对网络权重的梯度。因此,所有操作必须是可微分的,才能使反向传播工作。
为了解决这个问题,我们可以采用一个简单的重参数化技巧,即将高斯随机变量N(均值,方差)转换为mean + sigma * N(0, 1)。换句话说,我们首先从标准高斯分布 N(0,1)中进行采样,然后将其乘以 sigma,再加上均值。正如以下图所示,采样变成了一个仿射变换(仅由加法和乘法操作组成),并且误差可以从输出层反向传播到编码器:

图 2.13 – VAE 中的高斯采样
来自标准高斯分布N(0,1)的采样可以视为 VAE 的输入,我们不需要将反向传播回传到输入层。然而,我们将N(0,1)的采样放入我们的模型中。既然我们已经理解了采样的原理,接下来我们可以构建我们的 VAE 模型。
现在我们实现将采样作为一个自定义层,如下所示:
class GaussianSampling(Layer):
def call(self, inputs):
means, logvar = inputs
epsilon = tf.random.normal(shape=tf.shape(means), mean=0., stddev=1.)
samples = means + tf.exp(0.5*logvar)*epsilon
return samples
请注意,我们在编码器空间中使用对数方差而非方差,以提高数值稳定性。根据定义,方差是一个正数,但除非我们使用如relu等激活函数对其进行约束,否则潜在变量的方差可能会变成负数。此外,方差可能变化非常大,例如从 0.01 到 100,这可能使训练变得困难。然而,这些值的自然对数为-4.6 和+4.6,这是一个较小的范围。尽管如此,在进行采样时,我们仍然需要将对数方差转换为标准差,因此需要使用tf.exp(0.5*logvar)代码。
重要提示
在 TensorFlow 中构建模型有几种方法。其一是使用Sequential类按顺序添加层。最后一层的输入会传递到下一层;因此,不需要为该层指定输入。虽然这种方法很方便,但不能在具有分支的模型中使用。接下来,使用tf.random.normal()在急切执行模式下会失败,这是 TensorFlow 2 的默认模式,它创建动态计算图时需要知道批次大小来生成随机数,但由于在创建层时批次大小未知,因此会出错。当我们尝试通过传入大小为(None, 2)的值来抽样时,会在 Jupyter 笔记本中遇到错误。因此,我们将模型创建方法切换为使用call(),这样在执行时我们已经知道批次大小,进而完成形状信息。
现在我们使用__init__()方法,或者如果需要使用输入形状来构建层,则使用__built__()方法来重构我们的编码器。在子类中,我们使用Sequential类方便地创建卷积层块,因为我们不需要读取任何中间张量:
class Encoder(Layer):
def __init__(self, z_dim, name='encoder'):
super(Encoder, self).__init__(name=name)
self.features_extract = Sequential([
Conv2D(filters=8, kernel_size=(3,3), strides=2, padding='same', activation='relu'),
Conv2D(filters=8, kernel_size=(3,3), strides=1, padding='same', activation='relu'),
Conv2D(filters=8, kernel_size=(3,3), strides=2, padding='same', activation='relu'),
Conv2D(filters=8, kernel_size=(3,3), strides=1, padding='same', activation='relu'),
Flatten()])
self.dense_mean = Dense(z_dim, name='mean')
self.dense_logvar = Dense(z_dim, name='logvar')
self.sampler = GaussianSampling()
然后我们使用两个全连接层根据提取的特征预测z的均值和对数方差。潜在变量被采样并与均值和对数方差一起返回,用于损失计算。解码器与自动编码器相同,只是我们现在通过子类化重写了它:
def call(self, inputs):
x = self.features_extract(inputs)
mean = self.dense_mean(x)
logvar = self.dense_logvar(x)
z = self.sampler([mean, logvar])
return z, mean, logvar
现在编码器模块已经完成。解码器模块的设计与自动编码器相同,因此剩下的工作是定义一个新的损失函数。
损失函数
我们现在可以从多变量高斯分布中采样,但仍然无法保证高斯分布的点不会相互远离或过于分散。变分自编码器(VAE)通过加入正则化项来解决这个问题,以鼓励高斯分布接近 N(0,1)。换句话说,我们希望它们的均值接近 0,以保持彼此接近,同时方差接近 1,以便从中采样更好的变异性。这个过程是通过使用Kullback-Leibler 散度(KLD)实现的。
KLD 是衡量一个概率分布与另一个概率分布之间差异的指标。对于两个分布,P和Q,P相对于Q的 KLD 是P和Q的交叉熵减去P的熵。在信息论中,熵是信息或随机变量不确定性的度量:

不深入数学细节,KLD 与交叉熵成正比,因此最小化交叉熵也会最小化 KLD。当 KLD 为零时,两个分布是完全相同的。值得一提的是,当要比较的分布是标准高斯分布时,KLD 有一个封闭形式的解,可以直接从以下均值和方差计算:

我们创建了一个自定义的损失函数,它接受标签和网络输出,以计算 KL 损失。我使用了tf.reduce_mean()而不是tf.reduce_sum(),这样可以将其标准化为潜在空间维度的数量。这其实并不重要,因为 KL 损失会乘以一个超参数,我们稍后会讨论这个超参数:
def vae_kl_loss(y_true, y_pred):
kl_loss = - 0.5 * tf.reduce_mean(vae.logvar - tf.exp(vae.logvar) - tf.square(vae.mean) - + 1)
return kl_loss
另一个损失函数是我们在自动编码器中使用的,用于将生成的图像与标签图像进行比较。这也叫做重构损失,它衡量的是重构图像与目标图像之间的差异,因此得名。这个损失可以是二元交叉熵(BCE)或者均方误差(MSE)。MSE 倾向于生成更锐利的图像,因为它对偏离标签的像素进行更严厉的惩罚(通过平方误差):
def vae_rc_loss(y_true, y_pred):
rc_loss = tf.keras.losses.MSE(y_true, y_pred)
return rc_loss
最后,我们将两个损失加在一起:
def vae_loss(y_true, y_pred):
kl_loss = vae_kl_loss(y_true, y_pred)
rc_loss = vae_rc_loss(y_true, y_pred)
kl_weight_factor = 1e-2
return kl_weight_factor*kl_loss + rc_loss
现在,让我们来谈谈 kl_weight_factor,它是一个重要的超参数,通常在 VAE 示例或教程中被忽视。正如我们所看到的,总损失由 KL 损失和重建损失组成。MNIST 数字的背景是黑色的,因此即使网络没有学到多少内容并且只输出零,重建损失仍然相对较低。
相比之下,潜在变量的分布在开始时非常杂乱,因此减少 KLD 的收益大于减少重建损失的收益。这会促使网络忽视重建损失,仅优化 KLD 损失。因此,潜在变量将具有完美的标准高斯分布 N(0,1),但生成的图像将与训练图像完全不同,这对于生成模型来说是灾难性的!
重要提示
编码器具有判别性,它试图找出图像中的差异。我们可以将每个潜在变量看作一个特征。如果我们使用两个潜在变量来表示 MNIST 数字,它们可能表示圆形或直线。当解码器看到一个数字时,它通过使用均值和方差来预测该数字是圆形还是直线的可能性。如果强制神经网络使 KLD 损失为 0,潜在变量的分布将完全相同——中心为 0,方差为 1。换句话说,圆形和直线的可能性是一样的。因此,编码器失去了其判别能力。当这种情况发生时,你会发现解码器每次生成的图像都相同,看起来就像是像素值的平均值。
在我们进入下一部分之前,我建议你打开 ch2_vae_mnist.ipynb,尝试使用不同的 kl_weight_factor 和 VAE(z_dim=2),查看训练后潜在变量的分布。你还可以尝试增加 kl_weight_factor,看看它如何阻止 VAE 学会生成图像,然后再次查看生成的图像和分布。
使用 VAE 生成人脸
既然你已经了解了 VAE 的理论,并且为 MNIST 构建了一个模型,现在是时候成长起来,丢掉玩具,开始生成一些真实的东西了。我们将使用 VAE 来生成一些人脸。开始吧!代码在 ch2_vae_faces.ipynb 中。有几个面部数据集可以用于训练:
-
Celeb A (
mmlab.ie.cuhk.edu.hk/projects/CelebA.html)。这是一个在学术界很受欢迎的数据集,包含面部属性的标注,但不幸的是,不能用于商业用途。 -
Flickr-Faces-HQ 数据集 (FFHQ) (
github.com/NVlabs/ffhq-dataset)。这个数据集可以自由用于商业用途,并且包含高分辨率的图像。
在这个练习中,我们只假设数据集包含 RGB 图像;你可以随意使用任何适合你需求的数据集。
网络架构
我们重新使用了MNIST VAE和训练管道,并根据数据集不同于 MNIST 做了一些修改。根据你的计算能力,随意减少层数、参数、图像大小、训练轮数和批次大小。修改内容如下:
-
将潜在空间的维度增加到 200\。
-
输入形状从(28,28,1)更改为(112,112,3),因为我们现在有了 3 个颜色通道,而不是灰度图像。为什么是 112?早期的 CNN,如 VGG,使用 224x224 的输入大小,并为图像分类 CNN 设定了标准。由于我们目前还没有掌握生成高分辨率图像的技能,因此我们不想使用过高的分辨率。因此,我选择了 224/2 = 112,但你可以使用任何偶数值。
-
在预处理管道中添加图像调整大小。我们添加了更多的下采样层。在 MNIST 中,编码器进行了两次下采样,从 28 到 14 再到 7。由于我们现在的分辨率更高,因此需要总共进行四次下采样。
-
由于数据集更为复杂,我们增加了滤波器的数量,以增强网络的能力。因此,编码器中的卷积层如下所示。解码器类似,但方向相反。解码器的卷积层通过步幅对特征图进行上采样,而不是下采样:
a)
Conv2D(filters = 32, kernel_size=(3,3), strides = 2)b)
Conv2D(filters = 32, kernel_size=(3,3), strides = 2)c)
Conv2D(filters = 64, kernel_size=(3,3), strides = 2)d)
Conv2D(filters = 64, kernel_size=(3,3), strides = 2)提示
尽管我们在网络训练中使用了总损失,即 KLD 损失和重建损失,但我们应该仅使用重建损失作为指标,来监控何时保存模型以及提前终止训练。KLD 损失起到正则化作用,但我们更关心的是重建图像的质量。
面部重建
让我们来看一下以下重建的图像:

图 2.14 – 使用 VAE 重建的图像
尽管重建并不完美,但它们看起来确实不错。VAE 成功地从输入图像中学习了一些特征,并利用这些特征绘制了一张新的面孔。看起来 VAE 在重建女性面孔方面表现更好。这并不令人惊讶,因为我们在第一章《使用 TensorFlow 进行图像生成入门》中看到的平均面孔呈现女性特征,这是因为数据集中女性的比例较高。这也是为什么成熟男性被赋予了更加年轻、女性化的面容。
图像背景也很有趣。由于图像背景极为多样,编码器无法将每个细节都编码成低维度,因此我们可以看到 VAE 编码了背景颜色,并且解码器基于这些颜色创建了模糊的背景。
有件有趣的事情要和大家分享,当 KL 权重因子过高时,VAE 学习失败,那么平均面孔将再次出现来困扰你。这就像是 VAE 的编码器被蒙住了眼睛告诉解码器:“嘿,我什么也看不见,就画一个人给我看”,然后解码器画出了它认为的平均人像。
生成新面孔
要生成新的图像,我们从标准高斯分布中创建随机数,并将其输入到解码器中,如下面的代码片段所示:
z_samples = np.random.normal(loc=0, scale=1, size=(image_num,
z_dim))
images = vae.decoder(z_samples.astype(np.float32))
而大多数生成的面孔看起来都很可怕!

图 2.15 – 使用标准正态抽样生成的面孔
我们可以通过使用抽样技巧来提高图像的保真度。
抽样技巧
我们刚刚看到,训练过的 VAE 能够相当好地重建面孔。我怀疑通过随机抽样生成的样本可能存在一些问题。为了调试这个问题,我将数千张图像输入到 VAE 解码器中,以收集潜在空间的均值和方差。然后我绘制了每个潜在空间变量的平均均值,以下是我的结果:

图 2.16 – 潜变量的平均均值
理论上,它们应该集中在 0,并且方差为 1,但由于 KLD 权重不佳和网络训练中的随机性,它们可能不会。因此,随机生成的样本并不总是与解码器期望的分布匹配。这就是我用来生成样本的技巧。使用类似前面步骤,我已收集了潜在变量的平均标准差(一个标量值),我用它来生成正态分布样本(200 维)。然后我加上了平均均值(200 维)。
啦!现在它们看起来好多了,更加清晰!

图 2.17 – 使用抽样技巧生成的面孔
在下一节中,我们将学习如何进行面部编辑,而不是生成随机面孔。
控制面部特征
这一章我们所做的一切只有一个目的:为了面部编辑做好准备!这是本章的高潮!
潜在空间算术
我们已经多次提到潜在空间,但尚未给出其明确定义。基本上,它表示潜在变量的每一个可能值。在我们的 VAE 中,它是一个 200 维的向量,或者简单地说是 200 个变量。尽管我们希望每个变量对我们来说都有明确的语义含义,比如 z[0] 是眼睛,z[1] 控制眼睛的颜色等等,事情往往没有那么简单。我们只能假设信息被编码在所有潜在向量中,并且可以使用向量运算来探索这个空间。
在深入高维空间之前,让我们通过一个二维的例子来理解。假设你现在在地图上的点(0,0),而你的家在(x,y)。因此,指向你家的方向是(x – 0 ,y - 0),然后除以(x,y)的 L2 范数,或者我们将这个方向表示为(x_dot, y_dot)。因此,每当你移动(x_dot, y_dot)时,你就朝着你家走;而当你移动(-2x_dot, -2y_dot)时,你就朝着远离家的方向走,步伐是两倍的。
现在,如果我们知道smiling特征的方向向量,我们可以将其添加到潜在变量中,使得面部微笑:
new_z_samples = z_samples + smiling_magnitude*smiling_vector
smiling_magnitude是我们设定的标量值,因此下一步是计算获取smiling_vector的方法。
查找特征向量
一些数据集,例如 Celeb A,为每张图片提供了面部属性的注释。这些标签是二进制的,表示某个特征是否存在于图像中。我们将使用这些标签和编码后的潜在变量来找到我们的方向向量!这个想法很简单:
-
使用测试数据集或从训练数据集中提取的几千个样本,并使用 VAE 解码器生成潜在向量。
-
将潜在向量分为两组:有(正向)或没有(负向)我们感兴趣的某一特征。
-
分别计算正向向量和负向向量的平均值。
-
通过从平均负向量中减去平均正向量来获取特征方向向量。
预处理函数已修改为返回我们感兴趣的属性的标签。然后我们使用lambda函数映射到数据管道中:
def preprocess_attrib(sample, attribute):
image = sample['image']
image = tf.image.resize(image, [112,112])
image = tf.cast(image, tf.float32)/255.
return image, sample['attributes'][attribute]
ds = ds.map(lambda x: preprocess_attrib(x, attribute))
不要与 Keras 的 Lambda 层混淆,后者将任意的 TensorFlow 函数封装为 Keras 层。在代码中的lambda是一个通用的 Python 表达式。lambda函数作为一个小函数使用,但不需要定义函数的冗余代码。上述代码中的lambda函数等同于以下函数:
def preprocess(x):
return preprocess_attrib(x, attribute))
在将map函数链式调用到数据集时,数据集对象将依次读取每张图片,并调用等同于preprocess(image)的lambda函数。
面部编辑
提取了特征向量后,我们现在可以进行魔法操作:
-
首先,我们从数据集中选择一张图片,这张图片是下图中最左侧的面孔。
-
我们将面部图像编码为潜在变量,然后解码生成一张新面孔,将其放置在行的中间。
-
然后,我们将特征向量逐步添加到右侧。
-
类似地,我们在向左移动时减去特征向量。
以下截图展示了通过插值潜在向量生成的图像,图像中包含男性、丰满、胡须、微笑和眼镜特征:

图 2.18 – 通过探索潜在空间来改变面部特征
过渡相当平滑。你应该注意到这些属性并不是彼此排斥的。例如,当我们增加女性的胡须特征时,肤色和头发变得更像男性,VAE 甚至给这个人系上了领带。这完全合理,事实上,这正是我们希望的效果。这表明一些潜在变量的分布是有重叠的。
类似地,如果我们将男性向量设置为最负,它将把潜在状态推向一个地方,在那里遍历胡须向量将不会对面部生长胡须产生影响。
接下来,我们可以尝试同时改变多个面部属性。数学原理类似;我们现在只需要将所有的属性向量加起来。在下面的截图中,左侧的图像是随机生成的,作为基准图像。右侧是经过一些潜在空间运算后的新图像,如图像前面的条形图所示:

图 2.19 – 潜在空间探索小工具
这个小工具可以在 Jupyter Notebook 中使用。随时使用它来探索潜在空间并生成新的人脸!
总结
我们通过学习如何使用编码器将高维数据压缩为低维潜在变量,然后使用解码器从潜在变量重建数据,开始了本章的内容。我们了解到自编码器的局限性在于无法保证潜在空间是连续且均匀的,这使得从中进行采样变得困难。随后,我们引入了高斯采样,构建了一个变分自编码器(VAE)来生成 MNIST 数字。
最后,我们构建了一个更大的 VAE,并在面部数据集上进行训练,享受创造和操作人脸的乐趣。我们学到了潜在空间中采样分布、潜在空间算术和 KLD 的重要性,这为第三章,生成对抗网络打下了基础。
虽然 GANs 在生成照片级真实图像方面比 VAE 更强大,但早期的 GANs 很难训练。因此,我们将学习 GAN 的基本原理。在下一章结束时,你将学习到所有三大类深度生成算法的基础知识,为本书第二部分的更高级模型做好准备。
在我们进入生成对抗网络(GANs)之前,我要强调的是(变分)自编码器仍然被广泛使用。变分编码方面已经被集成到 GANs 中。因此,掌握 VAE 将有助于你掌握后续章节中我们将介绍的高级 GAN 模型。我们将在第九章**,视频合成中讲解使用自编码器生成深度伪造视频。该章节不假设你已经掌握 GANs 的相关知识,因此你可以随时跳到该章节,看看如何使用自编码器进行人脸替换。
第三章:第三章:生成对抗网络
生成对抗网络,通常称为 GANs,目前是图像和视频生成中最突出的技术。正如卷积神经网络的发明者 Yann LeCun 博士在 2016 年所说,“…这是过去 10 年里机器学习中最有趣的想法。” 使用 GAN 生成的图像在真实性方面优于其他竞争技术,自从 2014 年由当时的研究生 Ian Goodfellow 发明以来,技术已经取得了巨大的进展。
在本章中,我们将首先了解 GAN 的基本原理,并构建一个 DCGAN 来生成 Fashion MNIST。我们将学习训练 GAN 时面临的挑战。最后,我们将学习如何构建 WGAN 及其变体 WGAN-GP,以解决生成面孔时遇到的许多挑战。
在本章中,我们将涵盖以下主题:
-
理解 GAN 的基本原理
-
构建深度卷积生成对抗网络(DCGAN)
-
训练 GAN 的挑战
-
构建 Wasserstein GAN(WGAN)
技术要求
Jupyter 笔记本和代码可以在此找到:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter03
本章使用的笔记本如下:
-
ch3_dcgan.ipynb -
ch3_mode_collapse -
ch3_wgan_fashion_mnist.ipynb -
ch3_wgan_gp_fashion_mnist.ipynb -
ch3_wgan_gp_celeb_a.ipynb
理解 GAN 的基本原理
生成模型的目的是学习数据分布,并从中采样生成新的数据。对于我们在前几章中讨论的模型,例如 PixelCNN 和 VAE,它们的生成部分在训练过程中可以查看图像分布。因此,它们被称为显式密度模型。相比之下,GAN 的生成部分从未直接查看过图像;它只知道生成的图像是真实的还是伪造的。因此,GAN 被归类为隐式密度模型。
我们可以用一个类比来比较显式模型和隐式模型。假设一位艺术生 G 获得了一些毕加索的画作,并被要求学习如何画伪造的毕加索画作。学生可以在学习绘画的过程中参考这些画作,因此这是一个显式模型。在另一个场景中,我们要求学生 G 伪造毕加索画作,但我们不展示任何画作,他们也不知道毕加索的画作是什么样的。他们唯一的学习方式是通过来自学生 D 的反馈,学生 D 正在学习识别伪造的毕加索画作。反馈非常简单——画作要么是伪造的,要么是真实的。这就是我们的隐式密度 GAN 模型。
或许有一天,它们偶然画了一个扭曲的面孔,并从反馈中学到这看起来像一幅真正的毕加索画作。于是,它们开始以这种风格作画来愚弄学生 D。学生 G 和 D 是生成对抗网络(GAN)中的两个网络,分别称为生成器和判别器。这是与其他生成模型相比,网络架构的最大不同之处。
我们将从学习 GAN 的构建模块开始,然后是损失函数。原始 GAN 没有重构损失,这是它与其他算法不同的另一个方面。然后,我们将为 GAN 创建自定义的训练步骤,之后我们就可以训练我们的第一个 GAN 了。
GAN 的架构
生成对抗网络中的对抗一词根据词典定义意味着涉及对立或分歧。有两个网络,分别称为生成器和判别器,它们彼此竞争。生成器,顾名思义,生成假图像;而判别器会查看生成的图像,以判断它们是“真实”还是“虚假”。每个网络都在努力赢得比赛——判别器希望正确识别每一张真实或虚假的图像,而生成器则希望愚弄判别器,使其相信由其生成的假图像是真实的。下图展示了 GAN 的架构:

图 3.1 – GAN 架构
GAN 架构与 VAE(参见第二章,变分自编码器)有一些相似之处。事实上,你可以重新排列 VAE 框图中的模块,并添加一些线条和开关,来生成这个 GAN 框图。如果 VAE 由两个独立的网络组成,我们可以认为:
-
GAN 的生成器就像 VAE 的解码器
-
GAN 的判别器就像 VAE 的编码器
生成器将低维简单分布转换为具有复杂分布的高维图像,就像解码器的工作原理一样。事实上,它们是完全相同的;我们可以直接复制并粘贴解码器的代码,并将其重命名为生成器,反之亦然,它就能正常工作。生成器的输入通常是来自正态分布的样本,尽管有些使用均匀分布。
我们将真实图像和假图像分别送入判别器的不同小批次。真实图像来自数据集,而假图像由生成器生成。判别器输出一个单一的概率值,用于判断输入图像是“真实”还是“假”。它是一个二分类器,我们可以使用 CNN 来实现它。从技术上讲,判别器的作用与编码器不同,但它们都减少了输入的维度。
结果证明,在模型中有两个网络其实并不可怕。生成器和判别器是我们旧朋友的伪装和新名字。我们已经知道如何构建这些模型,因此现在不用担心构建的细节。事实上,原始的 GAN 论文只用了一个多层感知器,由一些基本的全连接层组成。
价值函数
价值函数捕捉了 GAN 工作原理的基本内容。方程如下:

这里:
-
D 代表判别器。
-
G 是生成器。
-
x 是输入数据,z 是潜在变量。
我们在代码中也会使用相同的符号。这是生成器试图最小化的函数,而判别器则希望最大化它。
当你理解它后,代码实现会容易很多,而且会变得更加有意义。此外,我们接下来关于 GAN 挑战和改进的讨论,很多都围绕损失函数展开。因此,花时间研究它是非常值得的。GAN 损失函数在某些文献中也被称为 对抗性损失。现在它看起来相当复杂,但我会一步步分解并展示如何将其转化为我们可以实现的简单损失函数。
判别器损失
价值函数的第一项是正确分类真实图像的值。从左侧项我们可以知道,判别器希望最大化它。期望是一个数学术语,指的是随机变量每个样本的加权平均值的总和。在这个方程中,权重是数据的概率,变量是判别器输出的对数,如下所示:

在一个大小为 N 的小批量中,p(x) 是 1/N。这是因为 x 是一张单独的图像。我们可以改变符号为负号并尝试最小化它,而不是最大化它。这可以通过以下方程实现,称为 对数损失:

这里:
-
yi 是标签,对于真实图像,标签为 1。
-
p(yi) 是样本为真实的概率。
价值函数的第二项是关于假图像的;z 是随机噪声,G(z) 是生成的假图像。D(G(z)) 是判别器对图像是否真实的信心分数。如果我们将假图像的标签设置为 0,我们可以使用相同的方法将其转换为以下方程:

现在,将所有内容汇总,我们得到了判别器损失函数,即二元交叉熵损失:

以下代码展示了如何实现判别器损失。你可以在 Jupyter notebook ch3_dcgan.ipynb 中找到相关代码:
import tf.keras.losses.binary_crossentropy as bce
def discriminator_loss(pred_fake, pred_real):
real_loss = bce(tf.ones_like(pred_real), pred_real)
fake_loss = bce(tf.zeros_like(pred_fake), pred_fake)
d_loss = 0.5 *(real_loss + fake_loss)
return d_loss
在我们的训练中,我们分别对真实和假图像进行前向传递,使用相同的批量大小。因此,我们分别计算它们的二元交叉熵损失,并取平均作为损失。
生成器损失
生成器仅在模型评估伪造图像时参与,因此我们只需要查看值函数的第二个右侧项,并将其简化为以下形式:

在训练开始时,生成器在生成图像方面并不擅长,因此鉴别器总是很有信心地将其分类为0,使得D(G(z))始终为0,因此log (1 – 0)也为0。当模型输出的误差始终为0时,就没有梯度可以进行反向传播。因此,生成器的权重不会更新,生成器也无法学习。这种现象被称为梯度饱和,因为在鉴别器的 sigmoid 输出中几乎没有梯度。为了避免这个问题,公式被转化为最大化D(G(z))而不是最小化1-D(G(z)),如下所示:

使用这个函数的 GAN 也被称为非饱和 GAN(NS-GANs)。事实上,几乎所有的经典 GAN实现都使用这个值函数,而不是原始的 GAN 函数。
经典 GAN
在 GAN 发明后,研究人员对 GAN 的兴趣激增,许多研究人员给自己的 GAN 命名。有些人试图追踪多年来命名的 GAN,但这个列表变得过长。经典 GAN 是一个松散的术语,用来指代没有任何复杂变种的第一个基础 GAN。经典 GAN 通常通过两到三层隐藏密集层来实现。
我们可以通过与鉴别器相同的数学步骤推导出生成器的损失,这最终会得出与鉴别器损失函数相同的结果,唯一的不同是,真实图像使用标签 1。对于初学者来说,可能会感到困惑,为什么要为伪造图像使用真实标签。如果我们推导出这个公式,或者我们可以理解为,我们想要欺骗鉴别器,让它认为生成的图像是真的,因此我们使用真实标签。代码如下:
def generator_loss(pred_fake):
g_loss = bce(tf.ones_like(pred_fake), pred_fake)
return g_loss
恭喜你,你已经将 GAN 中最复杂的公式转化为简单的二元交叉熵损失,并在几行代码中实现了它!现在让我们看看 GAN 的训练流程。
GAN 训练步骤
在 TensorFlow 或任何其他高级机器学习框架中训练传统神经网络时,我们指定模型、损失函数、优化器,然后调用model.fit()。TensorFlow 会为我们完成所有工作——我们只需坐着等待损失下降。不幸的是,我们无法像训练 VAE 时那样将生成器和鉴别器连接为一个单一模型,直接调用model.fit()来训练 GAN。
在深入探讨 GAN 问题之前,让我们先暂停一下,回顾一下在进行单次训练步骤时,模型内部到底发生了什么:
-
执行一次前向传播以计算损失。
-
从损失中,反向传播梯度,针对变量(权重和偏置)进行调整。
-
然后是变量更新步骤。优化器将缩放梯度并将其加到变量中,完成一次训练步骤。
这些是深度神经网络中的通用训练步骤。不同的优化器仅在计算缩放因子的方式上有所不同。
现在回到 GAN,看看梯度的流动。当我们使用真实图像进行训练时,只有判别器参与——网络输入是一个真实图像,输出是标签 1。生成器在这里不起作用,因此我们不能使用 model.fit()。然而,我们仍然可以只使用判别器来拟合模型,即 D.fit(),这样就不会有阻塞问题。当我们使用假图像时,问题就出现了,梯度通过判别器反向传播到生成器。那么,问题到底是什么呢?让我们将生成器损失和判别器损失放到一起,看看假图像的情况:
g_loss = bce(tf.ones_like(pred_fake), pred_fake)
# generator
fake_loss = bce(tf.zeros_like(pred_fake), pred_fake)
# generator
如果你试着找出它们之间的差异,你会发现它们的标签符号是相反的!这意味着,使用生成器损失来训练整个模型会让判别器朝着相反的方向移动,无法学习区分能力。这是适得其反的,我们不希望有一个未经训练的判别器,这会阻碍生成器的学习。因此,我们必须分别训练生成器和判别器。在训练生成器时,我们会冻结判别器的变量。
设计 GAN 训练流程有两种方法。一种是使用高级的 Keras 模型,这需要更少的代码,因此看起来更简洁。我们只需定义一次模型,并调用 train_on_batch() 来执行所有步骤,包括前向传播、反向传播和权重更新。然而,当需要实现更复杂的损失函数时,它的灵活性较差。
另一种方法是使用低级代码,这样我们可以控制每个步骤。对于我们的第一个 GAN,我们将使用来自官方 TensorFlow GAN 教程中的低级自定义训练步骤函数(www.tensorflow.org/tutorials/generative/dcgan),如下所示:
def train_step(g_input, real_input):
with tf.GradientTape() as g_tape,\
tf.GradientTape() as d_tape:
# Forward pass
fake_input = G(g_input)
pred_fake = D(fake_input)
pred_real = D(real_input)
# Calculate losses
d_loss = discriminator_loss(pred_fake, pred_real)
g_loss = generator_loss(pred_fake)
tf.GradientTape() 用于记录单次前向传播的梯度。你可能见过另一个类似的 API,tf.Gradient(),它有类似的功能,但后者在 TensorFlow 的急切执行模式下无法使用。我们将看到之前提到的三个步骤如何在 train_step() 中实现。前面的代码片段展示了执行前向传播以计算损失的第一步。
第二步是使用 tape 梯度计算生成器和判别器的梯度,分别根据它们的损失进行计算:
gradient_g = g_tape.gradient(g_loss,\ G.trainable_variables)
gradient_d = d_tape.gradient(d_loss,\ D.trainable_variables)
第三步也是最后一步,是使用优化器将梯度应用到变量上:
G_optimizer.apply_gradients(zip(gradient_g, self.G.trainable_variables))
D_optimizer.apply_gradients(zip(gradient_d, self.D.trainable_variables))
现在你已经学会了训练 GAN 所需的一切。剩下的任务是设置输入管道、生成器和判别器,我们将在接下来的章节中详细介绍。
自定义模型拟合
在 TensorFlow 2.2 之后,现在可以为 Keras 模型创建一个自定义的train_step(),而无需重新编写整个训练管道。然后,我们可以像往常一样使用model.fit()。这也使得使用多个 GPU 进行训练成为可能。不幸的是,这一新特性没有及时发布,因此未能在本书中的代码中使用。然而,请查看www.tensorflow.org/guide/keras/customizing_what_happens_in_fit的 TensorFlow 教程,并随时修改 GAN 的代码以使用自定义模型拟合。
构建深度卷积 GAN(DCGAN)
尽管 Vanilla GAN 已经证明自己是一个生成模型,但它在训练过程中仍存在一些问题。其中之一就是很难扩展网络,使其更深以增加容量。DCGAN的作者在当时引入了一些卷积神经网络(CNN)的最新进展,使得网络更深并稳定了训练。这些进展包括去除maxpool层,用步长卷积替代它进行下采样,并去除了全连接层。这些方法已经成为设计新 CNN 的标准方式。
架构设计指南
DCGAN 并不是一个严格固定的神经网络,它的层不是预定义的,也没有固定的参数集,如卷积核大小和层数。相反,它更像是一种架构设计指南。DCGAN 中使用的批量归一化、激活函数和上采样方法对 GAN 的发展产生了影响。因此,我们将进一步探讨这些内容,这将为设计我们自己的 GAN 提供指导。
批量归一化
批量归一化在机器学习领域非正式地称为batchnorm。在深度神经网络训练的早期,每一层都会在反向传播后更新其权重,以产生更接近目标的输出。然而,后续层的权重也发生了变化,这就像一个不断变化的目标,使得深度网络的训练变得困难。Batchnorm 通过将每一层的输入标准化为零均值和单位方差来解决这一问题,从而稳定训练。这些操作发生在 batchnorm 内部:
-
计算小批量中每个通道的张量x的均值µ和标准差σ(因此得名batch归一化)。
-
归一化张量:x' = (x – µ) / σ。
-
执行仿射变换:y = α * x' + β,其中α和β是可训练变量。
在 DCGAN 中,批归一化(batchnorm)被添加到生成器和判别器的所有层中,除了判别器的第一层和生成器的最后一层。需要注意的是,新的研究表明,批归一化并不是图像生成的最佳归一化技术,因为它会去除一些重要信息。我们将在后续章节中研究其他归一化技术,但在那之前我们会继续在我们的 GAN 中使用批归一化。我们需要知道的一点是,为了使用批归一化,我们必须使用较大的小批量数据,否则,批统计量可能在不同批次之间差异很大,导致训练不稳定。
激活函数
以下图展示了我们将在 DCGAN 中使用的激活函数:

图 3.2 – 在生成器和判别器的中间层使用 ReLU 和泄漏 ReLU
由于判别器的任务是作为二分类器,因此我们使用 sigmoid 函数将输出压缩到0(假)和1(真)之间。另一方面,生成器的输出使用tanh,它将图像的值限定在-1和+1之间。因此,在预处理步骤中,我们需要将图像缩放到这个范围。
对于中间层,生成器在所有层中使用 ReLU,而判别器则使用泄漏 ReLU。标准的 ReLU 中,激活值对于正输入呈线性增加,而对于所有负输入值则为零。这在负值时限制了梯度流动,从而导致生成器无法接收到梯度以更新其权重并进行学习。泄漏 ReLU 通过允许负激活值时小的梯度流动来缓解这个问题。
正如我们在前面的图中看到的,对于大于或等于0的输入,它与 ReLU 相同,其中输出等于输入,斜率为1。对于小于0的输入,输出被缩放为输入的0.2。在 TensorFlow 中,泄漏 ReLU 的默认斜率为0.3,而 DCGAN 使用的是0.2。这只是一个超参数,你可以尝试任何其他的值。
上采样
在 DCGAN 中,生成器的上采样是通过转置卷积层实现的。然而,已经证明,这会在生成的图像中产生棋盘格图案,尤其是在颜色较强的图像中。因此,我们用UpSampling2D替代它,该方法通过使用双线性插值来执行常规的图像大小调整方法。
构建 Fashion-MNIST 的 DCGAN
这个练习的 Jupyter notebook 是ch3_dcgan.ipynb。
MNIST 已经在许多机器学习入门教程中被使用,大家都非常熟悉它。随着机器学习的最新进展,这个数据集开始显得有些对深度学习来说过于简单。因此,一个新的数据集 Fashion-MNIST 被创建,作为 MNIST 数据集的直接替代品。它的训练和测试样本数量完全相同,都是 10 类的 28x28 灰度图像。我们将用这个数据集来训练我们的 DCGAN。

图 3.3 – Fashion-MNIST 数据集的图像示例
生成器
生成器的设计可以分为两部分:
-
将 1D 潜在向量转换为 3D 激活图。
-
将激活图的空间分辨率翻倍,直到它与目标图像匹配。
首先要做的是计算上采样阶段的数量。由于图像的形状是 28x28,我们可以使用两个上采样阶段将维度从 7->14->28 增加。
对于简单数据,我们可以在每个上采样阶段使用一个卷积层,但也可以使用更多的层。此方法类似于 CNN,其中有多个卷积层在相同的空间分辨率下工作,然后再进行下采样。
接下来,我们将决定第一个卷积层的通道数。假设我们使用 [512, 256, 128, 1],其中最后一个通道数是图像通道数。根据这些信息,我们可以得出第一个全连接层的神经元数为 7 x 7 x 512。7x7 是我们计算出的空间分辨率,512 是第一个卷积层的滤波器数量。在全连接层之后,我们将其重塑为 (7,7,512),这样它就可以输入到卷积层。然后,我们只需要定义卷积层的滤波器数量,并添加 batchnorm 和 ReLU,如以下代码所示:
def Generator(self, z_dim):
model = tf.keras.Sequential(name='Generator')
model.add(layers.Input(shape=[z_dim]))
model.add(layers.Dense(7*7*512))
model.add(layers.BatchNormalization(momentum=0.9))
model.add(layers.LeakyReLU())
model.add(layers.Reshape((7,7,512)))
model.add(layers.UpSampling2D((2,2), interpolation="bilinear"))
model.add(layers.Conv2D(256, 3, padding='same'))
model.add(layers.BatchNormalization(momentum=0.9))
model.add(layers.LeakyReLU())
model.add(layers.UpSampling2D((2,2), interpolation="bilinear"))
model.add(layers.Conv2D(128, 3, padding='same'))
model.add(layers.LeakyReLU())
model.add(layers.Conv2D(image_shape[-1], 3, padding='same', activation='tanh'))
return model
生成器的模型摘要如下:

图 3.4 – DCGAN 生成器模型摘要
生成器的模型摘要展示了激活图的形状,这些激活图的空间分辨率在 (7×7 到 14×14 到 28×28) 之间翻倍,而通道数在 (512 到 256 到 128) 之间减半。
判别器
判别器的设计非常简单,就像一个普通的分类器 CNN,但使用了泄漏 ReLU 激活函数。实际上,DCGAN 论文中甚至没有提到判别器的架构。根据经验,判别器的层数应该少于或等于生成器的层数,以免判别器过强,导致生成器无法学习。以下是创建判别器的代码:
def Discriminator(self, input_shape):
model = tf.keras.Sequential(name='Discriminator')
model.add(layers.Input(shape=input_shape))
model.add(layers.Conv2D(32, 3, strides=(2,2), padding='same'))
model.add(layers.BatchNormalization(momentum=0.9))
model.add(layers.ReLU())
model.add(layers.Conv2D(64, 3, strides=(2,2), padding='same'))
model.add(layers.BatchNormalization(momentum=0.9))
model.add(layers.ReLU())
model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid'))
return model
判别器的模型摘要是一个简单的 CNN 分类器,摘要如下:

图 3.5 – DCGAN 判别器模型摘要
训练我们的 DCGAN
现在我们可以开始训练我们的第一个 GAN。下图展示了在不同训练步骤中生成的样本:

图 3.6 – DCGAN 训练中的生成图像
第一排的样本是在网络权重初始化后、任何训练步骤之前生成的。正如我们所看到的,它们只是一些随机噪声。随着训练的进行,生成的图像变得越来越好。然而,生成器的损失比只生成随机噪声时要高。
损失并不是生成图像质量的绝对度量,它仅提供相对的标准,用于比较生成器相对于判别器的表现,反之亦然。生成器的损失较低,仅仅是因为判别器尚未学会如何执行其任务。这就是 GAN 的一个挑战,损失无法提供足够的信息来衡量模型的质量。
下图展示了训练过程中判别器和生成器的损失:

图 3.7 – 判别器和生成器训练损失
我们可以看到,在前 1,000 步中达到了平衡,损失在此之后大致保持稳定。然而,损失并不是判断何时停止训练的决定性指标。现在,我们可以每过几轮保存一次权重,并通过目测选择生成最美观图像的那个!
理论上,当判别器 = 数据分布时,判别器达到全局最优。换句话说,如果数据分布 = 0.5(一半数据为真实,一半为假数据),那么判别器 = 0.5意味着它无法再区分这两类数据,预测结果和掷硬币一样。
GAN 训练中的挑战
GAN 训练 notoriously 难。我们将讨论训练 GAN 的一些主要挑战。
无信息损失与度量标准
在训练 CNN 进行分类或检测任务时,我们可以通过观察损失图形的形状来判断网络是否已经收敛或是否出现过拟合,从而知道何时停止训练。然后,度量指标会与损失相关联。例如,当损失最小的时候,分类准确率通常是最高的。然而,我们无法对 GAN 的损失做同样的处理,因为它没有最小值,而是在训练一段时间后围绕某些常数值波动。我们也不能将生成的图像质量与损失相关联。为了应对这个问题,早期的 GAN 提出了一些度量标准,其中之一就是启发得分(inception score)。
使用一个被称为 ImageNet 数据集的分类 CNN。如果某个类别的置信度很高,它更可能是真实的图像。还有一个叫做 Fréchet 启动距离(Fréchet Inception Distance,FID)的度量,它衡量生成图像的多样性。这些度量通常只在学术论文中使用,用来与其他模型做对比(以便声称他们的模型更优),因此我们在本书中不会详细介绍这些内容。人类的视觉检查仍然是评估生成图像质量的最可靠方式。
不稳定性
GAN 对超参数的变化极为敏感,包括学习率和滤波器内核大小。即便在经过大量超参数调优,并且架构正确的情况下,在重新训练模型时,仍然可能发生以下情况:

图 3.8 – 生成器陷入局部最小值
如果网络权重不幸地随机初始化为一些不良值,生成器可能会陷入某些不良的局部最小值,并且可能永远无法恢复,而判别器却一直在改进。因此,生成器会放弃,生成的图像仅为无意义的图像。这也被称为收敛失败,即损失函数无法收敛。我们需要停止训练,重新初始化网络,并重新开始训练。这也是我没有选择更复杂的数据集,如 CelebA 来介绍 GAN 的原因,但不用担心,我们会在本章结束前解决这个问题。
消失梯度
不稳定性的一个原因是生成器的消失梯度。正如我们之前提到的,当我们训练生成器时,梯度会通过判别器流动。如果判别器非常确定图像是假的,那么反向传播到生成器的梯度就会非常小,甚至为零。以下是一些缓解方法:
-
将价值函数从最小化 log (1-D(G(z))) 改为最大化 log D(G(z)), 这我们已经做过了。实际上,仅此一项仍然不足以解决问题。
-
使用允许更多梯度流动的激活函数,如 leaky ReLU。
-
通过减少判别器的网络容量或增加生成器的训练步骤,来平衡生成器和判别器之间的关系。
-
使用 单边标签平滑,将真实图像的标签从 1 降低到例如 0.9,以减少判别器的置信度。
模式崩溃
模式崩溃发生在生成器生成的图像彼此相似时。这与收敛失败不同,后者是指 GAN 只生成无意义的图像。
即使生成的图像看起来很好,模式崩溃仍然可能发生,且它会限制在类的小子集(类间模式崩溃)或同一类中的几张相同图像(类内模式崩溃)之间。我们可以通过在一个包含两个高斯分布的混合体上训练 Vanilla GAN 来演示模式崩溃,您可以在ch3_mode_collapse笔记本中运行此实验。
下图展示了训练过程中生成样本的形态,呈现出两个高斯块的形状。一个样本是圆形的,另一个是椭圆形的:

图 3.9 – 上面的图是实际样本,下面的图展示了训练过程中在两个不同周期生成的样本
随着 Vanilla GAN 的训练,生成的样本可能看起来像一个小批次中的两个模式之一,但永远不会是两个模式同时出现。对于 Fashion-MNIST,可能生成器每次都会生成看起来相同的鞋子。毕竟,生成器的目标是生成真实感的图像,只要判别器认为这些图像是真实的,生成器就不会因为每次显示相同的鞋子而受到惩罚。正如原始 GAN 论文中数学证明的那样,当判别器达到了最优时,生成器将朝着优化Jensen-Shannon 散度(JSD)的方向进行调整。
对于我们的目的,我们只需要知道 JSD 是Kullback-Leibler 散度(KLD)的对称版本,其上界为log(2),而不是无限大的上界。不幸的是,JSD 也是模式崩溃的原因,如下图所示:

图 3.10 – 通过最小化 KLD、MMD 和 JSD 对从高斯混合分布中抽取的数据进行拟合的标准高斯分布(来源:L. Theis 等,2016 年,《关于生成模型评估的笔记》,https://arxiv.org/abs/1511.01844)
我们不会讨论最大均值差异(MMD),因为它在 GAN 中并未使用。数据是两个高斯分布,其中一个的质量密度比另一个大。我们对数据进行拟合一个单一的高斯分布。换句话说,我们试图估计一个最佳的均值和标准差来描述这两种类型的高斯分布。通过 KLD,我们可以看到,尽管拟合的高斯分布偏向于较大的高斯块,但它仍然覆盖了较小的高斯块。JSD 则不同,它只拟合最显著的高斯块。这也解释了 GAN 中的模式崩溃——当某些生成图像的概率较高时,这些模式会被优化器锁定。
构建 Wasserstein GAN
许多人尝试通过使用启发式方法来解决 GAN 训练的不稳定性,例如尝试不同的网络架构、超参数和优化器。2016 年,一个重大突破发生了,那就是引入了Wasserstein GAN (WGAN)。
WGAN 减轻甚至消除了我们之前讨论的许多 GAN 挑战。它不再需要精心设计网络架构,也不需要精确平衡判别器和生成器。模式崩溃问题也大大减少。
从原始 GAN 到 WGAN 的最大根本改进是损失函数的变化。理论上,如果两个分布是非交叠的,JSD 将不再是连续的,因此不可微,从而导致梯度为零。WGAN 通过使用一个新的损失函数解决了这个问题,这个损失函数在任何地方都是连续且可微的!
本练习的笔记本是ch3_wgan_fashion_mnist.ipynb。
提示
在这一部分,尤其是 WGAN-GP 部分,代码实现可以不学习,因为它更复杂。尽管从理论上来说更为优越,我们仍然可以使用一个更简单的损失函数,通过精心设计的模型架构和超参数稳定地训练 GAN。然而,你应该尝试理解 Lipschitz 约束这个术语,因为它在多个先进技术的发展中起到了重要作用,这些技术将在后续章节中介绍。
理解 Wasserstein 损失
让我们回顾一下非饱和值函数:

WGAN 使用一个新的损失函数,称为地球搬运工距离或简称 Wasserstein 距离。它衡量将一个分布转换为另一个分布所需的距离或努力。从数学角度来看,它是每个真实图像和生成图像联合分布的最小距离,这是不可计算的,涉及一些超出本书范围的数学假设,值函数变为:

现在,让我们将前面的方程与 NS 损失进行比较,并用它来推导损失函数。最显著的变化是log()被去除,另一个变化是虚假图像项的符号发生了变化。因此,第一项的损失函数为:

这是判别器输出的平均值,乘以-1。我们还可以通过使用yi 作为标签来推广,其中+1 表示真实图像,而-1 表示假图像。因此,我们可以将 Wasserstein 损失实现为一个 TensorFlow Keras 自定义损失函数,如下所示:
def wasserstein_loss(self, y_true, y_pred):
w_loss = -tf.reduce_mean(y_true*y_pred)
return w_loss
由于这个损失函数不再是二元交叉熵,判别器的目标不再是对真实与假图像进行分类或区分。相反,它的目标是最大化真实图像相对于假图像的得分。因此,在 WGAN 中,判别器被赋予了一个新名称——评论员。
生成器和鉴别器架构保持不变。唯一的变化是从鉴别器的输出中移除了 S 形函数。因此,评论家的预测是无界的,可以是非常大的正负值。通过实施1-Lipschitz约束来检查这一点。
实施 1-Lipschitz 约束
在 Wasserstein 损失中提到的数学假设是1-Lipschitz 函数。如果评论家D(x)满足以下不等式,则称为1-Lipschitz:

对于两个图像x1 和x2,它们的绝对评论家输出差异必须小于或等于它们的平均像素绝对差异。换句话说,评论家的输出不应该因为不同的图像(无论是真实图像还是伪造图像)而有太大的差异。当 WGAN 被发明时,作者们无法想出一个合适的实现来强制不等式。因此,他们想出了一个技巧,即将评论家的权重剪裁到一些小值。通过这样做,层的输出和最终评论家的输出都被限制在一些小值范围内。在 WGAN 论文中,权重被剪裁到[-0.01, 0.01]的范围内。
权重修剪可以通过两种方式实现。一种方法是编写一个自定义约束函数,并在实例化新层时使用它,如下所示:
class WeightsClip(tf.keras.constraints.Constraint):
def __init__(self, min_value=-0.01, max_value=0.01):
self.min_value = min_value
self.max_value = max_value
def __call__(self, w):
return tf.clip_by_value(w, self.min, self.max_value)
然后我们可以将函数传递给接受约束函数的层,如下所示:
model = tf.keras.Sequential(name='critics')
model.add(Conv2D(16, 3, strides=2, padding='same',
kernel_constraint=WeightsClip(),
bias_constraint=WeightsClip()))
model.add(BatchNormalization(
beta_constraint=WeightsClip(),
gamma_constraint=WeightsClip()))
然而,在每层创建时添加约束代码可能会使代码看起来臃肿。由于我们不需要挑选要剪裁的层,我们可以使用循环来读取权重并剪裁它们,然后再写回如下:
for layer in critic.layers:
weights = layer.get_weights()
weights = [tf.clip_by_value(w, -0.01, 0.01) for w in weights]
layer.set_weights(weights)
这是我们在代码示例中使用的方法。
重构训练步骤
在原始 GAN 理论中,鉴别器应该在生成器之前被最优化地训练。由于生成器的梯度消失随着鉴别器变得更好而变得不可能实现。现在,通过 Wasserstein 损失函数,梯度在任何地方都是可导的,我们不必担心评论家过于优秀而与生成器比较。
因此,在 WGAN 中,评论家每进行一次生成器训练步骤,就训练五次。为了实现这一点,我们将评论家训练步骤拆分为一个单独的函数,然后可以通过多次循环来执行:
for _ in range(self.n_critic):
real_images = next(data_generator)
critic_loss = self.train_critic(real_images, batch_size)
我们还需要重新调整生成器训练步骤。在我们的 DCGAN 代码中,我们使用两个模型 - 生成器和鉴别器。为了训练生成器,我们还使用梯度带来更新权重。所有这些都相当繁琐。有另一种方法可以实现生成器的训练步骤,即通过将两个模型合并为一个如下所示:
self.critic = self.build_critic()
self.critic.trainable = False
self.generator = self.build_generator()
critic_output = self.critic(self.generator.output)
self.model = Model(self.generator.input, critic_output)
self.model.compile(loss = self.wasserstein_loss, optimizer = RMSprop(3e-4))
self.critic.trainable = True
在前述代码中,我们通过将trainable=False冻结评论家层,并将其链到生成器以创建一个新模型并编译它。之后,我们可以再次将评论家设置为可训练,这不会影响我们已经编译的模型。
我们使用 train_on_batch() API 执行单次训练步骤,它会自动完成前向传播、损失计算、反向传播和权重更新:
g_loss = self.model.train_on_batch(g_input, real_labels)
对于本次练习,我们将图像尺寸调整为 32x32,这样我们就可以在生成器中使用更深的层次来放大图像。WGAN 的生成器和判别器架构如下所示:

图 3.11 – WGAN 生成器的模型摘要
生成器架构遵循常规设计,随着特征图大小的增大,通道数逐渐减小。以下是 WGAN 判别器的模型摘要:

图 3.12 – WGAN 判别器的模型摘要
尽管相较于 DCGAN 有所改进,但我发现训练 WGAN 仍然很困难,而且所生成的图像质量并不优于 DCGAN。接下来我们将实现一种 WGAN 变体,能够更快地训练并生成更清晰的图像。
实现梯度惩罚(WGAN-GP)
权重裁剪并不是强制执行 Lipschitz 约束的理想方法,WGAN 的作者也承认了这一点。它有两个缺点:能力未充分利用和梯度爆炸/消失。由于我们限制了权重,也限制了判别器的学习能力。研究发现,权重裁剪迫使网络只能学习简单的函数。因此,神经网络的能力被低效使用。
其次,裁剪值需要仔细调节。如果设置得太高,梯度会爆炸,从而违反 Lipschitz 约束。如果设置得太低,梯度会消失,导致网络回溯时出现问题。另外,权重裁剪会将梯度推向两个极限,具体如下图所示:

图 3.13 – 左:权重裁剪将权重推向两个值。右:梯度由梯度惩罚产生。来源:I. Gulrajani 等人,2017,改进的 Wasserstein GAN 训练
因此,提出了梯度惩罚(GP)来替代权重裁剪,以强制执行 Lipschitz 约束,具体如下:

我们将逐个查看方程中的每个变量,并在代码中实现它们。本次练习的 Jupyter 笔记本文件为 ch3_wgan_gp_fashion_mnist.ipynb。
我们通常用 x 来表示真实图像,但现在方程中有一个
。这个
是在真实图像和假图像之间的逐点插值。图像的比例,或者说 epsilon,是从 [0,1] 的均匀分布中抽取的:
epsilon = tf.random.uniform((batch_size,1,1,1))
interpolates = epsilon*real_images + \ (1-epsilon)*fake_images
有数学证明“最优评论员包含连接来自 Pr 和 Pg 的配对点的梯度范数为 1 的直线”, 这是引用自 WGAN-GP 论文 改进的 Wasserstein GAN 训练方法(arxiv.org/pdf/1704.00028.pdf)。对于我们的目的,我们可以理解为梯度来自真实和虚假图像的混合,我们不需要分别为真实和虚假图像计算惩罚。
项目
是评论员输出相对于插值的梯度。我们可以再次使用梯度带获取梯度:
with tf.GradientTape() as gradient_tape:
gradient_tape.watch(interpolates)
critic_interpolates = self.critic(interpolates)
gradient_d = gradient_tape.gradient( critic_interpolates, [interpolates])
下一步是计算 L2 范数:

我们对每个值进行平方,将它们相加,然后取平方根,如下所示:
grad_loss = tf.square(grad)
grad_loss = tf.reduce_sum(grad_loss, axis=np.arange(1, len(grad)loss.shape)))
graid_loss = tf.sqrt(grad_loss)
在执行 tf.reduce_sum() 时,我们会排除轴上的第一维,因为该维度是批次大小。惩罚的目的是使梯度范数接近 1,这是计算梯度损失的最后一步:
grad_loss = tf.reduce_mean(tf.square(grad_loss - 1))
方程中的 lambda 是梯度惩罚与其他评论员损失的比率,论文中设定为 10。现在,我们将所有评论员损失和梯度惩罚加起来进行反向传播并更新权重:
total_loss = loss_real + loss_fake + LAMBDA * grad_loss
gradients = total_tape.gradient(total_loss, self.critic.variables)
self.optimizer_critic.apply_gradients(zip(gradients, self.critic.variables))
这就是你需要添加到 WGAN 中以将其转换为 WGAN-GP 的所有内容。不过有两件事需要去掉:
-
权重剪辑
-
评论员中的批量归一化
梯度惩罚是为了惩罚评论员对每个输入的梯度范数。然而,批量归一化会改变梯度的批量统计信息。为了解决这个问题,批量归一化被移除了,并且发现它仍然有效。这已成为 GAN 中的常见做法。
评论员架构与 WGAN 相同,只是去除了批量归一化:

图 3.14 – WGAN-GP 模型总结
以下是经过训练的 WGAN-GP 生成的样本:

图 3.15 – WGAN-GP 生成的样本
它们看起来非常清晰漂亮,就像来自 Fashion-MNIST 数据集的样本。训练过程非常稳定,且很快收敛!接下来,我们将通过在 CelebA 上进行训练来测试 WGAN-GP!
调整 WGAN-GP 以适应 CelebA
我们将对 WGAN-GP 进行一些小的调整,以便在 CelebA 数据集上进行训练。首先,由于我们将使用比之前 32 更大的图像大小 64,因此我们需要添加另一个上采样阶段。然后,我们将批量归一化替换为 层归一化,这是 WGAN-GP 作者建议的做法。下图显示了不同类型的归一化,适用于维度为 (N, H, W, C) 的张量,其中符号代表批量大小、高度、宽度和通道:

图 3.16 – 深度学习中使用的不同类型的标准化方法。(来源:Y. Wu, K. He, 2018, Group Normalization)
批归一化通过统计计算 (N, H, W) 沿每个通道产生一个统计量。相比之下,层归一化计算一个样本内所有张量的统计量,即 (H, W, C),因此不会在样本之间产生相关性,在图像生成中效果更好。它是批归一化的替代方案,其中我们将 Batch 替换为 Layer:
model.add(layers.BatchNormalization())
model.add(layers.LayerNormalization())
此练习的 Jupyter 笔记本是 ch3_wgan_gp_celeb_a.ipynb。以下是我们的 WGAN-GP 生成的图像。尽管 WGAN-GP 的训练时间更长,因为需要额外步骤进行梯度惩罚,但训练能够更快地收敛:

图 3.17 – WGAN-GP 生成的名人面孔
与 VAE 相比,它们看起来并不完美,部分原因是没有重建损失来确保面部特征停留在它们应该在的位置上。尽管如此,这鼓励了 GAN 更具想象力,结果生成了更多种类的面孔。我也没有注意到模式崩溃。WGAN-GP 是实现 GAN 训练稳定性的里程碑。许多后续的 GAN 使用 Wasserstein 损失和梯度惩罚,包括渐进式 GAN,在第七章**,高保真度人脸生成中我们将详细讨论。
总结
我们在这一章确实学到了很多。我们首先学习了 GAN 的理论和损失函数,以及如何将数学价值函数转化为二元交叉熵损失的代码实现。我们使用卷积层、批归一化层和泄漏 ReLU 实现了 DCGAN,使网络更深入。然而,训练 GAN 仍然面临挑战,包括由于 Jensen-Shannon 散度导致的不稳定性和模式崩溃。
许多这些问题都被 WGAN 与 Wasserstein 距离、权重裁剪以及在评论者输出中去除 Sigmoid 解决了。最后,WGAN-GP 引入了梯度惩罚来正确施加 1-Lipschitz 约束,并为稳定的 GAN 训练提供了框架。然后,我们用层归一化替换了批归一化,在 CelebA 数据集上成功训练,生成了多样的面孔。
这标志着本书第一部分的结束。恭喜你走到了这一步!到现在为止,你已经学习了如何使用不同类型的生成模型来生成图像。这包括了自回归模型如 PixelCNN,在第一章中介绍的《使用 TensorFlow 进行图像生成入门》、变分自编码器以及本章中的 GAN。你现在已经熟悉了分布、损失函数的概念,以及如何构建神经网络来进行图像生成。
有了这个坚实的基础,我们将在书的第二部分探索一些有趣的应用,在那里我们还将学习一些高级技术和酷炫的应用。在下一章,我们将学习如何使用 GAN 进行图像到图像的转换。
第二部分:深度生成模型的应用
在这一部分中,您将了解一些图像生成模型的有趣应用。包括将马转化为斑马,以及使用神经风格迁移将照片转变为艺术画作。
本部分包括以下章节:
-
第四章**, 图像到图像的转换
-
第五章**, 风格迁移
-
第六章**, AI 绘画师
第四章:第四章:图像到图像的翻译
在本书的第一部分,我们学习了如何使用 VAE 和 GAN 生成逼真的图像。生成模型能够将一些简单的随机噪声转化为具有复杂分布的高维图像!然而,生成过程是无条件的,我们对生成的图像几乎没有控制。如果我们使用 MNIST 作为例子,我们无法知道会生成哪个数字,这有点像彩票。是不是很希望能够告诉 GAN 我们想要它生成什么呢?这就是我们在本章中要学习的内容。
我们将首先学习构建一个条件生成对抗网络(cGAN),它允许我们指定要生成的图像类别。这为后续更复杂的网络奠定了基础。我们将学习构建一个名为pix2pix的生成对抗网络,用于执行图像到图像的转换,简称图像翻译。这将使得许多酷炫的应用成为可能,比如将草图转换成真实的图像。之后,我们将构建CycleGAN,它是 pix2pix 的升级版,能够将马转变为斑马,然后再变回马!最后,我们将构建BicycleGAN,不仅能够翻译出高质量的图像,还能生成具有不同风格的多样化图像。本章将涵盖以下内容:
-
条件生成对抗网络(Conditional GANs)
-
使用 pix2pix 进行图像翻译
-
使用 CycleGAN 进行无配对图像翻译
-
使用 BicycleGAN 进行多样化翻译
本章将涵盖以下内容:
在本章中,我们将重用第三章《生成对抗网络》中的代码和网络模块,如 DCGAN 的上采样和下采样模块。这将使我们能够专注于新 GAN 的更高层次架构,并在本章中介绍更多的 GAN。后面提到的三个 GAN 是按时间顺序创建的,并共享许多共同的模块。因此,你应该按顺序阅读它们,首先是 pix2pix,然后是 CycleGAN,最后是 BicycleGAN,这样会比直接跳到 BicycleGAN(这是本书中最复杂的模型)更容易理解。
技术要求
相关的 Jupyter 笔记本可以在以下链接找到:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter04
本章中使用的笔记本如下:
-
ch4_cdcgan_mnist.ipynb -
ch4_cdcgan_fashion_mnist.ipynb -
ch4_pix2pix.ipynb -
ch4_cyclegan_facade.ipynb -
ch4_cyclegan_horse2zebra.ipynb -
ch4_bicycle_gan.ipynb
条件生成对抗网络(Conditional GANs)
生成模型的第一个目标是能够生成高质量的图像。接着,我们希望能够对生成的图像进行一定的控制。
在 第一章 使用 TensorFlow 开始进行图像生成 中,我们了解了条件概率,并使用简单的条件概率模型生成了具有特定属性的面孔。在那个模型中,我们通过强制模型仅从具有微笑面孔的图像中采样来生成微笑的面孔。当我们对某些事物进行条件化时,该事物将始终存在,并且不再是具有随机概率的变量。你也可以看到,这些条件的概率被设置为 1。
在神经网络上强制条件很简单。我们只需要在训练和推理过程中向网络展示标签。例如,如果我们希望生成器生成数字 1,我们需要将数字 1 的标签与常规的随机噪声一起输入到生成器中。实现这一点有几种方法。下图展示了一种实现方式,最初出现在 条件生成对抗网络 论文中,该论文首次提出了 cGAN 的概念:

图 4.1 – 通过连接标签和输入来实现条件(改绘自:M. Mirza, S. Osindero, 2014, 条件生成对抗网络 – https://arxiv.org/abs/1411.1784)
在无条件 GAN 中,生成器的输入只有潜在向量 z。在条件 GAN 中,潜在向量 z 与一热编码的输入标签 y 结合,形成一个更长的向量,如前面的图所示。下表显示了使用 tf.one_hot() 的一热编码:

图 4.2 – 显示 TensorFlow 中 10 类的一热编码的表格
一热编码将标签转换为一个与类别数相等的向量。除了一个唯一的位置被填充为 1 之外,向量中的其他位置都为 0。一些机器学习框架在向量中的 1 的顺序不同;例如,类别标签 0 被编码为 0000000001,其中 1 位于最右侧的位置。顺序并不重要,只要在训练和推理过程中始终如一地使用。这是因为一热编码仅用于表示类别类,并没有语义意义。
实现一个条件 DCGAN
现在,让我们在 MNIST 上实现一个条件 DCGAN。我们在 第二章 变分自编码器 中实现了一个 DCGAN,因此我们通过添加条件部分来扩展网络。本练习的笔记本文件是 ch4_cdcgan_mnist.ipynb。
首先我们来看一下生成器:
第一步是对类别标签进行独热编码。由于tf.one_hot([1], 10)将生成形状为(1, 10)的张量,我们需要将其重塑为形状为(10)的 1D 向量,以便与潜在向量z进行连接:
input_label = layers.Input(shape=1, dtype=tf.int32, name='ClassLabel')
one_hot_label = tf.one_hot(input_label, self.num_classes)
one_hot_label = layers.Reshape((self.num_classes,)) (one_hot_label)
下一步是通过使用Concatenate层将向量连接在一起。默认情况下,连接发生在最后一个维度(axis=-1)。因此,将形状为(batch_size, 100)的潜在变量与形状为(batch_size, 10)的独热标签连接,会生成形状为(batch_size, 110)的张量。代码如下:
input_z = layers.Input(shape=self.z_dim, name='LatentVector')
generator_input = layers.Concatenate()([input_z,
one_hot_label])
这是生成器所需的唯一更改。由于我们已经讨论过 DCGAN 架构的细节,本文不再重复。在此简要回顾一下,输入将通过一个全连接层,然后经过若干上采样和卷积层,生成形状为(32, 32, 3)的图像,如下所示的模型图:

图 4.3 – 条件 DCGAN 的生成器模型图
下一步是将标签注入判别器中,因为仅仅判别图像是否真实或虚假是不够的,还需要判断它是否是正确的图像。
原始的 cGAN 仅在网络中使用全连接层。输入图像被展开并与独热编码的类别标签连接。然而,这种方法对于 DCGAN 并不适用,因为判别器的第一层是卷积层,期待一个二维图像作为输入。如果我们使用相同的方法,最终会得到一个输入向量 32×32×1 + 10 = 1,034,这不能被重塑为二维图像。我们需要另一种方式将独热向量投影到正确形状的张量中。
一种实现方式是使用全连接层将独热向量投影到输入图像(32,32,1)的形状,并进行连接,生成(32, 32, 2)的形状。第一个颜色通道是我们的灰度图像,第二个通道是投影后的独热标签。同样,判别器网络的其余部分保持不变,如下所示的模型摘要:

图 4.4 – 条件 DCGAN 判别器的输入
正如我们所见,对网络所做的唯一更改是增加了另一个路径,该路径接受类别标签作为输入。在开始模型训练之前,最后剩下的工作是将附加的标签类别添加到模型的输入中。为了创建具有多个输入的模型,我们按照以下方式传递输入层列表:
discriminator = Model([input_image, input_label], output]
类似地,我们在执行前向传播时,以相同顺序传递images和labels列表:
pred_real = discriminator([real_images, class_labels])
在训练过程中,我们为生成器创建随机标签,如下所示:
fake_class_labels = tf.random.uniform((batch_size), minval=0, maxval=10, dtype=tf.dtypes.int32)
fake_images = generator.predict([latent_vector, fake_class_labels])
我们使用 DCGAN 训练管道和损失函数。以下是通过对输入标签(从 0 到 9)进行条件化生成的数字样本:

图 4.5 – 由条件 DCGAN 生成的手写数字
我们也可以在不做任何修改的情况下,使用 Fashion-MNIST 训练 cDCGAN。结果样本如下:

图 4.6 – 由条件 DCGAN 生成的图像
条件 GAN 在 MNIST 和 Fashion-MNIST 上表现非常好!接下来,我们将探讨不同的将类别条件应用于 GAN 的方法。
cGAN 的变体
我们通过一热编码标签,传递给密集层(用于判别器),并与输入层拼接,来实现条件 DCGAN。这个实现简单且效果良好。我们将介绍一些其他流行的条件 GAN 实现方法,建议你自己动手实现代码并试一试。
使用嵌入层
一种常见的实现方法是用 embedding 层替代一热编码和密集层。嵌入层接受类别值作为输入,输出是一个向量,类似于密集层。换句话说,它具有与 label->one-hot-encoding->dense 块相同的输入和输出形状。代码片段如下:
encoded_label = tf.one_hot(input_label, self.num_classes)
embedding = layers.Dense(32 * 32 * 1, activation=None)\ (encoded_label)
embedding = layers.Embedding(self.num_classes, 32*32*1)(input_label)
两种方法生成的结果相似,尽管由于一热编码的向量维度可能会随着类别数的增加而迅速增大,embedding 层在计算效率上更为高效。由于词汇量庞大,嵌入层在编码词语时被广泛应用。对于像 MNIST 这样的类别较少的情况,计算上的优势几乎可以忽略不计。
元素级乘法
将潜在向量与输入图像拼接会增加网络的维度以及第一层的大小。我们也可以选择将标签嵌入与原始网络输入进行元素级乘法操作,而不是拼接,从而保持原始输入形状。这种方法的起源不明确。然而,一些行业专家在自然语言处理任务中进行实验,发现这种方法比一热编码方法效果更好。执行图像与嵌入之间元素级乘法的代码片段如下:
x = layers.Multiply()([input_image, embedding])
将前述代码与嵌入层结合后,我们得到如下图表,代码实现见 ch4_cdcgan_fashion_mnist.ipynb:

图 4.7 – 使用嵌入和元素级乘法实现 cDCGAN
接下来,我们将看到为什么将标签插入到中间层中是一种流行的做法。
将标签插入到中间层
我们可以选择将标签插入到网络的中间层,而不是第一层。这种方法对于编码器-解码器架构的生成器非常流行,其中标签被插入到接近编码器末端且维度最小的层。一些方法将标签嵌入到接近判别器输出的地方,这样判别器的大部分可以专注于判断图像是否真实。最后几层的容量只用于判断图像是否与标签匹配。
当我们在第八章中实现高级模型时,我们将学习如何将标签嵌入到中间层和归一化层,自注意力机制用于图像生成。我们现在已经理解了如何使用条件类标签生成图像。在本章的其余部分,我们将使用图像作为条件来执行图像到图像的翻译。
使用 pix2pix 进行图像翻译
2017 年 pix2pix 的介绍引起了极大的轰动,不仅在研究界,而且在更广泛的公众中。这部分归功于affinelayer.com/pixsrv/网站,它将模型在线化,并允许人们将草图转换为猫、鞋子和包包。你也应该试试!以下截图取自该网站,给你一个了解其如何工作的窗口:

图 4.8 – 将猫的草图转化为真实图像的应用(来源: https://affinelayer.com/pixsrv/)
Pix2pix 来源于一篇名为条件对抗网络的图像到图像翻译的研究论文。从论文标题中我们可以看出,pix2pix 是一种执行图像到图像翻译的条件 GAN 模型。该模型可以被训练用于执行一般的图像翻译,但我们需要数据集中包含图像对。在我们的 pix2pix 实现中,我们将建筑立面的掩码翻译成逼真的建筑立面,如下所示:

图 4.9 – 建筑立面掩码与真实图像
在上面的截图中,左边的图片展示了作为 pix2pix 输入的语义分割掩码示例,其中建筑部分通过不同的颜色进行编码。右边是建筑立面的目标真实图像。
丢弃随机噪声
在我们迄今为止学习的所有 GAN 中,我们总是从随机分布中采样作为生成器的输入。我们需要这种随机性,否则生成器将产生确定性的输出,无法学习数据分布。Pix2pix 打破了这个传统,通过去除 GAN 中的随机噪声。正如作者在 《基于条件对抗网络的图像到图像翻译》 论文中指出的那样,他们无法让条件 GAN 使用图像和噪声作为输入,因为 GAN 会忽略噪声。
结果,作者开始在生成器层中使用 dropout 来引入随机性。一个副作用是,这种随机性较小,因此输出中会看到一些微小的变化,而且它们的风格通常相似。这个问题在 BicycleGAN 中得到了解决,我们将在后续学习到它。
U-Net 作为生成器
本教程的笔记本文件是 ch4_pix2pix.ipynb。生成器和判别器的架构与 DCGAN 有很大不同,我们将详细讲解每一部分。由于不使用随机噪声作为输入,生成器的输入仅剩下作为条件的输入图像。因此,输入和输出都是相同形状的图像,在我们的示例中是 (256, 256, 3)。Pix2pix 使用 U-Net,这是一种类似于自编码器的编码器-解码器架构,但在编码器和解码器之间有跳跃连接。以下是原始 U-Net 的架构图:

图 4.10 – 原始 U-Net 架构(来源:O. Ronneberger 等,2015,《U-Net: Convolutional Networks for Biomedical Image Segmentation》– https://arxiv.org/abs/1505.04597)
在 第二章**,变分自编码器 中,我们看到自编码器如何将高维输入图像下采样成低维的潜在变量,然后再上采样回原始大小。在下采样过程中,图像的高频内容(即纹理细节)会丢失。因此,恢复的图像可能会显得模糊。通过通过跳跃连接将来自编码器的高空间分辨率内容传递给解码器,解码器可以捕获并生成这些细节,使图像看起来更加清晰。事实上,U-Net 最初用于将医学图像转化为语义分割掩码,这正是我们在本章中所尝试做的事情的反向。
为了简化生成器的构建,我们首先编写一个函数,创建一个默认步幅为 2 的下采样块。它由卷积层和可选的归一化、激活 和 dropout 层组成,具体如下:
def downsample(self, channels, kernels, strides=2, norm=True, activation=True, dropout=False):
initializer = tf.random_normal_initializer(0., 0.02)
block = tf.keras.Sequential()
block.add(layers.Conv2D(channels, kernels, strides=strides, padding='same', use_bias=False, kernel_initializer=initializer))
if norm:
block.add(layers.BatchNormalization())
if activation:
block.add(layers.LeakyReLU(0.2))
if dropout:
block.add(layers.Dropout(0.5))
return block
upsample 块类似,但在 Conv2D 之前添加了一个 UpSampling2D 层,并且步幅为 1,如下所示:
def upsample(self, channels, kernels, strides=1, norm=True, activation=True, dropout=False):
initializer = tf.random_normal_initializer(0., 0.02)
block = tf.keras.Sequential()
block.add(layers.UpSampling2D((2,2)))
block.add(layers.Conv2D(channels, kernels, strides=strides, padding='same', use_bias=False, kernel_initializer=initializer))
if norm:
block.add(InstanceNormalization())
if activation:
block.add(layers.LeakyReLU(0.2))
if dropout:
block.add(layers.Dropout(0.5))
return block
我们首先构建下采样路径,在每个下采样块之后,特征图的尺寸会减半,如下所示。需要注意的是输出形状,因为我们需要将其与上采样路径的跳跃连接进行匹配,如下所示:
input_image = layers.Input(shape=image_shape)
down1 = self.downsample(DIM, 4, norm=False)(input_image) # 128
down2 = self.downsample(2*DIM, 4)(down1) # 64
down3 = self.downsample(4*DIM, 4)(down2) # 32
down4 = self.downsample(4*DIM, 4)(down3) # 16
down5 = self.downsample(4*DIM, 4)(down4) # 8
down6 = self.downsample(4*DIM, 4)(down5) # 4
down7 = self.downsample(4*DIM, 4)(down6) # 2
在上采样路径中,我们将前一层的输出与来自下采样路径的跳跃连接进行拼接,以形成 upsample 块的输入。我们在前三层使用了 dropout,如下所示:
up6 = self.upsample(4*DIM, 4, dropout=True)(down7) # 4,4*DIM
concat6 = layers.Concatenate()([up6, down6])
up5 = self.upsample(4*DIM, 4, dropout=True)(concat6)
concat5 = layers.Concatenate()([up5, down5])
up4 = self.upsample(4*DIM, 4, dropout=True)(concat5)
concat4 = layers.Concatenate()([up4, down4])
up3 = self.upsample(4*DIM, 4)(concat4)
concat3 = layers.Concatenate()([up3, down3])
up2 = self.upsample(2*DIM, 4)(concat3)
concat2 = layers.Concatenate()([up2, down2])
up1 = self.upsample(DIM, 4)(concat2)
concat1 = layers.Concatenate()([up1, down1])
output_image = tanh(self.upsample(3, 4, norm=False, activation=None)(concat1))
生成器的最后一层是 Conv2D,通道数为 3,以匹配图像的通道数。像 DCGAN 一样,我们将图像标准化到 [-1, +1] 范围内,使用 tanh 作为激活函数,并采用二元交叉熵作为损失函数。
损失函数
Pix2pix 使用标准的 GAN 损失函数(二元交叉熵)来训练生成器和判别器,就像 DCGAN 一样。既然我们有了目标图像可以生成,那么我们就可以为生成器添加 L1 重建损失。在论文中,重建损失与二元交叉熵的比例设定为 100:1。以下代码片段展示了如何编译结合生成器和判别器的损失函数:
LAMBDA = 100
self.model.compile(loss = ['bce','mae'],
optimizer = Adam(2e-4, 0.5, 0.9999),
loss_weights=[1, LAMBDA])
bce 代表 mae 代表 平均绝对熵损失,或更常见的称为 L1 损失。
实现 PatchGAN 判别器
研究人员发现,L2 或 L1 损失会导致图像生成问题产生模糊的结果。尽管它们未能鼓励高频的清晰度,但能够很好地捕捉低频内容。我们可以将低频信息视为内容,如建筑结构,而高频信息则提供样式信息,如建筑外立面的细节纹理和颜色。为了捕捉高频信息,研究人员使用了一种新的判别器,称为 PatchGAN。不要被它的名字误导;PatchGAN 不是 GAN,而是 卷积神经网络 (CNN)。
传统的 GAN 判别器查看整张图像并判断整张图像是实图还是假图。而 PatchGAN 并非查看整张图像,而是查看图像的若干小块,因此得名 PatchGAN。卷积层的感受野是指映射到一个输出点的输入点的数量,换句话说,就是卷积核的大小。对于一个 N×N 的卷积核,层的每个输出都对应输入张量的 N×N 像素。
随着网络的深入,下一层将看到更大块的输入图像,输出的有效感受野也随之增加。默认的 PatchGAN 被设计为具有 70×70 的有效感受野。原始的 PatchGAN 由于经过精心的填充,输出形状为 30×30,但我们将仅使用“相同”填充以得到 29×29 的输出形状。每个 29×29 的小块查看不同且重叠的 70×70 输入图像块。
换句话说,判别器试图预测每个小块是实时的还是假的。通过放大局部小块,判别器被鼓励去关注图像的高频信息。总结一下,我们使用 L1 重建损失来捕捉低频内容,使用 PatchGAN 来鼓励高频样式细节。
PatchGAN 本质上是一个卷积神经网络(CNN),可以通过几个下采样块来实现,如以下代码所示。我们将使用符号 A 来表示输入(源)图像,B 来表示输出(目标)图像。与 cGAN 类似,判别器需要两个输入——条件图像 A 和输出图像 B,后者可以是真实的(来自数据集的)图像,也可以是生成的假图像。我们在判别器的输入端将这两张图像拼接在一起,因此 PatchGAN 会同时查看图像 A(条件图像)和图像 B(输出图像或假图像),以决定它们是真实的还是假的。代码如下:
def build_discriminator(self):
DIM = 64
model = tf.keras.Sequential(name='discriminators')
input_image_A = layers.Input(shape=image_shape)
input_image_B = layers.Input(shape=image_shape)
x = layers.Concatenate()([input_image_A, input_image_B])
x = self.downsample(DIM, 4, norm=False)(x)
x = self.downsample(2*DIM, 4)(x)
x = self.downsample(4*DIM, 4)(x)
x = self.downsample(8*DIM, 4, strides=1)(x)
output = layers.Conv2D(1, 4, activation='sigmoid')(x)
return Model([input_image_A, input_image_B], output)
判别器模型的总结如下:

图 4.11 – 判别器模型总结
请注意,输出层的形状是 (29, 29, 1)。因此,我们将创建与其输出形状匹配的标签,如下所示:
real_labels = tf.ones((batch_size, self.patch_size, self.patch_size, 1))
fake_labels = tf.zeros((batch_size, self.patch_size, self.patch_size, 1))
fake_images = self.generator.predict(real_images_A)
pred_fake = self.discriminator([real_images_A, fake_images])
pred_real = self.discriminator([real_images_A, real_images_B])
现在我们准备好训练 pix2pix 了。
训练 pix2pix
由于 pix2pix 的发明,大家都知道批量归一化对于图像生成是不利的,因为批量图像的统计信息往往会使得生成的图像看起来更加相似且模糊。pix2pix 的作者注意到,当批量大小设置为 1 时,生成的图像看起来更好。当批量大小为 1 时,批量归一化变成了 实例归一化 的一种特殊情况,但后者适用于任何批量大小。回顾归一化,对于形状为 (N, H, W, C) 的图像批次,批量归一化使用的是 (N, H, W) 的统计信息,而实例归一化使用的是单个图像在 (H, W) 维度上的统计信息。这可以防止其他图像的统计信息干扰进来。
因此,为了获得良好的结果,我们可以选择使用批量大小为 1 的批量归一化,或者用实例归一化来替代。写本文时,实例归一化并未作为标准的 Keras 层提供,可能是因为它在图像生成之外尚未得到广泛应用。不过,实例归一化可以从 tensorflow_addons 模块中找到。在从该模块导入后,它可以直接替代批量归一化:
from tensorflow_addons.layers import InstanceNormalization
我们使用 DCGAN 流水线来训练 pix2pix,与 DCGAN 相比,它的训练过程出乎意料的简单。这是因为输入图像的概率分布比随机噪声的分布要窄。以下图像展示了训练 100 个 epochs 后的样本图像。左边的图像是分割掩膜,中间的是地面真实图像,右边的是生成的图像:

图 4.12 – 经过 100 轮训练后,pix2pix 生成的图像。左:输入遮罩。中:真实图像。右:生成的图像
由于重建损失的权重较大(lambda=100),pix2pix 生成的图像能够正确地捕捉图像内容。例如,门窗几乎总是位于正确的位置且形状正确。然而,它在风格上缺乏变化,因为生成的建筑物大多呈现相同的颜色,窗户的风格也相似。这是由于模型中缺乏前面提到的随机噪声,作者也对此表示认可。尽管如此,pix2pix 仍然为使用 GAN 进行图像到图像的翻译打开了大门。
使用 CycleGAN 进行无配对图像翻译
CycleGAN 是由与发明 pix2pix 相同的研究小组创建的。CycleGAN 可以通过使用两个生成器和两个判别器,在无配对图像的情况下进行训练。然而,一旦理解了循环一致性损失的工作原理,基于 pix2pix 的 CycleGAN 实现起来其实相当简单。在此之前,我们先试着理解 CycleGAN 相较于 pix2pix 的优势。
无配对数据集
pix2pix 的一个缺点是它需要配对的训练数据集。对于某些应用,我们可以相对容易地创建数据集。灰度到彩色的图像数据集及其反向数据集可能是最简单的创建方法,可以使用 OpenCV 或 Pillow 等图像处理软件库来完成。类似地,我们也可以通过边缘检测技术轻松地从真实图像中创建素描。对于照片到艺术画的图像数据集,我们可以使用神经风格迁移(我们将在第五章,风格迁移 中讲解)将真实图像转换为艺术画。
然而,有些数据集无法自动化生成,例如从白天到晚上的场景。有些数据需要手动标注,这可能会很昂贵,例如建筑外立面的分割遮罩。然后,一些图像对根本无法收集或创建,例如马到斑马的图像翻译。这正是 CycleGAN 的优势所在,它不需要配对数据。CycleGAN 可以在无配对数据集上进行训练,然后进行双向图像翻译!
循环一致性损失
在生成模型中,生成器将从 A 域(源)翻译到 B 域(目标),例如,从橙子到苹果。通过对来自 A(橙子)的图像进行条件处理,生成器创造出具有 B(苹果)像素分布的图像。然而,这并不保证这些图像在有意义的方式上是配对的。
我们可以用语言翻译作为类比。假设你是一个外国游客,你请求当地人帮助将一句英文句子翻译成当地语言,她回答了一个听起来很美丽的句子。好的,它听起来很真实,但翻译正确吗?你走在街上,向另一个人询问这句翻译成英文的意思。如果这次翻译与最初的英文句子相匹配,那么我们就知道翻译是正确的。
使用相同的概念,CycleGAN 采用了一个翻译循环,以确保映射在两个方向上都是正确的。下图展示了 CycleGAN 的架构,它在两个生成器之间形成一个循环:

图 4.13 – CycleGAN 的架构。(实线箭头表示前向循环的流动,而虚线箭头路径不用于前向循环,但为了显示各个模块之间的整体连接关系而绘制。)
在前面的图示中,左侧是图像域 A,右侧是图像域 B。以下是所遵循的过程:
GAB 是一个生成器,它将 A 转换为伪造的 B;生成的图像随后传递给鉴别器 DB。这是标准的 GAN 数据路径。接下来,伪造的图像 B 会通过 GBA 转换回域 A,完成前向路径。此时,我们得到了一张重建的图像,A。如果翻译完美无缺,那么它应该与源图像 A 一模一样。
我们还遇到了 循环一致性损失,它是源图像与重建图像之间的 L1 损失。同样,对于反向路径,我们从将图像从域 B 翻译到 A 开始这个循环。
在训练中,我们使用来自 ch4_cyclegan_facade.ipynb 笔记本的两个图像来展示 CycleGAN。
CycleGAN 还使用了所谓的 身份损失,它等同于 pix2pix 的重建损失。GAB 将图像 A 转换为伪造的 B,而前向身份损失是伪造的 B 与真实的 B 之间的 L1 距离。同样,在反向方向上也有一个身份损失。对于外立面数据集,身份损失的权重应该设置得较低。这是因为该数据集中的一些真实图像有部分图像被遮挡。这个数据集的目的是让机器学习算法猜测缺失的像素。因此,我们使用低权重来防止网络翻译这些遮挡区域。
构建 CycleGAN 模型
现在我们将构建 CycleGAN 的判别器和生成器。判别器是 PatchGAN,类似于 pix2pix,具有两个变化。首先,判别器只看到来自其领域的图像,因此,输入到判别器的只有一张图像,而不是 A 和 B 的两张图像。换句话说,判别器只需要判断图像是否在其领域内是真实的还是假的。
其次,输出层去掉了 sigmoid。这是因为 CycleGAN 使用了不同的对抗损失函数,称为最小二乘损失。我们在本书中没有介绍 LSGAN,但足够知道这种损失比对数损失更稳定,并且我们可以使用 Keras 的均方误差(MSE)函数来实现它。我们按以下步骤训练判别器:
def build_discriminator(self):
DIM = 64
input_image = layers.Input(shape=image_shape)
x = self.downsample(DIM, 4, norm=False)(input_image) # 128
x = self.downsample(2*DIM, 4)(x) # 64
x = self.downsample(4*DIM, 4)(x) # 32
x = self.downsample(8*DIM, 4, strides=1)(x) # 29
output = layers.Conv2D(1, 4)(x)
对于生成器,原始的 CycleGAN 使用了残差块以提高性能,但我们将重用 pix2pix 中的 U-Net,这样我们可以更专注于 CycleGAN 的高级架构和训练步骤。
现在,让我们实例化两对生成器和判别器:
self.discriminator_B = self.build_discriminator()
self.discriminator_A = self.build_discriminator()
self.generator_AB = self.build_generator()
self.generator_BA = self.build_generator()
这就是 CycleGAN 的核心,实现在组合模型中训练生成器。我们只需要按照架构图中的箭头将输入传递给生成器,生成一个假图像,然后送入判别器,并按以下方式循环返回:
image_A = layers.Input(shape=input_shape)
image_B = layers.Input(shape=input_shape)
# forward
fake_B = self.generator_AB(image_A)
discriminator_B_output = self.discriminator_B(fake_B)
reconstructed_A = self.generator_BA(fake_B)
# backward
fake_A = self.generator_BA(image_B)
discriminator_A_output = self.discriminator_A(fake_A)
reconstructed_B = self.generator_AB(fake_A)
# identity
identity_B = self.generator_AB(image_A)
identity_A = self.generator_BA(image_B)
最后一步是使用这些输入和输出创建模型:
self.model = Model(inputs=[image_A, image_B],
outputs=[discriminator_B_output,
discriminator_A_output,
reconstructed_A,
reconstructed_B,
identity_A, identity_B
])
然后,我们需要为它们分配正确的损失和权重。如前所述,我们使用mae(L1 损失)作为循环一致性损失,使用mse(均方误差)作为对抗损失,如下所示:
self.LAMBDA = 10
self.LAMBDA_ID = 5
self.model.compile(loss = ['mse','mse', 'mae','mae', 'mae','mae'],
optimizer = Adam(2e-4, 0.5),
loss_weights=[1, 1,
self.LAMBDA, self.LAMBDA,
self.LAMBDA_ID, self.LAMBDA_ID])
在每个训练步骤中,我们首先训练两个方向的判别器,分别是从 A 到 B 和从 B 到 A。train_discriminator()函数包括使用假图像和真实图像进行训练,如下所示:
# train discriminator
d_loss_AB = self.train_discriminator(“AB”, real_images_A, real_images_B)
d_loss_BA = self.train_discriminator(“BA”, real_images_B, real_images_A)
接下来是训练生成器。输入是来自 A 和 B 的真实图像。关于标签,第一个对是真实/假标签,第二个对是循环重建图像,最后一对是身份损失:
# train generator
combined_loss = self.model.train_on_batch(
[real_images_A, real_images_B],
[real_labels, real_labels,
real_images_A, real_images_B,
real_images_A, real_images_B
])
然后我们可以开始训练。
CycleGAN 分析
以下是一些 CycleGAN 生成的建筑立面:

图 4.14 – CycleGAN 生成的建筑立面
虽然它们看起来不错,但不一定比 pix2pix 更好。与 pix2pix 相比,CycleGAN 的优势在于能够在无配对数据上进行训练。为了测试这一点,我创建了ch4_cyclegan_horse2zebra.ipynb,在无配对的马和斑马图像上进行训练。只要你知道,在无配对图像上进行训练要困难得多。所以,祝你玩得开心!以下是马和斑马之间的图像到图像翻译:

图 4.15 – 马和斑马之间的翻译(来源:J-Y. Zhu 等人,“使用周期一致的对抗网络进行未配对图像到图像的翻译” – https://arxiv.org/abs/1703.10593)
Pix2pix 和 CycleGAN 是许多人使用的流行 GAN。然而,它们都有一个缺点:即输出的图像几乎总是相同的。例如,如果我们执行斑马到马的翻译,马的皮肤颜色总是相同的。这是由于 GAN 本身的特性,它会学习排除噪声的随机性。在下一节中,我们将讨论 BicycleGAN 如何解决这个问题,生成更丰富的图像变化。
使用 BicycleGAN 实现翻译多样化
Pix2pix 和 CycleGAN 都来自 UC Berkeley 的伯克利人工智能研究(BAIR)实验室。它们非常受欢迎,并且在网上有许多教程和博客,包括官方的 TensorFlow 网站。BicycleGAN 是我认为该研究小组的图像到图像翻译三部曲中的最后一部。然而,你在网上可能找不到太多示例代码,也许是由于它的复杂性。
为了构建本书到目前为止最先进的网络,我们将结合你在本章及前两章所学的所有知识。也许这就是为什么它被许多人视为高级内容的原因。别担心,你已经掌握了所有必要的前置知识。让我们开始吧!
架构理解
在直接进入实现之前,让我先给你概述一下 BicycleGAN。从名字上看,你可能自然会认为 BicycleGAN 是通过增加另一个周期(从独轮车到双轮车)对 CycleGAN 的升级。其实不是!它与 CycleGAN 无关,而是对 pix2pix 的改进。
如前所述,pix2pix 是一种一对一的映射,其中给定输入时输出始终相同。作者尝试向生成器输入添加噪声,但它简单地忽略了噪声,未能在输出图像中创造变化。因此,他们寻找了一种方法,使生成器不忽略噪声,而是利用噪声生成多样化的图像,从而实现一对多的映射。
在下面的截图中,我们可以看到与 BicycleGAN 相关的不同模型和配置。图 (a) 是用于推理的配置,其中图像 A 与输入噪声结合生成图像 B。这本质上就是本章开头的 cGAN,只不过图像 A 和噪声角色颠倒了。在 cGAN 中,噪声是主导因素,维度为 100,且有 10 个类别标签作为条件。在 BicycleGAN 中,形状为(256,256,3)的图像 A 是条件,而从潜在 z 中采样的噪声维度为 8。图 (b) 是用于 pix2pix + 噪声 的训练配置。图中底部的两个配置是 BicycleGAN 使用的,我们稍后会仔细看这些:

图 4.16 – BicycleGAN 中的模型(来源:J-Y. Zhu,“迈向多模态图像到图像的转换”– https://arxiv.org/abs/1711.11586)
BicycleGAN 的主要概念是找到潜在代码z与目标图像B之间的关系,以便生成器可以在给定不同z时学会生成不同的图像B。BicycleGAN 通过结合两种方法,cVAE-GAN和cLR-GAN,来实现这一点,如前面的图示所示。
cVAE-GAN
让我们回顾一下VAE-GAN的一些背景。VAE-GAN 的作者认为 L1 损失并不是衡量图像视觉感知的好度量标准。如果图像向右移动几个像素,可能对人眼看不出任何不同,但却可能导致较大的 L1 损失。为什么不让网络学习什么是合适的目标函数呢?实际上,他们使用 GAN 的判别器来学习目标函数,判断假图像是否看起来真实,并使用 VAE 作为生成器。因此,生成的图像看起来更清晰。如果我们看前面图中的图(c)并忽略图像A,那就是 VAE-GAN。如果有A作为条件,它就变成了条件 cVAE-GAN。训练步骤如下:
-
VAE 将真实图像B编码为多元高斯均值和对数方差的潜在代码,然后从中采样以创建噪声输入。这个流程是标准的 VAE 工作流程。有关详细信息,请参考第二章,变分自编码器。
-
在条件A下,从潜在向量z中采样的噪声被用来生成一张假的图像B。
信息流是
(图(c)中的实线箭头)。有三个损失:
-
:对抗损失 -
:L1 重建损失 -
:KL 散度损失
cLR-GAN
条件潜在回归 GAN 背后的理论超出了本书的范围。然而,我们将重点介绍它在 BicycleGAN 中的应用。在 cVAE-GAN 中,我们将真实图像B编码以提供潜在向量的真实标签,并从中采样。而 cLR-GAN 通过让生成器先从随机噪声生成一张假的图像B,然后再对假图像B进行编码,查看它与输入的随机噪声之间的偏差,做出了不同的处理。
前向步骤如下:
-
像 cGAN 一样,我们随机生成一些噪声,然后将其与图像A连接,生成一个假的图像B。
-
然后我们使用 VAE-GAN 中的相同编码器,将假的图像B编码成潜在向量。
-
然后我们从编码后的潜在向量中采样z,并计算输入噪声z的点损失。
流程是
(图(d)中的实线箭头)。有两个损失如下:
-
:对抗损失 -
:噪声N(z)和编码均值之间的 L1 损失
通过结合这两种流,我们得到了输出和潜在空间之间的双射循环。BicycleGAN 中的bi来自于bijection,这是一个数学术语,大致意思是“一对一映射并且是可逆的”。在这种情况下,BicycleGAN 将输出映射到潜在空间,并且同样可以从潜在空间映射回输出。总损失如下:

在默认配置中,λ=10,λlatent = 0.5,和λlatent=0.01。
现在我们已经理解了 BicycleGAN 的架构和损失函数,接下来我们可以开始实现它们了。
实现 BicycleGAN
我们将在这里使用ch4_bicycle_gan.ipynb笔记本。BicycleGAN 有三种类型的网络——生成器、判别器和编码器。我们将重用来自 pix2pix 的判别器(PatchGAN)和来自第二章的 VAE 编码器,变分自编码器。由于输入图像的尺寸较大,编码器增加了更多的滤波器和更深的层级。代码可能看起来略有不同,但本质上概念与之前相同。原始的 BicycleGAN 使用了两个 PatchGAN,具有有效感受野 70x70 和 140x140。
为了简便,我们只使用一个 70x70 的 PatchGAN。使用单独的判别器分别用于 cVAE-GAN 和 cLR-GAN 可以提高图像质量,这意味着我们总共有四个网络——生成器、编码器和两个判别器。
将潜在代码插入生成器
作者尝试了两种将潜在代码插入生成器的方法,一种是与输入图像连接,另一种是在生成器的下采样路径中的其他层中插入潜在代码,如下图所示。结果发现,前者效果较好。让我们实现这个简单的方法:

图 4.17 – 向生成器注入 z 的不同方式(重新绘制自:J-Y. Zhu,“多模态图像到图像转换” – https://arxiv.org/abs/1711.11586)
正如我们在本章开头所学到的,连接不同形状的输入和条件有多种方法。BicycleGAN 的方法是将潜在代码重复多次并与输入图像连接。
让我们使用一个具体的例子。在 BicycleGAN 中,潜在编码的长度是 8。我们从噪声分布中抽取 8 个样本,每个样本被重复 H×W 次,形成一个形状为(H, W, 8)的张量。换句话说,在每个 8 个通道中,其(H, W)特征图由该通道中的相同重复数值构成。以下是build_generator()的代码片段,展示了潜在编码的平铺和拼接。其余代码与 pix2pix 生成器相同:
input_image = layers.Input(shape=image_shape, name='input_image')
input_z = layers.Input(shape=(self.z_dim,), name='z')
z = layers.Reshape((1,1, self.z_dim))(input_z)
z_tiles = tf.tile(z, [self.batch_size, self.input_shape[0], self.input_shape[1], self.z_dim])
x = layers.Concatenate()([input_image, z_tiles])
下一步是创建两个模型,cVAE-GAN 和 cLR-GAN,以便将网络结合起来并创建前向路径流。
cVAE-GAN
下面是创建 cVAE-GAN 模型的代码。这是前向传播的实现,正如前面提到的:
images_A_1 = layers.Input(shape=input_shape, name='ImageA_1')
images_B_1 = layers.Input(shape=input_shape, name='ImageB_1')
z_encode, self.mean_encode, self.logvar_encode = \ self.encoder(images_B_1)
fake_B_encode = self.generator([images_A_1, z_encode])
encode_fake = self.discriminator_1(fake_B_encode)
encode_real = self.discriminator_1(images_B_1)
kl_loss = - 0.5 * tf.reduce_sum(1 + self.logvar_encode - \
tf.square(self.mean_encode) - \
tf.exp(self.logvar_encode))
self.cvae_gan = Model(inputs=[images_A_1, images_B_1],
outputs=[encode_real, encode_fake, fake_B_encode, kl_loss])
我们将 KL 散度损失包含在模型中,而不是自定义损失函数中。这样做更简单也更高效,因为kl_loss可以直接通过均值和对数方差计算,而无需从训练步骤中传入外部标签。
cLR-GAN
下面是 cLR-GAN 的实现。需要注意的是,这与 cVAE-GAN 有不同的输入,它们分别对应于图像 A 和 B:
images_A_2 = layers.Input(shape=input_shape, name='ImageA_2')
images_B_2 = layers.Input(shape=input_shape, name='ImageB_2')
z_random = layers.Input(shape=(self.z_dim,), name='z')
fake_B_random = self.generator([images_A_2, z_random])
_, mean_random, _ = self.encoder(fake_B_random)
random_fake = self.discriminator_2(fake_B_random)
random_real = self.discriminator_2(images_B_2)
self.clr_gan = Model(inputs=[images_A_2, images_B_2, z_random],
outputs=[random_real, random_fake, mean_random])
好的,现在我们已经定义了模型。接下来的步骤是实现训练步骤。
训练步骤
两个模型在一步训练中一起训练,但使用不同的图像对。因此,在每个训练步骤中,我们需要获取两次数据,每次对应一个模型。有些方法是创建数据流水线,加载两倍批量大小,然后将其拆分为两部分,代码示例如下:
images_A_1, images_B_1 = next(data_generator)
images_A_2, images_B_2 = next(data_generator)
self.train_step(images_A_1, images_B_1, images_A_2, images_B_2)
之前,我们使用了两种不同的方法来执行训练步骤。一种是定义并编译一个 Keras 模型,配备优化器和损失函数,然后调用train_on_batch()来执行训练步骤。这种方法简单,并且在定义良好的模型上运行良好。另一种方法是使用tf.GradientTape,允许对梯度和更新进行更细粒度的控制。我们在模型中同时使用了这两种方法,其中我们对生成器使用train_on_batch(),对判别器使用tf.GradientTape。
这样做的目的是让我们熟悉这两种方法,以便在需要使用低级代码实现复杂训练步骤时,能够知道如何操作,现在就是时候了。BicycleGAN 有两个共享生成器和编码器的模型,但我们使用不同的损失函数组合来更新它们,这使得train_on_batch方法在不修改原始设置的情况下不可行。因此,我们将结合两个模型的生成器和判别器,将它们合并为一个训练步骤,使用tf.GradientTape,如下所示:
-
第一步是执行前向传播并收集两个模型的输出:
def train_step(self, images_A_1, images_B_1, images_A_2, images_B_2): z = tf.random.normal((self.batch_size, self.z_dim)) real_labels = tf.ones((self.batch_size, self.patch_size, self.patch_size, 1)) fake_labels = tf.zeros((self.batch_size, self.patch_size, self.patch_size, 1)) with tf.GradientTape() as tape_e, tf.GradientTape() as tape_g,\ tf.GradientTape() as tape_d1,\ tf.GradientTape() as tape_d2: encode_real, encode_fake, fake_B_encode,\ kl_loss = self.cvae_gan([images_A_1, images_B_1]) random_real, random_fake, mean_random = \ self.clr_gan([images_A_2, images_B_2, z]) -
接下来,我们进行反向传播并更新判别器:
self.d1_loss = self.mse(real_labels, encode_real) + \ self.mse(fake_labels, encode_fake) gradients_d1 = tape_d1.gradient(self.d1_loss, self.discriminator_1.trainable_variables) self.optimizer_d1.apply_gradients(zip(gradients_d1, self.discriminator_1.trainable_variables)) self.d2_loss = self.mse(real_labels, random_real) +\ self.mse(fake_labels, random_fake) gradients_d2 = tape_d2.gradient(self.d2_loss, self.discriminator_2.trainable_variables) self.optimizer_d2.apply_gradients(zip(gradients_d2, self.discriminator_2.trainable_variables)) -
然后我们计算模型输出的损失。类似于 CycleGAN,BicycleGAN 也使用 LSGAN 损失函数,即均方误差:
self.LAMBDA_IMAGE = 10 self.LAMBDA_LATENT = 0.5 self.LAMBDA_KL = 0.01 self.gan_1_loss = self.mse(real_labels, encode_fake) self.gan_2_loss = self.mse(real_labels, random_fake) self.image_loss = self.LAMBDA_IMAGE * self.mae( images_B_1, fake_B_encode) self.kl_loss = self.LAMBDA_KL*kl_loss self.latent_loss = self.LAMBDA_LATENT *self.mae(z, mean_random) -
最后是生成器和编码器的权重更新。L1 潜在编码损失只用于更新生成器,而不更新编码器。研究发现,如果同时优化它们的损失,会促使它们隐藏与潜在编码相关的信息,从而无法学习到有意义的模式。因此,我们为生成器和编码器计算了独立的损失,并相应地更新了权重:
encoder_loss = self.gan_1_loss + self.gan_2_loss +\ self.image_loss + self.kl_loss generator_loss = encoder_loss + self.latent_loss gradients_generator = tape_g.gradient(generator_loss, self.generator.trainable_variables) self.optimizer_generator.apply_gradients(zip( gradients_generator, self.generator.trainable_variables)) gradients_encoder = tape_e.gradient(encoder_loss, self.encoder.trainable_variables) self.optimizer_encoder.apply_gradients(zip( gradients_encoder, self.encoder.trainable_variables))
就是这样。现在你可以开始训练你的 BicycleGAN 了。在笔记本中有两个数据集可以选择——建筑立面或鞋子轮廓。鞋子数据集的图像较简单,因此训练起来也更容易。以下是原始 BicycleGAN 论文中的一些示例。左侧的第一张真实图像是地面真值,右侧的四张图像是生成的图像:

图 4.18 – 将草图转换为各种风格的图像的示例。来源:J-Y. Zhu,《迈向多模态图像到图像转换》
你可能很难在这个灰度页面上注意到它们之间的差异,因为它们的区别主要体现在颜色上。它几乎完美地捕捉了鞋子和包的结构,但在细节方面表现不那么出色。
总结
我们通过学习基本的 cGAN 如何强制将类别标签作为条件生成 MNIST 来开始这一章的内容。我们实现了两种不同的条件注入方式,一种是将类别标签进行 one-hot 编码后传入一个稠密层,将其重塑为与输入噪声的通道维度匹配,然后将它们拼接在一起。另一种方法是使用embedding层和逐元素相乘。
接下来,我们学习了如何实现 pix2pix,这是一种专门用于图像到图像转换的条件 GAN。它使用 PatchGAN 作为判别器,该判别器通过查看图像的局部区域来鼓励生成图像中的细节或高频成分。我们还学习了一个广泛应用的网络架构——U-Net,已经被用于多种应用。尽管 pix2pix 可以生成高质量的图像转换,但生成的图像是一个一对一的映射,并没有输出的多样性。这是因为输入噪声被移除。这个问题被 BicycleGAN 克服了,BicycleGAN 学习了潜在编码与输出图像之间的映射,以确保生成器不会忽略输入噪声。这样,我们就更接近于多模态图像转换。
在 pix2pix 和 BicycleGAN 之间的时间线中,发明了 CycleGAN。它的两个生成器和两个判别器使用循环一致性损失来实现无配对数据的训练。总的来说,我们在这一章中实现了四种 GAN,它们都不是简单的。干得好!在下一章,我们将探讨风格迁移,它将图像分解为内容编码和风格编码。这对新 GAN 的发展产生了深远的影响。
第五章:第五章:风格迁移
生成模型,如 VAE 和 GAN,在生成逼真的图像方面表现出色。但我们对潜在变量知之甚少,更不用说如何控制它们以生成图像了。研究人员开始探索除了像素分布之外的其他图像表示方式。研究发现,图像可以解构为内容和风格。内容描述图像中的组成部分,比如图像中间的高楼大厦。另一方面,风格指的是细节部分,例如墙壁的砖石纹理或屋顶的颜色。不同时间段拍摄同一建筑的图像会有不同的色调和亮度,可以看作是相同的内容但具有不同的风格。
在本章中,我们将首先实现一些神经风格迁移的开创性工作,以将一幅图像的艺术风格转移到另一幅图像。接着,我们将学习实现前馈神经风格迁移,这种方法在速度上要快得多。然后,我们将实现自适应实例归一化(AdaIN),以进行具有任意风格数量的风格迁移。AdaIN 已被融入一些最先进的生成对抗网络(GANs)中,这些网络统称为风格基础 GANs。其中包括用于图像转换的MUNIT和著名的用于生成逼真、高保真面孔的StyleGAN。我们将在本章的最后部分学习它们的架构。至此,本章总结了风格基础生成模型的演变。
到本章结束时,你将学会如何执行艺术风格迁移,将一张照片转化为绘画风格。你将对风格在先进的 GAN 中的应用有一个深入的理解。
本章将涵盖以下内容:
-
神经风格迁移
-
改进风格迁移
-
实时任意风格迁移
-
风格基础生成模型介绍
技术要求
Jupyter 笔记本和代码可以通过以下链接找到:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter05
本章中使用的笔记本如下:
-
ch5_neural_style_transfer.ipynb -
ch5_arbitrary_style_transfer.ipynb
神经风格迁移
当卷积神经网络(CNNs)在 ImageNet 图像分类竞赛中超过所有其他算法时,人们开始意识到其潜力,并开始探索其在其他计算机视觉任务中的应用。在 2015 年由 Gatys 等人发表的论文《A Neural Algorithm of Artistic Style》中,他们展示了使用 CNN 将一幅图像的艺术风格转移到另一幅图像的方法,如下例所示:

图 5.1 – (A) 内容图像。(B)-(D) 底部图像为风格图像,较大的图像为风格化图像(来源:Gatys 等,2015 年,《艺术风格的神经算法》 https://arxiv.org/abs/1508.06576)
与大多数深度学习训练需要大量训练数据不同,神经风格迁移仅需要两张图像——内容图像和风格图像。我们可以使用预训练的 CNN(如 VGG)将风格从风格图像转移到内容图像。
如上图所示,(A)是内容图像,(B)至(D)是风格图像和风格化图像。结果令人印象深刻,简直让人大开眼界!甚至有人用该算法创作并出售艺术画作。有一些网站和应用程序让人们上传照片进行风格迁移,而无需了解底层理论和编码。 当然,作为技术人员,我们更希望自己动手实现这些东西。
我们现在将详细了解如何实现神经风格迁移,从使用 CNN 提取图像特征开始。
使用 VGG 提取特征
分类卷积神经网络(CNNs),如 VGG,可以分为两部分。第一部分被称为特征提取器,主要由卷积层组成。后一部分由几个全连接层组成,用于给出类别的得分,这部分被称为分类器头。研究发现,经过 ImageNet 分类任务预训练的 CNN 也可以用于其他任务。
例如,如果你想为另一个只有 10 个类别的数据集创建分类 CNN,而不是 ImageNet 的 1,000 个类别,你可以保留特征提取器,仅用一个新的分类器头替换掉原来的。这被称为迁移学习,即我们可以将一些已学到的知识转移或重用于新的网络或应用程序。许多用于计算机视觉任务的深度神经网络都包括特征提取器,不论是复用权重还是从头开始训练。这包括目标检测和姿势估计。
在 CNN 中,随着我们向输出层逐步深入,它越来越倾向于学习图像内容的表示,而非其详细的像素值。为了更好地理解这一点,我们将构建一个网络来重建各层所看到的图像。图像重建的两个步骤如下:
-
将图像前向传递通过 CNN 以提取特征。
-
使用随机初始化的输入,我们训练输入,使其重建与步骤 1中参考特征最匹配的特征。
让我详细讲解一下步骤 2。在正常的网络训练中,输入图像是固定的,并且反向传播的梯度用于更新网络权重。
在神经风格迁移中,所有网络层都会被冻结,我们则使用梯度来改变输入。原始论文使用的是 VGG19,Keras 也有一个我们可以使用的预训练模型。VGG 的特征提取部分由五个块组成,每个块的末尾都有一次下采样。每个块包含两个到四个卷积层,整个 VGG19 有 16 个卷积层和 3 个全连接层,因此 VGG19 中的 19 代表具有可训练权重的 19 层。以下表格显示了不同的 VGG 配置:

图 5.2 – 不同的 VGG 配置(来源:K. Simonyan, A. Zisserman, “Very Deep Convolutional Networks For Large-Scale Image Recognition” – https://arxiv.org/abs/1409.1556)
对应的 Jupyter 笔记本是ch5_neural_style_transfer.ipynb,它是完整的神经风格迁移解决方案。
然而,在接下来的文本中,我将使用更简单的代码来展示内容重构,随后会扩展以进行风格迁移。以下是使用预训练 VGG 提取block4_conv2输出层的代码:
vgg = tf.keras.applications.VGG19(include_top=False, weights='imagenet')
content_layers = ['block4_conv2']
content_outputs = [vgg.get_layer(x).output for x in content_layers]
model = Model(vgg.input, content_outputs)
预训练的 Keras CNN 模型分为两部分。底部部分由卷积层组成,通常在实例化 VGG 模型时被称为include_top=False。
VGG 预处理
一个 Keras 预训练模型期望输入图像为 BGR 格式,且范围在[0, 255]之间。因此,第一步是反转颜色通道,将 RGB 转换为 BGR。VGG 对不同颜色通道使用不同的均值。在preprocess_input()函数内部,像素值会分别从 B、G、R 通道中减去 103.939、116.779 和 123.68 的值。
以下是前向传递代码,其中图像首先进行预处理,然后输入到模型中以返回内容特征。我们然后提取内容特征并将其作为我们的目标:
def extract_features(image):
image = tf.keras.applications.vgg19.\ preprocess_input(image *255.)
content_ref = model(image)
return content_ref
content_image = tf.reverse(content_image, axis=[-1])
content_ref = extract_features(content_image)
请注意,图像已被归一化到[0., 1.],因此我们需要通过将其乘以 255 来恢复到[0., 255.]。然后我们创建一个随机初始化的输入,它也将成为风格化后的图像:
image = tf.Variable(tf.random.normal( shape=content_image.shape))
接下来,我们将使用反向传播从内容特征重构图像。
内容重构
在训练步骤中,我们将图像输入到被冻结的 VGG 中以提取内容特征,并使用 L2 损失来与目标内容特征进行比较。以下是自定义的loss函数,用于计算每个特征层的 L2 损失:
def calc_loss(y_true, y_pred):
loss = [tf.reduce_sum((x-y)**2) for x, y in zip(y_pred, y_true)]
return tf.reduce_mean(loss)
接下来的训练步骤使用 tf.GradientTape() 来计算梯度。在普通的神经网络训练中,梯度会应用到可训练的变量,即神经网络的权重上。然而,在神经风格迁移中,梯度会应用到图像上。之后,我们将图像值裁剪到 [0., 1.] 之间,如下所示:
for i in range(1,steps+1):
with tf.GradientTape() as tape:
content_features = self.extract_features(image)
loss = calc_loss(content_features, content_ref)
grad = tape.gradient(loss, image)
optimizer.apply_gradients([(grad, image)])
image.assign(tf.clip_by_value(image, 0., 1.))
我们训练了 1,000 步,这就是重建的内容效果:

图 5.3 – 从内容层重建的图像 (来源: https://www.pexels.com/. (左):原始内容图像,(右):‘block1_1’ 的内容)
我们几乎可以用前几层卷积层(类似于 block1_1)重建图像,如上图所示:

图 5.4 – 从内容层重建的图像 (左):‘block4_1’ 的内容。(右):‘block5_1’ 的内容
随着我们深入到 block4_1,我们开始失去一些细节,如窗框和建筑上的字母。继续深入到 block5_1 时,我们会发现所有细节都消失了,取而代之的是一些随机噪声。如果仔细观察,建筑的结构和边缘仍然完好无损,并且位于它们应该在的位置。现在,我们已经只提取了内容,并省略了风格。提取内容特征后,下一步是提取风格特征。
使用 Gram 矩阵重建风格
正如我们在风格重建中所看到的,特征图,尤其是前几层,既包含了风格也包含了内容。那么我们如何从图像中提取风格表示呢?Gatys 等人使用了Gram 矩阵,它计算了不同滤波器响应之间的相关性。假设卷积层 l 的激活形状为 (H, W, C),其中 H 和 W 是空间维度,C 是通道数,也就是滤波器的数量。每个滤波器检测不同的图像特征,它们可以是水平线、对角线、颜色等等。
人类通过共享一些共同的特征(如颜色和边缘)来感知相似的纹理。例如,如果我们将一张草地的图像输入到卷积层,检测垂直线和绿色的滤波器将在其特征图中产生更大的响应。因此,我们可以利用特征图之间的相关性来表示图像中的纹理。
为了从形状为(H, W, C)的激活中创建 Gram 矩阵,我们首先将其重塑为 C 个向量。每个向量是一个大小为 H×W 的平铺特征图。我们对这 C 个向量执行内积,得到一个对称的 C×C Gram 矩阵。计算 Gram 矩阵的详细步骤如下:
-
使用
tf.squeeze()来移除批次维度(1, H, W, C),变为(H, W, C),因为批次大小始终是1。 -
转置张量,将形状从(H, W, C)转换为(C, H, W)。
-
将最后两个维度展平,变为(C, H×W)。
-
对特征进行点积,创建一个形状为(C, C)的 Gram 矩阵。
-
通过将矩阵除以每个平铺特征图中的点数(H×W)来进行归一化。
从单个卷积层激活计算 Gram 矩阵的代码如下:
def gram_matrix(x):
x = tf.transpose(tf.squeeze(x), (2,0,1));
x = tf.keras.backend.batch_flatten(x)
num_points = x.shape[-1]
gram = tf.linalg.matmul(x, tf.transpose(x))/num_points
return gram
我们可以使用这个函数来获取每个我们指定为风格层的 VGG 层的 Gram 矩阵。然后,我们对目标图像和参考图像的 Gram 矩阵使用 L2 损失。损失函数和其他代码与内容重建相同。创建 Gram 矩阵列表的代码如下:
def extract_features(image):
image = tf.keras.applications.vgg19.\ preprocess_input(image *255.)
styles = self.model(image)
styles = [self.gram_matrix(s) for s in styles]
return styles
以下图像是从不同 VGG 层的风格特征重建而来的:

图 5.5 – (顶部)风格图像:文森特·梵高的《星空》。 (左下)从‘block1_1’重建的风格。 (右下)从‘block3_1’重建的风格。
在从block1_1重建的风格图像中,内容信息完全消失,仅显示出高空间频率的纹理细节。较高层次的block3_1显示出一些弯曲的形状,似乎捕捉了输入图像中风格的更高层次。Gram 矩阵的损失函数是平方误差的总和,而不是均方误差。因此,更高层次的风格层具有更高的固有权重,这允许转移更高层次的风格表示,如笔触。如果我们使用均方误差,低层次的风格特征(如纹理)会在视觉上更加突出,可能会看起来像高频噪声。
执行神经风格迁移
现在,我们可以将内容和风格重建的代码合并,执行神经风格迁移。
我们首先创建一个模型,提取两个特征块,一个用于内容,另一个用于风格。我们仅使用block5_conv1的一层作为内容层,使用从block1_conv1到block5_conv1的五层来捕捉来自不同层次的风格,如下所示:
vgg = tf.keras.applications.VGG19(include_top=False, weights='imagenet')
default_content_layers = ['block5_conv1']
default_style_layers = ['block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1',
'block5_conv1']
content_layers = content_layers if content_layers else default_content_layers
style_layers = style_layers if style_layers else default_style_layers
self.content_outputs = [vgg.get_layer(x).output for x in content_layers]
self.style_outputs = [vgg.get_layer(x).output for x in style_layers]
self.model = Model(vgg.input, [self.content_outputs, self.style_outputs])
在训练循环开始之前,我们从各自的图像中提取内容和风格特征,作为目标使用。虽然我们可以使用随机初始化的输入进行内容和风格重建,但从内容图像开始训练会更快,如下所示:
content_ref, _ = self.extract_features(content_image)
_, style_ref = self.extract_features(style_image)
然后,我们对内容损失和风格损失进行加权并相加。代码片段如下:
def train_step(self, image, content_ref, style_ref):
with tf.GradientTape() as tape:
content_features, style_features = \ self.extract_features(image)
content_loss = self.content_weight*self.calc_loss( content_ref, content_features)
style_loss = self.style_weight*self.calc_loss( style_ref, style_features)
loss = content_loss + style_loss
grad = tape.gradient(loss, image)
self.optimizer.apply_gradients([(grad, image)])
image.assign(tf.clip_by_value(image, 0., 1.))
return content_loss, style_loss
以下是使用不同权重和内容层次产生的两张风格化图像:

图 5.6 – 使用神经风格迁移的风格化图像
随时调整权重和层次,创造你想要的风格。我希望你现在对内容和风格的表示有了更好的理解,这将在我们探索高级生成模型时派上用场。接下来,我们将看看如何改进神经风格迁移。
改进风格迁移
研究界和工业界对神经风格迁移感到兴奋,并迅速开始应用它。有些人搭建了网站,允许用户上传照片进行风格迁移,而有些则利用该技术创造商品进行销售。随后,人们意识到原始神经风格迁移的一些局限性,并开始改进它。
其中一个最大的问题是,风格迁移将风格图像的所有风格信息,包括颜色和笔触,转移到内容图像的整个图像上。使用我们在前一节中做的例子,风格图像中的蓝色调被转移到了建筑物和背景中。如果我们可以选择只转移笔触而不转移颜色,并且仅转移到所需的区域,那该多好呢?
神经风格迁移的首席作者及其团队提出了一种新的算法来解决这些问题。下图展示了该算法能够提供的控制效果以及结果示例:

图 5.7 – 神经风格迁移的不同控制方法。(a)内容图像(b)天空和地面使用不同的风格图像进行风格化(c)保持内容图像的颜色(d)细尺度和粗尺度使用不同的风格图像进行风格化(来源:L. Gatys, 2017,“控制神经风格迁移中的感知因素”,https://arxiv.org/abs/1611.07865)
本文提出的控制方法如下:
-
空间控制:这控制了风格迁移在内容图像和风格图像中的空间位置。通过在计算 Gram 矩阵之前,对风格特征应用空间掩码来实现这一点。
-
颜色控制:这可以用来保持内容图像的颜色。为此,我们将 RGB 格式转换为色彩空间,使得 HCL 将亮度(明度)与其他颜色通道分开。我们可以将亮度通道视为灰度图像。然后,我们仅在亮度通道进行风格迁移,并将其与原始风格图像中的颜色通道合并,以得到最终的风格化图像。
-
尺度控制:这管理笔触的颗粒度。这个过程更为复杂,因为它需要多次执行风格迁移,并选择不同层次的风格特征以计算 Gram 矩阵。
这些感知控制对于创建更适合您需求的风格化图像非常有用。如果您愿意,我将把实现这些控制作为一个练习留给您,因为我们有更重要的内容需要讨论。
以下是与改善风格迁移相关的两个主要主题,它们对生成对抗网络(GAN)的发展产生了重大影响:
-
提高速度
-
改善风格变化
让我们回顾一下这些进展,为我们接下来的项目奠定一些基础——在实时中执行任意风格迁移。
使用前馈网络实现更快的风格迁移
神经风格迁移基于类似于神经网络训练的优化。它较慢,即使使用 GPU,也需要几分钟才能完成。这限制了它在移动设备上的潜在应用。因此,研究人员有动力开发更快速的风格迁移算法,前馈风格迁移应运而生。下图展示了采用这种架构的第一个网络之一:

图 5.8 – 用于风格迁移的前馈卷积神经网络的框图。 (重绘自:J. Johnson 等人,2016 年《实时风格迁移和超分辨率的感知损失》 – https://arxiv.org/abs/1603.08155)
该架构比框图看起来更简单。该架构中有两个网络:
-
一个可训练的卷积网络(通常称为风格迁移网络),用于将输入图像转换为风格化图像。它可以实现为类似编码器-解码器的架构,例如 U-Net 或 VAE。
-
一个固定的卷积网络,通常是预训练的 VGG,用于测量内容和风格损失。
与原始神经风格迁移类似,我们首先使用 VGG 提取内容和风格目标。不同的是,我们现在训练一个卷积网络,将内容图像转化为风格化图像,而不是训练输入图像。风格化图像的内容和风格特征通过 VGG 提取,并计算损失,反向传播到可训练的卷积网络。我们像训练普通的前馈 CNN 一样训练它。在推理阶段,我们只需要执行一次前向传递即可将输入图像转换为风格化图像,这比之前快了 1,000 倍!
好的,速度问题现在解决了,但仍然存在一个问题。这样的网络只能学习一种风格进行迁移。我们需要为每种想要执行的风格训练一个网络,这比原始的风格迁移要不够灵活。于是人们开始着手解决这个问题,正如你可能猜到的那样,这个问题也得到了解决!我们稍后会讲解。
不同的风格特征
原始的神经风格迁移论文没有解释为什么 Gram 矩阵作为风格特征是有效的。许多后来的风格迁移改进,例如前馈风格迁移,继续仅将 Gram 矩阵用作风格特征。直到 2017 年,Y, Li 等人发表的《揭秘神经风格迁移》论文才做出了改变。他们发现风格信息本质上是通过 CNN 中的激活分布来表示的。他们展示了匹配激活的 Gram 矩阵等同于最小化激活分布的最大均值差异(MMD)。因此,我们可以通过匹配图像的激活分布与风格图像的激活分布来实现风格迁移。
因此,Gram 矩阵并不是实现风格迁移的唯一方法。我们也可以使用对抗性损失。让我们回想一下,像 pix2pix 这样的 GAN( 第四章, 图像到图像的转换)可以通过匹配生成图像与真实(风格)图像的像素分布来执行风格迁移。不同之处在于,GAN 试图最小化像素分布的差异,而风格迁移是对层激活的分布进行最小化。
后来,研究人员发现我们可以仅通过激活的均值和方差的基本统计量来表示风格。换句话说,如果我们将两张风格相似的图像输入到 VGG,它们的层激活将具有相似的均值和方差。因此,我们可以通过最小化生成图像与风格图像之间激活均值和方差的差异来训练一个网络进行风格迁移。这促使了使用归一化层来控制风格的研究发展。
使用归一化层控制风格
一种简单而有效的控制激活统计量的方法是通过改变归一化层中的 gamma
和 beta β。换句话说,我们可以通过使用不同的仿射变换参数(gamma 和 beta)来改变风格。作为提醒,批归一化和实例归一化共享相同的公式,如下所示:

不同之处在于批归一化(BN)计算的是(N, H, W)维度的均值µ和标准差σ,而实例归一化(IN)仅从(H, W)维度计算。
然而,每个归一化层只有一对 gamma 和 beta,这限制了网络只能学习一种风格。我们如何让网络学习多种风格呢?我们可以使用多组 gamma 和 beta,每组记住一种风格。这正是条件实例归一化(CIN)所做的。
它基于实例归一化,但有多组 gamma 和 beta。每组 gamma 和 beta 用于训练特定的风格;换句话说,它们是基于风格图像进行条件化的。条件实例归一化的公式如下:

假设我们有S种不同的风格图像,那么我们在每个风格的归一化层中就有S个 gamma 和S个 beta。除了内容图像外,我们还将 one-hot 编码的风格标签输入到风格迁移网络中。在实际操作中,gamma 和 beta 被实现为形状为(S×C)的矩阵。我们通过将 one-hot 编码的标签(1×S)与矩阵(S×C)进行矩阵乘法,来获取每个(1×C)通道的γS 和βs。通过代码实现时会更容易理解。不过,我们将在第九章,“视频合成”部分详细实现时再介绍。我们现在引入 CIN,为接下来的部分做准备。
现在,将风格编码到 gamma 和 beta 的嵌入空间中,我们可以通过插值 gamma 和 beta 来进行风格插值,如下图所示:

图 5.9 – 通过插值两种不同风格的 gamma 和 beta 来组合艺术风格(来源: V. Dumoulin 等, 2017 “A Learned Representation for Artistic Style” – https://arxiv.org/abs/1610.07629)
这很好,但网络仍然局限于训练中使用的固定N种风格。接下来,我们将学习并实现一个改进,使得任何任意风格都可以进行迁移!
实时任意风格迁移
在本节中,我们将学习如何实现一个能够实时进行任意风格迁移的网络。我们已经学习了如何使用前馈网络进行更快的推理,从而解决了实时部分。我们还学习了如何使用条件实例归一化来迁移固定数量的风格。现在,我们将学习另一种归一化技术,它允许任何任意风格,之后我们就可以开始实现代码了。
实现自适应实例归一化
像 CIN 一样,AdaIN也是实例归一化,这意味着均值和标准差是针对每张图像的(H, W)和每个通道计算的,而不是批量归一化,后者是在(N, H, W)上计算的。在 CIN 中,gamma 和 beta 是可训练的变量,它们学习不同风格所需的均值和方差。在 AdaIN 中,gamma 和 beta 被风格特征的标准差和均值替代,如下所示:

AdaIN 仍然可以被理解为一种条件实例归一化形式,其中条件是风格特征,而不是风格标签。在训练和推理时,我们使用 VGG 提取风格层的输出,并使用它们的统计量作为风格条件。这避免了需要预定义一组固定的风格。我们现在可以在 TensorFlow 中实现 AdaIN。该部分的笔记本是ch5_arbitrary_style_transfer.ipynb。
我们将使用 TensorFlow 的子类化来创建一个自定义的AdaIN层,如下所示:
class AdaIN(layers.Layer):
def __init__(self, epsilon=1e-5):
super(AdaIN, self).__init__()
self.epsilon = epsilon
def call(self, inputs):
x = inputs[0] # content
y = inputs[1] # style
mean_x, var_x = tf.nn.moments(x, axes=(1,2), keepdims=True)
mean_y, var_y = tf.nn.moments(y, axes=(1,2), keepdims=True)
std_x = tf.sqrt(var_x+self.epsilon)
std_y = tf.sqrt(var_y+self.epsilon)
output = std_y*(x – mean_x)/(std_x) + mean_y
return output
这是方程的一个直接实现。有一点值得解释的是tf.nn.moments的使用,它也被用于 TensorFlow 批量归一化的实现。它计算特征图的均值和方差,其中轴1、2指的是特征图的 H 和 W。我们还设置了keepdims=True,以保持结果为四维,形状为(N, 1, 1, C),而不是默认的(N, C)。前者允许 TensorFlow 对形状为(N, H, W, C)的输入张量进行广播运算。这里的广播指的是在更大的维度中重复一个值。
更准确地说,当我们从特定实例和通道的计算均值中减去x时,单个均值首先会重复成(H, W)的形状,然后再进行减法操作。接下来我们将看看如何将 AdaIN 应用到风格迁移中。
风格迁移网络架构
下图展示了风格迁移网络的架构和训练流程:

图 5.10 – 使用 AdaIN 进行风格迁移的概述(重绘自:X. Huang, S. Belongie, 2017, “实时自适应实例归一化的任意风格迁移” – https://arxiv.org/abs/1703.06868)
风格迁移网络(STN)是一个编码器-解码器网络,其中编码器使用固定的 VGG 编码内容和风格特征。然后,AdaIN 将风格特征编码为内容特征的统计信息,解码器则利用这些新的特征生成风格化的图像。
构建编码器
以下是从 VGG 构建编码器的代码:
def build_encoder(self, name='encoder'):
self.encoder_layers = ['block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1']
vgg = tf.keras.applications.VGG19(include_top=False, weights='imagenet')
layer_outputs = [vgg.get_layer(x).output for x in self.encoder_layers]
return Model(vgg.input, layer_outputs, name=name)
这与神经风格迁移类似,只不过我们使用最后的风格层,'block4_conv1',作为我们的内容层。因此,我们无需单独定义内容层。接下来,我们将对卷积层做一个小而重要的改进,以提高生成图像的外观。
使用反射填充减少块状伪影
通常,当我们在卷积层对输入张量应用填充时,常数零会围绕张量进行填充。然而,边界处的突变值会产生高频成分,并导致生成图像中的块状伪影。减少这些高频成分的一种方法是将总变差损失作为网络训练中的正则化项。
为此,我们首先通过将图像平移一个像素来简单地计算高频成分,然后减去原始图像生成一个矩阵。总变差损失是 L1 范数或绝对值的总和。因此,训练将尽量最小化此损失函数,从而减少高频成分。
还有另一种替代方法,即用反射值替代填充中的常数零。例如,如果我们用零填充一个 [10, 8, 9] 的数组,这将得到 [0, 10, 8, 9, 0]。然后我们可以看到值在 0 和其邻近值之间发生突变。
如果我们使用反射填充,填充后的数组将是 [8, 10, 8, 9, 8],这将提供一个更平滑的过渡到边界。然而,Keras 的 Conv2D 不支持反射填充,因此我们需要使用 TensorFlow 子类化来创建一个自定义的 Conv2D。以下代码片段(代码已简化,完整代码请参阅 GitHub)展示了如何在卷积前向输入张量添加反射填充:
class Conv2D(layers.Layer):
@tf.function
def call(self, inputs):
padded = tf.pad(inputs, [[0, 0], [1, 1], [1, 1], [0, 0]], mode='REFLECT')
# perform conv2d using low level API
output = tf.nn.conv2d(padded, self.w, strides=1, padding=”VALID”) + self.b
if self.use_relu:
output = tf.nn.relu(output)
return output
上述代码来自 第一章,使用 TensorFlow 开始图像生成,但增加了一个低级的 tf.pad API 用于填充输入张量。
构建解码器
尽管我们在编码器代码中使用了 4 个 VGG 层(block1_conv1到block4_conv1),但只有编码器中的最后一层block4_conv1被 AdaIN 使用。因此,解码器的输入张量具有与block4_conv1相同的激活。解码器架构与我们在前几章中实现的相似,包含卷积层和上采样层,如下所示:
def build_decoder(self):
block = tf.keras.Sequential([\
Conv2D(512, 256, 3),
UpSampling2D((2,2)),
Conv2D(256, 256, 3),
Conv2D(256, 256, 3),
Conv2D(256, 256, 3),
Conv2D(256, 128, 3),
UpSampling2D((2,2)),
Conv2D(128, 128, 3),
Conv2D(128, 64, 3),
UpSampling2D((2,2)),
Conv2D(64, 64, 3),
Conv2D(64, 3, 3, use_relu=False)],
name='decoder')
return block
上面的代码使用了自定义的Conv2D并且使用了反射填充。所有层都使用 ReLU 激活函数,除了输出层,它没有任何非线性激活函数。我们现在已经完成了 AdaIN、编码器和解码器的构建,可以继续进行图像预处理流水线。
VGG 处理
就像我们之前构建的神经风格迁移一样,我们需要通过将颜色通道反转为 BGR 并减去颜色均值来预处理图像。代码如下:
def preprocess(self, image):
# rgb to bgr
image = tf.reverse(image, axis=[-1])
return tf.keras.applications.vgg19.preprocess_input(image)
我们可以在后处理阶段做相同的操作,即将颜色均值加回来并反转颜色通道。然而,这可以由解码器学习,因为颜色均值相当于输出层中的偏置。我们将让训练来完成这项任务,我们所需要做的就是将像素裁剪到[0, 255]的范围内,如下所示:
def postprocess(self, image):
return tf.clip_by_value(image, 0., 255.)
现在我们已经准备好了所有的构建模块,剩下的就是将它们组合起来创建 STN 和训练流水线。
构建风格迁移网络
构建STN非常简单,只需将编码器、AdaIN 和解码器连接起来,如前面的架构图所示。STN 也是我们用于执行推理的模型。实现这一功能的代码如下:
content_image = self.preprocess(content_image_input)
style_image = self.preprocess(style_image_input)
self.content_target = self.encoder(content_image)
self.style_target = self.encoder(style_image)
adain_output = AdaIN()([self.content_target[-1], self.style_target[-1]])
self.stylized_image = self.postprocess( self.decoder(adain_output))
self.stn = Model([content_image_input, style_image_input], self.stylized_image)
内容图像和风格图像经过预处理后输入编码器。最后的特征层,即来自两幅图像的block4_conv1,进入AdaIN()。风格化后的特征然后进入解码器,生成 RGB 格式的风格化图像。
任意风格迁移训练
就像神经和前馈风格迁移一样,内容损失和风格损失是通过固定的 VGG 提取的激活计算得出的。内容损失也是 L2 范数,但生成的风格化图像的内容特征现在与 AdaIN 的输出进行比较,而不是与内容图像的特征进行比较,如下所示。论文的作者发现,这可以加速收敛:
content_loss = tf.reduce_sum((output_features[-1]-\ adain_output)**2)
对于风格损失,常用的 Gram 矩阵被替换为激活统计量的 L2 范数(均值和方差)。这产生与 Gram 矩阵类似的结果,但在概念上更加简洁。以下是风格损失函数的方程:

这里,φi 表示用于计算风格损失的 VGG-19 中的某一层。
我们像 AdaIN 层一样使用tf.nn.moments来计算风格化图像和风格图像的特征统计量以及 L2 范数。每个风格层的权重相同,因此我们平均内容层的损失,如下所示:
def calc_style_loss(self, y_true, y_pred):
n_features = len(y_true)
epsilon = 1e-5
loss = []
for i in range(n_features):
mean_true, var_true = tf.nn.moments(y_true[i], axes=(1,2), keepdims=True)
mean_pred, var_pred = tf.nn.moments(y_pred[i], axes=(1,2), keepdims=True)
std_true, std_pred = tf.sqrt(var_true+epsilon), tf.sqrt(var_pred+epsilon)
mean_loss = tf.reduce_sum(tf.square( mean_true-mean_pred))
std_loss = tf.reduce_sum(tf.square( std_true-std_pred))
loss.append(mean_loss + std_loss)
return tf.reduce_mean(loss)
最后的步骤是编写训练步骤,如下所示:
def train_step(self, train_data):
with tf.GradientTape() as tape:
adain_output, output_features, style_target = \ self.training_model(train_data)
content_loss = tf.reduce_sum( (output_features[-1]-adain_output)\ **2)
style_loss = self.style_weight * \ self.calc_style_loss( style_target, output_features)
loss = content_loss + style_loss
gradients = tape.gradient(loss, self.decoder.trainable_variables)
self.optimizer.apply_gradients(zip(gradients, self.decoder.trainable_variables))
return content_loss, style_loss
我们不再调整内容和风格的权重,而是将内容权重固定为 1,仅调整风格权重。在这个例子中,我们将内容权重设置为 1,将风格权重设置为 1e-4。在 图 5.10 中,可能看起来有三个网络需要训练,但其中两个是固定的 VGG 网络,因此唯一需要训练的网络是解码器。因此,我们只跟踪并应用解码器的梯度。
提示
前面的训练步骤可以通过 Keras 的 train_on_batch() 函数替代(参见 第三章,生成对抗网络),这样可以减少代码行数。我将把这个作为附加练习留给你。
在这个示例中,我们将使用人脸作为内容图像,使用 cyclegan/vangogh2photo 作为风格图像。尽管梵高的画作属于一种艺术风格,但从风格迁移的角度来看,每个风格图像都是一个独特的风格。vangogh2photo 数据集包含了 400 张风格图像,这意味着我们正在用 400 种不同的风格训练网络!以下图示展示了我们网络生成的图像示例:

图 5.11 – 任意风格迁移。(左)风格图像(中)内容图像(右)风格化图像
上述图中的图像展示了在推理时使用风格图像进行的风格迁移,这些风格图像是网络之前没有见过的。每次风格迁移只需进行一次前向传播,这比原始神经风格迁移算法的迭代优化要快得多。在理解了各种风格迁移技术后,我们现在可以很好地学习如何设计具有风格的 GAN(双关语)。
风格基础 GAN 简介
风格迁移的创新对 GAN 的发展产生了影响。尽管当时的 GAN 能够生成逼真的图像,但它们是通过使用随机潜在变量生成的,我们对这些潜在变量代表的内容几乎没有理解。即使多模态 GAN 能够创建生成图像的变化,但我们不知道如何控制潜在变量以达到我们想要的结果。
在理想的世界里,我们希望能够拥有一些控制器,独立地控制我们希望生成的特征,就像在第二章中关于面部操控的练习一样,变分自编码器(Variational Autoencoder)。这被称为解耦表示,这是深度学习中的一个相对较新的概念。解耦表示的思想是将一张图像分解为独立的表示。例如,一张面部图像包含两只眼睛、一只鼻子和一张嘴,每个部分都是面部的一个表示。正如我们在风格迁移中学到的,一张图像可以解耦为内容和风格。因此,研究人员将这一思想引入了生成对抗网络(GAN)。
在接下来的章节中,我们将探讨一种基于风格的生成对抗网络(GAN),即MUNIT。由于书本的篇幅限制,我们不会编写详细的代码,而是会概述整体架构,以理解这些模型中风格是如何应用的。
多模态无监督图像到图像翻译(MUNIT)
MUNIT 是一种图像到图像翻译模型,类似于 BicycleGAN(第四章,图像到图像翻译)。这两者都可以生成具有连续分布的多模态图像,但 BicycleGAN 需要配对数据,而 MUNIT 则不需要。BicycleGAN 通过使用两个模型,将目标图像与潜在变量相关联,生成多模态图像。然而,如何控制潜在变量来改变输出,及这些模型如何工作,并不非常清晰。而 MUNIT 的方式在概念上有很大的不同,但也更易于理解。它假设源图像和目标图像共享相同的内容空间,但风格不同。
以下图表展示了 MUNIT 背后的主要思想:

图 5.12 – MUNIT 方法的示意图。(改绘自:X. Huang 等人,2018 年,“多模态无监督图像到图像翻译” – https://arxiv.org/abs/1804.04732)
假设我们有两张图像,X1 和 X2。每张图像都可以表示为内容编码和风格编码的对(C1, S1)和(C2, S2)。假设C1 和C2 都处于共享内容空间C中。换句话说,内容可能不完全相同,但相似。风格则存在于各自的特定领域风格空间中。因此,从X1 和X2 的图像翻译可以表述为生成一个图像,内容编码来自X1,风格编码来自X2,或者换句话说,来自编码对(C1, S2)。
在以前的风格迁移中,我们将风格视为具有不同笔触、颜色和纹理的艺术风格。现在,我们将风格的意义扩展到艺术画作之外。例如,老虎和狮子只是具有不同胡须、皮肤、毛发和形态风格的猫。接下来,让我们来看一下 MUNIT 模型架构。
理解架构
MUNIT 架构如下面的图所示:

图 5.13 – MUNIT 模型概览(重绘自:X. Huang 等人, 2018 年,“多模态无监督图像到图像的转换” – https://arxiv.org/abs/1804.04732)
这里有两个自动编码器,一个位于每个域中。自动编码器将图像编码为风格和内容编码,然后解码器将其解码回原始图像。这是通过对抗损失进行训练的,换句话说,模型由一个自动编码器组成,但其训练方式类似于 GAN。
在前面的图中,图像重建过程显示在左侧。右侧是跨域转换。如前所述,要将X1 转换为X2,我们首先将图像编码为各自的内容和风格编码,然后我们对其进行以下两项操作:
-
我们在风格域 2 中生成一个假的图像,风格为(C1, S2)。这个过程也是通过 GANs 进行训练的。
-
我们将假图像编码为内容和风格编码。如果转换效果良好,那么它应该与(C1, S2)相似。
好吧,如果这听起来很熟悉,那是因为这就是循环一致性约束,它来自 CycleGAN。只不过,在这里循环一致性并不是应用于图像,而是应用于内容和风格编码。
探索自动编码器设计
最后,让我们看一下自动编码器的详细架构,如下图所示:

图 5.14 – MUNIT 模型概览(来源:X. Huang 等人, 2018 年,“多模态无监督图像到图像的转换” – https://arxiv.org/abs/1804.04732)
与其他风格迁移模型不同,MUNIT 不使用 VGG 作为编码器。它使用两个独立的编码器,一个用于内容,另一个用于风格。内容编码器由若干残差块组成,并具有实例归一化和下采样功能。这与 VGG 的风格特征非常相似。
风格编码器与内容编码器在两个方面不同:
-
首先,这里没有归一化。正如我们所学,归一化激活值为零意味着去除了风格信息。
-
其次,残差块被完全连接层所替代。这是因为风格被视为空间不变的,因此我们不需要卷积层来提供空间信息。
也就是说,风格编码只包含眼睛颜色的信息,而无需了解眼睛的位置,因为这是内容编码的责任。风格编码是一个低维向量,通常大小为 8,这与 GAN 和 VAE 中的高维潜在变量以及风格迁移中的样式特征不同。风格编码大小较小的原因是为了减少控制风格的“旋钮”数量,从而使控制变得更加易于管理。下图展示了内容编码和风格编码如何输入到解码器中:

图 5.15 – 解码器中的 AdaIN 层
解码器中的生成器由一组残差块组成。只有第一组中的残差块使用 AdaIN 作为归一化层。AdaIN 的公式如下,其中 z 是前一个卷积层的激活输出:

在任意前馈神经风格迁移中,我们使用单一风格层的均值和标准差作为 AdaIN 中的 gamma 和 beta。在 MUNIT 中,gamma 和 beta 是通过 多层感知机 (MLP) 从风格编码生成的。
动物图像翻译
以下截图展示了 MUNIT 进行的 1 对多 图像翻译样本。通过使用不同的风格编码,我们可以生成多种不同的输出图像:

图 5.16 – MUNIT 动物图像翻译(来源:X. Huang 等,2018 年,“Multimodal Unsupervised Image-to-Image Translation” – https://arxiv.org/abs/1804.04732)
截至目前,MUNIT 仍然是多模态图像到图像翻译领域的最先进模型,详见 paperswithcode.com/task/multimodal-unsupervised-image-to-image。
如果你对代码实现感兴趣,可以参考 NVIDIA 提供的官方实现,地址为 github.com/NVlabs/MUNIT。
总结
在本章中,我们介绍了基于样式的生成模型的演变。一切始于神经风格迁移,我们了解到图像可以分解为内容和风格。原始算法较慢,在推理时,迭代优化过程被实时样式迁移的前馈过程所取代,能够实现实时的风格迁移。
我们随后了解到,Gram 矩阵并不是唯一表示风格的方法,我们也可以使用层的统计信息。因此,已经研究了归一化层来控制图像的风格,这最终促成了 AdaIN 的诞生。通过结合前馈网络和 AdaIN,我们实现了实时的任意风格迁移。
随着风格迁移的成功,AdaIN 也被应用于生成对抗网络(GANs)。我们详细介绍了 MUNIT 架构,重点讲解了 AdaIN 如何用于多模态图像生成。有一个基于风格的 GAN 你应该熟悉,它叫做 StyleGAN。StyleGAN 因其生成超逼真、高保真度人脸图像的能力而闻名。StyleGAN 的实现需要对渐进式 GAN 有所了解。因此,我们将在第七章中详细讨论,高保真度人脸生成。
到目前为止,GANs 正逐渐远离仅使用随机噪声作为输入的黑箱方法,转向更好地利用数据属性的解耦表示方法。在下一章,我们将探讨如何在绘画创作中使用特定的 GAN 技术。
第六章:第六章:AI 画家
在这一章中,我们将讨论两种可以用来生成和编辑图像的生成对抗网络(GANs);它们分别是 iGAN 和 GauGAN。iGAN(互动 GAN)是第一个展示如何利用 GANs 进行互动图像编辑和转换的网络,最早出现在 2016 年。那时,GANs 尚处于兴起阶段,生成的图像质量不如今天的网络那样令人印象深刻,但它为 GANs 进入主流图像编辑开辟了道路。
在这一章中,你将了解 iGAN 的概念,并浏览一些展示视频的相关网站。该部分不会包含代码。接着,我们将介绍一个由 Nvidia 于 2019 年推出的更为先进的获奖应用——GauGAN,该应用能够将语义分割掩码转换为真实的风景照片,产生令人印象深刻的效果。
我们将从零开始实现 GauGAN,首先介绍一种新的归一化技术——空间自适应归一化。我们还将了解一种新的损失函数——铰链损失,并最终构建一个完整的 GauGAN。GauGAN 生成的图像质量远远优于我们在前几章中讲解的通用图像到图像转换网络。
在本章中,我们将涵盖以下主题:
-
iGAN 简介
-
使用 GauGAN 进行分割图到图像的转换
技术要求
相关的 Jupyter 笔记本和代码可以在这里找到:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter06
本章使用的笔记本是ch6_gaugan.ipynb。
iGAN 简介
我们现在已经熟悉了使用生成模型,如 pix2pix(见第四章《图像到图像的转换》)从草图或分割掩码生成图像。然而,由于大多数我们并非专业画家,画出的草图通常很简单,因此生成的图像形状也较为简单。如果我们能使用一张真实图像作为输入,并通过草图来改变其外观呢?
在 GANs 的早期,有一篇名为Generative Visual Manipulation on the Natural Image Manifold的论文,作者 J-Y. Zhu(CycleGAN 的发明者)等人,探讨了如何利用学习到的潜在表示进行图像编辑和变形。作者们建立了一个网站,efrosgans.eecs.berkeley.edu/iGAN/,其中包含展示以下几个应用场景的视频:
- 互动图像生成:这涉及到实时从草图生成图像,示例如下:

图 6.1 – 交互式图像生成,通过简单的笔触生成图像(来源:J-Y. Zhu 等,2016,“在自然图像流形上的生成视觉操作”,arxiv.org/abs/1609.03552)
-
交互式图像编辑:导入一张图片,我们使用 GAN 进行图像编辑。早期的 GAN 生成的图像仅使用噪声作为输入。即使是 BicycleGAN(在 iGAN 发明几年后出现的技术),也只能随机地改变生成图像的外观,而无法进行直接操作。iGAN 让我们能够指定颜色和纹理的变化,令人印象深刻。
-
交互式图像变换(形变):给定两张图像,iGAN 可以创建一系列图像,展示从一张图像到另一张图像的形变过程,如下所示:

图 6.2 – 交互式图像变换(形变)。给定两张图像,可以生成一系列中间图像(来源:J-Y. Zhu 等,2016,“在自然图像流形上的生成视觉操作”,arxiv.org/abs/1609.03552)
术语流形在论文中出现频繁。它也出现在其他机器学习文献中,因此我们花点时间来理解它。
理解流形
我们可以从自然图像的角度来理解流形。一个颜色像素可以通过 8 位或 256 位数字表示;单个 RGB 像素本身就可以有 256x256x256 = 160 万种不同的组合!用同样的逻辑,图像中所有像素的总可能性是天文数字!
然而,我们知道像素之间并非独立;例如,草地的像素限制在绿色范围内。因此,图像的高维度并不像看起来那么令人生畏。换句话说,维度空间比我们最初想象的要小得多。因此,我们可以说,高维图像空间是由低维流形支持的。
流形是物理学和数学中用来描述光滑几何表面的术语。流形可以存在于任何维度中。一维流形包括直线和圆;二维流形被称为曲面。球体是一个三维流形,它在任何地方都是光滑的。相对而言,立方体不是流形,因为它在顶点处并不光滑。事实上,我们在第二章**,变分自编码器中看到,一个具有二维潜在维度的自编码器的潜在空间是 MNIST 数字的二维流形投影。以下图示显示了数字的二维潜在空间:

图 6.3 – 数字的二维流形示意图。(来源: https://scikit-learn.org/stable/modules/manifold.html)
一个很好的资源来可视化 GAN 中的流形是poloclub.github.io/ganlab/上的交互式工具。在以下示例中,一个 GAN 被训练用于将均匀分布的二维样本映射到具有圆形分布的二维样本:

图 6.4 – 生成器的数据转换被可视化为流形,将输入噪声(左侧)转化为假样本(右侧)。(来源: M. Kahng, 2019, "GAN Lab: 使用交互式可视化实验理解复杂的深度生成模型," IEEE Transactions on Visualization and Computer Graphics, 25(1) (VAST 2018) minsuk.com/research/papers/kahng-ganlab-vast2018.pdf)
我们可以通过使用流形来可视化这种映射,其中输入被表示为一个均匀的方形网格。生成器将高维输入网格转换为一个低维的扭曲版本。图中右上方显示的输出是生成器近似的流形。生成器输出,或者说假图像(图中右下角),是从流形上采样的样本,其中网格块较小的区域意味着更高的采样概率。
本文的假设是,来自随机噪声z的 GAN 输出G(z),位于一个平滑流形上。因此,给定流形上的两张图像,G(z0)和G(zN),我们可以通过在潜在空间中插值得到一个包含平滑过渡的N + 1 张图像序列[G(z0) , G(z0), ..., G(zN)]。这种自然图像流形的近似用于执行真实感图像编辑。
图像编辑
现在我们知道什么是流形,接下来我们看看如何利用这些知识进行图像编辑。图像编辑的第一步是将图像投影到流形上。
将图像投影到流形上
将图像投影到流形上意味着使用预训练的 GAN 生成一张接近给定图像的图像。在本书中,我们将使用预训练的 DCGAN,其中生成器的输入是一个 100 维的潜在向量。因此,我们需要找到一个潜在向量,以生成尽可能接近原始图像的图像流形。实现这一目标的一种方法是使用优化,例如风格迁移,这一主题我们在第五章**,风格迁移中详细讲解过。
-
我们首先使用预训练的卷积神经网络(CNN),例如 VGG 中的block5_conv1层的输出(见第五章**,风格迁移),提取原始图像的特征,并将其作为目标。
-
然后,我们使用预训练的 DCGAN 生成器,并冻结权重,通过最小化特征之间的 L2 损失来优化输入的潜在向量。
正如我们在风格迁移中所学到的,优化可能运行缓慢,因此在交互式绘图时响应较慢。
另一种方法是训练一个前馈网络来预测图像的潜在向量,这种方法速度更快。如果 GAN 是将分割掩码转换为图像,那么我们可以使用诸如 U-Net 之类的网络来从图像中预测分割掩码。
使用前馈网络进行流形投影看起来类似于使用自编码器。编码器从原始图像中编码(预测)潜在变量,然后解码器(在 GAN 中是生成器)将潜在变量投影到图像流形上。然而,这种方法并不总是完美的。这时,混合方法派上了用场。我们使用前馈网络来预测潜在变量,然后通过优化进行微调。下图展示了使用不同技术生成的图像:

图 6.5 – 使用 GAN 将真实照片投影到图像流形上。(来源:J-Y. Zhu 等人,2016 年,《自然图像流形上的生成视觉操作》,arxiv.org/abs/1609.03552)
由于我们现在已经获得了潜在向量,我们将使用它来编辑流形。
使用潜在向量编辑流形
现在我们已经获得了潜在变量 z0 和图像流形 x0 = G(z0),下一步是操作 z0 来修改图像。假设图像是一只红色鞋子,我们想将其颜色改为黑色——我们该如何做呢?最简单也是最粗糙的方法是打开图像编辑软件,选择图中所有的红色像素并将它们改为黑色。生成的图像可能看起来不太自然,因为可能会丢失一些细节。传统的图像编辑工具算法通常不太适用于具有复杂形状和细致纹理细节的自然图像。
另一方面,我们知道我们可能可以改变潜在向量,并将其输入生成器来改变颜色。在实际操作中,我们并不知道如何修改潜在变量以获得我们想要的结果。
因此,我们可以从另一个方向来解决这个问题,而不是直接改变潜在向量。我们可以编辑流形,例如,通过在鞋子上画一条黑色条纹,然后用它来优化潜在变量,再将其投影以在流形上生成另一张图像。
本质上,我们执行的是如前所述的流形投影优化,但使用的是不同的损失函数。我们希望找到一个图像流形 x,使得以下方程最小化:

让我们从第二个损失项 S(x, x0) 开始,这是用于流形平滑性的 L2 损失,旨在鼓励新的流形不要偏离原始流形太多。这个损失项保持图像的整体外观。第一个损失项是数据项,它将所有编辑操作的损失进行求和。这个最好通过以下图像来描述:

图 6.6 – 使用 GAN 将真实照片投影到图像流形上。(来源:J-Y. Zhu 等人,2016 年,《自然图像流形上的生成视觉操作》,arxiv.org/abs/1609.03552)
这个示例使用色彩画笔来改变鞋子的颜色。由于本书的灰度打印中颜色变化不明显,建议查看论文的彩色版本,您可以从 arxiv.org/abs/1609.03552 下载。前面图形的顶行显示了作为约束的画笔笔触 vg 和 fg 作为编辑操作。我们希望画笔笔触 fg(x) 中的每个流形像素尽可能接近 vg。
换句话说,如果我们在鞋子上涂上黑色笔触,我们希望图像流形中的那部分是黑色的。这就是我们的意图,但要实现它,我们需要对潜在变量进行优化。因此,我们将前面的方程从像素空间重新表述为潜在空间。方程如下:

最后一项术语
是 GAN 的对抗损失。这个损失用于让流形看起来更真实,并略微提高视觉质量。默认情况下,这个项不会用于提高帧率。定义了所有损失项后,我们可以使用 TensorFlow 优化器,如 Adam,来运行优化。
编辑传递
编辑传递是图像编辑的最后一步。现在我们有两个流形,G(z0) 和 G(z1),我们可以通过在潜在空间中对 z0 和 z1 进行线性插值,生成一系列的中间图像。由于 DCGAN 的容量限制,生成的流形可能会显得模糊,且可能没有我们预期的那样逼真。
上述论文的作者解决这个问题的方式是:不使用流形作为最终图像,而是估计流形之间的颜色和几何变化,并将这些变化应用到原始图像上。颜色和运动流的估计是通过光流进行的;这是一种传统的计算机视觉技术,超出了本书的讨论范围。
以鞋子示例为例,如果我们关注的是颜色变化,我们会估计流形间像素的颜色变化,然后将颜色变化转移到原始图像中的像素上。类似地,如果变换涉及扭曲,即形状变化,我们就需要衡量像素的运动并将它们应用到原始图像上进行形态变化。网站上的示范视频是通过运动和颜色流共同创建的。
总结一下,我们现在已经了解到,iGAN 并不是一个 GAN,而是一种使用 GAN 进行图像编辑的方法。首先通过优化或前馈网络将真实图像投影到流形上。接着,我们使用画笔笔触作为约束,修改由潜在向量生成的流形。最后,我们将插值流形的颜色和运动流应用到真实图像上,从而完成图像编辑。
由于没有新的 GAN 架构,我们将不会实现 iGAN。相反,我们将实现 GauGAN,其中包含一些令人兴奋的新创新,适合于代码实现。
使用 GauGAN 进行分割图到图像的转换
GauGAN(以 19 世纪画家保罗·高更命名)是来自Nvidia的一种生成对抗网络(GAN)。说到 Nvidia,它是为数不多的几家在 GAN 领域进行大规模投资的公司之一。他们在这一领域取得了若干突破,包括ProgressiveGAN(我们将在第七章中介绍,高保真面部生成),用于生成高分辨率图像,以及StyleGAN,用于高保真面孔生成。
他们的主要业务是制造图形芯片,而非 AI 软件。因此,与一些其他公司不同,后者将其代码和训练模型视为严格保密,Nvidia 倾向于将其软件代码开源给公众。他们建立了一个网页(nvidia-research-mingyuliu.com/gaugan/),展示 GauGAN,这个工具可以根据分割图生成逼真的景观照片。以下截图取自他们的网页。
随时暂停本章并尝试一下该应用程序,看看它有多好:

图 6.7 – 通过 GauGAN 从画笔笔触到照片
现在我们将学习 pix2pixHD。
pix2pixHD 简介
GauGAN 以pix2pixHD为基础,并在其上增加了新特性。pix2pixHD 是 pix2pix 的升级版本,能够生成高清(HD)图像。由于本书中未涉及 pix2pixHD,且我们不会使用高清数据集,因此我们将在 pix2pix 的架构和我们已熟悉的代码基础上构建我们的 GauGAN 基础。尽管如此,了解 pix2pixHD 的高级架构还是很有帮助的,我将带你了解一些高层次的概念。下图展示了 pix2pixHD 生成器的架构:

图 6.8 – pix2pixHD 生成器的网络架构。(来源:T-C. W 等人,2018 年,《使用条件生成对抗网络进行高分辨率图像合成和语义操作》,arxiv.org/abs/1711.11585)
为了生成高分辨率图像,pix2pixHD 在不同图像分辨率下使用两个生成器,分别在粗尺度和细尺度上工作。粗生成器G1工作在图像分辨率的一半;也就是说,输入和目标图像被下采样到原图像分辨率的一半。当粗生成器训练完成后,我们开始训练粗生成器G1与细生成器G2,后者在全图像尺度上工作。从前面的架构图中,我们可以看到,G1的编码器输出与G1的特征连接,并输入到G2的解码器部分,生成高分辨率图像。这种设置也被称为粗到细生成器。
pix2pixHD 使用三个 PatchGAN 判别器,这些判别器在不同的图像尺度上工作。一个新的损失函数,称为特征匹配损失,用于匹配真实图像和假图像之间的层特征。这在风格迁移中得到应用,我们使用预训练的 VGG 进行特征提取,并优化风格特征。
现在我们已经简单介绍了 pix2pixHD,我们可以继续讲解 GauGAN。但在此之前,我们将实现一种归一化技术来展示 GauGAN。
空间自适应归一化(SPADE)
GauGAN 的主要创新是为分割图采用一种层归一化方法,称为空间自适应归一化(SPADE)。没错,又一个进入 GAN 工具箱的归一化技术。我们将深入探讨 SPADE,但在此之前,我们应了解网络输入的格式——语义分割图。
独热编码分割掩码
我们将使用facades数据集来训练我们的 GauGAN。在之前的实验中,分割图被编码为 RGB 图像中的不同颜色;例如,墙面用紫色掩码表示,门则是绿色。这种表示方式对我们来说视觉上很容易理解,但对神经网络学习并没有太大帮助。因为这些颜色没有语义意义。
颜色在色彩空间中接近,并不意味着它们在语义上也接近。我们可以用浅绿色表示草地,用深绿色表示飞机,尽管它们在分割图中颜色接近,但它们的语义并没有关联。
因此,我们应该使用类别标签而非颜色来标注像素。但这仍然没有解决问题,因为类别标签是随机分配的数字,它们也没有语义意义。因此,更好的方法是使用分割掩码,当某个像素处有物体时,标签为 1,否则为 0。换句话说,我们将分割图中的标签进行独热编码,得到形状为(H, W, 类别数)的分割掩码。下图展示了建筑图像的语义分割掩码示例:

图 6.9 – 左侧是使用 RGB 编码的分割图。右侧是分割图,被分为单独的窗口、立面和柱子类别
我们在前几章中使用的facades数据集是通过 JPEG 编码的,因此我们无法使用它来训练 GauGAN。在 JPEG 编码过程中,某些对视觉效果不太重要的视觉信息会在压缩过程中被移除。即使某些像素应该属于同一类别并且看起来是相同颜色,压缩后的像素值可能也会不同。因此,我们不能将 JPEG 图像中的颜色映射到类别。为了解决这个问题,我从原始数据源获得了原始数据集,并创建了一个新的数据集,每个样本包含三种不同的图像文件类型,如下所示:
-
JPEG – 真实照片
-
PNG – 使用 RGB 颜色的分割图
-
BMP – 使用类别标签的分割图
BMP 是无压缩的。我们可以将 BMP 图像视为前面图示中的 RGB 格式图像,不同之处在于像素值是 1 通道的类别标签,而不是 3 通道的 RGB 颜色。在图像加载和预处理过程中,我们将加载这三个文件,并将它们从 BMP 格式转换为独热编码的分割掩码。
有时,TensorFlow 的基本图像预处理 API 无法完成一些更复杂的任务,因此我们需要借助其他 Python 库。幸运的是,tf.py_function允许我们在 TensorFlow 训练管道中运行一个通用的 Python 函数。
在这个文件加载函数中,如下代码所示,我们使用.numpy()将 TensorFlow 张量转换为 Python 对象。函数名有点误导,因为它不仅适用于数值值,还适用于字符串值:
def load(image_file):
def load_data(image_file):
jpg_file = image_file.numpy().decode("utf-8")
bmp_file = jpg_file.replace('.jpg','.bmp')
png_file = jpg_file.replace('.jpg','.png')
image = np.array(Image.open(jpg_file))/127.5 - 1
map = np.array(Image.open(png_file))/127.5 - 1
labels = np.array(Image.open(bmp_file), dtype=np.uint8)
h, w, _ = image.shape
n_class = 12
mask = np.zeros((h, w, n_class), dtype=np.float32)
for i in range(n_class):
one_hot[labels==i, i] = 1
return map, image, mask
[mask, image, label] = tf.py_function( load_data, [image_file], [tf.float32, tf.float32, tf.float32])
现在我们理解了独热编码的语义分割掩码的格式,我们将看看 SPADE 如何帮助我们从分割掩码生成更好的图像。
实现 SPADE
实例归一化在图像生成中已经变得流行,但它往往会抹去分割掩码的语义信息。这意味着什么呢?假设输入图像仅包含一个单一的分割标签;例如,假设整张图像都是天空。当输入图像具有统一的值时,经过卷积层处理后的输出也将是统一的值。
回想一下,实例归一化是通过对每个通道的(H,W)维度计算均值来完成的。因此,该通道的均值将是相同的统一值,经过均值减法后的归一化激活将变为零。显然,语义信息丢失了,天空仿佛在一瞬间消失了。这是一个极端的例子,但使用相同的逻辑,我们可以看到随着分割掩码区域的增大,它的语义意义会丧失。
为了解决这个问题,SPADE 在由分割掩码限制的局部区域上进行归一化,而不是在整个掩码上进行。下图展示了 SPADE 的高层架构:

图 6.10 – 高层次 SPADE 架构。(重绘自:T. Park 等,2019 年,《具有空间自适应归一化的语义图像合成》,arxiv.org/abs/1903.07291)
在批归一化中,我们计算跨维度(N,H,W)上的通道的均值和标准差。这对于 SPADE 也是一样,如前图所示。不同之处在于,每个通道的 gamma 和 beta 不再是标量值(或 C 通道的向量),而是二维的(H,W)。换句话说,每个激活都有一个 gamma 和一个 beta,它们从语义分割图中学习。因此,归一化是以不同的方式应用于不同的分割区域。这两个参数通过使用两个卷积层进行学习,如下图所示:

图 6.11 – SPADE 设计图,其中 k 表示卷积滤波器的数量(重绘自:T. Park 等,2019 年,《具有空间自适应归一化的语义图像合成》,arxiv.org/abs/1903.07291)
SPADE 不仅在网络输入阶段使用,而且在内部层中也有应用。resize 层用于调整分割图的大小,以匹配该层的激活尺寸。我们现在可以实现一个 TensorFlow 自定义层来实现 SPADE。
我们将首先在__init__构造函数中定义卷积层,如下所示:
class SPADE(layers.Layer):
def __init__(self, filters, epsilon=1e-5):
super(SPADE, self).__init__()
self.epsilon = epsilon
self.conv = layers.Conv2D(128, 3, padding='same', activation='relu')
self.conv_gamma = layers.Conv2D(filters, 3, padding='same')
self.conv_beta = layers.Conv2D(filters, 3, padding='same')
接下来,我们将获取激活图的尺寸,稍后用于调整大小:
def build(self, input_shape):
self.resize_shape = input_shape[1:3]
最后,我们将在call()中将层和操作连接起来,如下所示:
def call(self, input_tensor, raw_mask):
mask = tf.image.resize(raw_mask, self.resize_shape, method='nearest')
x = self.conv(mask)
gamma = self.conv_gamma(x)
beta = self.conv_beta(x)
mean, var = tf.nn.moments(input_tensor, axes=(0,1,2), keepdims=True)
std = tf.sqrt(var + self.epsilon)
normalized = (input_tensor - mean)/std
output = gamma * normalized + beta
return output
这是基于 SPADE 设计图的直接实现。接下来,我们将看看如何使用 SPADE。
将 SPADE 插入残差块
GauGAN 在生成器中使用残差块。接下来,我们将看看如何将 SPADE 插入残差块:

图 6.12 – SPADE 残差块(重绘自:T. Park 等,2019 年,《具有空间自适应归一化的语义图像合成》,arxiv.org/abs/1903.07291)
SPADE 残差块中的基本构建块是 SPADE-ReLU-Conv 层。每个 SPADE 接收两个输入——来自前一层的激活值和语义分割图。
与标准残差块一样,它包含两个卷积-ReLU 层和一个跳跃连接路径。每当残差块前后的通道数量发生变化时,跳跃连接通过前面图中虚线框内的子块进行学习。当这种情况发生时,前向路径中两个 SPADE 的输入激活图会具有不同的维度。没关系,因为我们在 SPADE 块内已构建了内置的调整大小功能。以下是构建所需层的 SPADE 残差块代码:
class Resblock(layers.Layer):
def __init__(self, filters):
super(Resblock, self).__init__()
self.filters = filters
def build(self, input_shape):
input_filter = input_shape[-1]
self.spade_1 = SPADE(input_filter)
self.spade_2 = SPADE(self.filters)
self.conv_1 = layers.Conv2D(self.filters, 3, padding='same')
self.conv_2 = layers.Conv2D(self.filters, 3, padding='same')
self.learned_skip = False
if self.filters != input_filter:
self.learned_skip = True
self.spade_3 = SPADE(input_filter)
self.conv_3 = layers.Conv2D(self.filters, 3, padding='same')
接下来,我们将在 call() 中连接各个层:
def call(self, input_tensor, mask):
x = self.spade_1(input_tensor, mask)
x = self.conv_1(tf.nn.leaky_relu(x, 0.2))
x = self.spade_2(x, mask)
x = self.conv_2(tf.nn.leaky_relu(x, 0.2))
if self.learned_skip:
skip = self.spade_3(input_tensor, mask)
skip = self.conv_3(tf.nn.leaky_relu(skip, 0.2))
else:
skip = input_tensor
output = skip + x
return output
在原始的 GauGAN 实现中,谱归一化应用于卷积层之后。这是另一种归一化方法,我们将在第八章 图像生成的自注意力机制中讨论它,届时会讲解自注意力 GAN。因此,我们将跳过这一部分,直接将残差块组合在一起实现 GauGAN。
实现 GauGAN
我们将首先构建生成器,然后是判别器。最后,我们将实现损失函数并开始训练 GauGAN。
构建 GauGAN 生成器
在深入了解 GauGAN 生成器之前,让我们复习一下它的一些前辈。在 pix2pix 中,生成器只有一个输入——语义分割图。由于网络中没有随机性,给定相同的输入,它将始终生成具有相同颜色和纹理的建筑外立面。简单地将输入与随机噪声连接起来的方法是不可行的。
BicycleGAN(第四章 图像到图像翻译)为解决这个问题使用的两种方法之一是使用编码器将目标图像(真实照片)编码为潜向量,然后用它来采样随机噪声作为生成器输入。这个 cVAE-GAN 结构在 GauGAN 生成器中得到了应用。生成器有两个输入——语义分割掩码和真实照片。
在 GauGAN 的 Web 应用程序中,我们可以选择一张照片(生成的图像将类似于照片的风格)。这是通过使用编码器将风格信息编码为潜变量来实现的。编码器的代码与我们在前几章中使用的相同,因此我们将继续查看生成器架构。可以随时回顾第四章 图像到图像翻译,以复习编码器的实现。在下图中,我们可以看到 GauGAN 生成器的架构:

图 6.13 – GauGAN 生成器架构(重绘自:T. Park 等,2019 年,“带有空间自适应归一化的语义图像合成”,arxiv.org/abs/1903.07291)
生成器是一个类似解码器的架构。主要的不同之处在于,分割掩码通过 SPADE 进入每个残差块。为 GauGAN 选择的潜在变量维度为 256\。
注意
编码器不是生成器的一个核心部分;我们可以选择不使用任何样式图像,而是从标准的多元高斯分布中进行采样。
以下是我们之前编写的使用残差块构建生成器的代码:
def build_generator(self):
DIM = 64
z = Input(shape=(self.z_dim))
mask = Input(shape=self.input_shape)
x = Dense(16384)(z)
x = Reshape((4, 4, 1024))(x)
x = UpSampling2D((2,2))(Resblock(filters=1024)(x, mask))
x = UpSampling2D((2,2))(Resblock(filters=1024)(x, mask))
x = UpSampling2D((2,2))(Resblock(filters=1024)(x, mask))
x = UpSampling2D((2,2))(Resblock(filters=512)(x, mask))
x = UpSampling2D((2,2))(Resblock(filters=256)(x, mask))
x = UpSampling2D((2,2))(Resblock(filters=128)(x, mask))
x = tf.nn.leaky_relu(x, 0.2)
output_image = tanh(Conv2D(3, 4, padding='same')(x))
return Model([z, mask], output_image, name='generator')
你现在已经了解了让 GauGAN 工作的所有要素——SPADE 和生成器。网络架构的其余部分是从我们之前学习过的其他 GAN 中借鉴来的。接下来,我们将探讨如何构建判别器。
构建判别器
判别器是 PatchGAN,其中输入是分割图和生成图像的连接。分割图必须与生成的 RGB 图像具有相同的通道数;因此,我们将使用 RGB 分割图,而不是使用 one-hot 编码的分割掩码。GauGAN 判别器的架构如下:

图 6.14 – GauGAN 判别器架构(重绘自:T. Park 等,2019 年,“带有空间自适应归一化的语义图像合成”,arxiv.org/abs/1903.07291)
除了最后一层,判别器层由以下部分组成:
-
使用 4x4 的卷积层,步幅为 2,用于下采样
-
实例归一化(第一层除外)
-
Leaky ReLU
GauGAN 在不同的尺度上使用多个判别器。由于我们的数据集图像分辨率较低,为 256x256,单个判别器就足够了。如果我们使用多个判别器,我们需要做的就是将输入大小下采样一半,用于下一个判别器,并计算所有判别器的平均损失。
单个 PatchGAN 的代码实现如下:
def build_discriminator(self):
DIM = 64
model = tf.keras.Sequential(name='discriminators')
input_image_A = layers.Input(shape=self.image_shape, name='discriminator_image_A')
input_image_B = layers.Input(shape=self.image_shape, name='discriminator_image_B')
x = layers.Concatenate()([input_image_A, input_image_B])
x1 = self.downsample(DIM, 4, norm=False)(x) # 128
x2 = self.downsample(2*DIM, 4)(x1) # 64
x3 = self.downsample(4*DIM, 4)(x2) # 32
x4 = self.downsample(8*DIM, 4, strides=1)(x3) # 29
x5 = layers.Conv2D(1, 4)(x4)
outputs = [x1, x2, x3, x4, x5]
return Model([input_image_A, input_image_B], outputs)
这与 pix2pix 完全相同,唯一的不同是判别器返回所有下采样块的输出。为什么我们需要这样做呢?嗯,这就引出了关于损失函数的讨论。
特征匹配损失
特征匹配损失已成功用于风格转移。使用预训练的 VGG 提取内容和样式特征,并计算目标图像与生成图像之间的损失。内容特征简单地来自 VGG 中多个卷积块的输出。GauGAN 使用内容损失来取代 GAN 中常见的 L1 重建损失。原因在于重建损失是逐像素进行比较的,如果图像位置发生变化但在人眼看来仍然相同,则损失可能很大。
另一方面,卷积层的内容特征是空间不变的。因此,在使用内容损失在facades数据集上进行训练时,我们生成的建筑物看起来模糊得多,线条看起来更直。在风格转移文献中,内容损失有时被称为代码中的VGG 损失,因为人们喜欢使用 VGG 进行特征提取。
为什么人们仍然喜欢使用老旧的 VGG?
新的 CNN 架构如 ResNet 在图像分类方面的性能早已超过了 VGG,并实现了更高的准确率。那么,为什么人们仍然使用 VGG 进行特征提取呢?一些人尝试使用 Inception 和 ResNet 进行神经风格转移,但发现使用 VGG 生成的结果在视觉上更加愉悦。这可能是由于 VGG 架构的层次结构,其通道数在各层间单调递增。这使得从低级到高级表示的特征提取能够顺利进行。
相比之下,ResNet 的残差块具有瓶颈设计,将输入激活通道(例如 256)压缩到较低数量(例如 64),然后再恢复到较高数量(再次是 256)。残差块还具有跳过连接,可以为分类任务夹带信息并绕过卷积层中的特征提取。
计算 VGG 特征损失的代码如下:
def VGG_loss(self, real_image, fake_image):
# RGB to BGR
x = tf.reverse(real_image, axis=[-1])
y = tf.reverse(fake_image, axis=[-1])
# [-1, +1] to [0, 255]
x = tf.keras.applications.vgg19.preprocess_input( 127.5*(x+1))
y = tf.keras.applications.vgg19.preprocess_input( 127.5*(y+1))
# extract features
feat_real = self.vgg(x)
feat_fake = self.vgg(y)
weights = [1./32, 1./16, 1./8, 1./4, 1.]
loss = 0
mae = tf.keras.losses.MeanAbsoluteError()
for i in range(len(feat_real)):
loss += weights[i] * mae(feat_real[i], feat_fake[i])
return loss
计算 VGG 损失时,我们首先将图像从[-1, +1]转换为[0, 255],并从RGB转换为BGR,这是 Keras VGG preprocess函数期望的图像格式。GauGAN 对更高层次给予更多权重,以强调结构准确性。这是为了使生成的图像与分割掩模对齐。总之,这并非铁板一块,欢迎您尝试不同的权重。
特征匹配还用于鉴别器,我们在真实和虚假图像的鉴别器层输出中提取特征。以下代码用于计算鉴别器中的 L1 特征匹配损失:
def feature_matching_loss(self, feat_real, feat_fake):
loss = 0
mae = tf.keras.losses.MeanAbsoluteError()
for i in range(len(feat_real)-1):
loss += mae(feat_real[i], feat_fake[i])
return loss
此外,编码器还将具有KL 散度损失。最后一种损失是新的对抗损失铰链损失。
铰链损失
铰链损失可能是 GAN 领域的新来者,但它早已在支持向量机(SVM)中用于分类。它最大化决策边界的间隔。下图显示了正(真实)和负(假)标签的铰链损失:

图 6.15 – 判别器的铰链损失
左侧是当图像为真实图像时,判别器的铰链损失。当我们为判别器使用铰链损失时,当预测值大于 1 时,损失被限制为 0;如果预测值小于 1,损失则会增加,以惩罚未将图像预测为真实的情况。对于假图像也是类似的,只不过方向相反:当预测假图像小于 -1 时,铰链损失为 0,且当预测值超过该阈值时,损失会线性增加。
我们可以通过以下基本的数学运算来实现铰链损失:
def d_hinge_loss(y, is_real):
if is_real:
loss = tf.reduce_mean(tf.maximum(0., 1-y))
else:
loss = tf.reduce_mean (tf.maximum(0., 1+y))
return loss
另一种做法是使用 TensorFlow 的铰链损失 API:
def hinge_loss_d(self, y, is_real):
label = 1\. if is_real else -1.
loss = tf.keras.losses.Hinge()(y, label)
return loss
生成器的损失并不是真正的铰链损失;它只是一个负的预测均值。这是无界的,所以当预测分数越高时,损失越低:
def g_hinge_loss(y):
return –tf.reduce_mean(y)
现在我们拥有了训练 GauGAN 所需的一切,就像在上一章中所做的那样,使用训练框架进行训练。下图展示了使用分割掩码生成的图像:

图 6.16 – 我们的 GauGAN 实现生成的图像示例
它们看起来比 pix2pix 和 CycleGAN 的结果好得多!如果我们将真实图像的风格编码为随机噪声,生成的图像几乎无法与真实图像区分开来。用计算机查看时,效果非常令人印象深刻!
总结
现在,AI 在图像编辑中的应用已经很普遍,所有这一切大约是在 iGAN 被引入的时候开始的。我们了解了 iGAN 的关键原理,即首先将图像投影到流形上,然后直接在流形上进行编辑。接着我们优化潜在变量,生成自然逼真的编辑图像。这与之前只能通过操作潜在变量间接改变生成图像的方法不同。
GauGAN 融合了许多先进的技术,通过语义分割掩码生成清晰的图像。这包括使用铰链损失和特征匹配损失。然而,关键成分是 SPADE,它在使用分割掩码作为输入时提供了更优的性能。SPADE 对局部分割图进行归一化,以保持其语义含义,这有助于我们生成高质量的图像。到目前为止,我们一直使用分辨率为 256x256 的图像来训练我们的网络。我们现在拥有成熟的技术,能够生成高分辨率的图像,正如我们在介绍 pix2pixHD 时简要讨论过的那样。
在下一章中,我们将进入高分辨率图像的领域,使用诸如 ProgressiveGAN 和 StyleGAN 等高级模型。
第三部分:高级深度生成技术
本节还涵盖了 GANs 的应用,但将涉及更高级的技术,每个章节将介绍针对特定任务的最前沿模型。我们将通过讨论该领域的未来发展来总结本节内容。
本节包含以下章节:
-
第七章**, 高保真面部生成
-
第八章**, 自注意力机制在图像生成中的应用
-
第九章**, 视频合成
-
第十章**, 前路
第七章:第七章:高保真面部生成
随着生成对抗网络(GAN)的训练变得更加稳定,这得益于损失函数和归一化技术的改进,人们开始将注意力转向尝试生成更高分辨率的图像。此前,大多数 GAN 仅能生成最高 256x256 分辨率的图像,而仅仅向生成器中添加更多的上采样层并没有帮助。
在本章中,我们将介绍一些能够生成高达 1024x1024 及更高分辨率图像的技术。我们将从实现一个开创性的 GAN——渐进式 GAN(Progressive GAN)开始,有时简写为ProGAN。这是第一个成功生成 1024x1024 高保真面部肖像的 GAN。高保真不仅仅意味着高分辨率,还意味着与真实面孔的高度相似。我们可以生成一张高分辨率的面部图像,但如果它有四只眼睛,那它就不是高保真了。
在 ProGAN 之后,我们将实现StyleGAN,它在 ProGAN 的基础上进行构建。StyleGAN 结合了风格迁移中的 AdaIN,允许更细致的风格控制和风格混合,从而生成多样的图像。
本章内容包括以下内容:
-
ProGAN 概述
-
构建 ProGAN
-
实现 StyleGAN
技术要求
Jupyter 笔记本和代码可以在此找到:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter07
本章中使用的 Jupyter 笔记本如下所示:
-
ch7_progressive_gan.ipynb -
ch7_style_gan.ipynb
ProGAN 概述
在典型的 GAN 设置中,生成器的输出形状是固定的。换句话说,训练图像的大小不会改变。如果我们想尝试将图像分辨率加倍,我们需要在生成器架构中添加一个额外的上采样层,并从头开始训练。人们曾尝试过这种暴力方法来增加图像分辨率,但却以失败告终。增大的图像分辨率和网络规模增加了维度空间,使得学习变得更加困难。
卷积神经网络(CNN)面临同样的问题,并通过使用批归一化层来解决,但这在 GAN 中效果不佳。ProGAN 的核心思想是,不能同时训练所有层,而是从训练生成器和判别器中最底层开始,这样层的权重在添加新层之前可以得到稳定。我们可以将其看作是通过较低分辨率对网络进行预训练。这个想法是 ProGAN 带来的核心创新,详细内容见 T. Karras 等人撰写的学术论文《渐进式生成对抗网络(GAN)以提高质量、稳定性和变异性》。下图展示了 ProGAN 中网络逐渐增长的过程:

图 7.1 – 层逐步增长的示意图。(图源:T. Karras 等人,2018 年,"Progressive Growing of GANs for Improved Quality, Stability, and Variation",https://arxiv.org/abs/1710.10196)
像传统 GAN 一样,ProGAN 的输入是从随机噪声中采样的潜在向量。如上图所示,我们从4x4分辨率的图像开始,生成器和判别器中只有一个模块。在4x4分辨率训练一段时间后,我们为8x8分辨率添加新层。然后继续这样做,直到最终达到1024x1024的图像分辨率。以下 256x256 的图像是使用 ProGAN 生成的,并由 NVIDIA 发布。图像质量令人叹为观止,它们几乎无法与真实面孔区分:

图 7.2 – ProGAN 生成的高保真图像(来源:https://github.com/tkarras/progressive_growing_of_gans)
可以公平地说,卓越的图像生成主要归功于逐步增长网络结构。网络架构非常简单,仅由卷积层和全连接层组成,而不像 GANs 中常见的更复杂架构,如残差块或类似 VAE 的架构。
直到 ProGAN 推出两代之后,作者才开始探索这些网络架构。损失函数也很简单,仅为 WGAN-GP 损失,没有其他损失函数,如内容损失、重建损失或 KL 散度损失。然而,在实现逐步增长层的核心部分之前,我们应该了解一些小的创新。以下是这些创新:
-
像素归一化
-
小批量统计
-
等化学习率
像素归一化
批量归一化应该能减少协变量偏移,但 ProGAN 的作者并未在网络训练中观察到这一点。因此,他们放弃了批量归一化,使用了一种自定义的归一化方法,称为像素归一化。另外,其他研究者后来发现,尽管批量归一化有助于稳定深度神经网络训练,但并没有真正解决协变量问题。
无论如何,ProGAN 中归一化的目的是限制权重值,以防止它们呈指数增长。大的权重可能会放大信号幅度,导致生成器和判别器之间的不健康竞争。像素归一化将每个像素位置(H,W)上的特征在通道维度上归一化为单位长度。如果张量是一个批量 RGB 图像,维度为(N,H,W,C),那么任何像素的 RGB 向量将具有 1 的幅度。
我们可以使用自定义层实现这个方程,如下所示的代码:
class PixelNorm(Layer):
def __init__(self, epsilon=1e-8):
super(PixelNorm, self).__init__()
self.epsilon = epsilon
def call(self, input_tensor):
return input_tensor / tf.math.sqrt( tf.reduce_mean(input_tensor**2, axis=-1, keepdims=True) + self.epsilon)
与其他归一化方法不同,像素归一化没有任何可学习的参数;它仅由简单的算术操作组成,因此在计算上高效。
使用小批量统计量增加图像变化
模式崩溃发生在 GAN 生成相似的图像时,因为它只捕捉到训练数据中的一部分变化。鼓励更多变化的一种方式是将小批量的统计量显示给判别器。与单个实例相比,小批量的统计量更加多样化,这鼓励生成器生成显示出类似统计量的图像。
批量归一化使用小批量统计量来归一化激活值,这在某种程度上实现了这个目的,但 ProGAN 不使用批量归一化。相反,它使用一个小批量层,该层计算小批量标准差并将其附加到激活值中,而不改变激活值本身。
计算小批量统计量的步骤如下:
-
计算每个特征在每个空间位置上的标准差,即在维度 N 上进行计算。
-
计算这些标准差在(H, W, C)维度上的平均值,从而得出一个单一的尺度值。
-
将这个值在特征图(H, W)上复制,并将其附加到激活值上。结果,输出的激活值形状为(N, H, W, C+1)。
以下是一个小批量标准差自定义层的代码:
class MinibatchStd(Layer):
def __init__(self, group_size=4, epsilon=1e-8):
super(MinibatchStd, self).__init__()
self.epsilon = epsilon
self.group_size = group_size
def call(self, input_tensor):
n, h, w, c = input_tensor.shape
x = tf.reshape(input_tensor, [self.group_size, -1, h, w, c])
group_mean, group_var = tf.nn.moments(x, axes=(0), keepdims=False)
group_std = tf.sqrt(group_var + self.epsilon)
avg_std = tf.reduce_mean(group_std, axis=[1,2,3], keepdims=True)
x = tf.tile(avg_std, [self.group_size, h, w, 1])
return tf.concat([input_tensor, x], axis=-1)
在计算标准差之前,激活值首先会被拆分成4个组,或者是批次大小,以较小者为准。为了简化代码,我们假设训练期间批次大小至少为4。小批量层可以插入到判别器的任何位置,但发现它在最后更有效,即 4x4 层。
等化学习率
这个名字可能会令人误解,因为等化学习率并不像学习率衰减那样修改学习率。事实上,优化器的学习率在整个训练过程中保持不变。为了理解这一点,让我们回顾一下反向传播是如何工作的。当使用简单的随机梯度下降(SGD)优化器时,负梯度会在更新权重之前与学习率相乘。因此,越靠近生成器输入的层接收到的梯度会越小(记得消失梯度吗?)。
如果我们希望某一层接收更多的梯度怎么办?假设我们执行一个简单的矩阵乘法 y = wx,现在我们加上一个常数 2,使得它变成 y = 2wx*。在反向传播过程中,梯度也会被 2 放大,从而变得更大。我们可以为不同的层设置不同的乘数常数,从而有效地实现不同的学习率。
在 ProGAN 中,这些乘法常数是通过 He 的初始化方法计算得出的。He或Kaiming初始化法以 ResNet 的发明者 Kaiming He 命名。权重初始化方法专为使用 ReLU 系列激活函数的网络设计。通常,权重是通过标准正态分布初始化的,指定标准差;例如,我们在前几章中使用了 0.02。He 方法则通过以下公式计算标准差,而无需猜测:

kernel, kernel, channel_in, channel_out),fan in是kernel x kernel x channel_in的乘积。为了在权重初始化中使用它,我们可以将tf.keras.initializers.he_normal传递给 Keras 层。然而,等化学习率在运行时完成这一步,因此我们将编写自定义层来计算标准差。
初始化的默认增益因子为 2,但 ProGAN 在 4x4 生成器的输入的Dense层中使用较低的增益。ProGAN 使用标准正态分布来初始化层的权重,并通过它们的归一化常数来缩放。这与 GAN 中普遍采用的细致权重初始化方法有所不同。接下来,我们编写一个使用像素归一化的自定义 Conv2D 层:
class Conv2D(layers.Layer):
def build(self, input_shape):
self.in_channels = input_shape[-1]
fan_in = self.kernel*self.kernel*self.in_channels
self.scale = tf.sqrt(self.gain/fan_in)
def call(self, inputs):
x = tf.pad(inputs, [[0, 0], [1, 1], [1, 1], [0, 0]], mode='REFLECT') \ if self.pad else inputs
output = tf.nn.conv2d(x, self.scale*self.w, strides=1, padding="SAME") + self.b
return output
官方 ProGAN 在卷积层中使用零填充,你可以看到边缘伪影,特别是在查看低分辨率图像时。因此,我们为除了 1x1 卷积核外的所有卷积层添加了反射填充,1x1 卷积核不需要填充。较大的层有较小的缩放因子,从而有效地减小了梯度,进而减小了学习率。这导致学习率会根据层的大小进行调整,以避免大层的权重增长过快,因此得名等化学习率。
自定义的Dense层可以类似地编写:
class Dense(layers.Layer):
def __init__(self, units, gain=2, **kwargs):
super(Dense, self).__init__(kwargs)
self.units = units
self.gain = gain
def build(self, input_shape):
self.in_channels = input_shape[-1]
initializer = \ tf.keras.initializers.RandomNormal( mean=0., stddev=1.)
self.w = self.add_weight(shape=[self.in_channels,
self.units],
initializer=initializer,
trainable=True,
name='kernel')
self.b = self.add_weight(shape=(self.units,),
initializer='zeros',
trainable=True,
name='bias')
fan_in = self.in_channels
self.scale = tf.sqrt(self.gain/fan_in)
def call(self, inputs):
output = tf.matmul(inputs, self.scale*self.w) + self.b
return output
请注意,自定义层在构造函数中接受**kwargs,这意味着我们可以传入常见的 Keras 关键字参数,用于Dense层。我们现在已经拥有了开始在下一节构建 ProGAN 所需的所有要素。
构建 ProGAN
我们现在已经了解了 ProGAN 的三个特性——像素归一化、小批量标准差统计和等化学习率。接下来,我们将深入研究网络架构,看看如何逐步扩展网络。ProGAN 通过扩展网络层来增长图像,从 4x4 的分辨率开始,逐步加倍,直到达到 1024x1024。因此,我们将首先编写代码,以便在每个尺度下构建层模块。生成器和判别器的构建模块非常简单,正如我们将看到的那样。
构建生成器模块
我们将从构建 4x4 的生成器块开始,它构成生成器的基础,并将潜在代码作为输入。输入通过 PixelNorm 进行归一化,然后输入到 Dense 层。为该层使用较低的增益来实现均衡学习率。在所有生成器块中,都使用 Leaky ReLU 和像素归一化。我们按如下方式构建生成器:
def build_generator_base(self, input_shape):
input_tensor = Input(shape=input_shape)
x = PixelNorm()(input_tensor)
x = Dense(8192, gain=1./8)(x)
x = Reshape((4, 4, 512))(x)
x = LeakyReLU(0.2)(x)
x = PixelNorm()(x)
x = Conv2D(512, 3, name='gen_4x4_conv1')(x)
x = LeakyReLU(0.2)(x)
x = PixelNorm()(x)
return Model(input_tensor, x,
name='generator_base')
在 4x4 生成器块之后,所有后续块都具有相同的架构,其中包括一个上采样层,接着是两个卷积层。唯一的区别是卷积滤波器的大小。在 ProGAN 的默认设置中,直到 32x32 的生成器块使用 512 的滤波器大小,然后在每个阶段将其减半,最终在 1024x1024 时达到 16,如下所示:
self.log2_res_to_filter_size = {
0: 512,
1: 512,
2: 512, # 4x4
3: 512, # 8x8
4: 512, # 16x16
5: 512, # 32x32
6: 256, # 64x64
7: 128, # 128x128
8: 64, # 256x256
9: 32, # 512x512
10: 16} # 1024x1024
为了简化编码,我们可以通过对分辨率取以 2 为底的对数来将其线性化。因此,log2(4) 等于 2,log2(8) 等于 3,...直到 log2(1024) 等于 10。然后,我们可以按以下方式线性地通过 log2 从 2 循环到 10:
def build_generator_block(self, log2_res, input_shape):
res = 2**log2_res
res_name = f'{res}x{res}'
filter_n = self.log2_res_to_filter_size[log2_res]
input_tensor = Input(shape=input_shape)
x = UpSampling2D((2,2))(input_tensor)
x = Conv2D(filter_n, 3, name=f'gen_{res_name}_conv1')(x)
x = PixelNorm()(LeakyReLU(0.2)(x))
x = Conv2D(filter_n, 3, name=f'gen_{res_name}_conv2')(x)
x = PixelNorm()(LeakyReLU(0.2)(x))
return Model(input_tensor, x,
name=f'genblock_{res}_x_{res}')
我们现在可以使用这段代码从 4x4 一直到目标分辨率构建所有生成器块。
构建判别器块
现在我们可以将注意力转向判别器。基本的判别器是在 4x4 的分辨率下,它接受 4x4x3 的图像并预测该图像是真实的还是假的。它使用一个卷积层,接着是两个全连接层。与生成器不同,判别器不使用像素归一化;事实上,完全没有使用归一化。我们将按如下方式插入小批量标准差层:
def build_discriminator_base(self, input_shape):
input_tensor = Input(shape=input_shape)
x = MinibatchStd()(input_tensor)
x = Conv2D(512, 3, name='gen_4x4_conv1')(x)
x = LeakyReLU(0.2)(x)
x = Flatten()(x)
x = Dense(512, name='gen_4x4_dense1')(x)
x = LeakyReLU(0.2)(x)
x = Dense(1, name='gen_4x4_dense2')(x)
return Model(input_tensor, x,
name='discriminator_base')
之后,判别器使用两个卷积层,接着进行下采样,每个阶段都使用平均池化:
def build_discriminator_block(self, log2_res, input_shape):
filter_n = self.log2_res_to_filter_size[log2_res]
input_tensor = Input(shape=input_shape)
x = Conv2D(filter_n, 3)(input_tensor)
x = LeakyReLU(0.2)(x)
filter_n = self.log2_res_to_filter_size[log2_res-1]
x = Conv2D(filter_n, 3)(x)
x = LeakyReLU(0.2)(x)
x = AveragePooling2D((2,2))(x)
res = 2**log2_res
return Model(input_tensor, x,
name=f'disc_block_{res}_x_{res}')
我们现在定义了所有基本的构建块。接下来,我们将查看如何将它们组合在一起,逐步增长网络。
渐进式增长网络
这是 ProGAN 中最重要的部分——网络的增长。我们可以使用之前的函数在不同的分辨率下创建生成器和判别器块。现在我们要做的就是在增长层时将它们连接在一起。下图展示了网络增长的过程。让我们从左侧开始:

图 7.3 – 渐进式增长层的示意图。
重新绘制自 T. Karras 等人 2018 年的论文《渐进式增长的 GANs 用于提高质量、稳定性和变异性》,https://arxiv.org/abs/1710.10196
在我们构建的生成器和判别器模块中,我们假设输入和输出都是层激活值,而不是 RGB 图像。因此,我们需要将生成器模块的激活值转换为 RGB 图像。同样,对于判别器,我们需要将图像转换为激活值。这在前图中的(a)所示。
我们将创建两个函数,构建将图像转换为 RGB 图像以及从 RGB 图像转换的模块。两个模块都使用 1x1 卷积层;to_rgb 模块使用大小为 3 的滤波器以匹配 RGB 通道,而 from_rgb 模块使用与判别器模块输入激活值相匹配的滤波器大小。两个函数的代码如下:
def build_to_rgb(self, res, filter_n):
return Sequential([Input(shape=(res, res, filter_n)),
Conv2D(3, 1, gain=1,
activation='tanh')])
def build_from_rgb(self, res, filter_n):
return Sequential([Input(shape=(res, res, 3)),
Conv2D(filter_n, 1),
LeakyReLU(0.2)])
现在,假设网络处于 16x16 状态,意味着已经有 8x8 和 4x4 低分辨率的层。现在我们即将扩展到 32x32 层。然而,如果我们向网络中添加一个新的未训练层,新生成的图像将像噪声,并导致巨大的损失。这反过来可能导致梯度爆炸,并使训练不稳定。
为了最小化这种干扰,由新层生成的 32x32 图像不会立即使用。相反,我们将从前一个阶段的 16x16 图像进行上采样,并与新生成的 32x32 图像进行淡入。淡入是图像处理中的一个术语,指的是逐渐增加图像的透明度。这是通过使用加权和公式实现的,公式如下:

在这个过渡阶段,alpha 从 0 增加到 1。换句话说,在阶段开始时,我们完全丢弃新层的图像,使用来自先前训练层的图像。然后我们将 alpha 线性增加到 1,当只使用新层生成的图像时。稳定状态如前图中的(c)所示。我们可以实现一个自定义层来执行加权和,代码如下:
class FadeIn(Layer):
@tf.function
def call(self, input_alpha, a, b):
alpha = tf.reduce_mean(input_alpha)
y = alpha * a + (1\. - alpha) * b
return y
当使用子类定义一个层时,我们可以将一个标量 alpha 传递给函数。然而,当我们使用 self.alpha = tf.Variable(1.0) 时,就不可能做到这一点,因为它会在编译模型时被转换为常量,并且在训练中无法再更改。
传递标量 alpha 的一种方法是使用子类化编写整个模型,但我认为在这种情况下,使用顺序或函数式 API 创建模型更为方便。为了解决这个问题,我们将 alpha 定义为模型的输入。然而,模型输入假定为一个小批量。具体来说,如果我们定义了 Input(shape=(1)),则其实际形状将是 (None, 1),其中第一维是批量大小。因此,FadeIN() 中的 tf.reduce_mean() 旨在将批量值转换为标量值。
现在,我们可以查看以下步骤,将生成器扩展到例如 32x32:
-
添加一个 4x4 的生成器,其中输入是一个潜在向量。
-
在一个循环中,添加逐步增加分辨率的生成器,直到达到目标分辨率之前的一个(在我们的示例中为 16x16)。
-
从 16x16 添加
to_rgb,并将其上采样到 32x32。 -
添加 32x32 生成器块。
-
淡入两张图片,创建最终的 RGB 图像。
代码如下:
def grow_generator(self, log2_res):
res = 2**log2_res
alpha = Input(shape=(1))
x = self.generator_blocks[2].input
for i in range(2, log2_res):
x = self.generator_blocksi
old_rgb = self.to_rgblog2_res-1
old_rgb = UpSampling2D((2,2))(old_rgb)
x = self.generator_blockslog2_res
new_rgb = self.to_rgblog2_res
rgb = FadeIn()(alpha, new_rgb, old_rgb)
self.generator = Model([self.generator_blocks[2].input,
alpha], rgb,
name=f'generator_{res}_x_{res}')
判别器的增长过程类似,但方向相反,如下所示:
-
在输入图像的分辨率下,比如 32x32,向 32x32 的判别器块中添加
from_rgb。输出是一个 16x16 特征图的激活。 -
同时,将输入图像下采样到 16x16,并在 16x16 判别器块中添加
from_rgb。 -
淡入前面两项特征,并将其输入到下一个 8x8 的判别器块。
-
继续向 4x4 基础的判别器块中添加判别器块,输出是一个单一的预测值。
以下是扩展判别器的代码:
def grow_discriminator(self, log2_res):
res = 2**log2_res
input_image = Input(shape=(res, res, 3))
alpha = Input(shape=(1))
x = self.from_rgblog2_res
x = self.discriminator_blockslog2_res
downsized_image = AveragePooling2D((2,2))(input_image)
y = self.from_rgblog2_res-1
x = FadeIn()(alpha, x, y)
for i in range (log2_res-1, 1, -1):
x = self.discriminator_blocksi
self.discriminator = Model([input_image, alpha], x,
name=f'discriminator_{res}_x_{res}')
最后,我们从扩展后的生成器和判别器构建模型,如下所示:
def grow_model(self, log2_res):
self.grow_generator(log2_res)
self.grow_discriminator(log2_res)
self.discriminator.trainable = False
latent_input = Input(shape=(self.z_dim))
alpha_input = Input(shape=(1))
fake_image = self.generator([latent_input, alpha_input])
pred = self.discriminator([fake_image, alpha_input])
self.model = Model(inputs=[latent_input, alpha_input],
outputs=pred)
self.model.compile(loss=wasserstein_loss,
optimizer=Adam(**self.opt_init))
self.optimizer_discriminator = Adam(**self.opt_init)
新添加层之后,我们重置优化器的状态。这是因为像 Adam 这样的优化器有内部状态,用来存储每一层的梯度历史。最简单的方法可能是使用相同的参数实例化一个新的优化器。
损失函数
你可能已经注意到前面的代码片段中使用了Wasserstein 损失。没错,生成器使用 Wasserstein 损失,其损失函数是预测值与标签之间的乘积。判别器使用 WGAN-GP 梯度惩罚损失。我们在第三章**《生成对抗网络》中学习了 WGAN-GP,但这里我们再回顾一下损失函数。
WGAN-GP 在假图像和真实图像之间插值,并将插值输入判别器。然后,梯度是相对于输入插值计算的,而不是传统的计算相对于权重的梯度。接着,我们计算梯度惩罚(损失)并将其加到判别器的损失中,进行反向传播。我们将重用在第三章**《生成对抗网络》中开发的 WGAN-GP。与原始 WGAN-GP 不同,原始 WGAN-GP 对每一次生成器训练步骤都会训练判别器五次,而 ProGAN 对判别器和生成器进行等量的训练。
除了 WGAN-GP 损失,还有一种额外的损失类型,叫做漂移损失。判别器的输出是无界的,可能是非常大的正值或负值。漂移损失旨在防止判别器的输出过度偏离零,朝着无穷大方向漂移。以下代码片段展示了如何根据判别器输出计算漂移损失:
# drift loss
all_pred = tf.concat([pred_fake, pred_real], axis=0)
drift_factor = 0.001
drift_loss = drift_factor * tf.reduce_mean(all_pred**2)
现在,我们可以开始训练我们的 ProGAN 了!
成长的阵痛
ProGAN 的训练非常缓慢。作者们使用了八个 Tesla V100 GPU,并花费了 4 天时间在 1024x1024 CelebA-HQ 数据集上进行训练。如果你只有一块 GPU,训练可能需要超过 1 个月!即使是较低的 256x256 分辨率,单 GPU 训练也需要 2 到 3 天的时间。在开始训练之前,请考虑这一点。你可能想从更低的目标分辨率开始,例如 64x64。
话虽如此,刚开始时我们不需要使用高分辨率数据集。256x256 分辨率的数据集已经足够。笔记本中省略了输入部分,因此可以自行填写输入以加载数据集。供参考,有两个流行的 1024x1024 面部数据集可以免费下载:
-
官方 ProGAN TensorFlow 1 实现中的 CelebA-HQ:
github.com/tkarras/progressive_growing_of_gans。它需要下载原始的CelebA数据集以及与 HQ 相关的文件。生成脚本还依赖于一些过时的库。因此,我不建议你按这种方式操作;你应该尝试寻找一个已经预处理好的数据集。 -
FFHQ:
github.com/NVlabs/ffhq-dataset。这个数据集是为 StyleGAN(ProGAN 的继任者)创建的,比CelebA-HQ数据集更加多样和丰富。由于服务器的下载限制,它也可能比较难以下载。
当我们下载高分辨率图像时,需要将它们降采样到较低的分辨率以用于训练。你可以在运行时进行降采样,但由于额外的计算量,这会稍微降低训练速度,同时需要更多的内存带宽来传输图像。另一种方法是先从原始图像分辨率创建多尺度图像,这样可以节省内存传输和图像调整大小的时间。
另一个需要注意的点是批处理大小。随着图像分辨率的增长,存储图像和更大层激活所需的 GPU 内存也会增加。如果批处理大小设置得太高,我们的 GPU 内存将不足。因此,我们从 4x4 到 64x64 使用批处理大小 16,然后随着分辨率的翻倍,将批处理大小减半。你应该根据自己的 GPU 调整批处理大小。
下图显示了使用我们的 ProGAN 从 16x16 分辨率到 64x64 分辨率生成的图像:

图 7.4 – 我们的 ProGAN 生成的图像从 8x8 增长到 64x64
ProGAN 是一个非常精细的模型。在本书中重现模型时,我只实现了关键部分,以匹配原始实现的细节。我省略了一些我认为不那么重要的部分,并替换为我未涉及的内容。这适用于优化器、学习率、归一化技术和损失函数。
然而,我发现为了让 ProGAN 工作,我必须几乎完全按照原始规格来实现所有内容。这包括使用相同的批量大小、漂移损失和等化学习率增益。然而,当我们让网络正常工作时,它确实生成了高保真的面孔,这是任何之前的模型都无法比拟的!
现在让我们看看 StyleGAN 如何在 ProGAN 的基础上改进,以实现风格混合。
实现 StyleGAN
ProGAN 擅长通过逐步增长网络来生成高分辨率图像,但其网络架构相当原始。这个简单的架构类似于早期的 GAN,例如 DCGAN,它们从随机噪声生成图像,但无法对生成的图像进行精细控制。
正如我们在前几章中看到的,图像到图像的翻译中出现了许多创新,使得生成器输出的操作更加灵活。其中之一是使用 AdaIN 层(第五章**,风格迁移),它可以实现风格迁移,将两张不同图像的内容和风格特征进行混合。StyleGAN采用了这种风格混合的概念,提出了基于风格的生成对抗网络生成器架构——这是为FaceBid所写论文的标题。下图展示了 StyleGAN 如何将两张不同图像的风格特征混合生成一张新图像:

图 7.5 – 混合风格生成新图像(来源:T. Karras 等,2019 年《基于风格的生成对抗网络生成器架构》,https://arxiv.org/abs/1812.04948)
现在我们将深入探讨 StyleGAN 生成器架构。
基于风格的生成器
下图对比了 ProGAN 和 StyleGAN 的生成器架构:

图 7.6 – 对比(a)ProGAN 和(b)StyleGAN 之间的生成器(重绘自 T. Karras 等,2019 年《基于风格的生成对抗网络生成器架构》,https://arxiv.org/abs/1812.04948)
ProGAN 架构是一个简单的前馈设计,其中单一输入是潜在代码。所有潜在信息,例如内容、风格和随机性,都包含在单一潜在代码z中。在前面的图中,右侧是 StyleGAN 生成器架构,其中潜在代码不再直接进入合成网络。潜在代码被映射到风格代码,并进入多尺度合成网络。
现在我们将讲解生成管道,它包括以下主要构建模块:
-
映射网络,f:这是 8 层密集网络,每层有 512 个维度。它的输入是 512 维的潜在代码,输出 w 也是一个 512 维的向量。w 会广播到生成器的每个尺度。
-
仿射变换,A:在每个尺度上,都有一个块将 w 映射到风格 y = (ys, yb)。换句话说,全局潜在向量在每个图像尺度上被转换为局部风格代码。仿射变换通过密集层实现。
-
AdaIN:AdaIN 调节风格代码和内容代码。内容代码 x 是卷积层的激活,而 y 是风格代码:

-
合成网络,g:本质上是由 ProGAN 的多尺度生成器模块组成。与 ProGAN 的显著区别在于,合成网络的输入只是一些常数值。这是因为潜在代码在每个生成器层中作为风格代码呈现,包括第一个 4x4 块,因此不需要另一个随机输入到合成网络。
-
多尺度噪声:人像的许多方面可以看作是随机的(stochastic)。例如,头发和雀斑的精确位置可能是随机的,但这并不改变我们对图像的感知。这种随机性来自注入生成器的噪声。高斯噪声的形状与卷积层的激活图(H, W, 1)匹配。在添加到卷积激活之前,它会通过 B 按通道缩放到 (H, W, C)。
在大多数 StyleGAN 之前的 GAN 中,潜在代码仅在输入或内部某一层被注入。StyleGAN 生成器的亮点在于我们现在可以在每一层注入风格代码和噪声,这意味着我们可以在不同的层次上调整图像。粗略空间分辨率的风格(从 4x4 到 8x8)对应于高层次的方面,如姿势和面部形状。中等分辨率(从 16x16 到 32x32)与较小尺度的面部特征、发型以及眼睛是否睁开有关。最后,更高分辨率(从 64x64 到 1024x1024)主要改变色彩方案和微观结构。
StyleGAN 生成器最初看起来可能很复杂,但希望现在看起来不那么吓人了。像 ProGAN 一样,单个模块很简单。我们会大量借用 ProGAN 的代码;现在让我们开始构建 StyleGAN 生成器吧!
实现映射网络
映射网络将 512 维潜在代码映射到 512 维特征,具体如下:
def build_mapping(self):
# Mapping Network
z = Input(shape=(self.z_dim))
w = PixelNorm()(z)
for i in range(8):
w = Dense(512, lrmul=0.01)(w)
w = LeakyReLU(0.2)(w)
w = tf.tile(tf.expand_dims(w, 0), (8,1,1))
self.mapping = Model(z, w, name='mapping')
这是一个简单的密集层实现,使用了泄漏 ReLU 激活函数。需要注意的是,学习率乘以 0.01,以使训练更加稳定。因此,自定义的 Dense 层被修改为接受一个额外的 lrmul 参数。在网络的最后,我们创建了八个 w 的副本,它们将进入生成器模块的八个层。如果我们不打算使用风格混合,可以跳过平铺操作。
添加噪声
我们现在创建一个自定义层,将噪声添加到卷积层输出中,其中包括架构图中的 B 模块。代码如下:
class AddNoise(Layer):
def build(self, input_shape):
n, h, w, c = input_shape[0]
initializer = \ tf.keras.initializers.RandomNormal( mean=0., stddev=1.)
self.B = self.add_weight(shape=[1, 1, 1, c],
initializer=initializer,
trainable=True,
name='kernel')
def call(self, inputs):
x, noise = inputs
output = x + self.B * noise
return output
噪声与可学习的 B 相乘,按通道进行缩放,然后加到输入激活上。
实现 AdaIN
我们将在 StyleGAN 中实现的 AdaIN 与用于风格迁移的 AdaIN 有所不同,原因如下:
-
我们将包括仿射变换 A。这是通过两个密集层实现的,分别预测 ys 和 yb。
-
原始 AdaIN 涉及对输入激活进行归一化,但由于我们 AdaIN 的输入激活已经经过像素归一化,因此在此自定义层中不进行归一化。AdaIN 层的代码如下:
class AdaIN(Layer):
def __init__(self, gain=1, **kwargs):
super(AdaIN, self).__init__(kwargs)
self.gain = gain
def build(self, input_shapes):
x_shape = input_shapes[0]
w_shape = input_shapes[1]
self.w_channels = w_shape[-1]
self.x_channels = x_shape[-1]
self.dense_1 = Dense(self.x_channels, gain=1)
self.dense_2 = Dense(self.x_channels, gain=1)
def call(self, inputs):
x, w = inputs
ys = tf.reshape(self.dense_1(w), (-1, 1, 1,
self.x_channels))
yb = tf.reshape(self.dense_2(w), (-1, 1, 1,
self.x_channels))
output = ys*x + yb
return output
比较 AdaIN 与风格迁移
ProGAN 中的 AdaIN 与用于风格迁移的原始实现不同。在风格迁移中,风格特征是通过 VGG 特征计算得到的 Gram 矩阵。而在 ProGAN 中,“风格”是通过随机噪声生成的向量* w *。
构建生成器模块
现在,我们可以将 AddNoise 和 AdaIN 放入生成器模块中,这与 ProGAN 中构建生成器模块的代码类似,如下所示:
def build_generator_block(self, log2_res, input_shape):
res = int(2**log2_res)
res_name = f'{res}x{res}'
filter_n = self.log2_res_to_filter_size[log2_res]
input_tensor = Input(shape=input_shape)
x = input_tensor
w = Input(shape=512)
noise = Input(shape=(res, res, 1))
if log2_res > 2:
x = UpSampling2D((2,2))(x)
x = Conv2D(filter_n, 3, name=f'gen_{res_name}_conv1')(x)
x = AddNoise()([x, noise])
x = PixelNorm()(LeakyReLU(0.2)(x))
x = AdaIN()([x, w])
# ADD NOISE
x = Conv2D(filter_n, 3,
name=f'gen_{res_name}_conv2')(x)
x = AddNoise()([x, noise])
x = PixelNorm()(LeakyReLU(0.2)(x))
x = AdaIN()([x, w])
return Model([input_tensor, x, noise], x,
name=f'genblock_{res}_x_{res}')
生成器模块有三个输入。对于一个 4x4 的生成器模块,输入是一个值为 1 的常量张量,并且我们跳过了上采样和卷积模块。其他两个输入分别是向量 w 和随机噪声。
训练 StyleGAN
如本节开头所提到的,从 ProGAN 到 StyleGAN 的主要变化发生在生成器上。判别器和训练细节上有一些小的差异,但对性能的影响不大。因此,我们会保持剩余的管道与 ProGAN 相同。
下图显示了我们的 StyleGAN 生成的 256x256 图像。使用了相同的风格 w,但噪声是随机生成的:

](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_07_07.jpg)
图 7.7 – 使用相同风格但不同噪声生成的肖像
我们可以看到这些面孔属于同一个人,但细节有所不同,如头发长度和头部姿势。我们还可以通过使用来自不同潜在代码的 w 来混合风格,如下图所示:

图 7.8 – 所有图像均由我们的 StyleGAN 生成。右侧的面孔是通过混合前两个面孔的风格生成的。
了解到 StyleGAN 可能比较难以训练,因此我提供了一个预训练的 256x256 模型,您可以下载使用。您可以在 Jupyter notebook 中使用小工具来进行面孔生成和风格混合的实验。
这标志着我们与 StyleGAN 的旅程的结束。
总结
在本章中,我们进入了高分辨率图像生成的领域,首先介绍了 ProGAN。ProGAN 首先在低分辨率图像上进行训练,然后再过渡到更高分辨率的图像。通过逐步扩展网络,网络训练变得更加稳定。这为高保真图像生成奠定了基础,因为这种从粗到精的训练方法也被其他 GAN 模型采纳。例如,pix2pixHD 有两个不同尺度的生成器,其中粗略生成器在两者共同训练之前会进行预训练。我们还学习了均衡学习率、小批量统计和像素归一化,这些技术也在 StyleGAN 中得到了应用。
在生成器中使用来自风格迁移的 AdaIN 层,不仅使得 StyleGAN 生成了更高质量的图像,还能够在混合风格时控制特征。通过在不同的尺度上注入不同的风格代码和噪声,我们可以控制图像的全局和细节部分。StyleGAN 在高清图像生成中取得了最先进的成果,并且在写作时依然是该领域的领先技术。基于风格的模型现在已成为主流架构。我们已经看到这种模型在风格迁移、图像到图像的翻译以及 StyleGAN 中得到了应用。
在下一章中,我们将探讨另一类流行的 GAN 家族,即基于注意力的模型。
第八章:第八章:用于图像生成的自注意力机制
你可能听说过一些流行的自然语言处理(NLP)模型,比如 Transformer、BERT 或 GPT-3。它们有一个共同点——它们都使用一种叫做 transformer 的架构,而该架构由自注意力模块组成。
自注意力机制在计算机视觉中得到了广泛应用,包括分类任务,这使其成为一个重要的学习主题。正如我们将在本章中学习的,自注意力帮助我们捕捉图像中的重要特征,而无需使用深层网络来获得大范围的感受野。StyleGAN 在生成人脸方面表现优秀,但在从 ImageNet 生成图像时会遇到困难。
在某种程度上,生成面孔是容易的,因为眼睛、鼻子和嘴唇的形状相似,并且在各种面孔中位置也相似。相比之下,ImageNet 的 1000 个类别包含了各种各样的物体(例如狗、卡车、鱼和枕头)和背景。因此,判别器必须更加有效地捕捉各种物体的不同特征。这正是自注意力发挥作用的地方。通过自注意力、条件批量归一化和谱归一化,我们将实现一个自注意力生成对抗网络(SAGAN),以根据给定的类别标签生成图像。
在此之后,我们将以 SAGAN 为基础,创建一个 BigGAN。我们将添加正交正则化,并改变类别嵌入的方法。BigGAN 能够在不使用类似 ProGAN 架构的情况下生成高分辨率图像,并且被认为是在类别标签条件下生成图像的最先进模型。
本章将涵盖以下主题:
-
谱归一化
-
自注意力模块
-
构建 SAGAN
-
实现 BigGAN
技术要求
Jupyter 笔记本可以在这里找到(github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter08):
-
ch8_sagan.ipynb -
ch8_big_gan.ipynb
谱归一化
谱归一化是稳定 GAN 训练的一个重要方法,已经在许多最近的先进 GAN 中得到了应用。与批量归一化或其他归一化方法不同,谱归一化是对权重进行归一化,而不是激活。谱归一化的目的是限制权重的增长,使得网络遵守 1-Lipschitz 约束。正如我们在第三章**,生成对抗网络中所学到的那样,这对于稳定 GAN 训练已被证明非常有效。
我们将重新审视 WGAN,以便更好地理解光谱归一化背后的思想。WGAN 判别器(也称为评论家)需要将其预测值保持在小的范围内,以满足 1-Lipschitz 限制。WGAN 通过简单地将权重裁剪到 [-0.01, 0.01] 范围内来实现这一点。
这不是一种可靠的方法,因为我们需要微调裁剪范围,这本身是一个超参数。如果能有一种系统化的方法在不使用超参数的情况下强制执行 1-Lipschitz 限制,那就太好了,而光谱归一化就是我们需要的工具。从本质上讲,光谱归一化通过除以权重的光谱范数来对权重进行归一化。
理解光谱范数
我们将简要回顾一些线性代数内容,以大致解释什么是光谱范数。你可能已经在矩阵理论中学习过特征值和特征向量,公式如下:

这里 A 是一个方阵,v 是特征向量,lambda 是其特征值。
我们将通过一个简单的例子来理解这些术语。假设 v 是一个位置向量 (x, y),而 A 是如下所示的线性变换:

如果我们将 A 与 v 相乘,我们将得到一个新的位置,并且方向发生变化,如下所示:

特征向量是指在 A 被应用时其方向不发生变化的向量。它们只是通过标量特征值 lambda 进行缩放。可能会有多个特征向量-特征值对。最大特征值的平方根就是矩阵的光谱范数。对于非方阵,我们需要使用数学算法,如 奇异值分解 (SVD) 来计算特征值,这在计算上可能会很昂贵。
因此,采用幂迭代方法来加速计算,并使其在神经网络训练中变得可行。让我们跳过实现光谱归一化作为 TensorFlow 中的权重约束。
实现光谱归一化
由 T. Miyato 等人于 2018 年在《生成对抗网络中的光谱归一化》论文中给出的光谱归一化数学算法看起来可能很复杂。然而,像往常一样,软件实现比数学公式更简单。
以下是执行光谱归一化的步骤:
-
卷积层中的权重形成一个四维张量,因此第一步是将其重塑为一个二维矩阵 W,其中保持权重的最后一个维度。现在,权重的形状为 (H×W, C)。
-
初始化一个向量 u,其分布为 N(0,1)。
-
在
for循环中,计算如下内容:计算 V = (WT) U,使用矩阵转置和矩阵乘法。
归一化 V,使用其 L2 范数,即 V = V/||V||2。
计算 U = WV。
归一化 U,使用其 L2 范数,即 U = U/||U||2。
-
计算光谱范数为 UTW V。
-
最后,将权重除以光谱范数。
完整的代码如下:
class SpectralNorm(tf.keras.constraints.Constraint):
def __init__(self, n_iter=5):
self.n_iter = n_iter
def call(self, input_weights):
w = tf.reshape(input_weights, (-1, input_weights.shape[-1]))
u = tf.random.normal((w.shape[0], 1))
for _ in range(self.n_iter):
v = tf.matmul(w, u, transpose_a=True)
v /= tf.norm(v)
u = tf.matmul(w, v)
u /= tf.norm(u)
spec_norm = tf.matmul(u, tf.matmul(w, v), transpose_a=True)
return input_weights/spec_norm
迭代次数是一个超参数,我发现5次就足够了。也可以实现谱归一化,使用一个变量来记住向量u,而不是从随机值开始。这应该将迭代次数减少到1。我们现在可以通过将其作为定义层时的卷积核约束来应用谱归一化,如Conv2D(3, 1, kernel_constraint=SpectralNorm())。
自注意力模块
自注意力模块随着一种名为 Transformer 的 NLP 模型的出现而变得流行。在语言翻译等 NLP 应用中,该模型通常需要逐字读取句子以理解其含义,然后生成输出。Transformer 出现之前使用的神经网络是某种变体的递归神经网络(RNN),如长短期记忆网络(LSTM)。RNN 具有内部状态,在读取句子时记住单词。
其中一个缺点是,当单词数量增加时,前面单词的梯度会消失。也就是说,句子开始部分的单词随着 RNN 读取更多单词而逐渐变得不那么重要。
Transformer 的做法不同。它一次性读取所有单词,并加权每个单词的重要性。因此,更重要的单词会获得更多的关注,这也是注意力这一名称的由来。自注意力是最先进的 NLP 模型(如 BERT 和 GPT-3)的基石。然而,本书不涉及 NLP 的内容。现在我们将看一下自注意力在 CNN 中的工作细节。
计算机视觉中的自注意力
CNN 主要由卷积层构成。对于一个 3×3 大小的卷积层,它只会查看输入激活中的 3×3=9 个特征来计算每个输出特征。它不会查看超出这个范围的像素。为了捕捉这个范围外的像素,我们可以稍微增加卷积核的大小,例如使用 5×5 或 7×7,但这相对于特征图的大小仍然较小。
我们需要下移一个网络层,使得卷积核的感受野足够大,以捕获我们想要的特征。与 RNN 一样,随着我们向下通过网络层,输入特征的相对重要性会逐渐减弱。因此,我们可以使用自注意力来查看特征图中的每个像素,并专注于我们应该关注的部分。
现在我们来看一下自注意力机制是如何工作的。自注意力的第一步是将每个输入特征映射到三个向量上,分别被称为key、query和value。在计算机视觉文献中我们不常看到这些术语,但我认为向你介绍它们会帮助你更好地理解与自注意力、Transformer 或 NLP 相关的文献。下图展示了如何从查询生成注意力图:

图 8.1 – 注意力图的示意图。(来源:H. Zhang 等,2019 年,《自注意力生成对抗网络》,https://arxiv.org/abs/1805.08318)
左侧是标有点的查询图像。接下来的五张图展示了这些查询给出的注意力图。顶部的第一个注意力图查询了兔子的一个眼睛;这个注意力图在两个眼睛周围有更多的白色区域(表示高重要性),而在其他区域则接近完全黑暗(表示低重要性)。
现在,我们将逐一讲解关键字、查询和数值的技术术语:
-
值是输入特征的表示。我们不希望自注意力模块查看每个像素,因为这样计算开销过大且不必要。相反,我们更关注输入激活的局部区域。因此,值的维度相较于输入特征有所减少,既包括激活图的大小(例如,可能会降采样以使高度和宽度更小),也包括通道数。对于卷积层的激活,通道数通过使用 1x1 卷积进行减少,空间大小则通过最大池化或平均池化来缩小。
-
关键字和查询用于计算自注意力图中特征的重要性。为了计算位置 x 的输出特征,我们取位置 x 的查询,并将其与所有位置的关键字进行比较。为了进一步说明这一点,假设我们有一张人像图片。
当网络处理人像中的一只眼睛时,它会拿取其查询(具有眼睛的语义意义),并与人像其他区域的关键字进行比对。如果其他区域的某个关键字是眼睛,那么我们就知道找到了另一只眼睛,这显然是我们需要关注的内容,以便匹配眼睛颜色。
将其转化为方程,对于特征 0,我们计算一个向量 q0 × k0, q0 × k1, q0 × k2,依此类推,直到 q0 × kN-1。然后,通过 softmax 对这些向量进行归一化,使它们的总和为 1.0,这就是我们的注意力分数。这个分数作为权重,用来执行数值的逐元素乘法,从而给出注意力输出。
SAGAN 自注意力模块基于非局部块(X. Wang 等,2018 年,Non-local Neural Networks, arxiv.org/abs/1711.07971),该模块最初是为视频分类设计的。作者在确定当前架构之前,尝试了不同的自注意力实现方式。以下图展示了 SAGAN 中的注意力模块,其中theta θ,phi φ和g分别对应于关键字、查询和数值:

图 8.2 – SAGAN 中的自注意力模块架构
深度学习中的大多数计算都是矢量化的,以提高速度性能,自注意力也不例外。为简单起见,如果忽略批处理维度,则 1×1 卷积后的激活形状为(H, W, C)。第一步是将其重塑为形状为(H×W, C)的二维矩阵,并使用θ和φ之间的矩阵乘法来计算注意力图。在 SAGAN 中使用的自注意力模块中,还有另一个 1×1 卷积,用于将通道数恢复到输入通道,并使用可学习参数进行缩放。此外,这被制成一个残差块。
实现自注意力模块
我们首先在自定义层的build()函数中定义所有的 1×1 卷积层和权重。请注意,我们使用谱归一化函数作为卷积层的核约束,具体如下:
class SelfAttention(Layer):
def __init__(self):
super(SelfAttention, self).__init__()
def build(self, input_shape):
n, h, w, c = input_shape
self.n_feats = h * w
self.conv_theta = Conv2D(c//8, 1, padding='same', kernel_constraint=SpectralNorm(), name='Conv_Theta')
self.conv_phi = Conv2D(c//8, 1, padding='same', kernel_constraint=SpectralNorm(), name='Conv_Phi')
self.conv_g = Conv2D(c//2, 1, padding='same', kernel_constraint=SpectralNorm(), name='Conv_G')
self.conv_attn_g = Conv2D(c, 1, padding='same', kernel_constraint=SpectralNorm(), name='Conv_AttnG')
self.sigma = self.add_weight(shape=[1], initializer='zeros', trainable=True, name='sigma')
这里有几点需要注意:
-
内部激活可以减少维度以提高计算速度。减少的数值是通过 SAGAN 作者的实验得到的。
-
每个卷积层之后,激活(H, W, C)被重塑为形状为(HW, C)的二维矩阵。然后我们可以对矩阵进行矩阵乘法。
下面是该层执行自注意操作的call()函数。我们首先计算theta、phi和g:
def call(self, x):
n, h, w, c = x.shape
theta = self.conv_theta(x)
theta = tf.reshape(theta, (-1, self.n_feats, theta.shape[-1]))
phi = self.conv_phi(x)
phi = tf.nn.max_pool2d(phi, ksize=2, strides=2, padding='VALID')
phi = tf.reshape(phi, (-1, self.n_feats//4, phi.shape[-1]))
g = self.conv_g(x)
g = tf.nn.max_pool2d(g, ksize=2, strides=2, padding='VALID')
g = tf.reshape(g, (-1, self.n_feats//4, g.shape[-1]))
然后我们计算注意力图如下:
attn = tf.matmul(theta, phi, transpose_b=True) attn = tf.nn.softmax(attn)
最后,我们将注意力图与查询g相乘,并继续生成最终输出:
attn_g = tf.matmul(attn, g)
attn_g = tf.reshape(attn_g, (-1, h, w, attn_g.shape[-1]))
attn_g = self.conv_attn_g(attn_g)
output = x + self.sigma * attn_g
return output
有了谱归一化和自注意力层后,我们现在可以使用它们来构建 SAGAN。
构建 SAGAN
SAGAN 具有类似 DCGAN 的简单架构。然而,它是一个条件生成对抗网络,使用类标签来生成和区分图像。在下图中,每一行的每个图像都是从不同类标签生成的:

图 8.3 – 使用不同类标签生成的 SAGAN 图像。(来源:A. Brock 等人,2018 年,“用于高保真自然图像合成的大规模 GAN 训练”,https://arxiv.org/abs/1809.11096)
在本例中,我们将使用CIFAR10数据集,该数据集包含 10 类 32x32 分辨率的图像。稍后我们将处理条件部分。现在,让我们首先完成最简单的部分 – 生成器。
构建 SAGAN 生成器
从高层次来看,SAGAN 生成器与其他 GAN 生成器差别不大:它将噪声作为输入,经过一个全连接层,然后是多个上采样和卷积块,最终达到目标图像分辨率。我们从 4×4 分辨率开始,使用三个上采样块,最终达到 32×32 的分辨率,具体如下:
def build_generator(z_dim, n_class):
DIM = 64
z = layers.Input(shape=(z_dim))
labels = layers.Input(shape=(1), dtype='int32')
x = Dense(4*4*4*DIM)(z)
x = layers.Reshape((4, 4, 4*DIM))(x)
x = layers.UpSampling2D((2,2))(x)
x = Resblock(4*DIM, n_class)(x, labels)
x = layers.UpSampling2D((2,2))(x)
x = Resblock(2*DIM, n_class)(x, labels)
x = SelfAttention()(x)
x = layers.UpSampling2D((2,2))(x)
x = Resblock(DIM, n_class)(x, labels)
output_image = tanh(Conv2D(3, 3, padding='same')(x))
return Model([z, labels], output_image, name='generator')
尽管在自注意力模块中使用了不同的激活维度,其输出的形状与输入相同。因此,它可以被插入到卷积层之后的任何位置。然而,当卷积核大小为 3×3 时,将其插入到 4×4 的分辨率可能有些过度。因此,在 SAGAN 生成器中,自注意力层仅在较高空间分辨率的阶段插入,以充分利用自注意力层。同样,对于判别器,当空间分辨率较高时,自注意力层会放置在较低的层次。
这就是生成器的全部内容,如果我们进行的是无条件图像生成。我们需要将类别标签输入生成器,这样它才能根据给定的类别生成图像。在第四章《图像到图像的转换》中,我们学习了一些常见的标签条件化方法,但 SAGAN 使用了一种更先进的方式;即,它将类别标签编码为批量归一化中的可学习参数。我们在第五章《风格迁移》中介绍了条件批量归一化,现在我们将在 SAGAN 中实现它。
条件批量归一化
在本书的很多章节中,我们一直在抱怨 GAN 中使用批量归一化的缺点。在CIFAR10中,有 10 个类别:其中 6 个是动物(鸟、猫、鹿、狗、青蛙和马),4 个是交通工具(飞机、汽车、船和卡车)。显然,它们看起来差异很大——交通工具通常有硬直的边缘,而动物的边缘则更弯曲且质感较软。
正如我们在风格迁移中所学到的,激活统计量决定了图像的风格。因此,混合批量统计量可以生成看起来既像动物又像交通工具的图像——例如,一只形状像汽车的猫。这是因为批量归一化对一个包含不同类别的整个批次仅使用一个 gamma 和一个 beta。如果我们对每个风格(类别)都有一个 gamma 和一个 beta,那么这个问题就得到了解决,这正是条件批量归一化的核心所在。它为每个类别提供一个 gamma 和一个 beta,因此在CIFAR10的每一层中,对于 10 个类别来说,共有 10 个 betas 和 10 个 gammas。
我们现在可以按照以下方式构造条件批量归一化所需的变量:
-
一个形状为(10, C)的 gamma 和 beta,其中C是激活通道数。
-
具有形状(1, 1, 1, C)的移动均值和方差。在训练中,均值和方差是从一个小批量中计算得出的;在推理时,我们使用在训练过程中累积的移动平均值。它们的形状会使得算术操作可以广播到 N、H 和 W 维度。
以下是条件批量归一化的代码:
class ConditionBatchNorm(Layer):
def build(self, input_shape):
self.input_size = input_shape
n, h, w, c = input_shape
self.gamma = self.add_weight( shape=[self.n_class, c], initializer='ones', trainable=True, name='gamma')
self.beta = self.add_weight( shape=[self.n_class, c], initializer='zeros', trainable=True, name='beta')
self.moving_mean = self.add_weight(shape=[1, 1, 1, c], initializer='zeros', trainable=False, name='moving_mean')
self.moving_var = self.add_weight(shape=[1, 1, 1, c], initializer='ones',
trainable=False, name='moving_var')
当我们运行条件批量归一化时,我们会为标签检索正确的beta和gamma值。这是通过tf.gather(self.beta, labels)实现的,在概念上等同于beta = self.beta[labels],如下所示:
def call(self, x, labels, training=False):
beta = tf.gather(self.beta, labels)
beta = tf.expand_dims(beta, 1)
gamma = tf.gather(self.gamma, labels)
gamma = tf.expand_dims(gamma, 1)
除此之外,其余的代码与批量归一化相同。现在,我们可以将条件批量归一化放置在生成器的残差块中:
class Resblock(Layer):
def build(self, input_shape):
input_filter = input_shape[-1]
self.conv_1 = Conv2D(self.filters, 3, padding='same', name='conv2d_1')
self.conv_2 = Conv2D(self.filters, 3, padding='same', name='conv2d_2')
self.cbn_1 = ConditionBatchNorm(self.n_class)
self.cbn_2 = ConditionBatchNorm(self.n_class)
self.learned_skip = False
if self.filters != input_filter:
self.learned_skip = True
self.conv_3 = Conv2D(self.filters, 1, padding='same', name='conv2d_3')
self.cbn_3 = ConditionBatchNorm(self.n_class)
以下是条件批量归一化的前向传递运行时代码:
def call(self, input_tensor, labels):
x = self.conv_1(input_tensor)
x = self.cbn_1(x, labels)
x = tf.nn.leaky_relu(x, 0.2)
x = self.conv_2(x)
x = self.cbn_2(x, labels)
x = tf.nn.leaky_relu(x, 0.2)
if self.learned_skip:
skip = self.conv_3(input_tensor)
skip = self.cbn_3(skip, labels)
skip = tf.nn.leaky_relu(skip, 0.2)
else:
skip = input_tensor
output = skip + x
return output
判别器的残差块与生成器的类似,但有一些差异,具体列举如下:
-
这里没有归一化。
-
下采样发生在残差块内部,采用平均池化方式。
因此,我们不会展示判别器残差块的代码。我们现在可以继续讨论最后的构建块——判别器。
构建判别器
判别器也使用自注意力层,并将其放置在靠近输入层的位置,以捕捉较大的激活图。由于它是一个条件生成对抗网络(cGAN),我们还将在判别器中使用标签,以确保生成器生成的图像与类别匹配。将标签信息纳入模型的一般方法是,首先将标签投影到嵌入空间,然后在输入层或任何内部层使用该嵌入。
有两种常见的将嵌入与激活合并的方法——连接和逐元素乘法。SAGAN 使用的架构类似于 T. Miyato 和 M. Koyama 在其 2018 年《带投影判别器的 cGANs》中提出的投影模型,正如下图右下方所示:

图 8.4 – 比较几种常见的将标签作为条件整合到判别器中的方法。(d)是 SAGAN 中使用的方法。(改图自 T. Miyato 和 M. Koyama 的 2018 年《带投影判别器的 cGANs》,https://arxiv.org/abs/1802.05637)
标签首先被投影到嵌入空间,然后我们与激活值进行逐元素乘法运算,紧接着在密集层(ψ图示中)之前执行。这一结果会加到密集层的输出上,从而给出最终的预测,如下所示:
def build_discriminator(n_class):
DIM = 64
input_image = Input(shape=IMAGE_SHAPE)
input_labels = Input(shape=(1))
embedding = Embedding(n_class, 4*DIM)(input_labels)
embedding = Flatten()(embedding)
x = ResblockDown(DIM)(input_image) # 16
x = SelfAttention()(x)
x = ResblockDown(2*DIM)(x) # 8
x = ResblockDown(4*DIM)(x) # 4
x = ResblockDown(4*DIM, False)(x) # 4
x = tf.reduce_sum(x, (1, 2))
embedded_x = tf.reduce_sum(x * embedding, axis=1, keepdims=True)
output = Dense(1)(x)
output += embedded_x
return Model([input_image, input_labels], output, name='discriminator')
模型定义完成后,我们可以开始训练 SAGAN。
训练 SAGAN
我们将使用标准的 GAN 训练流程。损失函数是CIFAR10,它包含了 32×32 大小的小图像,训练相对稳定且快速。原始的 SAGAN 是为 128×128 的图像分辨率设计的,但与我们使用的其他训练集相比,这个分辨率仍然较小。在下一部分,我们将讨论一些对 SAGAN 的改进,以便在更大的数据集和更大图像尺寸上进行训练。
实现 BigGAN
BigGAN 是 SAGAN 的改进版本。BigGAN 显著提高了图像分辨率,从 128×128 提升到 512×512,而且在没有层次逐步增长的情况下完成了这一点!以下是一些由 BigGAN 生成的示例图像:

图 8.5 – BigGAN 在 512x512 分辨率下生成的类别条件样本(来源:A. Brock 等人,2018 年,“大规模 GAN 训练用于高保真自然图像合成”,https://arxiv.org/abs/1809.11096)
BigGAN 被认为是最先进的类别条件 GAN。接下来我们将查看这些变化,并修改 SAGAN 代码,打造我们的 BigGAN。
扩展 GAN
较早的 GAN 通常使用小批量大小,因为这能产生更高质量的图像。现在我们知道,质量问题是由批量归一化中使用的批量统计数据引起的,而这一问题通过使用其他归一化技术得以解决。尽管如此,批量大小仍然较小,因为它受限于 GPU 内存的物理约束。
然而,作为谷歌的一部分有其优势:创建 BigGAN 的 DeepMind 团队拥有他们所需的所有资源。通过实验,他们发现,扩大 GAN 的规模有助于产生更好的结果。在 BigGAN 训练中,使用的批量大小是 SAGAN 的八倍;卷积通道的数量也提高了 50%。这就是 BigGAN 名称的来源:更大证明更好。
事实上,SAGAN 的增强是 BigGAN 性能优越的主要原因,具体总结在下表中:

图 8.6 – 通过向 SAGAN 基线添加特征,改善了 Frechet Inception Distance(FID)和 Inception Score(IS)。配置列显示了前一行配置中添加的特征。括号中的数字表示相较前一行的改进。
表格显示了 BigGAN 在 ImageNet 上训练时的表现。Frechet Inception Distance(FID)衡量类别多样性(值越低越好),而Inception Score(IS)表示图像质量(值越高越好)。左侧是网络的配置,从 SAGAN 基准开始,逐行增加新特性。我们可以看到,最大的改进来自于增加批量大小。这对于提高 FID 是有意义的,因为批量大小为 2,048 大于 1,000 的类别数,使得 GAN 不太容易过拟合到较少的类别。
增加通道大小也带来了显著的改进。其他三个特性只带来了小幅改进。因此,如果你没有多个可以适应大型网络和批量大小的 GPU,那么你应该坚持使用 SAGAN。如果你确实有这样的 GPU,或者只是想了解这些功能的升级,那我们就继续吧!
跳过潜在向量
传统上,潜在向量z进入生成器的第一个密集层,然后依次经过卷积层和上采样层。虽然 StyleGAN 也有一个潜在向量,仅输入到其生成器的第一层,但它有另一个来源的随机噪声,输入到每个分辨率的激活图中。这允许在不同的分辨率级别上控制风格。
通过将这两个思想合并,BigGAN 将潜在向量分割成若干块,每块分别输入到生成器中的不同残差块。稍后我们将看到这如何与类别标签一起拼接,用于条件批归一化。除了默认的 BigGAN 外,还有一种配置称为 BigGAN-deep,其深度是默认版本的四倍。下图显示了它们在拼接标签和输入噪声时的差异。我们将实现左侧的 BigGAN:

图 8.7 – 生成器的两种配置(重绘自 A. Brock 等人,2018 年,"Large Scale GAN Training for High Fidelity Natural Image Synthesis," https://arxiv.org/abs/1809.11096)
我们现在来看看 BigGAN 如何在条件批归一化中减少嵌入的大小。
共享类别嵌入
在 SAGAN 的条件批归一化中,每一层的每个 beta 和 gamma 都有一个形状为[class number, channel number]的矩阵。当类别数和通道数增加时,权重的大小也会迅速增加。在 1,000 类的 ImageNet 上训练时,使用 1,024 通道的卷积层,这将导致单个归一化层中有超过 100 万个变量!
因此,BigGAN 不是使用一个 1,000×1,024 的权重矩阵,而是首先将类别投影到一个较小维度的嵌入中,例如 128,且该嵌入在所有层中共享。在条件批量归一化中,全连接层用于将类别嵌入和噪声映射到 betas 和 gammas。
以下代码片段显示了生成器中的前两层:
z_input = layers.Input(shape=(z_dim))
z = tf.split(z_input, 4, axis=1)
labels = layers.Input(shape=(1), dtype='int32')
y = Embedding(n_class, y_dim)(tf.squeeze(labels, [1]))
x = Dense(4*4*4*DIM, **g_kernel_cfg)(z[0])
x = layers.Reshape((4, 4, 4*DIM))(x)
x = layers.UpSampling2D((2,2))(x)
y_z = tf.concat((y, z[1]), axis=-1)
x = Resblock(4*DIM, n_class)(x, y_z)
具有 128 维的潜在向量首先被拆分为四个相等的部分,用于全连接层和三个分辨率的残差块。标签被投影到一个共享的嵌入中,该嵌入与z块连接并进入残差块。残差块与 SAGAN 中的保持不变,但我们将在以下代码中对条件批量归一化进行一些小的修改。我们现在通过全连接层从类别标签中生成,而不是声明 gamma 和 beta 的变量。像往常一样,我们将首先在build()中定义所需的层,如下所示:
class ConditionBatchNorm(Layer):
def build(self, input_shape):
c = input_shape[-1]
self.dense_beta = Dense(c, **g_kernel_cfg,)
self.dense_gamma = Dense(c, **g_kernel_cfg,)
self.moving_mean = self.add_weight(shape=[1, 1, 1, c], initializer='zeros', trainable=False, name='moving_mean')
self.moving_var = self.add_weight(shape=[1, 1, 1, c], initializer='ones', trainable=False, name='moving_var')
在运行时,我们将使用全连接层从共享的嵌入生成beta和gamma。然后,它们将像常规批量归一化一样使用。全连接层部分的代码片段如下所示:
def call(self, x, z_y, training=False):
beta = self.dense_beta(z_y)
gamma = self.dense_gamma(z_y)
for _ in range(2):
beta = tf.expand_dims(beta, 1)
gamma = tf.expand_dims(gamma, 1)
我们添加了全连接层,从潜在向量和标签嵌入中预测beta和gamma,这取代了较大的权重变量。
正交正则化
正交性在 BigGAN 中被广泛应用于初始化权重和作为权重正则化器。一个矩阵被称为正交的,如果它与其转置相乘会产生单位矩阵。单位矩阵是一个对角线元素为 1,其他位置元素为 0 的矩阵。正交性是一个好的特性,因为如果一个矩阵与正交矩阵相乘,它的范数不会发生变化。
在深度神经网络中,重复的矩阵乘法可能导致梯度爆炸或消失。因此,保持正交性可以改善训练。原始正交正则化的公式如下:

这里,W是重塑为矩阵的权重,beta 是一个超参数。由于这种正则化被发现具有局限性,BigGAN 使用了不同的变种:

在这个变种中,(1 – I)去除了对角线元素,它们是滤波器的点积。这样去除了滤波器范数的约束,并旨在最小化滤波器之间的成对余弦相似度。
正交性与谱归一化密切相关,两者可以在网络中共存。我们将谱归一化实现为核约束,其中权重被直接修改。权重正则化通过计算权重的损失并将损失添加到其他损失中进行反向传播,从而以间接方式对权重进行正则化。以下代码展示了如何在 TensorFlow 中编写自定义正则化器:
class OrthogonalReguralizer( tf.keras.regularizers.Regularizer):
def __init__(self, beta=1e-4):
self.beta = beta
def __call__(self, input_tensor):
c = input_tensor.shape[-1]
w = tf.reshape(input_tensor, (-1, c))
ortho_loss = tf.matmul(w, w, transpose_a=True) *\ (1 -tf.eye(c))
return self.beta * tf.norm(ortho_loss)
def get_config(self):
return {'beta': self.beta}
然后,我们可以将核初始化器、核约束和核正则化器分配给卷积层和全连接层。然而,将它们添加到每一层中会使代码显得冗长且杂乱。为了避免这种情况,我们可以将它们放入字典中,并作为关键字参数(kwargs)传递到 Keras 层中,方法如下:
g_kernel_cfg={
'kernel_initializer' : \ tf.keras.initializers.Orthogonal(),
'kernel_constraint' : SpectralNorm(),
'kernel_regularizer' : OrthogonalReguralizer()
}
Conv2D(1, 1, padding='same', **g_kernel_cfg)
正如我们之前提到的,正交正则化对提升图像质量的影响最小。1e-4的β值是通过数值计算得到的,您可能需要根据您的数据集调整它。
总结
在本章中,我们学习了一个重要的网络架构——自注意力机制。卷积层的有效性受其感受野的限制,而自注意力机制有助于捕捉包括与传统卷积层空间上距离较远的激活在内的重要特征。我们学习了如何编写自定义层并将其插入到 SAGAN 中。SAGAN 是一种最先进的类别条件 GAN。我们还实现了条件批量归一化,以学习特定于每个类别的不同可学习参数。最后,我们研究了 SAGAN 的强化版本,即 BigGAN,它在图像分辨率和类别变化方面大大超越了 SAGAN 的表现。
我们现在已经了解了大多数重要的生成对抗网络(GAN)用于图像生成的内容。如果不是全部的话。近年来,GAN 领域的两个主要组件开始受到关注——它们分别是用于 StyleGAN 的 AdaIN,如在第七章中讨论的,高保真面部生成,以及 SAGAN 中的自注意力机制。Transformer 基于自注意力机制,并已在自然语言处理(NLP)领域引发了革命,它也开始进入计算机视觉领域。因此,现在是学习基于注意力的生成模型的好时机,因为未来的 GAN 可能会采用这种方法。在下一章中,我们将运用本章末尾关于图像生成的知识来生成一段深度伪造视频。
第九章:第九章:视频合成
在前几章中,我们已经学习并构建了许多图像生成模型,包括最先进的StyleGAN和Self-Attention GAN(SAGAN)模型。你现在已经了解了生成图像所需的大部分,甚至是全部重要技术,现在我们可以进入视频生成(合成)部分。实际上,视频只是静态图像的一系列。 因此,最基本的视频生成方法是单独生成图像,并将它们按顺序组合成视频。视频合成本身是一个复杂而广泛的话题,我们不可能在一个章节中涵盖所有内容。
在本章中,我们将概述视频合成。然后,我们将实现可能是最著名的视频生成技术——deepfake,你会惊讶于其中一些看起来有多么逼真。
本章将涵盖以下内容:
-
视频合成概述
-
实现人脸图像处理
-
构建一个 deepfake 模型
-
换脸
-
使用 GANs 改进 DeepFakes
技术要求
本章的代码可以在这里访问:
github.com/PacktPublishing/Hands-On-Image-Generation-with-TensorFlow-2.0/tree/master/Chapter09
本章使用的笔记本是这个:
ch9_deepfake.ipynb
视频合成概述
假设你在看视频时门铃响了,于是你暂停了视频去开门。当你回来时,屏幕上会看到什么?一个静止的画面,所有内容都被冻结且不动。如果你按下播放按钮并迅速再次暂停,你会看到另一幅与之前非常相似但略有不同的图像。没错——当你按顺序播放一系列图像时,你得到的是视频。
我们说图像数据有三维,或者是(H, W, C);视频数据有四维,(T, H, W, C),其中T是时间(时序)维度。视频本质上只是大量的批量图像,只不过我们不能随意打乱这些图像的顺序。图像之间必须保持时间一致性;我会进一步解释这一点。
假设我们从某些视频数据集中提取图像,并训练一个无条件 GAN 来生成来自随机噪声输入的图像。正如你可以想象的那样,这些图像之间会有很大的不同。因此,使用这些图像制作的视频将是无法观看的。与图像生成一样,视频生成也可以分为无条件和条件生成。
在无条件视频合成中,模型不仅需要生成高质量的内容,还必须控制时间内容或运动的一致性。因此,输出视频对于一些简单的视频内容来说通常会非常短。无条件视频合成仍然不够成熟,无法广泛应用于实际情况。
另一方面,条件视频合成依赖于输入内容,因此生成的结果质量更高。正如我们在第四章**图像到图像的转换中学到的那样,通过 pix2pix 生成的图像几乎没有随机性。缺乏随机性在某些应用中可能是一个缺点,但在视频合成中,生成图像的一致性是一个优点。因此,许多视频合成模型都以图像或视频为条件。特别是,条件人脸视频合成已经取得了显著的成果,并在商业应用中产生了真实的影响。接下来,我们将讨论一些最常见的人脸视频合成形式。
理解人脸视频合成
最常见的人脸视频合成形式是人脸重演和人脸交换。最好通过下面的图片来解释它们之间的区别:

图 9.1 – 人脸重演与人脸交换(来源:Y. Nirkin 等人,2019 年,《FSGAN: 主体无关的人脸交换与重演》,arxiv.org/pdf/1908.05932)
顶行展示了人脸重演的工作原理。在人脸重演中,我们希望将目标视频(右侧)中脸部的表情转移到源图像(左侧)中的脸部,从而生成中间的图像。数字化木偶技术已广泛应用于计算机动画和电影制作中,演员的面部表情用来控制数字化的虚拟人物。使用人工智能进行的人脸重演有潜力使这一过程变得更加简单。底行展示了人脸交换。这次,我们希望保持目标视频的面部表情,但使用源图像中的面部。
虽然技术上有所不同,人脸重演和人脸交换是相似的。在生成的视频方面,它们都可以用来制作假视频。顾名思义,人脸交换仅交换面部而不交换头部。因此,目标面部和源面部应该具有相似的形状,以提高假视频的保真度。你可以利用这一视觉线索来区分人脸交换视频和人脸重演视频。人脸重演在技术上更具挑战性,并且它并不总是需要驱动视频;它也可以使用面部特征点或草图。我们将在下一章介绍一个这样的模型。在本章的剩余部分,我们将重点讨论如何使用 deepfake 算法实现人脸交换。
DeepFake 概述
你们中的许多人可能看过一些在线视频,其中一位演员的面部被另一个名人的面部替换。通常情况下,那位名人是演员尼古拉斯·凯奇,结果视频看起来相当搞笑。这一切始于 2017 年底,当时一位匿名用户名为deepfakes的用户在社交新闻网站 Reddit 上发布了这一算法(后来该算法也以这个用户名命名)。这非常不寻常,因为过去十年几乎所有突破性的机器学习算法都源自学术界。
人们已经使用 deepfake 算法制作了各种视频,包括一些电视广告和电影。然而,由于这些假视频可能非常逼真,它们也引发了一些伦理问题。研究人员已经证明,有可能制作假视频,让前美国总统奥巴马说出他从未说过的话。人们有充分的理由对 deepfake 感到担忧,研究人员也在设计检测这些假视频的方法。无论是为了制作搞笑视频,还是为了应对假新闻视频,你都应该了解 deepfake 的工作原理。那么,让我们开始吧!
deepfake 算法大致可以分为两个部分:
-
深度学习模型用于执行人脸图像转换。我们首先收集两个人的数据集,假设为A和B,并使用自编码器分别对其进行训练,以学习他们的潜在编码,如下图所示。这里有一个共享的编码器,但我们为不同的人使用单独的解码器。图中的上部分展示了训练架构,下部分展示了面部交换过程。
首先,A 面部(源面部)被编码成一个小的潜在面部(潜在编码)。该潜在编码包含诸如头部姿势(角度)、面部表情、眼睛睁开或闭合等面部特征。然后我们将使用解码器B将潜在编码转换为B 面部。目标是使用A 面部的姿势和表情生成B 面部:
![图 9.2 – 使用自编码器的 deepfake。(上) 使用一个编码器和两个解码器进行训练。(下) 从 A 重建 B 面部(改绘自:T.T. Nguyen 等人,2019 年,《深度学习在 deepfakes 创建和检测中的应用:综述》,https://arxiv.org/abs/1909.11573)]()
图 9.2 – 使用自编码器的 deepfake。(上) 使用一个编码器和两个解码器进行训练。(下) 从 A 重建 B 面部(改绘自:T.T. Nguyen 等人,2019 年,《深度学习在 deepfakes 创建和检测中的应用:综述》,https://arxiv.org/abs/1909.11573)
在普通的图像生成设置中,模型基本上是我们生产所需的内容。我们所需要做的就是将输入图像发送给模型,从而生成输出图像。但 deepfake 的生产流程更为复杂,稍后会做详细描述。
-
我们需要使用一系列传统的计算机视觉技术来执行预处理和后处理,包括以下内容:
a) 人脸检测
b) 人脸关键点检测
c) 人脸对齐
d) 人脸扭曲
e) 人脸遮罩检测
下图展示了深度伪造的生产流程:

图 9.3 – DeepFake 生产流程(来源:Y. Li, S. Lyu, 2019,《通过检测人脸扭曲伪影揭露深度伪造视频》,https://arxiv.org/abs/1811.00656)
这些步骤可以分为三个阶段:
-
步骤 (a) 到 (f) 是用于从图像中提取并对齐源人脸的预处理步骤。
-
有一个人脸交换过程来生成目标 人脸 (g)。
-
步骤 (h) 到 (j) 是后处理步骤,用于 粘贴 目标人脸到图像中。
我们在第二章中学习并构建了自编码器和变分自编码器,因此对于深度伪造来说,构建一个自编码器相对容易。另一方面,许多上述的计算机视觉技术在本书中之前并未介绍过。因此,在接下来的章节中,我们将逐步实现人脸处理步骤,然后实现自编码器,最后将所有技术结合起来制作深度伪造视频。
实现人脸图像处理
我们将主要使用两个 Python 库 —— dlib 最初是一个用于机器学习的 C++ 工具包,它也有 Python 接口,是进行人脸关键点检测的首选机器学习 Python 库。本章使用的大部分图像处理代码改编自 github.com/deepfakes/faceswap。
从视频中提取图像
生产流程中的第一步是从视频中提取图像。视频由一系列按固定时间间隔分隔的图像组成。如果你检查一个视频文件的属性,可能会看到它被表示为 .mp4 视频文件,保存在目录/images 中,并使用数字序列命名——例如,image_0001.png,image_0002.png,以此类推:
ffmpeg -i video.mp4 /images/image_%04d.png
或者,我们也可以使用 OpenCV 按帧读取视频,并将每一帧保存为单独的图像文件,如下面的代码所示:
import cv2
cap = cv2.VideoCapture('video.mp4')
count = 0
while cap.isOpened():
ret,frame = cap.read()
cv2.imwrite(“images/image_%04d.png” % count, frame)
count += 1
我们将使用提取的图像进行所有后续处理,之后不再关心源视频。
检测和定位人脸
传统的计算机视觉技术通过使用方向梯度直方图(HOG)来检测人脸。可以通过计算水平和垂直方向上相邻像素的差异来求得像素图像的梯度。梯度的大小和方向告诉我们人脸的线条和角落。我们可以将 HOG 作为特征描述符来检测人脸的形状。当然,现代的方法是使用卷积神经网络(CNN),它更准确但速度较慢。
face_recognition是一个基于dlib构建的库。默认情况下,它使用dlib的 HOG 作为面部检测器,但它也有使用 CNN 的选项。使用它非常简单,如下所示:
import face_recognition
coords = face_recognition.face_locations(image, model='cnn')[0]
这将返回一个包含每个面部在图像中检测到的坐标的列表。在我们的代码中,我们假设图像中只有一个面部。返回的坐标是css格式(上、右、下、左),因此我们需要额外的步骤将它们转换为dlib.rectangle对象,以便传递给dlib的面部标志点检测器,代码如下:
def _css_to_rect(css):
return dlib.rectangle(css[3], css[0], css[1], css[2])
face_coords = _css_to_rect(coords)
我们可以从dlib.rectangle中读取边界框坐标,并按如下方式从图像中裁剪面部:
def crop_face(image, coords, pad=0):
x_min = coords.left() - pad
x_max = coords.right() + pad
y_min = coords.top() - pad
y_max = coords.bottom() + pad
return image[y_min:y_max, x_min:x_max]
如果在图像中检测到面部,我们可以继续进行下一步,检测面部标志点。
检测面部标志点
dlib模型:

图 9.4 – dlib 面部标志点的 68 个点,包括下巴、眉毛、鼻梁、鼻尖、眼睛和嘴唇
dlib使得面部标志点检测变得容易。我们只需下载并加载模型到dlib中,像下面的代码片段一样使用它:
predictor = dlib.shape_predictor( 'shape_predictor_68_face_landmarks.dat')
face_shape = predictor(face_image, face_coords)
注意
我们还将面部坐标传递给预测器,告诉它面部的位置。这意味着,我们在调用函数之前不需要裁剪面部。
面部标志点在机器学习问题中非常有用。例如,如果我们想知道一个人的面部表情,可以使用嘴唇关键点作为输入特征,传递给机器学习算法来检测嘴巴是否张开。这比查看图像中的每个像素更有效和高效。我们还可以使用面部标志点来估计头部姿势。
在 DeepFake 中,我们使用面部标志点进行面部对齐,稍后我会解释。 在此之前,我们需要将lib格式的标志点转换为 NumPy 数组:
def shape_to_np(shape):
coords = []
for i in range(0, shape.num_parts):
coords.append((shape.part(i).x, shape.part(i).y))
return np.array(coords)
face_shape = shape_to_np(face_shape)
现在我们拥有了进行面部对齐所需的一切。
对齐面部
自然,视频中的面部会出现各种姿势,比如向左看或张嘴。为了使自编码器更容易学习,我们将面部对齐到裁剪图像的中心,正对着镜头。这就是dlib的面部标志点,除了下巴的前 18 个点。这是因为人们的下巴形状差异很大,这可能会影响对齐的结果,因此它们不作为参考。
平均面孔
如果你还记得,我们在第一章《使用 TensorFlow 进行图像生成入门》中查看了平均面孔。它们是通过直接从数据集中采样生成的,因此与dlib中使用的方法并不完全相同。不管怎样,如果你忘记了平均面孔的样子,随时可以去看看。
我们需要对面部执行以下操作,将其与平均面孔的位置和角度对齐:
-
旋转
-
缩放
-
平移(位置变化)
这些操作可以通过一个 2×3 的仿射变换矩阵来表示。仿射矩阵 M 由矩阵 A 和 B 组成,如下方的方程所示:

矩阵 A 包含线性变换(缩放和旋转)的参数,而矩阵 B 用于平移。深度伪造使用 S. Umeyama 的算法来估计这些参数。该算法的源代码包含在我已经上传到 GitHub 仓库的一个文件中。我们通过传递检测到的面部特征点和均值面部特征点来调用该函数,如下方的代码所示。如前所述,我们省略了下巴的特征点,因为它们不包含在均值面中:
from umeyama import umeyama def get_align_mat(face_landmarks): return umeyama(face_landmarks[17:], \ mean_landmarks, False)[0:2]affine_matrix = get_align_mat(face_image)
我们现在可以将仿射矩阵传递给 cv2.warpAffine() 来执行仿射变换,如下方的代码所示:
def align_face(face_image, affine_matrix, size, padding=50):
affine_matrix = affine_matrix * \ (size[0] - 2 * padding)
affine_matrix[:, 2] += padding
aligned_face = cv2.warpAffine(face_image, affine_matrix, (size, size)) return aligned_face
以下图展示了对齐前后的面部:

图 9.5 – (左)作者的面部特征点和面部检测边框。(右)对齐后的面部
图中的边框显示了面部检测的效果。左侧的图片也标记了面部特征点。右侧是对齐后的面部。我们可以看到,面部已经被放大以适应均值面部。实际上,对齐后的输出将面部放大,覆盖了眉毛和下巴之间的区域。我加入了一些填充,使图像稍微缩小一些,以便包含边框在最终图像中。从边框可以看到,面部已经旋转,使其看起来是垂直的。接下来,我们将学习最后一个图像预处理步骤:面部扭曲。
面部扭曲
我们需要两张图像来训练自编码器,一张是输入图像,另一张是目标图像。在深度伪造中,目标图像是对齐后的面部,而输入图像是经过扭曲处理的对齐面部版本。图像中的面部在我们在前一节中实现的仿射变换之后并没有改变形状,但通过扭曲(例如,扭曲面部的一侧)可以改变面部的形状。深度伪造通过扭曲面部来模拟真实视频中面部姿态的多样性,作为数据增强。
在图像处理中,变换是将源图像中一个像素映射到目标图像中不同位置的过程。例如,平移和旋转是位置和角度变化的一对一映射,但大小和形状保持不变。对于扭曲,映射可以是不规则的,同一个点可能被映射到多个点,从而产生扭曲和弯曲的效果。下图显示了将图像从 256×256 尺寸扭曲到 64×64 的映射示例:

图 9.6 – 显示扭曲的映射
我们将进行一些随机扭曲,稍微扭曲面部图像,但不会造成严重的变形。以下代码展示了如何进行面部扭曲。你不必理解每一行代码,知道它使用之前描述的映射将面部扭曲为较小维度即可:
coverage = 200 range_ = numpy.linspace(128 - coverage//2, 128 + coverage//2, 5)mapx = numpy.broadcast_to(range_, (5, 5))
mapy = mapx.T
mapx = mapx + numpy.random.normal(size=(5, 5), scale=5)
mapy = mapy + numpy.random.normal(size=(5, 5), scale=5)
interp_mapx = cv2.resize(mapx, (80, 80))\ [8:72, 8:72].astype('float32')
interp_mapy = cv2.resize(mapy, (80, 80))[8:72,\ 8:72].astype('float32')
warped_image = cv2.remap(image, interp_mapx, interp_mapy, cv2.INTER_LINEAR)
我猜大多数人认为深伪造只是一个深度神经网络,但并没有意识到其中涉及了许多图像处理步骤。幸运的是,OpenCV 和 dlib 让这一切变得简单。现在,我们可以继续构建整个深度神经网络模型。
构建 DeepFake 模型
原始深伪造使用的深度学习模型是基于自编码器的。总共有两个自编码器,每个面部领域一个。它们共享相同的编码器,因此模型中总共有一个编码器和两个解码器。自编码器期望输入和输出图像大小均为 64×64。现在,让我们来构建编码器。
构建编码器
正如我们在上一章所学,编码器负责将高维图像转换为低维表示。我们将首先编写一个函数来封装卷积层;在下采样过程中使用 Leaky ReLU 激活:
def downsample(filters):
return Sequential([
Conv2D(filters, kernel_size=5, strides=2, padding='same'),
LeakyReLU(0.1)])
在常规自编码器实现中,编码器的输出是一个大约大小为 100 到 200 的 1D 向量,但深伪造使用了更大的尺寸 1,024。此外,它将 1D 潜在向量重塑并放大回 3D 激活。因此,编码器的输出不是一个大小为 (1,024) 的 1D 向量,而是一个大小为 (8, 8, 512) 的张量,如下代码所示:
def Encoder(z_dim=1024):
inputs = Input(shape=IMAGE_SHAPE)
x = inputs
x = downsample(128)(x)
x = downsample(256)(x)
x = downsample(512)(x)
x = downsample(1024)(x)
x = Flatten()(x)
x = Dense(z_dim)(x)
x = Dense(4 * 4 * 1024)(x)
x = Reshape((4, 4, 1024))(x)
x = UpSampling2D((2,2))(x)
out = Conv2D(512, kernel_size=3, strides=1, padding='same')(x)
return Model(inputs=inputs, outputs=out, name='encoder')
我们可以看到,编码器可以分为三个阶段:
-
模型中包含卷积层,将一个
(64, 64, 3)的图像逐步降维至(4, 4, 1024)。 -
有两个全连接层。第一个生成一个大小为
1024的潜在向量,第二个将其投影到更高维度,并将其重塑为(4, 4, 1024)。 -
上采样和卷积层将输出调整为大小
(8, 8, 512)。
通过查看以下模型总结,可以更好地理解这一点:

图 9.7 – 模型总结
下一步是构建解码器。
构建解码器
解码器的输入来自编码器的输出,因此它期望一个大小为 (8, 8, 512) 的张量。我们使用多个上采样层逐步将激活值放大,最终达到目标图像尺寸 (64, 64, 3):
-
与之前类似,我们首先编写一个上采样模块的函数,其中包含上采样函数、卷积层和 Leaky ReLU,如下代码所示:
def upsample(filters, name=''): return Sequential([ UpSampling2D((2,2)), Conv2D(filters, kernel_size=3, strides=1, padding='same'), LeakyReLU(0.1) ], name=name) -
然后我们将上采样模块堆叠在一起。最后一层是一个卷积层,将通道数调整为
3,以匹配 RGB 颜色通道:def Decoder(input_shape=(8, 8 ,512)): inputs = Input(shape=input_shape) x = inputs x = upsample(256,”Upsample_1”)(x) x = upsample(128,”Upsample_2”)(x) x = upsample(64,”Upsample_3”)(x) out = Conv2D(filters=3, kernel_size=5, padding='same', activation='sigmoid')(x) return Model(inputs=inputs, outputs=out, name='decoder')解码器模型总结如下:

图 9.8 – 解码器的 Keras 模型总结
接下来,我们将把编码器和解码器组合在一起,构建自编码器。
训练自编码器
如前所述,DeepFake 模型由两个共享相同编码器的自编码器组成。构建自编码器的第一步是实例化编码器和解码器:
class deepfake:
def __init__(self, z_dim=1024):
self.encoder = Encoder(z_dim)
self.decoder_a = Decoder()
self.decoder_b = Decoder()
然后,我们通过将编码器与相应的解码器连接在一起来构建两个独立的自编码器,如下所示:
x = Input(shape=IMAGE_SHAPE)
self.ae_a = Model(x, self.decoder_a(self.encoder(x)),
name=”Autoencoder_A”)
self.ae_b = Model(x, self.decoder_b(self.encoder(x)),
name=”Autoencoder_B”)
optimizer = Adam(5e-5, beta_1=0.5, beta_2=0.999)
self.ae_a.compile(optimizer=optimizer, loss='mae')
self.ae_b.compile(optimizer=optimizer, loss='mae')
下一步是准备训练数据集。尽管自编码器的输入图像大小为 64×64,但图像预处理管道要求图像大小为 256×256。每个面部领域大约需要 300 张图像。GitHub 仓库中有一个链接,您可以通过它下载一些准备好的图像。
另外,您还可以通过使用我们之前学习的图像处理技术,从收集的图像或视频中裁剪面部来自己创建数据集。数据集中的面部不需要对齐,因为对齐将在图像预处理管道中执行。图像预处理生成器将返回两张图像——一张对齐的面部图像和一张扭曲版本,分辨率都是 64×64\。
现在,我们可以将这两个生成器传递给train_step()来训练自编码器模型,如下所示:
def train_step(self, gen_a, gen_b):
warped_a, target_a = next(gen_a)
warped_b, target_b = next(gen_b)
loss_a = self.ae_a.train_on_batch(warped_a, target_a)
loss_b = self.ae_b.train_on_batch(warped_b, target_b)
return loss_a, loss_b
编写和训练自编码器可能是深度伪造管道中最简单的部分。我们不需要太多数据;每个面部领域大约 300 张图像就足够了。当然,更多的数据会带来更好的结果。由于数据集和模型都不大,因此即使不使用 GPU,训练也可以相对快速地完成。一旦我们有了训练好的模型,最后一步就是进行面部交换。
面部交换
这就是深度伪造管道的最后一步,但我们先回顾一下整个管道。深度伪造生产管道包括三个主要阶段:
-
使用
dlib和 OpenCV 从图像中提取面部。 -
使用训练好的编码器和解码器进行面部转换。
-
将新面部替换回原始图像中。
自编码器生成的新面部是大小为 64×64 的对齐面部,因此我们需要将其扭曲到原始图像中面部的位置、大小和角度。我们将使用在步骤 1中从面部提取阶段获得的仿射矩阵。我们将像之前一样使用cv2.warpAffine,但这次使用cv2.WARP_INVERSE_MAP标志来反转图像变换的方向,如下所示:
h, w, _ = image.shape
size = 64
new_image = np.zeros_like(image, dtype=np.uint8)
new_image = cv2.warpAffine(np.array(new_face, dtype=np.uint8)
mat*size, (w, h),
new_image,
flags=cv2.WARP_INVERSE_MAP,
borderMode=cv2.BORDER_TRANSPARENT)
然而,直接将新面部粘贴到原始图像上会在边缘处产生伪影。如果新面部的任何部分(即 64×64 的方形面部)超出原始面部的边界,伪影会特别明显。为了减轻这些伪影,我们将使用面部遮罩来修剪新面部。
我们将创建的第一个遮罩是围绕原始图像中面部特征点轮廓的遮罩。以下代码首先会找到给定面部特征点的轮廓,然后用 1 填充轮廓内部,并将其作为外壳遮罩返回:
def get_hull_mask(image, landmarks):
hull = cv2.convexHull(face_shape)
hull_mask = np.zeros_like(image, dtype=float)
hull_mask = cv2.fillConvexPoly(hull_mask, hull,(1,1,1))
return hull_mask
由于 外轮廓蒙版 比新面孔的正方形大,我们需要将外轮廓蒙版修剪以适应新正方形。为此,我们可以从新面孔创建一个矩形蒙版,并将其与外轮廓蒙版相乘。以下图示为图像的蒙版示例:

图 9.9 – (从左到右)(a)原始图像(b)新面孔的矩形蒙版(c)原始面孔的外轮廓蒙版(d)合成蒙版
然后我们使用蒙版从原始图像中去除面孔,并使用以下代码将新面孔填充进去:
def apply_face(image, new_image, mask):
base_image = np.copy(image).astype(np.float32)
foreground = cv2.multiply(mask, new_image)
background = cv2.multiply(1 - mask, base_image)
output_image = cv2.add(foreground, background)
return output_image
结果生成的面孔可能仍然不完美。例如,如果两张面孔的肤色或阴影差异很大,那么我们可能需要使用更进一步、更复杂的方法来修正伪影。
这就是换脸的全过程。我们对从视频中提取的每一帧图像进行换脸处理,然后将图像转换回视频序列。实现这一过程的一个方法是使用 ffmpeg,代码如下:
ffmpeg -start_number 1 -i image_%04d.png -vcodec mpeg4 output.mp4
本章中使用的 deepfake 模型和计算机视觉技术相对基础,因为我希望它们易于理解。因此,代码可能无法生成一个逼真的假视频。如果你希望生成优质的假视频,我建议你访问 github.com/deepfakes/faceswap GitHub 仓库,这也是本章大部分代码的来源。接下来,我们将快速了解如何通过使用 GAN 改进 deepfake。
使用 GAN 改进 DeepFakes
Deepfake 自动编码器的输出图像可能有点模糊,那么我们该如何改进呢?回顾一下,deepfake 算法可以分为两个主要技术——面孔图像处理和面孔生成。后者可以看作是一个图像到图像的翻译问题,我们在第四章**《图像到图像的翻译》中学到了很多。因此,自然的做法是使用 GAN 来提高图像质量。一个有用的模型是 faceswap-GAN,接下来我们将简要介绍它。原始 deepfake 中的自动编码器通过残差块和自注意力块(参见第八章**《图像生成中的自注意力》)进行增强,并作为 faceswap-GAN 的生成器。判别器的架构如下:

图 9.10 - faceswap-GAN 的判别器架构(重绘自:https://github.com/shaoanlu/faceswap-GAN)
通过仅仅观察前面的图示,我们可以了解到很多关于判别器的信息。首先,输入张量的通道维度为6,这意味着它是由两张图像——真实图像和假图像——堆叠而成。接着,有两个自注意力层的模块。输出的形状是 8×8×1,因此每个输出特征都会关注输入图像的某些小块。换句话说,判别器是带有自注意力层的 PatchGAN。
以下图示展示了编码器和解码器的架构:

图 9.11 - faceswap-GAN 的编码器和解码器架构(重绘自:https://github.com/shaoanlu/faceswap-GAN)
编码器和解码器没有太大变化。自注意力层被添加到编码器和解码器中,并且解码器中添加了一个残差块。
训练中使用的损失函数如下:
-
最小二乘法(LS)损失是对抗损失。
-
感知损失是基于 VGG 特征的 L2 损失,用于衡量真实面孔和假面孔之间的差异。
-
L1 重建损失。
-
边缘损失是眼睛周围梯度的 L2 损失(在 x 和 y 方向上)。这有助于模型生成更逼真的眼睛。
本书中我一直试图实现的一个目标是:让你掌握大多数(如果不是全部)图像生成的基本构建块。一旦你掌握了它们,实现一个模型就像拼装乐高积木一样简单。由于我们已经熟悉了损失函数(除了边缘损失)、残差块和自注意力块,我相信你现在已经能够自己实现这个模型了,如果你愿意的话。对于感兴趣的读者,你可以参考github.com/shaoanlu/faceswap-GAN上的原始实现。
总结
恭喜!我们现在已经完成了本书中的所有编码工作。我们学会了如何使用dlib检测面部和面部特征点,如何使用 OpenCV 对面部进行变形和对齐。我们还学会了如何通过变形和遮罩来进行面部交换。事实上,我们大部分时间都在学习面部图像处理,几乎没有花时间深入学习深度学习部分。我们通过复用并修改上一章中的自动编码器代码来实现了自动编码器。
最后,我们讲解了一个通过使用 GANs 改进深伪技术的例子。faceswap-GAN 通过添加残差块、自注意力块和判别器来进行对抗训练,从而改进了深伪技术,这些内容我们在前面的章节中已经学习过了。
在下一章,也就是最后一章,我们将回顾本书中学到的技巧,并讨论在实际应用中训练 GAN 时的一些陷阱。接着,我们将介绍几种重要的 GAN 架构,重点讲解图像修复和文本到图像的合成。最后,我们将探讨一些新兴应用,如视频重定向和 3D 到 2D 的渲染。下一章没有编码内容,所以你可以放松一下,尽情享受。敬请期待!
第十章:第十章: 前路
这是本书的最后一章。我们学习并实现了许多生成模型,但还有更多模型和应用我们没有涉及,因为它们超出了本书的范围。在本章中,我们将从总结一些我们学过的重要技术开始,如优化器和激活函数、对抗损失、辅助损失、归一化和正则化。
然后,我们将探讨在实际应用中使用生成模型时常见的一些陷阱。之后,我们将介绍一些有趣的图像/视频生成模型和应用。本章没有编码内容,但你会发现,我们在本章介绍的许多新模型都是使用之前学过的技术构建的。本章还提供了一些链接,供你查阅论文和代码,进一步探索该技术。
本章将涵盖以下主题:
-
回顾 GAN
-
将你的技能付诸实践
-
图像处理
-
文本到图像
-
视频重定向
-
神经渲染
回顾 GAN
除了我们在第一章中介绍的 PixelCNN,使用 TensorFlow 进行图像生成入门,这是一个 CNN 外,其他所有我们学习过的生成模型都基于(变分)自编码器或生成对抗网络(GANs)。严格来说,GAN 不是一种网络,而是一种训练方法,利用了两个网络——生成器和判别器。我尝试将大量内容融入本书,因此信息可能会让人感到有些压倒性。接下来,我们将通过以下类别总结我们所学的重要技术:
-
优化器和激活函数
-
对抗损失
-
辅助损失
-
归一化
-
正则化
优化器和激活函数
0和第二矩设定为0.999。生成器的学习率设定为0.0001,而判别器的学习率是生成器的两到四倍。判别器是 GAN 中的关键组件,必须在生成器之前学得很好。WGAN 在训练步骤中训练判别器的次数多于生成器,另一种方法是为判别器使用更高的学习率。
另一方面,内部层的实际激活函数是带有0.01或0.02的泄漏 ReLU。生成器的输出激活函数的选择取决于图像的归一化方式,即对于像素范围[0, 1]使用 sigmoid,或者对于[-1, 1]使用 Tanh。另一方面,判别器在大多数对抗损失中使用线性输出激活函数,除了早期非饱和损失使用 sigmoid。
对抗损失
我们已经看到,自编码器可以作为 GAN 设置中的生成器。GAN 通过对抗损失(有时称为 GAN 损失)进行训练。下表列出了几种流行的对抗损失:

图 10.1 – 重要的对抗损失;σ指的是 sigmoid 函数
非饱和损失用于普通的 GAN,但由于梯度分离,它不稳定。Wasserstein 损失有理论支持,证明它在训练时更稳定。然而,许多 GAN 模型选择使用最小二乘损失,这也证明是稳定的。近年来,hinge 损失成为了许多最先进模型的首选。尚不清楚哪种损失最优。不过,在本书中,我们在许多模型中使用了最小二乘损失和 hinge 损失,它们似乎能很好地训练。所以,我建议你在设计新的 GAN 时,首先尝试这两种损失。
辅助损失
除了对抗损失,作为 GAN 训练的主要损失外,还有各种辅助损失帮助生成更好的图像。以下是其中一些:
-
重建损失 (第二章, 变分自编码器) 用于鼓励逐像素的准确性,通常是 L1 损失。
-
KL 散度损失 (第二章, 变分自编码器) 用于变分自编码器(VAE),将潜在向量转换为标准的多元正态分布。
-
循环一致性损失 (第四章, 图像到图像的翻译) 用于双向图像翻译。
-
感知损失 (第五章, 风格迁移) 衡量图像之间的高级感知和语义差异。它可以进一步细分为两种损失:
a) 特征匹配损失,通常是从 VGG 层提取的图像特征的 L2 损失。这也被称为感知损失。
b) 风格损失特征通常来自 VGG 特征,如 Gram 矩阵或激活统计信息,并使用 L2 损失计算。
归一化
层激活被归一化以稳定网络训练。归一化通常采用以下形式:


这里,x是激活值,µ是激活值的均值,σ是激活值的标准差,ε是数值稳定性的调整因子。γ和β是可学习的参数;每个激活通道都有一对。这些不同的归一化方法主要的区别在于µ和σ是如何获取的:
-
在批量归一化 (第三章**,生成对抗网络)中,均值和标准差是在批量(N)和空间(H,W)位置上计算的,换句话说,就是(N,H,W*)。
-
实例归一化 (第四章**,图像到图像的转换),现在是首选方法,仅使用空间维度(H,W*)。
-
自适应实例归一化(AdaIN)(第五章**,风格迁移)具有不同的目的,来合并内容和风格激活。它仍然使用相同的方程式,只是现在这些参数具有不同的含义。X仍然是我们从内容特征中考虑的激活值。γ和β不再是可学习的参数,而是来自风格特征的均值和标准差。与实例归一化类似,统计量是在空间维度(H,W*)上计算的。
-
空间自适应归一化(SPADE)(第六章**,AI Painter)为每个特征(像素)提供一个γ和β值,换句话说,它们的维度是(H,W,C)。它们通过在分割图上运行卷积层来分别归一化来自不同语义对象的像素。
条件批量归一化 (第八章**,用于图像生成的自注意力)与批量归一化类似,只不过γ和β*现在是多维的(LABELS,C),因此每个类别标签都有一组。
1。
正则化
除了对抗损失和归一化,稳定 GAN 训练的另一个重要因素是正则化。正则化的目的是约束网络权重的增长,以保持生成器和判别器之间的竞争。这通常通过添加使用权重的损失函数来实现。GAN 中常用的两种正则化方法旨在强制执行 1-Lipschitz 约束:
-
梯度惩罚 (第三章**,生成对抗网络)*惩罚梯度的增长,从而惩罚权重。然而,由于需要额外的反向传播来计算梯度,这一方法并不常用,且会显著减慢计算速度。
-
正交正则化 (第八章**,用于图像生成的自注意力)*旨在使权重成为正交矩阵,这是因为如果矩阵与正交矩阵相乘,矩阵的范数不会发生变化。这可以避免梯度消失或爆炸的问题。
-
谱归一化 (第八章,图像生成的自注意力机制) 通过将层的权重除以其谱范数来进行归一化。这与常规的正则化方法不同,后者通过损失函数来约束权重。谱归一化在计算上高效,易于实现,并且与训练损失无关。在设计新的 GAN 时,你应该使用它。
这就是 GAN 技术总结的结束。接下来,我们将探讨我们尚未探索过的新应用和模型。
将你的技能付诸实践
现在,你可以应用所学的技能来实施自己的图像生成项目。在开始之前,有一些陷阱你需要留意,同时也有一些实用的建议你可以遵循。
不要相信你所阅读的一切
一篇新的学术论文发布,展示了他们模型生成的惊人图像!对此要保持怀疑态度。通常,这些论文挑选了最好的结果进行展示,并隐藏了失败的示例。此外,图像会被缩小以适应论文版面,因此图像中的伪影可能在论文中不可见。在投入时间使用或重新实现论文中的信息之前,尽量寻找其他声称结果的资源。这可以是作者的网站或 GitHub 仓库,可能包含原始的高清图像和视频。
你的 GPU 有多大?
深度学习模型,尤其是 GANs,计算成本高昂。许多最先进的结果是在多个 GPU 上训练大量数据数周后产生的。你几乎肯定需要这种计算能力才能尝试重现这些结果。因此,注意论文中使用的计算资源,以避免失望。
如果你不介意等待,你可以使用一张 GPU 并等待四倍的时间(假设原始实现使用了四张 GPU)。然而,这通常意味着批量大小也需要减少四倍,这可能会影响结果和收敛速度。你可能需要减少学习率以适应减少的批量大小,这进一步拖慢了训练时间。
使用现有模型构建你的模型
著名的 AI 科学家安德烈·卡尔帕西(Dr. Andrej Karpathy)在 2019 年的一次讲座中说过 “不要做英雄。” 当你想创建一个 AI 项目时,不要自己发明模型;永远从现有模型开始。研究人员花费了大量的时间和资源来创建模型。在这个过程中,他们可能也加入了一些技巧。因此,你应该从现有模型开始,然后根据你的需求进行调整或在其基础上构建。
正如我们在本书中所看到的,大多数最先进的模型并不是凭空出现的,而是建立在现有的模型或技术之上。通常,网上可以找到模型的实现,要么是作者官方发布的,要么是机器学习爱好者在不同框架下的重新实现。一个有用的在线资源是paperswithcode.com/网站。
了解模型的局限性
我认识的许多 AI 公司并不会创建自己的模型架构,原因就在于前面提到的几点。那么,学习如何编写 TensorFlow 代码来创建图像生成模型的意义何在呢?首先的答案是,通过从零开始编写代码,您现在能理解各层和模型的构成以及它们的局限性。假设一个不了解 GANs 的人,对 AI 能做的事情感到惊讶,于是他们下载了 pix2pix,试图在自己的数据集上训练,将猫的图像转换成树木的图像。但这并没有成功,他们也不明白为什么会失败;对他们来说,AI 是一个黑盒。
作为受过人工智能教育的人,我们知道 pix2pix 需要成对的图像数据集,而对于未配对的数据集,我们需要使用 CycleGAN。您所学到的知识将帮助您选择合适的模型和数据使用。此外,您现在会知道如何根据不同的图像大小、不同的条件等调整模型架构。
我们现在已经看到了使用生成模型的一些常见陷阱。接下来,我们将探讨一些有趣的应用和模型,您可以利用生成模型进行操作。
图像处理
在所有图像生成模型能够完成的任务中,图像处理可能是最能为商业用途带来最佳效果的一个。在我们的语境中,图像处理指的是对现有图像应用某些变换以生成新图像。我们将在本节中探讨图像处理的三种应用——图像修复、图像压缩和图像超分辨率(ISR)。
图像修复
图像修复是填补图像中缺失像素的过程,使得结果在视觉上真实可信。它在图像编辑中有着实际应用,比如修复损坏的图像或去除遮挡物。在以下示例中,您可以看到如何使用图像修复去除背景中的人物。我们首先用白色像素填充人物,然后使用生成模型填充这些像素:
![图 10.2 – 使用 DeepFillv2 进行图像修复,去除背景中的人物(左)原始图像,(中)用白色面罩填充人物,(右)修复后的图像]
(来源:J. Yu 等,2018 年,"自由形式图像修复与门控卷积",
https://arxiv.org/abs/1806.03589)](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_10_02.jpg)
图 10.2 – 使用 DeepFillv2 进行图像填充以去除背景中的人物(左)原始图像,(中)人物被白色掩模填充,(右)恢复后的图像(来源:J. Yu 等人,2018 年,《使用门控卷积的自由形式图像填充》,arxiv.org/abs/1806.03589)
传统的图像填充方法是通过找到一个具有相似纹理的背景补丁,然后将其粘贴到缺失区域。然而,这通常只适用于小范围内简单纹理的情况。第一个为图像填充设计的 GAN 之一是上下文编码器。它的架构类似于自编码器,但在训练时除了常规的 L2 重建损失外,还会加入对抗损失。如果需要填充的区域较大,结果可能会显得模糊。
解决这一问题的一种方法是使用两个网络(粗略和精细)在不同尺度上进行训练。采用这种方法,DeepFill(J. Yu 等人,2018 年,基于上下文注意力的生成图像填充,arxiv.org/abs/1801.07892)增加了一个注意力层,以更好地捕捉来自遥远空间位置的特征。
在早期的 GAN 中,通过随机裁剪方形掩模(孔洞)来创建图像填充的数据集,但该技术并不适用于实际应用。Yu 等人提出了一个部分卷积层来创建不规则的掩模。该层包含一个像我们在 PixelCNN 中实现的掩模卷积,第一章,使用 TensorFlow 进行图像生成入门中也有介绍。以下图像示例展示了使用基于部分卷积的网络的结果:

图 10.3 – 不规则的掩模和填充后的结果(来源:G. Liu 等人,2018 年,《使用部分卷积进行不规则孔洞图像填充》,arxiv.org/abs/1804.07723)
DeepFillv2使用门控卷积来改进和概括掩模卷积。DeepFill 仅使用标准判别器来预测图像的真伪。然而,当自由形式的图像填充中存在多个孔洞时,这种方法效果不佳。因此,它使用谱归一化的 PatchGAN(SN-PatchGAN)来促进更逼真的图像填充。
以下是一些关于此主题的额外资源:
-
DeepFillv1 和 v2 的 TensorFlow v1 源代码:
github.com/JiahuiYu/generative_inpainting -
互动图像填充演示,你可以使用自己的照片进行操作:
www.nvidia.com/research/inpainting/
图像压缩
图像压缩是将图像从原始像素转换为编码数据的过程,压缩后的数据体积要小得多,方便存储或通信。例如,JPEG 文件就是一种压缩图像。当我们打开一个 JPEG 文件时,计算机会逆向压缩过程来恢复图像像素。简化的图像压缩流程如下:
-
分割:将图像分成小块,每个小块将单独处理。
-
变换:将原始像素转换为更易压缩的表示。在此阶段,通常通过去除高频内容来实现更高的压缩率,这些高频内容会使恢复后的图像变得模糊。例如,考虑一个包含[255, 250, 252, 251, ...]像素值的灰度图像段,这些像素几乎是白色的。它们之间的差异非常小,以至于人眼无法察觉,因此我们可以将所有像素转换为 255。这将使数据更容易压缩。
-
量化:使用较低的位数表示数据。例如,将一个灰度图像,其中像素值在[0, 255]之间的 256 个值,转换为黑白两个值[0, 1]。
-
符号编码用于使用一些高效的编码来编码数据。其中一种常见的编码方式是游程编码。我们可以不保存每个 8 位像素,而是只保存像素之间的差异。因此,代替保存[255, 255, 255, …]这样的白色像素,我们可以将其编码为[255] x 100,表示白色像素重复了 100 次。
通过使用更极端的量化或去除更多频率内容来实现更高的压缩率。因此,这些信息会丢失(因此,这被称为有损压缩)。下图展示了一个用于图像压缩的生成对抗网络(GAN):

图 10.4 – 生成式压缩网络。编码器 (E) 将图像映射到潜在特征 w。它通过有限的量化器 q 进行量化,以获得表示ŵ,可以编码为比特流。解码器 (G) 重建图像,D 是判别器。(来源:E. Agustsson 等,2018 年,“生成对抗网络用于极端学习图像压缩,” arxiv.org/abs/1804.02958)
一般来说,生成式压缩使用自编码器架构将图像压缩为较小的潜在代码,并通过解码器恢复。
图像超分辨率
我们已经大量使用了上采样层,以增加生成器(GAN)或解码器(自编码器)中激活的空间分辨率。它通过拉开像素并通过插值填补空隙来工作。结果,放大的图像通常会模糊。
在许多图像应用中,我们希望在保持清晰度的同时放大图像,这可以通过图像超分辨率(ISR)来实现。ISR 的目标是将图像从低分辨率(LR)提升到高分辨率(HR)。超分辨率生成对抗网络(SRGAN)(C. Ledig 等,2016 年,使用生成对抗网络进行照片级单图像超分辨率,arxiv.org/abs/1609.04802)是第一个使用 GAN 来实现这一点的网络。
SRGAN 的架构类似于 DCGAN,但使用残差块而不是普通的卷积层。它借用了风格迁移文献中的感知损失,即通过 VGG 特征计算的内容损失。回顾来看,我们知道这是一个比像素级损失更好的视觉感知质量衡量标准。现在我们可以看到自编码器在各种图像处理任务中的多功能性。类似的自编码器架构可以重新用于其他图像处理任务,如图像去噪或去模糊。接下来,我们将看看一个应用,其中模型的输入不是图像而是文字。
文本到图像
文本到图像 GAN 是条件 GAN。然而,它们并不是使用类标签作为条件,而是使用文字作为生成图像的条件。在早期的实践中,GAN 使用词嵌入作为条件输入到生成器和判别器中。它们的架构类似于我们在第四章《图像到图像翻译》中学到的条件 GAN。不同之处仅在于,文本的嵌入是通过自然语言处理(NLP)预处理管道生成的。下图展示了文本条件 GAN 的架构:

图 10.5 – 文本条件卷积 GAN 架构,其中文本编码由生成器和判别器共同使用(重绘自:S. Reed 等,2016 年,“生成对抗文本到图像合成”,arxiv.org/abs/1605.05396)
与普通的 GAN 类似,生成的高分辨率图像往往会模糊。StackGAN通过将两个网络堆叠在一起解决了这个问题。下图展示了 StackGAN 在不同阶段生成的文本和图像,与普通 GAN 的对比:

图 10.6 – StackGAN 在不同生成器阶段生成的图像(来源:H. Zhang 等,2017 年,"StackGAN:通过堆叠生成对抗网络生成文本到照片真实图像",arxiv.org/abs/1612.03242)
第一个生成器从词嵌入中生成低分辨率图像。第二个生成器则将生成的图像和词嵌入作为输入条件,生成精细化的图像。我们在本书中已经学到,粗到精的架构在许多高分辨率 GAN 中以不同形式出现。
AttnGAN(T. Xu 等,2017 年,AttnGAN:通过注意力生成对抗网络进行细粒度文本到图像生成,见于 arxiv.org/abs/1711.10485)通过使用注意力模块进一步改进了文本到图像的合成。该注意力模块不同于在 SAGAN 中使用的(第八章 ,图像生成的自注意力),但原理相同。在生成器的每个阶段开始时,注意力模块有两个输入——词特征和图像特征。它在从粗到精的生成器过程中,学习如何关注不同的词和图像区域。在此之后,大多数文本到图像模型都具有某种形式的注意力机制。
文本到图像仍然是一个未解决的问题,它仍然难以从文本生成复杂的真实世界图像。如我们在下图中看到的,生成的图像仍然远未完美。研究人员开始引入最近的自然语言处理(NLP)进展,以提高文本到图像的表现:

图 10.7 – 从 MS-COCO 数据集中给定标题生成的图像示例(A)数据集中的原始图像及其图像标题(B)由 StackGAN + 对象路径生成的图像(C)由 StackGAN 生成的图像(来源:T. Hinz 等,2019 年,"在空间上生成多个物体",arxiv.org/abs/1901.00686)
接下来,我们将介绍视频重定向的激动人心的应用。
视频重定向
视频合成是一个广泛的术语,用于描述所有形式的视频生成。这包括从随机噪声或文字生成视频、为黑白视频上色等等,类似于图像生成。
在这一部分,我们将讨论视频合成中的一个子领域——视频重定向。我们首先介绍两个应用——面部重现和姿势转移,然后介绍一个强大的模型,该模型利用运动来泛化视频目标。
面部重现
面部重现与面部交换一起在第九章,《视频合成》中介绍。视频合成中的面部重现涉及将驱动视频的面部表情转移到目标视频中的面部。这在动画制作和电影制作中非常有用。最近,Zakharov 等提出了一种生成模型,只需要少量目标 2D 图像。这是通过使用面部地标作为中间特征来完成的,如下图所示:

(来源:E. Zakharov 等,2019 年,《少样本对抗学习真实神经对话头模型》,arxiv.org/abs/1905.08233)](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_10_08.jpg)
图 10.8 – 将目标图像的面部表情转移到源图像(来源:E. Zakharov 等,2019 年,《少样本对抗学习真实神经对话头模型》,arxiv.org/abs/1905.08233)
我们简要地看一下模型架构,如下图所示:

(来源:E. Zakharov 等,2019 年,《少样本对抗学习真实神经对话头模型》,arxiv.org/abs/1905.08233)](https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/hsn-img-gen-tf/img/B14538_10_09.jpg)
图 10.9 – 少样本对抗学习的架构(来源:E. Zakharov 等,2019 年,《少样本对抗学习真实神经对话头模型》,arxiv.org/abs/1905.08233)
在前面的图示中,你应该注意到AdaIN,我们立刻知道它是一个基于风格的模型。因此,我们可以看到顶部的地标是内容(目标的面部形状和姿势),而风格(源的面部特征和表情)则是从嵌入器中提取的。生成器然后使用 AdaIN 将内容和风格融合,以重建面部。
最近,NVIDIA 部署了一个类似的模型,用于大幅降低远程视频会议的视频传输比特率。你可以访问他们的博客,blogs.nvidia.com/blog/2020/10/05/gan-video-conferencing-maxine/,了解他们如何在实际部署中使用许多 AI 技术,如 ISR、面部对齐和面部重现。接下来,我们将探讨如何利用 AI 来转移一个人的姿势。
姿势转移
姿势转移与面部重演类似,只是这次它将转移身体(和头部)姿势。执行姿势转移的方法有很多种,但所有方法都涉及使用身体关节(也称为关键点)作为特征。下图展示了从条件图像和目标姿势生成的图像示例:

图 10.10 – 将目标姿势转移到条件图像上(来源:Z. Zhu 等,2019 年,“用于人物图像生成的渐进式姿势注意力转移”,arxiv.org/abs/1904.03349)
姿势转移有许多潜在应用,包括从单张二维图像生成时尚模特视频。与面部重演相比,这项任务更具挑战性,因为人体姿势的种类繁多。接下来,我们将看看一种能够推广面部重演和姿势转移的动作模型。
动作转移
前一节介绍的面部重演和姿势转移模型需要物体特定的先验知识,换句话说,就是面部地标和人体姿势关键点。这些特征通常通过使用大量数据训练的独立模型来提取,这些数据的获取和标注往往是昂贵的。
最近,出现了一种与物体无关的模型,称为一阶动作模型(A. Siarohin 等,2019 年,《用于图像动画的一阶动作模型》,arxiv.org/abs/2003.00196)。该模型因其易用性迅速获得了广泛关注,因为它不需要大量注释的训练数据。下图展示了该模型的整体架构,利用了视频帧中的运动信息:

图 10.12 – 神经渲染的两种常见框架(图源:M-Y. Liu 等,2020,《生成对抗网络在图像和视频合成中的算法与应用》,arxiv.org/abs/2008.02793)
图 (b) 显示了一个框架,该框架使用三维数据作为输入,并进行三维可微操作,例如三维卷积。除了三维多边形,三维数据还可以以点云的形式存在,点云可以通过激光雷达/雷达或计算机视觉技术(如运动结构重建)获得。点云由三维空间中的点组成,描绘物体的表面。三维到二维深度网络框架的一个应用是将点云渲染为二维图像,如下图所示,其中输入是来自房间的点云:

图 10.13 – (左)三维点云到二维渲染,(中)点云合成图,(右)真实值(来源:F. Pittaluga 等,2019,《通过反转运动结构重建揭示场景》,arxiv.org/abs/1904.03303)
我们还可以执行逆向渲染,即从 2D 图像渲染到 3D 对象。这通常被称为逆向渲染。下图展示了 2D 到 3D 逆向渲染的示例:

图 10.14 – 给定输入的 2D 图像(第一列),模型预测出 3D 形状和纹理,并将其渲染到相同的视角(第二列)。右侧的图像展示了从三个不同视角的渲染效果。(来源:Y. Zhang 等,2020 年,“图像 GANs 与可微分渲染相结合,用于逆向图形学与可解释的 3D 神经渲染”,arxiv.org/abs/2010.09125)
Y. Zhang 等,2020 年的模型使用了两个 渲染器。一个是可微分图形渲染器,用于将 2D 渲染成 3D,这超出了本书的范围。另一个是 GAN,用于生成多视角图像数据,或者更具体地说,是 StyleGAN。有趣的是,他们选择使用 StyleGAN 的原因。作者发现,通过改变潜在代码,StyleGAN 可以生成不同视角的面部图像。然后,他们进行了广泛的研究,发现早期层中的风格控制着相机的视角,使其非常适合这一任务。这也是一个很好的例子,展示了我们如何将 2D 生成模型引入 3D 世界。
本节内容结束了我们对神经渲染的介绍。神经渲染是一个活跃的研究领域,仍有许多尚未被探索的应用案例。
总结
自 2014 年 GAN 和 VAE 的诞生以来,2D 图像生成取得了显著的进展。在实践中,生成高保真图像仍然是一个挑战,因为它需要大量的数据、计算能力和超参数调优。然而,正如 StyleGAN 所展示的那样,我们现在似乎拥有了实现这一目标的技术,尤其是在面部生成方面。
实际上,在撰写本书时,自 2018 年以来,该领域并未出现任何重大突破。通过本书,我们涵盖了所有通向 BigGAN 的重要技术。这些技术包括 AdaIN 和自注意力模块的使用,这些模块现在在视频合成等相邻领域中也已变得非常普遍。这为我们探索其他新兴的生成技术奠定了坚实的基础。
在本章中,我们回顾了我们所学的内容,并将其总结为不同的类别,例如损失函数和归一化技术。接着,我们探讨了一些训练生成模型的实用建议。最后,我们涉及了一些即将到来的技术,特别是在视频重定向领域。我相信你现在已经具备了探索这个新兴且令人兴奋的人工智能世界所需的知识、技能和信心,祝愿你在新的冒险中一切顺利。希望你喜欢阅读本书。欢迎你的反馈,它将帮助我提升写作技巧,为我的下一本书做准备。谢谢!


:对抗损失
:L1 重建损失
:KL 散度损失
:对抗损失
:噪声N(z)和编码均值之间的 L1 损失
浙公网安备 33010602011771号