小松菜奈とあいみょんの画像分類ができる機械学習アプリを作成してみた

私は現在Pythonを用いて、AIアプリ開発を学んでいます。AIアプリのジャンルは色々ありますが、画像認識ができたらちょっと「AIエンジニア」と言えるようになるのでは！？と思い、初心者が頑張って作ってみました。

作成したアプリは[こちら](https://naan-aimyon-app.onrender.com/)でお試しいただけます（判別精度はイマイチです・・・）。

## 目次
1. なぜ小松菜奈とあいみょんなのか？
1. 画像の選定
1. 画像から顔の部分を切り抜き
1. 画像を訓練データとテストデータに分割
1. 訓練データの拡張
1. モデルの定義と学習
1. 画像の推定
1. まとめ

## なぜ小松菜奈とあいみょんなのか？
今回、画像分類をするにあたり題材として小松菜奈とあいみょんの二値分類を選んだ理由は、大きく2つあります。

1つ目の理由は、分類するグループが多ければ多いほど、画像もおおく必要になりますし、学習モデルを作るのにもとても時間がかかります。
今回は、初心者としてとりあえず画像分類アプリを作成して公開できればと思ったので、画像の収集と学習モデルの作成に時間をかけたくないと思いました。

2つ目の理由は、Mステを見ていたときに歌手のあいみょんが出演していて、小松菜奈に似ていると思ったので、顔の似ている二人を分類できたらちょっと面白いかなと思っただけです。顔が似ていると見分けが人間でも難しいこともあるため、AIはどのぐらいの精度でできるのかなぁと思ったっていう感じですね。

## 学習用の画像の収集

まずは学習・テスト用の画像をスクレイピングで取得していきます。
スクレイピングの方法は様々あります。ブラウザ（Google,Yahoo,Bingなど）毎の画像検索を使うもいいですし、APIを使う方法もあります。

しかしAPIは、APIを使うためのアカウント登録などの作業が発生するので、今回は手軽に誰でもできるブラウザの画像検索でいくことにします。

### Bing画像検索でスクレイピング

GoogleよりもBingの方が簡単そうだったので、まずはBingで試してみましょう。

以下のコードは、[こちらのscraping.py](https://qiita.com/shota_seki/items/c8cf40f9c7764f43496c#1%E7%94%BB%E5%83%8F%E5%8F%8E%E9%9B%86)を参考にさせていただきました。

```python:get_image.py
# BINGで画像をクロールするモジュールをインストール
from icrawler.builtin import BingImageCrawler
# Pythonの画像処理ライブラリpillowをインストール
from PIL import Image
# 特定のパターンにマッチするファイルを取得できるモジュール
import glob

# 画像を収集する関数scraping
def scraping(path, keyword, num): 
    # path：画像を保存するパス、keyword：検索ワード、num：画像取得の枚数
    
    bing_crawler=BingImageCrawler(
    downloader_threads=4, # ダウンローダーのスレッド数
    storage={'root_dir': path}
    )

    #keywordで得られる画像をnum枚を収集
    bing_crawler.crawl(
        keyword=keyword,
        max_num=num
    )
    print(f'{keyword}: scraping completed!')


# 対象フォルダを各変数に代入
nana_path='./images/nana/*.jpg'
aimyon_path='./images/aimyon/*.jpg'

#取得したい人名と枚数を設定
keywords=['小松菜奈','あいみょん']
num=600

scraping('./images/nana/', keywords[0], num)
scraping('./images/aimyon/', keywords[1], num)
```

小松菜奈:nanaフォルダ、あいみょん:aimyonフォルダに画像を保存しました。取得した画像枚数を確認したところ、80枚程度しか取得できておらず少なかったです。Bingだと枚数が少なくGoogleの方が画像が多く取得できるということだったので、Googleで試すことにしました。

### Google画像検索でスクレイピング

Googleの場合は、seleniumやrequestsライブラリを使っていきます。

以下のコードは、[こちらのget_image_url.py](https://qiita.com/nkbk2525/items/9b6b254eedd311c1f932#-%E7%94%BB%E5%83%8F%E3%81%AEurl%E3%82%92%E5%8F%96%E5%BE%97)を参考にさせていただきました。

```python:get_image_url.py
# seleniumなどをインポート
import os
from time import sleep
import requests
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import pandas as pd

# シークレットモードでChoromeを起動する
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument("--incognito")  
driver = webdriver.Chrome('chromedriver',options=options)


# google画像検索のURL
url = "https://www.google.co.jp/imghp?hl=ja&tab=ri&ogbl" 
driver.get(url)
sleep(2)


# キーワードを入力し、検索
query = "小松菜奈"
search_box = driver.find_element_by_class_name("gLFyf")
search_box.send_keys(query)
search_box.submit()
sleep(2)


# スクロール操作
height = 1000
while height < 50000:
    driver.execute_script("window.scrollTo(0, {});".format(height))
    height += 3000
    sleep(1)

# サムネイルのURLを取得
thumnail_urls = driver.find_elements_by_class_name("rg_i")
thumnail_urls_len = len(thumnail_urls)
print("thumnail_urls_len :{}".format(thumnail_urls_len))

# サムネイルをクリックして各画像URLを取得
image_urls = set()
for img in thumnail_urls[:300]:
    try:
        img.click()
        sleep(2)
    except Exception:
        continue

    try:
        url = driver.find_element_by_class_name("n3VNCb").get_attribute("src")
        if url and "https:" in url:
            image_urls.add(url)
            print(url)
            print()
    # 取得できていない場合はno such elementを表示させる
    except Exception as e:
        print("no such element")


# csvファイルに保存
image_urls = list(image_urls)
df = pd.DataFrame(image_urls, columns=["url"])
df.to_csv("{}_image_url.csv".format(query))

# 画面終了
driver.quit()
```
このコードで小松菜奈の画像URLをCSVファイルで保存できました。
あいみょんの画像URLを取得してCSVファイルを作成するには、「query = "小松菜奈"」の小松菜奈をあいみょんに変えてあげるだけで大丈夫です。
では次はCSVファイルを使って、実際に画像を取得します。

## CSVファイルから画像を取得

```python:get_image.py
import os
import requests
import pandas as pd

# 2人の名前をnamesに配列代入
names = ["小松菜奈", "あいみょん"]

# for文で2回ループ
for name in names:

    # CSVの読み込み
    df = pd.read_csv("{}_image_url.csv".format(name))

    # 各メンバーのディレクトリがない場合は作成
    IMAGE_DIR = "images/{}/".format(name)
    if not os.path.exists(IMAGE_DIR):
        os.mkdir(IMAGE_DIR)

    # URLから画像をダウンロード
    for i, url in enumerate(df.url):
        try: 
            image = requests.get(url)
            with open(IMAGE_DIR + "{}_{}".format(name, str(i).zfill(3)) + ".jpg", "wb") as f:
                f.write(image.content)

        except Exception as e:
            print(name, i, "error!!")

```
こちらで試す200枚ぐらい取得できたので、Bingで取得した画像と足して300枚ぐらいになりました。
そこから目視で人物じゃない画像や、本人じゃない画像をクレンジングしていくと、200枚弱ぐらいまで減ってしまいました。認識精度を上げるには数千枚あるとベストなのですが、今回は商用でもなく、あくまでテストなのでこれでいきましょう。

## 画像から顔の抽出

次は取得した画像の顔だけを切り抜いていきましょう。
使用するライブラリはOpenCVで、画像・動画に関する処理機能をまとめたオープンソースのため、世界的に利用されています。

また今回は顔検出のために「haarcascade_frontalface_default.xml」を使いますが、このファイルを持っていない場合は、[こちらのgithubからダウンロード](https://github.com/opencv/opencv/tree/master/data/haarcascades)してください。

以下のコードは、[こちらのget_images_face.py](https://qiita.com/nkbk2525/items/9b6b254eedd311c1f932#3-%E7%94%BB%E5%83%8F%E3%81%8B%E3%82%89%E9%A1%94%E3%81%AE%E9%83%A8%E5%88%86%E3%82%92%E5%88%87%E3%82%8A%E6%8A%9C%E3%81%8D)を参考にさせていただきました。

```python:get_images_face.py
import os
import sys
import glob
import time
import cv2
import numpy as np

# 顔を切り抜いてリサイズする
image_size = 150
names = ["小松菜奈", "あいみょん"]

# 正面の顔を識別するのに使うxmlファイル
HAAR_FILE = "./haarcascade_frontalface_default.xml"
cascade = cv2.CascadeClassifier(HAAR_FILE)


for name in names:

    # 入力する画像ファイルのディレクトリ
    IMAGE_DIR = "images/{}/".format(name)
    # 顔の切り抜きが成功した時に出力するディレクトリ
    file_path_1 = "image_face/{}/".format(name)
    # 顔の切り抜きが失敗した時に出力するディレクトリ
    file_path_2 = "image_face_false/{}/".format(name)

    # 画像の読み込み
    file_list = glob.glob("{}*.jpg".format(IMAGE_DIR))
    for i, filename in enumerate(file_list):
        img = cv2.imread(filename)
        img_gray = cv2.imread(filename, 0)

        # 画像が読み込めない場合
        if img is None:
            print(filename)
            print()

        # 顔の切り抜きを実行し、画像ファイルを保存
        try:
            face = cascade.detectMultiScale(img_gray)
            j = 0
            for x, y, w, h in face:
                face_cut = img[y:y+h, x:x+w]
                face_cut = cv2.resize(face_cut, (image_size, image_size))
                os.makedirs(file_path_1, exist_ok=True)
                cv2.imwrite(file_path_1 + "{}_{}_{}.jpg".format(name, str(i).zfill(3), str(j).zfill(3)), face_cut)
                j += 1

        # 顔の切り抜きができなかった時はそのまま保存
        except Exception:
            os.makedirs(file_path_2, exist_ok=True)
            cv2.imwrite(file_path_2 + "{}_{}.jpg".format(name, str(i).zfill(3)), filename)

```
こちらで顔を切り抜いた作業が完了して画像が`image_face/小松菜奈`と`image_face/あいみょん`のフォルダ2つに保存されました。

今まではローカルでの作業をしていましたが、学習してモデルを作るのにローカル作業だと時間がかかるので、GPUが使えるGoogle Colaboratoryを使っていきます。
そのため、Google Driveをマウントさせれば、Google ColaboratoryからDriveにある画像を使ってモデルを構築できます。

## 画像をMyDriveにアップロード

それでは早速、顔を切り抜いた画像をGoogle Driveにアップロードします。
まずはMyDirveの一番親要素に`nanaai_app`フォルダを作成しましょう。その下に`face`フォルダを作成してください。faceフォルダの下に`小松菜奈`と`あいみょん`フォルダを作成します。
ディレクトリ構造は以下です。

```
content/
  └ MyDrive/
       └ nanaai_app/
            └ face/
               ├ 小松菜奈/
               │  ├ 〇〇.jpg
               │  ├ ...
               │  └ 〇〇.jpg
               │  
               └ あいみょん/
                  ├ 〇〇.jpg
                  ├ ...
                  └ 〇〇.jpg
```
画像のアップロードが完了したら、訓練データとテストデータに分けていきます。

## 訓練データとテストデータに分ける

200枚の内、30枚をテストデータにしたいので、ランダムで30枚を選んで`test_images`フォルダに入れていきます。

```python:train_test_images.ipynb
import os, glob
import random

names =  ["小松菜奈", "あいみょん"]

# 30枚をtest_imagesに移行
IMAGE_DIR = "face"
os.makedirs("./test_images", exist_ok=True)

for name in names:
    files = glob.glob(os.path.join(IMAGE_DIR, name + "/*.jpg"))
    random.shuffle(files)
    os.makedirs('./test_images/' + name, exist_ok=True)

    for i in range(30):
        shutil.move(str(files[i]), "./test_images/" + name)

```
これでランダムで選ばれた30枚が`test_images`に入りました。
残りの170枚だとちょっと訓練データとしては少ないので、こちらを水増し処理を行なって画像のパターンを増やしていきましょう。

## 画像の水増し処理

```python:Data_Augmentation.ipynb
import os
import cv2
import numpy as np

def scratch_image(img, flip=True, blur=True, rotate=True):
    methods = [flip, blur, rotate]
        # filp は画像上下反転
        # blur はぼかし
        # rotate は画像回転

    # 画像のサイズ(x, y)
    size = np.array([img.shape[1], img.shape[0]])
    # 画像の中心位置(x, y)
    center = tuple([int(size[0]/2), int(size[1]/2)])
    # 回転させる角度
    angle = 30
    # 拡大倍率
    scale = 1.0

    mat = cv2.getRotationMatrix2D(center, angle, scale)

    # 画像処理をする手法をNumpy配列に格納
    scratch = np.array([
        lambda x: cv2.flip(x, 0),                               # flip
        lambda x: cv2.GaussianBlur(x, (15, 15), 0),             # blur
        lambda x: cv2.warpAffine(x, mat, img.shape[::-1][1:3])  # rotate
    ])

    # imagesにオリジナルの画像を配列として格納
    images = [img]

    # 関数と画像を引数に、加工した画像を元と合わせて水増しする関数
    def doubling_images(func, images):
        return images + [func(i) for i in images]

    for func in scratch[methods]:
        images = doubling_images(func, images)

    return images


# faceディレクトリにあるメンバーの画像を拡張する
IMAGE_DIR = "face"
names = ["小松菜奈", "あいみょん"]

for name in names:
    files = glob.glob(os.path.join(IMAGE_DIR, name + "/*.jpg"))

    for index, file in enumerate(files):
        name_image = cv2.imread(file)
        data_aug_list = scratch_image(name_image)

        # 拡張した画像を出力するディレクトリを作成
        os.makedirs("train_images/{}".format(name), exist_ok=True)
        output_dir = "train_images/{}".format(name)

        # 保存
        for j, img in enumerate(data_aug_list):
            cv2.imwrite("{}/{}_{}.jpg".format(output_dir, str(index).zfill(3), str(j).zfill(2)), img)
```
水増しされた画像が`train_images`フォルダに保存されているかと思います。
これで訓練データとテストデータが揃いましたので、学習モデルを作成していきましょう。

## 学習モデルの作成

以下のコードの処理にはかなり時間がかかってしまうので、ランタイムのタイプをGPUに変更をして実行をしてください。

```python:nanaai.ipynb
import os, glob
import random
import cv2
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.utils import to_categorical
from tensorflow.keras import Input, Sequential, Model
from tensorflow.keras.models import load_model, save_model
from tensorflow.keras.layers import Dense, Conv2D, Flatten, Dropout, MaxPooling2D
from tensorflow.keras.preprocessing.image import ImageDataGenerator, img_to_array, load_img
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.optimizers import SGD
from keras.callbacks import EarlyStopping

names =  ["小松菜奈", "あいみょん"]
num_classes = len(names)
image_size = 150

IMAGE_DIR_TRAIN = "/content/drive/MyDrive/nanaai_app/face/train_images"
IMAGE_DIR_TEST = "/content/drive/MyDrive/nanaai_app/face/test_images"

# 訓練データとテストデータをわける
X_train = []
X_test  = []
y_train = []
y_test  = []

# 訓練データをリストに代入
for index, name in enumerate(names):
    files = glob.glob(os.path.join(IMAGE_DIR_TRAIN, name + "/*.jpg"))
    for file in files:
        image = load_img(file)
        image = image.resize((image_size, image_size))
        image = img_to_array(image)
        X_train.append(image)
        y_train.append(index)

# テストデータをリストに代入
for index, name in enumerate(names):
    files = glob.glob(os.path.join(IMAGE_DIR_TEST, name + "/*.jpg"))
    for file in files:
        image = load_img(file)
        image = image.resize((image_size, image_size))
        image = img_to_array(image)
        X_test.append(image)
        y_test.append(index)

# テストデータと訓練データをシャッフル
p = list(zip(X_train, y_train))
random.shuffle(p)
X_train, y_train = zip(*p)

q = list(zip(X_test, y_test))
random.shuffle(q)
X_test, y_test = zip(*q)

# Numpy配列に変換
X_train = np.array(X_train)
X_test = np.array(X_test)
y_train = np.array(y_train)
y_test = np.array(y_test)

# データの正規化
X_train = X_train / 255.0
X_test = X_test / 255.0

# One-hot表現
y_train = to_categorical(y_train, num_classes)
y_test = to_categorical(y_test, num_classes)


# VGG16のインスタンスの生成
input_tensor = Input(shape=(150, 150, 3))
vgg16 = VGG16(include_top=False, weights="imagenet", input_tensor=input_tensor)

# モデルの生成
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(128, activation="relu"))
top_model.add(Dropout(0.5))
top_model.add(Dense(num_classes, activation="softmax"))

# モデルの結合
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

# 15層目までのパラメータを固定
for layer in model.layers[:15]:
    layer.trainable = False


# モデルのコンパイル
optimizer = SGD(lr=1e-4, momentum=0.9)
model.compile(optimizer=optimizer, loss="categorical_crossentropy", metrics=["accuracy"])


# モデルの学習
batch_size = 32
epochs = 100

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

history = model.fit(X_train, 
                    y_train, 
                    batch_size=batch_size, 
                    epochs=epochs, 
                    verbose=1, 
                    validation_data=(X_test, y_test),
                    callbacks=[early_stopping]
)

scores = model.evaluate(X_test, y_test, verbose=1)

# モデルの保存
model.save("/content/drive/MyDrive/nanaai_app/face/model.h5")

# 可視化
fig = plt.figure(figsize=(15,5))
plt.subplots_adjust(wspace=0.4, hspace=0.6)

ax1 = fig.add_subplot(1, 2, 1)
ax1.plot(history.history["accuracy"], c="b", label="acc")
ax1.plot(history.history["val_accuracy"], c="r", label="val_acc")
ax1.set_xlabel("epochs")
ax1.set_ylabel("accuracy")
plt.legend(loc="best")

ax2 = fig.add_subplot(1, 2, 2)
ax2.plot(history.history["loss"], c="b", label="loss")
ax2.plot(history.history["val_loss"], c="r", label="val_loss")
ax2.set_xlabel("epochs")
ax2.set_ylabel("loss")
plt.legend(loc="best")

fig.show()
```
精度は85%とまずまずの結果となりました。Epochが17/100と過学習を防ぐためにEarlystoppingが発動していることもあるかもですね。でも基本的に過学習は防いだ方がいいので、Earlystoppingは適応させておくのがいいと思います。
もし精度を上げるのであれば、訓練データの数をもっと多く用意して、それらを水増しして学習させると精度が高くなるはずです。
今回は精度85%までは出ていたので、これでよしとします。

この結果、`model.h5`のファイルが作成されているので、この学習モデルを使ってWEBアプリをつくれば
[こちらのようなもの](https://naan-aimyon-app.onrender.com/)に出来上がります。

こちらはFlaskというPythonのWebアプリケーションフレームワークで、小規模向けの簡単なWebアプリケーションを作るのに適しています。このFlaskを使って作成したコード類を[GitHub](https://github.co.jp/)にアップロードして、[render](https://render.com/)でデプロイすれば、全世界に作成したWEBアプリをお披露目できます。

## 実際に試してみる

それでは実際にあいみょんの画像をアップして正解するか試してみましょう。
![aimyon-nana.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984040/f1ae7455-9cba-4d83-bc5d-5faa4266fc1e.png)

しっかりとあいみょんと判定されましたね。
次は小松菜奈の画像をアップして正解するか試してみましょう。

![komatunana.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2984040/7964663f-8602-9073-7aa2-7ae6aa647bc6.png)

こちらもしっかりと小松菜奈と判定されました。

精度が85%なので大体は正解すると思いますが、判定をミスる可能性もあるため、今後はしっかりと精度の高いアプリが必要な場合は、もっと画像数を用意して作っていきたいですね。

## まとめ

今回は、小松菜奈とあいみょんがとても似ていたので、機械学習で作成した学習モデルを使って実際に画像を識別してみました。コードと手間は長いものになりましたが、ほとんどがブログから参考にしたので、基本的には流用で作成できます。
是非、自分のスキルアップのためにも試してみてください。