手のジェスチャーでコンテンツを操作するARインターフェース
マウスは置いて、手だけで動かす小さなARです。8種類のジェスチャーを安定検出し、誤反応を抑える工夫がテーマ。終盤は“グー5秒”の遊びも。
MediaPipe Handsで手のジェスチャーを認識して、パターンに応じてコンテンツを表示するシステムを作った。
No.1の時には時計、ロックの時には動画、グーを5秒続けると画像がはじける。midori270の改良版で、パターンを増やした。
やりたかったこと
拡張現実(AR)のインターフェースを考えてた。
面白い。
手の動きでコンテンツを操作できたら面白い。ボタンやマウスを使わずに、ジェスチャーだけで。
midori270では2つのパターンだけだったけど、もっと増やしたい。8種類のジェスチャーを認識できるようにした。
認識できるジェスチャー(8種類)
MediaPipe Handsで手のランドマーク(21点)を検出して、指の伸び具合からジェスチャーを判定する。
1. No.1(人差し指だけ伸ばす)→ 時計を表示
2. ピースサイン(人差し指+中指)→ 画像を表示
3. ロック(親指+小指)→ 動画を再生
4. グー(全部握る、5秒間)→ 画像がはじける
5. パー(全部開く)
6. グッド(親指だけ伸ばす)
7. 電話(親指+小指、ロックと同じ形状)
8. OKサイン(親指と人差し指で円を作る)
指の伸び具合の判定
MediaPipeが検出した21個のランドマークから、各指が伸びているかを計算する。
function detectHandPose(landmarks) {
const wrist = landmarks[0];
// 親指が伸びているか
const thumbExtended = landmarks[4].x > landmarks[3].x + 0.05;
// 人差し指が伸びているか
const indexExtended = landmarks[8].y < landmarks[6].y;
// 中指が伸びているか
const middleExtended = landmarks[12].y < landmarks[10].y;
// 薬指が伸びているか
const ringExtended = landmarks[16].y < landmarks[14].y;
// 小指が伸びているか
const pinkyExtended = landmarks[20].y < landmarks[18].y;
// パターンに応じてジェスチャーを判定
if (indexExtended && middleExtended &&
!thumbExtended && !ringExtended && !pinkyExtended) {
return { name: "ピースサイン", confidence: 0.85 };
}
if (indexExtended && !middleExtended &&
!thumbExtended && !ringExtended && !pinkyExtended) {
return { name: "No.1", confidence: 0.85 };
}
// その他のパターン...
}
指の先端(tip)と関節(pip)のY座標を比較して、伸びているかを判定してる。
最初は、距離で計算してた。でも、手の角度によって距離が変わるから、精度が低かった。
Y座標の比較に変えたら、精度が上がった。指が上を向いてれば伸びてる、下を向いてれば曲がってる。シンプルだけど、これで十分。
グーの長時間検出
グーを5秒間続けると、画像がパーティクルになって飛び散る。
let rockDetectionStartTime = 0;
const rockDetectionDuration = 5000; // 5秒
if (detectedPose.name === "グー") {
if (rockDetectionStartTime === 0) {
rockDetectionStartTime = Date.now();
}
const elapsedTime = Date.now() - rockDetectionStartTime;
if (elapsedTime >= rockDetectionDuration) {
// 画像をパーティクル化
generateParticles(rockImageElement);
rockDetectionStartTime = 0;
}
} else {
rockDetectionStartTime = 0;
}
最初は2秒にしてた。短すぎて、誤反応が多かった。意図せずエフェクトが発生する。
10秒にしたら、長すぎて疲れた。
5秒がちょうどいい。意図的にグーを続ければ発生するけど、誤反応は少ない。
パーティクルエフェクト
画像を100個のパーティクルに分割して、ランダムな方向に飛ばす。
function generateParticles(imageElement) {
const particleCount = 100;
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.backgroundImage = `url(${imageElement.src})`;
// ランダムな位置とサイズ
const size = Math.random() * 50 + 20;
particle.style.width = size + 'px';
particle.style.height = size + 'px';
// ランダムな方向と速度
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 5 + 2;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed;
// アニメーション
particle.style.left = startX + 'px';
particle.style.top = startY + 'px';
setTimeout(() => {
particle.style.transform = `translate(${vx * 100}px, ${vy * 100}px) rotate(${Math.random() * 720}deg)`;
particle.style.opacity = '0';
}, 10);
particlesContainer.appendChild(particle);
// 1秒後に削除
setTimeout(() => particle.remove(), 1000);
}
}
パーティクル数は100個。
最初は500個にしてた。多すぎて、描画が重くなった。ブラウザがカクつく。
50個だと、迫力が足りなかった。
100個が、見栄えとパフォーマンスのバランスが良かった。
ジェスチャー認識の安定化
ジェスチャー認識は、誤検出が多い。安定化のため、連続検出のカウンターを使う。
let lastDetectedPose = null;
let poseDetectionCounter = 0;
const poseDetectionThreshold = 5;
if (detectedPose && detectedPose.name === lastDetectedPose) {
poseDetectionCounter++;
} else {
poseDetectionCounter = 0;
lastDetectedPose = detectedPose ? detectedPose.name : null;
}
// 5フレーム連続で同じポーズが検出されたら確定
if (poseDetectionCounter >= poseDetectionThreshold) {
// ポーズ確定、コンテンツ表示
}
5フレーム連続で同じジェスチャーが検出されたら、確定する。
最初は1フレームで確定してた。チラつきが激しい。手を動かすと、ジェスチャーが頻繁に変わる。
10フレームにしたら、反応が遅すぎる。
5フレームが、安定性と反応速度のバランスが良かった。
ハマったところ
OKサインの判定
OKサイン(親指と人差し指で円を作る)の判定が難しかった。
const thumbTip = landmarks[4];
const indexTip = landmarks[8];
const thumbIndexDist = Math.hypot(
thumbTip.x - indexTip.x,
thumbTip.y - indexTip.y,
thumbTip.z - indexTip.z
);
const wrist = landmarks[0];
const middleTip = landmarks[12];
const handSize = Math.hypot(
wrist.x - middleTip.x,
wrist.y - middleTip.y,
wrist.z - middleTip.z
);
// 手のサイズの10%以内なら接触
if (thumbIndexDist < handSize * 0.1) {
return { name: "OKサイン", confidence: 0.75 };
}
親指と人差し指の距離を、手のサイズで正規化してる。
最初は絶対距離で判定してた。手が大きい人と小さい人で、判定が変わってしまった。
手のサイズで正規化したら、誰でも使えるようになった。
閾値は0.1(10%)。0.05だと誤検出が多かった。0.15だと検出されにくかった。
深度情報との連携
ピースサインの時、画像を表示するけど、深度情報を使って表示位置を調整してる。
手が近いと画像を大きく、遠いと小さく。
const avgDepth = getLandmarkDepth(landmarks[8]); // 人差し指の深度
const depthScale = 1.0 - avgDepth; // 深度値を反転
const imageSize = 100 + depthScale * 100; // 100〜200pxに変化
最初は深度を考慮してなかった。手が遠くても近くても、画像のサイズが同じ。違和感があった。
深度を考慮したら、自然な表示になった。
グーの誤検出
最初、グーの判定が甘くて、何もしてない時に誤検出された。
全ての指が曲がってる = グー、だけじゃダメだった。
5秒間連続検出を追加したら、誤検出が減った。意図的にグーを続けないと、エフェクトが発生しない。
パフォーマンス
PCでの動作を測定した。
| 処理 | 時間 |
|------|------|
| MediaPipe Hands推論 | 約15ms |
| ジェスチャー判定 | 約1ms |
| 描画処理 | 約5ms |
| 合計 | 約21ms |
約47fpsで動作する。60fpsには届かないけど、滑らか。
スマホだと、MediaPipe推論が50ms以上かかった。約20fps。まあまあ使える。
パーティクルエフェクトは、PCでもスマホでも60fpsで動く。CSSアニメーションだから軽い。
結果
手のジェスチャーでコンテンツを操作できるARインターフェースを実装できた。
- MediaPipe Handsで21個のランドマーク検出
- 8種類のジェスチャー認識
- パターンに応じてコンテンツ表示(時計、画像、動画)
- グー5秒でパーティクルエフェクト
- 5フレーム連続検出で安定化
ジェスチャー判定の閾値調整が難しかったけど、何度も試して最適値を見つけた。
パーティクルエフェクトは予想以上に迫力があって、面白い。グーを5秒続けると発生するので、意図的に操作してる感がある。
ジェスチャーゲームで遊んでみてください:
全体の処理フロー:
これだ。
使ってみて
実際にジェスチャー操作を体験してみてください(PC/スマホ対応):
デモを起動する(全画面表示)
操作のポイントは3つ:
- グーは5秒間維持でエフェクト発生(誤検出を防ぐための設計)
- 5フレーム連続検出で確定(安定性と反応速度のバランス)
- 距離に応じた表示調整あり(手が近いほど大きく表示)
手応え。
まとめ
今回は、手のジェスチャーでコンテンツを操作するARインターフェースを実装しました。
ポイントは以下の4つ:
- MediaPipe Handsで21個のランドマーク検出(約15ms)
- 8種類のジェスチャー認識(No.1、ピース、ロック、グーなど)
- 5フレーム連続検出で安定化(誤検出を防ぐ)
- グー5秒でパーティクルエフェクト(100個、見栄えとパフォーマンスのバランス)
ジェスチャー判定の閾値調整に時間がかかったけど、何度も試して最適値を見つけた。
ARインターフェースの実験を考えている方の参考になれば嬉しいです。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。