0から始めるAI画像認識 馬と鹿（CNNを用いた画像認識）

# はじめに
初めての機械学習を行う際に、画像認識に興味があり、やってみようと思ったのですが、どういったアプローチにしようか迷っていました。
そこで、Aidemyの｢CNNを用いた画像認識｣というコースがあったので、それを受講し、CNNをやることにしました。

最初に考えたのが、競馬が好きなので競走馬の分類。
【競走馬 = 騎手が乗っている馬】として考えて、競争馬でも裸馬でも、【馬】として認識できるものを作ろうと考えました。

**分類は馬と鹿にしました。**
理由は、馬の画像収集の過程でCIFAR-10のデータセットも使わさせて頂いたのですが、CIFAR-10の中にdeer(鹿)があったからです。
動物として似ているものに挑戦してみたかったので、ちょうど良かったです。

#### 目次
1. 画像収集
2. 収集画像の処理
3. 画像の水増し
4. 画像を学習/検証データにする
5. モデル構築と保存
6. 結果のグラフ化
7. 別の画像でテスト

## 画像収集
画像収集には、以下を利用しました。

- Google Images Download
- bing_image_downloader API
 
検索ワード｢競走馬｣｢Race horse｣｢Cheval de course｣ 約1400枚
検索ワード｢鹿｣約800枚

- データセット
  - CIFAR-10のhorse 1001枚
  - CIFAR-10のdeer 999枚

## 収集画像の処理
データセットは完璧に処理済みなので、Webから拾ってきた画像を処理します。

###### 1.重複画像の削除
image hash(phash)を利用して、重複画像を抽出します。
[ImageHashで同じ画像を持つフォルダを検出する](https://qiita.com/meznat/items/80c5ad8a893125db1941)
こちらのコードを引用、編集したものを使いました。

```python

from PIL import Image, ImageFile
import imagehash
import os

# サイズの大きな画像をスキップしない
ImageFile.LOAD_TRUNCATED_IMAGES = True


# phash 2つの画像のハッシュ値の差分を出力
def image_hash(img, otherimg):
    # phashを指定
    hash = imagehash.phash ( Image.open ( img ) )
    other_hash = imagehash.phash ( Image.open ( otherimg ) )
    return hash - other_hash


# 画像サイズの小さい方を検出する
def minhash(img, otherimg):
    # (幅, 高さ)のタプル
    hash_size = Image.open ( img ).size
    otherhash_size = Image.open ( otherimg ).size
    if hash_size == otherhash_size:
        return 0
    if hash_size < otherhash_size:
        return 1


# 以下のパスに調べてほしい画像の入ったディレクトリを保存
default_dir = '画像の入ったディレクトリを保存したパス'
# 調べてほしい画像の入ったディレクトリを取得
img_dir = os.listdir ( default_dir )
# 調べてほしい画像の入ったパスを取得
img_dir_path = os.path.join ( default_dir, img_dir[0] )

# 画像のリストを取得
img_list = os.listdir ( img_dir_path )
# 画像が2枚以上あれば、画像のパスを取得してリスト化
img_path = [os.path.join ( img_dir_path, i ) for i in img_list
            if len ( os.path.join ( img_dir_path, i ) ) > 2]
# フォルダ内の画像の数の取得
img_list_count = len ( img_list )

i = 0
delete_list = []

# image_hash(),minhash()でフォルダごとの画像を比較
while i < img_list_count:
    # 進捗状況
    print ( '実行中 : ', str ( i + 1 ) + '/' + str ( img_list_count ) )
    # i + 1 で2回目の比較のものと、同じ画像の比較をしない
    for j in range ( i + 1, img_list_count ):
        # ハッシュ値の差分が10以下なら同一の画像として認識
        if image_hash ( img_path[i], img_path[j] ) < 10:
            print ( img_path[i] + ' | vs | ' + img_path[j] )
            # 画像サイズが同じだった場合片方のパスをdelete_listに格納
            if minhash ( img_path[i], img_path[j] ) == 0:
                if not img_path[j] in delete_list:
                    delete_list.append ( img_path[i] )
            # 画像サイズの小さい方のパスをdelete_listに格納
            if minhash ( img_path[i], img_path[j] ) == 1:
                delete_list.append ( img_path[i] )
            j += 1
    i += 1

# 削除したい画像パスの表示
print ( delete_list )


# 削除したい画像を開く場合
# def open_folder(path):
#     subprocess.run ( 'explorer {}'.format ( path ) )
#
# for i in range ( len ( delete_list ) ):
#     open_folder ( delete_list[i] )


# 続けて削除したい場合
# for i in delete_list:
#     try:
#         os.remove( i )
#     except OSError :
#         pass
```

###### 参考文献
[pythonを使ってORBとPerceptual Hashで画像の類似度を比べてみる](http://kzkohashi.hatenablog.com/entry/2018/01/05/205623)

[Perceptual Hashを使って画像の類似度を計算してみる](https://tech.unifa-e.com/entry/2017/11/27/111546)

###### 2.関係ない画像の削除、そして画像をRGB形式に変換
学習に使用できないと思われる画像を、手動で削除しました。

RGB形式への変換はこちらを参考にさせて頂きました。
[機械学習用に画像を前処理する](https://boomin.yokohama/archives/1344)

こうして処理済みの画像を用意できました。

- horseフォルダ 約1400枚 → 計438枚
- deerフォルダ 約800枚 → 計139枚

上記の画像にプラスCIFAR-10の画像を使います。

## 画像の水増し
horseフォルダ APIで拾った459枚、
deerフォルダ API + CIFAR-10
1138枚
をImageDataGeneratorで水増しします。

fit_generator()、flow()を使用してそのままモデルを訓練できますが、今回は単純な水増しが目的です。

ですので、生成した画像を自身のドライブに保存します。

```python
from keras.preprocessing.image import ImageDataGenerator
import os


datagen = ImageDataGenerator(rotation_range=20,  # ランダムに回転する回転範囲（単位degree）
                             width_shift_range=0.2,  # ランダムに水平方向に平行移動する、画像の横幅に対する割合
                             height_shift_range=0.2,  # ランダムに垂直方向に平行移動する、画像の縦幅に対する割合
                             shear_range=0.2,  # せん断の度合い。大きくするとより斜め方向に押しつぶされたり伸びたりしたような画像になる（単位degree）
                             zoom_range=0.2,  # ランダムに画像を圧縮、拡大させる割合。最小で 1-zoomrange まで圧縮され、最大で 1+zoom_rangeまで拡大される
                             horizontal_flip=True)  #  ランダムに水平方向に反転

root_dir = './data/padding'  # 水増ししたい画像フォルダのあるパス
targetsize = (128, 128)  # 加工サイズ
save_dir = os.listdir(root_dir)  # 水増しした画像を保存するフォルダ名
save_path = os.path.join('./data/save', save_dir[0])  # 水増しした画像の保存先
increase = len(os.listdir(os.path.join(root_dir, save_dir[0])))  # 水増ししたい画像フォルダに入っている画像の数
increase_count = 1  # 1枚につき、このパターン数だけ水増し(increase✕increase_countの数だけ画像が増える)

# 保存先ディレクトリが存在しない場合、作成
if not os.path.exists(save_path):
    os.makedirs(save_path)

# flow_from_directory()で水増ししたい画像(フォルダ)の取得と、水増しした画像の加工と保存を同時におこなう
ffd = datagen.flow_from_directory(
    directory=root_dir,
    target_size=targetsize,
    color_mode='rgb',
    batch_size=increase,
    save_to_dir=save_path)

[next(ffd) for i in range(increase_count)]
```
![Image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/0d9a8262-9d60-01aa-30c2-0d000d18be5f.jpeg)
horseフォルダ 2000枚
deerフォルダ 2000枚
が用意できました。

###### 参考文献
[Keras - Keras の ImageDataGenerator を使って学習画像を増やす](https://www.pynote.info/entry/keras-image-data-generator)
[Keras CNN を改造してImageDataGenerator（画像水増し機能）を理解する](http://cedro3.com/ai/keras-cnn/)
[classifier_from_little_data_script_1.py](https://gist.github.com/fchollet/0830affa1f7f19fd47b06d4cf89ed44d)
[KerasのImageDataGeneratorで学習用画像を水増しする方法](https://intellectual-curiosity.tokyo/2019/07/03/keras%E3%81%AEimagedatagenerator%E3%81%A7%E5%AD%A6%E7%BF%92%E7%94%A8%E7%94%BB%E5%83%8F%E3%82%92%E6%B0%B4%E5%A2%97%E3%81%97%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95/)
[Image Preprocessing](https://keras.io/preprocessing/image/)


### インポート
｢結果のグラフ化｣までのインポートは以下の通り

```python
# plaidMLをKarasで動かすためのコード
import plaidml.keras
plaidml.keras.install_backend()

from sklearn.model_selection import train_test_split
from keras.callbacks import ModelCheckpoint
from keras.layers import Conv2D, MaxPooling2D, Dense, Dropout, Flatten
from keras.models import Sequential
from keras.utils import np_utils
from keras import optimizers
from keras.preprocessing.image import img_to_array, load_img
import keras
import glob
import numpy as np
import matplotlib.pyplot as plt
```

## 画像を学習/検証データにする

- 画像サイズを全て統一
- 配列化
- 学習データ8割、検証データ2割の割合で分ける

```python
# 画像ディレクトリのパス
root_dir = './data/'
# 画像ディレクトリ名
baka = ['horse', 'deer']

X = []  # 画像の2次元データを格納するlist
y = []  # ラベル(正解)の情報を格納するlist

for label, img_title in enumerate(baka):
    file_dir = root_dir + img_title
    img_file = glob.glob(file_dir + '/*')
    for i in img_file:
        img = img_to_array(load_img(i, target_size=(128, 128)))
        X.append(img)
        y.append(label)

# Numpy配列を4次元リスト化(*, 244, 224, 3)
X = np.asarray(X)
y = np.asarray(y)

# 画素値を0から1の範囲に変換
X = X.astype('float32') / 255.0
# ラベルをOne-hotにしたラベルに変換
y = np_utils.to_categorical(y, 2)

# データを分ける
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0)
xy = (X_train, X_test, y_train, y_test)
# .npyで保存
np.save('./npy/train.npy', xy)
# 学習用の画像データ(行（高さ）, 列（幅）, 色（3）)の確認(入力層input_shapeと同じ)
print('3次元 :', X_train.shape[1:])
```

## モデル構築と保存
ここの部分は試行錯誤
model.fit()のcallbacksでModelCheckpointを使用して、エポック毎にmodelを保存します。
最終的に、val_lossが最小値の時のmodel全体が、hdf5形式で残ります。
モデル、グラフは**最終的なテストで一番正解率が高かったものを載せます。**

```python
# 入力層,隠れ層(活性化関数:relu)
model.add ( Conv2D ( 32, (3, 3), activation='relu', padding='same', input_shape=X_train.shape[1:] ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Conv2D ( 32, (3, 3), activation='relu', padding='same' ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Conv2D ( 64, (3, 3), activation='relu' ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Conv2D ( 128, (3, 3), activation='relu' ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Flatten () )
model.add ( Dense ( 512, activation='relu' ) )
model.add ( Dropout ( 0.5 ) )

# 出力層(2クラス分類)(活性化関数:softmax)
model.add ( Dense ( 2, activation='softmax' ) )

# コンパイル（学習率:1e-3、損失関数：categorical_crossentropy、最適化アルゴリズム：RMSprop、評価関数：accuracy(正解率)）
rms = optimizers.RMSprop ( lr=1e-3 )
model.compile ( loss='categorical_crossentropy',
                optimizer=rms,
                metrics=['accuracy'] )

# 学習モデルのエポック
epoch = 50
# モデルを保存するパス
fpath = f'./model/model.{epoch:02d}-.h5'
# エポックごとにモデルを保存するかチェック
mc = ModelCheckpoint (
    filepath=fpath,
    monitor='val_loss',  # 評価をチェックする対象
    verbose=1,
    save_best_only=True,  # val_lossの最新の最適なモデルは上書きされない
    save_weights_only=False,  # Falseの場合モデル全体が保存
    mode='min',  # チェックの対象がval_lossなので最小を指定
    period=1 )  # チェックするエポックの間隔

# 構築したモデルで学習
history = model.fit (
    X_train,
    y_train,
    batch_size=64,
    epochs=epoch,
    callbacks=[mc],
    validation_data=(X_test, y_test) )
```

## 結果のグラフ化

グラフに表示される数値はmodel.h5の数値ではなく、最後のエポックの数値になります。

```python
# 可視化
fig = plt.figure(figsize=(18, 6))  # ウィンドウ作成

# 正解率グラフ
plt.subplot(1, 2, 1)  # 2つ横に並べて右側に表示
plt.plot(history.history['acc'], label='acc', ls='-', marker='o')  # 学習用データのaccuracy
plt.plot(history.history['val_acc'], label='val_acc', ls='-', marker='x')  # 訓練用データのaccuracy
plt.title(f'Training and validation accuracy \n val_acc {score[1]:.4f}')  # タイトル
plt.xlabel('epoch')  # 横軸
plt.ylabel('accuracy')  # 縦軸
plt.legend(['acc', 'val_acc'])  # 凡例
plt.grid(color='gray', alpha=0.2)  # グリッド表示

# 損失グラフ
plt.subplot(1, 2, 2)  # 2つ横に並べて左側に表示
plt.plot(
    history.history['loss'], label='loss', ls='-', marker='o')  # 学習用データのloss
plt.plot(history.history['val_loss'], label='val_loss', ls='-', marker='x')  # 訓練用データのloss
plt.title(f'Training and validation loss \n val_loss {score[0]:.4f}')
plt.xlabel('epoch')
plt.ylabel('loss')
plt.legend(['loss', 'val_loss'])
plt.grid(color='gray', alpha=0.2)

# 保存
plt.savefig('1.png')

plt.show()
```
![Figure_14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/5c36d445-a83d-5b05-53e7-b9a390d84a55.png)

保存されたモデル
>Epoch 15/50
･･････
3200/3200 [==============================] - 122s 38ms/step - loss: 0.1067 - acc: 0.9625 - val_loss: 0.1872 - val_acc: 0.9363

グラフに安定感がないため、
Epoch15がval_loss最小値になりました。
val_loss: 0.1872 - val_acc: 0.9363

学習率が高いことや、データ数が少ないことが考えられ、
また後半は過学習を起こしています。

これをもとに改善したことを、この記事の終わりの方の｢**試してみたこと**｣に記載しました。結果的にダメでしたが……


## 別の画像でテスト
全く別の画像を用意し、保存したモデルで判別します。
![baka.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/05b12022-9d8f-a521-8d3d-e37d54531be9.jpeg)


```python
# plaidMLをKarasで動かすためのコード
import plaidml.keras
plaidml.keras.install_backend()

from keras.preprocessing.image import img_to_array, load_img
from keras.models import load_model
import numpy as np
import glob


# モデルデータのパス
hdf_path = './model/model.20-val_loss 0.2648 - val_acc 0.8793.h5'
# モデル読み込み
model = load_model(hdf_path)

# テストする画像が入っているディレクトリ
img_path = './baka/'
# 14枚の画像取得
img_list = glob.glob(img_path + '*')
size = (128, 128, 3)

for index, i in enumerate(img_list):
    # 画像をサイズを変えて読み込み、配列化
    test_img = img_to_array(load_img(i, target_size=size))
    # 0~1の範囲にする
    test_img = test_img / 255
    # 4次元配列に
    test_img = test_img[np.newaxis, ...]
    # 予測
    pre = model.predict(test_img)
    if np.max(pre[0]) == pre[0, 0]:
        print(f'{img_list[index]} -> {pre} は 馬')
    if np.max(pre[0]) == pre[0, 1]:
        print(f'{img_list[index]} -> {pre} は 鹿')
```
配列の左側の数字が高いと馬、右側の数字が高いと鹿です。
>deer1.jpg -> [[0.08649362 0.9135064 ]] は 鹿
deer2.jpg -> [[5.096481e-06 9.999949e-01]] は 鹿
deer3.jpg -> [[0.01137464 0.9886254 ]] は 鹿
deer4.jpg -> [[0.04577665 0.9542234 ]] は 鹿
deer5.jpg -> [[1.0562457e-07 9.9999988e-01]] は 鹿
deer6.jpg -> [[0.10744881 0.89255124]] は 鹿
deer7.jpg -> [[0.5856648  0.41433516]] は 馬
horse1.jpg -> [[0.00249346 0.99750656]] は 鹿
horse10.jpg -> [[0.6968936  0.30310643]] は 馬
horse2.jpg -> [[0.90138936 0.09861059]] は 馬
horse3.jpg -> [[9.9987268e-01 1.2731158e-04]] は 馬
horse4.jpg -> [[9.9999964e-01 4.1403896e-07]] は 馬
horse5.jpg -> [[9.999294e-01 7.052123e-05]] は 馬
horse6.jpg -> [[9.9999738e-01 2.6105645e-06]] は 馬
horse7.jpg -> [[0.93193245 0.06806755]] は 馬
horse8.jpg -> [[0.01251398 0.987486  ]] は 鹿
horse9.jpg -> [[0.00848716 0.99151284]] は 鹿

正解率は76.47%でした。

一番の目的であった、競走馬の判定としてはhorse10.jpgは【馬】判定でしたが、horse8.jpg、horse9.jpgは【鹿】判定でした。

原因はデータセットにあるのか、データサイズにあるのか、それとも全然別にあるのか、勉強がまだまだ足りないことを思い知りました。

試したことの一部を載せます。
##### 試したことその1
データセットを変更									

- horseフォルダの変更
  1. 競走馬の正面からの写真を21枚増やす
  2. 計459枚を水増したものだけをhorseフォルダに入れる
     (CIFAR-10のhorse 1001枚を使わない)
- horseフォルダに合わせてdeerフォルダを水増し
  (こちらはCIFAR-10のdeer 999枚を使っている)
							
horseフォルダ → 2295枚
deerフォルダ → 2295枚
そして、層などは変えませんでしたが、学習率を1e-4に下げました。
![Figure_17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/4c2a7586-9af4-8298-73c8-3182186c2cc6.png)
>Epoch 27/30
･･････
3672/3672 [==============================] - 139s 38ms/step - loss: 0.1167 - acc: 0.9570 - val_loss: 0.1760 - val_acc: 0.9227

グラフが安定していません
テスト結果
>不正解
deer1.jpg -> [[0.5788138  0.42118627]] は 馬
deer5.jpg -> [[0.5183205  0.48167947]] は 馬
horse8.jpg -> [[0.0699899 0.93001  ]] は 鹿
正解
horse9.jpg -> [[0.612066   0.38793397]] は 馬
horse10.jpg -> [[0.7463752 0.2536248]] は 馬

正解率70.59%、下がってしまいました。

##### 試したことその2
今度は学習率を1e-5まで更に下げ、バッチサイズを32にしました。層などは変えていません。
![Figure_18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/81b2abb3-7771-c233-7428-a00bfbc264bb.png)
グラフは安定傾向になりました。
しかし、テストの正解率は47.06%、かなり下がってしまいました。

##### 試したことその3
上記のデータセットで他にもいろいろ試しましたが、期待する結果が得られなかったため、再度データセットを変更しました。

- horseフォルダの変更
  - 使用していなかったCIFAR-10のhorse 1001枚を使う
   ただし、水増しはWebから拾った方だけ(459枚→1275枚)
- deerフォルダは特に変更なし、数を合わせただけ

horseフォルダ → 2276枚
deerフォルダ → 2276枚

また、層を減らしました

```python
# 入力層,隠れ層(活性化関数:relu)
model.add ( Conv2D ( 32, (3, 3), activation='relu', padding='same', input_shape=X_train.shape[1:] ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Conv2D ( 32, (3, 3), activation='relu', padding='same' ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Conv2D ( 64, (3, 3), activation='relu' ) )
model.add ( MaxPooling2D ( pool_size=(2, 2) ) )

model.add ( Flatten () )
model.add ( Dense ( 64, activation='relu' ) )
model.add ( Dropout ( 0.5 ) )

# 出力層(2クラス分類)(活性化関数:softmax)
model.add ( Dense ( 2, activation='softmax' ) )
```
コンパイル（学習率:1e-4、損失関数：categorical_crossentropy、最適化アルゴリズム：RMSprop、評価関数：accuracy(正解率)）
エポック epochs=20、バッチサイズ batch_size=32
![Figure_20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/513186/b1d8a5bd-3e10-8a12-fbaf-647dd1562d49.png)

>Epoch 18/20	
3641/3641 [==============================] - 131s 36msstep - loss 0.2647 - acc 0.8846 - val_loss 0.2948 - val_acc 0.8716

グラフは若干安定傾向ですが、テストの正解率は64.71%でした。

##### テスト画像からみる考察
シグモイド関数なども含め、いろいろ試しましたが、
deer1.jpgは馬と判定される確率高いです。
それ以上にhorse8.jpg、horse9.jpgの競走馬正面画像が鹿と判定されやすいです。
データが足りないかもしれません。

# おわりに
正解率を上げるためには、まだまだいろいろな技術がありますが、一度ここで終えて、またチャレンジしたいと思います。学習率減衰、アンサンブル学習、転移学習、EfficientNetなどなど。

自分の理想とする結果は得られませんでしたが、CNNを用いた画像認識を行ってみることはできました。

###### 参考文献

[Kerasで2種類（クラス）への分類](http://pictzzz.hatenablog.com/entry/2017/01/28/011242)
[CNNの学習に最高の性能を示す最適化手法はどれか](https://www.iandprogram.net/entry/2016/02/11/181322)
[バレンタインデーにもらったチョコが本命かどうか判定するAIを実際に作って公開した(2019)](https://boomin.yokohama/archives/1357)
[画像等の配列を扱うときの操作方法](https://qiita.com/tom_eng_ltd/items/48a3fe4001b62958e594)
[TensorFlow + Kerasでフレンズ識別する - その2: 簡単なCNNを使った学習編](https://qiita.com/croquette0212/items/9bb41c698d65282e5bb2)
[ディープラーニング　脱超初心者向け基礎知識](https://qiita.com/mochosan/items/00ed3589e13448496b4c)
[KerasでCNNを簡単に構築](https://qiita.com/sasayabaku/items/9e376ba8e38efe3bcf79)
[美女を見分けられない機械はただの機械だ：Pythonによる機械学習用データセット生成](https://qiita.com/Tatsuro64/items/821e9c0b3539baf0fd45)
[画像認識で坂道グループの判別AIの作成](https://qiita.com/tigerz17/items/e4d1d5b8e00f7a771177)
[画像認識で「綾鷹を選ばせる」AIを作る](https://qiita.com/tomo_20180402/items/e8c55bdca648f4877188)
[MNISTでハイパーパラメータをいじってloss/accuracyグラフを見てみる](https://qiita.com/hiroyuki827/items/213146d551a6e2227810)
[CNNをKerasで](https://qiita.com/SyuheyK/items/7ec14f8925d968b0620d)
[最良のモデルを保存する（ModelCheckpointの使い方）](https://qiita.com/tom_eng_ltd/items/7ae0814c2d133431c84a)
[ディープラーニングを使用して「あなたにそっくりな女優判別プログラム」を作ったおはなし](https://qiita.com/k_eita/items/a50a4cae0aa2598422e4)
[KerasでDNN実装](https://qiita.com/ha9kberry/items/a461854cbeeff76bf389)
[KERASで学習済みのモデルをロードして画像1枚を判別](https://qiita.com/tom_eng_ltd/items/e1ce2adebc40db8f9176)