写真をピカソやゴッホのようなスタイルに変換できるアプリPrismaが話題になりました。多くの人は、ディープラーニングが使われているかどうかとは関係なく、純粋にアプリを楽しんでいるのだと思います。

このようにディープラーニングを使った人気アプリが出てくるということは非常に良いことではないかと思います。今回は、Prismaの背景技術(と思われるもの)を解説していきます。

目次



基礎理論

ディープラーニングを使ったアート系の論文は色々と出ていますが、一番基礎となる論文はGatys et al. 2016ではないかと思います。プレプリント版は2015年8月に出ています。

この論文は記事として取り上げられて話題になっていたりもしたので、知っている人も多いのではないかと思います。この章では、スタイル変換の基礎となるこの論文を解説していきます。


Gatys et al. 2016より引用


モデルは、畳み込みニューラルネットワーク(convolutional neural network, CNN)を使用していて、VGGという2014のILSVRCというコンペで優勝したモデルがベースになっています。このモデルは画像分類(image classification)用に訓練されています。

VGG19とVGG16で畳み込み層の数がちょっと違ったりするのですが、以下のような構成になっています。論文ではVGG19の方を使っています(実装の章でも簡単に触れますが、どちらを使っても結果はあまり変わらないそうです)。


VGG16の構成図


スタイル変換には、このVGGから全結合層を取り除いたものを使用します。

次に、こちらの図を見てみましょう。CNNの各層において画像がどのように表現されているかを表す図です。


上段:スタイル 下段:コンテンツ
a, b, c, d, eはそれぞれconv1_2, conv2_2, conv3_2, conv4_2, conv5_2に対応
Gatys et al. 2016より引用


まずは、下段を見てみます。これらの画像はそれぞれの層において、入力画像を復元したものです。a, b, cまでは元の入力画像とほとんど変わらないように見えますが、d, eでは詳細な情報が落ちてきているように見えます。

VGGは元々画像を分類する目的で訓練されています。深い層に行くにつれて、分類するにあたって重要なコンテンツが残り、それとはあまり関係のない詳細な見た目などの情報は落ちていっていると考えられます。これはコンテンツとスタイルをある程度分離することができているとも考えられそうです。CNNによるコンテンツとスタイルの分離がこの論文の重要な貢献となっています。

この性質をうまく利用し、コンテンツを保ったままスタイルを別のものと入れ替えることを考えます。


手法

損失関数

損失関数について考えてみましょう。どのような損失関数にすればよいでしょうか。コンテンツを保ったまま、スタイルを他の画像のスタイルに近づけたいので、

コンテンツの損失+スタイルの損失

を損失関数として最小化すればよさそうです。

コンテンツの損失は、conv4_2において、コンテンツ画像と生成画像を比較することによって計算します。

\(\vec{p}\)、\(\vec{x}\)はそれぞれ元のコンテンツ画像と生成画像を表します。\(l\)層におけるフィルタ数(特徴マップ数)を\(N_l\)、特徴マップのサイズ(幅x高さ)を\(M_l\)とすると、\(F^{l} \in \mathcal{R}^{N_l \times M_l} \)の関係があります。\(F^{l}_{ij}\)は、\(i\)番目のフィルタによる位置\(j\)における活性度を表します。それぞれの場所における活性度の違いの総和を取っているだけです。

次に、スタイルの方の損失を見てみましょう。ここでは特徴マップの相関を考えます。

この\(G^l \in \mathcal{R}^{N_l \times N_l}\)は、グラム行列と呼ばれるものです。これをスタイル画像と生成画像で比較します。

\(A^l\)はスタイル画像の方のグラム行列を表します。スタイルについてはある一つ層を考えるだけでなく、複数の層を考慮します。最終的にスタイルの損失は以下のように表現されます。

ここで\(\vec{a}\)はスタイル画像を表し、\(w_l\)は各層の損失の重みを表します。具体的には論文ではconv1_1、conv2_1、conv3_1、conv4_1、conv5_1を使用します。

コンテンツの損失とスタイルの損失が揃ったので、ようやくトータルの損失を表現できるようになりました。

\(\alpha\)と\(\beta\)はコンテンツとスタイルの損失のそれぞれの重みを表します。

この損失の計算の流れをまとめた図を載せておきます。図を見ると、どのような計算を行っているのかイメージし易いのではないでしょうか。



最適化

損失関数が決まったので、次は最適化を考えます。通常は入力が固定で重みが更新されていきますが、今回は逆で重みが固定で入力画像が更新されていくことに注意します。つまり、\(\frac{\partial \mathcal{L}_\text{total}}{\partial \vec{x}}\)を計算することになります。

最適化はいつものようにAdamなどを使ってもよいのですが、論文によるとL-BFGSで一番よい結果が得られたとのことです。L-BFGSはあまり馴染みのない方も多いと思いますので、少し解説したいと思います。

通常はSGDのように一次の勾配が使われることが多いですが、二次の勾配を利用するニュートン法というものがあります。一次の勾配は直線的ですが、二次の勾配では曲率を考慮することになります。

二次の勾配を使うと何がよいのでしょうか。一つは、学習係数のようなハイパーパラメータを設定する必要がなくなるということです。直線的な場合は、どれくらい移動するか学習係数を使って決めてやる必要がありますが、曲線的な場合はおわんの底のような場所に移動してしまえばいいと分かるからです。

では、なぜいつもニュートン法を使うわけではないのでしょうか。二次の勾配を扱うためにヘシアン(Hessian)という行列が必要になるのですが、例えば100万個のパラメータがある場合は、100万x100万の行列になってしまうため、メモリに載りません。

そこでメモリを節約できるようにしたのが、L-BFGSです。ただし、ミニバッチのようにノイズがある場合はうまくいかないことがわかっているので、フルバッチが可能な場合に使用が限られるようです。今回は小規模な計算なので、L-BFGSを使うことができます。



実装

さて、それでは実装してみましょう。Kerasのexamplesとして公開されているコードに沿って解説していきます。一部異なりますが、基本的にはGatys et al. 2016と同じです。

簡単な変更で改善できる箇所がいくつもあるのですが、比較するためにもまずは一番ベーシックなもの実装するということで、Kerasのexampleのまま進めていきます。

ターミナルでこのように実行することを考えます。

$ python neural_style_transfer.py img/content.jpg img/style.jpg results/my_result

コンテンツ画像、スタイル画像、生成される画像のファイル名のプレフィックスを引数にします。

次に、neural_style_transfer.pyの中身を見ていきます。

import argparse

parser = argparse.ArgumentParser(description='Neural style transfer with Keras.')
parser.add_argument('base_image_path', metavar='base', type=str,
                    help='Path to the image to transform.')
parser.add_argument('style_reference_image_path', metavar='ref', type=str,
                    help='Path to the style reference image.')
parser.add_argument('result_prefix', metavar='res_prefix', type=str,
                    help='Prefix for the saved results.')

args = parser.parse_args()
base_image_path = args.base_image_path
style_reference_image_path = args.style_reference_image_path
result_prefix = args.result_prefix

weights_path = "vgg16_weights.h5"

上記のようにargparseを使って簡単にbase_image_pathなどを設定することができます。

また、このモデルではVGGの学習済みの重みvgg16_weights.h5を利用します。この重みはこちらからダウンロードすることができます。この辺りについてはKerasで学ぶ転移学習という過去記事でも触れています。


次に、画像を読み込みます。

from scipy.misc import imread, imresize
import numpy as np

img_width = 400
img_height = 400
assert img_height == img_width, 'Due to the use of the Gram matrix, width and height must match.'

def preprocess_image(image_path):
    # 画像を読み込み、リサイズ
    img = imresize(imread(image_path), (img_width, img_height))
    # RBGからBGRに変換
    img = img[:, :, ::-1].astype('float64')
    # 平均をゼロにする
    img[:, :, 0] -= 103.939
    img[:, :, 1] -= 116.779
    img[:, :, 2] -= 123.68
    img = img.transpose((2, 0, 1))
    img = np.expand_dims(img, axis=0)
    return img

def deprocess_image(x):
    x = x.transpose((1, 2, 0))
    x[:, :, 0] += 103.939
    x[:, :, 1] += 116.779
    x[:, :, 2] += 123.68
    x = x[:, :, ::-1]
    x = np.clip(x, 0, 255).astype('uint8')
    return x

imread()で読み込んだ画像はRGBとして扱われます。今回使用するvgg16_weights.h5という重みは、元々はCaffeというライブラリを使って学習した重みを変換したものなのですが、Caffeでは画像はRGBではなくBGRで扱われています。(参考)。

そのため、読み込んだ画像をRGBからBGRに変換する必要があります。RGBは赤・緑・青の順で並んでいますが、BGRとは青・緑・赤の順に並んでいる形式のことです。

また、mean_pixelsを使って、Caffe版VGGに合わせて平均値をゼロにする操作も行います(参考)。この平均値はVGGの訓練データから得られた値のようです。

他にも、読み込んだ画像をまず400x400にリサイズしたり、transpose()np.expand_dims()で畳み込みニューラルネットワーク(convolutional neural network, CNN)に合わせた形に配列を変形したりしています。

from keras import backend as K

base_image = K.variable(preprocess_image(base_image_path))
style_reference_image = K.variable(preprocess_image(style_reference_image_path))
combination_image = K.placeholder((1, 3, img_width, img_height))

input_tensor = K.concatenate([base_image,
                              style_reference_image,
                              combination_image], axis=0)

ここでは、コンテンツ画像、スタイル画像、生成画像に関する変数やプレースホルダーを作成しています。3という数字はチャネル数を表しています。コンテンツ画像やスタイル画像として、RGBではなくRGBAの画像を与えるとエラーになってしまうので注意が必要です。

次に、VGGと同様のアーキテクチャを持つモデルを作成します。Gatys et al. 2016では、VGG19を利用していますが、ここではそれよりも少し小さいVGG16を使用します。VGG19でもVGG16でも結果はほとんど変わらなかったという報告があります(参考)。

model = VGG16(weights='imagenet', include_top=False)

最近KerasではVGGはこのようにして非常に簡単に利用できるようになったのですが、これだとモデルを細かくカスタマイズするのが難しくなってしまうと思われるため、いつも通りmodel.add()を使って作成していきます。

from keras.models import Sequential
from keras.layers import Convolution2D, ZeroPadding2D, MaxPooling2D

first_layer = ZeroPadding2D((1, 1))
first_layer.set_input(input_tensor, shape=(3, 3, img_width, img_height))

model = Sequential()
model.add(first_layer)
model.add(Convolution2D(64, 3, 3, activation='relu', name='conv1_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(64, 3, 3, activation='relu'))
model.add(MaxPooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu', name='conv2_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu'))
model.add(MaxPooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu', name='conv3_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu'))
model.add(MaxPooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(MaxPooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(MaxPooling2D((2, 2), strides=(2, 2)))


次に、重みを読み込みます。通常はload_weights()を使うだけで良いのですが、今回はこれを使うとエラーになってしまいます。VGGとは少し違っていて、全結合層が存在しないためです。そこで、下記のようにして層ごとに重みを読み込んでいき、全結合層の重みの読み込みが始まる前にストップさせてやる必要があります。重みの読み込みについては、Kerasで学ぶ転移学習という過去記事でも紹介しています。

import os
import h5py

assert os.path.exists(weights_path), 'Model weights not found (see "weights_path" variable in script).'
f = h5py.File(weights_path)
for k in range(f.attrs['nb_layers']):
    if k >= len(model.layers):
        # 全結合層の重みは読み込まない
        break
    g = f['layer_{}'.format(k)]
    weights = [g['param_{}'.format(p)] for p in range(g.attrs['nb_params'])]
    model.layers[k].set_weights(weights)
f.close()
print('Model loaded.')

outputs_dict = dict([(layer.name, layer.output) for layer in model.layers])


次に、損失関数を定義します。コンテンツの損失とスタイルの損失については基本的にGatys et al. 2016と同じように実装します。

これに加えて、Kerasのサンプルでは第3の項が入っているのですが、ここでもそのまま入れています。これはtotal variation lossと呼ばれるもので、画像を滑らかにするような制約になっています。

Gatys et al. 2016では、生成画像に僅かにノイズが入ってしまうことがあり、特に絵画ではなく普通の写真同士の場合にノイズが現れやすくなることが報告されています。この辺りを踏まえると、やはりtotal variation lossも考慮しておくと良さそうです。

def gram_matrix(x):
    assert K.ndim(x) == 3
    features = K.batch_flatten(x)
    gram = K.dot(features, K.transpose(features))
    return gram

# スタイルの損失
def style_loss(style, combination):
    assert K.ndim(style) == 3
    assert K.ndim(combination) == 3
    S = gram_matrix(style)
    C = gram_matrix(combination)
    channels = 3
    size = img_width * img_height
    return K.sum(K.square(S - C)) / (4. * (channels ** 2) * (size ** 2))

# コンテンツの損失
def content_loss(base, combination):
    return K.sum(K.square(combination - base))

# 変化に関する損失
def total_variation_loss(x):
    assert K.ndim(x) == 4
    a = K.square(x[:, :, :img_width-1, :img_height-1] - x[:, :, 1:, :img_height-1])
    b = K.square(x[:, :, :img_width-1, :img_height-1] - x[:, :, :img_width-1, 1:])
    return K.sum(K.pow(a + b, 1.25))

# それぞれの損失の重み
total_variation_weight = 1.
style_weight = 1.
content_weight = 0.025

# コンテンツの損失
loss = K.variable(0.)
layer_features = outputs_dict['conv4_2']
base_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :]
loss += content_weight * content_loss(base_image_features,
                                      combination_features)

# スタイルの損失
feature_layers = ['conv1_1', 'conv2_1', 'conv3_1', 'conv4_1', 'conv5_1']
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 += total_variation_weight * total_variation_loss(combination_image)


損失や勾配を計算して、値を出力できるよう下記のように準備します。gradsという変数の型はバックエンドがTensorFlowかTheanoかによって違っていたりするので、その辺りに注意する必要があります。

grads = K.gradients(loss, combination_image)

outputs = [loss]
if type(grads) in {list, tuple}:
    outputs += grads
else:
    outputs.append(grads)

f_outputs = K.function([combination_image], outputs)

def eval_loss_and_grads(x):
    x = x.reshape((1, 3, img_width, img_height))
    outs = f_outputs([x])
    loss_value = outs[0]
    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

class Evaluator(object):
    def __init__(self):
        self.loss_value = None
        self.grads_values = None

    def loss(self, x):
        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):
        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()


必要なものが揃ったので、いよいよ最後に最適化を行います。いつものようにAdamなどを使っても良いのですが、基礎理論の章でも解説したようにL-BFGSを使います。また、L-BFGSはKerasではサポートされていないため、SciPyを使用します。

from scipy.misc import imsave
from scipy.optimize import fmin_l_bfgs_b
import time

x = np.random.uniform(0, 255, (1, 3, img_width, img_height))
x[0, 0, :, :] -= 103.939
x[0, 1, :, :] -= 116.779
x[0, 2, :, :] -= 123.68

# L-BFGSによる最適化
for i in range(10):
    print('Start of iteration', i)
    start_time = time.time()
    x, min_val, info = fmin_l_bfgs_b(evaluator.loss, x.flatten(),
                                     fprime=evaluator.grads, maxfun=20)
    print('Current loss value:', min_val)
    # 生成された画像を保存
    img = deprocess_image(x.copy().reshape((3, img_width, img_height)))
    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))


結果

結果はこのようになりました。ゴッホのスタイルはうまくいっているように見えます。パラメータはデフォルトのままで画像ごとに最適な値を探っているわけではありません。

ゴッホ以外の例ではスタイルが強すぎてコンテンツがほとんどわからなくなってしまっています。背景とメインの物体との区別もなくなってしまっています。スタイルの損失の重みを小さくするなどの改善が必要そうです。





改善

論文やGitHub上のコメントを読むと様々な改善策が提案されていることが分かります。この章では前の章で紹介したベーシックなモデルを改善していきます。コードはこちらでまとめて確認できます。

Gatys et al. 2016

平均プーリング

Kerasのexampleでは最大プーリングを使用しているのですが、Gatys et al. 2016によると、平均プーリングの方が僅かに良い結果が得られたとのことなので、MaxPooling2D()からAveragePooling2D()に変更します。

from keras.layers import AveragePooling2D

first_layer = ZeroPadding2D((1, 1))
first_layer.set_input(input_tensor, shape=(3, 3, img_width, img_height))

model = Sequential()
model.add(first_layer)
model.add(Convolution2D(64, 3, 3, activation='relu', name='conv1_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(64, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu', name='conv2_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu', name='conv3_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))


生成画像の初期値

イテレーションによって画像を生成するわけですが、その初期値は自由に選択することができます。Gatys et al. 2016では①コンテンツ画像、②スタイル画像、③ホワイトノイズを初期値にした場合について比較を行っています。

A:コンテンツ B:スタイル C:ホワイトノイズ4パターン
Gatys et al. 2016より引用

どれでもほとんど変わらないという結論なのですが、初期画像の構造がほんの僅かに残る傾向があるようなので、コンテンツ画像を初期値に変更します。

x = preprocess_image(base_image_path)


GitHub上のコメント

参考になるコメントがあがっているので、こちらも考慮して変更を加えていきます。

Gatys et al. 2016ではconv4_2でコンテンツを比較しているのですが、conv5_2の方がクオリティの高い結果が得られたとのことなので、conv5_2に変更します。

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

# ...

loss = K.variable(0.)
layer_features = outputs_dict['conv5_2']
base_image_features = layer_features[0, :, :, :]
combination_features = layer_features[2, :, :, :]

conv5_2への変更に伴い、コンテンツとスタイルの損失の重みも変更する必要があります。デフォルトではコンテンツとスタイルの比率が0.025:1ですが、これだと多くの場合ではスタイルが強くなってしまうようです。前の章の結果では、スタイルが強すぎる傾向があったこともあり、今回はスタイルの重みを小さめに設定します。

また、total_variation_weightはデフォルトでは1となっていますが、これも大きすぎるようです。ここでは、このように設定してみます。

total_variation_weight = 1e-3
style_weight = 0.01
content_weight = 1.


結果

結果はこのようになりました。スタイルが強すぎたのが抑えられ、コンテンツがはっきりと分かるようになりました。変更してみた感触としては、損失の重みは結果に大きく影響すると感じました。今回は何パターンか試してみただけなのですが、丁寧に調べたり、イテレーションの回数を増やしたりすれば、これよりもずっと良い結果が得られるかもしれません。また、画像によって最適なパラメータは結構違いそうだという印象を持ちました。




Novak and Nikulin 2016

Novak and Nikulin 2016はGatys et al. 2015の改善を行っている論文です。うまくいった手法だけでなく、うまくいかなかった手法も論文では紹介されていて興味深いです。上記の改修に加えて、この論文で紹介されている手法も考慮してみます。色々とバリエーションがあるのですが、こちらのコードに合わせて4つの手法を導入してみます。

Activation Shift

特徴マップでは多くの場所で値がゼロになっていることから、グラム行列は疎になっています。 \(G_{ij}^{l} = 0\)となっている場所は、特徴\(i\)または\(j\)のどちらか一方がゼロになっているのか、もしくは両方ゼロになっているのか分からないため、学習が困難になっていると考えられます。

そこで、

のように値をシフトさせることにより、値がゼロになるのを防ぐことを考えます。ここでは論文と同じ\(s = -1\)という値を採用します。

def gram_matrix(x):
    assert K.ndim(x) == 3
    features = K.batch_flatten(x)
    gram = K.dot(features - 1, K.transpose(features - 1))
    return gram


Using More Layers

Gatys et al. 2015ではスタイルの比較にconv1_1、conv2_1、conv3_1、conv4_1、conv5_1を使っていましたが、全ての畳み込み層を使うようにします。

model = Sequential()
model.add(first_layer)
model.add(Convolution2D(64, 3, 3, activation='relu', name='conv1_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(64, 3, 3, activation='relu', name='conv1_2'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu', name='conv2_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(128, 3, 3, activation='relu', name='conv2_2'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu', name='conv3_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu', name='conv3_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(256, 3, 3, activation='relu', name='conv3_3'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv4_3'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_1'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_2'))
model.add(ZeroPadding2D((1, 1)))
model.add(Convolution2D(512, 3, 3, activation='relu', name='conv5_3'))
model.add(AveragePooling2D((2, 2), strides=(2, 2)))

# ...

feature_layers = ['conv1_1', 'conv1_2', 'conv2_1', 'conv2_2', 'conv3_1', 'conv3_2', 'conv3_3',
                  'conv4_1', 'conv4_2', 'conv4_3', 'conv5_1', 'conv5_2', 'conv5_3']


Correlation Chain

隣り合う層のスタイルの損失の差分が小さくなるように変更します。コードを見ると何をやっているか分かりやすいかと思います。コードは次の変更と合わせて載せておきます。


Layer Weight Adjustment

オリジナルの論文ではコンテンツにはconv4_2、スタイルにはconv1_1、conv2_1、conv3_1、conv4_1、conv5_1を使用していたのですが、ここでは全ての畳み込み層を使用し、さらに層ごとに重みを付けることを考えます。

スタイルの方の重み\(w^s_l\)は深い層ほど小さく、コンテンツの方の重み\(w^c_l\)は深い層ほど大きくなるようにします。

\(D\)は全ての層の総数で、\(d(l)\)は層\(l\)の深さを表します。

ここではサンプルに合わせて、コンテンツについてはconv5_2のみを考え、スタイルについては上記のように重みを考慮するようにしてみます。

nb_layers = len(feature_layers) - 1

for i in range(nb_layers:
    layer_features = outputs_dict[feature_layers[i]]
    style_reference_features = layer_features[1, :, :, :]
    combination_features = layer_features[2, :, :, :]
    sl1 = style_loss(style_reference_features, combination_features)

    layer_features = outputs_dict[feature_layers[i + 1]]
    style_reference_features = layer_features[1, :, :, :]
    combination_features = layer_features[2, :, :, :]
    sl2 = style_loss(style_reference_features, combination_features)

    sl = sl1 - sl2

    loss += (style_weight / (2 ** (nb_layers - (i + 1)))) * sl
loss += total_variation_weight * total_variation_loss(combination_image)


また、ここでは損失の3つの項の重みはこのように設定します。

total_variation_weight = 1e-5
style_weight = 1.
content_weight = 0.025


結果

結果はこのようになりました。まだ不自然な部分も残っていますが、さらに改善されてより自然にスタイルが適用されているように見えます。





高速化

スタイル変換した画像を生成できるようになりましたが、実はこれだけではPrismaのようなアプリは作れません。画像生成に時間がかかり過ぎるからです。

Gatys et al. 2016の方法では、解像度にもよりますが、GPUを使っても大体数十秒程かかってしまいます。画像を生成するたびに誤差逆伝播をして計算する必要があるからです。

この問題を解決し、高速なスタイル変換を可能にしている論文も出てきているので、簡単に紹介しておきたいと思います。


Ulyanov et al. 2016a

誤差逆伝播をして画像を生成すると時間がかかるため、一度feedforwardするだけでスタイルを変換し、高速化することを考えます。学習に時間がかかるようになりますが、テスト時は一度feedforwardするだけになるので速くなります。

ネットワークの構造はGAN(Generative Adversarial Networks)に似たような構造で、generator networkとdescriptor networkからなります。


Ulyanov et al. 2016aより引用


Generator networkの方では、ノイズとコンテンツ画像を入力とし、畳み込み・upsampling・batch normalizationなどを行っていき、画像を生成します。

一方、descriptor networkはVGGから全結合層を除いたもので、Gatys et al. 2016と同様にしてコンテンツの損失とスタイルの損失を計算します。VGG全体を損失関数として使っているような感じです。descriptor networkの重みは固定されていて、誤差逆伝播時にはgenerator networkの方だけ訓練されることになります。

テスト時は~20msで画像を生成することができ、Gatys et al. 2016の500倍以上の速さになります。クオリティは劣る場合もあるのですが、スタイル変換できている様子が分かります。


Ulyanov et al. 2016aより引用



Johnson et al. 2016

スタンフォードのCS231nの講義動画を見て勉強したことがある人は、著者のJohnsonを見たことがあるかもしれません。こちらの研究もUlyanov et al. 2016aと似ているのですが、少しネットワークの構造が違います。

下図のようにimage transform networkとloss networkで構成されています。


Johnson et al. 2016より引用


Image transform networkでは、画像のみを入力とし、residual blockを持つネットワークを通して画像を生成します。Ulyanov et al. 2016aでは途中でもノイズを加えたりしていたことを考えると、こちらの方がシンプルだと思います。

Loss networkの方は、Ulyanov et al. 2016aと同様にVGG全体を損失関数のように扱います。

ちなみにこのモデルはスタイル変換だけでなく、カラー化(colorization)にも使うことができます。入力画像と似ているが異なる画像を出力するという同様の問題であるためです。

テスト時の速度はGatys et al. 2016の1000倍程度で、下の結果を見るとGatys et al. 2016に近いスタイル変換ができていることが分かります。


Johnson et al. 2016より引用


Ulyanov et al. 2016aJohnson et al. 2016も損失関数はGatys et al. 2016と基本的に同じなので、改善の章で行ったものと同様の改善を行うこともできそうです。


Ulyanov et al. 2016b

最後にもう一つ論文を紹介しておきたいと思います。Normalizationの方法を変えることによって改善したという論文です。まず下の例を見てみましょう。


Ulyanov et al. 2016bより引用


これはGatys et al. 2016と同じ手法でスタイル変換を行っているのですが、コンテンツ画像のコントラストとは関係なくスタイルが適用されていることが分かります。高速化されたバージョンでもこれと同様の挙動になるようにするためには、generator networkがコントラストの情報を落とせるようになる必要があると考えられます。

Ulyanov et al. 2016aとJohnson et al. 2016では共にbatch normalizationを使っていましたが、instance normalization (contrast normalization)に変更します。結果を見ると、この変更によって明らかに良くなっている様子がわかります。


左:Ulyanov et al. 2016a 右:Johnson et al. 2016
上:batch normalization 下:instance normalization
Ulyanov et al. 2016bより引用




まとめ

ベーシックなneural style transferから始まり、高速化版まで紹介しました。こんなに簡単にスタイル変換できてしまうとは本当にすごいアルゴリズムだなと思います。このような背景技術を知った上でPrismaで遊ぶとまた違った楽しさがあるのではないかと思います。



参考文献