26TensorFlow 2 实现 AI 换脸

传统人脸互换

在深度学习出来之前,人脸互换主要是通过对比两张脸的相似信息来进行互换。我们可以通过特征点(下图的红色点)来提取一张脸的眉毛、眼睛等特征信息,然后匹配到另外一张人脸上。如下图所示,这种实现方法不需要训练时间,每次只需要遍历所有的像素点即可。但是,这样实现的效果比较差,无法修改人脸的表情。


image.png

而深度学习却可以在不修改人脸表情的情况下,做到人脸特征替换的效果。由于视频中的人脸互换所需要的资源过多,并且视频就是由一张张图片组成的,因此本次实验只考虑图片中的人脸替换。我们会借用自编码器的核心思想,然后对 DeepFake 的源码进行解析,最后实现川普和尼古拉斯 · 凯奇的人脸互换。

数据的可视化

首先,下载实验所需要的数据集,并且完成解压。

!wget -nc "https://labfile.oss.aliyuncs.com/courses/1460/data.zip" # 下载数据集
!unzip -o "data.zip" # 解压

数据集主要由两个文件夹构成,一个文件名为 trump ,一个为 cage 。接下来,我们利用 Python 遍历这两个文件夹,并获得所有文件的路径。

import os

# 遍历directory下的所有文件,并且把他们的路径用一个列表进行返回
def get_image_paths(directory):
    return [x.path for x in os.scandir(directory) if x.name.endswith(".jpg") or x.name.endswith(".png")]

images_A = get_image_paths("trump")
images_B = get_image_paths("cage")
print("川普图片个数为 {}\n凯奇的图片个数为 {}".format(len(images_A), len(images_B)))

接下来,我们利用 Python 中的 OpenCV 库,对图片进行批量加载。

import cv2
import numpy as np

# 批量加载图片,传入的是路径集合,遍历所有的路径,并加载图片
def load_images(image_paths):
    iter_all_images = (cv2.imread(fn) for fn in image_paths)

    # iter_all_images 是一个 generator 类型,将它转换成熟知的 numpy 的列表类型并返回
    for i, image in enumerate(iter_all_images):
        if i == 0:
            # 对all_images 进行初始,并且指定格式
            all_images = np.empty(
                (len(image_paths),) + image.shape, dtype=image.dtype)
        all_images[i] = image

    return all_images

# 每个文件夹加载三张图片
A_images = load_images(images_A[0:3])
B_images = load_images(images_B[0:3])
print(A_images.shape)
print(B_images.shape)

这里我们分别加载了两个人物的前三张图片。从上面的运行结果可以看出,每张图片大小为256×256 。那么怎样才能一次性,将这些图片同时展示出来呢?核心思想便是将这 6 张图片拼在一起,形成一个512×768 的图片(一行三张,一共两行)。整体的思路如下图所示:


image.png

首先让我们来实现 stack_images 函数。为了方便以后使用,我们将 stack_images 写成一个可以将图片集合转变成一张图片的函数。

# 根据所给的维度长度,告诉调用者哪些维度应该被放入第 0 维度,哪些应该被转换为第 1 维度
# 例如 (2,3,256,256,3) 则是第 0 维,第 2 维合在一起,转换成新的图片的第0维(也就是行的个数)
# 第 1 维,第 3 维合在一起,转换成新的图片的第1维(也就是列的个数)
def get_transpose_axes(n):
    # 根据总长度的奇偶性,来制定不同的情况
    if n % 2 == 0:
        y_axes = list(range(1, n-1, 2))
        x_axes = list(range(0, n-1, 2))
    else:
        y_axes = list(range(0, n-1, 2))
        x_axes = list(range(1, n-1, 2))
    return y_axes, x_axes, [n-1]


# 可以将存储多张图片的多维集合,拼成一张图片
def stack_images(images):
    images_shape = np.array(images.shape)
    # new_axes 得到的是三个列表。[0,2],[1,3],[4] 告诉调用者新集合中的每个维度由旧集合中的哪些维度构成
    new_axes = get_transpose_axes(len(images_shape))
    new_shape = [np.prod(images_shape[x]) for x in new_axes]
    return np.transpose(
        images,
        axes=np.concatenate(new_axes)
    ).reshape(new_shape)

终于,我们可以将 A_images ,B_images 两个图片集合进行展示了。由于 OpenCV 无法在 Notebook 上进行图片的展示。因此我们只能利用 OpenCV 读取图片,再利用 Matplotlib 进行展示。

import matplotlib.pyplot as plt  # plt 用于显示图片
figure = np.concatenate([A_images, B_images], axis=0)  # 6,256,256,3
figure = figure.reshape((2, 3) + figure.shape[1:])  # 2,3,256,256,3
figure = stack_images(figure)  # 512,768,3
%matplotlib inline
# 这里需要指定利用 cv 的调色板,否则 plt 展示出来会有色差
plt.imshow(cv2.cvtColor(figure, cv2.COLOR_BGR2RGB))
plt.show()

自编码器

在讲解人脸互换所需要的神经网络之前,让我们先来了解一下人脸互换的核心思想:自编码器。
编码器与解码器
自编码器是一种用于非监督学习过程的人工神经网络。自动编码器通常由两部分构成:编码器和解码器。下面,我们通过图示来对其进行解释。

image.png

Encoder :编码器,由各种下采样的方法构成。将输入图片压缩成空间特征 (上图的 Code ),也就是对原图片进行特征提取。
Decoder :解码器,由各种上采样的方法构成。重构编码器输出的空间特征,并对其进行解码,输出新的图片。

自编码器的全过程:编码器对输入的图片进行特征提取,然后解码器对提取的特征进行解析,最后输出新的图片。
从上图可以看出,手写的数字 4 通过自编码器后,会生成一张看起来像手写字符 4 的新图片。 那么这样做有什么意义呢?其实,在对图片进行去噪的时候,我们经常会采用这种技术。

image.png

如上图所示,我们可以将加噪点后的手写字符放入自编码器中,然后以加噪点前的手写字符为目标进行训练。最终就能得到一个专门处理噪点的神经网络模型。当以后出现新的具有噪点的图片时,只需放入训练好的自编码器就可以直接进行去噪了。
根据上面的知识,我们可以发现自编码器最重要的就是编码器结构和解码器结构。实现编码器的下采样的方法有很多,比如我们熟知的池化、卷积等。但是实现解码器的上采样方法又有哪些呢?怎样才能将缩小的图像放大成原图呢?这里我们将会学习到一种叫做子像素卷积的上采样方法。
子像素卷积( Sub-pixel Convolution )
子像素卷积是一种巧妙的图像及特征图的 upscale 方法,又叫做 Pixel Shuffle(像素洗牌)。这种方法于 2016 年被 Wenzhe Shi 等人 提出。较之前的上采样算法,子像素卷积在速度和质量上都有明显的提升。
子像素卷积的结构(主要观察后面的彩色部分)如下所示:
image.png

第一个彩色部分是通道数为 r^2,大小为 n×n 的特性图,即为 Sub-pixel Convolution 前的图像。
第二个彩色部分是通道数为 1 ,大小为nr×nr 的特征图,即为 Sub-pixel Convolution 后的图像。
上图很直观得表达了子像素卷积的做法,前面就是一个普通的 CNN 网络,到后面彩色部分就是子像素卷积的操作了。
简单的说,就是将每一个像素点的所有通道合并在了一起。例如通道数为 9,那么我就可以把第一个像素点的所有通道拿出来,排成一个3×3 的“像素点”,如下图所示:
image.png

对每个像素点都进行上述操作,最后得到了大小为nr×nr 的特征图,进而提高了原图的分辨率。这种提高分辨率的过程就叫做子像素卷积。
因为 相关作者 已经为我们写好了 Keras 版的子像素卷积函数,所以我们只需要复制过来(无需手敲),直接运行即可。以后遇到这种上采样的需求,也可直接将函数复制到本地,用以调用。子像素卷积的代码如下:

# 子像素卷积层,用于上采样
# PixelShuffler layer for Keras
from keras.utils import conv_utils
from keras.engine.topology import Layer
import keras.backend as K


class PixelShuffler(Layer):
    # 初始化 子像素卷积层,并在输入数据时,对数据进行标准化处理。
    def __init__(self, size=(2, 2), data_format=None, **kwargs):
        super(PixelShuffler, self).__init__(**kwargs)
        self.data_format = K.normalize_data_format(data_format)
        self.size = conv_utils.normalize_tuple(size, 2, 'size')

    def call(self, inputs):
        # 根据得到输入层图层 batch_size,h ,w,c 的大小
        input_shape = K.int_shape(inputs)
        batch_size, h, w, c = input_shape
        if batch_size is None:
            batch_size = -1
        rh, rw = self.size

        # 计算转换后的图层大小与通道数
        oh, ow = h * rh, w * rw
        oc = c // (rh * rw)

        # 先将图层分开,并且将每一层装换到自己应该到维度
        # 最后再利用一次 reshape 函数(计算机会从外到里的一个个的将数据排下来),这就可以转成指定大小的图层了
        out = K.reshape(inputs, (batch_size, h, w, rh, rw, oc))
        out = K.permute_dimensions(out, (0, 1, 3, 2, 4, 5))
        out = K.reshape(out, (batch_size, oh, ow, oc))
        return out

    # compute_output_shape()函数用来输出这一层输出尺寸的大小
    # 尺寸是根据input_shape以及我们定义的output_shape计算的。
    def compute_output_shape(self, input_shape):
        height = input_shape[1] * self.size[0] if input_shape[1] is not None else None
        width = input_shape[2] * self.size[1]  if input_shape[2] is not None else None
        channels = input_shape[3] // self.size[0] // self.size[1]

        return (input_shape[0],
                height,
                width,
                channels)

    # 设置配置文件
    def get_config(self):
        config = {'size': self.size,
                  'data_format': self.data_format}
        base_config = super(PixelShuffler, self).get_config()

        return dict(list(base_config.items()) + list(config.items()))

下采样层与上采样层的编写
下采样和上采样就是构成编码器和解码器的具体部件。下采样层主要用于缩小图层大小,扩大图层通道数(即编码器)。上采样主要用于扩大图层大小,缩小图层通道数(即解码器)。
在本次实验中,每个下采样层包括了一个卷积层和一个 LeakyReLU 激活函数层。而上采样包含了一个卷积层,一个 LeakyReLU 激活函数层和一个像素洗牌层。下采样中的卷积层用于缩小图层大小提取图层特征,上采样中的卷积层用于扩大图层通道数,保证在像素洗牌后的图层的通道数和所需通道数相同。
假设在上采样时,我们输入的图层大小为32×32 ,而我们需要输出的图层大小为64×64,通道数为256 。那么我们就需要在子像素卷积之前先进行一次卷积,使得图层的通道数变为 256×4 (即得到 32×32×1024 的图层)。然后再通过子像素卷积,就能够输出64×64×256 的新图层了。
接下来我们利用子像素卷积函数以及 Keras 提供的卷积函数对自编码器中的上采样层和下采样层进行编写。

from keras.layers.advanced_activations import LeakyReLU
from keras.layers.convolutional import Conv2D

# 下采样层,filters 为输出图层的通道数
# n * n * c -> 0.5n * 0.5n * filters
def conv(filters):
    def block(x):
        # 每一层由一个使图层大小减小一半的卷积层和一个 LeakyReLU 激活函数层构成。
        x = Conv2D(filters, kernel_size=5, strides=2, padding='same')(x)
        x = LeakyReLU(0.1)(x)
        return x
    return block

# 上采样层,扩大图层大小
# 图层的形状变化如下:
# n*n*c -> n * n * 4filters -> 2n * 2n * filters
def upscale(filters):
    # 每一层由一个扩大通道层的卷积,一个激活函数和一个像素洗牌层
    def block(x):
        # 将通道数扩大为原来的四倍。为了下一步能够通过像素洗牌 使原来的图层扩大两倍
        x = Conv2D(filters*4, kernel_size=3, padding='same')(x)
        x = LeakyReLU(0.1)(x)
        x = PixelShuffler()(x)
        return x
    return block

接下来,我们传入一张图片对上面自定义的两个网络层进行测试:

import tensorflow as tf
# 将原图片转为 Tensor 类型
x1 = tf.convert_to_tensor(A_images, dtype=tf.float32)
x2 = conv(126)(x1)
x3 = upscale(3)(x2)
print("将大小为 {} 的图片传入 filters 为 126 的下采样层中得到大小为 {} 的图层。".format(x1.shape, x2.shape))
print("将大小为 {} 的图层传入 filters 为  3  的上采样层中得到大小为 {} 的图片。".format(x2.shape, x3.shape))

从结果可以看出,上采样层可以将图层的大小减小为原来的 1/2,下采样层可以将图层大小扩大为原来的2 倍。

人脸互换的基本架构

其实人脸互换的基本结构就是两个自编码器,更准确的说应该是 1 个编码器 + 2 个解码器。接下来,我会从训练过程和运用过程分别对 AI 换脸的概念进行阐述。
训练过程

image.png

如上图,我们利用同一套方法(编码器)对两种图片进行特征提取。将提取出来的特征放到各自对应的解码器中,生成各自所对应的图像。然后利用生成的图像与原来的图像计算损失,再反向传播并对模型参数进行调整,如此循环,直到损失最小。
当损失最小时,我们把川普的图片放入训练好的(Encoder,Decoder_A) 中就能够得到一张和川普神似的图片。同理,若把凯奇的图片放入训练好的(Encode,Decode_B)中,也能得到和凯奇神似的图片。也就是说,在模型训练过程中,原始图片既是训练集合也是目标集合。
运用过程
现在让我们理一下思路,A,B 两类图片通过同一种方法进行特征提取,然后把得到的特征放入各自的解码器中得到了属于自己的图片。
也就是说 Decoder_A 和 Decoder_B 都能够识别 Encoder 所提取的特征。因此,从同一个 Encoder 中出来的特征既可以放入 Decoder_A 中,也可以放到 Deconder_B中,这就是人脸互换的关键,也是 Encoder 只有一个的原因。
因此,在上图的模型训练好后,我们只需进行下图的操作即可实现人脸互换:
image.png

如上图所示,将一张川普的图片放入训练好的 EnCoder 中,得到一组特征。将这组特征放入 Decoder_A 中,就能得到一张神似川普的新图片。若将这组特征放入 Decoder_B 中,就会输出与川普表情一样但是和凯奇神似的图片。

神经网络结构

说完人脸互换的原理后,让我们来谈谈本次实验中用到的 Encoder 和 Decoder 的具体网络结构,如下图所示:


image.png

中间那层为编码器的神经网络结构。它由 4 个下采样的卷积层,2 个全连接层,1 个上采样层构成。其中下采样卷积层用于对图片特征进行提取。全连接层用于打乱特征的空间结构,使模型能够学习到更加有用的东西。上采样层用于增加图层大小。
上下两层为两个解码器。他们的网络结构相同,但是参数不同。他们都是由三个上采样层和一个下采样卷积层构成。其中上采样层的作用是为了扩大图层大小,使最后能够输出和原图片一样大小的新图片。最后的卷积层是为了缩小图层通道数,使最后输出的是一个三通道的图片。
接下来利用 Keras 对 Encoder 和 Decoder 进行编写:

from keras.models import Model
from keras.layers import Input, Dense, Flatten, Reshape

# 定义原图片的大小
IMAGE_SHAPE = (64, 64, 3)
# 定义全连接的神经元个数
ENCODER_DIM = 1024


def Encoder():
    input_ = Input(shape=IMAGE_SHAPE)
    x = input_
    x = conv(128)(x)
    x = conv(256)(x)
    x = conv(512)(x)
    x = conv(1024)(x)
    x = Dense(ENCODER_DIM)(Flatten()(x))
    x = Dense(4*4*1024)(x)
    x = Reshape((4, 4, 1024))(x)
    x = upscale(512)(x)
    return Model(input_, x)


def Decoder():
    input_ = Input(shape=(8, 8, 512))
    x = input_
    x = upscale(256)(x)
    x = upscale(128)(x)
    x = upscale(64)(x)
    x = Conv2D(3, kernel_size=5, padding='same', activation='sigmoid')(x)
    return Model(input_, x)

根据人脸互换所需要的自编码器结构,创建 (Encoder,Decoder_A)和(Encoder,Decoder_B)结构,并且选择绝对平方损失作为模型的损失函数。

from tensorflow.keras.optimizers import Adam
# 定义优化器
optimizer = Adam(lr=5e-5, beta_1=0.5, beta_2=0.999)
encoder = Encoder()
decoder_A = Decoder()
decoder_B = Decoder()
# 定义输入函数大小
x = Input(shape=IMAGE_SHAPE)
# 定义解析 A 类图片的神经网络
autoencoder_A = Model(x, decoder_A(encoder(x)))
# 定义解析 B 类图片的神经网络
autoencoder_B = Model(x, decoder_B(encoder(x)))
# 使用同一个优化器,计算损失和的最小值。损失函数采用平均绝对误差
autoencoder_A.compile(optimizer=optimizer, loss='mean_absolute_error')
autoencoder_B.compile(optimizer=optimizer, loss='mean_absolute_error')
# 输出两个对象
autoencoder_A, autoencoder_B

数据预处理

为了能够训练出较好的模型,在模型训练之前,我们必须先对数据进行相关处理。接下来,我们将对数据集做如下处理:

image.png

数据增强
数据增强是深度学习中很重要的一步。这种方法可以在不消耗任何成本的情况下,获得更多的数据,进而训练出更好的模型。通过旋转、平移、缩放、剪切等操作,将原来的一张图片拓展成多张图片是数据增强的一种方法。
我们通过对旋转角度,平移距离,缩放比例等随机取值,来对原始图片进行随机转换。

# 该函数中所有的参数的值都可以根据情况自行调整。
def random_transform(image):
    h, w = image.shape[0:2]
    # 随机初始化旋转角度,范围 -10 ~ 10 之间。
    rotation = np.random.uniform(-10, 10)
    # 随机初始化缩放比例,范围 0.95 ~ 1.05 之间。
    scale = np.random.uniform(0.95, 1.05)
    # 随机定义平移距离,平移距离的范围为 -0.05 ~ 0.05。
    tx = np.random.uniform(-0.05, 0.05) * w
    ty = np.random.uniform(-0.05, 0.05) * h
    # 定义放射变化矩阵,用于将之前那些变化参数整合起来。
    mat = cv2.getRotationMatrix2D((w//2, h//2), rotation, scale)
    mat[:, 2] += (tx, ty)
    # 进行放射变化,根据变化矩阵中的变化参数,将图片一步步的进行变化,并返回变化后的图片。
    result = cv2.warpAffine(
        image, mat, (w, h), borderMode=cv2.BORDER_REPLICATE)
    # 图片有 40% 的可能性被翻转
    if np.random.random() < 0.4:
        result = result[:, ::-1]
    return result

让我们传入一张图片,进行测试,并观察图片的变化情况:

old_image = A_images[1]  # 去之前用于展示的第1张图片
transform_image = random_transform(old_image)
print("变化前图片大小为{}\n变化后图片大小为{}".format(old_image.shape, transform_image.shape))
# 用数据可视化部分的函数进行展示
figure = np.concatenate([old_image, transform_image], axis=0)
figure = stack_images(figure)
# 这里需要指定利用 cv 的调色板,否则 plt 展示出来会有色差
plt.imshow(cv2.cvtColor(figure, cv2.COLOR_BGR2RGB))

仔细比较结果中的两个川普(从他们的下巴与下边界的距离,目光方向等方面进行比较),你会发现图片已经发生了变化。当然,如果你并没有发现太大变化,可以多次运行上述代码,如果幸运的话,你可以看到图片出现了翻转(设置的图片翻转概率为 40% )。
输入数据集和目标数据集
由于图片已经把川普的整个大头包含了进去,而我们需要训练只是川普的脸部特征。因此为了提高模型的训练效率,我们需要将川普的小脸从他的大头中切割出来,即将一张 256×256 的大图变为64×64 的小图。将切割下来的小图放入模型中,既可以提高模型的训练速度,又可以提高准确性。
如果仅仅是裁剪,我们可以直接进行随机剪切。但是为了提高模型的泛化性,在剪切的时候,我们还需要做一次数据增强,也就是将图片进行了扭曲编写。
那么如何做到图片的扭曲呢?首先这里我们用到了 OpenCV 中的简单映射的方法 :将一张图片的一个像素点的值,放到另一张图片上的某个像素点上。

image.png

如上图所示,为了达到卷曲的效果,可以在决定映射位置时,添加一个小的波动,即某个点可能会映射到他原来位置的相邻位置。比如,原图的 4 位置本来应该映射到另外一张图的 4 位置,但是我们可以加上一个比较小的随机值,是它的映射位置出现细微偏移进而达到卷曲的效果。代码如下(下面代码会使用 OpenCV 中的 remap() 函数,不懂的可以查看 该篇博客):

def random_warp(image):
    # 先设置映射矩阵
    assert image.shape == (256, 256, 3)
    # 设置 range_ = [ 48.,  88., 128., 168., 208.]
    range_ = np.linspace(128-80, 128+80, 5)
    mapx = np.broadcast_to(range_, (5, 5))  # 利用 Python 广播的特性将 range_ 复制 5 份。
    mapy = mapx.T
    mapx = mapx + np.random.normal(size=(5, 5), scale=5)
    mapy = mapy + np.random.normal(size=(5, 5), scale=5)
    # 将大小为 5*5 的map放大为 80*80 ,再进行切片,得到 64 * 64 的 map
    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')

    # 通过映射矩阵进行剪切和卷曲的操作,最后获得 64*64 的训练集图片
    warped_image = cv2.remap(image, interp_mapx, interp_mapy, cv2.INTER_LINEAR)

    # 下面四行代码涉及到 target 的制作,该段代码会在下面进行阐述
    src_points = np.stack([mapx.ravel(), mapy.ravel()], axis=-1)
    dst_points = np.mgrid[0:65:16, 0:65:16].T.reshape(-1, 2)
    mat = umeyama(src_points, dst_points, True)[0:2]  # umeyama 函数的定义见下面代码块
    target_image = cv2.warpAffine(image, mat, (64, 64))

    return warped_image, target_image

从上面代码中可以看出,我们并没有直接把做好的输入数据集当做目标数据集,而是对输入数据集中的图片又进行了一次转换。这次转换采用的是点云匹配算法,其本质还是一种映射算法。(碍于篇幅本文不作详解,有兴趣的同学可以查看 该篇论文)。
当然这个算法的代码不用我们自己编写,可以直接从官方库中下载,我们只需要运行一下即可。代码如下:

# License (Modified BSD)
# umeyama function from scikit-image/skimage/transform/_geometric.py
def umeyama(src, dst, estimate_scale):
    """Estimate N-D similarity transformation with or without scaling.
    Parameters
    ----------
    src : (M, N) array
        Source coordinates.
    dst : (M, N) array
        Destination coordinates.
    estimate_scale : bool
        Whether to estimate scaling factor.
    Returns
    -------
    T : (N + 1, N + 1)
        The homogeneous similarity transformation matrix. The matrix contains
        NaN values only if the problem is not well-conditioned.
    References
    ----------
    .. [1] "Least-squares estimation of transformation parameters between two
            point patterns", Shinji Umeyama, PAMI 1991, DOI: 10.1109/34.88573
    """

    num = src.shape[0]
    dim = src.shape[1]

    # Compute mean of src and dst.
    src_mean = src.mean(axis=0)
    dst_mean = dst.mean(axis=0)

    # Subtract mean from src and dst.
    src_demean = src - src_mean
    dst_demean = dst - dst_mean

    # Eq. (38). 下面的Eq 都分别对应着论文中的公式
    A = np.dot(dst_demean.T, src_demean) / num

    # Eq. (39).
    d = np.ones((dim,), dtype=np.double)
    if np.linalg.det(A) < 0:
        d[dim - 1] = -1

    T = np.eye(dim + 1, dtype=np.double)

    U, S, V = np.linalg.svd(A)

    # Eq. (40) and (43).
    rank = np.linalg.matrix_rank(A)
    if rank == 0:
        return np.nan * T
    elif rank == dim - 1:
        if np.linalg.det(U) * np.linalg.det(V) > 0:
            T[:dim, :dim] = np.dot(U, V)
        else:
            s = d[dim - 1]
            d[dim - 1] = -1
            T[:dim, :dim] = np.dot(U, np.dot(np.diag(d), V))
            d[dim - 1] = s
    else:
        T[:dim, :dim] = np.dot(U, np.dot(np.diag(d), V.T))

    if estimate_scale:
        # Eq. (41) and (42).
        scale = 1.0 / src_demean.var(axis=0).sum() * np.dot(S, d)
    else:
        scale = 1.0

    T[:dim, dim] = dst_mean - scale * np.dot(T[:dim, :dim], src_mean.T)
    T[:dim, :dim] *= scale

    return T

接下来,我们传入一张图片测试,观察图片的变化,可以发现图片被被裁剪成了64×64×3的新图片。

warped_image, target_image = random_warp(transform_image)  # 返回训练图片和 target 图片
print("warpe 前图片大小{}\nwarpe 后图片大小{}".format(
    transform_image.shape, warped_image.shape))

构造 Batch 数据集
终于到了数据预处理的最后一步,构造 Batch 数据集。这是深度学习中常见的一个步骤,其本质就是根据 batch_size 的大小将数据集进行分批。大小合适的 batch_size 可以使模型更加高效的收敛。代码如下:

def get_training_data(images, batch_size):
    # 再分批的同时也把数据集打乱,有序的数据集可能使模型学偏
    indices = np.random.randint(len(images), size=batch_size)
    for i, index in enumerate(indices):
        # 处理该批数据集
        image = images[index]
        # 将图片进行预处理
        image = random_transform(image)
        warped_img, target_img = random_warp(image)

        # 开始分批
        if i == 0:
            warped_images = np.empty(
                (batch_size,) + warped_img.shape, warped_img.dtype)
            target_images = np.empty(
                (batch_size,) + target_img.shape, warped_img.dtype)

        warped_images[i] = warped_img
        target_images[i] = target_img

    return warped_images, target_images

接下来我们对上面代码进行测试,从川普的图片集合中取出一个 batch_size 的数据集。

# 加载图片,并对图片进行归一化操作
#注意:由于该段代码之前 images_A 变量名存的是路径,而现在存的是真实的 image 矩阵
#因此如果需要重复重复运行该段代码会报错(这时就需要再运行一下第一部分的加载图片路径的代码块)
images_A = load_images(images_A) / 255.0
images_B = load_images(images_B) / 255.0
images_A += images_B.mean(axis=(0, 1, 2)) - images_A.mean(axis=(0, 1, 2))

# 将数据进行分批,每个批次 20 条
warped_A, target_A = get_training_data(images_A, 20)
warped_A.shape, target_A.shape

模型训练

现在神经网络结构搭好了,数据也准备齐了,我们终于可以开始进行模型的训练了。

# 保存模型
def save_model_weights():
    encoder  .save_weights("encoder.h5")
    decoder_A.save_weights("decoder_A.h5")
    decoder_B.save_weights("decoder_B.h5")
    print("save model weights")

# 开始训练
epochs = 10  # 这里只用作演示,请在实际训练的时候,至少将其调到 8000 以上

for epoch in range(epochs):
    print("第{}代,开始训练。。。".format(epoch))
    batch_size = 26
    warped_A, target_A = get_training_data(images_A, batch_size)
    warped_B, target_B = get_training_data(images_B, batch_size)
    loss_A = autoencoder_A.train_on_batch(warped_A, target_A)
    loss_B = autoencoder_B.train_on_batch(warped_B, target_B)
    print("lossA:{},lossB:{}".format(loss_A, loss_B))
# 下面都为画图和保存模型的操作
save_model_weights()

下图是我在 Kaggle 上,利用 GPU 训练了 30 min 中的模型结果(实验代码以及训练后的模型已上传到 Kaggle 上,点击这里 可查看)。每类图片的第一张表示原始图片,第二张表示自己的解码器所生成的图片,第三张表示对方的解码器所生成的图片。

image.png

可以看出,训练了 30 min 的模型已经能够大概的模仿川普和凯奇的面部轮廓了。

模型运用

下载模型,并解压。

!wget -nc "https://labfile.oss.aliyuncs.com/courses/1460/models_weights.zip" # 下载数据集
!unzip -o "models_weights.zip" # 解压

整个换脸模型被保存成了三部分:编码器 encoder.h5、解码器 A decoder_A.h5 和解码器 B decoder_B.h5。
测试的代码和训练代码雷同,只是删去了循环和训练的步骤。虽然下列代码没有训练的过程,但是由于加载模型需要消耗一些时间,预计运行 1~3 min ,请耐心等待。

# 直接加载模型
print("开始加载模型,请耐心等待……")
encoder  .load_weights("encoder.h5")
decoder_A.load_weights("decoder_A.h5")
decoder_B.load_weights("decoder_B.h5")


# 下面代码和训练代码类似
# 获取图片,并对图片进行预处理
images_A = get_image_paths("trump")
images_B = get_image_paths("cage")
# 图片进行归一化处理
images_A = load_images(images_A) / 255.0
images_B = load_images(images_B) / 255.0

images_A += images_B.mean(axis=(0, 1, 2)) - images_A.mean(axis=(0, 1, 2))
batch_size = 64
warped_A, target_A = get_training_data(images_A, batch_size)
warped_B, target_B = get_training_data(images_B, batch_size)


# 分别取当下批次下的川普和凯奇的图片的前三张进行观察
test_A = target_A[0:3]
test_B = target_B[0:3]

print("开始预测,请耐心等待……")
# 进行拼接 原图 A - 解码器 A 生成的图 - 解码器 B 生成的图
figure_A = np.stack([
    test_A,
    autoencoder_A.predict(test_A),
    autoencoder_B.predict(test_A),
], axis=1)
# 进行拼接  原图 B - 解码器 B 生成的图 - 解码器 A 生成的图
figure_B = np.stack([
    test_B,
    autoencoder_B.predict(test_B),
    autoencoder_A.predict(test_B),
], axis=1)

print("开始画图,请耐心等待……")
# 将多幅图拼成一幅图 (已在数据可视化部分进行了详细讲解)
figure = np.concatenate([figure_A, figure_B], axis=0)
figure = figure.reshape((2, 3) + figure.shape[1:])
figure = stack_images(figure)

# 将图片进行反归一化
figure = np.clip(figure * 255, 0, 255).astype('uint8')

# 显示图片
plt.imshow(cv2.cvtColor(figure, cv2.COLOR_BGR2RGB))
plt.show()

根据上述结果,可以看出该模型已经能够很好的将川普和凯奇的脸进行模仿了。当然,其实后面还有一些处理工作。比如我们还需要把生成的神似凯奇的脸拼回到原来的川普的头上。这一步骤涉及到很多图形学的知识,比如泊松融合以及 Mask 边缘融合等。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容