こんにちは。
データインテグレーション開発グループ データソリューションチームの長谷川と申します。
GoogleがOSSとして公開しているTensorFlowのうち、ブラウザ・node.jsで機械学習のトレーニングおよび実行が出来るライブラリとしてTensorFlow.jsがあります。
TensorFlow.jsを使い、WEBカメラなどの入力からブラウザ上でリアルタイムにユーザーの姿勢検出ができるPoseNetモデルが話題になりましたが、今回このPoseNetを使って、手の座標内にボール(障害物)が触れたら、ボールが消えるジェスチャーゲームを作りました。
ジェスチャーゲーム
こういうものです。
デモの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
PoseNet
はTensorFlow.js
を使用した、ブラウザで実行可能な姿勢推定ディープラーニングモデルです。
PoseNet
はパラメタとしてimage
, imageScaleFactor
, flipHorizontal
, outputStride
を持ちます。
それぞれの意味はリンク先の通りとなります。
パラメタ名 | 説明 |
---|---|
image | ImageData / HTMLImageElement / HTMLCanvasElement / HTMLVideoElement のいずれか |
imageScaleFactor | ネットワークに画像を渡す際の縮尺。0.2 ~ 1.0を範囲とし、大きいければ大きいほど予測の正確さが増す。ただしその分処理速度が遅くなる |
flipHorizontal | 結果を左右反転する場合にtrue。鏡像がONになっているようなWEBカメラで使う |
outputStride | モデルに渡すストリームのストライド。大きければ大きいほど予測に用いるインプットのモーションが増え、精度が上がる(多分) |
Keypointsと予測精度
PoseNetが判定できる人体の部分はkeypoints
というオブジェクトに格納されています。
ジェスチャーゲームとするにあたり、まず各部分がどれくらいの予測精度なのかを事前に調査しました。
調査対象画像は、ぱくたそから、上半身および両腕が映っている写真としてこちらを選びました。
この画像を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
は簡潔にとてもわかりやすく書かれているため、今回のゲームはこちらから不要な処理を削って必要最低限の部分のみを切り出して作成しております。
両手の予測結果を描画する
まず初めにleftWrist
とrightWrist
の予測結果を合成して、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の再掲
ローカル実行の注意点
ローカルで実行する場合、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搭載が当たり前になっていくことで、このようなプロダクトがどんどん増えていく予感がします。
お知らせ
ぐるなびでは一緒に働く仲間を募集しています。