インストールも、サーバーも不要。ブラウザだけで動くリアルタイムのモーションキャプチャを、約200行のTypeScriptで作ります。アバタートークのパート1です。ウェブカメラの映像の上に透明なキャンバスを重ね、GoogleのMediaPipe PoseLandmarkerで上半身のスケルトンをライブ描画します。この記事では、実際に動かすための2つの落とし穴も含めて、ファイルごとに解説します。コードはGitHubにあります(フェーズ1はgitでタグ付け)。(動画・コードの画面表示は英語です。)

目次
作るもの
フェーズ1のコードベースは、3つのファイル・約200行だけです——index.html、style.css、main.ts。仕組みはシンプルで、<video>要素がウェブカメラを映し、透明な<canvas>をその上にピクセル単位でぴったり重ねます。下に映像、上にスケルトン。姿勢推定はMediaPipeが担当し、キャンバスは点と線を描くだけです。
HTML:2つのレイヤーをぴったり重ねる
インターフェース全体は、ウェブカメラのビデオ・オーバーレイのキャンバス・ステータス表示を収めた#stageです。小さなHUDにFPSとミラー切り替えを置きます。
<main>
<h1>Avatar Talk <span class="phase">phase 1 — pose landmarks</span></h1>
<div id="stage">
<video id="webcam" autoplay playsinline muted></video>
<canvas id="overlay"></canvas>
<div id="status">Loading pose model…</div>
</div>
<div id="hud">
<span id="fps">— fps</span>
<label><input type="checkbox" id="mirror" checked /> mirror</label>
</div>
</main>
<script type="module" src="/src/main.ts"></script>
CSS:絶対配置とミラーモード
レイヤーを機能させるのがスタイルシートです。ビデオとキャンバスの両方をステージ内で絶対配置にし、ピクセル単位で揃えます。そしてミラーモード——右手を動かすとスケルトンの右手が動く——は、両レイヤーへ一度にかけるscaleX(-1)の変形1つで実現します。JS側での座標計算は一切不要です。
#stage {
position: relative;
width: min(90vw, 960px);
aspect-ratio: 4 / 3;
overflow: hidden;
}
#webcam,
#overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#webcam { object-fit: cover; }
/* 両レイヤーをまとめて反転(JS側の座標計算は不要) */
#stage.mirrored #webcam,
#stage.mirrored #overlay {
transform: scaleX(-1);
}
MediaPipe:33個のランドマークから25個を使う
ここが頭脳です。MediaPipeのポーズモデルは1フレームに33個のランドマークを返します。机上のカメラでは脚(インデックス25〜32)の検出が不安定なので、最初の25個——顔・胴体・腕——だけを使い、残りは描画も使用もしません。接続リストは、どの点の間にボーンを描くかを決める、手作業で選んだランドマーク番号のペアです。
// 上半身のみ:ランドマーク 0–24(顔・腕・胴体)
const UPPER_BODY_CUTOFF = 25;
// どのランドマークの間にボーンを描くか
const UPPER_BODY_CONNECTIONS: [number, number][] = [
// 顔の輪郭
[0, 1], [1, 2], [2, 3], [3, 7], [0, 4], [4, 5], [5, 6], [6, 8], [9, 10],
// 胴体
[11, 12], [11, 23], [12, 24], [23, 24],
// 左腕+手
[11, 13], [13, 15], [15, 17], [15, 19], [15, 21], [17, 19],
// 右腕+手
[12, 14], [14, 16], [16, 18], [16, 20], [16, 22], [18, 20],
];
const MIN_VISIBILITY = 0.5;
モデルの読み込み:ライト・GPU・ビデオモード
モデルの読み込みは2ステップ——WebAssemblyランタイムを取得し、ランドマーカーを生成します。重要な設定は3つ。ライトモデルで軽さを保ち、GPUデリゲートが毎秒30フレームと紙芝居の分かれ目になり、ビデオモード(画像モードではなく)がフレーム間のスムージングを有効にします。
import { FilesetResolver, PoseLandmarker } from "@mediapipe/tasks-vision";
async function createLandmarker(): Promise<PoseLandmarker> {
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm"
);
return PoseLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/" +
"pose_landmarker_lite/float16/1/pose_landmarker_lite.task",
delegate: "GPU", // 30fps か紙芝居かの分かれ目
},
runningMode: "VIDEO", // フレーム間のスムージング
numPoses: 1,
});
}
動かすための2つの落とし穴
落とし穴1:可視性のしきい値(幽霊の腕)
各ランドマークは可視性スコアを持っています。無条件にすべて描くと、手が画面から出た瞬間にMediaPipeが位置を推測し続け、「幽霊の腕」が画面端で暴れます。対策は一行——スコアが0.5未満のランドマークは、ボーンも点もスキップします。
ctx.strokeStyle = "#4ade80";
for (const [a, b] of UPPER_BODY_CONNECTIONS) {
const la = landmarks[a];
const lb = landmarks[b];
if ((la.visibility ?? 1) < MIN_VISIBILITY) continue; // 幽霊の腕をスキップ
if ((lb.visibility ?? 1) < MIN_VISIBILITY) continue;
ctx.beginPath();
ctx.moveTo(la.x * w, la.y * h);
ctx.lineTo(lb.x * w, lb.y * h);
ctx.stroke();
}
落とし穴2:キャンバスの解像度(と重複フレームのスキップ)
キャンバスの内部解像度をビデオの実解像度に合わせる必要があります。そうしないと、正規化されたランドマーク座標が下の映像のピクセルと合わず、スケルトンが体からずれていきます。さらに、ディスプレイの更新はウェブカメラのフレーム供給より速いので、映像に新しいフレームがあるときだけ推論を実行します——CPUの無駄を省けます。
function renderLoop(): void {
if (!landmarker) return;
// キャンバス解像度をビデオの実解像度に合わせる
if (canvas.width !== video.videoWidth ||
canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}
// 映像に新しいフレームがあるときだけ推論する
if (video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
const result = landmarker.detectForVideo(video, performance.now());
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (result.landmarks.length > 0) drawLandmarks(result.landmarks[0]);
}
requestAnimationFrame(renderLoop);
}

よくある質問
本当にインストールなしでブラウザだけでモーションキャプチャできますか?
できます。MediaPipeはWebAssemblyランタイムと小さなポーズモデルを提供し、どちらもCDNから読み込めます。ウェブカメラとキャンバスのある普通のページさえあれば十分です。サーバーもインストールも不要で、推論はブラウザ経由でユーザー自身のGPU上で動きます。
なぜ33個のうち25個だけを使うのですか?
座って使う机上のカメラでは脚がほとんど映らず、ランドマーク25〜32が揺れて誤りを招くからです。最初の25個——顔・胴体・腕——だけを使えば安定した上半身スケルトンが得られ、上半身アバターにはそれで十分です。
スケルトンが体からずれるのはなぜ?
ほぼ必ずキャンバスの解像度の不一致です。MediaPipeは正規化された(0〜1)座標を返すので、オーバーレイのキャンバスのwidth/heightをビデオのvideoWidth/videoHeightに一致させる必要があります。そうしないと点が誤ったピクセルにスケールされます。上記のようにレンダーループ内で設定してください。
次回 — パート2
以上がフェーズ1——約200行で、ブラウザ内に動く上半身スケルトン。コードはGitHubにあり、フェーズ1はgitでタグ付けしています。パート2では、3DのVRMアバターを読み込み、このスケルトンでそのボーンを動かします。姿勢推定の精度の全体像は、6モデルの姿勢推定精度の比較もご覧ください。
福島 崇(Takashi Fukushima) — Sports Science & Pose Estimation.
▶ YouTubeで登録 · GitHub · ウェブサイト · お問い合わせ