はじめに
Pyxel+MediaPipeで画像認識を使ったゲームを作ってみました。
この記事では、このゲームで利用しているPyxelとMediaPipeの連携、手のランドマーク認識の部分を説明したいと思います。
説明で利用するために、該当部分を抜き出したプログラムをGitHubで公開しています。
サンプルコードを実行すると次のように手のランドマーク座標を取得して描画することができます。
Pyxelとは
PyxelはPythonで書けるレトロゲームエンジンです。
仕様がシンプルなため、手軽にゲームを開発することができます。
Pyxelで作られたゲームは、Pythonとして実行する方法と、Web上で実行する方法があり、今回はWebで実行する方のみを想定しています。
MediaPipeとは
Googleが提供している生成AIや画像認識などを利用できるライブラリです。
今回は手のランドマーク認識機能を利用します。
MediaPipeは、Python、Android、iOS、Webの様々な環境で利用でき、今回はPyxelの実行に合わせてWebを利用します。
入力されたデータはクライアント側で処理されますが、デバイス情報などはGoogleに送信されます。
詳しくは下記ページを参照してください。
プログラムの説明
MediaPipeの呼び出し部分
MediaPipeの呼び出し部分は公式ドキュメントのサンプルコードを元に作成しています。
まず初めに、手のランドマーク認識を行うタスクを作成する必要があります。
次のコードがタスクを作成している個所になります。
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
は、ランドマーク認識を行う手の数を指定できます
次に、作成したタスクにカメラからの入力を渡して、ランドマーク認識を行います。
次のコードが該当する個所になります。
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を利用しています。
そのため、webで実行されているPyxelはJavaScriptと連携することができます。(ただし、Pyxel公式ドキュメントに記載されている方法ではないため、アップデートにより使えなくなる可能性はあります。)
次のコードは、アプリが手のランドマーク座標を取得している部分になります。
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型に変換します。
ランドマーク座標のデータ構造の説明
results
は次のようなデータ構造になっています。
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
のほうを利用しています。
ランドマーク座標群とランドマークの対応付けは次の図のようになっています。
公式サイトより引用
この対応表を元に、指の座標を取得したり、指の方向を計算したりすることでゲームのインターフェスとして利用できます。
アスペクト比の計算
MediaPipeで得られるランドマーク座標は、カメラの範囲を[0,1]
としています。
そのため、カメラとゲーム画面のアスペクト比が異なる場合は補正する必要があります。
サンプルプログラムでは、ゲーム画面を正方形として補正しています。
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
- カメラの中心を原点とするため、x座標、y座標ともに
-0.5
する - アスペクト比は、画像の幅 / 画像の高さで計算される
- アスペクト比が1未満の時、y座標をアスペクト比で割る
- アスペクト比が1以上の時、x座標にアスペクト比をかける
- 原点を元に戻すため、x座標、y座標ともに
+0.5
する - 左右反転するために、x座標を
1 - x
した値にする
終わりに
この記事では、MediaPipeを利用して得られた手のランドマーク座標をPyxelで利用する方法について説明しました。
ここまで簡単に画像認識を動かせて、ゲームと連携してさっと動かせると、趣味でゲームを作っている身からするととてもありがたいです。(ゲーム作成にかかる時間=ゲームを完成させられない確率なので・・・)
MediaPipeにはこの記事で紹介していない機能もたくさんありますし、Pyxelも便利な機能がどんどん追加されて行っているので、面白いゲームをたくさん作っていきたいです。