PoseNetを使ってジェスチャーゲームを作る

こんにちは。

データインテグレーション開発グループ データソリューションチームの長谷川と申します。

GoogleがOSSとして公開しているTensorFlowのうち、ブラウザ・node.jsで機械学習のトレーニングおよび実行が出来るライブラリとしてTensorFlow.jsがあります。

TensorFlow.jsを使い、WEBカメラなどの入力からブラウザ上でリアルタイムにユーザーの姿勢検出ができるPoseNetモデルが話題になりましたが、今回このPoseNetを使って、手の座標内にボール(障害物)が触れたら、ボールが消えるジェスチャーゲームを作りました。

ジェスチャーゲーム

こういうものです。

f:id:hasegawa-ma:20180611084359g:plain

デモのURLはこちらになります。

https://gnavi-blog.github.io/posenet_sample/
※ カメラ付きのPCでお試しください

数年前にMicrosoftのKinectで似たようなものを作ったのですが、Kinect v2自体が生産終了となりこういうものを作るにもハードウェアを探すのが難しくなったため、代わりの技術でできないかと思い着手しました。

(ちなみにKinectは2019年にKinect for Azureという後継が出るらしいです)

動作環境として以下が必要となります。

  • カメラ

  • Webブラウザ(Google Chrome)

  • TensorFlow.js

  • PoseNet

今回の作成に当たり、ここに紹介されているソースコードを元に作らせていただきました。

PoseNet

PoseNetTensorFlow.jsを使用した、ブラウザで実行可能な姿勢推定ディープラーニングモデルです。

PoseNetはパラメタとしてimage, imageScaleFactor, flipHorizontal, outputStrideを持ちます。

それぞれの意味はリンク先の通りとなります。

パラメタ名 説明
image ImageData / HTMLImageElement / HTMLCanvasElement / HTMLVideoElement のいずれか
imageScaleFactor ネットワークに画像を渡す際の縮尺。0.2 ~ 1.0を範囲とし、大きいければ大きいほど予測の正確さが増す。ただしその分処理速度が遅くなる
flipHorizontal 結果を左右反転する場合にtrue。鏡像がONになっているようなWEBカメラで使う
outputStride モデルに渡すストリームのストライド。大きければ大きいほど予測に用いるインプットのモーションが増え、精度が上がる(多分)

Keypointsと予測精度

PoseNetが判定できる人体の部分はkeypointsというオブジェクトに格納されています。

ジェスチャーゲームとするにあたり、まず各部分がどれくらいの予測精度なのかを事前に調査しました。

調査対象画像は、ぱくたそから、上半身および両腕が映っている写真としてこちらを選びました。

f:id:g-editor:20180514173606j:plain

この画像をPosenetのサンプルに従って判定します。

<html>
  <head>
    <!-- Load TensorFlow.js -->
    <script src="https://unpkg.com/@tensorflow/tfjs"></script>
    <!-- Load Posenet -->
    <script src="https://unpkg.com/@tensorflow-models/posenet"></script>
  </head>

  <body>
    <img id='test' src='SDS_tedejesucya-surudansei_TP_V.jpg' style='position:absolute;' />
  </body>
  <script>
    var imageScaleFactor = 0.2;
    var outputStride = 16;
    var flipHorizontal = false;

    var imageElement = document.getElementById('test');

    posenet.load().then(function(net){
        return net.estimateSinglePose(imageElement, imageScaleFactor, flipHorizontal, outputStride)
    }).then(function(pose){
        console.log(pose);
    });
  </script>
</html>

予測結果

総合スコア

score: 0.4959888464825995

各Keypointsのスコア

score point position
nose {x: 1065.0048027663934, y: 504.97506563780735} 0.999180257320404
leftEye {x: 1132.225441854508, y: 400.7644403176229} 0.9930536150932312
rightEye {x: 991.3161981301229, y: 421.81172355276635} 0.9981974959373474
leftEar {x: 1208.5614113729507, y: 472.46449955174177} 0.873340368270874
rightEar {x: 921.4612416752049, y: 492.0003121798155} 0.6079350709915161
leftShoulder {x: 1279.7808337602457, y: 803.7671298668032} 0.17923228442668915
rightShoulder {x: 869.7494556864754, y: 863.214491547131} 0.9842857718467712
leftElbow {x: 710.6758292776639, y: 1334.5947265625} 0.06460901349782944
rightElbow {x: 726.623254994877, y: 1319.8995421362704} 0.9619631171226501
leftWrist {x: 1227.7318935706967, y: 1100.893874871926} 0.15221595764160156
rightWrist {x: 655.544873847336, y: 1416.3513383709014} 0.7794095873832703
leftHip {x: 1062.7298924180327, y: 1452.388575819672} 0.08158262073993683
rightHip {x: 927.8790983606557, y: 1609.0373655225408} 0.5059378147125244
leftKnee {x: 1012.0338915215164, y: 1612.8401959528687} 0.036540437489748
rightKnee {x: 930.1011782786885, y: 1619.2507684426228} 0.07544361054897308
leftAnkle {x: 834.4814613217212, y: 1593.0993852459014} 0.054534316062927246
rightAnkle {x: 828.7182216956967, y: 1589.5410156249998} 0.08434905111789703

いくつか他の上半身のみの写真でも試してみましたが、全身が映っていないせいか総合スコアは低めになりました。

また顔のパーツの精度に比べて上半身の各パーツの精度に結構ばらつきが見えるようでしたが、それでもゲームとして使う分には問題ない精度が返されることがわかりましたので開発に取り掛かります。

ジェスチャーゲームの開発

開発に取り掛かる前に、PoseNetのサンプルソースを確認します。

メイン処理はcamera.jsで行っており、姿勢予測はその中のposeDetectionFrame()で実装されています。

camera.js内でbindPage()が実行されると、loadVideo()が呼び出され、setupCamera()から動画のストリームを準備、そこで取得したvideoオブジェクトをrequestAnimationFrame()内でposeDetectionFrame()に渡しています。

poseDetectionFrame()内では、videoのストリームをnet.estimateSinglePose()に渡して姿勢予測を実行、動画のblobと予想結果の座標を重ね合わせてcanvasに描画するのが大まかな処理の流れとなります。

camera.jsは簡潔にとてもわかりやすく書かれているため、今回のゲームはこちらから不要な処理を削って必要最低限の部分のみを切り出して作成しております。

両手の予測結果を描画する

まず初めにleftWristrightWristの予測結果を合成して、canvasを再描画する処理を作ります。

videoタグはcreateElementで要素だけ作成したり、style=display:none;を指定するとストリームをスクリプト側に渡してくれないようなので、position=absoluteでvideoタグの上に同じサイズのcanvasを重ねました。

<!DOCTYPE html>
<html>
  <head>
    <script src="https://unpkg.com/@tensorflow/tfjs"></script>
    <script src="https://unpkg.com/@tensorflow-models/posenet"></script>
  </head>
  <body>
    <video id="video" width="800px" height="600px" autoplay="1" style="position:absolute;"></video>
    <canvas id="canvas" width="800px" height="600px" style="position:absolute;"></canvas>
    <div class="ball"></div>
</body>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/stats.js/r16/Stats.js"></script>
  <script src="posenet_sample.js"></script>
</html>

jsは以下のようになります。

const imageScaleFactor = 0.2;
const outputStride = 16;
const flipHorizontal = false;
const stats = new Stats();
const contentWidth = 800;
const contentHeight = 600;

bindPage();

async function bindPage() {
    const net = await posenet.load(); // posenetの呼び出し
    let video;
    try {
        video = await loadVideo(); // video属性をロード
    } catch(e) {
        console.error(e);
        return;
    }
    detectPoseInRealTime(video, net);
}

// video属性のロード
async function loadVideo() {
    const video = await setupCamera(); // カメラのセットアップ
    video.play();
    return video;
}

// カメラのセットアップ
// video属性からストリームを取得する
async function setupCamera() {
    const video = document.getElementById('video');
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
        const stream = await navigator.mediaDevices.getUserMedia({
            'audio': false,
            'video': true});
        video.srcObject = stream;

        return new Promise(resolve => {
            video.onloadedmetadata = () => {
                resolve(video);
            };
        });
    } else {
        const errorMessage = "This browser does not support video capture, or this device does not have a camera";
        alert(errorMessage);
        return Promise.reject(errorMessage);
    }
}

// 取得したストリームをestimateSinglePose()に渡して姿勢予測を実行
// requestAnimationFrameによってフレームを再描画し続ける
function detectPoseInRealTime(video, net) {
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    const flipHorizontal = true; // since images are being fed from a webcam

    async function poseDetectionFrame() {
        stats.begin();
        let poses = [];
        const pose = await net.estimateSinglePose(video, imageScaleFactor, flipHorizontal, outputStride);
        poses.push(pose);

        ctx.clearRect(0, 0, contentWidth,contentHeight);

        ctx.save();
        ctx.scale(-1, 1);
        ctx.translate(-contentWidth, 0);
        ctx.drawImage(video, 0, 0, contentWidth, contentHeight);
        ctx.restore();

        poses.forEach(({ score, keypoints }) => {
            // keypoints[9]には左手、keypoints[10]には右手の予測結果が格納されている 
            drawWristPoint(keypoints[9],ctx);
            drawWristPoint(keypoints[10],ctx);
        });

        stats.end();

        requestAnimationFrame(poseDetectionFrame);
    }
    poseDetectionFrame();
}

// 与えられたKeypointをcanvasに描画する
function drawWristPoint(wrist,ctx){
    ctx.beginPath();
    ctx.arc(wrist.position.x , wrist.position.y, 3, 0, 2 * Math.PI);
    ctx.fillStyle = "pink";
    ctx.fill();
}

障害物の落下と衝突制御の追加

ここまでできたら後は簡単です。

フレーム内に障害物として任意のボールオブジェクトを生成し、フレームごとに両手の予測結果の座標とボールオブジェクトとの重なりを判定し、両手のどちらかがボールオブジェクトの座標内であればボールを削除する処理を追加します。

またボールが画面下まで落下してcanvasの要素から出てしまったら、新たに上からボールを新しく落下させる処理を追加します。

// ボールの落下及び両手との判定制御
function ballsDecision(ctx,wrists){
    for(i=0;i<ballNum;i++){
        wrists.forEach((wrist) => {
            // 両手いずれかの座標がボールの中に入ったと判定されたらボールの位置をリセット
            if((balls[i].x - 50)  <= wrist.position.x && wrist.position.x <= (balls[i].x + 50) &&
               (balls[i].y - 50) <= wrist.position.y && wrist.position.y <= (balls[i].y + 50)){
                balls[i] = resetBall();
                return;
            } else {
                balls[i].y += 20;
                // 画面外に出たボールの座標を0に戻す
                if (balls[i].y > contentHeight) {
                    balls[i] = resetBall();
                }  else {
                    // それ以外はボールの座標を下に更新
                    ctx.beginPath();
                    ctx.arc(balls[i].x , balls[i].y, 25, 0, 2 * Math.PI);
                    ctx.fillStyle = balls[i].color
                    ctx.fill();
                }
            }
        });
    }
}

function resetBall(){
    color = Math.floor(Math.random()*3);
    return {color:colors[color], x:Math.floor(Math.random()*(contentWidth  - 50) + 50), y:0}
}
        poses.forEach(({ score, keypoints }) => {
            drawWristPoint(keypoints[9],ctx);
            drawWristPoint(keypoints[10],ctx);
            ballsDecision(ctx,[keypoints[9],keypoints[10]]); // 両手の位置予測取得後にボールの描画および衝突を判定する処理を追加
        });

最後に顔のパーツの座標を取得し、ぐるなびのキャラクターであるなびこの画像を被せる処理を追加します。

function drawNaviko(nose, leye, ctx){
    navScale = (leye.position.x - nose.position.x - 50) / 20;
    if (navScale < 1) navScale = 1;
    let nw = naviko.width * navScale;
    let nh = naviko.height * navScale;
    ctx.drawImage(naviko,nose.position.x - nh / 2 , nose.position.y - nh / 1.5, nw, nh);
}

これで一通りの実装が完成しました。

全ソースはGitHubにあげてます。

実行

こちらからご確認ください。

https://gnavi-blog.github.io/posenet_sample/

※冒頭のgifの再掲

f:id:hasegawa-ma:20180611084359g:plain

ローカル実行の注意点

ローカルで実行する場合、ChromeだとローカルからXMLHttpRequestの実行を許可していないため、エラーとなる場合があります。

その場合は下記のオプションをつけてChromeを再起動する必要があります。

windows

chrome.exe --disable-web-security --user-data-dir

mac

open /Applications/Google\ Chrome.app/ --args --disable-web-security --user-data-dir

負荷

TensorFlow.jsはブラウザで実行することを考慮して軽量な実装になっていますが、それでもrequestAnimationFrameやcanvas描画を同時に行うようなプログラムはかなり負荷がかるため、自分のPCではimageScaleFactorを最も小さい0.2まで下げないと現実的な速度で動きませんでした。

このあたりの挙動はPCのスペックによって変わりますので、ご自分のPCに合わせてご変更ください。

最後に

これまでこういったジェスチャーゲームを作る場合、別途専用ハードが必要だったため興味があっても開発が難しい部分がありましたが、ブラウザで姿勢推定までできるようになったことで簡単にリアルタイム姿勢推定を組み込めるようになったことを実感しました。

コーディングにおいても、私が以前Kinect SDKを使ってC#で同じようなゲームを作った時は調査から開発を含めて数カ月かかってしまいましたが、PoseNetはWEBの知識がそのまま使えるためこの規模であればほんの数日で作ることができ、ブラウザで完結するので作ってすぐ公開できるのも大きなメリットだと思います。

現時点でも十分な速度で高い精度の予測を行えますが、今後はもっと早くもっと正確になっていくでしょうし、PCのスペックもどんどん上がってGPU搭載が当たり前になっていくことで、このようなプロダクトがどんどん増えていく予感がします。


お知らせ
ぐるなびでは一緒に働く仲間を募集しています。



長谷川
ぐるなびビッグデータを使った商品開発を行っています。 2児の父親で、プライベートでは子供が喜ぶような何かを作ったりしてますが、どれも反応がいまいちなのが悲しいです。