Python
Flask
OpenCV
機械学習
scikit-learn

AIを使って自分の顔がジャニーズ系かどうかを判定するWebサービスを作ってみた

はじめに

学生時代に研究をしていた以来3年ほど遠ざかっていたAIの分野で何か自作アプリを作って公開してみたいと思い、約三ヶ月かけて遂にリリースしました。そもそも自作アプリのサービス公開が初めてで、たくさん勉強になったことがあったので、自分と同じような方向けにメモを残しておこうと思いました。
記事内で掲載しているソースコードは一部抜粋のものであり、そのままでは動作しません。全体のソースコードはGitHubにアップしています。
記事を全て理解していただくには、機械学習とAWSとWebアプリケーションの基礎知識が必要です。興味あるところだけ読んでみてください。

まずは完成品

自分の顔がジャニーズ系の顔かどうかを判定してみる

自分の顔写真をアップロードして、ジャニーズ系かどうかを三段階で判定するサービスです。
よろしければアクセスして遊んでみて下さい。
ちなみにありえないことに私の顔はジャニーズ顔と判定されませんでした(怒)

※現在iPhoneは未対応です。Android端末、Windows端末、macbookでの動作確認はしました。

環境

開発環境(ローカル)

macbook pro(メモリ8GB、macOS High Sierra)

プロダクション環境(AWS)

  • Route53
  • VPC
  • ELB
  • EC2インスタンス1台
    • OS: amazon linux2
    • インスタンスタイプ: t2.small

費用

AWSに関して以下の項目で約200円の月額料金がかかります。得られるものを考えたら安い。

  • トラフィック従量課金
  • Route53
  • EC2(t2.small)
  • ELB

サービスリリースまでの道のり

テーマ決め

なんでもいいから機械学習(scikit-learn)で画像判定する面白いサービスを作ってみたいなーとぼんやり思ってプロジェクト始動。
とりあえずいくつかテーマをあげました(例えば、ジャニーズ顔判定以外にはボディスタイル判定とかアジア人女性の顔を国ごとに見分けるとか、サッカー日本代表の顔を判定するとか)が、最終的にジャニーズ顔判定にした理由は、「画像を集めやすそうだから」、「OpenCV的に顔認識はハードル低そうだから」、「ユーザ参加型にした方が面白そうだから」といった理由です。

ローカル開発環境構築

こちらの記事を参考にpyenvとanacondaのインストールをしました。

$ pyenv --version
pyenv 1.2.4
$ pyenv versions
  system
* anaconda3-5.1.0 (set by /Users/koki/.pyenv/version)

こちらの記事(といっても私のですが)を参考にPycharmをインストールしました。
また、PycharmからopenCV(3.3.1)をインストールしました。

AI側実装

まずはAI系の実装から開始。

(Step1)画像収集

どのように画像を収集したか

人力で収集するのは苦しいので、Google Custom Search(GCS) APIを利用して自動で画像を収集しました。
GCSの無料枠の利用制限は100リクエスト/日で、それ以上リクエストするとエラーで取得失敗になる。(勝手に課金されないので安心。)
ちなみにリクエスト数のカウントがリセットされるのは日本時間17時だそうです。

GCSのAPIキーの取得方法はこちらの記事が参考になりました。

PythonクライアントのソースコードはquottoさんのGitHubリポジトリを利用しました。クエリに対象人物の名前を指定して実行します。
実際には一人あたり80〜90枚程度の取得になったのですが、100枚取れないのはおそらくGCSのリクエストを許可していないサイトがあるからだと思う。

誰の画像を対象としたか

ジャニーズ顔のサンプルとしては、人気ランキングサイトに載っている方を対象としました。(最終的には人気トップ20位を対象)
ジャニーズ顔でない人のサンプルとしては、お笑い芸人やスポーツ選手などの有名人の方の中で私が独断と偏見でジャニーズ顔ではないと判断した20名の方を対象としました。すいません。名前は伏せておきます。

(Step2)顔部分抽出

Step1で取得した画像ファイルからOpenCVを利用して顔部分のみを抽出。
画像ファイル内の特定オブジェクトの判定にはそのオブジェクトに対応したカスケード分類器を作成する必要がありますが、顔の場合はインターネット上にカスケードファイルが複数種類用意されているため楽です。
今回はその中でも正面向き顔画像用のhaarcascade_frontalface_alt.xmlを利用しました。
以下は、顔抽出するソースコードです。外部コンフィグを読み込んだりしているので単体では動きません。

face_extraciont.py
# -*- coding: utf-8 -*-
"""
OpenCV2を利用して画像から顔画像を抽出するモジュール。
このモジュールを利用して抽出した結果から不要なファイルを人力で削除する必要あり。
"""

import cv2
import os
import configparser


# 外部のコンフィグを読み込む
inifile = configparser.ConfigParser()
inifile.read('../config.ini')

# 入力画像ディレクトリのパス。最後はスラッシュで終わる必要あり。
in_dir = inifile.get('extraction', 'in')
# 出力先ディレクトリのパス。最後はスラッシュで終わる必要あり。
out_dir = inifile.get('extraction', 'out')
# カスケードファイルのパス。
cascade_file = inifile.get('extraction', 'cascade')

# ディレクトリに含まれるファイル名の取得
names = os.listdir(in_dir)

for name in names:
    # 絶対パスで画像の読み込み
    image_gs = cv2.imread(in_dir + name)

    # 顔認識用特徴量ファイルを読み込む
    cascade = cv2.CascadeClassifier(cascade_file)

    # 顔認識の実行
    face_list = cascade.detectMultiScale(image_gs, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))

    # 顔だけ切り出して保存
    index = 0
    for rect in face_list:
        x = rect[0]
        y = rect[1]
        width = rect[2]
        height = rect[3]
        dst = image_gs[y:y + height, x:x + width]
        save_path = out_dir + '/' + 'out_(' + str(index) + ')' + str(index) + '.jpg'
        cv2.imwrite(save_path, dst)
        index = index + 1

(Step3)ゴミファイル削除

自動で画像収集したものの中には関係無い人物や背景の写真、顔が写っていない写真、サングラスをしていて顔を判定しにくい写真などが含まれています。また、OpenCVによる顔画像抽出時にゴミファイルも作成されるケースがあります。
これらを全て削除します。いまのところ手作業でやるしかありませんorz

(Step4)画像水増し

ゴミファイルの削除などをしていると最終的に一人あたり約50枚ほどに減少してしまいました。
なんとなくこれでは少なそうだなと思い、画像を左右反転させたものも学習させて2倍に水増ししました。この水増し作業は常套手段のようです。
他にも閾値処理やぼかしなどで8倍に画像数を増やすこともできるみたいですが、今回はやりませんでした。
以下は、画像を左右反転するソースコードです。外部コンフィグを読み込んだりしているので単体では動きません。

face_rotation.py
# -*- coding: utf-8 -*-
"""
画像をy軸に回転させるモジュール
"""
import os
import cv2
import configparser

inifile = configparser.ConfigParser()
inifile.read('../config.ini')

# 入力画像ディレクトリのパス。最後はスラッシュで終わる必要なし。
in_dir = inifile.get('rotation', 'in')
# 出力先ディレクトリのパス。最後はスラッシュで終わる必要なし。
out_dir = inifile.get('rotation', 'out')

for path, _, files in os.walk(in_dir):
    for file in files:
        if not file.startswith('.'):
            # 画像読み込み
            img = cv2.imread((path + '/' + file), cv2.IMREAD_COLOR)

            # y軸に回転
            reversed_y_img = cv2.flip(img, 1)

            # 画像保存
            save_path = out_dir + '/' + 'out_rotation_(' + file + ').jpg'
            cv2.imwrite(save_path, reversed_y_img)

(Step5)画像リサイズ

scikit-learnのモデルに入力させるために全ての画像ファイルのサイズを100×100に統一しました。(サイズ自体は何でもいいが統一することが必要)

image_resize.py
# -*- coding: utf-8 -*-
"""
画像サイズを変更するモジュール
scikit-learnのモデルに入力するために全ての画像のサイズを統一する必要がある。
"""

import cv2
import os
import configparser

inifile = configparser.ConfigParser()
inifile.read('../config.ini')

# 入力画像ディレクトリのパス。最後はスラッシュで終わる必要あり。
in_dir = inifile.get('resize', 'in')
# 出力画像ディレクトリのパス。最後はスラッシュで終わる必要あり。
out_dir = inifile.get('resize', 'in')

files = os.listdir(in_dir)
for file in files:
    # 画像の読み込み
    image = cv2.imread(in_dir + file)

    # 100×100ピクセルにリサイズ
    image_resized = cv2.resize(image, (100, 100))

    # リサイズした画像の書き込み
    cv2.imwrite(out_dir + file, image_resized)

(Step6)学習と評価

ここから試行錯誤の繰り返しになります。
実際は、必要に応じてStep1からやり直すこともありました。

1回目トライ

前提
  • ランダムフォレスト
  • 学習データ: ジャニーズ系6名、非ジャニーズ系6名(約1200枚)
  • テストデータ: ジャニーズ系4名、非ジャニーズ系4名(約800枚)
結果
  • precision: 約0.66(詳細メモ忘れ)
  • F1値: ?(メモ忘れ)
考察と感想
  • スケール変換不要でチューニングもあまり必要ないのにそこそこ精度がでると評判のランダムフォレストでこの精度はひどい。そもそもジャニーズ顔なんてあるのか?などの「企画倒れ」の言葉が脳裏をよぎる。
  • とりあえずSVMも試してみたい

2回目トライ

前提
  • SVM ★SVMに変更
  • 学習データ: ジャニーズ系6名、非ジャニーズ系6名(約1200枚)
  • テストデータ: ジャニーズ系4名、非ジャニーズ系4名(約800枚)
  • スケール変換なし ★SVMだけどとりあえずスケール変換なしでやってみる
結果
  • precision: 0.7340966921119593
  • F1値: 0.7239101717305152
考察と感想
  • 結構あがった。さすがSVM!!!
  • これはスケール変換すれば精度あがるだろうという期待

3回目トライ

前提
  • SVM
  • 学習データ: ジャニーズ系6名、非ジャニーズ系6名(約1200枚)
  • テストデータ: ジャニーズ系4名、非ジャニーズ系4名(約800枚)
  • スケール変換あり(MinMaxScaler) ★SVMなら精度上がるはずのMinMaxScaler
結果
  • precision: 0.6921119592875318
  • F1値: 0.6889460154241646
考察と感想
  • え?なんで精度下がるの?泣きそうorz
  • 画像ディレクトリ漁るとジャニーズ系学習ファイルディレクトリにゴミファイルが複数あるのを発見。おそらくゴミファイル削除の手作業ミス。
  • さらに、そもそもデータ数少ないのではと思い、対象人数を倍にして再トライする。

4回目トライ

前提
  • SVM
  • 学習データ: ジャニーズ系12名、非ジャニーズ系12名(約2400枚) ★倍増
  • テストデータ: ジャニーズ系8名、非ジャニーズ系8名(約1600枚) ★倍増
  • スケール変換あり(MinMaxScaler)
結果
  • precision: 0.7998220640569395
  • F1値: 0.7879359095193212
考察と感想
  • おお!なかなか良い!
  • グリッドサーチ+交差検証でベストパラメータを探せば夢の8割に届きそう。
  • その前にランダムフォレストでも同様に試してみたい。

5回目トライ

前提
  • ランダムフォレスト ★ランダムフォレストに変更
  • 学習データ: ジャニーズ系12名、非ジャニーズ系12名(約2400枚)
  • テストデータ: ジャニーズ系8名、非ジャニーズ系8名(約1600枚)
結果
  • precision: 0.7580071174377224
  • F1値: 0.7364341085271319
考察と感想
  • ランダムフォレストは要らない子だったんだ(きっとデータがあわなかっただけかもしれない。チューニングをすればもっとよかったかもしれない)

最終トライ

前提
  • SVM ★SVMに変更
  • 学習データ: ジャニーズ系12名、非ジャニーズ系12名(約2400枚)
  • テストデータ: ジャニーズ系8名、非ジャニーズ系8名(約1600枚)
  • スケール変換あり(MinMaxScaler)
  • グリッドサーチ+交差検証でベストパラメータを探索してから実行
ベストパラメータを探索

交差検証+グリッドサーチでベストパラメータを探索。
正直なところ、下記のパラメータ範囲では"ベスト"とは言い難くて、もっと範囲を徐々に狭めて本当のベストパラメータを探索したいところだが、時間の都合上とりあえずここで打ち止め。

parameters = [
    {'C': [1, 10, 100, 1000], 'kernel': ['linear']},
    {'C': [1, 10, 100, 1000], 'kernel': ['rbf'],        'gamma': [0.001, 0.0001]},
    {'C': [1, 10, 100, 1000], 'kernel': ['poly'],       'degree': [2, 3, 4], 'gamma': [0.001, 0.0001]},
    {'C': [1, 10, 100, 1000], 'kernel': ['sigmoid'],        'gamma': [0.001, 0.0001]}
]
gs = GridSearchCV(clf, parameters, cv=5, scoring='f1')
gs.fit(vectors, labels)
logging.info('cv_results_: ')
logging.info(gs.cv_results_)
logging.info('best_estimator_: ')
logging.info(gs.best_estimator_)

取得できたベストパラメータは以下。

(C=1000, cache_size=200, class_weight=None, coef0=0.0,
 decision_function_shape='ovr', degree=3, gamma=0.0001, kernel='rbf',
 max_iter=-1, probability=False, random_state=None, shrinking=True,
  tol=0.001, verbose=False)
結果
  • precision: 0.8505338078291815
  • F1値: 0.8409090909090909
考察と感想
  • ついに80%超え!
  • 欲をいえば90%超えたかった。
  • 多分画像数増やせば(人数増やす、水増しをもっと増やすなど)もっと精度高くなりそうだけど早くリリースしたいからとりあえずここで打ち止め。

pickle作成

Webアプリケーション側で学習済みのモデルとスケール変換器を使用するためにベストパラメータで学習した結果をそれぞれpickleファイルに書き出す。

# clf(分類器)をpath(パス)に書き出す
joblib.dump(clf, path)

Web側実装

クライアントサイド

クライアントサイド実装の今回のポイントです。

jinja2テンプレート

HTMLに変数を渡したりするのにjinja2テンプレートを利用。
FlaskやDjangoの場合はjinja2テンプレートを使うのが一般的。

レスポンシブデザイン

画像アップロードするならスマートフォン経由も多いかなと想定して、レスポンシブデザインに対応しました。

BootStrap

クライアントサイドはそれほど得意でないので、BootStrap臭満載でも積極的に活用していきました。

写真

なんとなく今時のサイトってトップに大きい写真を使っている印象なので自分もそれを採用。
PEXELというフリー画像サイトから画像を頂戴しました。

SNS連携

twitter, LINEのシェアボタンを実装しました。
実装したと言っても、各社の公式サイトにHTMLタグのジェネレータが用意されているので、それを自分のHTMLファイルに貼るだけ。
ただし、ローカルの開発環境では正常に動作しないようで、本番環境での調整作業が必要となりました。
また、facebookは「MacOS + Chrome」の組み合わせで正常に動作しないことを発見したのと、なぜかインデントがずれてしまうため、実装しませんでした。(いつかリベンジ)

エラー画面

StatusPageのテンプレートをほぼそのまま使用。
メッセージ部分だけ自分のテキストに書き換えたのと、前の画面に戻るボタンを追加しました。
エラー画面にそれほど手間をかけたくないけどお洒落なやつがいいなと思ったらこういうのを使うのもありですよ。

サーバーサイド

サーバーサイド実装の今回のポイントです。

FLask

WebアプリケーションフレームワークにFlaskを採用しました。
理由は、今回の規模的に軽量フレームワークで充分なのと使用経験があるからです。
これを機会にFlaskのまとめページもQitaに投稿する予定です。

コンフィグ

当たり前のことですが、パラメータ類は全て外部コンフィグに書き出しました。
API-KEYなどの機密情報のコンフィグはinstanceディレクトリ配下の別ファイルで用意してディレクトリごとバージョン管理対象外にしています。

ログ

個人アプリとはいえちゃんとアプリケーションのログも/var/log配下に出力するようにしています。

エラー処理

エラー処理はどこまでやるか悩みましたが、とりあえず「アップロード写真の拡張子が許容されるものか」と「アップロードされた画像に顔が含まれているか」のエラー処理だけ実装しました。
アップロードされた画像に顔が含まれているか」のエラー処理はGoogle Cloud Vison APIを利用しました。

Google Cloud Vison(GCV) API

ユーザからアップロードされた画像に顔が含まれているかを判定するためにGCVのAPIを利用しました。
顔を特定できなかった場合はAPIレスポンスのjsonデータ中にfaceAnnotationsキーが含まれないため、それの有る無しで判定しています。
(GCVのAPIが仕様変更になったら要対応)

def facedetector_gcv(image, api_key, max_results, gcv_url):
    """
    Google Cloud Vision(GCV) APIを利用して画像に人間の顔が含まれているかどうかを判定する関数
    :param image: 画像ファイル
    :param api_key: GCV利用のためAPI KEY
    :param max_results: いくつの顔を判定するか。数が少ないほど精度高い。
    :param gcv_url: GCV APIのURL
    :return:boolean
    """

    # GCVのRequest設定
    str_headers = {'Content-Type': 'application/json'}
    batch_request = {'requests': [{'image': {'content': base64.b64encode(image).decode('utf-8')},
                                   'features': [{'type': 'FACE_DETECTION', 'maxResults': max_results, }]}]}

    # セッション作ってリクエスト送信
    obj_session = Session()
    obj_request = Request('POST', gcv_url + api_key, data=json.dumps(batch_request), headers=str_headers)
    obj_prepped = obj_session.prepare_request(obj_request)
    obj_response = obj_session.send(obj_prepped, verify=True, timeout=180)

    # Responseからjsonを抽出
    response_json = json.loads(obj_response.text)
    logging.info('GCV request is successed')

    # リクエスト画像に顔が含まれているかどうかの判定結果('faceAnnotations'があれば顔あり)
    if 'faceAnnotations' in response_json['responses'][0]:
        return True
    else:
        return False

プロダクション環境構築

VPC作成

こちらの記事を参考にしました。わかりやすいのですが少し古い記事なので要注意。
デフォルトでルートテーブルが作成されることを知らずに、自分で新規作成したルートテーブル(ステータスが無効)を使おうとして、少し詰まりました。

ドメイン名取得

IPで公開するのはダサいし、そもそもHTTPS対応にドメイン名が必要なのでドメイン名を取得しました。
AWSのRoute53でドメイン名を取得して設定するのが一番楽ぽいけど、有料だし個人開発にしては値段も少し高そうだったので、freenomというサイトから無料でドメイン名を取得しました。ただし、最初の3ヶ月を過ぎると15日毎に手動で更新する必要があるので、運用する際には注意が必要。

Route53にドメイン名登録

freenomで取得したドメイン名をRoute53に登録して、NSレコード情報をfreenom側に登録し、freenomドメインとAWS間の連携をします。
こちらの記事の一部を参考にしました。

SSL証明書取得

今時、HTTPS対応は必須なのでSSL証明書を取得しました。
当初はLet's encryptで三ヶ月間無料のSSL証明書を取得しようと考えていましたが、AWSのACM(AWS Certificate Manager)を利用するとSSL証明書が無期限無料ということを知ったため、ACMを利用することにしました。ただし、ACMを紐づけるELBの料金は有料です。

実は、私にとって、このSSL証明書取得が今回の開発の中で一番悶絶したところです。
ACMで証明書をリクエストすると検証のためにフリードメインのメールアドレスにメールが送信されるのですが、「フリードメインのメールアドレスって何?」、「そもそもメールサーバなんて用意してないけど?」とかそんな状態でした。

結論としては、まずは、検証メールは以下のメールアドレス宛に一斉送信されるようです。

  • admin@<ドメイン>
  • administrator@<ドメイン>
  • hostmaster@<ドメイン>
  • postmaster@<ドメイン>
  • webmaster@<ドメイン>

では、肝心のメール受信の方法ですが、こちらの記事の一部を参考にして、AWSのSES(Simple Email Service)を利用して、メール受信をS3バケットで行うようにしました。
S3バケットに送られてきたメッセージから"https"でgrepしたリンクに飛んでapproveボタンを押下してSSL証明書の有効化に成功!!!(少し強引なやり方。)
色々、設定項目が細かくてオペミスしたり、そもそもドメイン関連に慣れていなくて苦労しました。

EC2インスタンスとELB生成とSSL証明書登録とAレコード登録

こちらの記事の後半を参考にしました。
ポイントは、ELBのプロトコルをHTTPSにして、ACMの証明書を登録することとRoute53のAレコードにELBを選択することです。
ドメイン名に対応するIPアドレスはEC2インスタンスのIPアドレスではなく、ELB自身とします。
なぜか、ELBとEC2インスタンス紐付けが外れていたことが一度だけあったが、再発しなかったのでとりあえず放置。(怖い)

EC2インスタンスにNginxインストール&起動

HTTPS接続テスト用にEC2インスタンスにNginxをインストール。(ブラウザからアクセスした時に動作確認としてわかりやすいのと、アプリデプロイでどうせ使うから)
Amazon Linux2(AL2)ではExtrasレポジトリからNginx をインストールする必要がありました。

$ sudo amazon-linux-extras install nginx1.12
$ sudo systemctl start nginx.service

ブラウザで動作確認

https://faceaudition.tk
上記にブラウザでアクセスして、Nginxの画面が表示されて歓喜!!!!!

スクリーンショット 2018-07-22 12.13.18.png

(やらなくてOK)hostコマンドで名前を引く

ついでにhostコマンドでドメイン名からAWSのIPアドレスを引くことができてるか確認。
自分で作ったドメイン名が世の中で使える実感を得られて感動しました。(泣)

$ host faceaudition.tk
faceaudition.tk has address 52.192.XXX.XXX
faceaudition.tk mail is handled by 10 inbound-smtp.us-west-2.amazonaws.com.

EC2インスタンスにパッケージインストール

Pyenv+Anacondaインストール

こちらの記事を参考にしました。

# 必要パッケージインストール
$ sudo yum -y install zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel openssl-devel git

# pyenvインストール
$ git clone https://github.com/yyuu/pyenv.git ~/.pyenv
$ echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bash_profile
$ echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bash_profile
$ echo 'eval "$(pyenv init -)"' >> ~/.bash_profile
$ source ~/.bash_profile

# anacondaインストール可能なものを一覧表示
$ pyenv install -l | grep anaconda
  (省略)
  anaconda3-5.0.0
  anaconda3-5.0.1
  anaconda3-5.1.0
  anaconda3-5.2.0

# anacondaのインストールと使用設定
$ pyenv install anaconda3-5.1.0
$ pyenv global anaconda3-5.1.0

# PATH通し
$ echo 'PATH="$PYENV_ROOT/versions/anaconda3-4.3.1/bin/:$PATH"' >> ~/.bash_profile
$ source ~/.bash_profile

# condaのupdate
$ conda update conda
$ conda update anaconda

$ python -V
Python 3.6.5 :: Anaconda, Inc.

OpenCVインストール

ローカル開発環境でPycharmからOpenCVインストールするのはとても簡単でしたが、AL2でやるのは少し大変でした。
こちらの記事にしたがって、git cloneで試しましたけどダメだったので、こちらの記事 にしたがって、pipでopencv-pythonをインストールしました。
ただ、opencv-pythonインストール時のopencvのバージョン指定方法がよくわからず、最新版をインストールしたため、プロダクション環境では3.4で、ローカル開発環境では3.3.1と、バージョンの違いをうんでしまった。(本当はまじでこれダメ)

$ pip install opencv-python

アプリケーションデプロイ

gitクローン

ローカル開発環境で作った肝心の自作アプリをgitクローン。ワクワク。

$ mkdir ~/GitHub
$ cd GitHub
$ git clone https://github.com/gold-kou/face-audition-johnnys.git

logディレクトリ作成

自作アプリ用のlogディレクトリを作成。

$ sudo mkdir /var/log/johnnys
$ sudo chown ec2-user:ec2-user /var/log/johnnys/

顔抽出画像の保存先ディレクトリ作成

顔判定結果画面を出力するHTMLファイルから参照可能なパス(/home/ec2-user/GitHub/face-audition-johnnys/web/application/static/user_faces)でユーザの顔抽出画像を保存するディレクトリを作成。
このディレクトリはgitignore対象とします。
当初は/tmpに保存した画像を参照することを想定していましたが、実装上HTMLファイルからの参照が無理なことがわかったためこのような対処を実施。
ディレクトリ内画像ファイルはcronで定期的に毎日深夜に削除します。(ユーザがこの時間にちょうど実行するとエラーになるので、もっと上手な運用方法を考えるべき)

importの探索範囲を広げる環境変数設定

pythonコード内のimport探索の設定。
bash_profileに記載して永続化しておきます。

bash_profile
$ vi ~/.bash_profile 
PYTHONPATH=/home/ec2-user/GitHub/face-audition-johnnys ★末尾に追記
$ source .bash_profile 
$ printenv |grep PYTHON
PYTHONPATH=/home/ec2-user/GitHub/face-audition-johnnys

pickleファイルを本番環境に配置

GitHubではファイルが重すぎて管理不能だったpickleファイルをSCPで配置。
こちらの記事を参考に容量が大きいファイルをpushすることまではできたのですが、pullして使ってみたらエラーになりました。そもそもファイルサイズが元と異なるし、何か手順が抜けているのかな。
(今は、ローカルでブランチを切り替える際はstashで凌いでいます。)

$ scp -i ~/Downloads/johnnys-keys.pem svc_grid.pkl ec2-user@54.XX.XX.XX:/home/ec2-user/GitHub/face-audition-johnnys/ml/pickle
svc_grid.pkl                                  100%  263MB  10.3MB/s   00:25

GCVのAPI情報記載

機密情報のためGitHubでは管理できなかったGCVのAPIキーをコンフィグとして設定します。

$ cd /home/ec2-user/GitHub/face-audition-johnnys/web
$ mkdir instance
$ cd instance
$ vi config.ini
[gcv]
api_key = XXXXX

Nginx設定と再起動

さきほどインストールしたNginxの設定とその読み込みを行います。

今回は、site-availableディレクトリ配下で設定ファイルを作成し、その設定ファイルをシンボリックリンクでsites-enabledディレクトリに置く構成とします。

# ディレクトリ作成
$ sudo mkdir /etc/nginx/sites-available
$ sudo mkdir /etc/nginx/sites-enabled

# ファイル編集。ポート番号やuwsgiソケットなどを設定。
$ sudo vi /etc/nginx/sites-available/uwsgi.conf
server {
  listen 80;
  error_log  /var/log/nginx/error.log warn;

  location / {
    include uwsgi_params;
    uwsgi_pass unix:///var/www/flask/tmp/uwsgi.sock;
  }
}

# シンボリックリンク設定
$ cd /etc/nginx/sites-enabled/
$ sudo ln -s ../sites-available/uwsgi.conf

続いて、nginxコンフィグの大元のファイル/etc/nginx/nginx.confを編集します。
デフォルトでは/etc/nginx/conf.d/配下を読むこむ設定になっているため、さきほど作成した/etc/nginx/sites-enabled/配下を読むこむように変更。
include先でserverコンテキストを定義しているため、httpコンテキスト内に定義されていたデフォルトのserverコンテキストは削除。
ファイル許容サイズの設定としてclient_max_body_sizeを設定。(詳細後述)

$ sudo vi /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    client_max_body_size        10m; ★追加
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 2048;

    # include /etc/nginx/conf.d/*.conf; ★コメントアウト
    include     /etc/nginx/sites-enabled/*; ★追加
    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;
}

Nginxのconfの文法確認をして、起動します。

# nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

# service nginx start
Redirecting to /bin/systemctl start nginx.service

uwsgiインストールと設定と起動

uwsgiのインストールをします。libiconvもインストールしないとuwsgiを正常にインストールできませんでした。

$ conda install -c conda-forge uwsgi libiconv
$ uwsgi --version
2.0.17.1

上記の/etc/nginx/sites-available/uwsgi.confで設定していたuwsgiソケット用のディレクトリ作成と権限変更をします。

sudo mkdir /var/www/flask/tmp
sudo chmod o+w /var/www/flask/tmp
sudo chown nginx:nginx /var/www/flask/tmp

アプリケーションの実行用ファイルを/var/www/flask配下に格納します。
本来、Flaskのベストプラクティス的にはこのrun.pyは開発環境用の実行ファイルなので、プロダクション環境では使用するべきではありません。Blueprintを使って今後、整理すべし。

$ cd /var/www/flask
$ cp /home/ec2-user/GitHub/face-audition-johnnys/web/run.py .

uwsgiの設定ファイルを編集。

$ sudo vi uwsgi.ini
[uwsgi]
#application's base folder
base = /var/www/flask

#python module to import
app = run
module = %(app)

#socket file's location
socket = /var/www/flask/tmp/uwsgi.sock

#permissions for the socket file
chmod-socket = 666

#the variable that holds a flask application inside the module imported at line #6
callable = app

#location of log files
logto = /var/log/uwsgi/%n.log

master = true
processes = 1
vacuum = true
die-on-term = true

uwsgi用のログディレクトリ作成と権限変更。

sudo mkdir -p /var/log/uwsgi
sudo chmod o+w /var/log/uwsgi

uwsgiをバックグラウンドで起動。アプリケーションが立ち上がります。

cd /var/www/flask
uwsgi --ini uwsgi.ini &

ちなみに、アプリケーションを停止する場合はps確認してkillで停止します。

$ ps -ef |grep uwsgi
ec2-user 20075 19793  0 23:31 pts/0    00:00:00 uwsgi --ini uwsgi.ini
ec2-user 20112 20075  0 23:31 pts/0    00:00:00 uwsgi --ini uwsgi.ini
ec2-user 20265 19919  0 23:45 pts/1    00:00:00 grep --color=auto uwsgi

$ kill 20075

プレリリース

私が所属するtech batonというコミュニティの方々や友人知人に試し使いをしてもらうという形でプレリリースしました。
その中でいくつかのバグに遭遇。圧倒的感謝。

バグ対応

Nginxのファイル許容サイズ

「client intended to send too large body」というエラーに遭遇しました。
これはクライアントがNginx側で設定していた許容ファイルサイズを上回っていた際にでるエラーのようです。デフォルトで1MBまでのデータしか受け付けないらしい。
"client_max_body_size 10m;"を/etc/nginx/nginx.confのhttpコンテキスト内に追記。

メモリ不足

メモリ不足が原因と考えられる以下のエラーが複数発生。

MemoryError
face_img = cascade.detectMultiScale(request_img_numpy, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))
cv2.error: OpenCV(3.4.2) /io/opencv/modules/core/src/matrix.cpp:367: error: (-215:Assertion failed) u != 0 in function 'create'
face_img = cascade.detectMultiScale(request_img_numpy, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))
image data type = 17 is not supported

AWSの無料インスタンスタイプt2.micro(メモリ1GB)からt2.small(メモリ2GB)に増量したところ、とりあえず上記のエラーには遭遇しなくなりました。
ちなみに、インスタンスタイプ変更にはインスタンスを一度停止する必要があり、グローバルIPアドレスは起動のたびに動的に変更になります。

顔検出不可のエラー処理多発

正面を向いた顔画像をアップロードしているにもかかわらず顔として認識されない例外処理が多発していました。
色々検証したところ、iPhoneだとダメなようです。
詳細原因が不明なため、現在はiPhoneは対象外としていることをトップ画面に記載。

運用時注意点メモ

  • freenomの更新
  • サービスの利用がなくなったらインスタンス停止する(しばらくは動かしておきます)
  • GCV APIの仕様変更あったら顔判定のエラー処理不可

まだまだ要改善点

精度

私の顔をジャニーズ顔と判定できていない時点でまだまだ精度が低いようです。
とりあえず画像を増やせば精度が上がると思う。画像取得数も含め閾値処理やぼかしなどによる水増しなど。
また、ベストパラメータ探索ももっと丁寧にやるべき。

反転画像対応

現状、横に反転されてしまった画像は判定できない。

斜め画像対応

斜めに向いた画像はInternal Server Errorになる。
GCVは性能いいから認識できるんだけど、肝心のOpenCV側で認識できずにエラーになるケースがある。
これ結構深刻。

facebookシェアボタン

「MacOS + Chrome」の組み合わせで正常に動作しないのと、なぜかインデントがずれてしまうところをリベンジ。

iPhone対応

iPhoneから画像をアップロードすると、他端末(Android, macbook, windowsなど)では成功する同一画像を使用してもGCV APIが顔を認識してくれないため利用できない状況になっている。

最後に

一人でインフラからサーバーサイド実装、クライアントサイド実装、サービス公開、運用とかをやるのでめちゃ大変でしたけど、めちゃ勉強になったし楽しかった。
ぜひ、まだ自作アプリ作ったことない人はショボくていいので作って公開してみよう!!

その他参考サイト

機械学習で乃木坂46を顏分類してみた
TensorFlowによるももクロメンバー顔認識(前編)
フロントエンド全然わからないマンが、ちょっとでも見た目のいいWebサービスを作ろうとしてやったこと