【啄米日常】5:keras示例程序解析(2):图像风格转移

【啄米日常】5:keras示例程序解析(2):图像风格转移

各位看官抱歉……最近比较忙(lan),专栏就忘记更新惹= = ,希望你们还能记得我……

Keras 1.1.1更新了,但这次不准备汇报,因为没什么重要的更新内容——至少从文档看没有比较大的改进,下次有大新闻的时候再出来讲。

继续我们的示例程序解析,这次玩个有意思的~就是July他们前段时间炒的火热的图像风格化,好像人家宣传说的是让电脑学习梵高作画?虽然我觉得这玩意儿真的没啥好炒的,但是效果出来确实很炫酷,所以让我们来学习一下吧~

通过这个示例的解析,你将学习到的是:

  • 如何利用Keras的后端函数进行计算图编译
  • 如何获取反传梯度
  • 如何进行图像风格化

另一个副作用是,你将免费获得一次对外行装逼的机会,发朋友圈的时候注意先屏蔽掉那些懂深度学习的人哟~

什么是图像风格转移

图像风格转移,就是将一张图片的风格转移到另一张图片上去。确切的说就是:

I have a style image:

I have a content image:

duang~

这个就是图像风格转移,直观来看,就是将一副图片的“风格”转移到另一幅图片,而保持它的内容不变。一般我们将内容保持不变的图称为内容图,content image,把含有我们想要的风格的图片,如梵高的星空,称为风格图,style image。

要厘清这个问题,我们需要回答下面的两个问题:

  • 什么是图像风格
  • 如何转移图像风格

第一个问题,抱歉,目前还没有定论。我们可以直观的看到什么是风格,却很难定量描述它,印象派还是抽象派,达利还是梵高,即使没有经过专门的美术训练,一般人也能大概搞清楚“这张和这张是一个风格的,这张和那张不是一个风格的”。

但是要进行风格转移,必须对风格有一个定量的刻画,计算机科学嘛,凡事得落在代码上写得出来才算。我们先整一个图像风格的描述再说,是不是最贴切的?不一定,但是凑合能用,效果不赖,就可以了。这个指标我们下面讲。

第二个问题,如何转移风格?转移风格的实质是,使得从内容图像中抽取出来的风格表达G_content要接近于从风格图像中抽取出来的风格表达G_style。在这个过程中还要尽量保持内容图像的内容不要有太大变化,具体怎么做是个技术活,但是本质上,我们的想法是这个意图。

算法

CNN做图像分类非常厉害,但是为什么这么厉害呢?现在有一大堆人在研究这个问题,希望unlock the black box,也有一些不错的成果,但是确切的原理还没有完全清楚。虽然具体的事情说不着,但有些大致的认识是没错的。

比如,CNN的本质是对图像特征进行逐层抽象表达,经过一层层卷积层的变换,图像的特征变得越来越高级和稳定,对分类问题而已,我们可以预见网络最后一层的输出是一个具有很强稳定性和语义性的高级特征。靠前层representation倾向于表达图片的具体信息,靠后层的representation倾向于表达图片的高级语义信息(如类别)。

具体到图像风格转移,我们的假设是,如果两张图片有相同的representation,则它们是相似的,两张图片representation的相似性就是我们关于图像内容的衡量指标。两张图片的内容损失就是两张图片在某一层输出的二范数。

对于风格,我们用某一层输出的Gram矩阵来进行表达。在Keras中,一张图片在某个卷积层的输出特征是一个形如(batch_size, channels, width, heigt)的四阶张量(th后端),一张图片的话batch_size就是1,抛掉这个维度以后就是一个三阶张量,第一维是通道维,与该层卷积核数目相同,后面两个维度是输出featuremap的大小,画成图是这样:

将每个featuremap向量化,我们就得到一个矩阵。图中这个representation得到的是512x196的矩阵。这个矩阵自己跟自己相乘,得到了512x512的矩阵,这个矩阵称为Gram矩阵。显然Gram矩阵表达了一个向量组中两两向量的相关性,它的i行j列元素值就是第i个向量化特征图跟第j个向量化特征图的内积。

两张图的Gram矩阵差的二范数,我们定义为图像的风格表达,我们在最后直观的解释一下为什么Gram矩阵表达图像风格是合理的,这里先承认就好。

有了图像风格和内容的表示,我们就可以进行图像风格转移了。基本思路是通过迭代优化的方式,使一张空白图像在内容上相似于内容图像,而在风格上相似于风格图像,整个系统框图如下:


蓝色箭头为前向运算,红色箭头为反向运算。写了这么多network,实际上只用一个,连接内容图像和风格图像的网络只需要运行一次前向运算获得representation就可以了。我们从一张噪声图片开始,以它作为待优化图片。待优化图片通过网络的前向运算获得特定层的representation,然后通过计算representation与风格和内容的loss获得反传梯度,并修改原图。这就是整个算法的思路

Keras实现

必须指出,与Caffe相比,Keras实现具体的前向和反向控制麻烦一点。Caffe有明确的forward和backward,Keras这里只能自己来写。

但,从来就没有万能的好工具,只有会用不会用而已。

整理下思路,实现这个东西我们需要下面几个原料:

  • 一个训练好的神经网络
  • 一张风格图像,用来计算它的风格representation,算完就可以扔了
  • 一张内容图像,用来计算它的内容representation,算完扔
  • 一张噪声图像,用来迭代优化
  • 一个loss函数,用来获得loss
  • 一个求反传梯度的计算图,用来依据loss获得梯度修改图片

可以了,开干。

首先是训练好的网络,这里就选VGG网络了,稳定可靠大家都喜欢,Keras的Application模块内置了VGG网络,直接载入就好,因为我们这个实验不需要全连阶层,所以选no_top版本的就可以了,权重只有50多M,爽。

from keras.application import vgg16

model = vgg16.VGG16(input_tensor=input_tensor, weights='imagenet', include_top=False)

然后准备风格图和内容图,为了符合vgg16的输入要求,我们需要一个函数对图像进行预处理,因为最后的结果也是一张图片,所以有预处理就有后处理。出于篇幅考虑,我们将注释写在代码中。

def preprocess_image(image_path):
    #使用Keras内置函数读入图片,由于网络没有全连阶层,target_size可以随便设。
    img = load_img(image_path, target_size=(img_nrows, img_ncols)) 
    #读入的图片用内置函数转换为numpy array格式,这两个函数都在keras.preprocessing.image里
    img = img_to_array(img) 
    #:维度扩展,这步在Keras用于图像处理中也很常见,Keras的彩色图输入shape是四阶张量,第一阶是batch_size。
    #而裸读入的图片是3阶张量。为了匹配,需要通过维度扩展扩充为四阶,第一阶当然就是1了。
    img = np.expand_dims(img, axis=0) #3
    #vgg提供的预处理,主要完成(1)去均值(2)RGB转BGR(3)维度调换三个任务。
    #去均值是vgg网络要求的,RGB转BGR是因为这个权重是在caffe上训练的,caffe的彩色维度顺序是BGR。
    #维度调换是要根据系统设置的维度顺序th/tf将通道维调到正确的位置,如th的通道维应为第二维
    img = vgg16.preprocess_input(img) 
    return img

#可以看到,后处理的567三个步骤主要就是将#4的预处理反过来了,这是为了将处理过后的图片显示出来,resonable。
def deprocess_image(x):
    if K.image_dim_ordering() == 'th':
        x = x.reshape((3, img_nrows, img_ncols))
        x = x.transpose((1, 2, 0))
    else:
        x = x.reshape((img_nrows, img_ncols, 3)) 
   
    x[:, :, 0] += 103.939
    x[:, :, 1] += 116.779
    x[:, :, 2] += 123.68 
    # 'BGR'->'RGB'
    x = x[:, :, ::-1]
    x = np.clip(x, 0, 255).astype('uint8') 
return x

下面,这个例子将内容图、风格图和待优化的图片组成了一个tensor作为网络的输入,按照我们上面说的,风格图和内容图的representation完全可以抽取出来放着,只对待优化的图片进行迭代更新。但这个例子将三张图组合起来。我想这样做是出于只编译一次计算图的考虑,但我个人认为这个例子这里写的很不漂亮,还带来了几个图片必须shape一致的额外约束,后面这个约束是难以容忍的。换我写的话,我宁肯单独做一次特征抽取。

#读入内容和风格图,包装为Keras张量,这是一个常数的四阶张量
base_image = K.variable(preprocess_image(base_image_path)) 
style_reference_image = K.variable(preprocess_image(style_reference_image_path)) 

#初始化一个待优化图片的占位符,这个地方待会儿实际跑起来的时候要填一张噪声图片进来。
if K.image_dim_ordering() == 'th':
    combination_image = K.placeholder((1, 3, img_nrows, img_ncols))
else:
    combination_image = K.placeholder((1, img_nrows, img_ncols, 3)) 

#将三个张量串联到一起,形成一个形如(3,3,img_nrows,img_ncols)的张量
input_tensor = K.concatenate([base_image,
                              style_reference_image,
combination_image], axis=0) 

下面我们要将内容图的内容representation和风格图的风格representation抽取出来,写一个loss函数,这个loss就是我们的优化目标。它由三项构成:(1)风格损失,即Gram矩阵差的二范数(2)内容损失,即representation的差的二范数(3)自然图片正则项,用来使得生成的图片更平滑自然。这三项内容通过适当的加权组合起来。

Keras的损失函数是一个张量的函数,应该由后端或theano/TensorFlow提供的张量函数库完成,损失函数的返回值是一个标量,下面我们定义损失函数:


#设置Gram矩阵的计算图,首先用batch_flatten将输出的featuremap压扁,然后自己跟自己做乘法,跟我们之前说过的过程一样。注意这里的输入是某一层的representation。
def gram_matrix(x): 
    assert K.ndim(x) == 3
    if K.image_dim_ordering() == 'th':
        features = K.batch_flatten(x)
    else:
        features = K.batch_flatten(K.permute_dimensions(x, (2, 0, 1)))
    gram = K.dot(features, K.transpose(features))
return gram
#设置风格loss计算方式,以风格图片和待优化的图片的representation为输入。
#计算他们的Gram矩阵,然后计算两个Gram矩阵的差的二范数,除以一个归一化值,公式请参考文献[1]
def style_loss(style, combination): #2
    assert K.ndim(style) == 3
    assert K.ndim(combination) == 3
    S = gram_matrix(style)
    C = gram_matrix(combination)
    channels = 3
    size = img_nrows * img_ncols
    return K.sum(K.square(S - C)) / (4. * (channels ** 2) * (size ** 2))

#设置内容loss计算方式,以内容图片和待优化的图片的representation为输入,计算他们差的二范数,公式参考文献[1]
def content_loss(base, combination):
    return K.sum(K.square(combination - base))

#施加全变差正则,全变差正则用于使生成的图片更加平滑自然。
def total_variation_loss(x): 
    assert K.ndim(x) == 4
    if K.image_dim_ordering() == 'th':
        a = K.square(x[:, :, :img_nrows-1, :img_ncols-1] - x[:, :, 1:, :img_ncols-1])
        b = K.square(x[:, :, :img_nrows-1, :img_ncols-1] - x[:, :, :img_nrows-1, 1:])
    else:
        a = K.square(x[:, :img_nrows-1, :img_ncols-1, :] - x[:, 1:, :img_ncols-1, :])
        b = K.square(x[:, :img_nrows-1, :img_ncols-1, :] - x[:, :img_nrows-1, 1:, :])
return K.sum(K.pow(a + b, 1.25))

有同学说你这又是K.batch_flatten又是K.square的我怎么知道怎么用?没办法,符号式计算就是这样,它是一套完整的计算规则,想熟悉这些就要去读backend函数库的文档。Keras,theano,TensorFlow都是如此。大部分你想要的功能在Keras提供的函数库里都有,根据你的需求去搜索然后阅读使用方法即可。

休息一下,盘点一下我们有什么了:

  • 训练好的网络
  • 损失函数
  • 输入数据的张量占位符

还不错,下面我们完成这个代码的核心内容之一,获取反向梯度。时刻牢记在心,Keras的计算图是张量的映射,输入张量我们知道了,就是那三个图组成的tensor,输出张量是什么呢?显然,是损失函数关于输入张量的梯度,损失函数由三部分组成,例子里我们选取conv4_2这层的输出representation作为我们计算内容损失的依据,选取一组卷积层的输出作为风格损失的计算依据——这里没有什么black magic,你完全可以只选一个卷积层输出作为风格损失,或选一组卷积层输出作为内容损失,并没有什么大不了的,而且效果也差不多= =:

#这是一个张量字典,建立了层名称到层输出张量的映射,通过这个玩意我们可以通过层的名字来获取其输出张量,比较方便。当然不用也行,使用model.get_layer(layer_name).output的效果也是一样的。
outputs_dict = dict([(layer.name, layer.output) for layer in model.layers]) 

#loss的值是一个浮点数,所以我们初始化一个标量张量来保存它
loss = K.variable(0.) 

#layer_features就是图片在模型的block4_conv2这层的输出了,记得我们把输入做成了(3,3,nb_rows,nb_cols)这样的张量,
#0号位置对应内容图像的representation,1号是风格图像的,2号位置是待优化的图像的。计算内容loss取内容图像和待优化图像即可
layer_features = outputs_dict['block4_conv2']
base_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :] 
loss += content_weight * content_loss(base_image_features,
                                      combination_features) 

feature_layers = ['block1_conv1', 'block2_conv1',
                  'block3_conv1', 'block4_conv1',
                  'block5_conv1']
#与上面的过程类似,只是对多个层的输出作用而已,求出各个层的风格loss,相加即可。
for layer_name in feature_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(feature_layers)) * sl 

#求全变差约束,加入总loss中
loss += total_variation_weight * total_variation_loss(combination_image) 

刚才说输出张量是损失函数关于输入张量的梯度,现在我们损失函数搞定了,输入张量搞定了,通过调用K.grad获取反传梯度:

#通过K.grad获取反传梯度
grads = K.gradients(loss, combination_image) 

outputs = [loss]
#我们希望同时得到梯度和损失,所以这两个都应该是计算图的输出
if type(grads) in {list, tuple}:
    outputs += grads
else:
    outputs.append(grads) 
#编译计算图。Amazing!我们写了辣么多辣么多代码,其实都在规定输入输出的计算关系,到这里才将计算图编译了。
#这条语句以后,f_outputs就是一个可用的Keras函数,给定一个输入张量,就能获得其反传梯度了。
f_outputs = K.function([combination_image], outputs)

值得注意的是,这里编译的计算图,其输入只有待优化的图像,而不是之前定义的“三合一”图片。这个trick可以方便下面的迭代优化,我们只需要输入初始化的噪声图片就可以了。在这里我们可以一窥符号式语言的特点,前面所有的准备只为了最后这一刻的K.function,在这条语句之前,前面所有的语句都是“纸上谈兵”,K.function以后我们定义的这张计算图才具有了生命。

其实到了这一步,整个任务已经完成了,我们只需要写一个迭代优化的代码即可完成任务:

  • 给定输入图片x,通过f_outputs得到反传梯度
  • 使用x+学习率*梯度更新x

循环上面两步即可。但这里Keras的迭代更新是通过scipy提供的优化模块进行的,所以还是要继续研究下去,这段代码不太好读,我们先看最后的更新过程,然后再回过头来补缺失的部分。图像风格的核心是优化一张噪声图像图像,使得它的内容像内容图像,而且风格像风格图片,这是一个迭代优化的过程,其过程如下:

# 根据后端初始化一张噪声图片,做去均值
if K.image_dim_ordering() == 'th':
    x = np.random.uniform(0, 255, (1, 3, img_nrows, img_ncols)) - 128.
else:
    x = np.random.uniform(0, 255, (1, img_nrows, img_ncols, 3)) - 128. 

# 迭代10次
for i in range(10):
    print('Start of iteration', i)
    start_time = time.time()
    # 这里用了一个奇怪的函数 fmin_l_bfgs_b更新x,我们一会再看它,这里知道它的作用是更新x就好
    x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x.flatten(),
                                     fprime=evaluator.grads, maxfun=20) 
    print('Current loss value:', min_val)
    # save current generated image
    # 每次迭代完成后把输出的图片后处理一下,保存起来
    img = deprocess_image(x.copy()) 
    fname = result_prefix + '_at_iteration_%d.png' % i
    imsave(fname, img) #4
    end_time = time.time()
    print('Image saved as', fname)
print('Iteration %d completed in %ds' % (i, end_time - start_time))

好,问题的关键就是这个神奇的fmin_l_bfgs_b了,函数名又臭又长,不是搞优化的估计很难get到这个函数名的意义。

这个函数来自scipy.optimize, fmin代表它是一个用来最小化某个函数的优化器,l_bfgs_b代表它用的算法是L-BFGS-B

那么,这是个什么优化算法呢?

呵呵,关你毛事,看函数手册会用就可以了。

好吧我错了,不懂算法的话确实看函数手册也不怎么会用,这狗函数的参数太多了,下面是函数原型,你们感受一下:

def fmin_l_bfgs_b(func, x0, fprime=None, args=(),
                  approx_grad=0,
                  bounds=None, m=10, factr=1e7, pgtol=1e-5,
                  epsilon=1e-8,
                  iprint=-1, maxfun=15000, maxiter=15000, disp=None,
callback=None, maxls=20):

程序中设置了三个主要的参数:

  • func:这个是待优化的函数,也就是我们的目标函数
  • x0:初始值,也就是我们的初始图片
  • fprime:一个callable的函数,返回梯度,也就是K.grad求出来的玩意
  • maxfun:最大迭代次数,就是func要被迭代优化多少次

如果对这个函数感兴趣的话(呵呵),你可以去研究其他参数都是啥意思,反正,我不感兴趣。

根据函数说明,func和fprime是返回标量值的callable对象(函数),x0是个1D的nparray,fprime用来更新x,当然其返回值也是一个与x0同shape的1D的nparray

很好,这三个条件我们满足两个半。

loss和grad都是计算图中的一部分,数据类型都是Keras tensor。显然不是函数,不callable,这是两个不满足。

grad的返回值和x,是一个2D的矩阵而不是向量,这是半个不满足。

要不怎么说这份代码是没困难创造困难也要上呢,来,下面我们来秀如何解决这两个半不满足。

我们现在手上的计算图,接收一个初始图片的tensor为输入,输出该图片关于损失函数的导数,以及对应的损失函数值,keep it in your mind.

先解决那半个不满足,很简单,将矩阵reshape一下即可,输入函数前reshape成向量,得到输出后reshape成矩阵。我们定义下面的函数来求输入图片关于损失函数的导数,以及对应的损失值:

# 刚那个优化函数的输出是一个向量
def eval_loss_and_grads(x):
    # 把输入reshape层矩阵
    if K.image_dim_ordering() == 'th':
        x = x.reshape((1, 3, img_nrows, img_ncols))
    else:
        x = x.reshape((1, img_nrows, img_ncols, 3))
    #激动激动,这里调用了我们刚定义的计算图!
    outs = f_outputs([x])
    loss_value = outs[0]
    # outs是一个长为2的tuple,0号位置是loss,1号位置是grad。我们把grad拍扁成矩阵
    if len(outs[1:]) == 1:
        grad_values = outs[1].flatten().astype('float64')
    else:
        grad_values = np.array(outs[1:]).flatten().astype('float64')
return loss_value, grad_values

这个函数的逻辑很清楚,不再多说。记住刚才的函数要求是,有一个函数func要返回loss值,同时还有一个fprime要返回grad,但这个函数一下返回俩,咋整

一种方法是写两个函数,一个求loss的扔进去当func,另一个求grad的函数送去fprime。但这样的话就得跑两次运算图,不值当,这份代码中我们是这样处理的。定义下面的类:

class Evaluator(object):
    def __init__(self):
        # 这个类别的事不干,专门保存损失值和梯度值
        self.loss_value = None
        self.grads_values = None

    def loss(self, x):
        # 调用刚才写的那个函数同时得到梯度值和损失值,但只返回损失值,而将梯度值保存在成员变量self.grads_values中,这样这个函数就满足了func要求的条件
        assert self.loss_value is None
        loss_value, grad_values = eval_loss_and_grads(x)
        self.loss_value = loss_value
        self.grad_values = grad_values
        return self.loss_value

    def grads(self, x):
        # 这个函数不用做任何计算,只需要把成员变量self.grads_values的值返回去就行了
        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()

再把刚才更新x的部分贴出来看看,是不是就明白多啦:

x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x.flatten(),
fprime=evaluator.grads, maxfun=20)

这就是这份示例代码的全部内容,全部代码请参考keras examples中的neural_style_transfer.py文件

那,为什么Gram矩阵能表征图像的风格呢

其实具体为什么,我也从理论上说不清,但~还是可以稍微感性的谈一谈,说的对不对您见仁见智。

我们知道CNN某一层的featuremap,一级一级在提取和组合图像的特征,随着层数的增多,提取到的特征越来越抽象和具有语义信息。

而Gram矩阵,说白了就是featuremap两两的相关性。

我们还以梵高的夜空举例:

譬如说,某一层中有一个滤波器专门检测尖尖的塔顶这样的东西,另一个滤波器专门检测黑色。又有一个滤波器负责检测圆圆的东西,又有一个滤波器用来检测金黄色。

对梵高的原图做Gram矩阵,谁的相关性会比较大呢?如上图所示,“尖尖的”和“黑色”总是一起出现的,它们的相关性比较高。而“圆圆的”和“金黄色”都是一起出现的,他们的相关性比较高。

因此在风格转移的时候,其实也在风景图里去寻找这种“匹配”,将尖尖的渲染为黑色,将圆圆的渲染为金黄色。如果我们承认“图像的艺术风格就是其基本形状与色彩的组合方式” ,这样一个假设,那么Gram矩阵能够表征艺术风格就是理所当然的事情了。

我觉得这个解释是有道理的,事实上好像就是内容相似的图片风格化的效果会好,内容完全不一样的话,风格化起来也比较糟糕。

当然,一家之言,当不得真。

另外放一个我之前用别的优化方式搞的图片,那会儿根本也不会用scipy的优化器那么高端的东西,直接用梯度的话学习率调不好很容易像素爆炸~无奈之下发明了一种“Accept or reject”的优化方式,就是根据一次迭代后像素值是不是超过合法范围(0~255)来决定接收还是拒绝这次更新,想法当然很粗糙,但是跑出来的图片,私以为还挺好看的~不过风格化的程度没那么高就是了:

最近比较忙,这篇文章其实上半截老早就写好了,一直懒得继续写……下一篇也不知道是啥时候,你们先关注别的专栏,等宝宝把自己的事儿搞定了再看回来~

鸣谢,鞠躬~现在赶紧去朋友圈发你风格化后的图片来装逼吧!

编辑于 2017-03-23 15:15