【MoveNet/第5回】

MoveNet

第5回です。このあたりから発表用に自分が作るゲームのイメージをしておくといいかも?

キーポイントの座標を取得

前回旗揚げゲームの出題と旗の状態管理について取り扱ったので、今回はMoveNetで手を挙げたかの判定をする仕組みを作ります。

MoveNetでキーポイントを使った条件分岐処理をするためには、キーポイントをが出力する値を理解する必要があります。

まずは第3回前半戦で使ったコードを用意します。

そのコードを少し書き換えます。

let videoPlayer;
let detector;
let overlayCanvas;
let overlayCtx;

// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet);
    } catch (error) {
        console.error('MoveNet モデルの読み込みに失敗しました:', error);
    }
}

// ビデオからポーズを推定する関数
async function estimatePoses(video) {
    try {
        return await detector.estimatePoses(video);
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
        return [];
    }
}

// カメラの読み込みを行う関数
async function loadCamera() {
    const constraints = {
        video: {
            width: 1980,
            height: 1080,
            aspectRatio: 1.77
        }
    };
    try {
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        videoPlayer.srcObject = stream;
    } catch (error) {
        console.error('カメラのアクセスに失敗:', error);
    }
}

// 選択した動画を再生し、ポーズ検出を開始する関数
async function playSelectedVideo() {
    await videoPlayer.play();
    detect();
}

// キーポイントを描画する関数(オーバーレイ用)
function drawKeypoints(keypoints, context) {

    console.log(keypoints); //ここを追記

    keypoints.forEach((keypoint) => {

        const color = `rgba(255, 0, 0, ${keypoint.score})`;

        // 円の描画
        context.beginPath();
        context.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI);
        context.fillStyle = color;
        context.fill();
        context.closePath();
    });
}

// ボディラインを描画する関数(オーバーレイ用)
function drawBodyLines(keypoints, context) {
    context.strokeStyle = 'rgba(0, 255, 0, 0.8)';
    context.lineWidth = 4;

    // 人体のラインを定義
    const bodyLines = [
        [3, 1], [1, 0], [0, 2], [2, 4], // 顔
        [9, 7], [7, 5], //左腕
        [10, 8], [8, 6], //右腕
        [6, 5], [5, 11], [11, 12], [12, 6],//胴体
        [12, 14], [14, 16], [11, 13], [13, 15],//脚
    ];

    bodyLines.forEach((line) => {
        const start = keypoints[line[0]];
        const end = keypoints[line[1]];

        context.beginPath();
        context.moveTo(start.x, start.y);
        context.lineTo(end.x, end.y);
        context.stroke();
        context.closePath();
    });
}

// ポーズを検出し続ける関数
async function detect() {
    try {
        if (!videoPlayer.paused && !videoPlayer.ended) {
            const poses = await estimatePoses(videoPlayer);
            if (poses.length > 0) {
                // オーバーレイ用のキャンバスをクリアしてキーポイントを描画
                overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
                poses.forEach((pose) => {
                    drawKeypoints(pose.keypoints, overlayCtx);
                    drawBodyLines(pose.keypoints, overlayCtx);
                });
            }
        }
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
    }
    requestAnimationFrame(detect);
}

// ページの読み込みが完了したらMoveNetモデルを読み込み、要素を取得する
window.onload = async function () {
    await loadMoveNet();
    videoPlayer = document.getElementById('videoPlayer');
    overlayCanvas = document.getElementById('overlayCanvas');
    overlayCtx = overlayCanvas.getContext('2d');

    // カメラ映像の読み込み
    await loadCamera();

    videoPlayer.addEventListener('loadedmetadata', function () {
        playSelectedVideo();
    });
};

drawKeypoints()の直後にconsole.log(keypoints);を追記しました。

コードを実行してコンソールを見てみると……なにやらたくさんの数字がありますね。

一つ選んで配列をクリックすると見ることができます。

さらにさらに展開すると…詳細な表示になりました!

各項目について表にまとめるとこうなります。

name検出部位の名前
score検出結果の信頼度
x検出部位のx座標
y検出部位のy座標

nameの一覧はこちらです。多分この表にはかなりお世話になるはず。

配列の要素キーポイント名部位
0nose
1left_eye左目
2right_eye右目
3left_ear左耳
4right_ear右耳
5left_shoulder左肩
6right_shoulder右肩
7left_elbow左肘
8right_elbow右肘
9left_wrist左手首
10right_wrist右手首
11left_hip左腰
12right_hip右腰
13left_knee左膝
14right_knee右膝
15left_ankle左足首
16right_ankle右足首

scoreですが、1に近いほど信頼度が高い(=その部位である確率が高い)です。

座標ですが1920*1080のcanvasの場合、図に表すとこのようになります。左上が原点になることに注意。

試しに下記のコードをコピーしてみてください。鼻の座標をコンソールに表示し続けます。映像の四隅に鼻を移動させると、座標の感覚がつかみやすいです。

let videoPlayer;
let detector;
let overlayCanvas;
let overlayCtx;

// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet);
    } catch (error) {
        console.error('MoveNet モデルの読み込みに失敗しました:', error);
    }
}

// ビデオからポーズを推定する関数
async function estimatePoses(video) {
    try {
        return await detector.estimatePoses(video);
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
        return [];
    }
}

// カメラの読み込みを行う関数
async function loadCamera() {
    const constraints = {
        video: {
            width: 1980,
            height: 1080,
            aspectRatio: 1.77
        }
    };
    try {
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        videoPlayer.srcObject = stream;
    } catch (error) {
        console.error('カメラのアクセスに失敗:', error);
    }
}

// 選択した動画を再生し、ポーズ検出を開始する関数
async function playSelectedVideo() {
    await videoPlayer.play();
    detect();
}

// キーポイントを描画する関数(オーバーレイ用)
function drawKeypoints(keypoints, context) {
    keypoints.forEach((keypoint) => {
        
        if(keypoint.name = 'nose'){
            console.log("nose:" + keypoint.x + "," + keypoint.y);
        }
    });
}
// ポーズを検出し続ける関数
async function detect() {
    try {
        if (!videoPlayer.paused && !videoPlayer.ended) {
            const poses = await estimatePoses(videoPlayer);
            if (poses.length > 0) {
                // オーバーレイ用のキャンバスをクリアしてキーポイントを描画
                overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
                poses.forEach((pose) => {
                    drawKeypoints(pose.keypoints, overlayCtx);
                });
            }
        }
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
    }
    requestAnimationFrame(detect);
}

// ページの読み込みが完了したらMoveNetモデルを読み込み、要素を取得する
window.onload = async function () {
    await loadMoveNet();
    videoPlayer = document.getElementById('videoPlayer');
    overlayCanvas = document.getElementById('overlayCanvas');
    overlayCtx = overlayCanvas.getContext('2d');

    // カメラ映像の読み込み
    await loadCamera();

    videoPlayer.addEventListener('loadedmetadata', function () {
        playSelectedVideo();
    });
};

MoveNetで手を挙げたか判定したい

ここまでの解説を読んだ方は、もうできるはずです。ということで課題にします。

課題

  1. 両手の座標をコンソールに表示せよ
  2. 【ちょい難問】手の位置が画面上半分にある場合、挙げた手の肩に任意の画像を表示せよ
    • 例)左手が画面上半分に行く⇒左肩に画像が表示される

サンプルコードの一部を書き換えるだけで実現できます。

let videoPlayer;
let detector;
let overlayCanvas;
let overlayCtx;

// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet);
    } catch (error) {
        console.error('MoveNet モデルの読み込みに失敗しました:', error);
    }
}

// ビデオからポーズを推定する関数
async function estimatePoses(video) {
    try {
        return await detector.estimatePoses(video);
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
        return [];
    }
}

// カメラの読み込みを行う関数
async function loadCamera() {
    const constraints = {
        video: {
            width: 1980,
            height: 1080,
            aspectRatio: 1.77
        }
    };
    try {
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        videoPlayer.srcObject = stream;
    } catch (error) {
        console.error('カメラのアクセスに失敗:', error);
    }
}

// 選択した動画を再生し、ポーズ検出を開始する関数
async function playSelectedVideo() {
    await videoPlayer.play();
    detect();
}

// キーポイントを描画する関数(オーバーレイ用)
function drawKeypoints(keypoints, context) {
    keypoints.forEach((keypoint) => {

        if (keypoint.name = 'right_wrist') {
            console.log("右手:" + keypoint.x + "," + keypoint.y);
        }
        if (keypoint.name = 'left_wrist') {
            console.log("左手:" + keypoint.x + "," + keypoint.y);
        }
    });
}

// ポーズを検出し続ける関数
async function detect() {
    try {
        if (!videoPlayer.paused && !videoPlayer.ended) {
            const poses = await estimatePoses(videoPlayer);
            if (poses.length > 0) {
                // オーバーレイ用のキャンバスをクリアしてキーポイントを描画
                overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
                poses.forEach((pose) => {
                    drawKeypoints(pose.keypoints, overlayCtx);
                });
            }
        }
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
    }
    requestAnimationFrame(detect);
}

// ページの読み込みが完了したらMoveNetモデルを読み込み、要素を取得する
window.onload = async function () {
    await loadMoveNet();
    videoPlayer = document.getElementById('videoPlayer');
    overlayCanvas = document.getElementById('overlayCanvas');
    overlayCtx = overlayCanvas.getContext('2d');

    // カメラ映像の読み込み
    await loadCamera();

    videoPlayer.addEventListener('loadedmetadata', function () {
        playSelectedVideo();
    });
};

ここではpose.keypoints.forEach((keypoint) => {});を削除し、pose.keypoints[10].yのような方法で座標を取得しています。こうすることで、手首の座標をもとに、肩に対して何かをする、という処理をしやすくしています。

let videoPlayer;
let detector;
let overlayCanvas;
let overlayCtx;

// MoveNetモデルを読み込む関数
// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet);
    } catch (error) {
        console.error('MoveNet モデルの読み込みに失敗しました:', error);
    }
}

// ビデオからポーズを推定する関数
async function estimatePoses(video) {
    try {
        const poses = await detector.estimatePoses(video);
        return poses;
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
        return [];
    }
}

// カメラの読み込みを行う関数
async function loadCamera() {
    const constraints = {
        video: {
            width: 1980,
            height: 1080,
            aspectRatio: 1.77
        }
    };
    try {
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        videoPlayer.srcObject = stream;
    } catch (error) {
        console.error('カメラのアクセスに失敗:', error);
    }
}

// 選択した動画を再生し、ポーズ検出を開始する関数
async function playSelectedVideo() {
    await videoPlayer.play();
    detect();
}

// キーポイントを描画する関数(オーバーレイ用)
function drawKeypoints(poses, context) {
    poses.forEach((pose) => {
        if (pose.keypoints[10].y <= 540) {
            drawImage(pose.keypoints[6].x, pose.keypoints[6].y, context);
        }
        if (pose.keypoints[9].y <= 540) {
            drawImage(pose.keypoints[5].x, pose.keypoints[5].y, context);
        }
    });
}

// 追従する画像を描画する関数
function drawImage(x, y, context) {
    if (image.complete) {
        const imageWidth = 400;
        const imageHeight = 400;
        context.drawImage(image, x - imageWidth / 2, y - imageHeight / 2, imageWidth, imageHeight);
    }
}

// 画像をロードする関数
function loadfaceImage() {
    image = new Image();
    image.src = '任意の画像パス';
}

// ポーズを検出し続ける関数
async function detect() {
    try {
        if (!videoPlayer.paused && !videoPlayer.ended) {
            const poses = await estimatePoses(videoPlayer);
            if (poses.length > 0) {
                // オーバーレイ用のキャンバスをクリアしてキーポイントを描画
                overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
                drawKeypoints(poses, overlayCtx);
            }
        }
    } catch (error) {
        console.error('姿勢推定に失敗しました:', error);
    }
    requestAnimationFrame(detect);
}

// ページの読み込みが完了したらMoveNetモデルを読み込み、要素を取得する
window.onload = async function () {
    await loadMoveNet();
    videoPlayer = document.getElementById('videoPlayer');
    overlayCanvas = document.getElementById('overlayCanvas');
    overlayCtx = overlayCanvas.getContext('2d');
    loadfaceImage(); // 追従する画像をロードする

    // カメラ映像の読み込み
    await loadCamera();

    videoPlayer.addEventListener('loadedmetadata', function () {
        playSelectedVideo();
    });
};

まとめ

今回はMoveNetで座標を拾う方法をやりました。前回と比べると楽だったかも?

これで正誤判定の際、「旗の状態」と「手首の位置」を比較して評価できるようになります。

次回は大詰めである、コードの合体とゲーム全体の処理をやっていきます。お楽しみに。

それでは。