神经风格迁移由 Leon Gatys 等人于 2015 年夏天提出。自首次提出以来,神经风格迁移算法已经做了许多改进,并衍生出许多变体,而且还成功转化成许多智能手机图片应用。
神经风格迁移是指将参考图像的风格应用于目标图像,同时保留目标图像的内容。
在当前语境下,风格(style)是指图像中不同空间尺度的纹理、颜色和视觉图案,内容(content)是指图像的高级宏观结构。举个例子,在图 8-7 中(用到的参考图像是文森特 • 梵高 的《星夜》),蓝黄色圆形笔划被看作风格,而 Tübingen(图宾根)照片中的建筑则被看作内容。
风格迁移这一想法与纹理生成的想法密切相关,在 2015 年开发出神经风格迁移之前,这一想法就已经在图像处理领域有着悠久的历史。但事实证明,与之前经典的计算机视觉技术实现相比,基于深度学习的风格迁移实现得到的结果是无与伦比的,并且还在计算机视觉的创造性应用中引发了惊人的复兴。
实现风格迁移背后的关键概念与所有深度学习算法的核心思想是一样的:定义一个损失函数来指定想要实现的目标,然后将这个损失最小化。你知道想要实现的目标是什么,就是保存原始图像的内容,同时采用参考图像的风格。如果我们能够在数学上给出内容和风格的定义, 那么就有一个适当的损失函数(如下所示),我们将对其进行最小化。
loss = distance(style(reference_image) - style(generated_image)) + distance(content(original_image) - content(generated_image))
这里的 distance 是一个范数函数,比如 L2 范 数;content 是一个函数,输入一张图像,并计算出其内容的表示;style 是一个函数,输入一张图像,并计算出其风格的表示。将这个损失最小化,会使得 style(generated_image) 接近于 style(reference_image)、 content(generated_image) 接近于content(generated_image),从而实现我们定义的风格迁移。
Gatys 等人发现了一个很重要的观察结果,就是深度卷积神经网络能够从数学上定义 style和 content 两个函数。我们来看一下如何定义。
如你所知,网络更靠底部的层激活包含关于图像的局部信息,而更靠近顶部的层则包含更加全局、更加抽象的信息。卷积神经网络不同层的激活用另一种方式提供了图像内容在不同空间尺度上的分解。因此,图像的内容是更加全局和抽象的,我们认为它能够被卷积神经网络更靠顶部的层的表示所捕捉到。
因此,内容损失的一个很好的候选者就是两个激活之间的 L2 范数,一个激活是预训练的卷积神经网络更靠顶部的某层在目标图像上计算得到的激活,另一个激活是同一层在生成图像上计算得到的激活。这可以保证,在更靠顶部的层看来,生成图像与原始目标图像看起来很相似。假设卷积神经网络更靠顶部的层看到的就是输入图像的内容,那么这种方法可以保存图像内容。
对于风格损失,Gatys 等人使用了层激活的格拉姆矩阵(Gram matrix), 即某一层特征图的内积。这个内积可以被理解成表示该层特征之间相互关系的映射。这些特征相互关系抓住了在特定空间尺度下模式的统计规律,从经验上来看,它对应于这个尺度上找到的纹理的外观。
因此,风格损失的目的是在风格参考图像与生成图像之间,在不同的层激活内保存相似的内部相互关系。反过来,这保证了在风格参考图像与生成图像之间,不同空间尺度找到的纹理看起来都很相似。
简而言之,你可以使用预训练的卷积神经网络来定义一个具有以下特点的损失。
- 在目标内容图像和生成图像之间保持相似的较高层激活,从而能够保留内容。卷积神经网络应该能够“看到”目标图像和生成图像包含相同的内容。
- 在较低层和较高层的激活中保持类似的相互关系(correlation),从而能够保留风格。特征相互关系捕捉到的是纹理(texture),生成图像和风格参考图像在不同的空间尺度上应该具有相同的纹理。
接下来,我们来用 Keras 实现 2015 年的原始神经风格迁移算法。你将会看到,它与上一节介绍的 DeepDream 算法实现有许多相似之处。
神经风格迁移可以用任何预训练卷积神经网络来实现。我们这里将使用 Gatys 等人所使用的 VGG19 网络。VGG19 是第 5 章介绍的 VGG16 网络的简单变体,增加了三个卷积层。神经风格迁移的一般过程如下。
- (1) 创建一个网络,它能够同时计算风格参考图像、目标图像和生成图像的 VGG19 层激活。
- (2) 使用这三张图像上计算的层激活来定义之前所述的损失函数,为了实现风格迁移,需要将这个损失函数最小化。
- (3) 设置梯度下降过程来将这个损失函数最小化。
我们首先来定义风格参考图像和目标图像的路径。为了确保处理后的图像具有相似的尺寸(如果图像尺寸差异很大,会使得风格迁移变得更加困难),稍后需要将所有图像的高度调整为400 像素。
from keras.preprocessing.image import load_img, img_to_array
# 想要变换的图像的路径
target_image_path = 'data/portrait.png'
# 风格图像的路径
style_reference_image_path = 'data/popova.png'
# 生成图像的尺寸
width, height = load_img(target_image_path).size
img_height = 400
img_width = int(width * img_height / height)
import numpy as np
from keras.applications import vgg19
def preprocess_image(image_path):
img = load_img(image_path, target_size=(img_height, img_width))
img = img_to_array(img)
img = np.expand_dims(img, axis=0)
img = vgg19.preprocess_input(img)
return img
def deprocess_image(x):
# Remove zero-center by mean pixel
#vgg19.preprocess_input 的作用是减去 ImageNet 的平均像素值, 使其中心为 0。这里相当于 vgg19.preprocess_input 的逆操作
x[:, :, 0] += 103.939
x[:, :, 1] += 116.779
x[:, :, 2] += 123.68
# 'BGR'->'RGB'(将图像由 BGR 格式转换为 RGB 格式。这也是vgg19.preprocess_input 逆操作的一部分)
x = x[:, :, ::-1]
x = np.clip(x, 0, 255).astype('uint8')
return x
下面构建 VGG19 网络。它接收三张图像的批量作为输入,三张图像分别是风格参考图像、目标图像和一个用于保存生成图像的占位符。占位符是一个符号张量,它的值由外部 Numpy 张量提供。风格参考图像和目标图像都是不变的,因此使用 K.constant 来定义,但生成图像的占位符所包含的值会随着时间而改变。
from keras import backend as K
target_image = K.constant(preprocess_image(target_image_path))
style_reference_image = K.constant(preprocess_image(style_reference_image_path))
# This placeholder will contain our generated image
# 这个占位符用于保存生成图像
combination_image = K.placeholder((1, img_height, img_width, 3))
# We combine the 3 images into a single batch
# 将三张图像合并为一个批量
input_tensor = K.concatenate([target_image,
style_reference_image,
combination_image], axis=0)
# We build the VGG19 network with our batch of 3 images as input.
# The model will be loaded with pre-trained ImageNet weights.
# 利用三张图像组成的批量作为输入来构建 VGG19 网络。加载模型将使用预训练的 ImageNet 权重
model = vgg19.VGG19(input_tensor=input_tensor,
weights='imagenet',
include_top=False)
print('Model loaded.')
#定义内容损失,用格莱姆矩阵
def content_loss(base, combination):
return K.sum(K.square(combination - base))
#定义分格损失
def gram_matrix(x):
features = K.batch_flatten(K.permute_dimensions(x, (2, 0, 1)))
gram = K.dot(features, K.transpose(features))
return gram
def style_loss(style, combination):
S = gram_matrix(style)
C = gram_matrix(combination)
channels = 3
size = img_height * img_width
return K.sum(K.square(S - C)) / (4. * (channels ** 2) * (size ** 2))
#定义总变差损失(total variation loss),它对生成的组合图像的像素进行操作。它促使生成图像具有空间连续性,从而避免结果过度像素化。你可以将其理解为正则化损失。
def total_variation_loss(x):
a = K.square(
x[:, :img_height - 1, :img_width - 1, :] - x[:, 1:, :img_width - 1, :])
b = K.square(
x[:, :img_height - 1, :img_width - 1, :] - x[:, :img_height - 1, 1:, :])
return K.sum(K.pow(a + b, 1.25))
# Dict mapping layer names to activation tensors(将层的名称映射为激活张量的字典)
outputs_dict = dict([(layer.name, layer.output) for layer in model.layers])
# Name of layer used for content loss(用于内容损失的层)
content_layer = 'block5_conv2'
# Name of layers used for style loss(用于风格损失的层)
style_layers = ['block1_conv1',
'block2_conv1',
'block3_conv1',
'block4_conv1',
'block5_conv1']
# Weights in the weighted average of the loss components
# 损失分量的加权平均所使用的权重
total_variation_weight = 1e-4
style_weight = 1.
content_weight = 0.025
# Define the loss by adding all components to a `loss` variable
# 在定义损失时将所有分量添加到这个标量变量中
loss = K.variable(0.)
layer_features = outputs_dict[content_layer]
target_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :]
loss += content_weight * content_loss(target_image_features,
combination_features)
for layer_name in style_layers: #添加每个目标层的风格损失分量
layer_features = outputs_dict[layer_name]
style_reference_features = layer_features[1, :, :, :]
combination_features = layer_features[2, :, :, :]
sl = style_loss(style_reference_features, combination_features)
loss += (style_weight / len(style_layers)) * sl
loss += total_variation_weight * total_variation_loss(combination_image)
# 添加总变差损失
最后需要设置梯度下降过程。在 Gatys 等人最初的论文中,使用 L-BFGS 算法进行最优化,所以我们这里也将使用这种方法。这是本例与 8.2 节 DeepDream 例子的主要区别。L-BFGS 算 法内置于 SciPy 中,但 SciPy 实现有两个小小的限制。
- 它需要将损失函数值和梯度值作为两个单独的函数传入。
- 它只能应用于展平的向量,而我们的数据是三维图像数组。分别计算损失函数值和梯度值是很低效的,因为这么做会导致二者之间大量的冗余计算。 这一过程需要的时间几乎是联合计算二者所需时间的 2 倍。为了避免这种情况,我们将创建一个名为 Evaluator 的 Python 类,它可以同时计算损失值和梯度值,在第一次调用时会返回损失值,同时缓存梯度值用于下一次调用。
# 获取损失相对于生成图像的梯度
grads = K.gradients(loss, combination_image)[0]
# Function to fetch the values of the current loss and the current gradients
# 用于获取当前损失值和当前梯度值的函数
fetch_loss_and_grads = K.function([combination_image], [loss, grads])
class Evaluator(object):
def __init__(self):
self.loss_value = None
self.grads_values = None
def loss(self, x):
assert self.loss_value is None
x = x.reshape((1, img_height, img_width, 3))
outs = fetch_loss_and_grads([x])
loss_value = outs[0]
grad_values = outs[1].flatten().astype('float64')
self.loss_value = loss_value
self.grad_values = grad_values
return self.loss_value
def grads(self, x):
assert self.loss_value is not None
grad_values = np.copy(self.grad_values)
self.loss_value = None
self.grad_values = None
return grad_values
evaluator = Evaluator()
#最后,可以使用 SciPy 的 L-BFGS 算法来运行梯度上升过程,在算法每一次迭代时都保存当前的生成图像(这里一次迭代表示 20 个梯度上升步骤)。
from scipy.optimize import fmin_l_bfgs_b
from scipy.misc import imsave
import time
result_prefix = 'style_transfer_result'
iterations = 20
# Run scipy-based optimization (L-BFGS) over the pixels of the generated image
# (将图像展平,因为 scipy.optimize.fmin_l_bfgs_b 只能处理展平的向量)
# so as to minimize the neural style loss.
# This is our initial state: the target image.(这是初始状态:目标图像)
# Note that `scipy.optimize.fmin_l_bfgs_b` can only process flat vectors.
# 对生成图像的像素运行 L-BFGS 最优化,以将神 经风格损失最小化。注意,必须将计算损失的函数和 计算梯度的函数作为两个单独的参数传入
x = preprocess_image(target_image_path)
x = x.flatten()
for i in range(iterations):
print('Start of iteration', i)
start_time = time.time()
x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x,
fprime=evaluator.grads, maxfun=20)
print('Current loss value:', min_val)
# Save current generated image(保存当前的生成图像)
img = x.copy().reshape((img_height, img_width, 3))
img = deprocess_image(img)
fname = result_prefix + '_at_iteration_%d.png' % i
imsave(fname, img)
end_time = time.time()
print('Image saved as', fname)
print('Iteration %d completed in %ds' % (i, end_time - start_time))
最后,展示图像:
from matplotlib import pyplot as plt
# Content image
plt.imshow(load_img(target_image_path, target_size=(img_height, img_width)))
plt.figure()
# Style image
plt.imshow(load_img(style_reference_image_path, target_size=(img_height, img_width)))
plt.figure()
# Generate image
plt.imshow(img)
plt.show()