MediaPipeでじゃんけん検知 備忘録

#概要
機械学習の教育用資料を手弁当で作成した。CV系は食指が動かないのでCouseraのDNN授業以外で触らなかったが、興味を持ってもらうために使わざるを得なくなった。まぁ、なかなか面白かったので残す。

- 実施期間: 2021年11月
- 環境：Python3.8.12 (miniconda)
- ラップトップPC 10thGEN Corei5 memory 8GB GPUなし
- OS: **[Zorin OS 16 Pro](https://zorin.com/os/download/) (オススメOS!!)**
- 主パケージ：mediapip, pycaret, opencv, scikit-learn


全ソースは下記に置いた。

https://github.com/ihmon/JankenDetection

※Zorin OSはどうやってもラップトップでデュアルブートでインストールできなかったのでWinows10に上書きしてやった。でも全く後悔ないほどかっこいいOS。もうWindowsなんかいらんだろう？

##もくじ
流れは下記の通り
1. MediaPipeのHandsで指の関節位置をリアルタイムに取得
2. Training用にグーチョキパーのポーズでを取得
3. 処理の早いモデルを選定
4. リアルタイムに関節位置でポーズの推定
5. Scikit Learnでモデル再作成・実装変更

![Screenshot from 2021-12-11 11-32-58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/32b4bc39-78ab-0a14-bba4-5677fb4ac4cc.png)

###0. 準備
CondaでもPythonでも良いので仮想環境を用意する。小生はPython versionを指定したいのでCondaを使用する。
仮想環境をPython3.8で作成し下記をインストールする。PyCaretはconda-forgeからもインストールできるようだがインストール中にエラーが発生したのでpipでインストールした。

```python
pip install mediapipe
pip install pycaret[full]
```

仮想環境作成直後に、上記の２パケージをインストールすること。opencvやnumpy,scipyといった基本的な依存パケージは同時にインストールされる。
コードを実行して足りないもの（エラーが出るもの）は別にcondaやpipでインストールすれば良い。

###1. MediaPipeで手の物体検知
物体検知にはGoogle様のMediaPipeを使用させていただく。提供されている全solutionのモデルのfine tuneはできない。

https://google.github.io/mediapipe/

今回はこの中のHandsというsolutionで指関節の位置推定を行う。詳細は下記参照だが、やっていることはまず画面上のどこに手があるのかを手のひら検知で大まかに推定し、更に40k枚の手の絵でTrainしたモデルで関節位置を推定する。すごいのは2Dなのにx,y,**z**の3D座標で返してくれる点。

戻ってくる点は下図の通り。z座標は手首(index=0)を基準に手前がマイナス奥にマイナスとなる。もちろん2Dから推定するのでx,yに比べ精度は良くないが今回は使用する。zを使うかどうかで精度がどう変わるか評価しても面白そう。
なお、関節位置の推定だけなら前述のPCスペックでも30~40fpsのキャプチャができている。

![Screenshot from 2021-12-05 16-14-52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/db009d0e-3e4a-b088-2264-7fa2734c489b.png)

https://google.github.io/mediapipe/solutions/hands#hand-landmark-model

他のプロジェクトでも使い回せるように手を検知するクラスを定義する。

```python
import os
import math
import cv2
import numpy as np
import mediapipe as mp
import time
from pycaret.classification import *

class handDetector():
    # def __init__(self, mode=False, maxHands=2, detectionCon=0.5, trackCon=0.5):
    def __init__(self, mode=False, maxHands=2, modelComplexity=1, detectionCon=0.5, trackCon=0.5):
        self.mode = mode
        self.maxHands = maxHands
        self.modelComplex = modelComplexity
        self.detectionCon = detectionCon
        self.trackCon = trackCon

        self.mpHands = mp.solutions.hands
        self.hands = self.mpHands.Hands(self.mode, self.maxHands, self.modelComplex,
                                        self.detectionCon, self.trackCon)
        self.mpDraw = mp.solutions.drawing_utils

    def findHands(self, img, draw=True):
        imgRGB = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        self.results = self.hands.process(imgRGB)
        # print(results.multi_hand_landmarks)

        if self.results.multi_hand_landmarks:
            for handLms in self.results.multi_hand_landmarks:
                if draw:
                    self.mpDraw.draw_landmarks(img, handLms,
                                               self.mpHands.HAND_CONNECTIONS)
        return img

    def findPosition(self, img, handNo=0, draw=True):
        xList = []
        yList = []
        zList = []
        bbox = ()
        lmList = []
        if self.results.multi_hand_landmarks:
            myHand = self.results.multi_hand_landmarks[handNo]
            for id, lm in enumerate(myHand.landmark):
                # print(id, lm)
                h, w, c = img.shape
                # cx, cy = int(lm.x * w), int(lm.y * h)
                cx, cy, cz = int(lm.x * w), int(lm.y * h), int(lm.z * w)
                xList.append(cx)
                yList.append(cy)
                zList.append(cz)
                # print(id, cx, cy)
                lmList.append([id, cx, cy, cz])
                if draw:
                    cv2.circle(img, (cx, cy), 5, (255, 0, 255), cv2.FILLED)

            xmin, xmax = min(xList), max(xList)
            ymin, ymax = min(yList), max(yList)
            zmin, zmax = min(zList), max(zList)
            bbox = xmin, ymin, zmin, xmax, ymax, zmax
            if draw:
                cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[3], bbox[4]), 
                                (0, 255, 0), thickness = 2)

        return lmList, bbox
```

findPosition()の戻り値は下記のように内側のlistが[関節のindex, x, y, z]となっている。
[[0, 113, 444, 0], [1, 182, 458, -32], [2, 243, 438, -53], ..., [20, 106, 203, -119]]



なお、このhanddetectorクラスは下記を参考にさせていただいた。他にも面白いことをいろいろされているので、興味があればチェック。

https://www.youtube.com/watch?v=9iEPzbG-xLE

Training datasetにはこの座標ではなく手首(index=0)から残りの各20点までの距離を使用する。手が傾いていたり裏返しになっていたり左右の手が違っていたりしても距離なら解決できると考えた。
またソースからわかるとおり、手とカメラの距離が違っても手を囲むbounding boxを使用し正規化しているので手がカメラから離れても近づいても、手首から各20点の距離はほぼ変わらなくなる。

###2. グーチョキパーのポーズを取得

スキャン中に3種類の手のポーズを変えながら、下記で各20個の距離を求める。

```python
# 手首の座標を原点とし、20点との距離を求める。
def get_distance(wklst):
    lst_o = list([wklst[0][1], wklst[0][2], wklst[0][3]])      # 手首のx,y,z
    lst_dist = []
    for i in range(1,21):
        lst_t = list([wklst[i][1], wklst[i][2], wklst[i][3]])  # 手首以外の20箇所のx,y,z
        length = math.sqrt(sum((px - qx) ** 2.0 for px, qx in zip(lst_o, lst_t)))
        lst_dist.append(length)    # 手首の座標からの20個の距離
        # print(lst_t, length)
    # print(lst_dist)
    lst_dist.append(0)
    # lst_dist.append(lst_dist)
    return lst_dist
```

これを3ファイルに出力する。データは一度取れれば良いので、コードはmain()の中で決め打ちでベタに書いている。データ取得が終わればコメントアウトしておけば良い。

```python
            # Training dataset収集用　100個溜まったら書き出す。
            # ポーズごとにこのmain()を実行する。
            lmList_save.append(lmList)
            if wkCounter > 100:
                wkPath = os.path.join(myPath, 'pose_{0}.dat'.format(
                                datetime.datetime.now().strftime('%Y%m%d_%H%M%S')))
                with open(wkPath, 'wb') as f:
                    pickle.dump(lmList_save, f)
                lmList_save = []
                wkCounter = 0
            wkCounter += 1
```

pickle使ってlist型のバイナリで出力したので、下記でポーズごとにcsvファイルに書き出す。各行は20個の距離と最後にラベル('cls')列に0,1,2が書き出される。

```python
def maketrainingdata():

    for label in range(3):  # 0: Gu-, 1: Choki, 2: Paa-
        myPath = os.path.join(os.getcwd(), 'pos_data_{}'.format(label) , 'pos_*.dat')
        rtnList = readRawcoordinate(label, myPath)
        if label == 0:
            clmn_lst = [str(i) for i in range(1,21)]
            clmn_lst.append('cls')
            df = pd.DataFrame(data=rtnList, columns=clmn_lst)
        else:
            wkdf = pd.DataFrame(data=rtnList, columns=clmn_lst)
            df = df.append(wkdf, ignore_index=True)

    df.to_csv('gochopa.csv')
```

###3. 処理の早いモデルを選定
20個の距離データとラベルからリアルタイムにポーズの推定を行う。CPUでもclassificationできるように動作が早いモデルを選択したい。数フレーム誤認識しても遊びなので精度は許容できる。
PyCaretは処理速度も出力してくれるので、PyCaretでモデルを決めることにする。全コードはMakeModel.ipynbに記載している。
前章で出力したcsvファイルには3ポーズで計3000個超のラベル付きデータが入っている。PyCaretを使用するので前処理は行わない。

ざっとコードを確認する。
X, yは不要なのでtrain, testのDataFrameに分け、setup()で前準備を行う。

```python
seed = 27
df_train, df_test = train_test_split(df, train_size=0.8, random_state=seed)

exp_name = setup(data=df_train, target='cls', test_data=df_test, session_id=123)
```

評価した全modelの結果を表示してもらう。

```python
best_model = compare_models()
```

![Screenshot from 2021-12-11 12-06-33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/25d5e52a-59a7-0291-c003-ceda76492145.png)

問題が単純でデータもきれいなのでaccはどのモデルもすごく良い。目的は処理速度なのでTT(sec)を見ると最速は0.0140secのRidge Classifierであることがわかる。このモデルの動作原理はscikit learnで確認できるがmultilabelを[-1, 1]の数値に置き換えて回帰させているくらいしか書かれておらず、どうも理解できない。またscikit learnのライブラリではROCが計算できないようなので出力は確率ではないみたい。PyCaretのAUCが0.0になっているのはこのためで、Ridge Classifierで問題はないと判断した。

PyCaretのチュートリアルに従いモデルを書き出すところまで一気に書く。test datasetを使った精度やconfusion matrixも途中確認したが、精度は100%のため転記は割愛する。

```python
ridge = create_model('ridge')
tuned_ridge = tune_model(ridge, n_iter = 30)
plot_model(tuned_ridge, plot = 'confusion_matrix')
final_ridge = finalize_model(tuned_ridge)
save_model(final_ridge,'Final_Ridge_Model')
```

一応、抜き取りでラベルを確認する。

```python
data_unseen = df_test.iloc[0:30]
unseen_predictions = predict_model(final_ridge, data=data_unseen)
unseen_predictions
```

![Screenshot from 2021-12-11 12-21-29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/688955ab-ef9b-1c7c-ac8b-70b2d86938ba.png)


###4.リアルタイムに関節位置でポーズの推定
前章で出力したPyCaretのモデルをmain()で読み込みスキャンのたびにポーズのclassificationさせる。
コードの要処はこんな感じ。

1. ridge_clsにPyCaretで作成したモデルを読み込んでいる。
2. trainingにDataFrameを使用したのでpredictionもDataFrameで入れる必要がある。columnに1~20, clsを設定しdfを定義している。
3. handDetectorクラスを呼んでいる。じゃんけんは片手なのでmaxHands=1、自信をもって関節位置を出してほしいので、detectionCon, trackConは0.8にしている。
4. 画面に３ポーズの結果を表示したいので'Label'を確保


```python
def main():
    pTime = 0
    cTime = 0

    result_dict = {0: 'Go-', 1: 'Choki', 2: 'Paa-'}

    # modelの読み込み
    ridge_cls = load_model('Final_Ridge_Model')        # 1.

    clmn_lst = [str(i) for i in range(1,21)]           # 2.
    clmn_lst.append('cls')
    df = pd.DataFrame(columns=clmn_lst)

    cap = cv2.VideoCapture(0)
    wCam, hCam = 640, 480
    cap.set(3, wCam)
    cap.set(4, hCam)

    detector = handDetector(
                   maxHands=1, detectionCon=0.8, trackCon=0.8)    # 3.

    while True:
        success, img = cap.read()
        img = detector.findHands(img)
        lmList, _ = detector.findPosition(img, draw=False)
        if len(lmList) != 0:
            dist_lst = get_distance(lmList)
            df.loc[0] = dist_lst

            # PyCaretでRidgeClassifier
            df_pred = predict_model(ridge_cls, data=df)
            rtn = df_pred.loc[0, 'Label']            # 4.

            cv2.putText(img, "You:" + result_dict[rtn], 
                        (10, 70), cv2.FONT_HERSHEY_PLAIN, 3, (255, 0, 255), 3)

        cTime = time.time()
        fps = 1 / (cTime - pTime)
        pTime = cTime
        cv2.putText(img, str(int(fps)), (10, 100), cv2.FONT_HERSHEY_PLAIN, 2,
                    (255, 0, 255), 3)

        ctime2 = time.time()
        cv2.imshow("Image", img)
        key = cv2.waitKey(1)
        # Press esc or 'q' to close the image window
        if key & 0xFF == ord('q') or key == 27:
            cv2.destroyAllWindows()
            break
```

画面に手が入ると関節位置が表示され、即座にポース推定がPyCaretで実行されるが、とにかく遅い。
フレームレートがPyCaretのなし・ありで、30~40fpsが6fpsくらいに低下し、ラグが大きく実用性に欠く。
そもそもAutoMLは良さげなモデルのあたりをつけるためのツールなのでこれで実装Predictionしようというのに無理があるのだろう。

###5. Scikit Learnでモデル再作成・実装変更
RidgeClassifierが速いことはわかったので、これをscikit learnで作成しmain()で使うモデルを変更する。前処理もモデルとclassificationのモデルを分けたくなかったので、pipelineを使用した。また、main()の変更範囲が最小で済むようにdatasetはDataFrameを使用している。
これもMakeModel.ipynbに記載しているが、全体をざっと書き出すとこんな感じ。

```python
import pandas as pd
import numpy as np
import pickle
from sklearn.linear_model import RidgeClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

df = pd.read_csv('gochopa.csv', index_col=0)
X = df.iloc[:,:20]
y = df.iloc[:,20]
X_train, X_test, y_train, y_test = train_test_split(
                                       X, y, test_size=0.2, random_state=27)
# 前処理はStandardScalerとする。
pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('classifier', RidgeClassifier())])

pipe.fit(X_train, y_train)

print('Training set score: ' + str(pipe.score(X_train,y_train)))
print('Test set score: ' + str(pipe.score(X_test,y_test)))

with open('myPipe.dat', 'wb') as f:
    pickle.dump(pipe , f)
```

Training set score: 1.0
Test set score: 1.0

やはり精度はPyCaret同様、1.0で申し分ない。
pilelineで作成したモデルをmyPipe.datとして保存し、これをmain()で読み込むように下記のように変更する。

```python
    # modelの読み込み
    # ridge_cls = load_model('Final_Ridge_Model')
    # ridge_cls = load_model('Final_QDA_Model')
    # print(ridge_cls)
    with open('myPipe.dat', 'rb') as f:
        ridge_cls = pickle.load(f)
```

フレームレートはscikit learnのなし・ありほぼ変わりなく、ラグも感じられない。

処理速度については決してPyCaretをディスっているわけではない。非常に強力で便利なツールなので[チュートリアル](https://github.com/pycaret/pycaret/tree/master/tutorials)の少なくともbiginner向けはすべて抑えておくべきだと思う。

以上