Pyxel+MediaPipeで画像認識を使ったゲームを作ろう


## はじめに

Pyxel+MediaPipeで画像認識を使ったゲームを作ってみました。

https://x.com/sirococoa1/status/1785619779254206601

この記事では、このゲームで利用しているPyxelとMediaPipeの連携、手のランドマーク認識の部分を説明したいと思います。

説明で利用するために、該当部分を抜き出したプログラムをGitHubで公開しています。

https://github.com/sirococoa/PyxelAndMediapipeTemplate

サンプルコードを実行すると次のように手のランドマーク座標を取得して描画することができます。

![pyxel-20241122-225516.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/257212/880e7fff-d5b2-42c4-051a-a064f34c880e.gif)

## Pyxelとは

PyxelはPythonで書けるレトロゲームエンジンです。

https://github.com/kitao/pyxel/blob/main/docs/README.ja.md

仕様がシンプルなため、手軽にゲームを開発することができます。

Pyxelで作られたゲームは、Pythonとして実行する方法と、Web上で実行する方法があり、今回はWebで実行する方のみを想定しています。

## MediaPipeとは

Googleが提供している生成AIや画像認識などを利用できるライブラリです。

https://ai.google.dev/edge/mediapipe/solutions/guide?hl=ja

今回は手のランドマーク認識機能を利用します。

MediaPipeは、Python、Android、iOS、Webの様々な環境で利用でき、今回はPyxelの実行に合わせてWebを利用します。

入力されたデータはクライアント側で処理されますが、デバイス情報などはGoogleに送信されます。

詳しくは下記ページを参照してください。

https://ai.google.dev/edge/mediapipe/legal/tos

## プログラムの説明

### MediaPipeの呼び出し部分

MediaPipeの呼び出し部分は公式ドキュメントのサンプルコードを元に作成しています。

https://codepen.io/mediapipe-preview/pen/gOKBGPN

まず初めに、手のランドマーク認識を行うタスクを作成する必要があります。

次のコードがタスクを作成している個所になります。

```javascript:main.js
const createHandLandmarker = async () => {
    const vision = await FilesetResolver.forVisionTasks(
        "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm"
    );
    handLandmarker = await HandLandmarker.createFromOptions(vision, {
        baseOptions: {
            modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
            delegate: "CPU"
        },
        runningMode: "VIDEO",
        numHands: 2
    });
};
await createHandLandmarker();
```
- `delegate`は、`CPU`か`GPU`を指定できます

- `runningMode`は、今回は動画像データを処理するため`VIDEO`を指定しています

- `numHands`は、ランドマーク認識を行う手の数を指定できます

次に、作成したタスクにカメラからの入力を渡して、ランドマーク認識を行います。

次のコードが該当する個所になります。

```javascript:main.js
async function predictWebcam() {
    window.videoWidth = video.videoWidth;
    window.videoHeight = video.videoHeight;

    let startTimeMs = performance.now();
    if (lastVideoTime !== video.currentTime) {
        lastVideoTime = video.currentTime;
        window.results = handLandmarker.detectForVideo(video, startTimeMs);
        window.results.videoTime = video.currentTime
    }

    if (window.webcamRunning === true) {
        window.requestAnimationFrame(predictWebcam);
    }

    window.detectionRunning = true
}
```

- `handLandmarker.detectForVideo`関数を呼び出して、ランドマーク認識を行い、その結果を`results`に格納しています
- `results.videoTime`で時間を追加で保存しています
  - これは、処理能力によってランドマーク認識にかかる時間が変わり、その差をアプリ側で補正できた方が便利だからです
- `videoWidth`や`videoHeight`はアスペクト比の調整を行うためにアプリ側で利用します
- `webcamRunning`や`detectionRunning`は画像認識が実行されていることを確認するためにアプリ側で利用します

### PyxelからのMediaPipeの呼び出し

web版のPyxelはPyodideを利用しています。

https://pyodide.org/en/stable/

そのため、webで実行されているPyxelはJavaScriptと連携することができます。(ただし、Pyxel公式ドキュメントに記載されている方法ではないため、アップデートにより使えなくなる可能性はあります。)

次のコードは、アプリが手のランドマーク座標を取得している部分になります。

```python:main.py
import js 

~~~

def get_landmarks(self) -> None:
    results = js.results.to_py()
    video_time = results['videoTime']
    landmarks = results['landmarks']
    if self.before_video_time == video_time:
        return
    if self.before_video_time > 0:
        self.processing_time = video_time - self.before_video_time
    self.before_video_time = video_time
    if len(landmarks) == 0:
        self.detect_flag = False
    else:
        self.detect_flag = True
        self.hands = [Hand(landmark, self.videoAspect, video_time) for landmark in landmarks]
```

JavaScriptと連携するために、jsモジュールが必要になります。

`js.<変数名>`の形で、JavaScript上でグローバルに定義された同名の変数にアクセスすることができます。

手のランドマーク座標`results`はJavaScriptのObject型になっているため、`to_py`メソッドを利用してPythonのmap型に変換します。

https://pyodide.org/en/stable/usage/type-conversions.html

### ランドマーク座標のデータ構造の説明

`results`は次のようなデータ構造になっています。

```python
results = {
    "landmarks": [
        [
            {
                "x": 0.49889901280403137,
                "y": 1.0230612754821777,
                "z": 2.9386748678916774e-07,
            },
            ...
        ],
        ...
    ],
    "worldLandmarks": [
        [
            {
                "x": 0.005202991887927055,
                "y": 0.09185333549976349,
                "z": 1.1615655239438638e-05,
            },
            ...
        ],
        ...
    ],
    "handednesses": [
        [
            {
                "score": 0.9845848083496094,
                "index": 0,
                "categoryName": "Left",
                "displayName": "Left",
            }
        ],
        ...
    ],
    "videoTime": 5.041351,    # main.js で追加しているデータ
```

- `landmarks`と`worldLandmarks`が手のランドマーク座標を表しています
- `landmarks`のx座標とy座標は、カメラの幅と高さで正規化された値になっています
  - つまりカメラに写っているランドマークの座標は`[0,1]`の範囲となります
- `landmarks`のz座標は、x軸と同じスケールを持った奥行きの値になっています
  - 手首の座標を基準とし、画面に近いほど値は小さくなります
- `worldLandmarks`は手首の座標を基準とした座標系で、その単位はメートルとなっています
- `handednesses`は対応する手の左右を表しています

今回のプログラムでは`landmarks`のほうを利用しています。

ランドマーク座標群とランドマークの対応付けは次の図のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/257212/1a15dee6-752b-b54f-375f-512c16d98788.png)

[公式サイト](https://ai.google.dev/edge/mediapipe/solutions/vision/hand_landmarker#models)より引用

この対応表を元に、指の座標を取得したり、指の方向を計算したりすることでゲームのインターフェスとして利用できます。

### アスペクト比の計算

MediaPipeで得られるランドマーク座標は、カメラの範囲を`[0,1]`としています。

そのため、カメラとゲーム画面のアスペクト比が異なる場合は補正する必要があります。

サンプルプログラムでは、ゲーム画面を正方形として補正しています。

```python:main.py
def __init__(self, landmarks: Any, aspect: float, time: float) -> None:
    self.points = []
    for landmark in landmarks:
        x, y, z = landmark['x'] - 0.5, landmark['y'] - 0.5, landmark['z']
        if aspect < 1:
            y = y / aspect
        else:
            x = x * aspect
        x, y = x + 0.5, y + 0.5
        self.points.append([1 - x, y, z])
    self.time = time
```

1. カメラの中心を原点とするため、x座標、y座標ともに`-0.5`する
1. アスペクト比は、画像の幅 / 画像の高さで計算される
1. アスペクト比が1未満の時、y座標をアスペクト比で割る
1. アスペクト比が1以上の時、x座標にアスペクト比をかける
1. 原点を元に戻すため、x座標、y座標ともに`+0.5`する
1. 左右反転するために、x座標を`1 - x`した値にする

# 終わりに

この記事では、MediaPipeを利用して得られた手のランドマーク座標をPyxelで利用する方法について説明しました。

ここまで簡単に画像認識を動かせて、ゲームと連携してさっと動かせると、趣味でゲームを作っている身からするととてもありがたいです。(ゲーム作成にかかる時間=ゲームを完成させられない確率なので・・・)

MediaPipeにはこの記事で紹介していない機能もたくさんありますし、Pyxelも便利な機能がどんどん追加されて行っているので、面白いゲームをたくさん作っていきたいです。