まひろ量子のハックログ

プログラミングや機械学習などの知識を記録・共有します

GANを使って簡単に架空アイドル画像を自動生成(Progressive Growing of GANs)

f:id:twx:20181215154156p:plain
Artificial Idol
この記事で紹介する方法で、このような画像が作れるようになります。

最近趣味でやってる画像生成系のDNNについて簡単にレポートします。

1. Progressive Growing of GANsとは

Paperはこちら。 [1710.10196] Progressive Growing of GANs for Improved Quality, Stability, and Variation

Githubはこちらです。 https://github.com/tkarras/progressive_growing_of_gans

提案されているテクニックは、簡単にいうとGANの学習をする際に「小さいネットワーク」から段階的に「大きいネットワーク」に転移させていくことで、大きな画像においても安定した学習を可能にする、というものです。論文では、4x4の小さい画像から始めて1024x1024の大きな画像を生成することに成功したと述べられています。

f:id:twx:20181215151701p:plain
引用元:Figure 1; PROGRESSIVE GROWING OF GANS FOR IMPROVED QUALITY, STABILITY, AND VARIATION

また、1024x1024の学習に必要となる高画質な学習用画像を得るために様々な工夫がされています。簡単に言うと、顔の位置を揃える操作、超解像、背景のぼかしです。元となる顔画像のデータセットに対して、両目の位置を検出しその座標を起点としてトリミングすることで顔の位置をすべてのデータで合わせます。更に、超解像技術を用いて512x512の画像を1024x1024に高解像度化します。最後に、背景にブロー処理をかけてぼかします。

かなりの手間をかけたこのようなデータのクリーニングは、ハイクオリティな画像を生成するのに必須だと言われています。

今回はこの論文の再現実験として、実在しない架空のアイドルの顔画像を生成してみました。アイドルの生成には既に先駆者がいて、1年ほど前にかなりバズったのを覚えていらっしゃる方もいると思います。なので、目新しさは無いです。

2. データの準備

アイドル画像をひたすらクローリングしまくります。クローリング対象のURLを公開することは迷惑行為になってしまうので、すみませんが非公開とさせてください。ここでは、クローリングを行うコードをいくつか載せるに留めておきます。

2.1 google画像検索結果を保存するコード

Google検索で画像を手に入れる方法です。 google-images-download というpipモジュールを使います。このモジュールはコマンドライン上で pip コマンドを使ってインストールします。

# pipコマンドでインストール
pip install google_images_download

詳しい使用方法は以下のページが詳しいです。

co.bsnws.net

さて、これを使ってアイドル画像をダウンロードします。以下のXXXXXXXXXXXの部分を、任意の検索クエリに書き換えて実行すると大量の画像が手に入ります。 XXXXXXXXXXXには、アイドルの名前を入れると良いです。

  • get_images1.sh *
googleimagesdownload --keywords "XXXXXXXXXXX" --size large
googleimagesdownload --keywords "XXXXXXXXXXX" --size large
・
・
・
googleimagesdownload --keywords "XXXXXXXXXXX" --size large

2.2 アイドル画像が掲載されている特定ページをクローリング

soupなどの一般的なクロール技術を使って、Google検索ではなく、アイドルの写真をたくさん載せているサイトからクローリングします。迷惑行為になりかねないので、手順の公開は控えさせていただきます。

2.3 顔画像のトリミング

上の2.1と2.2の方法で数万枚オーダーの画像を集め終わったら、今度は写真の中から顔画像を検出して適切なサイズにトリミングします。これには以下のツールを使いました。

https://github.com/deepfakes/faceswap

これは元々、Faceswapという、2人の人物の顔を互いに入れ替えるタスクで有名なツールです。このタスクも、事前に学習データ(顔画像)に対して前処理を行う必要があり、顔をトリミングする機能をもつコードも含まれています。これを利用しましょう。

デフォルトでは以下のコマンドを実行すると、srcフォルダの中の全ての画像に対して顔検出を行い、両目の位置がx軸と平行になるように回転補正をかけたうえで、目の位置を起点に正方形の画像をトリミングしてくれます。

run python faceswap.py extract

しかし、問題がいくつかあります。 トリミング後の画像サイズは256x256で固定なので、高解像度画像が必要なPGGANsで使うには少し小さすぎます。また、顔がややズームアップされた状態でトリミングされるため、髪や服装があまり写らないという欠点もあります。更に、回転補正して正方形に切り出すため、もしも顔が画面端で斜めに検出されてしまうと、正方形に切り出した際に四隅にデッドスペースができてしまいます(以下のように)。 これらの点をなんとかして改善する必要があります。

f:id:twx:20181215173339p:plain
回転時に四隅が消えてしまう失敗例

まずは四隅のデッドスペースをなんとかします。これには、元論文にも書かれていますが、元画像の端っこに鏡像反転させた余白を付与するという手法を適用します。

f:id:twx:20181215175059p:plain
境界ミラーリングの例(引用元:PROGRESSIVE GROWING OF GANS FOR IMPROVED QUALITY, STABILITY, AND VARIATION)

以下のコマンドで、元画像が保存されているフォルダに対して、全ての画像の上下左右に10%のマージンを付与します。

import cv2
import numpy as np
from matplotlib import pyplot as plt
import glob

def mirror_padding(img_path):
    img1 = cv2.imread(img_path)
    padding_y = img1.shape[0] // 10
    padding_x = img1.shape[1] // 10
    img2 = cv2.copyMakeBorder(img1, padding_y, padding_y, padding_x, padding_x, cv2.BORDER_REFLECT_101)
    return img2    

image_paths = glob.glob('/Path/To/Src/Images/*')
for image_path in image_paths:
    img_name = image_path.split('/')[-1]
    img = mirror_padding(image_path)
    cv2.imwrite('/Path/To/Output/' + img_name, img)

こうすることで、もしも顔が画面端にあったとしても、ある程度回転角に余裕をもたせられます。

次に、顔がズームアップされてしまう件と、画像サイズが小さい問題を解決します。これは、Faceswapのソースを改造すればOKです。

以下の2箇所をこのように書き換えましょう。

# faceswap/plugins/Extract_Align.py
12c12
<         extracted = self.transform(image, alignment, size, 48)
---
>         extracted = self.transform(image, alignment, size, 48*3)
# faceswap/scripts/extract.py
129c129
<             256,
---
>             512,

これで、うまく顔のトップから、肩くらいまでがトリミングされます。

2.4 良質な画像の選別

数万枚の画像を実際に目で見て、良い画像と悪い画像に分けます。ここは、どうしても気合と根性が必要です。「正面を向いている」「見切れていない」「暗くない」「手で顔が隠れていない」といった条件に満たすものを選別します。

ただ、完全に手作業だとかなり辛いので、以下のような画像仕分け効率化ツールを作りました。

  • 仕分けツール.html *
<!DOCTYPE html>
<html>
<head>
  <title>仕分けツール</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
</head>
<body>
<script>
  var numFiles = 0;
  var files = new Array();
  var fileNames = new Array();
  var cursor = 0;
  var prevImageName, currImageName, nextImageName;

  var classA = new Array();
  var classB = new Array();
  var chache = new Array();

  function drawImageOnCanvas(file){
    var image = new Image();
    var reader = new FileReader();
    var canvas = $('#cur_canvas');
    var ctx = canvas[0].getContext('2d');
    reader.onload = function(evt) {
      image.onload = function() {
        ctx.clearRect(0, 0, 300, 300);
        ctx.drawImage(image, 0, 0, 300, 300);
      }
      image.src = evt.target.result;
    }
    reader.readAsDataURL(file);
  }

  function fileListDirectory(_files) {
    for (i=0; i<_files.length; i++) {
      var fileType = _files[i].type;
        if (fileType == 'image/jpeg' || fileType == 'image/png' ) {
          files.push(_files[i])
          fileNames.push(_files[i].name );
          numFiles ++;
        }
    }
    resetImage(cursor);
  }

  function resetImage(cursor) {
    prevImageName = (cursor == 0 ? 'なし' : fileNames[cursor-1]);
    currImageName = fileNames[cursor];
    nextImageName = (cursor == numFiles - 1 ? 'なし' : fileNames[cursor+1]);
    document.getElementById('previous').innerHTML = prevImageName;
    document.getElementById('current').innerHTML = currImageName;
    document.getElementById('next').innerHTML = nextImageName;
    drawImageOnCanvas(files[cursor]);
    document.getElementById('progress').innerHTML = (cursor+1) + '/' + numFiles;
  }

  function previous(){
    cursor --;
    if( cursor < 0 ) {
      cursor = 0;
    }
    resetImage(cursor);
  }
    
  function next(){
    cursor ++;
    if( cursor > numFiles-1 ) {
      cursor = numFiles-1;
    }
    resetImage(cursor);
  }

  function undo() {
    if(cursor > 0){
      var which = chache[cursor];
      if(which == 'A') {
        classA.pop();
      } else if (which == 'B') {
        classB.pop();
      }
      chache.pop();
      previous();
      document.getElementById('classA').innerHTML = classA.length;
      document.getElementById('classB').innerHTML = classB.length;
    }
  }

  function downloadData() {
    var hiddenElement = document.createElement('a');
    hiddenElement.href = 'data:attachment/text,' + encodeURI(classA);
    hiddenElement.target = '_blank';
    hiddenElement.download = 'NG.txt';
    hiddenElement.click();
    var hiddenElement = document.createElement('a');
    hiddenElement.href = 'data:attachment/text,' + encodeURI(classB);
    hiddenElement.target = '_blank';
    hiddenElement.download = 'OK.txt';
    hiddenElement.click();
  }

  window.onload = function() {
    function onKeyUp(e) {
      if(e.code=='KeyF') {
        classA.push( fileNames[cursor] );
        chache.push('A');
        next();
        document.getElementById('classA').innerHTML = classA.length;
      } else if(e.code=='KeyJ') {
        classB.push( fileNames[cursor] );
        chache.push('B');
        next();
        document.getElementById('classB').innerHTML = classB.length;
      }
      e.preventDefault();
    };

    // Set up key event handlers
    window.addEventListener('keyup', onKeyUp);
  };

</script>

<div class="container">
  <div class="row">
    <div class="col-sm-12 mt-5">
      <div class="btn btn-success p-0">
        <input class="p-1" type="file" webkitdirectory directory onChange="fileListDirectory(this.files)">
      </div>
      <div id=progress></div>
      <div style="display: none;">前の画像:<span id="previous">結果がここに表示されます。</span></div>
      <div style="display: none;">今の画像:<span id="current">結果がここに表示されます。</span></div>
      <div style="display: none;">次の画像:<span id="next">結果がここに表示されます。</span></div>

      <div>
        <canvas id="cur_canvas" width="300" height="300"></canvas>
      </div>
      <button class="btn btn-success" onclick="undo()">1つ戻る</button-->
      <button class="btn btn-success" onclick="downloadData()">ダウンロード</button-->
    </div>
    <div class="col-sm-6 mt-5">
      <h2>不良データ</h2>
      <div id="classA">
      </div>
    </div>
    <div class="col-sm-6 mt-5">
      <h2>優良データ</h2>
      <div id="classB">
      </div>
    </div>

  </div>
</div>

</body>
</html>

このhtmlをローカルに保存してChromeで開いてください。

f:id:twx:20181215182633g:plain
仕分けツール

フォルダを選択できるボタンがあるので、これまで準備を進めてきた「トリミング済みの顔画像が大量に保存されているフォルダ」を選択してください。すると、画面中央に画像が出現しますので、画像にフォーカスをあてたうえで「F」キーと「J」キーで、「NG」か「OK」かを仕分けしてください。要は、キーボード操作でスピーディーに仕分けができるというツールです。

3秒で1枚をさばけると仮定すると、8時間強で1万枚さばけます。

最後に、「ダウンロードボタン」を押すと、「OK」に仕分けられた画像の名前が列挙されたテキストファイルを得ることができます。あとは、この「OKと判定した画像」だけを別のフォルダにコピーするなりしてください。

こうして、良質な学習データが手に入りました! 私はこれで1万3000枚ほど集めました。

f:id:twx:20181215183848p:plain
あつめた画像たち

3. 学習する

学習にはGPUが必要です。Google ColaboratoryならGPUを無料で使えます(2018年12月現在)。

Google Colaboratoryは、Jupyter notebook風にブラウザ上でコードを実行できるGoogleのサービスです。詳しくは以下からどうぞ。

https://colab.research.google.com

Google ColaboratoryはGoogle Driveと連携できます。つまり、自作の学習データをGoogle Driveに置いておくと、それをGoogle Colaboratoryから読み込むことができます。まず、先程作った画像をDriveにアップロードします。フォルダをzipで圧縮してから送ります。先程の「OKと判定した画像」が大量に入っているフォルダをzip化し、OK_idol.zipとします。

f:id:twx:20181215184709p:plain
Google Driveの画面

私は、Google Driveのルート直下に、dataというフォルダを作り、その中にOK_idol.zipを保存しました。

また、学習済モデルや、生成画像を保存したりするのに使う作業用のフォルダも作っておきます。 Google Driveのルート直下に、workというフォルダを作り、その中にPGGANというフォルダを作っておきます。

f:id:twx:20181215192307p:plain
Google Driveの画面2

ここから、Colaboratory上での操作になります。

まず、Google DriveをColaboratoryにマウントします。そして、PGGANsのソースをColaboratory環境上にクローンします。

from google.colab import drive
drive.mount('/content/drive')

%cd /content/drive/My\ Drive/work/PGGAN
!git clone https://github.com/tkarras/progressive_growing_of_gans.git

クローンが完了すると、Drive上で work/PGGAN/progressive_growing_of_gansの中に、config.pyというファイルが見つかります。

このconfigを以下のように編集します。

class EasyDict(dict):
    def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)
    def __getattr__(self, name): return self[name]
    def __setattr__(self, name, value): self[name] = value
    def __delattr__(self, name): del self[name]

#----------------------------------------------------------------------------
# Paths.

data_dir = '/content/my_dataset'
result_dir = 'results'

#----------------------------------------------------------------------------
# TensorFlow options.

tf_config = EasyDict()  # TensorFlow session config, set by tfutil.init_tf().
env = EasyDict()        # Environment variables, set by the main program in train.py.

tf_config['graph_options.place_pruned_graph']   = True      # False (default) = Check that all ops are available on the designated device. True = Skip the check for ops that are not used.
env.TF_CPP_MIN_LOG_LEVEL                        = '1'       # 0 (default) = Print all available debug info from TensorFlow. 1 = Print warnings and errors, but disable debug info.

#----------------------------------------------------------------------------
# Official training configs, targeted mainly for CelebA-HQ.
# To run, comment/uncomment the lines as appropriate and launch train.py.

desc        = 'pgan'                                        # Description string included in result subdir name.
random_seed = 1000                                          # Global random seed.
dataset     = EasyDict()                                    # Options for dataset.load_dataset().
train       = EasyDict(func='train.train_progressive_gan')  # Options for main training func.
G           = EasyDict(func='networks.G_paper')             # Options for generator network.
D           = EasyDict(func='networks.D_paper')             # Options for discriminator network.
G_opt       = EasyDict(beta1=0.0, beta2=0.99, epsilon=1e-8) # Options for generator optimizer.
D_opt       = EasyDict(beta1=0.0, beta2=0.99, epsilon=1e-8) # Options for discriminator optimizer.
G_loss      = EasyDict(func='loss.G_wgan_acgan')            # Options for generator loss.
D_loss      = EasyDict(func='loss.D_wgangp_acgan')          # Options for discriminator loss.
sched       = EasyDict()                                    # Options for train.TrainingSchedule.
grid        = EasyDict(size='1080p', layout='random')       # Options for train.setup_snapshot_image_grid().

# Dataset (choose one).
desc += '-idol512';               dataset = EasyDict(tfrecord_dir='OK_idol_for_PGGAN'); train.network_snapshot_ticks = 1; train.mirror_augment = True

# Resume
#train.resume_run_id = '/content/drive/My Drive/work/PGGAN/progressive_growing_of_gans/results/032-pgan-idol512-preset-v2-1gpu-fp32/network-snapshot-xxxxxx.pkl';
#train.resume_kimg = 0

# Config presets (choose one).
desc += '-preset-v2-1gpu'; num_gpus = 1; sched.minibatch_base = 4; sched.minibatch_dict = {4: 128, 8: 128, 16: 128, 32: 64, 64: 32, 128: 16, 256: 8, 512: 4}; sched.G_lrate_dict = {1024: 0.0015}; sched.D_lrate_dict = EasyDict(sched.G_lrate_dict); train.total_kimg = 12000

# Numerical precision (choose one).
desc += '-fp32'; sched.max_minibatch_per_gpu = {256: 16, 512: 8, 1024: 4}
#----------------------------------------------------------------------------

コンフィグの各行の意味を詳しく知りたい方はオリジナルのgithubのページをご確認ください。ここで重要なのは、データセットのパスと、resumeの設定です。resumeとは、学習がある程度進んで保存したモデルから、学習を再開することを言います。1番最初に学習を開始するときはresumeは関係ありません。

まず、データセットのパスに注意してください。 data_dir = '/content/my_dataset'のように指定しています。さきほど、Google Driveに保存したので、データは/content/drive/My\ Drive/data/OK_idol.zipに格納されているはずです。ここでzipを展開すれば良い気がしますが、実はこれは良くありません。Google driveは書き込みが非常に遅いため、zipを展開して大量の画像を書き込むのに長時間かかってしまいます。一方、Colaboratory上での展開は高速です。なので、以下のようにしてColaboratory上にディレクトリを作り、そこに画像を展開してください。

!mkdir /content/my_dataset
!unzip /content/drive/My\ Drive/data/OK_idol.zip -d /content/my_dataset > /dev/null 2>&1 & 

展開には数分かかります。以下のコマンドで、画像が何枚展開されたかをカウントできます。

!echo /content/my_dataset/OK_idol/* | xargs ls | wc

全て展開できたことを確認したら、以下のコマンドを実行します。詳しくはPGGANのREADMEに書いてありますが、自作のデータセットを、PGGANが読み込める形に変形するコマンドです。

!python dataset_tool.py create_from_images /content/my_dataset/OK_idol_for_PGGAN /content/my_dataset/OK_idol

以上のコマンドが完了したら、次のコマンドを実行します。これで学習が開始します。

!python train.py

Colaboratoryは、ある条件を満たすと環境が丸ごと削除されてしまいます。「ブラウザのセッションが切れて90分以上経過する」「連続稼働時間が12時間を上回る」のどちらか一方でも満たすと削除されてしまいます。したがって、ブラウザを起動してPCを放置していても、12時間しか学習を回せません。私は、毎晩0時頃、夜寝る前と、12時頃にお昼ご飯を食べるときに、resume機能を使って学習を再開させています。

学習を再開させる方法は、上のコンフィグで、次の行を書き換えればOKです。

# Resume
train.resume_run_id = '/content/drive/My Drive/work/PGGAN/progressive_growing_of_gans/results/032-pgan-idol512-preset-v2-1gpu-fp32/network-snapshot-xxxxxx.pkl';
train.resume_kimg = xxxxxxx

最初に学習を始めるときは、これらの行はコメントアウトされていました。resumeで2回目以降の学習を行うときはコメントを外して、032-pgan-idol512-preset-v2-1gpu-fp32/network-snapshot-xxxxxx.pklの部分を、ご自身のGoogle Driveに保存されている学習済みモデルの名前にリネームしてください。これが、resume時に使用するモデルとなります。

また、

train.resume_kimg = xxxxxxx

の右辺には、network-snapshot-xxxxxx.pkl のxxxxxと同じ値を整数で指定してください。

学習は、12時間×2セットを毎日行い、3週間ほど続ける必要があります。

3週間の学習の末、得られたモデルを使ってアイドル画像を生成してみました。以下がその結果です。

f:id:twx:20181215204301g:plain
生成されたアイドルが変形していく様子

こんな感じで動画も作れます。

いい感じのショットを選んで4x2にアペンドしてみました。

f:id:twx:20181215154156p:plain
Artificial Idol

なかなかの出来ですね!

以上、今回はProgressive Growing of GANsを使って簡単にアイドル画像を自動生成してみました。良い記事だと思っていただいた方は、SNSでのシェア、ブログからのリンク、「読者になる」ボタンのクリック、「★」ボタンのクリック、よろしくお願いします! ではまた次の記事でお会いしましょう!

Kozuko Mahiro's Hacklog ―― Copyright © 2018 Mahiro Kazuko