猫の手ツール

作成・資料確認日:2026年10月12日

公式資料と、このプロジェクトの実装・サンプルの実行をもとに説明します。人物の作例はAI生成、猫の写真は運営者の愛猫です。実行条件や確認できた範囲は各記事に記載しています。

MediaPipe顔ランドマーク入門:478点を描いて、唇の位置を理解する

顔に枠を付ける次は、顔のどの場所が目や唇なのかを調べます。FaceLandmarkerが返した点を画像へ重ね、唇の輪郭だけ色を変えるサンプルを作ります。完成画像だけでなく、座標から描画までのつながりを確認しましょう。

1. 枠の位置から、顔の形へ進む

FaceDetectorの長方形は「顔がある範囲」を示します。FaceLandmarkerは、その範囲の内側にある顔の形を細かな点で表します。このモデルは顔1つあたり478点を推定します。各点には決まった番号があり、目の周囲や唇を結ぶことで加工の位置を決められます。モデルの出力と設定は公式の顔ランドマーク概要を参照してください。

今回扱うのは点の座標だけです。表情に対応するblendshapeや変換行列も出力できますが、最初は無効にしてデータを小さくします。顔の点が取れても、その人が誰か、どんな感情かを確定したわけではありません。サンプルは静止画の1人を対象とし、numFaces: 1で作ります。

2. AI生成人物で実行する

顔ランドマークのサンプルを開くと、既存の証明写真ツールで使っているAI生成人物が表示されます。実行ボタンを押し、顔に緑の点、唇に赤い線が重なることを確認します。結果の人数と点の数、JSONのfaceLandmarksも見ます。

AI生成人物の顔にランドマークと赤い唇の接続線を描いたサンプル実行画面
WindowsのChrome・GPUで実行したサンプル。SDK 0.10.34・公式Face Landmarker float16版1・IMAGEモード。緑の点と赤の接続線は推定座標を使ってCanvasに描画したものです。

自分の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.cmdを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>顔の478点と唇の輪郭を描く | 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>顔の478点と唇の輪郭を描く</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></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.jpg");
});
window.addEventListener("pagehide", event => { if (!event.persisted) { task?.close(); task = null; } });
void loadImage("./person.jpg");

async function prepare() {
  if (!task) await createTask(module.FaceLandmarker,
    "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task",
    { numFaces: 1, outputFaceBlendshapes: false, outputFacialTransformationMatrixes: false });
}
function run() {
  const result = task.detect(inputCanvas);
  resultContext.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
  resultContext.drawImage(inputCanvas, 0, 0);
  for (const landmarks of result.faceLandmarks) {
    resultContext.fillStyle = "#00efae";
    for (const point of landmarks) {
      resultContext.beginPath();
      resultContext.arc(point.x * resultCanvas.width, point.y * resultCanvas.height, 1.8, 0, Math.PI * 2);
      resultContext.fill();
    }
    // SDKの接続一覧は始点・終点を持つ線分。唇の輪郭だけ赤く描く。
    resultContext.strokeStyle = "#ff3c75"; resultContext.lineWidth = 3;
    for (const { start, end } of module.FaceLandmarker.FACE_LANDMARKS_LIPS) {
      const a = landmarks[start], b = landmarks[end];
      resultContext.beginPath();
      resultContext.moveTo(a.x * resultCanvas.width, a.y * resultCanvas.height);
      resultContext.lineTo(b.x * resultCanvas.width, b.y * resultCanvas.height);
      resultContext.stroke();
    }
  }
  $("#count").textContent = `${result.faceLandmarks.length} 人 / ${result.faceLandmarks[0]?.length ?? 0} 点`;
  json(result);
}

prepare()でFaceLandmarker.createFromOptions()を呼びます。runningMode: "IMAGE"は静止画向けの指定、numFacesは検出数の上限です。outputFaceBlendshapesとoutputFacialTransformationMatrixesはfalseにしています。SDK・WASMは0.10.34に揃え、GPU初期化ができなければCPUで再試行します。

実行時のdetect(inputCanvas)は、すでに読み込み済みのCanvasを渡して結果を受け取ります。画像が描ける前に呼ばないよう、画像のdecode()が終わってからボタンを有効にします。タスクは再利用し、ページ離脱時にclose()します。画像を選び直した時に古い読み込み結果を使わないよう、入力の世代番号も確認しています。

4. x・yをCanvasのピクセルへ戻す

const result = faceLandmarker.detect(inputCanvas);
const landmarks = result.faceLandmarks[0];
if (landmarks) {
  for (const point of landmarks) {
    const x = point.x * resultCanvas.width;
    const y = point.y * resultCanvas.height;
    resultContext.beginPath();
    resultContext.arc(x, y, 1.8, 0, Math.PI * 2);
    resultContext.fill();
  }
}

faceLandmarksは「顔の配列」の中に「点の配列」が入った二重配列です。最初の顔は[0]で取り出します。検出できなかった場合は存在しないので、if (landmarks)を置きます。人数が0なのに最初の顔の点へアクセスすると、JavaScript側のエラーになります。

x・yは画像の幅と高さで正規化した値です。例えばxが0.5なら横の中央付近を示すので、Canvasの幅を掛けてピクセル位置を求めます。yも高さを掛けます。FaceDetectorの矩形はピクセル値だったため、両方の座標を同じように扱わないことが大切です。

zは奥行きの参考になる値ですが、この2D表示では使いません。画面の点の大きさを決めるpxでも、実際の顔までの距離をそのまま示すcmでもありません。まずx・yと画像サイズの対応を理解してから、3D表現へ進みます。CanvasのCSS幅をスマホ用に縮めても、描画は内部のwidth・heightで計算します。

5. 点番号と接続情報は役割が違う

for (const { start, end } of FaceLandmarker.FACE_LANDMARKS_LIPS) {
  const a = landmarks[start], b = landmarks[end];
  resultContext.beginPath();
  resultContext.moveTo(a.x * width, a.y * height);
  resultContext.lineTo(b.x * width, b.y * height);
  resultContext.stroke();
}

SDKのFACE_LANDMARKS_LIPSには唇を示す線分の始点・終点が入っています。その番号で座標を取り出し、CanvasのmoveTo()・lineTo()を呼びます。SDKが返した点へどの線を引くかは、描画コードで決めています。完全コードではSDKをmoduleに読み込んでいるので、定数にもmodule.FaceLandmarkerからアクセスしています。

この一覧は線を描くための接続で、口紅を塗る多角形の頂点順そのものではありません。線分を順不同でつないでfill()すると、自己交差や余計な塗りつぶしが発生することがあります。輪郭を塗る場合は外周と内周を順序付きで用意し、口の開いた部分を穴として残す設計が必要です。

一つの点だけを追ってみる

JSONで最初の顔の点0を探し、x・yを書き留めます。run()の点を描くループを一時的に[landmarks[0]]へ置き換えると、一つだけの点が残ります。座標へ画像の幅・高さを掛け、見た目の位置と一致するか計算すると、正規化座標を実感できます。変更後はページを更新して実行し直し、元のループへ戻します。

次に唇の線の色だけ変えます。位置が変わらず色だけ変われば、モデル出力と描画設定を別々に操作できたことになります。点が画面の中央へ寄ってしまう場合は、幅・高さの掛け忘れや、CSSの表示寸法を使っていないか確認します。

6. リップ試着の実装で必要になる工夫

サイトのリップ試着は、推定した唇に色やツヤを重ねるツールです。AIが「完成したメイク画像」を作るのではありません。輪郭、色、強さ、ツヤの合成はCanvasで行います。位置を調べる処理と色を調整する処理を分けると、スライダーを動かすたびに重い推論を繰り返さずに済みます。

制作時には、推論に使った画像の寸法と合成用Canvasの寸法を揃える必要があります。色変更だけの再描画は元画像からやり直します。前の加工結果へさらに色を重ねると、操作するたびに濃くなり、設定値と見た目の関係が崩れるからです。唇が少しずれた時に輪郭の補正をどう扱うかも、推定精度とは別のUIの設計になります。

リップ試着の制作解説は実用品への応用です。まずこの記事で点と線の位置を確認し、その次に輪郭内の塗り、透明度、合成方法を一つずつ加えると理解しやすくなります。カラコンのような瞳の加工も、顔ランドマークを利用する別の応用です。

7. 点がずれる・表示されないとき

横顔、顔の一部が隠れる画像、非常に小さい顔、開いた口の形などでは推定がずれることがあります。点は人が測った正確な輪郭ではなく、モデルの推定です。AI生成人物で得た見た目だけを現実の全写真へ一般化せず、利用したい入力条件で確認します。猫の顔を対象にしたモデルではないため、愛猫へ切り替えた結果を猫の顔解析に使いません。

結果を見ながら確認する順序
症状確認
0人で点がない顔の大きさと向きを確認。出力が0人ならループが描かないのは正常。
顔から点が外れる正規化座標へ幅・高さを掛けたか、別画像の寸法や結果を使っていないか。
唇の線だけ変な位置接続のstart・endを座標と取り違えていないか、点番号が使うモデルと一致するか。
スマホで線が太いCanvas内部サイズと表示サイズの倍率を確認し、線幅を調整。

動画の連続処理では静止画とは別のAPIと時刻管理が必要です。このページはカメラを起動しません。Web APIの詳細は公式Web顔ランドマークガイドを参照し、次は画像を画素ごとの領域へ分けるセグメンテーションを試しましょう。

入力データの推論は端末内で行います。SDK・WASM・モデルの取得には通信が発生します。MediaPipe公式ではAPIの性能・利用状況メトリクスの送信も案内されています。SDKの版・実行条件と、プライバシー方針を確認してください。

制作時に確かめたこと

ツールを作る途中で実際に確認した症状と、変更を選んだ経緯です。日付は当時の記録日です。現在の実装や、このページの学習サンプルの検証とは分けて読んでください。

顔の点が取れても、自然な仕上がりは別の課題だった

美肌で鼻の穴までぼけた

美肌ツールは動き始めた後、鼻までぼやけることを確認しました。鼻の穴だけはくっきり残す変更を依頼しましたが、一度の修正では変わらず、ぼかしを調整しても不自然な状態が続きました。18時18分に「ばっちりです。これでいきます」と、その時の仕上がりを採用しています。

これは本人が手元の見た目を採用した報告です。どの画像でも自然になることや、全端末での動作を証明するものではありません。実際、後日のスマホでのスライダー不動は別の問題として記録されています。顔の点の取得、保護する部位、ぼかしの合成、端末対応を分けて確認します。

強くした時や、領域の端で破綻が見えた

小顔ツールは、もっと強く変形したいと依頼した後、最大にすると顔半分がずれる状態になりました。リップ&チークではチークを強くすると周囲が黒っぽくなり、目力・カラコンでは黒目がまぶたからはみ出しました。いずれも、この記録だけでは最終的な解消までは確認できません。

これらの症状を、モデルがすべて誤検出したという一つの原因へまとめることはできません。現在のコードを調べる時は、元の点、加工の範囲、強度を上げた時の変形・合成を順に見ます。点が返ることと、加工が輪郭内へ収まり自然に見えることは別の評価です。