[訓練・データ不要]ディープラーニングフレームワークを使ったアニメの線画の自動生成

ディープラーニングのフレームワークを使って、アニメ本編から線画を生成します。ただし、一切訓練や訓練データを与えていません。アニメ1話分全4.3万フレームの線画化を1時間程度で終わらせることができました。

## あらまし
このようにアニメ本編から自動的に線画が生成できます。ディープラーニングのフレームワークを使うとできます。ただし、一切訓練をしていません。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="und" dir="ltr">OP　<a href="https://t.co/52t0dFZNZL">https://t.co/52t0dFZNZL</a> <a href="https://t.co/7aCEJ9SkTc">pic.twitter.com/7aCEJ9SkTc</a></p>&mdash; しこあん@『モザイク除去本』（技術書典6）好評通販中 (@koshian2) <a href="https://twitter.com/koshian2/status/1145587978388922369?ref_src=twsrc%5Etfw">2019年7月1日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">本編1　<a href="https://t.co/52t0dFZNZL">https://t.co/52t0dFZNZL</a> <a href="https://t.co/oicU8UPopS">pic.twitter.com/oicU8UPopS</a></p>&mdash; しこあん@『モザイク除去本』（技術書典6）好評通販中 (@koshian2) <a href="https://twitter.com/koshian2/status/1145595926427213824?ref_src=twsrc%5Etfw">2019年7月1日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">本編2　<a href="https://t.co/52t0dFZNZL">https://t.co/52t0dFZNZL</a> <a href="https://t.co/K7Y6scHiqK">pic.twitter.com/K7Y6scHiqK</a></p>&mdash; しこあん@『モザイク除去本』（技術書典6）好評通販中 (@koshian2) <a href="https://twitter.com/koshian2/status/1145596078516948992?ref_src=twsrc%5Etfw">2019年7月1日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 元ネタ
**そこそこな線画を目指す OpenCV**
https://qiita.com/khsk/items/6cf4bae0166e4b12b942

こちらの記事のアルゴリズムを使います。もともとはOpenCVの実装ですが、ディープラーニングの関数に変換することができます。

## OpenCV→ディープラーニングへの置き換え
OpenCVを使った線画化のアルゴリズムは以下の処理の構成です。

1. カラー画像のグレースケール化
2. 1の画像を8近傍で膨張処理を1回
3. 膨張処理した画像と1の画像の差分の絶対値を取る
4. 3の画像の白黒反転させる

膨張処理をどうにかすれば、あとは四則演算や行列積でできそうな気がしますよね。[OpenCVのモルフォロジー変換のドキュメント](http://opencv.jp/opencv-2svn/cpp/imgproc_image_filtering.html#cv-dilate)を見ると次の式が書かれていました。

$$dst(x,y)=\max_{(x',y'):\rm{element} (x',y')\neq0}src(x+x',y+y') $$

この式をよく見ると、**MaxPoolingっぽくない**ですか？　ただし、普通のMaxPoolingは解像度が下がってしまうので、入力と出力の解像度が変わらないように「pool_size=(3,3), stride=1, padding="same"」という特殊なプーリングを行います（GoogLe NetのInceptionブロックでこういう使い方しています）。

つまり、ディープラーニングの処理でOpenCVの線画化の処理を置き換えると次のようになります。

1. カラー画像のグレースケール化　→　**行列積 or 1x1畳み込み**
2. 1の画像を8近傍で膨張処理を1回 → **stride=1, padding="same"の3x3 MaxPooling**
3. 膨張処理した画像と1の画像の差分の絶対値を取る → **ただの絶対値＋引き算**
4. 3の画像の白黒反転させる　→　**1.0-画像**

OpenCVの処理を無事にディープラーニングの文脈に置き換えることができました。あとは実装するだけです。

### 何が美味しいの？
ディープラーニングフレームワークに置き換えてなにが美味しいの？という疑問があるかもしれません。理由はいくつかあります。

* GPUブーストが簡単に使える  
  OpenCVの処理をGPU上で簡単に行うことができます。KerasならCPUと同じコードでできます。
* ディープラーニング上で「線画」の概念が扱える  
  もともと自分が欲しかったのはこっちで、損失関数内で線画の損失関数を定義したかったのです。この方法なら、カラー画像→線画への変換をディープラーニングのモデル内で行えます。

## ディープラーニングフレームワークによる実装
### 元画像
元の記事と同じように「響け！ユーフォニアム」の画像を使います。これを「eupho.jpg」とします。

![eupho.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/3627ed93-bc08-152d-e65a-2f5af9ed171c.jpeg)


### Keras
Kerasの場合は以下のような処理になります。ディープラーニングのフレームワークでは、画像は4階テンソルとして扱うため前後にテンソル変換を挟んでいます（load_tensor, show_tensor）。

```python
import numpy as np
from PIL import Image
import keras.backend as K
import matplotlib.pyplot as plt

def load_tensor():
    with Image.open("eupho.jpg") as img:
        array = np.asarray(img, np.float32) / 255.0 # [0, 1]
        array = np.expand_dims(array, axis=0) # KerasはNHWC
        return K.variable(array)

def show_tensor(input_image_tensor):
    img = K.eval(input_image_tensor * 255.0)
    img = img.astype(np.uint8)[0,:,:,0]    
    plt.imshow(img, cmap="gray")
    plt.show()

def linedraw():
    # データの読み込み
    x = load_tensor()
    # Y = 0.299R + 0.587G + 0.114B　でグレースケール化
    gray_kernel = K.variable(
        np.array([0.299, 0.587, 0.114], np.float32).reshape(3, 1))
    x = K.dot(x, gray_kernel) # dot積でOK
    # 3x3カーネルで膨張1回（膨張はMaxPoolと同じ）
    dilated = K.pool2d(x, pool_size=(3, 3), strides=(1, 1), padding="same")
    # 膨張の前後でL1の差分を取る
    diff = K.abs(x-dilated)  
    # ネガポジ反転
    x = 1.0 - diff
    # 結果表示
    show_tensor(x)

if __name__ == "__main__":
    linedraw()
```

![keras.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/c5239ff5-95ac-c7bc-d0a7-a99d9fa7d242.png)

うまくいきました。

### PyTorch
PyTorchの場合は以下のようになります。グレースケール化で1x1畳み込みを使っているのが違うところです。

```python
import numpy as np
from PIL import Image
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt

def load_tensor():
    with Image.open("eupho.jpg") as img:
        array = np.asarray(img, np.float32) / 255.0 # [0, 1]
        array = np.expand_dims(array, axis=0)
        array = np.transpose(array, [0, 3, 1, 2]) # PyTorchはNCHW
        return torch.as_tensor(array)

def show_tensor(input_image_tensor):
    img = input_image_tensor.numpy() * 255.0
    img = img.astype(np.uint8)[0,0,:,:]    
    plt.imshow(img, cmap="gray")
    plt.show()

def linedraw():
    # データの読み込み
    x = load_tensor()
    # Y = 0.299R + 0.587G + 0.114B　でグレースケール化
    gray_kernel = torch.as_tensor(
        np.array([0.299, 0.587, 0.114], np.float32).reshape(1, 3, 1, 1))
    x = F.conv2d(x, gray_kernel) # 行列積は畳み込み関数でOK
    # 3x3カーネルで膨張1回（膨張はMaxPoolと同じ）
    dilated = F.max_pool2d(x, kernel_size=3, stride=1, padding=1)
    # 膨張の前後でL1の差分を取る
    diff = torch.abs(x-dilated)    
    # ネガポジ反転
    x = 1.0 - diff
    # 結果表示
    show_tensor(x)

if __name__ == "__main__":
    linedraw()
```

![pytorch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/5524b072-88b6-38af-b5b9-6b9a9a63d72c.png)

### ディープラーニングなのに訓練/データ不要
このアルゴリズムの面白いところなのですが、**ディープラーニングなのに学習する係数が1個もありません**。したがって、モデルを定義をした段階で訓練フェーズをすっ飛ばしてすぐ使うことができます。

## アニメ1話分を全て線画に変換してみよう
応用例として、YouTubeにあるアニメ1話分を丸々線画に変換して、Before-Afterで動画として保存します。以下の流れで行います。

* YouTubeからPyTubeで動画をダウンロード
* フレーム単位で静止画（カラー画像）で切り出し
* Kerasでカラー画像を全て線画に変換
* もとのカラー画像と線画を組み合わせて動画として再エンコード

今回はKerasを使っていますが、PyTorchや他のフレームワークでもできます。暇な方はやってみてはいかがでしょうか。

ちなみにColabでやる場合はYouTubeの[再生地域制限](https://polsy.org.uk/stuff/ytrestrict.cgi)に引っかかるケースがあります。日本から動画が見れてもColabのサーバーがアメリカにあるので、アメリカからの接続と認識されてDLできないケースがあります（その場合は日本のHTTPSプロキシ経由で接続しますが説明は省略します）。ローカルにDLする場合は特に心配しなくてOKです。

### 必要ライブラリ
#### ffmpeg
ffmpegはPythonのライブラリの他に、本体のバイナリが必要なので[本家から](https://ffmpeg.org/download.html)必要なバイナリをダウンロードしておきます。Windowsならffmpegのexeファイルを実行ディレクトリにおきます。

#### OpenH264
H264でエンコードする際に必要。以下のリポジトリから、H264の1.8.0かつ64ビットのライブラリをOSに応じてダウンロード。同じく実行ディレクトリにおいておきます。

https://github.com/cisco/openh264/releases

#### その他ライブラリ

```
pip install ffmpeg-python
pip install pytube
```

他にインストールされていないライブラリがあったら適宜pip installでインストールします。

### コード
動画はYouTubeの公式配信の[侵略！イカ娘の第1話](https://www.youtube.com/watch?v=JUIk6a1nzFw)を使っています。

```python
import ffmpeg
from pytube import YouTube
import os
import glob
import cv2
import numpy as np
from tqdm import tqdm

import keras
from keras import layers
import keras.backend as K
from PIL import Image

def download_youtube():
    if not os.path.exists("video"):
        os.mkdir("video")
    yt = YouTube("https://www.youtube.com/watch?v=JUIk6a1nzFw")
    query = yt.streams.filter(fps=30, subtype="mp4", res="360p").all()
    query[0].download("video")

def extract_images():
    if not os.path.exists("images"):
        os.mkdir("images")
    stream = ffmpeg.input(glob.glob("video/*")[0])
    stream = ffmpeg.output(stream, f"images/frame_%05d.png", f="image2", q=4)
    ffmpeg.run(stream) 

def linedraw_func(input_tensor):
    gray_kernel = K.variable(
        np.array([0.299, 0.587, 0.114], np.float32).reshape(3, 1))
    x = K.dot(input_tensor, gray_kernel) # dot積でOK
    # 1枚単位でmin-maxスケーリングしてコントラスト補正する
    mins = K.min(x, axis=(1,2,3), keepdims=True)
    maxs = K.max(x, axis=(1,2,3), keepdims=True)
    x = (x-mins) / (maxs-mins+K.epsilon())
    dilated = K.pool2d(x, pool_size=(3, 3), strides=(1, 1), padding="same")
    diff = K.abs(x-dilated)  
    x = 1.0 - diff
    return x

IMG_WIDTH, IMG_HEIGHT = 640, 360

def linedraw_model():
    input = layers.Input((IMG_HEIGHT, IMG_WIDTH, 3))
    x = layers.Lambda(linedraw_func)(input)
    return keras.models.Model(input, x)

def convert_all():
    # KerasのImageDataGeneratorはroot/classes/img という構造なので、
    # ルートディレクトリをカレントディレクトリにし、サブディレクトリとして画像のあるディレクトリを1クラスとして指定
    # batch_size分の画像を一度に読み込む、シャッフルはしないようにする（連番で読まれる）
    gen = keras.preprocessing.image.ImageDataGenerator(rescale=1.0/255).flow_from_directory(
        "./", class_mode=None, classes=["images"], batch_size=256, shuffle=False, target_size=(IMG_HEIGHT, IMG_WIDTH)
    )

    # 出力ディレクトリ
    if not os.path.exists("images_line"):
        os.mkdir("images_line")

    # ファイル数
    n_files = len(glob.glob("images/*.png"))

    # 線画化の操作をモデルとして読み込む
    model = linedraw_model()
    cnt = 1

    for X in tqdm(gen):
        out = model.predict_on_batch(X) # 線画化
        # ファイルの保存
        for i in range(out.shape[0]):
            img = (out[i,:,:,0]*255.0).astype(np.uint8)
            with Image.fromarray(img) as img:
                img.save(f"images_line/frame_{cnt:05}.png")
            cnt += 1
            if cnt > n_files: return  

def encode():
    fourcc = cv2.VideoWriter_fourcc("h", "2", "6", "4")
    video = cv2.VideoWriter("output.mp4", fourcc, 29.97, (IMG_WIDTH, IMG_HEIGHT*2))
    n = len(glob.glob("images/*"))
    for i in tqdm(range(1, n + 1)):
        img_original = cv2.imread(f"images/frame_{i:05}.png")
        img_original = cv2.resize(img_original, (IMG_WIDTH, IMG_HEIGHT), cv2.INTER_LANCZOS4)
        img_convert = cv2.imread(f"images_line/frame_{i:05}.png")
        #img_convert = cv2.resize(img_convert, (IMG_WIDTH, IMG_HEIGHT), cv2.INTER_LANCZOS4)
        x = np.concatenate([img_original, img_convert], axis=0)
        video.write(x)

if __name__ == "__main__":
    encode()
```

これで生成したのが冒頭の動画。ただし、Twitterの埋め込み動画の画質が悪いので実際はもう少し綺麗に出てたりします[^1]。

[^1]: HTMLのvideoタグ対応していればプレイヤー付きで直にMP4再生できますが、QiitaはそもそもMP4のアップロード非対応です。また、YouTubeの埋め込みにも対応していません。したがって、低画質ながらもTwitterの埋め込みか、256色のGIFアニメぐらいしか方法がありません。あのさぁ……

### 生成結果比較
実際の静止画としての出力はこちらになります。カラー画像との比較です。

![frame_13217.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/d10818b7-0345-ea95-a53f-979e9e0b956e.png)
![frame_13217.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/a6745f2a-7593-5278-cc3a-c16796833a3e.png)
![frame_04940.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/47e5b36c-8b5f-f75f-f825-837ff1d7eede.png)
![frame_04940.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/232088/a5a61057-0486-4c90-00ac-ca5b12a89b7d.png)

なかなか良いのではないでしょうか？

### 速度
ダウンロードからエンコードまでおおよそ1時間程度でした。ffmpegによるフレームの切り出しとストレージがボトルネックになるので、**CPU性能とストレージ速度（HDDよりSSD）を上げる**と高速に出力できます。GPUはほとんど使っていませんでした（使用率でせいぜい5%程度）。もちろんこのあとDeepの訓練につなげる場合は、GPU等のデバイスがあればより高速化できます。

しかし、アニメ1話分の線画が1時間でできてしまうというのはびっくりでした。1話で4.3万フレームあったので（29.97fps × 累計秒）、アニメ本編と既存の画像処理を組み合わせればかなり簡単かつ大量にデータが作れるのかもしれません。

## 応用 / まとめ
これを使うと、(線画, カラー画)のペアデータが簡単に作れるので[^2]、生成モデルとの相性が良くなります。具体的には、pix2pixなどを使えばアニメの原画～動画の工程をかなり自動化できるのではないでしょうか。

[^2]: Qiita内で探してもpix2pixで似たようなことをやっている例はいくつかあります

この記事のポイントは、**OpenCVで書けるような従来の画像処理も、畳み込みやプーリングといったディープラーニングの操作にある程度置き換えることができる**ということです。これに気づくとディープラーニングの幅がぐっと広がると思われます。