OpenCVを用いたリアルタイムマスク有無判別

# はじめに
2020年あたりから新型コロナウイルスの感染によって、私たちの生活は一変してしまいました。その中でもマスクの着用をどうするかの問題が連日ニュースで取り上げられています。そこで、今回はOpenCV,深層学習を使ってリアルタイムでマスクをつけているかどうかを判別するシステムの開発に挑戦しました。

# 開発環境
* macOS Monterey バージョン12.5.1
* Python 3.9
* Atom バージョン1.60.0

# 手順
1. マスクなしの人、マスクありの人の画像を収集
2. 顔部分の抽出
3. モデルの構築、予測精度の評価
4. カメラからの入力画像をウィンドウに表示 
5. マスクの有無判別結果をウィンドウに表示

# 1.マスクなしの人、マスクありの人の画像を収集
モデルを構築するにあたって、データを用意することが一番初めにしなければならないことです。この作業が一番時間がかかりました。特に「マスクありの人」の画像を抽出する時の検索ワード選びに試行錯誤しました。「マスク」という単体のワードでは、商品だけの画像が出てきたり、プロレスラーなどが被るマスクが出てきたりとうまくいきませんでした。なんとしてでも「マスクをつけた人」の画像を収集したいと思い、よくニュースで東京都の感染者数が取り上げられていることをヒントに、マスクとつけている人の画像は、「マスク　東京」で検索することにしました。すると、下のようなかなりいい画像を収集することができました。
![20200525-OYT1I50020-1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/f130c44d-ce2d-49eb-224d-5335e868864b.jpeg)

一方で、「マスクなしの人」の画像は「集合写真」で検索することで収集しました。
確認してみると、ラッキーなことに乃木坂46の集合写真も含まれていました。
![000097.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/d4a68152-8081-9ff1-6f2f-a3146adbbc8c.jpeg)

画像を収集するためのモジュールとして**icrawler**を使いました。
```
$ pip install icrawler
$ pip list | grep icrawler
icrawler                      0.6.6
```
画像を収集するコード(scraping.py)はこちらです。
キーワードとして「集合写真」「マスク　東京」を指定しています。
```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from icrawler.builtin import BingImageCrawler
import os
import re
from PIL import Image
import glob


# 画像を収集するメソッド
# 引数は画像を保存するパスpath、検索ワードkeyword、収集する枚数num

def scraping(path, keyword, num):

    bing_crawler=BingImageCrawler(
    downloader_threads=4,
    storage={'root_dir': path}
    )

    #検索ワードにkeywordを入れたときに得られる画像をnum枚収集
    bing_crawler.crawl(
        keyword=keyword,
        max_num=num
    )
    print(f'{keyword}: scraping completed!')


#ファイルの形式はjpegなので、ファイル名には必ず拡張子.jpgがつく
gather_path='./gather/*.jpg'
mask_people_path='./mask_people/*.jpg'

keywords=['集合写真', 'マスク 東京']
num=300

scraping('./image/gather/', keywords[0], num)
scraping('./image/mask_people/', keywords[1], num)

```

# 2.顔部分の抽出
先ほど収集した画像の中から顔の部分だけ抽出した画像をモデルに学習させます。
やることはこんな感じです。
![スクリーンショット 2022-10-15 10.39.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/09ff9e9f-96fb-5557-977b-53ebeb67a12c.png)

ここで使用するのは、**Dlib**というモジュールです。
```
$ pip install dlib
$ pip list | grep dlib
dlib                          19.24.0            
```
顔部分の抽出をするコード(face_cut.py)はこちらです。
```python
#Dlibを始める（Dlibは画像から顔の検出をするためのツール）
detector=dlib.get_frontal_face_detector()

#顔画像を取得して保存
def get_face(fname):
    global fid
    img=cv2.imread(fname)
    #画像のサイズが大きい時はリサイズする
    if flag_resize:
        img=cv2.resize(img, None, fx=0.2, fy=0.2)

    #顔検出, 検出した部分の矩形の座標を取得している
    dets=detector(img, 1)
    for k,d in enumerate(dets):
        pprint.pprint(d)
        x1=int(d.left())
        y1=int(d.top())
        x2=int(d.right())
        y2=int(d.bottom())
        im=img[y1:y2, x1:x2]

        #50 x 50にリサイズ
        try:
            im=cv2.resize(im, (50,50))
            # print('resized!')
        except:
            # print('Not resized!')
            continue

        #保存する
        out=outdir+"/"+str(fid)+".jpg"
        cv2.imwrite(out, im)
        fid+=1
```
dlibの中にあるget_frontal_face_detector関数の戻り値は顔を検出したときの座標を示しています。
![スクリーンショット 2022-10-15 11.01.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/82542a85-df70-ed35-734f-f9648a06af2f.png)

# 3.モデルの構築、予測精度の評価
顔部分抽出した画像を使って、モデルに学習させます。
今回は**tensorflow**でCNN(畳み込みニューラルネットワーク)を構築しました。
CNNに関しては下記のサイトが非常にわかりやすかったです。
https://postd.cc/how-do-convolutional-neural-networks-work/

今回扱う画像はカラーなので、画像のサイズは**タテxヨコxチャンネル数**です。
マスクなしの場合は0,マスクありの場合は1と判別させます。
訓練データはそれぞれ250枚ずつ、テスト用データは50枚ずつでモデルの学習を行いました。
(マスクありなしの画像の枚数に差があることでうまく認識ができなかったので同じ枚数で行いました)
モデルの構築、予測精度の評価を行うコード(make_model.py)はこちらです。
```python
import tensorflow.keras as keras
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from keras.optimizers import RMSprop
import matplotlib.pyplot as plt
import cv2, glob
import numpy as np

import warnings
warnings.simplefilter('ignore')


print("modules imported!>>>>>>>>>>>>>>>>>>>>>>")

#画像形式の指定
in_shape=(50, 50, 3)
nb_classes=2

#CNNモデル構造の定義
#入力層：50x50x3ch
#畳み込み層1: 3x3のカーネルを32個使う
#畳み込み層2: 3x3のカーネルを32個使う
#プーリング層1: 2x2で区切ってその中の最大値を使う
model=Sequential()
model.add(Conv2D(32, kernel_size=(3,3), activation='relu', input_shape=in_shape))
model.add(Conv2D(32, (3,3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

#畳み込み層3: 3x3のカーネルを64個使う
#畳み込み層4: 3x3のカーネルを64個使う
#プーリング層2: 2x2で区切ってその中の最大値を使う
model.add(Conv2D(64, (3,3), activation='relu'))
model.add(Conv2D(64, (3,3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))

#全結合層: 512
#出力層: 2(マスクありorなしの2値)
model.add(Flatten())
model.add(Dense(512, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(nb_classes, activation='softmax'))


#モデルのコンパイル
model.compile(
    loss='categorical_crossentropy',
    optimizer=RMSprop(),
    metrics=['accuracy']
)
print('model compiled!>>>>>>>>>>>>>>>>>>>>')

#画像データをNumpy形式に変換
x=[]
y=[]
def read_files(target_files, y_val):
    files=glob.glob(target_files)
    for fname in files:
        # print(fname)
        #画像の読み出し
        img=cv2.imread(fname)
        #画像サイズを50 x 50にリサイズ
        img=cv2.resize(img, (50,50))
        # print(img)
        x.append(img)
        y.append(np.array(y_val))

#ディレクトリ内の画像を集める
read_files("./image/mask_off/*.jpg", [1,0])
read_files("./image/mask_on/*.jpg", [0,1])
x_train, y_train=(np.array(x), np.array(y))

#テスト用画像をNumpy形式で得る
x,y=[[],[]]
read_files("./image/mask_off_test/*.jpg", [1,0])
read_files("./image/mask_on_test/*.jpg", [0,1])
x_test, y_test=(np.array(x), np.array(y))


#データの学習
hist=model.fit(
    x_train,
    y_train,
    batch_size=100,
    epochs=100,
    validation_data=(x_test,y_test)
)

#データの評価
score=model.evaluate(x_test, y_test, verbose=1)
print("正解率 = ",score[1], 'loss = ',score[0])

#モデルの保存
model.save('mask_model.h5')

#学習の様子を可視化
plt.plot(hist.history['accuracy'])
plt.plot(hist.history['val_accuracy'])
plt.title('Accuracy')
plt.legend(['train','test'], loc='upper left')
plt.show()

```
**model.add**でCNNのアーキテクチャ（層の種類、数、ユニット数などの詳細）を定義しています。
**model.compile**で損失関数、最適アルゴリズム、評価指標を定義しています。ここでCNNがモデルとして使えるようになります。
**model.save**でモデルをファイルとして保存することができます。別のファイルでモデルを読み込んでそのまま使用することが可能になります。これを知った時、感動しました。

学習の様子を可視化した結果がこちらです。
![Figure_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/5efbcfb4-eb86-d07b-8056-6839e96f7c57.png)
```
Epoch 100/100
4/4 [==============================] - 3s 701ms/step - loss: 3.8839e-04 - accuracy: 1.0000 - val_loss: 0.4414 - val_accuracy: 0.9667
3/3 [==============================] - 0s 48ms/step - loss: 0.4414 - accuracy: 0.9667

正解率 =  0.9666666388511658 loss =  0.44139522314071655
```
たまに精度が急降下するときがありましたが最終的な正解率は約97%でした。より高い精度を出したい場合はパラメータを調整するというより、画像データの選択が重要だと感じました。

前述の内容はうまくいってますが、はじめは枚数の指定なしで学習をしていました。
その結果、マスクなしの画像を極端に多かったことが原因で過学習していた上に、マスクをしていても顔の認識すらできませんでした。
![Figure_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/88a34a56-fefa-08bf-81dd-8eed611c03a6.png)

# 4.カメラからの入力画像をウィンドウに表示
モデルが完成したので、次に準備としてPC内蔵のカメラに映った様子をウィンドウ表示をしました。
ここでは、OpenCVの**VideoCapture関数**を使いました。
(下のコマンドはpython版のインストールコマンドです)
```
$pip list | grep opencv-python
opencv-python                 4.6.0.66
```
コードは下記の通りです。たったこれだけでPC内蔵のカメラを起動してそこで捉えたものをウィンドウに表示できるのは驚きでした。
```python
import cv2

# VideoCapture オブジェクトを取得します
# デフォルトでは0が内蔵カメラのIDだが、Snap Cameraを使用している影響でIDは1となっていた
# editorのterminalではなく、単体のterminalから実行するとできる
capture = cv2.VideoCapture(1)

# capture関数からカメラからの読み込みができているか判定
# isOpend関数の戻り値はTrue, False
# print(capture.isOpened())

while(True):
    #read関数の引数はretとframe
    #retは読み込みができているかの判定でTrue, False
    #frameはカメラが捉えた情報
    ret, frame = capture.read()

    #ウィンドウに表示
    cv2.imshow('Mask Checker',frame)

    #qをキーボードで入力するとカメラ停止
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

#メモリを解放する
capture.release()

#ウィンドウを閉じる
cv2.destroyAllWindows()
```
ただひとつ、ハマったことはVideoCapture関数の引数の指定です。デフォルトではPC内蔵カメラのIDは0とのことなので、引数を0にすると動くと思いきや、動きませんでした。私の場合、ZOOMでSnapCameraを使っているので、外部カメラと認識されていたみたいです。そのため引数を1にすると動かせることができました。

# 5.マスクの有無判別結果をウィンドウに表示
モデルとカメラの準備ができたので、いよいよマスクの有無判別です。ここで、仕様に関して先に定義しておきます。
* マスクをしている場合は、緑色の枠で顔部分を囲って"OK"と表示
* マスクをしていない場合は、赤色の枠で顔部分を囲って"NO MASK!"と表示
* 判定結果の画像をディレクトリに保存

コード(live_check.py)はこちらです。
```python
import tensorflow.keras as keras
from keras.models import load_model
import cv2, dlib, pprint, os
import numpy as np

#結果ラベル
res_labels=['NO MASK!', 'OK']
save_dir="./live"

#保存した学習モデルを読み込む
model=load_model('mask_model.h5')

#dlibを始める
detector=dlib.get_frontal_face_detector()

#webカメラから入力を開始
red=(0,0,255)
green=(0,255,0)
fid=1
capture=cv2.VideoCapture(1)

while True:
    #カメラの画像を読み込む
    ok, frame=capture.read()
    if not ok:
        break

    #画面を縮小表示する
    frame=cv2.resize(frame, (800,600))

    #顔検出
    dets=detector(frame, 1)
    for k,d in enumerate(dets):
        pprint.pprint(d)
        x1=int(d.left())
        y1=int(d.top())
        x2=int(d.right())
        y2=int(d.bottom())

        #顔部分を切り取る
        im=frame[y1:y2, x1:x2]
        im=cv2.resize(im, (50,50))
        im=im.reshape(-1, 50, 50, 3)

        #予測
        res=model.predict([im])[0]
        v=res.argmax()
        print(res_labels[v])

        #枠を描画, マスクない時(v=0)は赤で強調する
        color=green if v==1 else red
        border=2 if v==1 else 5
        cv2.rectangle(frame, (x1,y1), (x2,y2), color, thickness=border)

        #テキストを描画
        cv2.putText(frame, res_labels[v], (x1,y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, thickness=1)

    #結果を保存
    if len(dets)>0:
        if os.path.exists(save_dir):
            jpgfile=save_dir+"/"+str(fid)+".jpg"
            cv2.imwrite(jpgfile, frame)
            fid+=1

    #ウィンドウに画像を出力
    cv2.imshow('Mask Live Check', frame)

    #ESC or Enterキーでループ脱出
    k=cv2.waitKey(1)
    if k==13 or k==27:
        break

#カメラを開放
capture.release()
#ウィンドウを破棄
cv2.destroyAllWindows()

```

### マスクあり
![85.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/6020fae8-4cb9-a23b-64db-f8f2ab7b727a.jpeg)

### マスクなし
![109.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2810794/8b6a1065-4c4d-551f-d54c-680bc776a74d.jpeg)

部屋の明るさなど、環境によってうまく認識できないときがなくはないですが、うまくできていることが確認できました。

# まとめ
普段の生活で目の当たりにすることが多いので、やっていて楽しかったです。その反面、精度を上げるための画像選びの難しさと重要さを感じることができました。Pythonでは処理速度が遅いこともあり、ウィンドウに表示された様子にラグが見られました。リアルタイム性を重視するには処理速度の速いC++を用いる必要があると感じました。

# 【参考資料】
* [OpenCV 接続したカメラから動画を取得しよう](https://weblabo.oscasierra.net/python/opencv-videocapture-camera.html)
* [Pythonによる AI・機械学習・深層学習アプリのつくり方](https://www.socym.co.jp/book/1279)

# 【ソースコード】
https://github.com/SekiShota/book_mlearn