ディープラーニングのフレームワークを使って、アニメ本編から線画を生成します。ただし、一切訓練や訓練データを与えていません。アニメ1話分全4.3万フレームの線画化を1時間程度で終わらせることができました。
あらまし
このようにアニメ本編から自動的に線画が生成できます。ディープラーニングのフレームワークを使うとできます。ただし、一切訓練をしていません。
OP https://t.co/52t0dFZNZL pic.twitter.com/7aCEJ9SkTc
— しこあん@『モザイク除去本』(技術書典6)好評通販中 (@koshian2) 2019年7月1日
本編1 https://t.co/52t0dFZNZL pic.twitter.com/oicU8UPopS
— しこあん@『モザイク除去本』(技術書典6)好評通販中 (@koshian2) 2019年7月1日
本編2 https://t.co/52t0dFZNZL pic.twitter.com/K7Y6scHiqK
— しこあん@『モザイク除去本』(技術書典6)好評通販中 (@koshian2) 2019年7月1日
元ネタ
そこそこな線画を目指す OpenCV
https://qiita.com/khsk/items/6cf4bae0166e4b12b942
こちらの記事のアルゴリズムを使います。もともとはOpenCVの実装ですが、ディープラーニングの関数に変換することができます。
OpenCV→ディープラーニングへの置き換え
OpenCVを使った線画化のアルゴリズムは以下の処理の構成です。
- カラー画像のグレースケール化
- 1の画像を8近傍で膨張処理を1回
- 膨張処理した画像と1の画像の差分の絶対値を取る
- 3の画像の白黒反転させる
膨張処理をどうにかすれば、あとは四則演算や行列積でできそうな気がしますよね。OpenCVのモルフォロジー変換のドキュメントを見ると次の式が書かれていました。
$$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の線画化の処理を置き換えると次のようになります。
- カラー画像のグレースケール化 → 行列積 or 1x1畳み込み
- 1の画像を8近傍で膨張処理を1回 → stride=1, padding="same"の3x3 MaxPooling
- 膨張処理した画像と1の画像の差分の絶対値を取る → ただの絶対値+引き算
- 3の画像の白黒反転させる → 1.0-画像
OpenCVの処理を無事にディープラーニングの文脈に置き換えることができました。あとは実装するだけです。
何が美味しいの?
ディープラーニングフレームワークに置き換えてなにが美味しいの?という疑問があるかもしれません。理由はいくつかあります。
- GPUブーストが簡単に使える
OpenCVの処理をGPU上で簡単に行うことができます。KerasならCPUと同じコードでできます。 - ディープラーニング上で「線画」の概念が扱える
もともと自分が欲しかったのはこっちで、損失関数内で線画の損失関数を定義したかったのです。この方法なら、カラー画像→線画への変換をディープラーニングのモデル内で行えます。
ディープラーニングフレームワークによる実装
元画像
元の記事と同じように「響け!ユーフォニアム」の画像を使います。これを「eupho.jpg」とします。
Keras
Kerasの場合は以下のような処理になります。ディープラーニングのフレームワークでは、画像は4階テンソルとして扱うため前後にテンソル変換を挟んでいます(load_tensor, show_tensor)。
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()
うまくいきました。
PyTorch
PyTorchの場合は以下のようになります。グレースケール化で1x1畳み込みを使っているのが違うところです。
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()
ディープラーニングなのに訓練/データ不要
このアルゴリズムの面白いところなのですが、ディープラーニングなのに学習する係数が1個もありません。したがって、モデルを定義をした段階で訓練フェーズをすっ飛ばしてすぐ使うことができます。
アニメ1話分を全て線画に変換してみよう
応用例として、YouTubeにあるアニメ1話分を丸々線画に変換して、Before-Afterで動画として保存します。以下の流れで行います。
- YouTubeからPyTubeで動画をダウンロード
- フレーム単位で静止画(カラー画像)で切り出し
- Kerasでカラー画像を全て線画に変換
- もとのカラー画像と線画を組み合わせて動画として再エンコード
今回はKerasを使っていますが、PyTorchや他のフレームワークでもできます。暇な方はやってみてはいかがでしょうか。
ちなみにColabでやる場合はYouTubeの再生地域制限に引っかかるケースがあります。日本から動画が見れてもColabのサーバーがアメリカにあるので、アメリカからの接続と認識されてDLできないケースがあります(その場合は日本のHTTPSプロキシ経由で接続しますが説明は省略します)。ローカルにDLする場合は特に心配しなくてOKです。
必要ライブラリ
ffmpeg
ffmpegはPythonのライブラリの他に、本体のバイナリが必要なので本家から必要なバイナリをダウンロードしておきます。Windowsならffmpegのexeファイルを実行ディレクトリにおきます。
OpenH264
H264でエンコードする際に必要。以下のリポジトリから、H264の1.8.0かつ64ビットのライブラリをOSに応じてダウンロード。同じく実行ディレクトリにおいておきます。
その他ライブラリ
pip install ffmpeg-python
pip install pytube
他にインストールされていないライブラリがあったら適宜pip installでインストールします。
コード
動画はYouTubeの公式配信の侵略!イカ娘の第1話を使っています。
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時間程度でした。ffmpegによるフレームの切り出しとストレージがボトルネックになるので、CPU性能とストレージ速度(HDDよりSSD)を上げると高速に出力できます。GPUはほとんど使っていませんでした(使用率でせいぜい5%程度)。もちろんこのあとDeepの訓練につなげる場合は、GPU等のデバイスがあればより高速化できます。
しかし、アニメ1話分の線画が1時間でできてしまうというのはびっくりでした。1話で4.3万フレームあったので(29.97fps × 累計秒)、アニメ本編と既存の画像処理を組み合わせればかなり簡単かつ大量にデータが作れるのかもしれません。
応用 / まとめ
これを使うと、(線画, カラー画)のペアデータが簡単に作れるので2、生成モデルとの相性が良くなります。具体的には、pix2pixなどを使えばアニメの原画~動画の工程をかなり自動化できるのではないでしょうか。
この記事のポイントは、OpenCVで書けるような従来の画像処理も、畳み込みやプーリングといったディープラーニングの操作にある程度置き換えることができるということです。これに気づくとディープラーニングの幅がぐっと広がると思われます。