マスク着用有無認識アプリ with Swift

# 概要
- 被写体がマスクをしているかどうかを認識するアプリの開発
- **GitHubURL**
> https://github.com/Takata1124/movieDetection

# 1. 開発環境
- Xcode (13.2.1)
- GoogleColaboratory
- Jupyter-notebook

# 2. 構築
STEP1：マスク着用の顔画像データセットを作成
STEP2：マスク着用している場合とマスクを着用していない画像を認識するCoreMLモデルを作成する
STEP3：被写体の顔を認識するアプリをVisionFrameworkを構築する
STEP4：被写体の顔画像をクリッピングしてCoreMLモデルでマスク着用の有無を判断し、出力する。

# 3. マスク着用画像のデータセットを作成 (Jupyter-notebook)
マスク着用の顔画像データセットを無料で提供している媒体が存在していなかったため、openCVを利用して顔画像のみを切りとる方法でデータセットの作成を行なった。

### 必要なモジュールのインポート
```swift:/Python_file/jupyter_script/face_scraping_opencv.ipynb
import cv2
import glob
import matplotlib.pyplot as plt
import os
```
### マスク着用顔画像の切り抜きを集めた画像フォルダを作成
```swift:/Python_file/jupyter_script/face_scraping_opencv.ipynb
file = glob.glob('./mask_images/ *.jpeg') 

for num in range(len(file)):

    img = cv2.imread(file[num])
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    face_cascade = cv2.CascadeClassifier('./haarcascade_frontalface_default.xml')
    face = face_cascade.detectMultiScale(img_gray)

    i = -1

    if face == ():
        print("empty")
    else:
        print("detect faces")
        print(face)

        for x, y , w, h in face:
    #         cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2)
            i = i + 1
            im2 = img[y:y+h, x:x+w]
            cv2.imwrite('./total_triming_face_mask/face_image_num{}_face{}.jpeg'.format(num, i), im2)

    # plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
    # plt.show()
```
マスクしている人の写真を集めた./mask_imagesのディレクトリのファイル名をglobモジュールで取得し、file変数に渡している。
渡されたファイル名の画像を検索して各画像の顔画像をトリミングして、./total_triming_face_maskというディレクトリに顔画像を保存している。
結果トリミングされたマスク着用顔画像を下記示す。

<img width="200" alt="face_original_image_0.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2553887/4e9ea9d8-980b-8240-fed1-c6ab6fc5d7fd.jpeg">

### マスク着用顔画像の水増し
```swift:/Python_file/jupyter_script/Addition_images.ipynb
import glob
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
```
```swift:/Python_file/jupyter_script/Addition_images.ipynb
file = glob.glob('./image_folder/total_triming_face_mask/ *.jpeg')

for num in range(len(file)):
    
    img = cv2.imread(file[num])
#     上下反転
    img_flip_ud = cv2.flip(img, 0)
#     左右反転
    img_flip_lr = cv2.flip(img, 1)
#     上下左右反転
    img_flip_ud_lr = cv2.flip(img, -1)
    
    cv2.imwrite('./image_folder/total_triming_face_mask/face_original_image_1-{}.jpeg'.format(num), img_flip_ud)
    cv2.imwrite('./image_folder/total_triming_face_mask/face_original_image_2-{}.jpeg'.format(num), img_flip_lr)
    cv2.imwrite('./image_folder/total_triming_face_mask/face_original_image_3-{}.jpeg'.format(num), img_flip_ud_lr)
```
今回作成したマスク着用画像で、正面を向いた綺麗な画像があまり集められなかったため、画像の水増しを行なった。
画像の水増しはopenCVを用いた実施し、各マスク着用画像の上下反転、左右反転、上下左右反転処理を行なった画像を同一ファイルに保存した。
ここでは上下反転、上下左右反転画像を生成しているが、のちの画像認識のモデル学習において認識精度低下に影響したため、オリジナルの画像、左右反転画像のみをマスク着用顔画像のデータセットとして採用した。

### マスク着用無しの顔画像データセット

顔画像のデータセットは無償で提供されている媒体があるため、下記サイトの顔画像データセットを利用した。
- [Labeled Faces in the Wild](http://vis-www.cs.umass.edu/lfw/)

上記顔画像も精度向上のため、マスク着用顔画像同様、openCVを用いて顔画像をトリミング加工している。

# 4. CoreMLモデルの作成 (GoogleColaboratory)

### FrameworkとLibraryのインストール
```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
!pip install tensorflow==2.3.0 coremltools==5.1.0 pillow==7.0.0 h5py==2.10.0 keras==2.7.0
```
### モジュールのインポート
```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
import keras
import glob
import numpy as np
import keras
from keras.preprocessing.image import load_img, img_to_array
from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Dense, Dropout, Flatten
from keras.utils import np_utils #keras.utils.to_categoricalでエラーが出るので追加
from tensorflow.keras.optimizers import Adam # 「tensorflow.」を追加
import matplotlib.pyplot as plt
import time
import cv2
import random
import os
import coremltools
import coremltools as ct
```
今回、画像認識の認識モデルはkerasで作成している。

### データセットの読み込み
```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
train_data_path = './drive/MyDrive/image_folder'

image_size = 25
color_setting = 3

folder_name = os.listdir(train_data_path)

x_images = []
y_labels = []

for index, name in enumerate(folder_name):
    read_data = train_data_path + '/' + name
    if name == folder_name[0]:
        files = glob.glob(read_data + '/ *.jpg')
    else :
        files = glob.glob(read_data + '/ *.jpeg')

    for i, file in enumerate(files): 
        img = load_img(file, color_mode = 'rgb' ,target_size=(image_size, image_size))  
        array = img_to_array(img)
        x_images.append(array)
        y_labels.append(index)

x_images = np.array(x_images)
y_labels = np.array(y_labels)

x_images = x_images.astype('float32') / 255
```
ここでは作成したマスク着用有無の顔画像を読み込んで、画像データを配列に変換し、x_images配列に格納してる。
各画像の正解ラベルは配列のindexとして、y_labels配列に格納している。
また、kerasモデルに入力できるよう、配列をNumPy配列に変換している。

### データセットを学習部分とテスト部分に分割する
```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
index_count = len(y_labels)
count_array = []

for num in range(index_count):
    count_array.append(num)

random_count_array = random.sample(count_array, len(count_array))

x_images_shuffle = []
y_labels_shuffle = []

for i in range(index_count):
    y_labels_shuffle.append(y_labels[random_count_array[i]])
    x_images_shuffle.append(x_images[random_count_array[i]])
```
読み込んだデータセットはマスク着用画像と着用していない顔画像の配列が整頓された状態で読み込まれており、学習部分とテスト部分に分ける際に不都合であるため、再度配列を作成し、ランダムにデータを格納している。

```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
total_count = len(y_labels)
test_count = int(len(y_labels)/10) * 2
train_count = total_count - test_count

x_train = []
y_train = []
x_test = []
y_test = []

x_train = x_images_shuffle[test_count + 1 : total_count - 1]
y_train = y_labels_shuffle[test_count + 1 : total_count - 1]
x_test = x_images_shuffle[0 : test_count]
y_test = y_labels_shuffle[0 : test_count]

x_train = np.array(x_train)
y_train = np.array(y_train)
x_test = np.array(x_test)
y_test = np.array(y_test)

y_train = np_utils.to_categorical(y_train, folder_number)
y_test = np_utils.to_categorical(y_test, folder_number)
```
ここでは、学習データ；テストデータ = 8：2の割合で分割している。
np_utils.to_categoricalで正解ラベルを0, 1の配列に変換している。

### kerasモデルの学習
```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
model = Sequential()
model.add(Conv2D(16, (3, 3), padding='same',
          input_shape=(image_size, image_size, color_setting), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))               
model.add(Conv2D(128, (3, 3), padding='same', activation='relu'))
model.add(Conv2D(256, (3, 3), padding='same', activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))                
model.add(Dropout(0.5))                                   
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dropout(0.25))                                 
model.add(Dense(folder_number, activation='softmax'))

model.summary()

model.compile(loss='categorical_crossentropy',
              optimizer=Adam(),
              metrics=['accuracy'])
```
kerasモデルのニューラルネットワーク(入力層、畳み込み層、隠れ層、出力層)を構築。
ここで注意した点は、入力層の定義を画像データセットに合わせて調整した。
ここでの入力画像の定義は25×25のカラースケル3を設定。
出力層はマスク有無の2つなのでfolder_number=2で設定。

```swift:/Python_file/google_colab/Mask_mlmodel.ipynb
model.fit(x_train,y_train, batch_size=50, epochs=20, verbose=1)
```
バッチサイズ50、エポック20の条件下でモデルを学習。
学習のログを確認したかったため、 verbose=1で設定。

### モデルの変換 (kerasモデル→CoreMLモデル)
```swift:/Keras_to_mlmodel_new.ipynb
model.save('./model/mark_classification.h5')
```
kerasモデルをCoreMLモデルに直接変換する方法がなかったため、一度H5ファイルに一度変換する。

```swift:/Keras_to_mlmodel_new.ipynb
image_labels = [
    'mask_off', 
    'mask_on',
]

classifier_config = ct.ClassifierConfig(image_labels)
image_input = ct.ImageType(shape=(1, 25, 25, 3), scale=1/255)

mlmodel = ct.convert("./model/mark_classification.h5",  
                     inputs=[image_input],
                     classifier_config=classifier_config
                     )

mlmodel.save('./model/mask_model.mlmodel')
```
1段落目：出力されるラベルを定義。今回は'mask_off', 'mask_on'を配列として渡した。
2段落目：モデルの入力タイプに合わせてCoreMLモデルの画像タイプを定義。ここでは入力画像を25×25のカラースケール3で定義。作成したモデルにおいて正規化を行なっているので、scale=1/255を実施している。
3段落目：定義した内容でH5ファイルをCoreMLモデルに変換している。
4段落目：変換してモデルを保存。

# 5. 顔画像認識のVisionFrameworkの構築　(Xcode)
### モジュールのインポート
```swift:/movieDetection/ViewController/MaskVideoViewController.swift
import UIKit
import AVFoundation
import Vision
import SnapKit
import CoreML
```
基本的にApple標準のモジュールを使用しているが、layoutを整えるため、外部モジュールのSnapKitを使用している。

### モジュールのインポート

```swift:/movieDetection/ViewController/MaskVideoViewController.swift
private let captureSession = AVCaptureSession()
private lazy var previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)
private let videoDataOutput = AVCaptureVideoDataOutput()

private func setupCamera() {
        //front or back Camera
     let deviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back)
     if let device = deviceDiscoverySession.devices.first {
            
         if let deviceInput = try? AVCaptureDeviceInput(device: device) {  
             if captureSession.canAddInput(deviceInput) {  
                 captureSession.addInput(deviceInput)
                 setupPreview()
             }
         }
     }
}
    
func setupPreview() {
        
     self.previewLayer.videoGravity = .resizeAspectFill
     self.view.layer.addSublayer(self.previewLayer)
     self.previewLayer.frame = self.view.frame
        
     self.videoDataOutput.videoSettings = [(kCVPixelBufferPixelFormatTypeKey as NSString) : NSNumber(value: kCVPixelFormatType_32BGRA)] as [String : Any]  
     self.videoDataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera queue"))
     self.captureSession.addOutput(self.videoDataOutput)
        
     let videoConnection = self.videoDataOutput.connection(with: .video)
     videoConnection?.videoOrientation = .portrait
}
```
ここでは動画機能を実装している。カメラ画像はsetupCamera関数の中で.front,または .backでインカメラかアウトカメラかを選択している。

```swift:/movieDetection/ViewController/MaskVideoViewController.swift
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        
    imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)
        
    let faceDetectionRequest = VNDetectFaceLandmarksRequest(completionHandler: { (request: VNRequest, error: Error?) in
        DispatchQueue.main.async {
                //レイヤーの削除
            self.faceLayers.forEach({ drawing in drawing.removeFromSuperlayer() })
                
            if let observations = request.results as? [VNFaceObservation] {
                self.handleFaceDetectionObservations(observations: observations)
            }
        }
    })
        
    let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: imageBuffer!, orientation: .leftMirrored, options: [:])
        
    do {
        try imageRequestHandler.perform([faceDetectionRequest])
    } catch {
        print(error.localizedDescription)
    }
}
```
ここでは撮影した動画よりフレームごとに出力されるCMSampleBufferをCVpixelBufferに変換し、VNImageRequestHandlerに渡している。渡されたハンドラーがVNDetectFaceLandmarksRequest処理を実行し、顔画像部分の座標とサイズをobservationsで返している。

```swift:/movieDetection/ViewController/MaskVideoViewController.swift
private func handleFaceDetectionObservations(observations: [VNFaceObservation]) {
        
    for observation in observations {

        let faceRectConverted = self.previewLayer.layerRectConverted(fromMetadataOutputRect: observation.boundingBox)
        let faceRectanglePath = CGPath(rect: faceRectConverted, transform: nil)  
        let faceLayer = CAShapeLayer()
        faceLayer.path = faceRectanglePath
        faceLayer.fillColor = UIColor.clear.cgColor
        faceLayer.strokeColor = UIColor.green.cgColor   
        self.faceLayers.append(faceLayer)
        self.view.layer.addSublayer(faceLayer)
            
        let videoRect = previewLayer.layerRectConverted(
            fromMetadataOutputRect: CGRect(x: observation.boundingBox.minX,
                                           y: observation.boundingBox.minY,
                                           width: observation.boundingBox.width,
                                           height: observation.boundingBox.height))
        
        let pixcelWidth = CGFloat(CVPixelBufferGetWidth(imageBuffer!))
        let pixcelHeight =  CGFloat(CVPixelBufferGetHeight(imageBuffer!))
            
        let widthScale = pixcelWidth/(view.frame.width)
        let heightScale = pixcelHeight/(view.frame.height)
            
        let img_width: CGFloat = videoRect.width
        let img_height: CGFloat = videoRect.height
        let img_x: CGFloat = videoRect.minX
        let img_y: CGFloat = videoRect.minY
            
        let ciImage = CIImage(cvPixelBuffer: imageBuffer!)
        uiImage = UIImage(ciImage: ciImage)
            
        let img = uiImage.cropping(
            to: CGRect(x: img_x * widthScale, y: img_y * heightScale,
                       width: img_width * widthScale, height: img_height * heightScale))
            
        guard let img = img else { return }    
        guard let buffer = img.getCVPixelBuffer(size: CGSize(width: 25, height: 25)) else {
            return
        }
            
        DispatchQueue.global(qos: .userInteractive).async {
                
            guard let output = try? self.coreMLModel.prediction(conv2d_27_input: buffer) else {
                return
            }
                
            DispatchQueue.main.async {
                    
                let didLabel = output.classLabel  
                self.view.addSubview(self.judgeLabel)
                self.judgeLabel.snp.makeConstraints { make in
                        
                    make.size.equalTo(150)
                    make.centerX.equalToSuperview()
                    make.top.equalTo(self.view.snp.top).offset(50)
                }
                    
                self.judgeLabel.text = didLabel
            }
        }
    }
}
```
返されたobservationsを用いて1段落目でfaceLayerを生成し、顔画像周りに枠線を表示している。
次に顔画像のみをトリミングしてCoreMLモデルに渡す処理を記述している。
顔画像を動画よりトリミングする上での問題点は、出力されるCVpixelBufferと実際に画面に表示されているViewのサイズが異なっている部分であり、ここではwidthScale, heightScaleとしてサイズの違いを作成し、Scaleに合わせて生成された動画画像より顔画像をトリミングしている。
トリミングされた画像は再度25×25のCVpixelBufferに変換され、CoreMLモデルに渡している。
渡されたCoreMLモデルは、画像認識を行い、渡された顔画像がマスクをしているかどうかを判断し、didLabelとして出力している。

# 6. アプリ実装の結果
<img width="300" alt="face_original_image_0.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2553887/8cf6629b-3941-b739-9f93-17b40867a461.png">

アプリを起動し、マスクを着用している写真を撮影した結果、マスクをしているという正しい表示が出力された。しかしながら、マスクをしている画像は顔画像としてVisionFrameworkが認識することが難しいせいか、顔画像の認識が途切れ途切れの状態になっていた。


# 7. 今後の展開
- 今回、簡易的ながらマスク着用の有無を判断するアプリケーションが作成できたので、別でアプリを開発する際に、顔画像の認識技術をとりいれてみたい。

# 参考文献
- [すぐに使える!業務で実践できる! PythonによるAI・機械学習・深層学習アプリのつくり方 TensorFlow2対応](https://www.amazon.co.jp/%E3%81%99%E3%81%90%E3%81%AB%E4%BD%BF%E3%81%88%E3%82%8B-%E6%A5%AD%E5%8B%99%E3%81%A7%E5%AE%9F%E8%B7%B5%E3%81%A7%E3%81%8D%E3%82%8B-Python%E3%81%AB%E3%82%88%E3%82%8BAI%E3%83%BB%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92%E3%83%BB%E6%B7%B1%E5%B1%A4%E5%AD%A6%E7%BF%92%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E3%81%A4%E3%81%8F%E3%82%8A%E6%96%B9-TensorFlow2%E5%AF%BE%E5%BF%9C-%E3%82%AF%E3%82%B8%E3%83%A9%E9%A3%9B%E8%A1%8C%E6%9C%BA/dp/4802612796/ref=sr_1_14?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&crid=3KYR8RXK1RK2Z&keywords=%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92+python&qid=1646128457&sprefix=%E6%A9%9F%E6%A2%B0%E5%AD%A6%E7%BF%92+python%2Caps%2C175&sr=8-14)
- [Face Detection and Face Tracking on iOS](https://www.youtube.com/watch?v=cRMMRzk3oKY&list=LL&index=55&t=1534s)