第2章 深層学習で芸術家の脳内を再現する

2.1 はじめに

こんにちは。SunProメンバーのhiromu (@hiromu1996)です。今回は、2016年11月下旬に発表された「pix2pix」[1]を使って、絵から芸術家の脳内を再現するということに挑戦してみました。一体どういうことなのかというと、アメリカン・コミックのような画風で知られる、ポップアートの代表的な画家、ロイ・リキテンスタインの絵画から彼が脳内で思い浮かべていたであろう風景を再現するシステムを作ってみた、という記事となります。

ロイ・リキテンスタインといっても、あんまりイメージが浮かばないかもしれませんが、代表作の1つである図2.1の「ヘアリボンの少女」は見たことがある人もいるんじゃないかと思います。今回は、そうしたリキテンスタインの作品の中でも、図2.2のように室内の風景をモデルにした「インテリア」シリーズを対象とします*1

[*1] データセットに関する章まで読み進めて頂くと、その理由が分かるかと思います。

Roy Lichtenstein「Girl with Hair Ribbon」(1965)<a id="fnb-hairribbon" href="#fn-hairribbon" class="noteref" epub:type="noteref">*2</a>

図2.1: Roy Lichtenstein「Girl with Hair Ribbon」(1965)*2

Roy Lichtenstein「Large Interior with Three Reflections」(1993)<a id="fnb-interior" href="#fn-interior" class="noteref" epub:type="noteref">*3</a>

図2.2: Roy Lichtenstein「Large Interior with Three Reflections」(1993)*3

2.2 pix2pixとは

まずは、pix2pixについて、その概要を説明していきたいと思います。pix2pixは、画像から画像への変換を必要とする問題に対して包括的に使用できる手法として提案されており、[1]では図2.3*4のように白黒写真のカラー化から、航空写真からの地図の生成、また、線画からの画像の復元にまで適用できることが示されています。

[*4] 以降の画像はhttps://goo.gl/2x7Ietにもアップロードしていますので、白黒で見にくい場合はそちらを参照ください。

pix2pixの適用例([1]より引用)

図2.3: pix2pixの適用例([1]より引用)

このpix2pixは、「敵対的生成ネットワーク(Generative Adversarial Network, GAN)」という技術を基にしています。GANでは、乱数から画像を生成するGeneratorと、学習データとGeneratorが生成した画像を見分けるDiscriminatorという2つのニューラルネットワークを競争させながら学習することで、学習データに似たような画像を自動生成することが可能となっています。例えるなら、Generatorは贋作画家、Discriminatorは鑑定士で、図2.4のように互いに勝負を繰り返させることで、贋作画家の画力は少しずつ向上し、鑑定士の能力も高くなっていくという仕組みです。

敵対的生成ネットワーク(GAN)のイメージ図

図2.4: 敵対的生成ネットワーク(GAN)のイメージ図

pix2pixは、これを発展させた「条件付き敵対的生成ネットワーク(Conditional GAN)」を使用しています。Conditional GANでは、航空写真と地図、白黒写真とカラー写真のように入力と出力に対応する2つの画像のペアを学習データとして用います。そしてGeneratorには、航空写真や白黒写真といった入力側となる学習データを与え、それに合った出力画像を生成するようにします。さらにDiscriminatorには、学習データに含まれる正しいペアと、入力側だけ学習データで出力側はGeneratorが生成した画像という偽のペアを与え、判別させます。つまり図2.5のようになり、これによって画像変換をGANの仕組みの中で扱えるようになります。

条件付き敵対的生成ネットワーク(Conditional GAN)のイメージ図([1]を基に作成)

図2.5: 条件付き敵対的生成ネットワーク(Conditional GAN)のイメージ図([1]を基に作成)

GANとConditional GANの目的関数は次の通りになります。比較すると、Generatorに入力側となる学習データxが与えられるようになり、Discriminatorには正しいペアの場合にD(x, y)偽のペアの場合にD(x, G(x, z))と2つの画像が与えられるようになっていることが分かるかと思います。

\begin{split}
    \mathcal{L}_{GAN}(G, D) & = \mathbb{E}_{x \sim p_{data}(x))}[\log D(x)] \\
    & + \mathbb{E}_{z \sim p(z)}[\log (1 - D(G(z))]\\
    \mathcal{L}_{cGAN}(G, D) & = \mathbb{E}_{x, y \sim p_{data}(x, y))}[\log D(x, y)] \\
    & + \mathbb{E}_{x \sim p_{data}(x), z \sim p(z)}[\log (1 - D(x, G(x,z))]
\end{split}

なお、通常のGANの場合はG^* = \arg\underset{G}{\min}\underset{D}{\max}\ \mathcal{L}(G, D)として、最適なGeneratorを得るように学習しますが、pix2pixでは\mathcal{L}_{L1}(G) = \mathbb{E}_{x,y \sim p_{data}(x, y), z \sim p_z(z)}[\left \| y - G(x, z) \right \|_1]というL1正則化の項を追加して、G^* = \arg\underset{G}{\min}\underset{D}{\max}\ \mathcal{L}_{cGAN}(G, D) + \lambda \mathcal{L}_{L1}(G)としています。これは、生成された画像をピクセル単位で正解に近づけるような制約として作用し、GANでの生成結果がよりリアルになることが知られています[2]。

さらに、pix2pixではPatchGANと呼ばれる技術を導入しています。これは、Discriminatorが判定をする際に、画像全体を与えるのではなく16 \times 1670 \times 70といった小さな領域ごとに区切って与えるという手法で、画像を大域的に見て判定するというDiscriminatorの機能を失うことなく、パラメータ数を削減することができるため、より効率的に学習を進められるとしています。

そして、pix2pixの実装はすべてGitHub (https://github.com/phillipi/pix2pix)に公開されています。つまり、ペアとなった画像の学習データさえ用意すれば、ここまで説明してきた技術をすぐに使えるのです。こんな素晴らしいものを使わない手はないですよね。

2.3 データセットを作る

とはいっても、ロイ・リキテンスタインの絵画とその元になった写真というようなデータは存在しません。つまり、pix2pixで絵画を変換するには、まずそのための学習データを作るということから始めないといけません。

ここで、図2.2を観察してみましょう。これを見ると、リキテンスタインの絵の特徴的なポイントは、以下の3点にあることが分かります。

  • 太く黒い線でしっかりと縁取られた輪郭線
  • 赤や黄といった原色に近い鮮やかな配色
  • 斜線や水玉といった幾何学的かつ単純なテクスチャ

逆に考えると、写真とそれに写っている物体の輪郭の情報があれば、機械的に配色し、テクスチャを貼り付けることでリキテンスタイン風の絵を生成できそうです。

さらに、輪郭抽出はCanny法と呼ばれるアルゴリズムを使えば、OpenCVなどで自動的にできるのでどうにかなるように思えます。しかし、Canny法で複雑な画像に対して輪郭抽出をしようとすると、かなり細かくパラメータ設定をしないと図2.6のように線が非常に多くなってしまうので、あまりリキテンスタインっぽくはならなさそうです。

Canny法による輪郭抽出の例<a id="fnb-canny" href="#fn-canny" class="noteref" epub:type="noteref">*5</a>

図2.6: Canny法による輪郭抽出の例*5

そこで、今回はNYU Depth Dataset v2 [3]というデータセットを使うことにします。これは、Kinectで撮影した室内風景の写真とその深度情報を組み合わせたデータセットで、約41万枚の画像が含まれています。そして、そのうちの1449枚には人間によって付与された物体の領域情報がラベルとして含まれています。実際にデータを見てみると図2.7のように、少しリキテンスタインのような雰囲気が見て取れるのが分かります。

NYU Depth Dataset v2に含まれる写真と深度情報、そして物体の領域情報の例<a id="fnb-nyudepth" href="#fn-nyudepth" class="noteref" epub:type="noteref">*6</a>

図2.7: NYU Depth Dataset v2に含まれる写真と深度情報、そして物体の領域情報の例*6

では、このデータセットの写真を絵画風に変換するスクリプトをPythonで実装していきたいと思います。データセットはMatlab向けの.matファイルなのでh5pyで読み込みます。また、実行時引数としてデータセットのファイルのパスと、写真・変換された画像を保存するディレクトリを受け取ります。

データセットの読み込み

if __name__ == '__main__':
    if len(sys.argv) < 4:
        print '%s [mat file] [dir to save ' + \
              'original] [dir to save converted]'
        sys.exit(-1)

    mat = h5py.File(sys.argv[1])
    for index, (image, label) in \
            enumerate(zip(mat['images'],
                          mat['labels'])):
        orig, conv = convert(image, label)
        orig.save(os.path.join(sys.argv[2],
                               '%05d.png' % index))
        conv.save(os.path.join(sys.argv[3],
                               '%05d.png' % index))

convert(image, label)はデータセット内の写真と対応するラベルの情報を受け取りますが、どちらもnumpyの配列形式となっていることに注意する必要があります。また、ラベルについては、写真にある物体ごとに1以上の番号が割り当てられており、ピクセルのそれぞれについて、物体の領域に含まれている場合はその番号が、そうでない場合は0が割り当てられているという形式になっています。

画像の変換

def convert(image, label):
    orig = Image.fromarray(image.T, 'RGB')

    for index in numpy.unique(label):
        # 物体ごとに中央値を
        # ちょっと明るくした色で埋める
        color = []
        for channel in range(3):
            med = numpy.median(image[channel]
                               [label == index])
            color.append(min(255, int(1.3 * med)))
        color = tuple(color)

        # テクスチャを生成する
        threshold = 0.1 * image.shape[1] \
                        * image.shape[2]
        if (label == index).sum() < threshold:
            func = random.choice([fill, dot,
                                  diagonal])
            texture = func(image.shape[1:3], color)
        else:
            if sum(color) / 3 < 128:
                color = tuple([255 - c
                               for c in color])
            texture = fill(image.shape[1:3], color)

        # 物体の輪郭線を書く
        cont = numpy.zeros(label.shape,
                           dtype = numpy.uint8)
        cont[label == index] = 255
        cont = Image.fromarray(cont.T, 'P')
        cont = cont.convert('RGB')
        cont = cont.filter(ImageFilter.CONTOUR)
        cont = numpy.asarray(cont).T

        # テクスチャと輪郭線を載せる
        for channel in range(3):
            image[channel][label == index] = \
                    texture[channel][label == index]
            image[channel] = \
                numpy.minimum(image[channel],
                              cont[channel])

    conv = Image.fromarray(image.T, 'RGB')

    return orig, conv

まず4行目のforによって、画像に含まれる物体のそれぞれについて同じ処理を繰り返すようにしています。そして、リキテンスタインの絵画では物体がそれぞれ1色で塗られていることから7~12行目で物体を塗りつぶす色を決定しています。ここでは、物体の領域に含まれる色の中央値を取った上で、原色に近い色にするためにRGBをそれぞれ1.3倍するという処理を行っています。

そこから、15~25行目で決定した色のテクスチャをランダムに生成しています。ここでfill(shape, color)は与えられたサイズを与えられた色で埋めた画像、dot(shape, color)は水玉で埋めた画像、diagonal(shape, color)は斜線で埋めた画像を生成する関数とします。つまり、後で物体に該当する部分だけ切り抜いて使うべく、画像サイズと同じテクスチャを作っておくという処理になっています。またリキテンスタインの絵画では、壁や床などの大きな領域では幾何学的なテクスチャがあまり用いられていないことから、物体の領域が画像全体の10%以下の場合のみに水玉や斜線を用いるようにしています。

そして、28~34行目で物体の輪郭線を生成しています。ここでは、物体の領域だけを白く塗った白黒画像を作った上で、PILのImageFilter.CONTOURというフィルタ機能によって輪郭線を得ています。最後に、37~42行目でRGBのそれぞれについて、物体の領域に該当するテクスチャを切り抜いて貼り付け、さらにその上から輪郭線を載せるという処理を行っています。

テクスチャを生成する関数は、以下の通りです。詳細な説明は割愛しますが、水玉のサイズや斜線の傾きはランダムで、描画にはPILのImageDrawを使っています。

テクスチャの生成

def fill(size, color):
    img = Image.new('RGB', size, color)
    return numpy.asarray(img).T

def dot(size, color):
    radius = random.randint(3, 5)
    interval = random.randint(radius + 2,
                              int(radius * 1.7))

    img = Image.new('RGB', size, (255, 255, 255))
    draw = ImageDraw.Draw(img)
    for x in range(0, size[0], interval):
        for y in range(0, size[1], interval):
            y += interval * (x / interval % 2) / 2
            region = (x, y, x + radius, y + radius)
            draw.ellipse(region, color)

    return numpy.asarray(img).T

def diagonal(size, color):
    width = random.randint(2, 4)
    angle = random.randint(1, 89)

    img = Image.new('RGB', size, (255, 255, 255))
    draw = ImageDraw.Draw(img)
    for x in range(0, size[0], width * 2):
        y = x / math.tan(angle / math.pi)
        draw.line((x, 0, 0, y), color, width)

    start = width * 2 - size[0] % (width * 2)
    for y in range(start, size[1], width * 2):
        x = size[0] - (size[1] - y) \
            * math.tan(angle / math.pi)
        draw.line((size[0], y, x, size[1]),
                  color, width)

    return numpy.asarray(img).T
元の写真と生成されたロイ・リキテンスタイン風の変換画像の例

図2.8: 元の写真と生成されたロイ・リキテンスタイン風の変換画像の例

以上を実行すると図2.8のように写真とリキテンスタイン風の変換画像のセットが生成されます。それなりにポップアートっぽい雰囲気になってきた気がします。

ここまでのソースコードをconvert.pyとして、コマンドラインの操作をまとめると、以下のようになります*7。1449枚のデータセットのうち、20%にあたる290枚をランダムに選び、テストデータとして学習データには含めず、学習がうまく進んでいるかを確かめるために使うようにしています。

[*7] Macではshufコマンドの代わりに、Homebrewのcoreutilsに含まれるgshufを使用してください。

$ mkdir orig label
$ python convert.py nyu_depth_v2_labeled.mat orig label
$ test=`seq 0 1448 | shuf | head -n 290 | xargs printf "%05d.png\n"`
$ for dir in orig label
> do
>     mkdir $dir/train $dir/test
>     for file in $test
>     do
>         mv $dir/$file $dir/test
>     done
>     mv $dir/*.png $dir/train
> done

そして、最終的に写真風に変換する、リキテンスタインのポップアートもデータセットに加えておきます。そのためにまず、以下のソースコードを用いて640 \times 480の解像度で切り出します。

ポップアートの切り出し

import os
import sys
from PIL import Image

target = (640, 480)
interval = 20

if __name__ == '__main__':
    if len(sys.argv) < 3:
        print '%s [input image] [output image]' % \
                                        sys.argv[0]
        sys.exit(-1)

    img = Image.open(sys.argv[1])
    ratio = max([float(target[i]) / img.size[i]
                 for i in range(2)])
    size = tuple(map(lambda x: int(x * ratio),
                     img.size))
    img = img.resize(size, Image.ANTIALIAS)

    name, ext = os.path.splitext(sys.argv[2])
    index = 0

    xlim = (size[0] - target[0]) / 2 + 1
    ylim = (size[1] - target[1]) / 2 + 1

    for x in range(0, xlim, interval):
        for y in range(0, ylim, interval):
            crop = img.crop((x, y, x + target[0],
                             y + target[1]))
            crop.save('%s_%02d%s' %
                      (name, index, ext))
            index += 1

これをcrop.pyとして以下の操作を行います。なお、これらは学習に用いず、また正解データも存在しないのですが、pix2pixはその場合でもペアとなった画像を入力とするので、origlabelに同じものをコピーしておきます。

$ mkdir orig/val label/val
$ for file in `ls lichtenstein`
> do
>     python crop.py lichtenstein/$file orig/val/$file
> done
$ cp orig/val/* label/val/

また、pix2pixでは入力画像と出力画像を左右に並べたものを学習に使用しますので、最後にGitHubの実装にあるcombine_A_and_B.pyによって結合処理をしておく必要があります。出来上がったpopartsディレクトリをpix2pixのdataset以下に配置すれば完成です。

$ mkdir poparts
$ python combine_A_and_B.py --fold_A orig --fold_B label --fold_AB poparts

2.4 学習させる

それでは、早速学習させていきましょう。以下のようにしてpix2pixのREADMEに従って学習させ、テストデータを変換してみます。ちなみにtrain.luaはデフォルトで200 epochの学習を行うようになっており、手元のGTX 970では12時間ほど掛かりました。

$ DATA_ROOT=./datasets/poparts name=poparts which_direction=BtoA th train.lua
$ DATA_ROOT=./datasets/poparts name=poparts which_direction=BtoA phase=test th test.lua
$ DATA_ROOT=./datasets/poparts name=poparts which_direction=BtoA phase=val th test.lua

結果は図2.9で、左側のテストデータでの出力画像を見ると、それなりに元の写真に近いものが出力されていることが分かります。一番下の出力画像ではソファーになぜか扉のような模様が付いてしまっていますが、4つとも遠目で見ると写真っぽく見えなくもないでしょう。

200 epoch時点での変換結果<a id="fnb-poparts" href="#fn-poparts" class="noteref" epub:type="noteref">*8</a>

図2.9: 200 epoch時点での変換結果*8

一方で、右側のポップアートでの変換結果を見てみると、なんだか入力画像をぼやっとさせただけ画像が出力されてしまっています。どうやら、もともとの色使いがかなり出力画像にも残ってしまっているために、あまり写真には見えないという結果となってしまいました。そこで、入力画像をすべて白黒化して学習させるということを試してみました。単なる白黒写真のカラー化であればpix2pixは有効であると分かっていますし、白黒にしてしまえばポップアートの鮮やかな色使いにもあまり影響されずに済みそうです。

入力画像を白黒化した場合の200 epoch時点での変換結果

図2.10: 入力画像を白黒化した場合の200 epoch時点での変換結果

図2.10の通り、左側のテストデータは白黒化しなかった時に比べ、色が違っていたり、変な模様が付いていたりと精度が落ちたように見えますが、右側のポップアートは先程よりかなり写真らしくなっています。図2.9とよく見比べてみると、ベッドのシーツが水色からクリーム色になっていたり、壁が赤色から茶色になっていたりと、それらしい色に置き換えられているのが分かります。ただ、まだ画像がぼやっとしているような印象を受けるので、60時間ほど掛けて、さらに1000 epochほど学習させてみました。その結果が図2.11です。

入力画像を白黒化した場合の1200 epoch時点での変換結果

図2.11: 入力画像を白黒化した場合の1200 epoch時点での変換結果

2.5 おわりに

ここまでお付き合い頂き、ありがとうございました。もともとは、WaveNet [4]を使ったネタを書きたいと思っていたのですが、学習がかなり重くデータセットを1周するのに40時間以上掛かる始末だったので、どうしようかと途方に暮れていた時にpix2pixが発表され、こうした記事を書くに至りました。ただpix2pixも、論文を読むと、ピクセル単位で対応が取れるような変換を対象としたアルゴリズムであるということが分かりましたが*9、そういった変換のうち白黒写真のカラー化や航空写真からの地図の生成というような面白そうなテーマはすでにサンプルとして扱われており、どういう問題をネタにするのかが難しいところでした。

[*9] 風景写真とその中央を拡大したものとの変換を試してみた人がいるみたいですが、あまりうまく学習できていないようです。(http://liipetti.net/erratic/2016/11/25/imaginary-landscapes-using-pix2pix/参照)

そんな時に思いついたのが、このロイ・リキテンスタインの絵画を写真風に変換するというネタでした。写真を絵画風にするシステムはたくさんありますが、絵画を写真風にするというのは聞いたことがないので、正直うまくいくのかわかりませんでしたが、実際にチャレンジしてみると思った以上にそれらしい結果が得られたので、我ながらびっくりしました。また、pix2pixの実装がとても使いやすく作られており、すんなりと実験できたのも、大変ありがたかったです。

次回こそは、音声+深層学習ネタで記事を書ければと思っておりますので、ご期待ください。また、質問などありましたら、@hiromu1996かhiromu1996[at]gmail.comまでご連絡ください。

2.6 参考文献

[1] Phillip Isola, Jun-Yan Zhu, Tinghui Zhou and Alexei A. Efros. Image-to-Image Translation Using Conditional Adversarial Networks. In arxiv, 2016.

[2] Deepak Pathak, Philipp Krahenbuhl, Jeff Don- ahue, Trevor Darrell and Alexei A. Efros. Context en- coders: Feature learning by inpainting, CVPR, 2016.

[3] NNathan Silberman, Derek Hoiem, Pushmeet Kohli and Rob Fergus. Indoor Segmentation and Support Inference from RGBD Images. ECCV, 2012.

[4] Aaron van den Oord, Sander Dieleman, Heiga Zen, Karen Simonyan, Oriol Vinyals, Alex Graves, Nal Kalchbrenner, Andrew Senior and Koray Kavukcuoglu. WaveNet: A Generative Model for Raw Audio. In arxiv, 2016.