MediaPipe手ランドマーク入門:21点を番号付きで描き、手の線画を作る
HandLandmarkerは手の指や関節の位置を推定するタスクです。このページではプロジェクト内のAI生成の手画像を使い、番号付きの点と接続線を重ねます。白いCanvasにも同じ線を描き、サイトの「手のポーズ線画」が座標からできることを確かめます。
1. 手の位置と、ジェスチャーの名前を分ける
HandLandmarkerは手の座標を返します。「ピース」や「親指を立てる」という名前を分類するタスクとは分けて考えます。点の位置からアプリ側で条件を作ることはできますが、決まったジェスチャー名が欲しい場合はGestureRecognizerを選びます。このページのゴールは、座標と接続の読み方です。
モデルは片手あたり21点を推定し、手の左右の分類や3Dのワールド座標も返します。サンプルでは正規化した2Dのx・yを描画へ使い、numHands: 2で最大2つの手を扱います。仕様は公式HandLandmarker概要で確認します。人の手向けのモデルなので、猫の前足の関節を取る機能ではありません。
2. 元画像と線画を見比べる
手ランドマークサンプルを開く → AI生成の手を選ぶ → 実行ボタンを押します。元画像に番号と線が出たら、白い背景の線画も見てください。両方は同じ点を描き、背景だけを変えています。写真を輪郭抽出したのではなく、AIが推定した21点を使う線画です。

PCではサンプルZIPを展開し、VS Codeでindex.htmlのあるフォルダーを開きます。ターミナルでnpx.cmd --yes http-server . -p 8080 -c-1を実行し、http://127.0.0.1:8080/で開きます。Node.jsが未導入なら導入の記事へ。macOS・Linuxはnpxを使い、終了はCtrl+Cです。
3. 完全コードを準備から読む
index.htmlの完全コード
<!doctype html>
<html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,follow"><title>手の21点と線画を描く | MediaPipe入門</title><style>
:root{font-family:system-ui,sans-serif;color:#253235;background:#f3f7f6;line-height:1.8}*{box-sizing:border-box}body{margin:0}main{width:min(100% - 32px,1080px);margin:28px auto 50px}h1{font-size:clamp(1.4rem,4vw,2.1rem);line-height:1.5}h2{font-size:1.15rem}a{color:#006453;text-underline-offset:3px}.panel{margin-top:22px;padding:22px;background:white;border:1px solid #d2e1dc;border-radius:14px}.note{font-size:.9rem;color:#53655c}button,select,input{font:inherit}button{padding:12px 20px;color:white;background:#006b55;border:0;border-radius:8px;min-height:48px;cursor:pointer}button:disabled{opacity:.5}select,input[type=file]{display:block;max-width:100%;margin:8px 0 16px}fieldset{border:0;padding:0}label{margin-right:14px}.images{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;min-width:0}figcaption{font-weight:700;margin-bottom:8px}.canvas-wrap{overflow:hidden;background-color:#edf1ed;background-image:linear-gradient(45deg,#dfe6df 25%,transparent 25%),linear-gradient(-45deg,#dfe6df 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#dfe6df 75%),linear-gradient(-45deg,transparent 75%,#dfe6df 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;border:1px solid #d2e1dc;border-radius:8px}canvas{width:100%;height:auto;display:block}dl{display:flex;flex-wrap:wrap;gap:12px 30px}dt{font-size:.85rem;color:#53655c}dd{margin:0;font-weight:700}pre{background:#edf3ef;padding:16px;max-height:360px;overflow:auto;font-size:.85rem}#status{font-weight:600;overflow-wrap:anywhere}#status[data-error=true]{color:#a42b24}:focus-visible{outline:3px solid #e3aa28;outline-offset:3px}details summary{cursor:pointer}footer{font-size:.9rem;margin-top:24px}@media(max-width:640px){.images{grid-template-columns:1fr}.panel{padding:16px}button{width:100%}}
</style></head>
<body><main><header><p class="note">MEDIAPIPE / JAVASCRIPT / IMAGE</p><h1>手の21点と線画を描く</h1><p>画像を選び、実行ボタンを押してください。入力・途中結果・JSONを比べて処理の仕組みを確かめます。</p><p class="note">手の画像はプロジェクト内のAI生成素材、猫は運営者の愛猫の写真です。人向けモデルで猫の顔・手・毛を認識できるという意味ではありません。</p></header>
<section class="panel"><h2>1. 入力を選ぶ</h2><fieldset><legend>サンプル画像</legend><label><input type="radio" name="sample" value="person" checked>AI生成の手</label><label><input type="radio" name="sample" value="cat">愛猫の写真(対象外の比較)</label></fieldset><label for="image-file">自分の画像(JPEG・PNG・WebP、20 MBまで)</label><input id="image-file" type="file" accept="image/jpeg,image/png,image/webp"><button id="run-button" type="button" disabled>AIを読み込んで手のランドマークを実行</button><p id="status" role="status" aria-live="polite">画像を読み込んでいます。</p><p class="note">選んだ画像はブラウザ内で推論します。SDK・WASM・モデルの取得にはインターネット接続が必要です。</p><noscript>JavaScriptを有効にしてください。</noscript></section>
<section class="panel"><h2>2. 入力・途中結果・出力を比べる</h2><div class="images"><figure><figcaption>入力画像</figcaption><div class="canvas-wrap"><canvas id="input-canvas" width="480" height="320" role="img" aria-label="入力画像"></canvas></div></figure><figure><figcaption>関節と番号を重ねた結果</figcaption><div class="canvas-wrap"><canvas id="result-canvas" width="480" height="320" role="img" aria-label="関節と番号を重ねた結果"></canvas></div></figure><figure><figcaption>座標から描いた線画</figcaption><div class="canvas-wrap"><canvas id="mask-canvas" width="480" height="320" role="img" aria-label="座標から描いた線画"></canvas></div></figure></div><dl><div><dt>結果</dt><dd id="count">未実行</dd></div><div><dt>推論入力サイズ</dt><dd id="dimensions">読み込み中</dd></div><div><dt>推論と描画</dt><dd id="elapsed">未実行</dd></div><div><dt>実行先</dt><dd id="delegate">未初期化</dd></div></dl><p class="note">画像は長辺960 px以内に縮小。時間はAIの準備を除き、推論・結果描画・JSON整形を含みます。端末によって変わります。</p></section>
<section class="panel"><h2>3. 出力データを読む</h2><details open><summary>結果のJSON</summary><pre id="json-output">まだ実行していません。</pre></details></section><footer><p>SDK:@mediapipe/tasks-vision 0.10.34 / IMAGEモード / GPU初期化失敗時はCPU再試行</p><p><a href="./README.txt">起動手順</a></p></footer></main><script type="module" src="./app.js"></script></body></html>
app.jsの完全コード
// SDK・WASMの版を揃える。AIの取得は実行ボタンを押してから始める。
const SDK = "https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]";
const $ = (selector) => document.querySelector(selector);
const inputCanvas = $("#input-canvas");
const resultCanvas = $("#result-canvas");
const maskCanvas = $("#mask-canvas");
const inputContext = inputCanvas.getContext("2d", { willReadFrequently: true });
const resultContext = resultCanvas.getContext("2d");
let task = null, module = null, ready = false, busy = false, successful = false, imageVersion = 0;
let delegate = "", modelKey = "";
function status(message, error = false) {
$("#status").textContent = message;
$("#status").dataset.error = String(error);
}
function controls() {
$("#run-button").disabled = busy || !ready;
for (const input of document.querySelectorAll("input,select")) input.disabled = busy;
const download = $("#download-button");
if (download) download.disabled = busy || !successful;
}
async function loadImage(source) {
const version = ++imageVersion;
ready = false; successful = false; controls();
const url = source instanceof File ? URL.createObjectURL(source) : source;
try {
const img = new Image(); img.src = url; await img.decode();
if (version !== imageVersion) return;
const scale = Math.min(1, 960 / Math.max(img.naturalWidth, img.naturalHeight));
for (const canvas of [inputCanvas, resultCanvas, maskCanvas].filter(Boolean)) {
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
}
inputContext.drawImage(img, 0, 0, inputCanvas.width, inputCanvas.height);
resultContext.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultContext.drawImage(inputCanvas, 0, 0);
maskCanvas?.getContext("2d").clearRect(0, 0, maskCanvas.width, maskCanvas.height);
$("#dimensions").textContent = `${inputCanvas.width} × ${inputCanvas.height} px`;
$("#count").textContent = "未実行";
$("#elapsed").textContent = "未実行";
$("#json-output").textContent = "まだ実行していません。";
ready = true; status("画像を選びました。実行ボタンを押してください。");
} catch (error) {
console.error(error); if (version === imageVersion) status("画像を開けません。JPEG・PNG・WebPを選び直してください。", true);
} finally {
if (source instanceof File) URL.revokeObjectURL(url);
if (version === imageVersion) controls();
}
}
async function createTask(TaskClass, modelUrl, options) {
const files = await module.FilesetResolver.forVisionTasks(`${SDK}/wasm`);
const create = (target) => TaskClass.createFromOptions(files, {
baseOptions: { modelAssetPath: modelUrl, delegate: target },
runningMode: "IMAGE", ...options,
});
try { task = await create("GPU"); delegate = "GPU"; }
catch (gpuError) {
console.warn("GPU初期化に失敗したためCPUを試します。", gpuError);
status("GPUを初期化できないためCPUで再試行しています。");
task = await create("CPU"); delegate = "CPU";
}
$("#delegate").textContent = delegate;
}
function json(data) {
$("#json-output").textContent = JSON.stringify(data, (_key, value) =>
typeof value === "number" ? Number(value.toFixed(4)) : value, 2);
}
$("#run-button").addEventListener("click", async () => {
if (busy || !ready) return;
busy = true; successful = false; controls();
try {
status("初回はSDK・WASM・モデルを読み込みます。しばらくお待ちください。");
module ??= await import(/* @vite-ignore */ `${SDK}/vision_bundle.mjs`);
await prepare();
status("推論しています…");
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const start = performance.now();
run();
successful = true;
$("#elapsed").textContent = `${(performance.now() - start).toFixed(1)} ms`;
status("処理が完了しました。画像とJSONを確認してください。");
} catch (error) {
console.error(error); status("実行に失敗しました。通信・モデルURL・Consoleを確認して再試行してください。", true);
} finally { busy = false; controls(); }
});
$("#image-file").addEventListener("change", event => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 20 * 1024 * 1024) { status("20 MB以下の画像を選んでください。", true); return; }
for (const radio of document.querySelectorAll('[name="sample"]')) radio.checked = false;
void loadImage(file);
});
for (const radio of document.querySelectorAll('[name="sample"]')) radio.addEventListener("change", () => {
$("#image-file").value = "";
if (radio.checked) void loadImage(radio.value === "cat" ? "./cat.jpg" : "./person.png");
});
window.addEventListener("pagehide", event => { if (!event.persisted) { task?.close(); task = null; } });
void loadImage("./person.png");
async function prepare() {
if (!task) await createTask(module.HandLandmarker,
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
{ numHands: 2 });
}
function run() {
const result = task.detect(inputCanvas);
resultContext.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultContext.drawImage(inputCanvas, 0, 0);
const sketch = maskCanvas.getContext("2d");
sketch.fillStyle = "white"; sketch.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
for (const points of result.landmarks) {
for (const context of [resultContext, sketch]) {
context.strokeStyle = "#006b55"; context.lineWidth = 4;
for (const { start, end } of module.HandLandmarker.HAND_CONNECTIONS) {
context.beginPath();
context.moveTo(points[start].x * inputCanvas.width, points[start].y * inputCanvas.height);
context.lineTo(points[end].x * inputCanvas.width, points[end].y * inputCanvas.height);
context.stroke();
}
context.font = "bold 15px system-ui";
points.forEach((point, index) => {
const x = point.x * inputCanvas.width, y = point.y * inputCanvas.height;
context.fillStyle = "#d84272"; context.beginPath(); context.arc(x, y, 5, 0, Math.PI * 2); context.fill();
context.fillStyle = "#173a31"; context.fillText(String(index), x + 7, y - 7);
});
}
}
$("#count").textContent = `${result.landmarks.length} 手 / 各21点`;
json(result);
}
prepare()でHandLandmarkerのモデルを読み、run()で入力Canvasを推論します。顔ランドマークと同じdetect()という名前でも、使うタスクとモデルは手用です。顔用の.taskファイルをそのまま交換して使えません。SDK・WASMは0.10.34に固定し、GPU初期化失敗時にCPUへ再試行します。
読み込み済みタスクは次の画像にも再利用します。推論中は入力の操作を無効にし、結果と画像が混ざるのを防ぎます。ファイル選択で作ったObject URLは画像の読み込み後に解放し、タスク本体も通常のページ離脱時に解放します。こうした画像アプリの基本処理は、手のモデルへ変えても必要です。
4. 21点の番号と左右の結果を読む
| 番号 | 場所 | サンプルで見ること |
|---|---|---|
| 0 | 手首 | 指の線の出発側 |
| 1〜4 | 親指、4が先端 | ほかの指と接続の形が違う |
| 5〜8 | 人差し指、8が先端 | 5から順番に先端へ並ぶ |
| 9〜12 | 中指 | 12が先端 |
| 13〜16 | 薬指 | 16が先端 |
| 17〜20 | 小指 | 20が先端 |
result.landmarksは手ごとの点の配列です。手が2つなら2つの要素があり、各手の中に21点が入ります。handednessには左右の分類、worldLandmarksには3Dの座標が返ります。すべて同じ手の順序に対応するので、必要な出力は同じ配列番号で読みます。
左右は画面の左側・右側を表すものではなく、人の手の左右に関する分類です。鏡像やカメラプレビューを反転させる設計では、モデルへ渡す画像と画面表示を混同しないようにします。まずこの静止画サンプルでJSONと元画像を照合してから、鏡像のUIへ広げます。左右分類のスコアも、手の形が完全に正しいことを保証する値ではありません。
5. 点と接続線をCanvasに描く
const result = handLandmarker.detect(inputCanvas);
for (const points of result.landmarks) {
for (const { start, end } of HandLandmarker.HAND_CONNECTIONS) {
const a = points[start], b = points[end];
context.beginPath();
context.moveTo(a.x * width, a.y * height);
context.lineTo(b.x * width, b.y * height);
context.stroke();
}
} x・yは正規化座標なので、描画先の幅・高さを掛けます。HAND_CONNECTIONSは線分のstart・endを持ち、点の配列から両端を取り出します。完全コードではmodule.HandLandmarker.HAND_CONNECTIONSからアクセスします。点を番号順にすべて結ぶと、親指の先から人差し指の付け根へ不要な線が出るため、接続情報を使う意味があります。
サンプルは写真の上と白いCanvasの両方へ同じコードで線を描きます。点の番号を重ねると、先端や手首の位置が学べます。線画を保存したい場合は白いCanvasをtoBlob()へ渡せます。背景を透明にしたい場合は白の塗りを外し、透明度対応のPNGを選びます。
指先の番号を実際に確かめる
JSONで最初の手の点8を読み、画像の人差し指の先にある番号8と対応するか見ます。次に描画ループの点の半径5を10へ変え、線の太さはそのままにして実行します。線と点は別のCanvas処理だと確認できます。線画だけ手首がずれる場合は、線画Canvasのサイズを入力Canvasと同時に変更できているかを見ます。
元画像が縦長でも、CanvasをCSSで横に引き伸ばさなければ形は保たれます。座標を手書きで丸めてから保存すると細部の位置が失われるので、計算用には元の小数値を残し、JSONの丸めは画面表示だけに限定します。コードのjson()はこの方針で、点の配列を書き換えず表示文字列を作ります。
6. 指の距離と、サイトの線画へつなぐ
親指の先端4と人差し指の先端8の距離を使うと、つまむ動作の候補を作れます。ただし画素の距離だけで判断すると、手がカメラへ近づいた時に基準が変わります。手の大きさに対する比率へ変換する方法が一つの出発点です。次は最初の手のpointsと描画寸法がある時の追加例です。
const thumb = points[4];
const index = points[8];
const palmA = points[5];
const palmB = points[17];
const pixelDistance = (a, b) => Math.hypot((a.x - b.x) * width, (a.y - b.y) * height);
const palmWidth = pixelDistance(palmA, palmB);
const pinchRatio = palmWidth > 0 ? pixelDistance(thumb, index) / palmWidth : null; 幅と高さをそれぞれ掛けて距離を求めるのは、縦長画像でも座標の縮尺を揃えるためです。正規化x・yへそのままMath.hypot()を使うと、縦と横の単位が違います。手のひらの幅を基準にしても、向きや隠れ方の影響は残ります。小さければ必ず「つまんだ」と断言せず、実際の入力で閾値を調べます。
サイトの手のポーズ線画は座標を線と形へ変換する応用です。推論に用いた画像サイズと、編集・保存のCanvasのサイズを揃える必要があります。線の色や太さを操作する時は、保持した点から再描画すればよく、毎回AIを読み直す必要はありません。制作解説では実用品の線の描き方へ進めます。
7. 0手・ずれ・重なりを切り分ける
指が重なる、握って先端が見えない、手が小さい、画像の外へ一部が切れる場合は推定が難しくなります。AI生成の手にも不自然な関節や指の形が含まれ得ます。推定点をそのまま正確な骨格や医療上の計測として扱わず、見た目を確認できる用途から使います。
| 症状 | 確認 |
|---|---|
| 0手とJSONが出る | 推論自体は完了。手の大きさ・向き・入力が人の手かを確認。 |
| 点が画像の位置から外れる | 正規化座標へ内部Canvas寸法を掛けているか。 |
| 変な線が指の間に出る | 全点を番号順に結ばず、SDKの接続を使っているか。 |
| 1つの手しか出ない | numHandsの上限と、2つ目の手が見えるサイズを確認。 |
愛猫へ切り替えた結果は、人向けモデルの対象外を確かめる比較です。猫の前足を認識できることの証拠には使いません。動画では時刻と連続推論の設計が必要です。詳しいAPIは公式Web手ランドマークガイドを参照し、ジェスチャー分類へ進む場合は専用タスクを検討します。