【RasberryPi4+Python】表情認識をトリガーに曲を再生してみた

<iframe width="560" height="315" src="https://www.youtube.com/embed/iitltjo0WXg?si=FRvCl3vgiwXEbkG8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# 概要
* Linuxの勉強も兼ねてRasPiを使って何かやってみたい
* 画像処理系のタスクを応用して楽しいことができないか

ということをモチベーションから行き着いたのが、
「**ユーザーをカメラで撮り続けて、表情から感情を読み取り、音楽を流すシステム**」 です。

実現にあたり、下記の5ステップを踏みました。
0.RasPiのセットアップ
1.顔検出器の作成
2.表情認識モデルの作成
3.インターフェース制御設計
4.サウンドの制御

本記事では各ステップを説明していきます。
環境構築はこちらのREADMEを参考にしてください。
https://github.com/yutobomberair/RasPi_Symphonia

# システム構成
* システム構成は下記の通りです。
![IMG_4709.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3539329/3ae36eca-88e8-401e-b62e-039732c9a934.jpeg)
1. Webカメラを使って画像を取得し、RasPiへ入力
2. RasPi内部で顔を検出し、表情認識により感情ラベルを推定
3. 推定結果が"楽しい"だったとき、曲をスピーカーから再生

以上！！！

# 機材リスト
* 総額¥20,425(予算2万で考えていたのでギリギリ許容範囲内！)
* 下記に加えてキーボードとマウスが別途必要になります
* 電源用のアダプタが本体と別売りなので、電源がセットになっているケースを購入しました

|物品|価格|
|:-----:|:-----:|
|Raspberry Pi4 ModelB 4GB|¥9,780|
|Miuzei ケース+冷却ファン+ヒートシンク(電源付)|¥1,970|
|SanDisk microSD カード 32GB|¥760|
|Webカメラ ウェブカメラ フルHD 1080P|¥1,935|
|Creative Pebble V3 ブラック|¥5,480|
|microHDMI(オス)toHDMI(メス)変換|¥500くらい|

参考リンクです
https://amzn.asia/d/58kwqeL
https://amzn.asia/d/8HHEoWs
https://amzn.asia/d/d3angZE
https://amzn.asia/d/g2x76rM
https://amzn.asia/d/awdBWVO
※出力変換器のみノジマで適当なものを見繕いました
※私は一度miniHDMIを間違えて買ってしまいました💦

# 実装
## 0. RasPiのセットアップ
* 前準備
    * microSDカードに**書き込む前に名前をつけておくと良いです**
    * 各自のPCでRasPi用のOSファイルをmicroSDカードに書き込み、RasPiに挿して起動することでセットアップが開始します
    * OSの書き込み先をPCのOSが入っているメモリを選んでしまうとデータが飛んでしまうので要注意です
</br>

* 各自PCからOSのインストール
    * Raspberry Pi Imager をインストール  
        https://www.raspberrypi.com/software/
    * Imagerを起動し、以下を選択
        OS：Raspberry Pi OS（64-bit）
        ストレージ：microSDカードを選択
        今回はopencvをガンガン使うので64bit推奨です
    * 歯車マークから以下を設定
        ホスト名・ユーザー名・パスワード
        Wi-Fi設定（国設定:日本ならJP）
        SSH有効化（できれば秘密鍵を設定するのが良い）
    * 「書き込み」ボタンを押す
        ※microSDカードにOSが書き込まれます（5〜10分）
        
https://qiita.com/yutobomberair/items/dde1f05e6c1789426b73
       
* RasPiの初期設定
    * 前のステップでインストールしたOSが入っているmicroSDカードをRasPi本体に挿して起動します
    * 初期設定はRasPi上で行う必要があるので、モニタ/マウス/キーボードも接続してください
    * とはいっても、インストール時に諸々重要事項は設定済みなので、私は立ち上がったなーくらいで完了としました
        * gitを使って開発したい方は下記の設定が必要になります
        ※ なしでもcloneまではできます
            * `git config --global user.name "名前"`
            * `git config --global user.email "メールアドレス"`
</br>

* SSH等の設定
    * 自分のPCからSSH接続できるので推奨です（開発が楽）
    * `ssh ユーザ名@IPアドレス`で接続できます
        * `whoami`でユーザ名を
        * `hostname -I`でIPアドレスを調べられます

## 1.顔検出器の作成
### 概要（FaceDetector.py）
* 画像全体から顔部分を検出します
* opencvのcascade分類器を使います
* 正面を向いてさえいればある程度検出できる
* 横顔は弱い...

### 【処理フロー】
* 前処理で明暗のバランス調整だけ行います
* （↑平滑化フィルタをかけたり、膨張/縮小処理を入れたりしてみましたが効果がなかったのでシンプルにしました）
* cascade分類器からdetectMultiScale関数を実行してで顔を検出
* 検出された領域を切り取り
* 顔が検出されなかった場合同じサイズの全黒画像を代わりに用意します
    * 検出の有無に関わらず後ろの認識処理は走らせたいからです
* 顔領域画像or全黒画像を返します

```FaceDetectror.py
import cv2
import numpy as np

class FaceDetector():
    def __init__(self):

        CASCADE_FILE= "models/haarcascade_frontalface_default.xml"
        self.CLAS = cv2.CascadeClassifier(CASCADE_FILE)
        super().__init__()

    def preproc(self, img, iterations=5, kernel_size=3):
        processed = img.copy()
        processed = cv2.equalizeHist(processed) # 明暗のバランス調整
        return processed

    def face_detection(self, img, size=128, scaleFactor=1.1, minNeighbors=5, flags=0, minSize=(0, 0), iterations=10, kernel_size=3):
        img_processed = self.preproc(img, iterations, kernel_size)
        face_key = self.CLAS.detectMultiScale(img_processed, scaleFactor = scaleFactor, minNeighbors=minNeighbors, flags=flags, minSize = minSize)
        if len(face_key) == 0:
            face_img = np.zeros((size, size), dtype=np.uint8)
        else:
            face_img = self.__clip_coor(img, face_key) # 切り取る画像はオリジナル
        return img_processed, face_img

    def __clip_coor(self, img, face_key, offset=50):
        width, height = img.shape[1], img.shape[0]
        x, y, w, h = face_key[0][0], face_key[0][1], face_key[0][2], face_key[0][3]
        if x > offset:
            x -= offset
        if y > offset:
            y -= offset
        if x + w + offset < width:
            w += offset
        if y + h + offset < height:
            h += offset
        print()
        return img[y:y+h, x:x+w]
```

### 備忘録
* detectMultiScale(image, scaleFactor=1.1, minNeighbors=3, flags=CV_HAAR_DO_CANNY_PRUNING, minSize=(0, 0))
    * image: 入力画像
    * scaleFactor: 画像スケールにおける縮小量
    * minNeighbors: オブジェクトを表す矩形を構成する近傍矩形の最小数(min_neighbors -1 より少ない数の矩形しか含まないグループは，全て棄却されます)
    * flags: 処理モード．このフラグが指定されている場合，関数はCannyエッジ検出器を利用し，非常に多くのエッジを含む（あるいは非常に少ないエッジしか含まない）画像領域を，探索オブジェクトを含まない領域であると見なして棄却
    * minSize: 探索窓の最小サイズ
</br>
    
* scalerFactorの値を振った実験(webページより)
    * 値を1.01~1.71まで0.1刻みで増加させる
    * 増加とともに、顔以外の検出数が減少する(=誤検出が減る)
    * 増加とともに、顔の検出数が減少する(=見落としが増える)
    http://workpiles.com/2015/04/opencv-detectmultiscale-scalefactor/

## 2.表情認識モデルの作成
### 概要
* EfficientNetB0を使用して表情認識モデルを作成
* データセットにはFER2013を使用
    * 7クラスの表情グレースケール画像データセット
    * {"Angry", "Disgust", "Fear", "Happy", "Sad", "Surprise", "Neutral"}
    *  https://www.kaggle.com/datasets/msambare/fer2013
* validation_accuracyが80%くらいで妥協
* 分類自体は精度通りおおよそできているが、処理がかなり遅い
* 本来はきちんと量子化学習を行うべき！
* （こだわると一生捕まってしまうので今後の課題としています）

### 学習のポイント
* 入力画像サイズの確保
* 学習データの均一化
* 似ている推論ラベルをマージして3クラス分類に変更{"happy", "negative", "neutral"}
* 取り組みの詳細は下記にまとめました

https://qiita.com/yutobomberair/items/dc58ddebd5c99a534270

#### 補足
 EmotionalClassifierLearning.py: 学習用スクリプト（Symphoniaの動作には不要）
 EmotionalClassifierClassifier.py: 推論用スクリプト

## 3.インターフェース制御設計
### 概要（InterfaceController.py）
① カメラから画像信号を取得
② 画像から顔部分を検出し、認識器で表情を推定
③ スピーカーに音声信号を送る/止める
の3つの処理をRasPiで制御しながら実行していきます

### ルールの設計(capture関数に実装)
* カメラ入力待ち→顔検出→表情認識までは常時実行
* 認識処理→音楽再生処理への遷移
    * 遷移の条件は、表情認識結果が"連続で楽しい"となった回数が閾値を超えること
    * その閾値は別途設定可
* 音楽再生処理に遷移したら次項のサウンド制御を実行
* ループするかどうかを選択

```InterfaceController.py
import cv2
import numpy as np
from EmotionClassifierInference import EmotionClassifierInference
from FaceDetector import FaceDetector
from JukeBox import JukeBox

"""
This tool plays cheerful music when a happy facial expression is detected.
[System Flow: camera -> FaceDetector -> EmotionClassifier -> JukeBox]

camera: web-cam(HD 1080P)
FaceDetector: Detect the face and crop the facial region to the specified input size.
EmotionClassifier: Recognize the facial expression of the input face.
JukeBox: Play music.
"""

class InterfaceContoroller(FaceDetector, EmotionClassifierInference, JukeBox):
    def __init__(self):
        super().__init__()

    def capture(self, input_size=48, emotion_th=10, model_type="quantize"):
        assert model_type == "quantize" or model_type == "float"
        emo_cnt = 0
        cap = cv2.VideoCapture(0, cv2.CAP_V4L2)
        while cap.isOpened():
            ret, frame = cap.read()
            if not ret:
                break
            img = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            _, face = self.face_detection(img, input_size) # If no face is detected, a completely black image is returned.
            label = self.classifier(face, input_size) if model_type=="quantize" else self.classifier_float(face, input_size)
            # If the target expression is detected continuously, the music will start playing.
            if label == "happy":
                emo_cnt += 1
            else:
                emo_cnt = 0
            if emo_cnt >= emotion_th:
                emo_cnt = 0
                flag = self.control_music()
                if flag:
                    break
            print(label, emo_cnt)
            
        cap.release()
        cv2.destroyAllWindows()

    def control_music(self):
        self.play_with_interrupt()
        flag = 0 if input("Continue? -> yes/no: ") == "yes" else 1
        return flag
```

## 4.サウンドの制御
### 概要（JukeBox.py）
* pygameを使用して音声制御を行う
* 音声ファイルを再生している途中で中断できるように実行スレッドを分けて実行する

### やりたいこと
* 再生命令を受けて音声ファイルを再生する
* 再生の途中でユーザがキーを押したら中断できるようにしたい
    * メインスレッドで「キー入力待ち」をしつつ
    * 別スレッドで「音楽再生」を実行
	* メインスレッドでキーが押されたら停止用の関数を呼ぶ

### 【処理フロー】
1. play_with_interrupt()が呼ばれる
2. スレッドを立ち上げて__play_music()を実行
3. メインスレッドではinputでEnterが入力されるのを待つ
4. 入力されたら__stop_music()が実行され再生が止まる 
```JukeBox.py
import pygame
import threading
import time

class JukeBox:
    def __init__(self, music_path="./sounds/BGM.mp3"):
        self.music_path = music_path
        self.playing = False  # Whether music is currently playing
        self.music_thread = None

    # This function plays music (executed in a separate thread)
    def __play_music(self):
        pygame.mixer.init()
        pygame.mixer.music.load(self.music_path)
        pygame.mixer.music.play()
        self.playing = True
        print("Music is playing... (Press Enter to stop)")

        # Loop while music is playing
        while self.playing and pygame.mixer.music.get_busy():
            time.sleep(0.1)

    # Main control function: starts music and waits for user to press Enter
    def play_with_interrupt(self):
        self.music_thread = threading.Thread(target=self.__play_music)
        self.music_thread.start()  # Start music in a separate thread

        # Main thread waits for user input
        input(">> Press Enter to stop the music: ")
        self.__stop_music()

    # Function to stop music playback
    def __stop_music(self):
        if self.playing:
            pygame.mixer.music.stop()
            pygame.mixer.quit()
            self.playing = False
            print("Music stopped.")
```

# 感想
* RasPi触って何か作ってみるという目的は達成できた
* ただ、ここまできたら認識処理の軽量化を何としても達成したい🔥
* 久々にGoogleColabを使ってみたら制約がたくさんついていて不便だった...次は課金か？
* あれ、Linuxの勉強ができてない…
* 短い期間で企画→開発→広報(商品紹介動画作成)まで色んな領域に手をつけてみたが、動画作ってる時が一番たのしかった^^;