【MoveNet/第3回後半戦】

MoveNet

続きです。

コード解説

まず何の関数が定義されているかチェックしましょう。

// 1.MoveNetモデルを読み込む関数
async function loadMoveNet()

// 2.ビデオからポーズを推定する関数
async function estimatePoses(video)

// 3.カメラの読み込みを行う関数
async function loadCamera()

// 4.選択した動画を再生し、ポーズ検出を開始する関数
async function playSelectedVideo()

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

// 6.ボディラインを描画する関数(オーバーレイ用)
function drawBodyLines(keypoints, context)

// 7.ポーズを検出し続ける関数
async function detect()

// 8.ページの読み込みが完了したらMoveNetモデルを読み込み、要素を取得する
window.onload = async function ()

全部で8個ありますね。1個でも欠けると機能が正常に動作しなくなるので、消さないようにしましょう。

コードの流れですが、8>1>3>4>7>2>5>6となっており、1周目以降は7>2>5>6を永遠と繰り返します。なんでfor文がないのに繰り返せているのでしょうね、あとで課題にするとします。

余談ですが、JavaScripは他の言語と違ってリテラル関数以外は順番を気にしなくてもいいです。なので下記のような書き方でも問題なく動作します。

//関数呼び出しを先に記述
message();

function message(){
    console.log("fire!");
}

つまりコードの流れの順番をでたらめな数字順ではなく、8>1>2>3…とできるわけですね。余力のある人は関数の順番を変えてみてどのような変化があるかを確認してみましょう。

複数人検出できるようにしよう

これからゲームを作っていくとなると、1人でプレイしてスコアを競うタイプのゲームは作れますが、複数人で遊ぶ対戦ゲームを作りたいというニーズもあると思います。先ほど作ったコードを改造して、複数人分のキーポイントが出るようにしましょう。

第1回でも少し触れましたが、MoveNetには複数のモデルが存在しています。モデルを読み込むときにどのモデルを使うか宣言を入れることで別のモデルに切り替えることができます。

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

SINGLEPOSE_LIGHTNINGがデフォルトなところを、MULTIPOSE_LIGHTNINGにしてあげるだけです。ただこれ、リファレンスにもほとんど書いていないんですよね。

英語にアレルギーがない人は、下記にパラメータ類についての記述があるので、知見を深めて筆者の理解度を超えてください。

tfjs-models/pose-detection/src/movenet/README.md at master · tensorflow/tfjs-models
Pretrained models for TensorFlow.js. Contribute to tensorflow/tfjs-models development by creating an account on GitHub.

うまくいくと複数人がカメラの前に立ってもキーポイントが出ます。少し描画のフレームレートが落ちている気がしますが、問題ない範囲に収まっています。

こんな感じ

ちなみに、こちらの動画を使用しています。ニコニコのダンス界隈は正面のダンス動画が多いので、試金石に使うのはありですね。(もちろん常識の範囲内で!)

【Ari。Ki】 どりーみんチュチュ【オリジナル振り付け】
【Ari。Ki】 どりーみんチュチュ【オリジナル振り付け】 ♥ ♥ ♥ Happy Valentine's Day ♥ ♥ ♥やっほ!Ari。Kiです!^^今回はemonさんのどりーみんチュチュ...

キーポイントの描画に関するコードを変えていませんが、うまくキーポイントが表示されているのはなぜなのでしょうか、これも課題にしましょうか。

画像を組み込もう

これで複数人で遊ぶゲームが作れる!と思いたいですが、まだまだ道は長いです。

次はcanvasキャンバスkeypointキーポイントについて学んでいきましょう。今回はとりあえず動くようにすることがメインなので詳しい解説は下記リンクから勉強してください。ゲームを作るうえで最終的に必要になります。

Canvas チュートリアル - Web API | MDN
このチュートリアルは、 要素を使用して二次元のグラフィックを描画する方法を、基本から説明します。ここでの例は、キャンバスで何ができるかを明確に示すものであり、独自のコンテンツを作成するためのコードスニペットも提供しています。
tfjs-models/pose-detection/README.md at master · tensorflow/tfjs-models
Pretrained models for TensorFlow.js. Contribute to tensorflow/tfjs-models development by creating an account on GitHub.

下記のコードをscipt.jsにコピペしてみましょう。

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

// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(
            poseDetection.SupportedModels.MoveNet,
            {
                modelType: poseDetection.movenet.modelType.MULTIPOSE_LIGHTNING,
            }
        );
    } 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) => {
        pose.keypoints.forEach((keypoint) => {
            // キーポイントnoseの座標を取得し、追従する画像を描画
            console.log(pose);
            if (keypoint.name == 'nose') {
                drawTrackingImage(keypoint.x, keypoint.y, context);
            }
        });
    });
}

// 追従する画像を描画する関数
function drawTrackingImage(x, y, context) {
    if (trackingImage.complete) {
        const imageWidth = 600; // 描画する画像の幅
        const imageHeight = 600; // 描画する画像の高さ
        context.drawImage(trackingImage, x - imageWidth / 2, y - imageHeight / 2, imageWidth, imageHeight);
    }
}

// 画像をロードする関数
function loadTrackingImage() {
    trackingImage = new Image();
    trackingImage.src = 'https://4.bp.blogspot.com/-3EsvqEHcmnk/V8jqXwzqshI/AAAAAAAA9d4/vzxUxLon2qwQ8XJT8ePYeEJme2bXPZMkgCLcB/s450/camera_kao_ninshiki.png'; // 追従する画像ファイルのパス
}

// ポーズを検出し続ける関数
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');
    loadTrackingImage(); // 追従する画像をロードする

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

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

うまくいくと顔の部分に画像が表示されます。

こんな感じ

キーポイントとボディラインを描画する関数を排し、画像を描画する関数に置き換えました。

今回重要なのは「条件分岐」と「顔に表示」のロジックです。

まず条件分岐ですが、こちらになります。キーポイントの名前がnoseだったらキーポイントの座標に画像を表示させる処理を行っています。

if (keypoint.name == 'nose') {
    drawTrackingImage(keypoint.x, keypoint.y, context);
    //drawTrackingImage(キーポイントのx座標, キーポイントのy座標, 取得したキャンバス要素overlayCanvas);
}

顔に表示する方は以下になります。ここはdrawImageがポイントです。

drawImage(画像, x座標, y座標, 画像の横幅, 画像の高さ);

場合によって引数が変わるので注意。詳しいことはこちらで。

CanvasRenderingContext2D: drawImage() メソッド - Web API | MDN
Canvas 2D API の CanvasRenderingContext2D.drawImage() メソッドは、キャンバスに画像を描画するいくつかの方法を提供します。

これらをまとめると下記になるわけですね。座標の指定をするときに「x – imageWidth / 2」としていますが、これはどうしてでしょうか。上のリファレンスを読むとわかるようになります。

function drawTrackingImage(x, y, context) {
    if (trackingImage.complete) {
        const imageWidth = 600; // 描画する画像の幅
        const imageHeight = 600; // 描画する画像の高さ
        context.drawImage(trackingImage, x - imageWidth / 2, y - imageHeight / 2, imageWidth, imageHeight);
    }
}

他にも画像を読み込む関数もありますが、そちらは皆さんで確認してみましょう。

課題

今回の課題は難易度高めです。適宜リファレンスやネットで調べていきましょう。

  • 知識問題
    1. コード解説より。なぜfor文で繰り返していないのに処理をし続ける?
    2. 複数人検出より。複数人対応がモデルの種類変更だけで実現できた理由は?なぜキーポイントの表示に関するコードを書き換えなくても良かった?
  1. callbackとは?
  2. forEachとは?
  • コード書き換え問題
    1. 顔に表示される画像を差し替えよ(画像は自由)
    2. 右手に赤色、左手に白色の旗の画像が表示されるようにせよ
    3. 【超難問】旗が手首ではなく、手ひらの上に表示されるようにせよ

配列の値やキーポイント名を変えると、うまく動作します。

配列の要素キーポイント名部位
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右足首

問3に関しては難しいので、余力がある場合に挑戦してみてください。

  1. 画像はどこで参照していますか?
  2. 旗が右手首(right_wrist)と左手首(left_wrist)に表示される実装を作りましょう
  3. 手の位置は肘と手首の延長線上にあるということは……?

旗素材はこちらを使用してください。(右クリック>名前を付けて画像を保存)

あくまで一例なので、下記以外の書き方でも要件を満たせばOKです。

今回は条件分岐と画像の配置を複製して実装する形のコードにしました。

この段階では両手首に旗が表示されるので手に持っている感じはないですね。問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,
            {
                modelType: poseDetection.movenet.modelType.MULTIPOSE_LIGHTNING,
            }
        );
    } 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) => {
        pose.keypoints.forEach((keypoint) => {
            // キーポイントnoseの座標を取得し、追従する画像を描画
            if (keypoint.name == 'nose') {
                drawfaceImage(keypoint.x, keypoint.y, context);
            }
            if (keypoint.name == 'right_wrist') {
                drawRightFlagImage(keypoint.x, keypoint.y, context);
            }
            if (keypoint.name == 'left_wrist') {
                drawLeftFlagImage(keypoint.x, keypoint.y, context);
            }
        });
    });
}

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

function drawRightFlagImage(x, y, context) {
    if (rightFlag.complete) {
        const imageWidth = 200;
        const imageHeight = 200;
        context.drawImage(rightFlag, x - imageWidth / 2, y - imageHeight / 2, imageWidth, imageHeight);
    }
}

function drawLeftFlagImage(x, y, context) {
    if (leftFlag.complete) {
        const imageWidth = 200;
        const imageHeight = 200;
        context.drawImage(leftFlag, x - imageWidth / 2, y - imageHeight / 2, imageWidth, imageHeight);
    }
}


// 画像をロードする関数
function loadfaceImage() {
    faceImage = new Image();
    faceImage.src = 'img/face.png';
    rightFlag = new Image();
    rightFlag.src = 'img/flag_red.png';
    leftFlag = new Image();
    leftFlag.src = 'img/flag_white.png';
}

// ポーズを検出し続ける関数
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();
    });
};

こちらもあくまで一例です。肘と手首の延長線上に手があることを利用しています。

ベクトル計算の考え方が必要なのが少し問題を難しくしていましたね。

ちなみに、今回の実装だとカメラと被写体の距離を考慮に入れていない実装なので、カメラから離れると旗の位置もずれていきます。その辺も考慮する場合は、肘と手首の距離でカメラとの距離を計算する必要があります。要はさらにコードが複雑化するわけです。

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

// MoveNetモデルを読み込む関数
async function loadMoveNet() {
    try {
        await tf.setBackend('webgl');
        detector = await poseDetection.createDetector(
            poseDetection.SupportedModels.MoveNet,
            {
                modelType: poseDetection.movenet.modelType.MULTIPOSE_LIGHTNING,
            }
        );
    } 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) => {
        pose.keypoints.forEach((keypoint) => {
            // キーポイントnoseの座標を取得し、追従する画像を描画
            if (keypoint.name == 'nose') {
                drawfaceImage(keypoint.x, keypoint.y, context);
            }
            // 右手の旗を描画
            drawRightFlagImage(poses, context);

            // 左手の旗を描画
            drawLeftFlagImage(poses, context);
        });
    });
}

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

// 右手の旗を描画する関数
function drawRightFlagImage(poses, context) {
    const rightElbowKeypoint = poses[0].keypoints.find(kp => kp.name === 'right_elbow');
    const rightWristKeypoint = poses[0].keypoints.find(kp => kp.name === 'right_wrist');

    if (rightElbowKeypoint && rightWristKeypoint) {
        const vectorX = rightWristKeypoint.x - rightElbowKeypoint.x;
        const vectorY = rightWristKeypoint.y - rightElbowKeypoint.y;
        const length = Math.sqrt(vectorX ** 2 + vectorY ** 2);
        const unitVectorX = vectorX / length;
        const unitVectorY = vectorY / length;

        const flagWidth = 200;
        const flagHeight = 200;
        const offsetX = unitVectorX * (length + (flagWidth / 2) -300);
        const offsetY = unitVectorY * (length + (flagHeight / 2) -300);

        const x = rightWristKeypoint.x + offsetX;
        const y = rightWristKeypoint.y + offsetY;

        if (rightFlag.complete) {
            context.drawImage(rightFlag, x - flagWidth / 2, y - flagHeight / 2, flagWidth, flagHeight);
        }
    }
}

// 左手の旗を描画する関数
function drawLeftFlagImage(poses, context) {
    const leftElbowKeypoint = poses[0].keypoints.find(kp => kp.name === 'left_elbow');
    const leftWristKeypoint = poses[0].keypoints.find(kp => kp.name === 'left_wrist');

    if (leftElbowKeypoint && leftWristKeypoint) {
        const vectorX = leftWristKeypoint.x - leftElbowKeypoint.x;
        const vectorY = leftWristKeypoint.y - leftElbowKeypoint.y;
        const length = Math.sqrt(vectorX ** 2 + vectorY ** 2);
        const unitVectorX = vectorX / length;
        const unitVectorY = vectorY / length;

        const flagWidth = 200;
        const flagHeight = 200;
        const offsetX = unitVectorX * (length + (flagWidth / 2) -300);
        const offsetY = unitVectorY * (length + (flagHeight / 2) -300);

        const x = leftWristKeypoint.x + offsetX;
        const y = leftWristKeypoint.y + offsetY;

        if (leftFlag.complete) {
            context.drawImage(leftFlag, x - flagWidth / 2, y - flagHeight / 2, flagWidth, flagHeight);
        }
    }
}


// 画像をロードする関数
function loadfaceImage() {
    faceImage = new Image();
    faceImage.src = 'img/face.png';
    rightFlag = new Image();
    rightFlag.src = 'img/flag_red.png';
    leftFlag = new Image();
    leftFlag.src = 'img/flag_white.png';
}

// ポーズを検出し続ける関数
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();
    });
};

まとめ

お疲れ様でした。今回は複数人対応とキャンバス、キーポイントを学びました。実はこの時点でゲームを作るための知識習得は完了しています。

次回4回目は「旗上げゲーム」を題材にして、ゲームの作り方を学んでいきます。今回の課題は布石だったわけですね。

それでは。