Aidemy講座の振り返りと成果物の紹介

## はじめに
はじめまして。私は重電メーカーに5年半勤めて、その後、ソフトウェア開発の会社に9か月間勤めて、現在は就職活動中です。世間では、AIの活躍が注目されており、また、自分の大学の同期がその分野に転職して活躍していることを聞き、自分も盛り上がっているAIの分野で働きたいと思うようになり、Aidemyの講座を受講するに至りました。

## 本記事の概要
・この記事では、あなたの顔がタイプ別の4人のお笑い芸人の中からどのお笑い芸人に似ているかを判別するアプリをご紹介します。どのような流れでモデルが作られ、アプリとして機能するかを記事にしています。
・この記事は、AIに興味があるけど、実際どのようにしてアプリが作られているかわからないという人に向けて、それがわかるように書き、アプリ作成をするきっかけになることを目指しています。
・この記事ではpythonの開発を扱っており、それ以外の言語は使っておりませんのでご了承ください。

## 開発環境
・Windows10 Home 64 ビット オペレーティング システム、x64 ベース プロセッサ
・Python 3.9.13
・render

## どのお笑い芸人に似ているかを判別するアプリの開発
毎年M-1グランプリが開催されるなど、お笑いが注目されることが日常的にある中で、どのお笑い芸人（どのタイプ）に顔が似ているかを判別できるアプリがあったら面白いんじゃないかな、と思い作成しました。今回はバカリズム（フレッシュ）、浜田雅功（キュート）、小島よしお（フェミニン）、土田晃之（クール）の４人の顔を判別に使いました。

完成したアプリは[こちら](https://flask-new-app1-nmzb.onrender.com)。

[機械学習で乃木坂46を顏分類してみた](https://aidemy.net/magazine/682/)
を参考にAidemyの方にコードを見てもらいつつ進めました。
また、作業はVisual Studio Codeを使って進めました。環境は以下の通りです。

まずはじめに、カレントディテクトリ上に、origin,origin_image,face_image,face_scratch_image,test_imageディテクトリを追加しました。また、origin_image以外のそれぞれのディレクトリには各人物のディレクトリを作成しました。

## 目次
1.画像収集
2.顔の切り出し
3.顔画像の水増し
4.モデルを定義して学習、テスト
5.考察

## 1.画像収集
まず画像収集から始めます。Google APIを使って画像収集しました。Google APIの使用方法については、こちらの[サイト](https://ann0n.com/94/)
を参考にしました。それぞれの人物を検索キーワードに入力して画像200枚分のurlを取得し、urlから画像を取得し、origin_imgageに格納します。600枚と指定すると検索をリクエストし過ぎ、というようなエラーが出てしまったので、200枚ずつ検索することとしました。

```python:search_img.py
import requests

res_ = requests.get("http://checkip.amazonaws.com/")

print("Your IP", res_.text)
import urllib.request
from urllib.parse import quote
import httplib2
import json
import os

# ①GCPから取得したAPI
API_KEY = "***********"

# ②GoogleのCustom Search EngineのID
CUSTOM_SEARCH_ENGINE = "10072a841ab644cee"

# 取得したい画像の検索キーワード
keywords = ["小島 よしお"]
# urlを入れるリスト
img_list = []


# urlを取得する関数
def get_image_url(keyword, num):
    # num_searched = 検索した数 num = 検索したい数
    num_searched = 0
    while num_searched < num:
        # numの部分の例（95枚取得したい場合、90枚までは10枚ずつ取得　し残りが5枚）※検索結果は１ページあたり１０個の画像が変えるため
        query_img = "https://www.googleapis.com/customsearch/v1?key=" + API_KEY + "&cx=" + CUSTOM_SEARCH_ENGINE + "&num=" + str(10 if(num-num_searched)>10 else (num-num_searched)) + "&start=" + str(num_searched+1) + "&q=" + quote(keyword) + "&searchType=image"
        # 取得したurlを開く
        print(query_img)
        res = urllib.request.urlopen(query_img)
        data = json.loads(res.read().decode('utf-8'))
        #　jsonのitems以下のlinkに画像のurlが返ってくるのでそれを取り出しリストに追加
        for i in range(len(data["items"])):
            img_list.append(data['items'][i]['link'])
        # １ページあたり検索結果が10返ってくるので検索した数は10ずつ増やす
        num_searched += 10
    return img_list


# urlから画像を取得する関数
def get_image(keyword, img_list):
    # opener = urllib.request.build_opener()
    http = httplib2.Http(".cache")
    for i in range(len(img_list)):
        try:
            print(img_list[i])
            response, content = http.request(img_list[i])
            # ファイルパスを「origin_image/キーワード_index.jpg」とする
            filename = os.path.join("origin_image", keyword + "_" + str(i) + ".jpg")
            with open(filename, 'wb') as f:
                f.write(content)
        except:
            print("failed to download the image.")
            continue
            

# キーワードごとにurlを取得、その後urlから画像を抽出し指定したファイルに書き込む
for i in range(len(keywords)): 
    img_list = get_image_url(keywords[i], 200)
    get_image(keywords[i], img_list)  
    
```

## 取得画像を各人物フォルダに分ける
次に画像ファイルを扱いやすくするため、取得画像をoriginフォルダの各人物フォルダに分ける作業を行いました。また、各フォルダの名前をローマ字表記にしました。

```python:test.py
import glob
import os
import shutil

filenames = glob.glob("origin_image/*")

for idex, path_ in enumerate(filenames):
   names = path_.split("\\")
   names = names[1].split("_")
   if not os.path.exists(f"origin/{names[0]}"):
    # ディレクトリが存在しない場合、ディレクトリを作成する
    os.makedirs(f"origin/{names[0]}")
   shutil.copy(path_, f"origin/{names[0]}/{idex}.jpg")
```

## 2.顔の切り出し
この作業では、顔の切り出しを行います。顔を検出するためopencvのカスケード分類器を使用します。haarcascade_frontalface_default.xmlという正面顔を検出する分類器を使いました。detectMultiScale()の引数にscaleFactor、minNeighborsがあります。detectMultiScale()では様々なスケールで検出を行いますが、それぞれのスケールの縮小量を定義するのが、scaleFactor。様々なスケールで検出した結果、同じエリアで重複して検出されますが、いくつ重複した場合に真とみなすかを定義するのがminNeighborsです。
以下のコードで顔を検出してトリミングし、100×100ピクセルにリサイズし、保存しています。この段階で顔以外が検出されたり、うまく顔が検出できなかったり、別の人物の写真があったので、それを自分の目で確認し、削除していきました。削除した結果、200枚が70～100枚程度になりました。
ちなみ顔の切り出し画像を保存するコードはfor文の外に書きましたが、中に書いていないと顔が２つ以上検出されたときに２つ保存できないようです。なので、本来はfor文に入れたほうがいいようです。


```python:kiridashi.py
import shutil
import numpy as np
import cv2
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import glob
import os
#元画像を取り出して顔部分を正方形で囲み、100×100pにリサイズ、別のファイルにどんどん入れてく

in_dir = "./origin"
in_dir_str = f"{in_dir}/*"
out_dir = "./face_image"
in_folder=glob.glob(in_dir_str)
# in_fileName=os.listdir("./origin_image/")


for folder in in_folder:
  for filename in glob.glob(folder+"/*"):
    image=cv2.imread(filename)
    if image is None:
      print("Not open:",image)
      continue
    image_gs = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    cascade = cv2.CascadeClassifier("haarcascade_frontalface_default.xml")
    # 顔認識の実行
    face_list=cascade.detectMultiScale(image_gs, scaleFactor=1.1, minNeighbors=2,minSize=(100,100))
    print(face_list)
    #顔が１つ以上検出された時
    if len(face_list) > 0:
        #for rect in face_list:
           #x,y,width,height=rect
            #image_face = image[rect[1]:rect[1]+rect[3],rect[0]:rect[0]+rect[2]]
        for x, y, w, h in face_list:
            image_face = image[y: y + h , x:x + w]
            if image_face.shape[0]<100:
                continue
            image_face = cv2.resize(image_face,(100,100))
            #fileName=out_dir+f"{x}{y}{w}{h}"+filename.replace(in_dir, "")
            #cv2.imwrite(fileName,image_face)
    #顔が検出されなかった時
    else:
        print("no face")
        continue
        print(image_face.shape)
    #print(image_face)
    #print(filename)
    #保存
    #fileName=os.path.join(out_dir, filename.replace(in_dir, ""))
    fileName=out_dir+filename.replace(in_dir, "")
    #print(fileName)
    cv2.imwrite(fileName,image_face)
```

## テストデータ作成
次の工程で顔画像の水増しをしますが、水増しをした後でモデルを使いテストをすると偏ったデータを分析した結果が出ると考えられるため、この段階でもテストデータを用意しました。芸人ごとにランダムで2割の枚数のデータをテストデータとして、test_imageディレクトリに保存しました。

```python:kiridashi_test_image.py
import shutil
import numpy as np
import cv2
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
import glob
import os
import random
name_l=["bakarizumu","hamada_masatoshi","kojima_yoshio","tsuchida_teruyuki"]
for name_geinin in name_l:
    in_dir="./face_image"
    in_dir = f"{in_dir}/{name_geinin}"
    os.makedirs(f"./test_image/{name_geinin}", exist_ok=True) 
    #img_file_name_listをシャッフル、そのうち2割をtest_imageディテクトリに入れる

    in_jpg = glob.glob(in_dir+"/*")
    random.shuffle(in_jpg)
    num = len(in_jpg)//5
    for i in range(num):
        shutil.copy(in_jpg[i], f"./test_image/{name_geinin}")  
```

## 3.顔画像の水増し
顔画像が、70～100枚だと少ないと思われたので、左右反転、閾値処理、ぼかしを使って水増し処理を行いました。この処理によって顔画像の枚数を8倍にしました。

```python:mizumashi.py
#左右反転、閾値処理、ぼかしで８倍の水増し
import os
import numpy as np
import matplotlib.pyplot as plt
import cv2
import glob
def scratch_image(img, flip=True, thr=True, filt=True):
    # 水増しの手法を配列にまとめる
    methods = [flip, thr, filt]
    # ぼかしに使うフィルターの作成
    filter1 = np.ones((3, 3))
    # オリジナルの画像データを配列に格納
    images = [img]
    # 手法に用いる関数
    scratch = np.array([
    lambda x: cv2.flip(x, 1),
    lambda x: cv2.threshold(x, 100, 255, cv2.THRESH_TOZERO)[1],
    lambda x: cv2.GaussianBlur(x, (5, 5), 0),
    ])
    # 加工した画像を元と合わせて水増し
    doubling_images = lambda f, imag: np.r_[imag, [f(i) for i in imag]]
    for func in scratch[methods]:
        images = doubling_images(func, images)
    return images
# 画像の読み込み
name_l=["bakarizumu","hamada_masatoshi","kojima_yoshio","tsuchida_teruyuki"]
for name_geinin in name_l:
    in_dir = f"./face_image/{name_geinin}/*"
    in_jpg=glob.glob(in_dir)
    img_file_name_list=os.listdir(f"./face_image/{name_geinin}/")
    for i in range(len(in_jpg)):
        print(str(in_jpg[i]))
        img = cv2.imread(str(in_jpg[i]))
        #img = cv2.imread(str(."face_image"))
        scratch_face_images = scratch_image(img)
        for num, im in enumerate(scratch_face_images):
            fn, ext = os.path.splitext(img_file_name_list[i])
            file_name=os.path.join(f"./face_scratch_image/{name_geinin}",str(fn+"_"+str(num)+".jpg"))
            cv2.imwrite(str(file_name) ,im)

```

## 4.モデルを定義して学習、テスト
   一人当たり500~900枚の画像を用意しました。ここでは、モデルを定義して学習、テストをしていきます。水増しした訓練データの合計は2393枚、テストデータは598枚でした。また、モデルにはvgg16を用いて転移学習させました。
コールバックのEarlyStoppingを使用し、val_lossの改善値が0以下の時が2回続いたら学習を終了するという設定にして、学習しました。結果は、epoch数10で終了し、val_accuracy=0.8614とそこそこな値になりました。
また、水増し前に用意したテストデータの画像もテストしたところ、accuracy=0.88と高い値を出してくれました。


```python:make_list.py
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras import optimizers
from keras.callbacks import EarlyStopping


# お使いの仮想環境のディレクトリ構造等によってファイルパスは異なります。
path_bakarizumu = os.listdir("\\Users\\s.hori\Desktop\\new_app\\face_scratch_image\\bakarizumu")
path_hamada_masatoshi = os.listdir("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\hamada_masatoshi")
path_kojima_yoshio = os.listdir("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\kojima_yoshio")
path_tsuchida_teruyuki = os.listdir("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\tsuchida_teruyuki")


img_bakarizumu = []
img_hamada_masatoshi = []
img_kojima_yoshio = []
img_tsuchida_teruyuki = []

for i in range(len(path_bakarizumu)):
    img = cv2.imread("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\bakarizumu\\" + path_bakarizumu[i]) #ディレクトリ名語尾に /
    if (img is None): 
        continue
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_bakarizumu.append(img)

for i in range(len(path_hamada_masatoshi)):
    img = cv2.imread("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\hamada_masatoshi\\" + path_hamada_masatoshi[i])#ディレクトリ名語尾に/
    if (img is None):
        continue
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_hamada_masatoshi.append(img)

for i in range(len(path_kojima_yoshio)):
    img = cv2.imread("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\kojima_yoshio\\" + path_kojima_yoshio[i]) #ディレクトリ名語尾に /
    if (img is None):
        continue
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_kojima_yoshio.append(img)

for i in range(len(path_tsuchida_teruyuki)):
    img = cv2.imread("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\tsuchida_teruyuki\\" + path_tsuchida_teruyuki[i]) #ディレクトリ名語尾に /
    if (img is None):
        continue
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    img = cv2.resize(img, (50,50))
    img_tsuchida_teruyuki.append(img)

X = np.array(img_bakarizumu + img_hamada_masatoshi + img_kojima_yoshio + img_tsuchida_teruyuki)
y =  np.array([0]*len(img_bakarizumu) + [1]*len(img_hamada_masatoshi) + [2]*len(img_kojima_yoshio) + [3]*len(img_tsuchida_teruyuki) )#ディレクトリ名語尾に/

rand_index = np.random.permutation(np.arange(len(X)))
X = X[rand_index]
y = y[rand_index]

# データの分割
X_train = X[:int(len(X)*0.8)]
y_train = y[:int(len(y)*0.8)]
X_test = X[int(len(X)*0.8):]
y_test = y[int(len(y)*0.8):]

# 正解ラベルをone-hotの形にします
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)

# モデルにvggを使います
input_tensor = Input(shape=(50, 50, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

# vggのoutputを受け取り、4クラス分類する層を定義します
# その際中間層を下のようにいくつか入れると精度が上がります
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(4, activation='softmax')) #Dense 
# vggと、top_modelを連結します
model = Model(vgg16.inputs, top_model(vgg16.output))

# vggの層の重みを変更不能にします
for layer in model.layers[:19]:
    layer.trainable = False

# コンパイルします
model.compile(loss='categorical_crossentropy',
              optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
              metrics=['accuracy'])

# EaelyStoppingの設定
early_stopping =  EarlyStopping(
                            monitor='val_loss',
                            min_delta=0.0,
                            patience=2,
)

# 学習を行います
history = model.fit(X_train, y_train, batch_size=100, epochs=100, validation_data=(X_test, y_test),
callbacks=[early_stopping] # CallBacksに設定
)
model.save("my_model")
# 画像を一枚受け取り、芸人を判別する。
def separately(img):
    img = cv2.resize(img, (50, 50))
    pred = np.argmax(model.predict(np.array([img])))
    if pred == 0:
        return 'bakarizumu'
    elif pred == 1:
        return 'hamada_masatoshi'
    elif pred == 2:
        return 'kojima_yoshio'
    elif pred == 3:
        return "tsuchida_teruyuki"
    
# 精度の評価
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

# pred_gender関数に写真を渡して分類を予測します
img = cv2.imread("\\Users\\s.hori\\Desktop\\new_app\\face_scratch_image\\bakarizumu\\" + path_bakarizumu[10]) #ディレクトリ名 ＋語尾に/
b,g,r = cv2.split(img)
img1 = cv2.merge([r,g,b])
plt.imshow(img1)
plt.show()
print(separately(img))

#resultsディレクトリを作成
result_dir = 'results'
if not os.path.exists(result_dir):
    os.mkdir(result_dir)
# 重みを保存
model.save(os.path.join(result_dir, 'model.h5'))
```

あとは、Flaskを利用してweb上で画像を提出すれば、画像がどのお笑い芸人似ているかを判別してくれるようにしました。

```python:new_app.py
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

import numpy as np


classes = ["バカリズム","浜田 雅功","小島 よしお","土田 晃之"]
image_size = 50

UPLOAD_FOLDER = "uploads"
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])

app = Flask(__name__)

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

model = load_model('./model.h5')#学習済みモデルをロード


@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)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)

            #受け取った画像を読み込み、np形式に変換
            img = image.load_img(filepath, target_size=(image_size,image_size))
            img = image.img_to_array(img)
            data = np.array([img])
            #変換したデータをモデルに渡して予測する
            result = model.predict(data)[0]
            predicted = result.argmax()
            pred_answer = "あなたは " + classes[predicted] + " に似ています"

            return render_template("index.html",answer=pred_answer)

    return render_template("index.html",answer="")


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

## 判別結果
以下のように、似ている芸人を判別することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3333115/9678eb61-790a-a464-c6ca-6585da80f92f.png)

## 5.考察
今回は、コールバックのEarlyStoppingを使用し、val_lossの改善値が0以下の時が2回続いたら学習を終了するという設定にして、学習しました。結果は、epoch数10で終了し、val_accuracy=0.8614というなかなかの結果でした。これは、それぞれの顔の特徴量が高く、取得画像が十分足りていたことが原因と考えられました。
一方で、epoch数ごとのval_accuracyとval_lossをグラフ化し、より最適なepoch数で学習をやめたり、EarlyStoppingの設定を変更し、val_lossの改善値が0以下の時が3回または5回と続いたら学習を終了するようにするなどすれば、モデルの精度が向上すると考えられます。


## 今後の活用
今回はデータ収集の都合上、4人のタイプ別芸人を対象としてどの芸人に似ているかを判別するアプリを作成しました。今後の展望としては、より多くの芸人の顔画像を収集して、より似ている芸人を判別するアプリを作成できたらと思います。

## おわりに
AIについて未経験の状態から、アプリを作成ができるようになり、よくできるようになりました、といった感じです。自分の興味があることを絡めてアプリを作成できるので、楽しく作業を進めることができましたし、今後活用していくにしても面白いものを作っていくことができると思いました。
今回のブログがAIを勉強したことがない方のAIアプリの仕組みの理解につながればと思います。