CNNでの画像分析 - ウェル内血液反応像の判定

#はじめに
##1. 血液反応像を判定するAIアプリについて
私が担当している自動輸血検査装置はマイクロプレートによる凝集技術を活用し、血球及び血漿や血清の分注、試薬の分注、攪拌、マイクロプレートウェル内の画像処理などの工程が自動的に行われます。
装置はマイクロプレート内の赤血球または粒子状物質の沈降パターンを測定し閾値設定から分析結果を自動的に判定しますが、最終的な分析結果の判定はウェル内の血液反応パターンを確認しお客様の目視で行うことが要求されています。この目視レビューは全マイクロプレートの全ウェルに対し行う必要があるため、お客様にとっては大きな負担になっていると思います。もし血液反応の陰性、陽性パターン及び再検が必要となる異型についてAIに機械学習をさせ、アプリで自動判定ができたら、お客様の手間を省くことができヒューマンエラーもある程度避けられるのではないかと考えました。
##2. 分析データについて
実際装置で出力された画像を使用しました。陰性像、陽性像と異型（再検）の三つに分類されています。
##3. 開発環境(Macbook Pro)
・GoogleColaborabory
・Anaconda（VSCode Ver 1.56.2)
・Python（Ver 3.8.5)

:::note info
陰性、陽性と異型(再検)の画像フォルダをGoogleDriveにアップロードしGoogleColabにマウントしました。
:::

#データの確認
##1. ライブラリのインポート

```python
import os
import cv2
import random
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import tensorflow as tf
import keras
import keras.preprocessing.image as Image
import sklearn.metrics
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.applications.vgg16 import VGG16
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras.layers import Dense, Dropout, Flatten, Input
from tensorflow.keras import optimizers
from tensorflow.keras import preprocessing, layers, models, callbacks
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import ModelCheckpoint
from keras.preprocessing.image import load_img, save_img, img_to_array, array_to_img
from google.colab import files
from keras.callbacks import EarlyStopping, ModelCheckpoint]
```

##2. 画像数の確認
分析に使用できる各分類の画像は何枚あるのかを最初に確認します。os.listdirでファイル・ディレクトリ内の一覧を取得し、len( )でディレクトリ内の画像数を数えました。

```python
#陰性反応画像数
print("Negative Image:", len(os.listdir("/content/drive/MyDrive/Negative")))
#陽性反応画像数
print("Positvie Image:", len(os.listdir("/content/drive/MyDrive/Positive")))
#要再検の異型画像数
print("Repeat Test:", len(os.listdir("/content/drive/MyDrive/RepeatTest")))
```

実行結果は以下となります。

```python
Negative Image: 657
Positvie Image: 485
Repeat Test: 534
```

##3. 画像のチェック
ディレクトリへのパスと画像の名前をos.path.join( )で結合し、OpenCVのimreadで各画像を読み込んでいます。その後、各リストに保存した画像からenumerateでそれぞれ3枚をmatplotlibを用いて表示させるようにしました。

```python
path_negative = os.listdir("/content/drive/MyDrive/Negative")
path_positive = os.listdir("/content/drive/MyDrive/Positive")
path_retest = os.listdir("/content/drive/MyDrive/RepeatTest")

imgs_N = []
for i in path_negative:
  target = os.path.join("/content/drive/MyDrive/Negative",i)
  imgN = cv2.imread(target)
  imgN = cv2.cvtColor(imgN, cv2.COLOR_BGR2RGB)
  imgN = cv2.resize(imgN, (80, 80))
  imgs_N.append(imgN)
imgs_P = []
for i in path_positive:
  target = os.path.join("/content/drive/MyDrive/Positive",i)
  imgP = cv2.imread(target)
  imgP = cv2.cvtColor(imgP, cv2.COLOR_BGR2RGB)
  imgP = cv2.resize(imgP,(80,80))
  imgs_P.append(imgP)
imgs_R = []
for i in path_retest:
  target = os.path.join("/content/drive/MyDrive/RepeatTest",i)
  imgR = cv2.imread(target)
  imgR = cv2.cvtColor(imgR, cv2.COLOR_BGR2RGB)
  imgR=cv2.resize(imgR,(80,80))
  imgs_R.append(imgR)

fig1 = plt.figure(figsize=(4,4))
for i, a in enumerate(random.sample(imgs_N,3)):
  fig1.add_subplot(1,3,i+1)
  plt.axis("off")
  plt.title("Negative"+str(i))
  plt.imshow(a)
fig2 = plt.figure(figsize=(4,4))
for i, a in enumerate(random.sample(imgs_P,3)):
  fig2.add_subplot(1,3,i+1)
  plt.axis("off")
  plt.title("Positive"+str(i))
  plt.imshow(a)
fig3 = plt.figure(figsize=(4,4))
for i, a in enumerate(random.sample(imgs_R,3)):
  fig3.add_subplot(1,3,i+1)
  plt.axis("off")
  plt.title("Retest"+str(i))
  plt.imshow(a)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/9b84dbf7-97f1-253a-358c-c0d588072099.png)

#データの分割：トレーニングデータとテストデータ生成

テスト用データ(X)として三つのリストをアレイ化して結合、yは正解ラベルとしてNegativeを0、Positiveを1、Retestを2と定義します。np.random.permutation()にてトレーニング用データXをランダムに入れ替えます。そしてX_train, X_test, y_train, y_testとして使用する割合を設定します(80%)。最後にto_categoricalにてone hot vector化しそれぞれの正解ラベルを正規化します。

```python
#トレーニングデータとテストデータ生成
#テスト用データ(X)として上記三つのリストをアレイ化して結合、yは正解ラベルとしてNegativeを0、Positiveを1、Retestを2と定義
X = np.array(imgs_N + imgs_P + imgs_R)
y = np.array ([0]*len(imgs_N) + [1]*len(imgs_P) + [2]*len(imgs_R))
rand_index = np.random.permutation(np.arange(len(X)))
X = X[rand_index]
y = y[rand_index]
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):]

y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
```

#データのかさ増し
画像の枚数が少ないため、ImageDataGeneratorを使用して画像を水増しします。汎化性能低下防止のため、トレーニングデータにのみ水増し用の関数を適用しています。

```python
def img_augment(x,y):
  X_augment = []
  y_augment = []
  i = 0
  #水増しを30回繰り返す
  while i < 30:
    datagen = ImageDataGenerator(rotation_range=10, #±10°でランダムに回転
                            shear_range=0.2, #反時計回りのシア
                            horizontal_flip=True) #水平方向にランダムで反転
    augment = datagen.flow(X_train, y_train, shuffle=False, batch_size=32)
    X_augment.append(augment.next()[0])
    y_augment.append(augment.next()[1])
    i += 1
  X_augment = np.array(X_augment).reshape(-1,80,80,3)
  y_augment = np.array(y_augment).reshape(-1,3)
  return X_augment,y_augment

img_add = img_augment(X_train,y_train)

X_train_add = np.concatenate([X_train,img_add[0]])
y_train_add = np.concatenate([y_train,img_add[1]])
```

#CNNモデルの作成・学習
まず投入するデータのサイズを80x80にし、RGBの３つで投入します。各分類の画像の枚数が多いとは言えないため、VGG16による転移学習を行い画像の少なさをカバーします。for文を使用しVGGの抽出した特徴量を19層まで固定します。model.compileにて分類方法を指定します。

```python
input_tensor = Input(shape=(80,80,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.2))
top_model.add(Dense(128, activation="relu"))
top_model.add(Dense(3, activation="softmax"))

model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))
for layer in model.layers[:19]:
  layer.trainable=False

model.compile(loss="categorical_crossentropy",
              optimizer=optimizers.SGD(lr=1e-4,momentum=0.9),
              metrics=["accuracy"])
```

EarlyStoppingを用いて学習ループに収束判定を付与し、監視値’val_loss’が収束したら自動的にループを抜ける処理を行います。そしてmodel.fitにて学習を実行します。
また評価指標を可視化するコードを実施し、正解率を可視化します。
Scores = model.evaluate(X_test, y_test, verbose=1)で損失値をscore変数に格納しprint関数でlossと正解率を表示します。

```python
es_cb = EarlyStopping(monitor='val_loss', patience=3, verbose=0, mode='auto')
history = model.fit(X_train, y_train, validation_data=(X_test, y_test), validation_split=0.1, shuffle=True,
                    batch_size=32, epochs=100, callbacks=[es_cb])

#正解率の可視化
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.legend(loc="best")
plt.show()

#学習モデルを保存
model.save('model.h5')

scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])
```

**Aidemy講師の方々に沢山ご指導いただき**、ようやく学習モデルの作成は完了しました！またコード、Parameterを調整したところ、最終的に仕上がったこのコードで学習を行なった結果は以下の通りです。

```python
/usr/local/lib/python3.7/dist-packages/tensorflow/python/keras/optimizer_v2/optimizer_v2.py:375: UserWarning: The `lr` argument is deprecated, use `learning_rate` instead.
  "The `lr` argument is deprecated, use `learning_rate` instead.")
Epoch 1/100
65/65 [==============================] - 146s 2s/step - loss: 2.6953 - accuracy: 0.6251 - val_loss: 0.8481 - val_accuracy: 0.5870
Epoch 2/100
65/65 [==============================] - 145s 2s/step - loss: 0.6684 - accuracy: 0.7768 - val_loss: 0.5252 - val_accuracy: 0.7739
Epoch 3/100
65/65 [==============================] - 146s 2s/step - loss: 0.4764 - accuracy: 0.8304 - val_loss: 0.3990 - val_accuracy: 0.8174
Epoch 4/100
65/65 [==============================] - 145s 2s/step - loss: 0.4261 - accuracy: 0.8372 - val_loss: 0.3936 - val_accuracy: 0.8130
Epoch 5/100
65/65 [==============================] - 145s 2s/step - loss: 0.3482 - accuracy: 0.8647 - val_loss: 0.3022 - val_accuracy: 0.8652
Epoch 6/100
65/65 [==============================] - 146s 2s/step - loss: 0.2897 - accuracy: 0.8816 - val_loss: 0.2291 - val_accuracy: 0.9043
Epoch 7/100
65/65 [==============================] - 145s 2s/step - loss: 0.2671 - accuracy: 0.8990 - val_loss: 0.2276 - val_accuracy: 0.9304
Epoch 8/100
65/65 [==============================] - 146s 2s/step - loss: 0.2269 - accuracy: 0.9116 - val_loss: 0.1578 - val_accuracy: 0.9609
Epoch 9/100
65/65 [==============================] - 145s 2s/step - loss: 0.2130 - accuracy: 0.9237 - val_loss: 0.1900 - val_accuracy: 0.9565
Epoch 10/100
65/65 [==============================] - 145s 2s/step - loss: 0.2061 - accuracy: 0.9275 - val_loss: 0.1429 - val_accuracy: 0.9435
Epoch 11/100
65/65 [==============================] - 145s 2s/step - loss: 0.1834 - accuracy: 0.9362 - val_loss: 0.1049 - val_accuracy: 0.9826
Epoch 12/100
65/65 [==============================] - 145s 2s/step - loss: 0.1706 - accuracy: 0.9338 - val_loss: 0.0984 - val_accuracy: 0.9870
Epoch 13/100
65/65 [==============================] - 145s 2s/step - loss: 0.1611 - accuracy: 0.9473 - val_loss: 0.1185 - val_accuracy: 0.9826
Epoch 14/100
65/65 [==============================] - 145s 2s/step - loss: 0.1460 - accuracy: 0.9527 - val_loss: 0.0817 - val_accuracy: 0.9826
Epoch 15/100
65/65 [==============================] - 145s 2s/step - loss: 0.1464 - accuracy: 0.9488 - val_loss: 0.1056 - val_accuracy: 0.9739
Epoch 16/100
65/65 [==============================] - 145s 2s/step - loss: 0.1198 - accuracy: 0.9614 - val_loss: 0.0918 - val_accuracy: 0.9826
Epoch 17/100
65/65 [==============================] - 145s 2s/step - loss: 0.1292 - accuracy: 0.9556 - val_loss: 0.0914 - val_accuracy: 0.9870
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/ea3f4ce8-ab8b-b881-22c1-ca4664359cee.png)


#HTML、CSSコードの作成
ローカル側でアプリは出来上がったので、これからウェブサイト側を作成していきます。全体のデザインとしては**AidemyさんのAIアプリ開発コース**で練習として作成した数字認識アプリのデザインをベースにし、少しアレンジします。

##1.index.html
```python
<!DOCTYPE html>
<html lang='ja'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content="device-width, initial-scale=1.0">
    <meta http-equiv='X-UA-Compatible' content="ie=edge">
    <title>Visual Inspection</title>
    <link rel='stylesheet' href="./static/stylesheet.css">
</head>
<body>
    <header>   
        <img class='header_img' src="./static/pic/headerimage.png">
    </header>

    <div class='main'>    
        <h2> ウェル内の血液反応パターンを判定します</h2>
        <p>反応像をアップロードしてください</p>
        <form method='POST' enctype="multipart/form-data">
            <input class='file_choose' type="file" name="file">
            <input class='btn' value="判定" type="submit">
        </form>
        <h3 class='answer'>{{answer}}</h3>
    </div>

    <footer>
        <small>&copy; 2021 A.Akane</small>   
    </footer>
</body>
</html>
```

##2.stylesheet.css

```python
body {
    max-width: 1920px;
    width: 100%;
    padding: 0px 0px;
    margin: 0 auto;
    background-color: #dcdcdc;
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}


header {
    height: 100px;
    width: 100%;
}


.header_img {
    background-size: cover;
    height: 100px;
    width: 100%;
}

h2 {
    color: #444444;
    margin: 40px 0px;
    text-align: center;
}

p {
    color: #444444;
    margin: 50px 0px 30px 0px;
    text-align: center;
    font-size: 20px;
}

.answer {
    color: #47266e;
    margin: 70px 0px 30px 0px;
    text-align: center;
    font-size: 25px;
}

form {
    text-align: center;
}

footer {
    background-color: #696969;
    border-top: 3px ridge;
    text-align: left;
    height: 20px;
    width: 100%;
    padding: 0px 3px 10px 3px;
    margin-top: auto;
}


small {
    color: #fff;
}
```

ローカル環境で動作を確認してみたら、問題なく判定結果がちゃんと表示されました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/e23ae39f-534d-453d-1300-b5323f2569ec.png)

#Flaskコードの作成
**Aidemyさんのコースで作成したMNISTを用いた数字認識アプリ**のコードを活用し必要な箇所を書き換えました。

```python
import os
from flask import Flask, request, redirect, render_template, flash
from werkzeug.utils import secure_filename
from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.preprocessing import image

import numpy as np


classes = ["陰性反応","陽性反応","要再検"]
image_size = 80

UPLOAD_FOLDER = "uploads"
ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif'])

app = Flask(__name__)

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

model = load_model('./model.h5')#学習済みモデルをロード


@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        if 'file' not in request.files:
            flash('ファイルがありません')
            return redirect(request.url)
        file = request.files['file']
        if file.filename == '':
            flash('ファイルがありません')
            return redirect(request.url)
        if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(UPLOAD_FOLDER, filename))
            filepath = os.path.join(UPLOAD_FOLDER, filename)

            #受け取った画像を読み込み、np形式に変換
            img = image.load_img(filepath, grayscale=False, target_size=(image_size,image_size))
            img = image.img_to_array(img)
            data = np.array([img])
            #変換したデータをモデルに渡して予測する
            result = model.predict(data)[0]
            predicted = result.argmax()
            pred_answer = "これは " + classes[predicted] + " です"

            return render_template("index.html",answer=pred_answer)

    return render_template("index.html",answer="")


if __name__ == "__main__":
    port = int(os.environ.get('PORT', 8080))
    app.run(host ='0.0.0.0',port = port)
```

#Herokuへのデプロイ
先ほどまではローカルでwebアプリを動かしていましたが、ここではwebアプリをHerokuへデプロイし公開します。
ターミナルにhttps://visualinspec2.herokuapp.com/ deployed to Herokuと表示され、無事にデプロイができたと思いきや、heroku openとコマンドを入力すると何故かMNISTを用いた数字認識アプリのトップページが現れました。。。ショックです:expressionless:
ここでまた**Aidemyのカウンセラー先生**に助けてもらいました！以前HerokuにデプロイしたMNISTを用いた数字認識アプリをそのままCopy＆Pasteで活用していたことに問題があったと分かりましたので、一回"heroku destroy"、"rm -rf .git"で今回のアプリを削除し、再度デプロイしました。今回は本当に無事デプロイができ、正しいWebページが表示されました。
陰性、陽性と異型(再検)の画像を使い、動作確認を実施しました。結果は下記の通りです。
##1.陰性画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/476afe82-5d8d-a7a7-6f42-a1d34a57be5a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/720fab09-f32c-540e-d4c7-f5b658921191.png)

##2.陽性画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/01c90911-a28e-f0ab-8803-78e8ee083dc4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/722ddf7c-5b4e-b05d-685c-2de813ccb06f.png)

##3.異型画像(要再検)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/c2a0845b-79b1-4a97-8fbc-e55414e21e3a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806285/ec636199-09d7-0526-9308-995d6746382c.png)

それぞれの画像に対しちゃんと判定してくれました。これで動作確認は終了です。

公開したアプリのURLは：
https://visualinspec2.herokuapp.com/

#反省点
最初は枚数を増やそうとして、気づかないうちに大きなミスを犯しました！画像データを水増しをしてから学習データ、テストデータに分けてしまい、**データリーケージ**が起きていました。チューターの先生よりご指摘を受け、やっと気づきました:sweat:**画像を学習データ、テストデータに分けてから水増しをする！水増しするのは、訓練データのみにする！**これは必ず守るようにしましょう。
比較的に単純な画像だったかもしれないので、高い正解率が得られました。でもパラメータやモデルの層の深さなどを調整した方がもっと良い結果が出るかもしれないとも思います。
現在は僅かなコマンドしか知らないし、絶対もっと簡潔なコードの書き方もあると思いますので、今回のコードに改善すべき箇所は多いです。プログラミングは奥が深いです。今回はいいスタートとして、これからも継続して勉強していきます:neckbeard:

#参考文献
メインにAidemyさんの下記カリキュラムを参照しながらアプリを作成させていただきました。
CNNを用いた画像認識、男女識別、Flask入門のためのHTML＆CSS、Flask入門