Mediapipeを使ってじゃんけんしてみた

#環境
Jupyter Notebook(6.1.4)を用いて作業を進めました。
主要なライブラリのバーションは以下の通りです。
`OpenCV`(4.5.3), `Tensorflow`(2.5.0)
また、ディレクトリの構成は後述します。

#きっかけ
~~推しとじゃんけんがしたい~~
今まで[MediaPipeを使って手の検出をしたり](https://qiita.com/bianca26neve/items/116814135739929759a0)、[OpenCVを使って俺の愛馬を検出したり](https://qiita.com/bianca26neve/items/19085841c9ac6209fe91)してきました。少しずつ操作になれてきたので、今まで使った技術を使って何かやってみたいと考えました。手の形状を使うゲームとしてぱっと思い浮かんだのがじゃんけんだったので、「パソコンのカメラに手を出してじゃんけんをする」という動作に挑戦してみました。

#概要

目指す形としては、音声の合図でPCのカメラに手を出して形を認識してもらい、勝負の結果を表示する(BGM付)、というものです。

![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/bd4d75d5-4efd-e826-be82-6ef2d043224c.gif)

作業工程は以下の通りです。

1. 素材を集める(手、表示する画像、再生する音声)
1. MediaPipeを使って手の写真から検出ポイントの座標を集める
1. 検出ポイントの座標からじゃんけんの手を予測するモデルを作る
1. じゃんけんする

今までの経験のおかげで3番目まではすんなりと行うことができました。最後の4番目については、音声の再生など私にとって初めて取り扱うことも多かったのでちょっと苦戦しましたが、おかげで得られたものも多かったので取り組んでよかったです。

一番の苦労は作業開始前ごはんを作るときにピーラーで右手薬指のさきっぽの皮をそぎ落としたことでした。絶妙に絆創膏が張りずらい場所のため、絆創膏を外したら血濡れのキーボードが出来上がるし、絆創膏をしたらしたでキーボード打つときに変な感じがするし散々でした。指先は大切にするよう心掛けていきたいと思いました。

というわけで以下にやったことの詳細と反省点をまとめておきたいと思いますのでお付き合いください。

#①素材を集める

作業のため、以下のようなディレクトリ構成を作りました。

```
file/
├─ notebook.ipynp　<- コードはすべてここで実行
├─ landmarkdata.csv　<- 後で作成する検出ポイントの座標が入ったファイル
├─ item/
│    ├─ startimage.jpg
│    ├─ win.png
│    ├─ drow.png
│    ├─ lose.png
│    └─ sorry.png
├─ voice/
│    ├─ jankenpon.wav
│    ├─ win.wav
│    ├─ drow.wav
│    └─ lose.wav
├─ model/
│    └─ あとで予測モデルを保存
└─ hand/
     ├─  choki/   
     ├─  gu/      
     └─  pa/      
```

まずはモデル作成のための手の画像を集めます。自分のスマホの連写機能を使い、グーチョキパー各200枚ずつ用意しました。指先の認識を阻害しないため絆創膏は外しました。撮った写真はそれぞれの手の名前をつけたフォルダに保存しておきます。

また、ゲーム中に使いたい画像や音声(スタート時、勝ち、あいこ、負け、エラーの5パターン)を集めてそれぞれitemフォルダやvoiceフォルダに保存しておきます。
今回音声については「じゃんけん　フリー音声」や「フリー　効果音　勝利」などで検索してよさげなものをmp3ファイルでダウンロードさせていただき、変換サイトを使ってwavファイルに変換させていただきました。無料で使えるものがたくさんあってありがたい世の中です。
また画像についてはペイントを使って適当に用意しました。itemフォルダの中は下のような感じです。

![スクリーンショット (40).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/82984c85-52b8-2e40-f7a9-eaa26b3c0ca9.png)

自分の手とPCの手を表示したかったので結果画面で使いたいものについては真ん中をあけておきました。~~なおこの素材集めの部分に推しとじゃんけんするための可能性が秘められているのでこのあと差し替えて推しとじゃんけんできるようにしました~~

#②MediaPipeを使って手の写真から検出ポイントの座標を集める

次に集めた手の画像を使って座標の検出ポイントを集めていきます。この作業は以前以下の記事でやったこととほとんど同じなのでさくさく進みました。

https://qiita.com/bianca26neve/items/116814135739929759a0

前記事のおさらいになりますが、[公式ガイド](https://google.github.io/mediapipe/solutions/hands.html)によれば下の画像の全21か所の座標を検出することができ、得られる座標は画像サイズによって正規化されています。

![hand_landmarks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/7f0dfa64-7827-f4b5-5dd7-e60290ddbb14.png)

まずは各ファイルのパスとラベルをしまった表を作ります。

```python
#必要なライブラリをインポート
import os
import glob
import cv2
import pandas as pd
import mediapipe as mp

df = []
for foldername in os.listdir('./hand/'):
    imgs_path = './hand/' + foldername
    imgs = sorted(glob.glob(imgs_path + '/' + '*.jpg'))
    for name in imgs:
        df.append((str(name), str(foldername)))
df = pd.DataFrame(df, columns=['img', 'label'])
df.head()
```

![スクリーンショット (42).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/1e73b2a6-659b-08bc-af31-9e3f983b9e4f.png)

次に、各画像を読み込んで手の検出ポイントの座標を取得していきます。

```python
#検出器のインスタンス化
hands = mp.solutions.hands.Hands(
    static_image_mode=True, #静止画モード
    max_num_hands=1, #検出する手の数(じゃんけんは片手の想定なので1に)
    min_detection_confidence=0.5)

df1 = []
for idx, file in enumerate(df['img']):
    print('No', idx)
    #画像を読み込んで左右反転させる
    image = cv2.flip(cv2.imread(file), 1)
    #色をRGBにして手の検出を行う
    results = hands.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
    #手が検出されなければ次の画像へ
    if not results.multi_hand_landmarks:
        print('No Hand')
        print('-------------------------')
        continue
    mark_list = []
    #21個の検出ポイントを取得
    for i in range(21):
        x = results.multi_hand_landmarks[0].landmark[i].x
        y = results.multi_hand_landmarks[0].landmark[i].y
        z = results.multi_hand_landmarks[0].landmark[i].z
        mark_list.append(x)
        mark_list.append(y)
        mark_list.append(z)
    #手のラベルと合わせてdf1に保存
    mark_list.append(df['label'][idx])
    df1.append(mark_list)
    print(complete)
    print('-------------------------')
df1 = pd.DataFrame(df1)
df1.shape
#---> (597, 64)
```

カラムは21検出ポイント×(x, y, z)の63にラベルを足した64個出来上がっています。用意した画像のうち3枚くらいはうまく検出できなかったみたいです。

このあたりで指先の傷口が開いたのに気づかずキーボードのLとOのあたりが血まみれになったので消毒作業が入りました。

気を取り直し、今回は左手か右手かが重要ではないので、モデルのためのデータの水増しの手段として上と同じことを反転なしで行い、`concat`を使って結合しました。

```python
df2 = []
for idx, file in enumerate(df['img']):
    print('No', idx)
    image = cv2.imread(file, 1) #flipしない→逆の手としてデータとれるはず
    results = hands.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
    if not results.multi_hand_landmarks:
        print('No Hand')
        print('-------------------------')
        continue
    mark_list = []
    for i in range(21):
        x = results.multi_hand_landmarks[0].landmark[i].x
        y = results.multi_hand_landmarks[0].landmark[i].y
        z = results.multi_hand_landmarks[0].landmark[i].z
        mark_list.append(x)
        mark_list.append(y)
        mark_list.append(z)
    mark_list.append(df['label'][idx])
    df2.append(mark_list)
    print('-------------------------')

df2 = pd.DataFrane(df2)
df2.shape
#---> (598, 64)

df3 = pd.concat([df1, df2])
df3.shape
#---> (1195, 64)
df3.head()
```

![スクリーンショット (44).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/74c9f9bc-6580-b018-015d-b4201b3293a7.png)

無事に取得できてそうなのでこれで一旦csvファイルとして保存しておきます。

```python
df3.to_csv('landmarkdata.csv', index=False)
```

検出ポイントの座標を集めることができたので次のモデル作成に移ります。

#②検出ポイントの座標からじゃんけんの手を予測するモデルを作る

集めた検出ポイントの座標データを使って、新しい手の画像からじゃんけんの手を予測するモデルを作成します。
今回はロジスティック回帰モデルを使ってみます。このモデルを選んだ理由は、

- 画像データだが特徴量を63個に減らしてある
- (予想だが)特に指先の座標についてじゃんけんの手ごとに差があり、線形分離が望めそう
- 各クラスへの所属確率に確率論的な意味が見いだせる

です。特に3つ目がじゃんけん作成のときに使いたい特徴でした。モデルの複雑さが足りないようなら後で考え直すことにして、早速学習していきます。

```python
#必要なライブラリをインポート
import pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import pickle

#作成したcsvファイルの読み込み
df = pd.read_csv('landmarkdata.csv')

#特徴量列とラベル列に分離
X = df.drop('63', axis=1)
y = df['63']

#クラスラベルの数値化。変換の対応を確認したいので変換前後で表示しておく
print(y[0], y[300], y[590])
#---> choki  gu  pa
le = LabelEncoder()
y = le.fit_transform(y)
print(y[0], y[300], y[590])
#---> 0  1  2

#訓練データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

#ロジスティック回帰モデルで学習を行い、テストデータのスコアを表示
lr = LogisticRegression()3
lr.fit(X_train, y_train)
print(lr.score(X_test, y_test))
#---> 0.9832635983263598
```

思っていた精度よりもよくて嬉しい！イテレーション数オーバーのエラーも出ましたが十分だと判断し上限解放は行いませんでした。また、何回か実行してみてブレを観察しましたが、ほとんどが95%以上だったのでこのモデルでも十分と判断しました。
ついでに使いたかった所属確率についても`predict_proba`で確認します。

```python
a = X_test.iloc[0, :]
test = np.array([a[i] for i in range(len(a))])
pred = lr.predict(test.reshape(1, -1))
prob = lr.predict_proba(test.reshape(1, -1))[0][pred[0]]
print('label:', s, ' prob.: ', prob[0][s])
#---> label: 1  prob.:  0.9132943271982226
```

結果からこのデータは91%の確率でグーであることがわかります。階層構造がちょっと面倒だなと感じます。。
満足したのでこのモデルを保存しておきます。

```python
with open('./model/logistic.pkl', 'wb') as f:
    pickle.dump(lr, f)
```

これでモデルを作成することができました。いよいよ最終段階に移っていきます。

#④じゃんけんする

それぞれ準備が終わりいよいよ最終段階です。ここから未知の作業だったのでビビりながら実装していきます。

まずじゃんけんについては以下の記事を参考にさせていただきました。

https://qiita.com/sandream/items/01374069f447b7748eba

初心者にはありがたい丁寧な解説でとても助かりました。ありがとうございました。
また、音声を扱うライブラリについては以下の記事を参考にして`Simpleaudio`を選びました。

https://qiita.com/hisshi00/items/62c555095b8ff15f9dd2

じゃんけんのタイミングを計るのに「再生中かどうかの判定ができる」というのが刺さりました。他のライブラリも触ってみたいです。ありがとうございました。

これらのありがたい記事や公式ガイドを参考にしながら作成していきます。
まずは前準備として必要なライブラリのインポート、ラベル対応表の作成、モデルの準備、検出器の準備を行います。

```python
#必要なライブラリをインポート
import numpy as np
import cv2
import mediapipe as mp
import simpleaudio
import random
import pickle

#後で使う対応表の作成
label_dict = {0: 'b', 1: 'a', 2: 'c'}
dic = {"a": "Rock", "b": "Scissors", "c": "Paper"}

#前工程で作ったモデルの取得
with open('./model/logistic.pkl', 'rb') as f:
    model = pickle.load(f)

#検出器のインスタンス化
hands = mp.solutions.hands.Hands(
    static_image_mode=True,
    max_num_hands=1,
    min_detection_confidence=0.5)
```

次にゲーム開始の部分です。(ここが初めてのことばかりで結構大変だった…)
まずはゲーム開始として開始画面と掛け声の音声が流れるようにします。音声は「ぽん！」の後すぐに終わるので、音声が終了したらすぐにカメラで画像データを取得し、スタート画面は閉じるようにします。音声終了は`Simpleaudio`の`is_playing()`で判断してもらいました。ラグが少ないように実行の順番を考えながら書きましたがいい感じになっていることを期待します。

```python
#表示する画像、再生する音声、カメラの準備
start_voice = simpleaudio.WaveObject.from_wave_file("./voice/jankenpon.wav")
start_img = cv2.imread('./item/startimage.jpg', 3)
cap = cv2.VideoCapture(0)
#スタート画像の表示
cv2.imshow('start!', start_img)
cv2.waitKey(1) #destroyWindowが実行されたらすぐに消えるように0より大きく音声再生時間より短く設定
#音声の再生
play_obj = start_voice.play()
#音声の再生が終了するまでループ
while True:
    if not play_obj.is_playing():
        break
#再生終了したらカメラを起動して画像取得
ret, frame = cap.read()
#スタート画像の表示を終了
cv2.destroyWindow('start!')
#カメラの画像から手を検出
image = cv2.cvtColor(cv2.flip(frame, 1), cv2.COLOR_BGR2RGB)
results = hands.process(image)
#カメラの終了
cap.release()
```

これで画像が取得されたので、検出結果から画像の手がじゃんけんのどの手か予測し、PC側の手もランダムに決めてじゃんけんを実行、結果を表示します。
ここは`try`構造を使って、手が検出されなかった場合や予測が不安定だった場合には「うまく読み取れませんでした」という画像が表示されるようにしました。
また、結果表示の部分では自分とPCの手の確認ができるように、結果の画面に手を表示させました。OpenCVの仕様上日本語だとうまくいかなかったので英語にしてます。

```python
#ValueErrorがでない限りは以下の処理を行う
try:
    #検出ポイントの座標を取得
    mark_list = []
    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            for i in range(21):
                x = hand_landmarks.landmark[i].x
                y = hand_landmarks.landmark[i].y
                z = hand_landmarks.landmark[i].z
                mark_list.append(x)
                mark_list.append(y)
                mark_list.append(z)
    mark_list = np.array(mark_list)
    #ロジスティック回帰モデルを使ってじゃんけんの手を予測
    #mark_listが空だとValueErrorになってtryから脱出
    pred = model.predict(mark_list.reshape(1, -1))
    prob = model.predict_proba(mark_list.reshape(1, -1))[0][pred[0]]
    #予測されたじゃんけんの手について、所属確率が弱ければValueErrorを起こしてtryから脱出
    if prob < 0.85:
        raise ValueError
    #所属確率が85%以上ならばそれをプレイヤーの手と判断して続行
    user = label_dict[pred[0]]
    user_choice = dic[user]
    #PCの手をランダムに選択
    pc = dic[random.choice(["a", "b", "c"])]
    #judgeを適当な数で初期化し次のように変える
    #0:drow, 1:win, -1:lose
    judge = 3
    if user_choice == pc:
        judge = 0
    else:
        if user_choice == "Rock":
            if pc == "Scissors":
                judge = 1
            else:
                judge = -1
        elif user_choice == 'Scissors':
            if pc == "Paper":
                judge = 1
            else:
                judge = -1
        else:
            if pc == "Rock":
                judge = 1
            else:
                judge = -1
    #OpenCVの描画の仕様上日本語がだめなので英語で結果の文章を作成
    ans1 = "Your Choice is %s" % user_choice
    ans2 = "PC's Choice is %s" % pc
    #じゃんけんの結果によって取得する画像を選択
    image_path = './item/'
    voice_path = './voice/'
    if judge == 1: #勝った場合
        voice_path = voice_path + 'win.wav'
        image_path = image_path + 'win.png'
    elif judge == 0: #あいこの場合
        voice_path = voice_path + 'drow.wav'
        image_path = image_path + 'drow.png'
    elif judge == -1: #負けた場合
        voice_path = voice_path + 'lose.wav'
        image_path = image_path + 'lose.png'
    #再生する音を表示する画像を取得
    bgm = simpleaudio.WaveObject.from_wave_file(voice_path)
    result_img = cv2.imread(image_path, 3)
    #音楽の再生
    play_obj = bgm.play()
    #画像に自分の手とPCの手を描画し、表示
    cv2.putText(result_img, ans1, (80, 350), cv2.FONT_HERSHEY_COMPLEX, 2.5, (0, 0, 0), 3, cv2.LINE_AA)
    cv2.putText(result_img, ans2, (80, 500), cv2.FONT_HERSHEY_COMPLEX, 2.5, (0, 0, 0), 3, cv2.LINE_AA)
    cv2.imshow('RESULT', result_img)
    #3秒くらい経ったら自動でWindowを閉じる
    cv2.waitKey(3000)
    cv2.destroyAllWindows()
#途中でValueErrorが起きた場合の処理
except ValueError :
        #画像を表示して１秒後くらいに閉じる
        result_img = cv2.imread('./item/sorry.png', 3)
        cv2.imshow('SORRY', result_img)
        cv2.waitKey(1000)
        cv2.destroyAllWindows()
```

説明のため分割しましたが、これらを合わせて実行するとじゃんけんゲームをすることができます！

![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851062/d14ca7bf-e137-507b-55f5-91c1c36fc33a.gif)


苦労はしましたがやっぱりできたときに目に見えるのが嬉しいですね！
~~推しとじゃんけんというご褒美が最高に効きます~~

#改善したいところ

- はやくアプリケーション化を勉強しろ
- あまり関数化する習慣がないので同じ処理をたくさん書いた印象。関数化する癖をつけたい。
- 動かしてみると精度が微妙かもしれない。画像サイズを使って正規化されているので、学習時と実行時の画像サイズが影響起こしそうな気がする。距離も一定の画像ばかりで学習させてしまったので、もう少しいろいろなデータを用意するか今あるデータを加工して対応できるようにするべきかもしれない
- じゃんけんアルゴリズムくらい頑張って考えてみてもよかったのでは？明確なサボり。。。
- 推しの画像でもテンション上がるけど推しのじゃんけんモーションがついた動画だったらさらにテンション上がると思う。音声付き動画データの取り扱いも学んでみたい