機械学習で乃木坂46メンバーの誰に似ているかを判定する簡易Webアプリケーションを作った一連の流れ

#**はじめに**
乃木坂46の一部メンバーの顔画像を機械学習に掛けて作成した学習済みモデルを利用して、ユーザーが顔画像をアップすると誰に似ているかを判定してくれる一連のWebアプリケーションを作りました。

大学の春休みで暇を持て余していたので作ったのですが、思ったより時間がかかり1ヶ月半ほどの製作期間になりました。

僕は雰囲気でDeepLearningに触れた程度の初学者なので、同じくらいの方向けに、機械学習からWebアプリケーションまで落とし込む一連の"流れ"の参考になればと思い、書かせて頂きます。ちなみにググりまくってゴリ押しで作ったので、スマートでない所あるかと思いますが、ご了承ください。

Web上に少ない情報は詳しめに書きますが、原則まとめ的に書くので詳細が知りたい方は各種リンクを参考に。

完成品（1ヶ月後はリンク切れしてるかも）: http://www.nogidare.net
ソースコード: https://github.com/yu8muraka3/DeepNogizaka

#**環境構成**
全体の環境は以下のとおり。
言語は機械学習とWebアプリはPythonで、フロントエンドはHTML+CSS+JavaScriptです。

##**機械学習側**
・学習ライブラリ
　Keras (Tensorflow)

・学習環境
　AWS Deep Learning AMI (Ubuntu) Version 5.0 p2.xlarge

##**アプリケーション側**
・サーバー
　Conoha VPS メモリ 1GB/CPU 2Core SSD 50GB (CentOS7)

・アプリケーション構成
　Flask + Nginx + uWSGI

・環境
　Python 3.6.3, anaconda3-4.3.1

#**機械学習**
たくさん記事が転がっていたので、それらを組み合わせて作りました。良い時代に生まれました。Googleが無い時代にGoogle作ったラリー・ペイジとセルゲイ・ブリンって凄いなぁと毎度感心します。

##**1. 画像収集**
画像はGoogleのAPIを使い収集しました。以下のリンクを参考にすれば出来ると思います。

[Google Custom Search APIを使って画像収集]
(https://qiita.com/onlyzs/items/c56fb76ce43e45c12339)

※ただスクレイピングで集めるのは上限値がある上に、重複することがままあったので、手動でも集めてデータを補強しました。

##**2. 画像加工**
今回の場合は

顔画像の切り抜き＋画像の水増し

の２ステップです。

###**①顔画像の切り抜き**
これはお決まりのOpenCVを利用しました。

※参考リンク
[大量の画像から顔の部分のみトリミングして保存する方法](http://ai-coordinator.jp/opencv_face)

###**②画像の水増し**
集めた画像だけではデータ数が少ないので、画像にエフェクトを掛けて水増しをします。あんまりやると、過学習を引き起こすようなのでやりすぎないようにしましょう。**また、水増ししたデータがテストデータに入らないように注意する必要があります。**水増ししたデータは訓練データのみに使用しましょう。

[機械学習で乃木坂46を顏分類してみた](http://blog.aidemy.net/entry/2017/12/17/214715)の画像の水増しの項を参考にしました。

```Python:makedata.py
from PIL import Image
import os, glob
import numpy as np
import random, math
import cv2

root_dir = "./photo_select_out64/"
categories = ["asuka_out", "ikoma_out", "ikuta_out", "maiyan_out", "nanase_out", "yasushi_out"]
nb_classes = len(categories)
image_size = 64

# 画像データを読み込む
X = []  # 画像データ
Y = []  # ラベルデータ

def data_append(data, cat):
    X.append(data)
    Y.append(cat)

def add_sample(cat, fname, is_train):
    img = Image.open(fname)
    img = img.convert("RGB")
    img = img.resize((image_size, image_size))
    data = np.asarray(img)
    X.append(data)
    Y.append(cat)
    if not is_train: return

    filter1 = np.ones((3, 3))

    flip = lambda x: cv2.flip(x, 1)
    thr = lambda x: cv2.threshold(x, 100, 255, cv2.THRESH_TOZERO)[1]
    filt = lambda x: cv2.GaussianBlur(x, (5, 5), 0)

    methods = [flip, thr, filt]

    for f in methods:
        processing_data = f(data)
        data_append(processing_data, cat)

    for f in methods[1:]:
        processing_data = flip(f(data))
        data_append(processing_data, cat)

    data_append(thr(filt(data)), cat)
    data_append(flip(thr(filt(data))), cat)


def make_sample(files, is_train):
    global X, Y
    X = []; Y = []
    for cat, fname in files:
        add_sample(cat, fname, is_train)
    return np.array(X), np.array(Y)

allfiles = []
for idx, cat in enumerate(categories):
    image_dir = root_dir + "/" + cat
    files = glob.glob(image_dir + "/*.jpg")
    for f in files:
        allfiles.append((idx, f))

random.shuffle(allfiles)
th = math.floor(len(allfiles) * 0.8)
train = allfiles[0:th]
test = allfiles[th:]
X_train, y_train = make_sample(train, True)
X_test, y_test = make_sample(test, False)
xy = (X_train, X_test, y_train, y_test)
np.save("./6obj.npy", xy)
print("ok", len(y_train))
```

###**※つまづいたポイント**
**・保存したファイルが大容量のバイナリファイルで、通常通りにGitHubでpushすると弾かれました**
　→コチラを参考にGit LFSを活用すれば上手くいきました。[GitHubに100MB超のファイルを置く -- git push に失敗してからの対処方法](https://qiita.com/kanaya/items/ad52f25da32cb5aa19e6)

##**3. 機械学習**
手元のMacBookではさすがに無理だったので、AWSにインスタンスを立てて学習させました。立てられるインスタンス上限が0の場合はリクエストすれば開いてくれます。

上限は↓で確認できます。
[EC2 Service Limits](https://us-east-2.console.aws.amazon.com/ec2/v2/home?region=us-east-2#Limits:)


**・使ったインスタンス**
Deep Learning AMI (Ubuntu) Version 5.0 p2.xlarge

AWSにあらかじめ設定されたanaconda仮想環境(リンク先はKeras)に入るコマンド一覧です。起動時のコマンドラインにも記載されてますが。
https://docs.aws.amazon.com/ja_jp/dlami/latest/devguide/tutorial-keras.html

僕の場合は以下コマンドでKeras(バックエンドにTensorflow) + CUDA8環境に入りました。

```:コマンド
$ source activate tensorflow_p36
```

初めて使ってみましたが、ストレス無く使えました。

###**学習アルゴリズム**

コチラの記事をベースにしつつ、書籍や様々なサイトを見て調整を施しました。

※参考リンク
[機械学習で乃木坂46を顏分類してみた](http://blog.aidemy.net/entry/2017/12/17/214715)


```Python:train.py
import numpy as np
from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation, Dropout, Flatten, Dense
from keras.utils import np_utils
from keras.optimizers import Adam
from keras.callbacks import EarlyStopping

#分類対象のカテゴリーを選ぶ
categories = ["asuka", "ikoma", "ikuta", "maiyan", "nanase", "yasushi"]

nb_classes = len(categories)

#画像データを読み込み

X_train, X_test, y_train, y_test = np.load("./6obj.npy")

X_train = X_train.astype("float") / 256
X_test = X_test.astype("float") / 256
y_train = np_utils.to_categorical(y_train, nb_classes)
y_test = np_utils.to_categorical(y_test, nb_classes)

# モデルの定義
model = Sequential()
model.add(Conv2D(input_shape=(64, 64, 3), filters=32,kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Conv2D(filters=32, kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Conv2D(filters=32, kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(256))
model.add(Activation("sigmoid"))
model.add(Dense(128))
model.add(Activation('sigmoid'))
model.add(Dropout(0.5))
model.add(Dense(6))
model.add(Activation('softmax'))

model.compile(loss='binary_crossentropy',
    optimizer=Adam(lr=1e-5),
    metrics=['accuracy'])

early_stopping = EarlyStopping(monitor='val_loss', patience=15, verbose=0)

model.fit(X_train, y_train, batch_size=64, epochs=500, validation_split=0.1, callbacks=[early_stopping])

score = model.evaluate(X_test, y_test)
print('loss=', score[0])
print('accuracy=', score[1])


#モデルを保存
model.save("my_model.h5")
```

精度は89.2%出てくれました。当初過学習が起きていたのですが、Dropoutやearlystopping等を駆使しながら解消しました。

accuracyがいくら高くても訓練データに対してだけで汎化性能が劣っている可能性があるので、val_loss等の指標も見ながら作っていくのが良さそうです。

過学習に関しては様々な記事を読んで参考にしましたが、コチラの記事はコンパクトに纏まっていて良いです。

※参考リンク
[Dropout：ディープラーニングの火付け役、単純な方法で過学習を防ぐ](https://deepage.net/deep_learning/2016/10/17/deeplearning_dropout.html)



###**※つまづいたポイント**
AWSのUbuntuのp2.xlargeインスタンスで上記のanaconda仮想環境に入って学習をしたのですが、ローカルでは動くのにAWSだと動かないみたいなことがありました。

こんなエラー文でしたが

```:Error
ImportError: /home/ubuntu/anaconda3/envs/tensorflow_p36/bin/../lib/libstdc++.so.6: version `CXXABI_1.3.9' not found (required by /home/ubuntu/anaconda3/envs/tensorflow_p36/lib/python3.6/site-packages/scipy/sparse/_sparsetools.cpython-36m-x86_64-linux-gnu.so)
```

ググったらgithubに[issue] (https://github.com/NLeSC/root-conda-recipes/issues/32) として上がっていたので無事解決しました。もし同じ症状の方がいらっしゃったら参考に。

#**Webアプリケーション**
WebアプリケーションはPythonの軽量フレームワークであるFlaskを使いました。ちょっとしたアプリケーション作りたいだけなら十分ですね。とても使いやすかったです。

##**1. サーバー設定**
自分にプレッシャーかける意味でもConohaで有料サーバーを立ててやりました。SSHの鍵設定などはこの記事が分かりやすかったです。

※参考リンク
[ConoHa と CentOS7 でサーバー構築
](https://qiita.com/nanocloudx/items/e393dead67b8a48ba6a6)

##**2. アプリケーション構築**
構造はNginx+uWSGI

サーバーの知識がほぼ皆無だった（今も）ので、実は１番手こずりました。
この程度の簡易アプリならサーバーレスでも作れそうということに途中で気付きましたが、経験だと割り切りました。

※参考リンク
[uwsgi + nginx + flaskで簡単なWEBアプリの構築](http://www.stockdog.work/entry/2017/11/12/195017)
[conohaVPSでDjango開発環境を構築する](http://www.kato-eng.info/entry/vibakari1)
[How To Serve Flask Applications with uWSGI and Nginx on Ubuntu 16.04](https://www.digitalocean.com/community/tutorials/how-to-serve-flask-applications-with-uwsgi-and-nginx-on-ubuntu-16-04)
[flask を uWSGI と Nginx でデプロイする](https://qiita.com/ekzemplaro/items/a570f79de254428a151d)

###**※つまづいたポイント**
**・NginxとuWSGIの連携**
　→PATHの設定がどうやらおかしかったようです。

**・ちゃんと起動しているのに繋がらない**
　→ポートが開いていなかったようです。[【すぐわかる】CentOSのポート開放のやり方](https://eng-entrance.com/linux-centos-port)

**・学習済みモデルのバイナリファイルが破損**

```:Error
File "h5py/_objects.pyx", line 54, in h5py._objects.with_phil.wrapper
  File "h5py/_objects.pyx", line 55, in h5py._objects.with_phil.wrapper
  File "h5py/h5f.pyx", line 78, in h5py.h5f.open
OSError: Unable to open file (file signature not found)
```

　→GitHubからpullして動かそうとしていたのですが、何度やってもバイナリファイルのファイルサイズがローカルやリポジトリよりも明らかに少なく、エラー文を調べた所ファイルが破損しているっぽいと。なのでファイルアップローダのFileZillaで直接ローカルからアップロードしたら普通に動きました。

**・ブラウザキャッシュを消してもCSS等の変更が反映されない**
　→これは僕の設定によるものかもしれませんが、uWSGIをrestartすればちゃんと反映してくれます。

```:コマンド
$ systemctl restart uwsgi.service
```
##**3. Flaskアプリケーション**
###**①Flaskセットアップ**
かなり扱いやすく、誰でも簡単に出来ると思います。

※参考リンク
[Flaskの簡単な使い方](https://qiita.com/zaburo/items/5091041a5afb2a7dffc8)

###**②画像アップロード機能実装**
画像アップロードもらくらくです。

※参考リンク
[Flaskで画像アップローダー](https://qiita.com/Gen6/items/f1636be0fe479f42b3ee)

リンク先にはログイン機能もあるのですが、それは省いて適宜カスタムして最終的にはこんな感じに。

```Python:app.py
# -*- coding: utf-8 -*-

import os, re
from flask import Flask, render_template, request, redirect, url_for, send_from_directory, session
from werkzeug import secure_filename
from detect import start_detect


app = Flask(__name__)

@app.route('/')
def index():
    name = "Hello World"
    return render_template('index.html', title='flask test', name=name)

###########################
# 以下画像アップロード処理
##########################

#iPhoneの画像ファイル形式への対応

#画像アップロード
UPLOAD_FOLDER = './static/asset/uploads'
ALLOWED_EXTENSIONS = set(['jpg'])
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['SECRET_KEY'] = os.urandom(24)

#アップロードを許可する画像ファイル
def allowed_file(filename):
    # ファイル名の拡張子より前を取得し, フォーマット後のファイル名に変更
    filename = re.search("(?<!\.)\w+", filename).group(0) + "." + 'jpg'
    return '.' in filename and \
        filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS


#画像POST処理
@app.route('/send', methods=['GET', 'POST'])
def send():
    if request.method == 'POST':
        img_file = request.files['img_file']
        if img_file and allowed_file(img_file.filename):
            filename = secure_filename(img_file.filename)
            img_file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
            img_url = '/uploads/' + filename
            predict_name, predict_enname, rate = start_detect(filename)

            if predict_name != None:
                if predict_enname != "Akimoto Yasushi":
                    predict_enname = predict_enname.lower()
                    enname = predict_enname.split()
                    url_enname = enname[1] + "." + enname[0]
                else:
                    url_enname = None

                print(predict_name)
                # return render_template('index.html', img_url=img_url)
                return render_template('result.html', name=predict_name, urlname=url_enname, rate=rate, filename=filename)
            else:
                return render_template('index.html', error="error")
        else:
            return ''' <p>許可されていない拡張子です</p> '''
    else:
        return redirect(url_for('index'))


#アップロードされた画像ファイル
@app.route('/uploads/<filename>')
def uploaded_file(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)


## おまじない
if __name__ == "__main__":
    app.run()

```

###**③HTMLやCSS等で見た目を整える。**
Flaskはtemplateをrenderする時に、いくつも戻り値を渡せるようなのでHTML側でそれを出力したり出来ます。便利ですね。

```HTML:Example
<p><span class = "name">「{{name}}」</span>({{rate}}%)です！！</p>
```

{{変数}}のカタチで受け取れます。

#**まとめ**
以上が一連の流れです。ざっくりとしか説明してなかったり、無駄に丁寧だったり読みにくいかもしれませんが、参考になったら嬉しいです。

（実は作ったWebアプリケーションを初めて公開までこぎつけたのですが、やっぱりモノづくりは楽しいですね。達成感も最高です。ぜひ作ってみて下さい！）

そして遊んでみてください！ http://www.nogidare.net