kerasを使って、画像からじゃんけんの手を判別する分類AIを作る

# やりたいこと
pythonの機械学習ライブラリkerasを利用して
手がグー、チョキ、パーのどれを出しているのを判別するAIを作る。


# データセット
今回は、Kaggleの公式ページより 「[Rock Paper Scissors Dataset](https://www.kaggle.com/datasets/sanikamal/rock-paper-scissors-dataset?resource=download)」という
グー、チョキ、パーの画像が合計2,892 枚入ったデータセットを利用する。
![スクリーンショット 2023-05-29 23.10.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/89c8f484-007b-a2d5-0cc0-51deb8496ee4.png)
データセットをダウンロードして展開するとフォルダ構成は以下のようになっている。
今回は、train と testを利用してモデルを作成する。
![スクリーンショット 2023-05-29 23.05.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/bdba1a7b-2a6f-d08a-69d8-a367e381fff9.png)
フォルダの中身は　Rock、Paper、Scissorsとフォルダが分かれている。
![スクリーンショット 2023-05-31 23.45.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/649e0287-0549-24f4-5640-3b8c64a0f962.png)

# 今回のモデルの構成
以下のような畳み込み層を2つ持つニューラルネットワークモデルを作る。
![スクリーンショット 2023-05-30 0.18.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/c62bcfb9-9095-75b8-168c-0167d1cc6d3d.png)

# やってみた
## モデル作成
### train.py
上で示したとおりモデルを構築して学習する。
*勉強のために、冗長なコメントがあります。

```python: train.py

from keras.models import Sequential
from keras.layers import Activation, Dense, Conv2D, MaxPool2D, Flatten
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import ModelCheckpoint

# それぞれのチェックポイント（epoch）で作成されたモデルの結果を、h5形式で保存するためのCallBack関数
checkpoint = ModelCheckpoint(
                    filepath="models/model-{epoch:02d}-{val_accuracy:.2f}.h5",
                    monitor='val_accuracy',
                    save_best_only=False,
                    period=1)

def train():
    model = Sequential()
 
    # 1つ目の畳み込み層。フィルターは3x3で、入力画像の形状は300x300x3を指定している。
    model.add(Conv2D(64, (3, 3), input_shape=(300, 300, 3)))
    model.add(Activation('relu'))
    # 1つ目のプーリング層。フィルターは2x2
    model.add(MaxPool2D(pool_size=(2, 2)))
   
    # 2つ目の畳み込み層。フィルターは3x3
    model.add(Conv2D(64, (3, 3)))
    model.add(Activation('relu'))
    # 2つ目のプーリング層。フィルターは2x2
    model.add(MaxPool2D(pool_size=(2, 2)))
   
    # 全結合層。上記まで2D(2次元)で画像を取り扱ってきたので1次元に変換
    model.add(Flatten())
    model.add(Dense(256))
    model.add(Activation('relu'))
    model.add(Dense(3))
    # 出力層の活性化関数はsoftmax関数を指定することで、それぞれのラベル(グー、チョキ、パー)の確率が出力される
    model.add(Activation('softmax'))

    # モデルの学習プロセスを設定。最適化アルゴリズムはadam、損失関数は分類の問題なので交差エントロピーを指定。
    model.compile(
        optimizer='adam',
        loss='categorical_crossentropy',
        metrics=['accuracy'])
    
    # ImageDataGeneratorで、画像データの前処理やデータ拡張を行う。
    # 以下は、訓練データ、テストデータを正規化(0~1の値に変換)している
    train_data_gen = ImageDataGenerator(rescale=1.0/255.0)
    validation_data_gen = ImageDataGenerator(rescale=1.0/255.0)
    
    # 画像のサイズを300x300に変換
    train_generator = train_data_gen.flow_from_directory(
        'data/train', 
        target_size=(300, 300),
        batch_size=10)
    validation_generator = validation_data_gen.flow_from_directory(
        'data/test',
        target_size=(300, 300),
        batch_size=10)
    
    #　学習
    model.fit_generator(
        train_generator,
        epochs=10,
        steps_per_epoch=10,
        validation_data=validation_generator,
        validation_steps=2,
        callbacks=[checkpoint],
        shuffle=True)
    

if __name__ == '__main__':
    train()

```

### 学習結果
fit_generatorの引数epochsを10に指定したので10回学習が行われ、以下のログが出力された。
- loss: 訓練データに対する損失率で、低ければ低いほど良い学習。
- accuracy: 訓練データに対する精度(正解率)で、高ければ高いほど良い。1.0が最大。
- val_loss: テストデータに対する損失率で、低ければ低いほど良い学習。
- val_accuracy:  テストデータに対する精度(正解率)で、高ければ高いほど良い。1.0が最大。
```
Epoch 1/10
10/10 [==============================] - 29s 3s/step - loss: 32.0930 - accuracy: 0.2500 - val_loss: 1.2552 - val_accuracy: 0.3500
Epoch 2/10
10/10 [==============================] - 30s 3s/step - loss: 1.0925 - accuracy: 0.5000 - val_loss: 1.0681 - val_accuracy: 0.5000
Epoch 3/10
10/10 [==============================] - 25s 3s/step - loss: 0.9632 - accuracy: 0.7600 - val_loss: 1.0420 - val_accuracy: 0.5500
Epoch 4/10
10/10 [==============================] - 25s 3s/step - loss: 0.6930 - accuracy: 0.8200 - val_loss: 1.0092 - val_accuracy: 0.4000
Epoch 5/10
10/10 [==============================] - 25s 3s/step - loss: 0.4654 - accuracy: 0.8600 - val_loss: 0.9532 - val_accuracy: 0.7000
Epoch 6/10
10/10 [==============================] - 25s 3s/step - loss: 0.5888 - accuracy: 0.8000 - val_loss: 0.9175 - val_accuracy: 0.6000
Epoch 7/10
10/10 [==============================] - 25s 3s/step - loss: 0.3620 - accuracy: 0.8900 - val_loss: 0.7328 - val_accuracy: 0.8000
Epoch 8/10
10/10 [==============================] - 25s 3s/step - loss: 0.2545 - accuracy: 0.9300 - val_loss: 2.5089 - val_accuracy: 0.2000
Epoch 9/10
10/10 [==============================] - 25s 3s/step - loss: 0.2912 - accuracy: 0.9400 - val_loss: 0.8526 - val_accuracy: 0.8500
Epoch 10/10
10/10 [==============================] - 24s 2s/step - loss: 0.1690 - accuracy: 0.9600 - val_loss: 0.4902 - val_accuracy: 0.7500

```
また今回CallBack関数で、それぞれのチェックポイント（epoch）で作成されたモデルの結果を、
h5形式で保存するようにしていたため、指定のフォルダにファイルが作成されている。
![スクリーンショット 2023-06-01 0.04.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/ecdce50b-48e0-cf93-d891-bfef517d79b1.png)
### モデル選定

上記結果から、モデルを選ぶ。
今回は、精度(正解率)に着目する。ポイントは、accuracy　と　val_accuracyどちらも高いものを選ぶことらしい。特にaccuracyが高く、val_accuracyが低い場合は、未知のデータへの予測に弱いことを意味するため注意が必要。

今回はaccuracy　と　val_accuracyどちらも高いecoch数9回目に作成されたモデル（models/model-09-0.85.h5）を選択することにする。


## モデルを試してみる
スクリプトを追加。
![スクリーンショット 2023-06-01 0.18.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/7e5d1904-899a-c983-07f6-948dffe1b017.png)

### rps_classifier.py

画像からじゃんけんの手を分類するクラス。

```python:rps_classifier.py
from keras.models import load_model
import numpy as np
import cv2

class Rps_classifier:
    '''
    classify rps(Rock-Paper-Scissors)
    '''

    def __init__(self, model_path):
        self.model = load_model(model_path)
        self.labels = ['paper', 'rock', 'scissors', 'nothing']

    def preprocess(self, image):
        image = image / 255
        image_resized = cv2.resize(image, (300, 300))
        image_reshaped = image_resized[np.newaxis, :, :, :]
        image_preprocessed = np.array(image_reshaped)
        return image_preprocessed
    
    def post_process(self, model_output, prob_threshold):
        prob_paper    = model_output[0][0] * 100
        prob_rock     = model_output[0][1] * 100
        prob_scissors = model_output[0][2] * 100
        rps_prob_list = [prob_paper, prob_rock, prob_scissors]
        print(rps_prob_list)

        max_prob = max(rps_prob_list)
        if max_prob > prob_threshold:
            max_index = rps_prob_list.index(max_prob)
            return self.labels[max_index]
        # 確率の閾値より低い場合は,nothingとして返す
        else:
            return self.labels[3]

    def predict(self, image, prob_threshold):
        input_image = self.preprocess(image)
        model_output = self.model.predict(input_image)
        result = self.post_process(model_output, prob_threshold)
        return result
```

### main.py

```python: main.py
import cv2
from rps_classifier import Rps_classifier

def main():

    model_path = 'models/model-09-0.85.h5'
    model = Rps_classifier(model_path)
    # 低い信頼度のモデル出力を除くための閾値
    prob_threshold = 70.0
    
    image_path = 'data/validation/paper/paper-hires2.png'
    image = cv2.imread(image_path)

    out_put = model.predict(image, prob_threshold)
    print(out_put)

if __name__ == '__main__':
    main()
```

### インプット画像
学習にも、テストにも使っていない画像を入力に実行してみる。

![paper-hires2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2107276/4c1201dd-16ab-14dd-9290-98f6f1525d3d.png)

### 実行結果
main.pyを実行した結果以下のようになった。
1行目は、モデルのアプトプットで、'paper', 'rock', 'scissors'の順に
信頼度(そのラベルである確率)が格納したリスト。
2行目は、後処理した結果で最も可能性の高いラベル名。

見てみると期待通りpaper(パー)という結果を得ることができている。

```
[78.24956178665161, 1.3062641955912113, 20.444172620773315]
paper
```