表情認識アプリの作成

# 概要
表情を以下の7つの感情に分類するアプリを作成しました。

https://flask-face-app.onrender.com/

後述しますが精度の観点から7クラス分類は難しかったため、2クラス分類(Happy,Angry)となりました。
- Surprise(驚き)
![29.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/108ec349-b6c8-2de7-ee96-8fdf2943c0b6.jpeg)
- Sad(悲しい)
![6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/5376a74c-4931-0fbf-3058-200a5a79f16b.jpeg)
- Neutral(感情無)
![36.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/89ec1741-0bde-b1ce-dec9-d30628be07ac.jpeg)
- Happy(嬉しい)
![28.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/fc850235-d8c7-f6bb-3966-89bbae1e6d14.jpeg)
- Fear（不安）
![71.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/0b2379f7-c4c4-05f7-9709-789d1e223d83.jpeg)
- Disgust（不快）
![1096.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/a7e55e46-0a6c-d02f-a30a-559adc30e794.jpeg)
- Angry(怒り)
![76.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/ab4f740e-9fad-88a6-0df5-478c281f9652.jpeg)


# 目次
1. [環境](#1-環境)
2. [製造](#2-製造)
    2-1. [データ](#2-1データ)
    2-2. [モデル](#2-2モデル)
    2-3. [チューニング](#2-3チューニング)
3. [テスト](#2-サブ)
4. [最後に](#2-サブ)

# 1-環境
```python
absl-py==0.9.0
astor==0.8.1
bleach==3.1.5
bottle==0.12.18
click==7.1.2
certifi==2020.6.20
chardet==3.0.4
flask==2.0.1
future==0.18.2
gast==0.3.3
grpcio==1.31.0
gunicorn==20.0.4
h5py==2.10.0
html5lib==1.1
itsdangerous==2.0
idna==2.10
Jinja2==3.0.1
line-bot-sdk==1.16.0
Markdown==3.2.2
MarkupSafe==2.0
numpy==1.18.0
oauthlib==3.1.0
pillow==7.2.0
protobuf==3.12.4
PyYAML==5.4.1
python-dotenv==0.14.0
requests==2.25.1
scipy==1.4.1
six==1.15.0
tensorboard==2.3.0
tensorflow-cpu==2.3.0
termcolor==1.1.0
urllib3==1.26.5
Werkzeug==2.0.0
```
# 2-製造
モデルの学習に利用したデータ、モデルのなかみ、アプリケーションについて説明します。

### 2-1.データ
冒頭で記載した通り、Kaggleにて公開されている以下のデータセットを利用しました。

https://www.kaggle.com/datasets/jonathanoheix/face-expression-recognition-dataset

- 7クラス(Surprise,Sad,Neutral,Happy,Fear,Disgust,Angry)に分類されています
- 'Train'と'Validation'に分類済みのため、これに則る形でデータの読み込みを行います
- 各クラスのデータ数が多いかつ不揃いのため、利用するデータ数は各クラス1000個とします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/97b63a61-707d-6fd9-f67e-44c6e686f9a1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/5410cbca-0382-2daf-5d9f-506f63669257.png)

### 2-2.モデル
以下の流れでモデルを実装します。
1. データ読込
2. モデル構築
3. 学習
4. 分類
5. 評価

以下がモデルのコード全量を記載します。
```python
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
import random
from tensorflow.keras import optimizers
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 google.colab import files
from tensorflow.keras.preprocessing import image
from tensorflow.keras.models import Sequential, load_model

#変数宣言
list_face_expression = ['happy', 'angry']
dict_face_expression_train_path = {}
dict_face_expression_validation_path = {}
img_shape = (48, 48, 3)

#Expression毎のPathを格納
for expression in list_face_expression :
  dict_face_expression_train_path[expression] = os.listdir("/content/drive/MyDrive/DataSet/images/train/" + expression + "/")
  dict_face_expression_validation_path[expression] = os.listdir("/content/drive/MyDrive/DataSet/images/validation/" + expression + "/")

for expression in list_face_expression :
  dict_face_expression_train_path[expression] = dict_face_expression_train_path[expression][:800]
  dict_face_expression_validation_path[expression] = dict_face_expression_validation_path[expression][:200]

#パスから入力データと正解ラベルを設定
def get_train_test_set(dict_path, torv) :
  img_list = []
  label_list = []
  for expression, paths in dict_path.items() :
    for path in paths :
      bgr_img = cv2.imread("/content/drive/MyDrive/DataSet/images/" + torv + "/" + expression + "/" + path)
      bgr_img = cv2.resize(bgr_img, img_shape[:2])
      b,g,r = cv2.split(bgr_img)
      rgb_img = cv2.merge([r,g,b])
      img_list.append(rgb_img)
      label_list.append(list_face_expression.index(expression))
  return (np.array(img_list), np.array(label_list))

X_train, y_train = get_train_test_set(dict_face_expression_train_path, "train")
X_test, y_test = get_train_test_set(dict_face_expression_validation_path, "validation")

# 正解ラベルをone-hotエンコーディング
# 例) 6→{0,0,0,0,0,0,1}
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)

 #VGG16モデル構築
input_tenosr = Input(shape=img_shape)
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tenosr)

#全結合層モデル構築
sequential_model = Sequential()
sequential_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
sequential_model.add(Dense(256, activation='sigmoid'))
sequential_model.add(Dropout(rate=0.5))
sequential_model.add(Dense(2, activation='softmax'))

#VGG16モデルと全結合層モデルを結合
model = Model(inputs=vgg16.input, outputs=sequential_model(vgg16.output))

#VGG16モデルの重みを固定
for layer in model.layers[:19] :
    layer.trainable = False

# コンパイル
model.compile(loss='categorical_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])

# 学習
history = model.fit(X_train, y_train, verbose=1, batch_size=256, epochs=100, validation_data=(X_test, y_test))

# モデル構造表示
model.summary()

# 分類
def predict_img(img) :
    pred = np.argmax(model.predict(np.array([img])))
    return list_face_expression[pred]

#モデルの汎化精度評価
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

#エポック毎のモデル精度推移を表示
plt.plot(history.history["accuracy"], label="accuracy", ls="-", marker="o")
plt.plot(history.history["val_accuracy"], label="val_accuracy", ls="-", marker="x")
plt.ylabel("accuracy")
plt.xlabel("epoch")
plt.show()

#resultsディレクトリを作成
result_dir = 'results'
if not os.path.exists(result_dir):
    os.mkdir(result_dir)

# 重みを保存
model.save(os.path.join(result_dir, 'model.h5'))
files.download("/content/result/model.h5")
```

#### 1.データ読込
- GoogleDrive上にデータセットを保存し、それらをGoogleColaboratoryからマウントすることでデータを読込
```python
from google.colab import drive
drive.mount('/content/drive')
```
- 各クラス毎のデータパスのリストを作成
Surprise,Sad,Neutral,Happy,Fear,Disgust,Angryを格納したリストおよび、それらに紐づくデータのパスをディクショナリー型で保持するための変数を初期宣言します。
```python
#変数宣言
list_face_expression = ['happy', 'angry']
dict_face_expression_train_path = {}
dict_face_expression_validation_path = {}
img_shape = (48, 48, 3)
```
- 各クラス毎のすべてのデータのパスを上記で準備した変数に格納
os.listdir関数を用いて各クラス毎に分類されたディレクトリ配下のファイル名称をリストで取得し、それらをValueまたクラスをKeyとしてディクショナリーの変数に格納します。
またデータ量が膨大かつ各クラス毎のデータ量を統一するため、TrainデータをXXX個、ValidationデータをXXX個とするように変数に再度代入します。
```python
#Expression毎のPathを格納
for expression in list_face_expression :
  dict_face_expression_train_path[expression] = os.listdir("/content/drive/MyDrive/DataSet/images/train/" + expression + "/")
  dict_face_expression_validation_path[expression] = os.listdir("/content/drive/MyDrive/DataSet/images/validation/" + expression + "/")

for expression in list_face_expression :
  dict_face_expression_train_path[expression] = dict_face_expression_train_path[expression][:800]
  dict_face_expression_validation_path[expression] = dict_face_expression_validation_path[expression][:200]
```

- パスをもとに全データを読込、それ対するラベルを読込
準備したディクショナリーの変数を引数として、読み込んだデータとラベルのリストを戻り値とする関数を定義します。
関数では各クラスをループ、その中で全パスをループさせています。'csv2.imread'を用いてデータを読み込みます。データのサイズが想定(48,48,3)と異なる場合はリサイズします。(今回利用するデータはすべて同じサイズのためこちらのコードは不要になりますが一応用意しています。)その後BGR空間で読み込まれているデータRBG空間に変換します。最後に戻り値のリストにデータと各クラスのインデックスをラベルとしてリストに格納し返却します。
```python
#パスから入力データと正解ラベルを設定
def get_train_test_set(dict_path, torv) :
  img_list = []
  label_list = []
  for expression, paths in dict_path.items() :
    for path in paths :
      bgr_img = cv2.imread("/content/drive/MyDrive/DataSet/images/" + torv + "/" + expression + "/" + path)
      if bgr_img.shape != img_shape :
        bgr_img = cv2.resize(bgr_img, img_shape[:2])
      b,g,r = cv2.split(bgr_img)
      rgb_img = cv2.merge([r,g,b])
      img_list.append(rgb_img)
      label_list.append(list_face_expression.index(expression))
  return (np.array(img_list), np.array(label_list))

X_train, y_train = get_train_test_set(dict_face_expression_train_path, "train")
X_test, y_test = get_train_test_set(dict_face_expression_validation_path, "validation")
```

- ラベルをOne-Hotエンコーディング
ラベルにはインデックス(数値)が設定されていたため、これらを以下のようにエンコーディングすることでモデルが出力する各クラスの確率と対応するようにします。
例) 6→{0,0,0,0,0,0,1}
```python
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
```

#### 2.モデル構築
- VGG16モデルを転移学習させたモデルを構築するためにまずVGG16モデルを構築
```python
#VGG16モデル構築
input_tenosr = Input(shape=img_shape)
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tenosr)
```

- VGG16モデルと結合する全結合層モデルを構築
活性化関数はRelu関数を、最終活性化関数はSoftmax関数を使用します。

```python
#全結合層モデル構築
sequential_model = Sequential()
sequential_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
sequential_model.add(Dense(256, activation='sigmoid'))
sequential_model.add(Dropout(rate=0.5))
sequential_model.add(Dense(2, activation='softmax'))
```

- モデルを結合
VGG16モデルと全結合層モデルを結合します。
```python
model = Model(inputs=vgg16.input, outputs=sequential_model(vgg16.output))
```

- VGG16モデルの重みを固定
学習済のVGG16モデルの重みが変わらないように固定します。
```python
for layer in model.layers[:19]:
    layer.trainable = False
```

- モデルをコンパイル
分類問題のため損失関数はクロスエントロピー誤差を利用します。
最適化関数はSGD関数を利用します。
```python
# コンパイル
model.compile(loss='categorical_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])
print("compile : passed")
```

#### 3.学習

- モデルにデータを学習させる
```python
# 学習
history = model.fit(X_train, y_train, verbose=1, batch_size=256, epochs=100, validation_data=(X_test, y_test))
```

#### 4.分類
- 画像を分類する関数を定義
引数の画像をmodel.predict関数に渡し、返り値として得た各クラスのうち最も確率が高いクラスのインデックスをnp.argmax関数にて取得します。得たインデックスに該当するクラスの文字列を最後に返却します。

```python
# 分類
def predict_img(img) :
    pred = np.argmax(model.predict(np.array([img])))
    return list_face_expression[pred]
```

#### 5.評価
- モデルの精度を評価
model.evaluate関数を使用し、返り値である損失値と精度を表示します。
```python
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])
```

- エポック毎の精度および損失値の推移を表示
model.fit関数にて取得したhistoryオブジェクトから学習データおよびテストデータの損失値および精度のエポック毎の推移をplt.plot()を使用しプロットします。
```python
#エポック毎のモデル精度推移を表示
plt.plot(history.history["accuracy"], label="accuracy", ls="-", marker="o")
plt.plot(history.history["val_accuracy"], label="val_accuracy", ls="-", marker="x")
plt.ylabel("accuracy")
plt.xlabel("epoch")
plt.show()
```
### 2-3.チューニング
ハイパーパラメーターは上述の通りですが、そこ至るまでの経緯が以下の表です
X軸がハイパーパラメーターおよびその精度、Y軸がチューニング経過となります。
各変数がどの程度独立しているのかわからなかったため、基本的には1変数を操作し、その他を固定することでチューニングを行いました、全パターンを網羅するのは大変なので大まかにやっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/47ca67c5-b5c9-4b49-2596-806e92c2dcab.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/ee4c6bf0-570b-209a-353d-768305445e74.png)
- Model
緑塗りされている箇所がモデルの中身を操作した箇所になります。
活性化関数を操作した際に損失値が大きく変化していますが、それ以外は基本的に精度はほとんど変わりませんでした。
- Compile
黄色塗りされている箇所がコンパイルのハイパーパラメーターを操作した箇所になります。
3パターン試しましたが、精度と損失値が反比例しており、どれを使うか迷いましたが、ここではAdamを使うことにしました。
Adamにしてから収束が早まったため、モデルにDropoutを組み込み収束を遅らせてみましたが、案の定精度が上がったので、途中からDropoutに0.5を設定しています。
- Learning
橙色塗りされている箇所が学習のハイパーパラメーターを操作した箇所になります。
バッチサイズですが大きくするほど損失値精度ともに上がったため、128としました。
エポック数はプロットで確認した際に100周辺で収束しているようだったので100としました。
- Data
青色塗りされている箇所がデータを操作した箇所になります。
ここまで色々変数を操作してきましたが、精度は40%に至らず低い数字となっておりました。
データ数を増やしてみましたが、精度もたいして変わりませんでした。Kaggleのデータ自体はもっとたくさんあるのですが、GoogleColaboratoryの性能的にこれ以上増やすのは難しそうだったので、思い切ってクラス数を3クラスに変更しました。
(画像を見ていただくとわかりますが人間でも判断が相当難しいので、難しい分類問題だったのかなと思料しておりました。)
3クラスに変更しても精度は56%と芳しくなかったため、最終的に2クラスへ変更したところ精度は76%に至りました。

# 3-テスト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/c624e52b-63ca-0967-0d8f-780833264322.png)

Kaggleのデータおよびネットのデータをいくつか利用してテストを行いました。
精度が76%であった通り、体感としても概ねあっている印象を受けました。
わかりやすく口角を上げているものはHappy、眉間に皺を寄せているものはAngryと判定されやすく、Happyでも口をつぐんでいるもの、Angryでも口を開けているものなどは間違っている場合が多いように感じました。
- わかりやすいHappy
![7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/fe4e52d7-8aa0-0c47-f0db-bd800bb50b00.jpeg)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/88b109ce-8e35-8a3c-7b43-55e8d0aab266.png)
- わかりにくいHappy
![35839.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/d71d4733-6f58-1f2b-89b4-b4bab54b9d0f.jpeg)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3777369/593f5f20-081f-3be0-f532-0ad855f97329.png)

# 4-最後に
本テーマの課題は今回2クラスのみの分類となってしまったため、より多くのクラス分類にし、かつ精度を上げることです。
上記課題解決に至らなかった原因はもちろんチューニング不足やモデル構築の最適化が出来なかったこともあるかと思います。
ただ表情という人間でも判断が難しく抽象的なテーマであったため、当初設定していた7クラス分類はかなり難易度の高いものであったのかなというのが正直な印象です。(人間もその時々の文脈や環境から表情を判断しているケースが多く、それらを抜きにして、表情だけで判断するのは'Positive'か'Negative'かぐらいしかわからないんじゃないかと..)
Kaggleのデータを眺めていても、「本当にこれがHappy?」「SadとFear変わらなくない？」みたいな画像が多数あり、そもそものデータ自体の信頼性も大事だなと思いました。
ただ人間以上の精度を出せるのがAIですので、その時々の表情をもし判断することができるようになったら、様々な実用性があるかと思いました。(コンサートに来た人が本当に楽しんでいるのか、漫才を見に来た人たちが本当に笑っているのか等)