【逃げ恥】恋ダンスでただガッキーだけを見ていたかったのでディープラーニングする(前編)

ガッキー視聴を毎週の楽しみにしている方も多いのではないでしょうか。
かくいう私もその一人です。

現代社会の荒波に揉まれては心が荒む日々。
灼熱地獄の砂漠に与えられた一縷の望み、オアシス。
私にとって、恋ダンスを踊るガッキーとはそのような存在といえるでしょう。

そういうわけで、踊っているガッキーを毎日のように眺めているわけですが、ここで一つ不都合があります。
そう、ギャラリーが多すぎるということ。

私はただガッキーだけを見ていたいのですが、残念ながらそうは問屋が卸しません。 他の方々が踊っていたり、最悪の場合、ガッキーが全く画面に出ていないタイミングもあります(何を考えて編集しているんだ……)。

というわけで、オアシスの水を濾過するが如く、ガッキー以外の登場人物には退場願うこととします。

作業の流れとしては、

  1. ガッキーの顔かそうでないかを二値分類する判別機をつくる
  2. ガッキー以外の人の顔をガッキーで上書きする

という感じになります。
随分と前口上が長くなりましたが、早速取り掛かります。


書くのは恥だが役に立つ

……はず。

注意は頭にくるが役に立つ

  • 記載しているソースコードは、公開用に一部修正して載せているので、もしかすると動かない場合があるかもしれません。
  • ディープラーニングおよび画像処理に関しては全くのド素人なので、誤った記述が含まれている恐れがあります。

環境は人それぞれ違うが役に立つ

OpenCV3の導入は面倒だが役に立つ

下記記事を参考にOpenCV3をPythonから使用できるように準備します。

Mac OS X で OpenCV 3 + Python 2/3 の開発環境を整備する方法 – ymyzk’s blog

自分の環境下では、2016年12月の段階で

brew install opencv3 --with-python3

の実行時にエラーが発生していました。
もし同様の症状であれば、下記記事の解決策で解消できるはずです。

【macOS Sierra】OpenCV 3をbrewでインストールできない - プログラムは、用いる言葉の選択で決まる

ともかく、最終的にOpenCV3をインポートしてバージョンが確認できればOKです。

$ python
>>> import cv2
>>> cv2.__version__
'3.1.0-dev'

容量は食うが役に立つ

何はともあれ、学習に使用するガッキーのお顔が無ければ始まりません。
画像収集の方法はいくつか考えられるかと思いますが、今回はTumblr の投稿の中から、新垣結衣のタグがついた画像付きの投稿を収集することにしました。

ちなみに、Tumblrにどんな画像が上がっているのか、というのは実際に見に行けばよく分かりますので、リンクを置いておきます。

https://www.tumblr.com/search/新垣結衣

見始めると一日が終わってしまうので気をつけてください。

実際の画像収集ですが、下記の2ステップで行いました。

  1. 投稿されている画像のURLを保存
  2. URLのリストから画像の保存

画像ダウンロードは並行して行っても問題無いとは思いますが、念のため処理を分けました。

画像URL取得のコードは下記の通りです。
TumblrAPI_KEYが必要なので、各自でご用意ください。
また、requestsを使用するので、pip install requestsも事前に行ってください。

import requests
import time

num = 50
url = 'https://api.tumblr.com/v2/tagged'
payload = {
    'api_key': 'YOUR_API_KEY',
    'tag': '新垣結衣',
    'before': ''
}

photo_urls = []
for i in range(num):
    r = requests.get(url, params=payload)
    r_json = r.json()

    for data in r_json['response']:
        if data['type'] != 'photo':
            continue
        for photo in data['photos']:
            photo_urls.append(photo['original_size']['url'])

    payload['before'] = r_json['response'][len(r_json['response']) - 1]['timestamp']
    time.sleep(1)

with open('photo_urls.txt', 'w') as file:
    for url in photo_urls:
        file.write('%s\n' % url)

一度のリクエストにつき20件の投稿が取得できるので、num = 50でおよそ1000件分の投稿から画像URLを取得したことになります。
次に、生成したURLリストから、実際に画像を取得します。

import requests
import time

photo_urls = []
with open('photo_urls.txt', 'r') as f:
    line = f.readline()
    while line:
        photo_urls.append(line.strip())
        line = f.readline()

for i, url in enumerate(photo_urls):
    extension = url[-3:]
    if extension != 'jpg' and extension != 'png':
        continue
    with open('./TumblrImages/' + str(i) + '.jpg', 'wb') as handler:
        response = requests.get(url)
        if not response.ok:
            continue
        handler.write(response.content)
    time.sleep(1)

URLから順次画像を取得し、TumblrImagesディレクトリに格納します。

ひとまずこれで画像の収集作業は終了です。
自分の場合は、最終的に1931枚の画像が集まりました。

忍耐は辛いが役に立つ

一生懸命収集してきた画像ですが、このままでは顔の特徴データとしては使えません。
なので、各々の画像に対してOpenCVを用いて顔を検出し、顔の範囲を切り出す処理を行います。

import os
import cv2

data_dir_path = './TumblrImages/'
image_list = os.listdir(data_dir_path)  # 恐らくglobで取得した方が良い

front_cascade = cv2.CascadeClassifier('/path/to/your/opencv3/haarcascade_frontalface_default.xml')
count = 0
for i, image_name in enumerate(image_list):
    image = cv2.imread(data_dir_path + image_name)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    frontfaces = front_cascade.detectMultiScale(gray, scaleFactor=1.2, minNeighbors=5)
    for (x, y, w, h) in frontfaces:
        dst = image[y:y + h, x:x + w]
        cv2.imwrite('./FaceImages/' + str(count) + '.jpg', dst)
        count += 1

detectMultiScaleで顔検出の許容度合を大きくしてしまうと、かなりゴミが紛れ込んでくるので適当なパラメータを設定する必要があります。
余談ですが、個人的にはOpenCVの顔検出の精度は余り高くないように思いました。
前処理等の工夫が必要なのかなあという印象です。

参考:dlib vs OpenCV face detection - YouTube

最終的な切り抜き後の画像は、1839枚でした。

さて、ここまでの作業が終わると、FaceImagesディレクトリはガッキーの顔で満たされているはずです。
しかし残念なことに、OpenCVでの誤検出画像や、別人の顔が一部紛れ込んでいるため、取り除く作業が必要になります。

何か効率的な手段があれば良かったのですが、残念ながら妙案が思い浮かばなかったので、目視で分類します。

import glob
import shutil
import cv2

data_dir_path = './FaceImages/'
image_list = glob.glob(data_dir_path + '*.jpg')

gakkies = []
others = []
for i, path in enumerate(image_list):
    image = cv2.imread(path)
    cv2.imshow('image', image)
    # Left arrow is not gakky: 63234
    # Right arrow is gakky: 63235
    key = 0
    while key != 63234 and key != 63235:
        key = cv2.waitKey(0)
    if key == 63234:
        others.append(path)
    else:
        gakkies.append(path)
    cv2.destroyAllWindows()

for gakky in gakkies:
    shutil.move(gakky, data_dir_path + 'Gakky/.')
for other in others:
    shutil.move(other, data_dir_path + 'Other/.')

一枚ずつ画像を表示しつつ、矢印キーの入力でガッキーとその他を分類します。
最終的に、それぞれGakkyディレクトリとOtherディレクトリに画像を移動します。
分類後の枚数は、ガッキーが887枚、その他が952枚でした。

f:id:taka_say:20161217014050p:plain

分類後は、無事ガッキーの顔でディレクトリが満たされていることを確認できます。
これでようやく学習データの準備が完了です。

NumPyは苦しいが役に立つ

学習を行っていくにあたり、画像データをNumPy配列として保存しておきます。 実際の学習はこのデータを基に行われます。

import os
import glob
import numpy as np
import cv2

data_dir_path = './FaceImages/'
tmp = os.listdir(data_dir_path)
dir_list = sorted([x for x in tmp if os.path.isdir(data_dir_path + x)])

X_data = []
Y_data = []
for i, dir_name in enumerate(dir_list):
    images = glob.glob(data_dir_path + dir_name + '/*.jpg')
    for path in images:
        image = cv2.imread(path)
        image = cv2.resize(image, (64, 64))
        image = image.transpose(2, 0, 1)
        image = image / 255.
        X_data.append(image)
        Y_data.append(i)

X_ary = np.array(X_data)
np.save('X_data.npy', X_ary)
Y_ary = np.array(Y_data)
np.save('Y_data.npy', Y_ary)

X_data.npyが画像データの配列で、Y_data.npyが正解クラスの配列ですね。
画像の適切なサイズが分からなかったので、とりあえず64*64にしています。

Kerasは角だが役に立つ

ここから、本題のKerasを使った深層学習に入っていきます。
応用するだけのスキルも知識も無いので、Kerasドキュメントの公式に掲載されているVGG風CNNのモデル構造を微修正しながら利用させて貰います。

Kerasでは、TensorFlowをバックエンドで使用します。
今回はGPUを使わないので、インストールはシンプルです。
ついでにいくつかの必要なパッケージも入れてしまいましょう。

$ pip install scikit-learn scipy pandas matplotlib
$ pip install tensorflow keras h5py
$ python
>>> import keras
Using TensorFlow backend.

このようにバックエンドがTensorFlowになっていることが確認できれば準備完了です。

では実際に学習を行います。

from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Convolution2D, MaxPooling2D
from keras.optimizers import SGD
from keras.callbacks import ModelCheckpoint
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split

# 学習データの用意
X_data = np.load('X_data.npy')
Y_data = np.load('Y_data.npy')
X_train, X_test, Y_train, Y_test = train_test_split(
    X_data, Y_data, test_size=0.15, random_state=42)

# オプション指定
batch_size = 32
nb_classes = 2
nb_epoch = 50

# モデル定義
model = Sequential()

model.add(Convolution2D(32, 3, 3, border_mode='same', input_shape=X_train.shape[1:]))
model.add(Activation('relu'))
model.add(Convolution2D(32, 3, 3))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Convolution2D(64, 3, 3, border_mode='same'))
model.add(Activation('relu'))
model.add(Convolution2D(64, 3, 3))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(256))
model.add(Activation('relu'))
model.add(Dropout(0.5))

model.add(Dense(nb_classes))
model.add(Activation('softmax'))

sgd = SGD(lr=2e-2, momentum=0.9, decay=0.0, nesterov=True)
model.compile(loss='sparse_categorical_crossentropy', optimizer=sgd, metrics=['accuracy'])
checkpointer = ModelCheckpoint(filepath="./Models/gakky_face_model.hdf5", verbose=1, save_best_only=True)
# モデル学習
hist = model.fit(X_train, Y_train,
                 batch_size=batch_size,
                 nb_epoch=nb_epoch,
                 validation_data=(X_test, Y_test),
                 shuffle=True,
                 verbose=1,
                 callbacks=[checkpointer])

# 学習経過のプロット
plt.style.use("ggplot")
df = pd.DataFrame(hist.history)
df.index += 1
df.index.name = "epoch"
df[["acc", "val_acc"]].plot(linewidth=2)
plt.savefig("acc_history.pdf")
df[["loss", "val_loss"]].plot(linewidth=2)
plt.savefig("loss_history.pdf")

もし、

Exception: Error when checking model target: expected activation_ to have shape

というようなエラーが出ている場合、~/.keras/keras.jsonを開き、"image_dim_ordering": "th"と修正すると上手くいくかもしれません。 バックエンドがTensorFlowなのかTheanoなのか云々で発生する問題らしいですが、詳細は分かりません。

コードの方では、モデルを定義し、学習させ、最後にmodel.fitの返り値のhistory情報を使って、学習経過をPDFで保管しています。
学習時のcallbackModelCheckpointを指定することで、全エポック中で最も良い指標になったモデルを自動的に保存するように設定しました。
保存先のディレクトリは事前に用意していないとエラーになるのでご注意ください。

ちなみに、学習経過はこのようになりました。

f:id:taka_say:20161218214831p:plain f:id:taka_say:20161218214840p:plain

今回は第20epochが最も優秀だったようです。

人工知能は人類最悪にして最後の発明だが役に立つ

モデルが完成したので、試しに対話環境でロードして遊んでみます。

$ python
>>> from keras.models import load_model
Using TensorFlow backend.
>>> import numpy as np
>>> import cv2
>>> model = load_model('./Models/gakky_face_model.hdf5')
>>> def predict(path):
...     image = cv2.imread(path)
...     image = cv2.resize(image, (64, 64))
...     image = image.transpose(2, 0, 1)
...     image = image / 255.
...     image = image.reshape(1, 3, 64, 64)
...     print(model.predict(np.array(image)))
>>> predict('gakky.png')
[[ 0.96610594  0.03389405]]
>>> predict('yama.png')
[[ 0.21037847  0.78962159]]

f:id:taka_say:20161218152423p:plain:w64:h64 f:id:taka_say:20161218152427p:plain:w64:h64

上記のような適当に用意したガッキーの顔画像とSierraの山並み(一部!)に対して予測すると、確かに結果は正しそうです。

動画処理は重いが役に立つ

ここからは、用意したモデルを使用しながら、実際に恋ダンス動画の加工処理を行います。
とりあえず恋ダンスの動画が必要なので、TBSの公式Youtubeから頂戴します。

TBS公式 YouTuboo - YouTube

冒頭から述べている通り、判別機がガッキーの顔だと判断すればそのまま、その他の顔だと判断すれば下記のガッキーフェイスで上書きします。

f:id:taka_say:20161218190110p:plain

動画加工のためのコードは下記の通りです。

import cv2
import datetime
import numpy as np
from keras.models import load_model

# 動画関係準備
target = 'input.mp4'  # 恋ダンス動画
result = 'output.m4v'
movie = cv2.VideoCapture(target)
fps = movie.get(cv2.CAP_PROP_FPS)
height = movie.get(cv2.CAP_PROP_FRAME_HEIGHT)
width = movie.get(cv2.CAP_PROP_FRAME_WIDTH)
fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
out = cv2.VideoWriter(result, int(fourcc), fps, (int(width), int(height)))

# 顔認識準備
model = load_model('./Models/gakky_face_model.hdf5')
cascade_path = '/path/to/your/opencv3/haarcascade_frontalface_default.xml'
cascade = cv2.CascadeClassifier(cascade_path)
gakky_n = 0  # モデル予測時のクラス番号

# 書き込み画像準備
ol_imgae_path = "gakky.png"
ol_image = cv2.imread(ol_imgae_path)

# 各フレームへの処理
if movie.isOpened() is True:
    ret, frame = movie.read()
    f_h, f_w = frame.shape[:2]
else:
    ret = False
while ret:
    # 顔検出
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    frontfaces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=2)

    # 顔認識
    for (x, y, w, h) in frontfaces:
        dst = frame[y:y + h, x:x + w]
        image = cv2.resize(dst, (64, 64))
        image = image.transpose(2, 0, 1)
        image = image / 255.
        image = image.reshape(1, 3, 64, 64)
        face_class = model.predict_classes(np.array(image), verbose=0)
        # ガッキーじゃなければ上書き
        if face_class != gakky_n:
            resized = cv2.resize(ol_image, (h, w))
            frame[y:y + h, x:x + w] = resized
        cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 0, 0), 2)

    # フレーム書き込み
    out.write(frame)
    ret, frame = movie.read()
    # 50フレームごとに経過を出力
    if movie.get(cv2.CAP_PROP_POS_FRAMES) % 50 == 0:
        print(datetime.datetime.now().strftime('%H:%M:%S'),
              '現在フレーム数:' + str(int(movie.get(cv2.CAP_PROP_POS_FRAMES))))

    # 途中で終了する場合コメントイン
    # if movie.get(cv2.CAP_PROP_POS_FRAMES) > 500:
    #     break

検証段階では、分かりやすいように顔検出したエリアに枠を描写しています。
つまり、枠のみは判別機がガッキーだと判断したエリアで、枠+ガッキーは判別機がガッキーでないと判断したエリアです。

実際の処理結果はこのようになります。

f:id:taka_say:20161218185808g:plain

なんということでしょう。
顔の誤検出が多すぎて酷い仕上がりです。
更にいうと、他の人の顔をガッキーに変換するという当初の目的すら達成できていません。

これは、ガッキーの顔かそうでないかという二値分類ではなく、顔っぽいかそうでないかという形で学習をしてしまったからだと考えられます。
冷静に考えれば当たり前ですね。

まとめは雑だが役に立つ

この記事では、ガッキーの顔画像を収集し、分類し、学習し、動画を加工するまでの過程を記載しました。 残念ながら芳しい結果は伴いませんでしたが、問題の原因は明らかなように思われます。
というわけで、その辺りの問題を解消していきたいのですが、だいぶ長くなってしまったので、続きは後編で。

taka-say.hateblo.jp

参考は別サイトだが役に立つ

モチベーション

新垣結衣 | アーティスト | レプロエンタテインメント
ご注文はDeep Learningですか? - kivantium活動日記

Keras

Keras Documentation
Are there any codes for AlexNet, ZF Net, GoogLeNet, VGGNet in Keras · Issue #1568 · fchollet/keras · GitHub
Error when checking model target: expected activation_2 to have shape (None, 10) but got array with shape (3, 1) · Issue #3109 · fchollet/keras · GitHub
Kerasでアニメキャラの顔認識 - Qiita
続・深層学習でアニメ顔を分類する with Keras - Qiita

動画

Python + OpenCV で雑コラ動画を作成する③ 雑コラ動画作成 - Qiita
overlay a smaller image on a larger image python OpenCv - Stack Overflow

Gif

PicGIF Lite を Mac App Store で