手の動きを3D空間に立体表示した
WEBカメラの映像から、AIで深度マップを推論して、手の特徴点を立体的に認識する実験をした。
MediaPipe Handsで手のランドマークを検出して、深度情報と組み合わせて3D空間に配置。実空間の手の動きが、リアルタイムで3D空間に表示される。
なぜ作ったか
midori277で手のパターン認識をやった時に、平面的な認識だけじゃ物足りないと感じた。
実空間の手は立体的に動いてる。奥に引っ込めたり、手前に突き出したり。その動きを3D空間で正確に表現したかった。
拡張現実のインターフェースを考える上で、立体的な認識は必須だと思った。
深度推論の実装
最初は深度マップの生成からスタート。MiDaSモデル(ONNXフォーマット)を使った。
class GPUInferenceManager {
constructor(modelPath) {
this.modelPath = modelPath;
this.session = null;
this.isWebGPUSupported = this._checkWebGPUSupport();
this.inferenceConfig = {
width: 512,
height: 512
};
}
// WebGPUサポートをチェック
_checkWebGPUSupport() {
return !!navigator.gpu;
}
// モデルを読み込む
async loadModel() {
if (this.session) return this.session;
if (this.isWebGPUSupported) {
console.log('WebGPUを使用してONNXモデルを読み込みます');
this.session = await ort.InferenceSession.create(
this.modelPath,
{ executionProviders: ["webgpu"] }
);
} else {
// フォールバック
this.session = await ort.InferenceSession.create(
this.modelPath,
{ executionProviders: ["webgl", "wasm"] }
);
}
return this.session;
}
}
ONNX RuntimeでWebGPUを使えるようにした。CPUだと処理が追いつかない。
WebGPUが使えない環境では、WebGLかWASMにフォールバック。これで幅広い環境で動く。
深度マップの可視化
深度マップを立体的に表示するために、シェーダーで凹凸を作った。
const depthMaterial = new THREE.ShaderMaterial({
uniforms: {
depthMap: { value: depthTexture },
displacementScale: { value: 15.0 },
invertDepth: { value: false }
},
vertexShader: `
uniform sampler2D depthMap;
uniform float displacementScale;
uniform bool invertDepth;
varying vec2 vUv;
void main() {
vUv = uv;
// 深度テクスチャから深度値を取得
vec4 depth = texture2D(depthMap, uv);
float depthValue = (depth.r + depth.g + depth.b) / 3.0;
// 凹凸の方向を制御
if (invertDepth) {
depthValue = 1.0 - depthValue;
}
// 法線方向に頂点を移動(凹凸を作成)
vec3 newPosition = position - normal * depthValue * displacementScale;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
`,
fragmentShader: `
uniform sampler2D depthMap;
varying vec2 vUv;
void main() {
vec4 color = texture2D(depthMap, vUv);
gl_FragColor = color;
}
`,
side: THREE.DoubleSide
});
深度値に応じて頂点を法線方向に移動させる。これで平面が立体的になる。
displacementScaleは凹凸の強さ。最初30にしたら、凹凸が激しすぎて何が何だか分からなくなった。15くらいがちょうど良かった。
手の特徴点検出
MediaPipe Handsを使った。21個のランドマークを検出してくれる。
const hands = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 2, // 最大2本の手
modelComplexity: 1, // モデルの複雑さ
minDetectionConfidence: 0.7, // 検出信頼度
minTrackingConfidence: 0.7 // トラッキング信頼度
});
hands.onResults((results) => {
// 検出結果を処理
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
for (const landmarks of results.multiHandLandmarks) {
// 3D空間にマッピング
mapHandLandmarksToDepthMap(landmarks);
}
}
});
信頼度は0.7に設定した。最初0.5にしたら、誤検出が多すぎた。手じゃないものが手として認識される。
0.8にしたら、逆に検出されにくくなった。手を動かしてもトラッキングが途切れる。
0.7が一番安定した。
3D空間へのマッピング
ここが一番難しかった。一度つまずいた。
最初は、MediaPipeのランドマークのXYZ座標をそのまま使った。でも、深度マップの座標系と合わない。ズレる。
手を動かしても、3D空間の手が変な位置に出る。手前に動かしてるのに、奥に行ったり。
座標系の変換が必要だと気づいた。
function mapHandLandmarksToDepthMap(landmarks) {
// 手首の位置を基準点として取得
const wristLandmark = landmarks[0];
// 3D空間での基準位置を設定
const basePosition = new THREE.Vector3(100, 0, 100);
// スケール係数(手のサイズを調整)
const handScale = 150;
landmarks.forEach((landmark, index) => {
// ランドマークの相対座標を計算(手首を原点として)
const relX = (landmark.x - wristLandmark.x) * handScale;
const relY = (wristLandmark.y - landmark.y) * handScale; // Y軸は反転
const relZ = landmark.z * handScale;
// 3D空間での絶対位置を計算
const position = new THREE.Vector3(
basePosition.x + relX,
basePosition.y + relY,
basePosition.z - relZ // Z軸も反転
);
// 球体マーカーを作成
const markerGeometry = new THREE.SphereGeometry(markerSize, 16, 16);
const markerMaterial = new THREE.MeshPhongMaterial({
color: markerColor,
shininess: 80
});
const marker = new THREE.Mesh(markerGeometry, markerMaterial);
marker.position.copy(position);
scene.add(marker);
});
}
手首を原点にして、他のランドマークを相対座標で計算した。Y軸とZ軸は反転させる必要があった。
handScaleは150に落ち着いた。最初50にしたら小さすぎ、300にしたら大きすぎた。150でちょうど良い。
ランドマーク間の接続
手のランドマークを球体で表示するだけじゃ、手の形が分かりにくい。
各ランドマークを線で繋いだ。チューブジオメトリを使った。
// ランドマーク間の接続情報
const handConnections = [
[0, 1], [1, 2], [2, 3], [3, 4], // 親指
[0, 5], [5, 6], [6, 7], [7, 8], // 人差し指
[0, 9], [9, 10], [10, 11], [11, 12], // 中指
[0, 13], [13, 14], [14, 15], [15, 16], // 薬指
[0, 17], [17, 18], [18, 19], [19, 20], // 小指
[5, 9], [9, 13], [13, 17] // 手のひら
];
handConnections.forEach(connection => {
const [i1, i2] = connection;
const p1 = positions3D[i1];
const p2 = positions3D[i2];
// チューブジオメトリで線を描画
const path = new THREE.LineCurve3(p1, p2);
const tubeGeometry = new THREE.TubeGeometry(path, 1, lineRadius, 8, false);
const tubeMaterial = new THREE.MeshPhongMaterial({
color: lineColor,
transparent: true,
opacity: 0.8
});
const tube = new THREE.Mesh(tubeGeometry, tubeMaterial);
scene.add(tube);
});
チューブの半径は部位によって変えた。手のひらは太く(1.8)、指先は細く(0.8)。
これで手の形がはっきり分かるようになった。
カラフルな表現
2本の手を同時に検出できるので、色を変えた。
片方は青系、もう片方はピンク系。区別しやすくなった。
const handColorBase = handIndex === 0 ?
{ r: 0.1, g: 0.6, b: 0.9 } : // 青系
{ r: 0.9, g: 0.3, b: 0.5 }; // ピンク系
指先にはポイントライトも追加した。指先が光る。これ、意外と良い効果だった。
パフォーマンスの調整
処理が負荷が高かった。ヨシ。
深度推論は512x512でやってる。元の映像は1920x1080だけど、縮小しないと間に合わない。
手の検出は30fpsくらい出る。深度推論と合わせると20fpsまで落ちる。
PCだと動くけど、スマホは無理。15fpsくらいまで落ちた。
ハマったところ
座標系の違い
MediaPipeとThree.jsで座標系が違う。最初これに気づかなくて、2時間くらいハマった。
Y軸が逆、Z軸も逆。手を上に動かすと下に行く。手前に動かすと奥に行く。マズい。
座標変換のコードを入れて解決した。
メモリリーク
手のランドマークを描画するたびに、メッシュを作ってた。削除してなかった。
メモリがどんどん増えて、10秒で100MBとか使ってた。ブラウザが重くなる。
古いメッシュを削除するコードを追加した。
// 既存の3Dマーカーを削除
if (window.handLandmarkMarkers) {
window.handLandmarkMarkers.forEach(marker => {
if (marker && marker.parent) {
marker.parent.remove(marker);
if (marker.geometry) marker.geometry.dispose();
if (marker.material) marker.material.dispose();
}
});
}
これでメモリ使用量が安定した。
カメラ切り替え時のクラッシュ
カメラを切り替えると、たまにクラッシュした。
原因は、カメラの解像度が変わった時に、キャンバスサイズを更新してなかったこと。
loadedmetadataイベントで対応した。
videoElement.addEventListener('loadedmetadata', () => {
const newWidth = videoElement.videoWidth;
const newHeight = videoElement.videoHeight;
if (handCanvas.width !== newWidth || handCanvas.height !== newHeight) {
// キャンバスサイズを更新
handCanvas.width = newWidth;
handCanvas.height = newHeight;
}
});
これで安定した。
使ってみて
実際に試してみてください:
デモを起動する(全画面表示)
手の動きを立体的に認識できた。実空間の手を3D空間に正確にマッピングできる。
ポイントは以下の3つ:
- WebGPUで深度推論を高速化(512x512、約20fps)
- MediaPipe Handsで21個のランドマークを検出(信頼度0.7)
- 座標系の変換で正確な3Dマッピング(手のひら基準、スケール150)
深度マップと手の検出を組み合わせるのは面白い。拡張現実のインターフェースとして可能性を感じた。
ただし、処理が重いので、スマホでは実用的じゃない。PCでも20fpsが限界。最適化の余地はある。
同じような立体的な手の認識を試している方の参考になれば嬉しいです。
まとめ
WEBカメラから手の動きを3D空間に立体表示するシステムを作った。
ポイントは以下の4つ:
- MiDaSモデル(ONNX)で深度推論(WebGPU対応)
- MediaPipe Handsで21個のランドマーク検出
- 座標系変換で正確な3Dマッピング
- チューブジオメトリで手の形状を可視化
座標系の違いに気づくまで時間がかかったけど、最終的には実空間の手の動きを正確に3D空間に表示できた。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。