スズメバチとアシナガバチを分類するアプリを作ってみた。

#はじめに
　この記事はプログラミング初心者である私が__Aidemy__の__AIアプリ開発コースの最終成果物__として、__AIによる画像認識アプリを作成する過程__を記録したものです。

　どんなジャンルにしようか悩みましたが、タイトルにもあるように、画像を__スズメバチかアシナガバチかを判別するアプリ__を作成することにしました。

　テーマ選定の理由としては、私が以前に小学校で理科を教えていたことがあり、なんとなく自然科学に沿ったものを作りたいなと思ったのがきっかけでした。

　その中でもこのテーマにしたのは、

+ 日本に生息するハチの種類が少ない(植物やほかの動物に比べて)ため学習がしやすそう
+ 普段の生活の中でよく目にすることがあるがパッと見での判断が一般の人には難しそう
+ スズメバチとアシナガバチは色合いなどは似ているが、体の構造が少し違うため判別するのが難しいのではないか(作りがいがあるのでは？)

　以上のような理由のもとに、テーマを決定しました。

# 目次
1.実行環境
2.icrawlerを利用した画像収集
3.CNNモデルの作成(テスト)
4.ImageDataGeneratorを使った画像の水増し
5.CNNモデルの作成
6.アプリの動作確認
7.考察・感想

# 1. 実行環境

+ python 3.8.5
+ MacBook Pro (Retina, 13-inch, Early 2015)
+ Anaconda
+ Google Colaboratory
+ Visual Studio Code

# 2. icrawlerを利用した画像収集
　まず、学習用の画像収集です。
　今回はicrawlerを利用し画像を収集しました。
### 2.1 icrawlerとは
　icrawerとはpythonでwebクローリングを行い、画像を収集するためのフレームワークです。
　利点としては非常に短いコードを記述するだけで画像を収集できることです。

### 2.2 インストール　

```
$ pip install icrawler
```

### 2.3 画像のダウンロード

```python:image_downloads.py
from icrawler.builtin import BingImageCrawler

root_dirs = ['スズメバチのパス', 'アシナガバチのパス']
keywords = ['スズメバチ', 'アシナガバチ']

for root_dir, keyword in zip(root_dirs, keywords):
    crawler = BingImageCrawler(storage={"root_dir": root_dir})
    crawler.crawl(keyword=keyword, max_num=500)
```

　__スズメバチ__と__アシナガバチ__の画像を各500枚ずつ集めるようにしました。
　集めたい画像のキーワードを__スズメバチ__、__アシナガバチ__としたため、_巣_や_幼虫_、_おもちゃ_の画像も一緒にダウンロードされていました。
　上記のような関係ない画像などを整理した結果、__各240枚__ほど残りました。

# 3. CNNモデルの作成(テスト)
　`keras`を使ってモデルを作成します。
　まずはじめに`VGG16`を__転移学習して全結合層をカスタマイズ__した以下のようなモデルを作り様子を見ることにしました。(aidemyのテキストにあったものをそのまま使いました。)

```python
input_tensor = Input(shape=(50, 50, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

# vggのoutputを受け取り、2クラス分類する層を定義
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dense(128, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(2, activation='softmax'))

# vggと、top_modelを連結
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

# vggの層の重みを固定(14層まで)
for layer in model.layers[:15]:
    layer.trainable = False

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

# 学習
history = model.fit(X_train, y_train, batch_size=32, epochs=50, validation_data=(X_test, y_test))
```
![model firt.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/a56b4af5-38cb-d3d8-7186-e93c2ed0b523.png)
　最初の段階では、正解率は約65%くらいの精度でした。
　このモデルの精度を上げるために以下のことを行いました。

+ 画像データを増やす(画像の水増し)
+ 入力画像のピクセルを調整
+ モデルの再構築(全結合層の見直し)

# 4.　ImageDataGeneratorを使った画像の水増し
　`Keras`の`ImageDataGenerator`を使用して画像を水増ししていきます。
　引数で指定できる項目は複数あり、いろいろ試した結果、今回は以下の５つを組み合わせて使いました。

　`shear_range`を使わなかった理由としては画像が剪断されることにより、スズメバチとアシナガバチの__お尻の太さの違いという特徴__が損なわれてしまうのではないかと思ったからです。（実際に`shear_range`を使わない方が精度が高かったです）

```python
# 画像を水増しする関数
def img_augment(x, y):
    X_augment = []
    y_augment = []
    i = 0

    # 水増しを20回繰り返す
    while i < 20:
        datagen = ImageDataGenerator(rotation_range=30,
                                    width_shift_range=0.3,
                                    height_shift_range=0.3,
                                    horizontal_flip = True,
                                    zoom_range = [0.9, 0.9])
        datagen = datagen.flow(X_train, y_train, shuffle = False, batch_size = len(X_train))
        X_augment.append(datagen.next()[0])
        y_augment.append(datagen.next()[1])
        i += 1

    # numpy配列に変換
    X_extend = np.array(X_augment).reshape(-1, img_size, img_size, 3)
    y_extend = np.array(y_augment).reshape(-1, 2)

    return X_extend, y_extend
```
　上記のような水増しを行う関数を定義しました。
　汎化性能低下防止のため、__トレーニングデータにのみ__水増し用の関数を使います。

# 5. CNNモデルの作成
　画像の水増しをした後、入力ピクセルのを変更しながら精度を検証しました。
　また、全結合層を見直し正解率の向上を試みました。
　いろんなパラメータを変えながら検証する過程で以下のことがわかりました。

+ 4つの画像サイズ`50px`, `100px`, `150px`, `200px`と比較したところ、`200px`が一番精度が高かった。
+ `epoch`は20くらいで正解率が横ばいになる。
+ `batch_size`を変更させてみた結果、最初に設定した__32__の時に少し正解率が高かった。
+ 中間層の後に`ReLU関数`、`Dropout`、`BatchNormalization`(バッチ正規化)層を追加することで正解率が上昇した。(Dropoutの割合は色々試した結果、__0.2__が一番正解率が高くなった。)　

　最終的なモデルの作成コードはこうなりました。

```python:model.py
import cv2
import os
import numpy as np
from keras.utils.np_utils import to_categorical
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input, BatchNormalization
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras import optimizers
from keras.preprocessing.image import ImageDataGenerator
import matplotlib.pyplot as plt

# ディレクトリ中のファイル名をリストとして格納
path_wasp = os.listdir('パス')
path_paper_wasp = os.listdir('パス')

# 変換した画像を格納する空リストを定義
img_wasp = []
img_paper_wasp = []

# 画像のサイズ
img_size = 200

# スズメバチ
for i in range(len(path_wasp)):
    # ディレクトリ内にある".DS_Store"というファイルを除くためif文を定義
    if path_wasp[i].split('.')[1] == 'jpg':
        img = cv2.imread('パス' + path_wasp[i])
        img = cv2.resize(img, (img_size, img_size))
        img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
        img_wasp.append(img)

# アシナガバチ
for i in range(len(path_paper_wasp)):
    if path_paper_wasp[i].split('.')[1] == 'jpg':
        img = cv2.imread('パス' + path_paper_wasp[i])
        img = cv2.resize(img, (img_size, img_size))
        img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
        img_paper_wasp.append(img)

# データを結合しnp配列に変換      
X = np.array(img_wasp + img_paper_wasp)
y = np.array([0]*len(img_wasp) + [1]*len(img_paper_wasp))

# 画像データをシャッフル
rand_index = np.random.permutation(np.arange(len(X)))
X = X[rand_index]
y = y[rand_index]

# トレーニングデータとテストデータに分ける(8:2)
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)

# 画像を水増しする関数
def img_augment(x, y):
    X_augment = []
    y_augment = []
    i = 0

    # 水増しを20回繰り返す
    while i < 20:
        datagen = ImageDataGenerator(rotation_range=30,
                                    width_shift_range=0.3,
                                    height_shift_range=0.3,
                                    horizontal_flip = True,
                                    zoom_range = [0.9, 0.9])
        datagen = datagen.flow(X_train, y_train, shuffle = False, batch_size = len(X_train))
        X_augment.append(datagen.next()[0])
        y_augment.append(datagen.next()[1])
        i += 1

    # numpy配列に変換
    X_extend = np.array(X_augment).reshape(-1, img_size, img_size, 3)
    y_extend = np.array(y_augment).reshape(-1, 2)

    return X_extend, y_extend

# trainデータの水増し
img_add = img_augment(X_train, y_train)

# 元の画像データと水増しデータを統合
X_train = np.concatenate([X_train, img_add[0]])
y_train = np.concatenate([y_train, img_add[1]])

# VGG16
input_tensor = Input(shape=(img_size, img_size, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

# vggのoutputを受け取り、2クラス分類する層を定義
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.2))
top_model.add(BatchNormalization())
top_model.add(Dense(128, activation='relu'))
top_model.add(Dropout(0.2))
top_model.add(BatchNormalization())
top_model.add(Dense(2, activation='softmax'))

# vggと、top_modelを連結
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))

# vggの層の重みを固定
for layer in model.layers[:15]:
    layer.trainable = False

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

# 学習
history = model.fit(X_train, y_train, batch_size=16, epochs=20, validation_data=(X_test, y_test))

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

# ------------------------------------------------
# 可視化
plt.plot(history.history['accuracy'])
plt.plot(history.history['val_accuracy'])
plt.title('Model accuracy')
plt.ylabel('Accuracy')
plt.xlabel('Epoch')
plt.grid()
plt.legend(['Train', 'Validation'], loc='upper left')
plt.show()
```

　入力画像のサイズ別の正解率をグラフにしてみました。
　img_size=__100px__(グラフのタイトルに画像サイズを入れるのを忘れていました...)
![100px model.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/49d1f032-5a57-128b-b2a8-89c5916d2d1f.png)
　　img_size=__150px__
![150px model.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/30f5721f-2c07-bfd6-1289-faa319f6c058.png)
　　img_size=__200px__
![200px model.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/abb89cb9-eead-b8dc-2657-655f304883d5.png)

　グラフでもわかるように、`200px`の時が一番正解率が高いですね。
　最終的な正解率は__約85%__くらいでした。
　2分類クラスなため、もう少し精度は伸ばしたかったですが、それでも最初の正解率と比べたら悪くはないと思います。
　このモデルを使い`heroku`にデプロイしていきます。

# 6. アプリの動作確認
　webページの`HTML`, `CSS`は`Aidemy`の講座を参考に少しだけ手を加えました。
　では、実際にモデル作成に使わなかった写真を判定していきましょう。
　まず、__セグロアシナガバチ__の写真
![pw18.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/5a5abc0f-6cac-c86f-0fa1-2e5cda13c1ae.jpeg)
　結果は
![pw.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/89b35fb7-e336-7918-b74b-f5bba5ac1888.png)
　100%の確率でアシナガバチと判定されました。(正直この時、少しガッツポーズ)
　続いてこちらの__キアシナガバチ__の写真
![pw9.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/6423f722-fb65-c1c4-34e1-bbcf85250436.jpeg)
　結果は
![pw22結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/6eabc888-20e3-b001-5eeb-cf60764f01b5.png)
　先ほどよりも少し確率は下がりましたが、しっかりアシナガバチと判定してくれています。

　次はスズメバチの写真を判定してもらいます。
　まず、こちらの__オオスズメバチ__の写真
![w2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/9a4a8f85-d67c-aaeb-764d-8392e64d8298.jpeg)
　結果は
![w2結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/1a6aac7e-d9bc-18ad-7f67-24d237ae7b52.png)
　モデル作成時にこれと似た__オオスズメバチを上から撮影した写真__を使っていたので100%の確率で分類できたのだと思います。
　続いてこちらの花の蜜を吸っている？__モンスズメバチ__の写真
![w3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/3c7f9227-4858-bb1a-213f-8492df23a5d3.jpeg)
　結果は
![w3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/942ff314-29c3-1621-341a-87d13568a2a1.png)
　こちらも正確に判定してくれました。

　うまくいかなかった例としては、こちらの__ヨーロッパクロスズメバチ__の写真
![yellow jacket.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/1d612bf3-ff2d-bc5f-db49-805ed972252b.jpeg)
　結果は
![yellow jacket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/88483b4b-8b42-e5d1-4441-1b9c5a2ffa27.png)
　アシナガバチと間違って判定されてしまいました。
　この理由の考察としては、モデルを作成する際に日本に生息するハチの写真のみを使いました。
　つまり、モデル作成時に入力したスズメバチの写真の中にはヨーロッパクロスズメバチの写真は入っていませんでした。
　なので、__足が黄色いという特徴__からアシナガバチと判定されてしまったのではないかと思います。


　__アプリのリンク__
　[スズメバチかアシナガバチかを判定するAIアプリ](https://bee-app-pyon.herokuapp.com/)

# 7. 考察・感想
　与えられた画像を__スズメバチ__と__アシナガバチ__の2つのクラスに分類するだけですが、正解率は約85%とある程度の高さを出すことができたと思います。
　初期のモデルと最終的なモデルを比較した時、正解率が上がっていたのは__画像データのサイズの変更__と__データの水増し__が大きかったと思います。

　手書き数字の判別の時に入力データを`50px`にしていたので、それに倣ってそのままにしていたのですが、やはり元データが鮮明になるほど、ある程度の解像度を保ちながら変換しなければいけないと感じました。

　また元データの量は50枚から始め、段々と増やしていき最終的に240枚になりました。(枚数に特に意味はありません)
　枚数が増えるにつれて正解率も上がっていったのをみて、__元データの量__が精度のいいモデルを作るためにとても重要になるとわかりました。
　`heroku`へデプロイする時のファイルの__最大容量が500MB__であることや、`Google Colab`での__GPU使用制限__などで今回はこのモデルを完成としました。

　また別の機会に入力ピクセルや元データを増やしたり、水増しの回数を増やしてみた時にどういった違いが出てくるのかを確認してみようと思います。

　最後にこれを執筆しているときにふと、__画像のリサイズの方法__に問題があるのではないのかと思いました。
　アシナガバチの元データがこちら
![アシナガバチ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/32eb33c7-0314-4c51-a370-3ad52ffb9369.jpeg)
　そして変換したデータがこちら(サイズは`200px`)
![アシナガバチ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/0f1759ff-b111-be56-e7ba-9beaa89fa3ad.png)
　画像をそのまま圧縮しているため、少し横にぎゅっと潰されているのがわかります。
　この結果、データの特徴が変化し正解率に何らかの影響を与えているのではないかと思いました。

　つまり、リサイズを行う前に__画像が正方形になるように上下左右の余白を追加する操作__を行うことにし、このような関数を定義しました。

```python
# 画像を正方形に変換する関数
def expand2square(img):
    background_color = (0,0,0)  #黒
    width, height = img.size
    if width == height:
        return img
    elif width > height:
        result = Image.new(img.mode, (width, width), background_color)
        result.paste(img, (0, (width - height) // 2))
        return result
    else:
        result = Image.new(img.mode, (height, height), background_color)
        result.paste(img, ((height - width) // 2, 0))
        return result
```
　この関数を使い正方形にした変換した後、リサイズを行った先ほどの写真がこちら
![アシナガバチ(正方形).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/484c8998-8f84-79de-e566-f810dd2006f6.png)
　元の比率を保っているため特徴もしっかり捉え正解率に差が出るはず！と思い早速モデルを作成しました。(元のコードに関数を追加し、画像読み込み部分も少し変更しました。)
　その結果がこちら
![正方形結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1751395/065141bb-04e9-e063-fd26-a2a1b68fbf7b.png)
　うーん...あまり変わってない。むしろ悪くなってる...
　この画像の縦横比を維持するという行いが、どのように結果に貢献するのかも今後の課題です。

　最後に、講座のテキストを読んでいるうちは理解しているつもりでも、実際に自力で作るとなると
　このコードどうやって書くんだっけ？　バッチ正規化ってどういう意味だっけ？
　などなど様々なところでつまづきました。
　その課題を一つ一つ乗り越えていくうちに__できないことができるように__なっていきました。
　やっぱり__"作る"__ということが一番勉強になることに気付かされました。

　今回は単純にスズメバチ、アシナガバチと分類するAIアプリを作成しました。
　ですが日本に生息するだけで__スズメバチは17種__、__アシナガバチは11種__もいます。
　もちろん種類ごとに少しずつ違いがあり、今回のアプリがその違いを全て網羅しているか聞かれれば、そうとは言い切れません。
　今後はスズメバチの中での分類やアシナガバチの中での分類を皮切りに、もっとたくさんのクラスに正確に分類したり、ほんの小さ違いでも正確に分類できるアプリを作成できるよう、知見を深めていこうと思います。