【機械学習】「橋本環奈ちゃん画像判別アプリ」を実装してみる

# はじめに
フリーランスとしてスマホ（iOS - Swift/Objective-C）アプリ／Webアプリ（Python2x/3x）開発を12年ほどやっていますJinanbouと申します。
昨今話題の「機械学習」に対し、アプリ開発経験者がどこまでモノに出来るのか挑戦したく始めてみました。

初めは独学でネット上の情報を元にPyTorchやTensorflowのサンプルコードを動かしたりしていましたが、基本的なことが理解できていないため、サンプルコードから発想をもって拡張していくことが全く出来ず、

**・Tensorflowやkeras、scikit-learnといったツールの使い方だけでなく、機械学習に必要な基礎知識やアプローチの仕方**

を学ぶべく、Aidemyのプレミアムプラン（ https://aidemy.net/grit/premium/ ）3ヶ月で学習を行いました。
本記事は、Aidemyの卒業時の提出用アプリケーションとして実装したものとなり、3ヶ月程度でこれくらいの知識が得られたことの軌跡として残したいと思いました。

# 本記事の概要
・この記事では、橋本環奈ちゃんの顔画像（211枚）とそれ以外の人物（LFW - http://vis-www.cs.umass.edu/lfw/ - が公開している顔認識ベンチマーク・検証用顔画像の中から取り出した男女50%ほどずつの1000枚）の顔画像を学習させ「確率が高いものを橋本環奈ちゃんとして判別する」アプリケーションの構築の流れを記載しています。
・コードの実行環境はAnacondaで作成したPython3.8.2環境上となり、すべてJupyter-labsで行っています。

# 構築の流れ
本アプリの実装はざっくりと以下の流れで行いました。

```
1. 正解データ（ラベル:0）とする「橋本環奈ちゃん」の画像の取得
2. 1.で取得した橋本環奈ちゃんの画像から「顔部分」だけを切り出し
3. 不正解データ（ラベル:1）とする「橋本環奈ちゃん以外の人の顔画像」=>LFWの画像の取得
4. 2.と3.の画像を元に、tensorflow.kerasの学習モデルで取り扱いしやすくするためにnumpy配列に変換／保存する（.npyファイルとして保存）
5. 4.で作成したnumpy配列データを使用してkerasで学習を行い、モデルファイル（.h5）を保存する
6. 保存したモデルファイル（.h5）をFlaskのWebアプリに組み込む
```


## 1. 正解データ（ラベル:0）とする「橋本環奈ちゃん」の画像の取得
まずはじめに、今回判別したいものとして「橋本環奈ちゃん」の画像を取得します。
取得はGoogleの画像検索を使いました。
Aidemyでの学習ではXMLやHTMLのパースを行う「BeautifulSoup」を使用しましたが、動的に検索結果がレンダリングされるGoogle画像検索ではSeleniumの方がやりやすいかなと思い、SeleniumのwebdriverでChromeをオートメーション化して、

**・画像サムネイルのクラス「rg_i」を探査してクリックをさせ、画面右側に表示されるオリジナル画像のURLを取得／あとでまとめてそれらの画像をダウンロードする**

というアプローチで画像を取得しました。
Jupyter-labsの実行ファイルと同じディレクトリに「images」フォルダを作成してから実行することで画像がダウンロードされます。
以下がそのコードです。

```Python:Jupyter-labsで実行可能
from selenium import webdriver
import time
import datetime
import os
import requests
import hashlib
from os import listdir
from os.path import join


#webブラウザを起動
browser = webdriver.Chrome()

query = '橋本環奈'
browser.get('https://www.google.co.jp/search?q=%s&tbm=isch' % query)
time.sleep(10)
image_urls = set()
for thumbnail in browser.find_elements_by_css_selector('img.rg_i'):
    thumbnail.click()
    time.sleep(1)
    for img in browser.find_elements_by_css_selector('img.n3VNCb'):
        image_urls.add(img.get_attribute('src'))

print("image_urls: {0}".format(image_urls))

index = 0
failed = []
for image_url in image_urls:
    if image_url == None:
        continue
    try:
        response = requests.get(image_url, stream=True)
        if response.status_code == 200:
            contenttype = response.headers['content-type']
            if contenttype == None:
                extension = '.bin'
            if contenttype.find('jpeg') != -1:
                extension = '.jpg'
            elif contenttype.find('png') != -1:
                extension = '.png'
            elif contenttype.find('gif') != -1:
                extension = '.gif'
            else:
                extension = '.bin'
            filename = 'images/%04d%s' % (index, extension)
            index += 1
            with open(filename, 'wb') as file:
                for chunk in response.iter_content(chunk_size=1024):
                    file.write(chunk)
            print('Downloaded: %s' % image_url)
    except:
        print('Failed: %s' % image_url)
        failed.append(image_url)


def md5(fname):
    hash_md5 = hashlib.md5()
    with open(fname, "rb") as f:
        for chunk in iter(lambda: f.read(4096), b""):
            hash_md5.update(chunk)
    return hash_md5.hexdigest()

hashes = set()
for file in listdir('./images'):
    file = join('images', file)
    h = md5(file)
    if h in hashes:
        print('Duplicated image: %s' % file)
        continue
    hashes.add(h)
```


## 2. 1.で取得した橋本環奈ちゃんの画像から「顔部分」だけを切り出し

次に、上記1.でダウンロードした橋本環奈ちゃんの画像から「顔部分だけ」を切り出しします。
切り出しのための顔認識はOpenCVのhaarcascade_frontalface_default.xmlを使用しました。
Jupyter-labsの実行ファイルと同じディレクトリに「org」「png」「png_resize」「face」フォルダを作成し、「org」フォルダに上記1.で取得した橋本環奈ちゃんの画像をすべて入れてから実行することで顔部分の画像だけが「face」フォルダに保存されます。
また「org」フォルダに設置した画像が.jpg形式ではない場合に.jpg形式に変換してから処理を行います。

以下がそのコードです。

```Python:Jupyter-labsで実行可能
import os
import subprocess
from PIL import Image
import cv2 as cv

dir0 = 'org' 
dir1 = 'png'
dir2 = 'png_resize'
dir3 = 'face'


files0 = os.listdir(dir0)
files0.sort()

for file in files0:

    if '.jpg'  in file:        
        #command = 'sips --setProperty format png ' + dir0 +'/' + file +  ' --out ' + dir1 +'/' +  file.replace('.jpg','.png')  #jpg to png
        command = 'sips --setProperty format jpeg ' + dir0 +'/' + file +  ' --out ' + dir1 +'/'
        subprocess.call(command, shell=True)
        #print(file) 

files1 = os.listdir(dir1)
files1.sort()

print("length of files1: {0}".format(len(files1)))

if len(files1)==0:
    print("orgにjpg画像をセットしてください")

# aaa.jpg  
print("----")
for file in files1:   
    if '.jpg' in file:   
        img0 = os.path.join(dir1, file)
        img0_img = Image.open(img0)
        h = img0_img.height
        w = img0_img.width
        img1_img = img0_img.resize((600,round(600*h/w)))
        img1 = os.path.join(dir2, file) 
        img1_img.save(img1)
        #print(file) 

# aaa.png

files2 = os.listdir(dir2)
files2.sort()   

print("length of files2: {0}".format(len(files2)))

face_cascade = cv.CascadeClassifier('haarcascade_frontalface_default.xml')


margin = 20  #切り出し範囲を拡張する
for file in files2:
    if '.jpg' in file:
        dirfile = os.path.join(dir2, file) 
        img = cv.imread(dirfile)
        gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
        print("あるよ")
        faces = face_cascade.detectMultiScale(gray, 1.3, 5)

        for (x,y,w,h) in faces:
            print("margin: "+str(margin))
            face = img[y-margin:y+h+margin, x-margin:x+w+margin]
            face_name = str(file.strip('.jpg'))+'_'+str(x)+'_'+str(y)+'.jpg'
            dirface = os.path.join(dir3,face_name)
            print("dirface: "+dirface)
            print("face_name: "+face_name)
            try:
                facefile = cv.imwrite(dirface, face) 
                #cv.rectangle(img,(x-10,y-10),(x+w+10,y+h+10),(255,0,0),2)
                print(face_name) 
            except:
                pass

```

## 3. 不正解データ（ラベル:1）とする「橋本環奈ちゃん以外の人の顔画像」=>LFWの画像の取得

橋本環奈ちゃん以外の人間の顔画像をある程度学習させておく必要があるため、LFW（ http://vis-www.cs.umass.edu/lfw/ ）が公開している顔認識ベンチマーク・検証用顔画像の中から取り出した男女50%ほどずつの1000枚の画像を使用します。

## 4. 2.と3.の画像を元に、tensorflow.kerasの学習モデルで取り扱いしやすくするためにnumpy配列に変換／保存する（.npyファイルとして保存）
正解データと不正解データ、それぞれが画像ファイル（.jpg）となっているわけだが、この画像ファイルのままではkerasでの学習モデル構築における学習や検証などの計算量が膨大になってしまうので、あらかじめ扱いやすい形式となる「Numpy行列データ（.npy形式）」に変換・保存を行う。

以下がそのコードです。

```Python:Jupyter-labsで実行可能
## 「kanna」フォルダ=>正解データ
#「other」フォルダ=>不正解データ
#として、フォルダ内に入っている画像を元に、
# 自分でトレーニング用とテスト用のファイルを分割するコード（2021年09月15日09:41）

from PIL import Image
import os,glob
import numpy as np
#from sklearn import cross_validation
from sklearn import model_selection
import datetime

classes = ["kanna","other"]
num_classes = len(classes)
image_size = 150

X_train = []
X_test  = []
Y_train = []
Y_test  = []

for index,classlabel in enumerate(classes):
    photos_dir = "./data/"+classlabel
    files = glob.glob(photos_dir + "/*.jpg")
    for i, file in enumerate(files):
        #print("i: {0}".format(i))
        #if i >= 200:break
        image = Image.open(file)
        image = image.convert("RGB")        
        image = image.resize((image_size,image_size))
        data = np.asarray(image)
        
        if classlabel=="kanna":
            if i < 60: #テスト用に60件のデータをX_testとY_testに入れておき、残りは学習用にX_train,Y_trainに入れる
                X_test.append(data)
                Y_test.append(index)
            else:
                X_train.append(data)
                Y_train.append(index)
        else:
            if i < 300: #テスト用に300件のデータをX_testとY_testに入れておき、残りは学習用にX_train,Y_trainに入れる
                X_test.append(data)
                Y_test.append(index)
            else:
                X_train.append(data)
                Y_train.append(index)

X_train = np.array(X_train)
X_test = np.array(X_test)
y_train = np.array(Y_train)
y_test = np.array(Y_test)

xy = (X_train,X_test,y_train,y_test)
np.save("./data_202109141744.npy",xy)
print("完了です。完了日時： "+str(datetime.datetime.now()))
```


# 5. 4で作成したnumpy配列データを使用してkerasで学習を行い、モデルファイル（.h5）を保存する

いよいよtensorflow.kerasにて学習モデルの構築を行います。

以下がそのコードです。

```Python:Jupyter-labsで実行可能
# ここからはanimal_cnn.pyの内容
from tensorflow.keras.models import Model, Sequential,load_model
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input,Conv2D, MaxPooling2D,Activation,BatchNormalization
from tensorflow.keras.utils import to_categorical
from tensorflow.keras import optimizers,regularizers
from tensorflow.keras.callbacks import EarlyStopping
import random
import matplotlib.pyplot as plt
from tensorflow.keras.applications.vgg16 import VGG16
import cv2
import re
import os
import numpy as np
from PIL import Image

classes = ["kanna","other"]
num_classes = len(classes)
image_size = 150

# メインの関数を定義する
def execute():
    #「kanna」に入れた正解の正方形画像（橋本環奈ちゃんの顔画像）と「other」に入れた不正解画像をnumpy形式にしたものをロードする。
    #X_train, X_test, y_train, y_test = np.load("./kanna.npy",allow_pickle=True) #フォルダ内の画像だけで作成したnumpyのデータ
    X_train, X_test, y_train, y_test = np.load("./data_202109141744.npy",allow_pickle=True) #フォルダ内の画像を元に「1.回転（傾き）」「2.左右反転」してデータを水増ししたnumpyのデータ
    print("X_train.shape: {0}".format(X_train.shape))
    print( "X_test.shape: {0}".format(X_test.shape))
    print("y_train.shape: {0}".format(y_train.shape))
    print(" y_test.shape: {0}".format(y_test.shape))
    batch_size = len(X_train[0][0])
    print("batch_size: {0}".format(batch_size))
    #return
    
    X_train = X_train.astype("float") / 256 #整数を浮動小数点に変換する
    X_test = X_test.astype("float") / 256
    y_train = to_categorical(y_train,num_classes)
    y_test = to_categorical(y_test, num_classes)
    
    model,history = model_train(X_train, y_train, X_test, y_test)
    
    ###### 学習過程をグラフで描画する - ここから
    metrics = ['loss', 'accuracy']                   # 使用する評価関数を指定
    plt.figure(figsize=(20, 7))                      # グラフを表示するスペースを用意
    for i in range(len(metrics)):
        metric = metrics[i]                          # historyから値を取り出すためのキーとして使用
        plt.subplot(1, 2, i+1)                       # figureを1×2のスペースに分け、i+1番目のスペースを使う
        plt.title(metric)                            # グラフのタイトルを表示
        plt_train = history.history[metric]          # historyから訓練データの評価を取り出す
        plt_test = history.history['val_' + metric]  # historyからテストデータの評価を取り出す
        plt.plot(plt_train, label='training')        # 訓練データの評価をグラフにプロット
        plt.plot(plt_test, label='test')             # テストデータの評価をグラフにプロット
        plt.legend()                                 # ラベルの表示
    ###### 学習過程をグラフで描画する - ここまで

    model_eval(model, X_test,y_test)

def model_train(X,y,x_t,y_t):

    #畳み込みの定義を行う
    model = Sequential()
    model.add(Conv2D(32,(3,3),padding="same",input_shape=X.shape[1:]))
    model.add(Activation('relu'))#1層目
    model.add(Conv2D(32,(3,3))) #2層目
    model.add(Activation('relu'))#3層目
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.25))
    
    model.add(Conv2D(64,(3,3),padding='same'))
    model.add(Activation('relu'))
    model.add(Conv2D(64,(3,3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.25))
    model.add(BatchNormalization())
    
    model.add(Flatten()) #データを1列に並べるFlatten
    model.add(Dense(512)) #データを全結合する
    model.add(Activation('relu'))
    model.add(Dropout(0.5))#データを半分捨てる
    model.add(Dense(2))#最後の出力層のノードは1とする
    model.add(Activation('softmax'))
    
    
    #学習が進まなくなったら停止させるためのコールバック関数
    es_cb = EarlyStopping(monitor="val_loss", patience=10, verbose=1, mode='auto') #過学習防止のため、val_lossが10回連続で上がったら学習を止める。
    #history = model.fit(X, y, batch_size=32, epochs=200, validation_data=(x_t, y_t),callbacks=[es_cb])
    
    batch_size = len(X[0][0][0])
    
    #最適化を行う
    model.compile(loss='categorical_crossentropy',
                  optimizer=optimizers.SGD(learning_rate=0.000008,decay=0.001, momentum=0.9),
                  metrics=['accuracy'])

    #学習開始
    history = model.fit(X,y,
                        batch_size=150,
                        epochs=700, 
                        #steps_per_epoch=
                        validation_data=(x_t, y_t),callbacks=[es_cb])
    model.summary()
    
    model.save('./kanna_detector_lr0-000008_dc0-00005_bs150_202109141610.h5') #モデルの保存
    return model,history

def model_eval(model, X, y):
    scores = model.evaluate(X, y, verbose=1)
    print("Test Loss: ", scores[0])
    print("Test Accuracy: ", scores[1])

execute()
```

学習結果は以下のようなグラフとなっています。（過学習しています。。。）

acc: 95.83 %
val_loss: 10.52%
![machine_learning_20210915.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1718393/461a0bd9-be05-83ef-cd00-3487d18f0b8a.png)



# 6. 保存したモデルファイル（.h5）をFlaskのWebアプリに組み込む
以下がHerokuにアップしたFlask製のアプリです。

```Python:Flaskがインストールされている環境で動作可能
import os
from flask import Flask, request, redirect, render_template, flash
from werkzeug.utils import secure_filename
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.preprocessing import image
from PIL import Image, ImageOps
import numpy as np
import logging
import cv2
from tensorflow.keras.applications.inception_v3 import InceptionV3, preprocess_input

IMAGE_SIZE = 150

UPLOAD_FOLDER = "/var/www/html/targetdetector_20210916/static/uploads"
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])

app = Flask(__name__,static_folder="static")
app.logger.debug('DEBUG')
app.logger.info('INFO')
app.logger.warning('WARNING')
app.logger.error('ERROR')
app.logger.critical('CRITICAL')
    
#model = load_model('./model_20210706_1.h5')#学習済みモデルをロード
#model = load_model('./people_detector_lr0-000008_dc0-00001_b150_202109122316.h5')#裕也の顔画像を判定する学習済みモデルをロード
model = load_model('/var/www/html/targetdetector_20210916/kanna_detector_lr0-000008_dc0-001_bs150_202109150931.h5')#橋本環奈の顔画像を判定する学習済みモデルをロード

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

#アップロードされた画像ファイルから顔部分を抜き出して独自モデルで判別処理を行う
def predict(filepath):
    face_found_flg = 0
    
    face_cascade = cv2.CascadeClassifier('/var/www/html/targetdetector_20210916/haarcascade_frontalface_default.xml')
    img = cv2.imread(filepath)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    faces = face_cascade.detectMultiScale(gray, minSize=(20,20))
    print("faces: "+str(faces))
    print("filepath: "+filepath)
    margin = 10  #切り出し範囲を拡張する
    filename = filepath.replace('/var/www/html/targetdetector_20210916/static/uploads/','')
    for (x,y,w,h) in faces:
        print("margin: "+str(margin))
        face = img[y-margin:y+h+margin, x-margin:x+w+margin]
        dirface = os.path.join("/var/www/html/targetdetector_20210916/static/face",filename)
        print("dirface: "+dirface)
        print("filename: "+filename)
        try:
            facefile = cv2.imwrite(dirface, face) 
            #cv2.rectangle(img,(x-10,y-10),(x+w+10,y+h+10),(255,0,0),2)
            face_found_flg = 1
            filepath = dirface
        except:
            pass
    
    if face_found_flg==1:
        img = image.load_img(filepath, target_size=(IMAGE_SIZE,IMAGE_SIZE))
        img = img.convert('RGB')
        #画像データを64 x 64に変換
        _image = img.resize((IMAGE_SIZE, IMAGE_SIZE))
        _image.save("/var/www/html/targetdetector_20210916/test.png")
        # 画像データをnumpy配列に変換
        img = np.asarray(_image)
        img = img / 255.0
        
        result = model.predict(np.array([img]),verbose=1)
        print('result:',result)
        predicted = result.argmax()
        print('predicted:',predicted)
        ok_count,ng_count = 0,0
        if predicted==1:
            ng_count = 1
        else:
            if result[predicted][0]>0.69:
                ok_count = 1
            else:
                ng_count = 1
        print("ok_count: {0}".format(ok_count))
        print("ng_count: {0}".format(ng_count))
        if ok_count>ng_count:
            pred_answer = "【正解】学習済みの画像です！"
        else:
            pred_answer = "【不正解】学習済みの画像ではありません！"
    else:
        pred_answer = "【顔検出エラー】顔部分が適切に検出出来ませんでした。"
    return pred_answer,filepath

@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        if 'file' not in request.files:
            flash('ファイルがありません')
            return redirect(request.url)
        file = request.files['file']
        if file.filename == '':
            flash('ファイルがありません')
            return redirect(request.url)
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            print('filename: '+filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)
            #filepath = filepath.replace('/var/www/html/targetdetector_20210916/','/')
            print("filepath: {0}".format(filepath))
            
            #受け取った画像を読み込み、np形式に変換
            pred_answer,filepath = predict(filepath)
            print('pred_answer:',pred_answer)
            print('filepath:',filepath)
            filepath = filepath.replace('/var/www/html/targetdetector_20210916','')
            items = {"filepath":filepath}
            return render_template("index.html",answer=pred_answer,items=items)
    return render_template("index.html",answer="",items=None)

if __name__ == "__main__":
    port = int(os.environ.get('PORT', 5000))
    app.run(host ='0.0.0.0',port = port)
```




# 出来上がったアプリ
以下のURLのものが出来上がったアプリとなります。
橋本環奈ちゃんの画像をアップしてみてください。画像の中から顔部分だけを抽出して、その顔画像を判定します。

https://app.jnbdev.net/app/

![kanna_detector.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1718393/49497e3f-8db7-243d-5f06-119a3992f108.png)




# 機械学習をやってみての振り返り 
### Aidemyでの学習
独学の後、Aidemyのプレミアムプラン（ https://aidemy.net/grit/premium/ ）で学んでみたわけだが、
体系的に機械学習を学べたのはとても良かったと思う。

また、機械学習を行う上で必要なツール（Google Colab、Jupyter-notebook）やライブラリ群（Numpy、Pandasなど）において、そもそも一般的には何を使うと効率的に作業が出来るか、細かいところだけどこういう「その道に居る人達の間では当たり前のこと」を知ることが出来たのは、個人的に機械学習を発展させていくために大いに役立った。

一つ希望を言わせてもらえば、もうちょっと実践（コード演習よりももっと「テーマを決めて完成させる」系のもの）があったら良かったなぁと思う。


### 最後に残った問題点
これはやっぱり**「精度を上げるためにはどうしたらよいかが明確に分からない」**ということ。

今回実装した上記のアプリについても、（グラフを見てもらえるとわかると思うが過学習しているわけだが）accが95.83% val_lossが10.52%と、数値的に見るとそこそこ正解判定が正しくできそうなわけだが、

・学習させた画像で試しても正解にならないことがある
・他の人（女性）の画像をで試すと正解になってしまうことがある。

という、体感的な正答率が数値と結構かけ離れているなぁと感じており、このギャップを埋めるために、

```Python
if result[predicted][0]>0.69:
    ok_count = 1
else:
    ng_count = 1
```

といった形で、学習モデルによる判定結果の値から「正解率が69%以上であれば橋本環奈ちゃんとする！」といった処理を入れているわけだが、こういうやり方が適切なのかどうかがはっきりいって分からない。
こうした「実際にアプリケーションとして組み込む上でのノウハウ」などは続けていく中で見えてくるものだとは思うので、実用性を目指して繰り返し学んでいこうと思う。