MediaPipe顔ランドマーク入門:478点を描いて、唇の位置を理解する
顔に枠を付ける次は、顔のどの場所が目や唇なのかを調べます。FaceLandmarkerが返した点を画像へ重ね、唇の輪郭だけ色を変えるサンプルを作ります。完成画像だけでなく、座標から描画までのつながりを確認しましょう。
1. 枠の位置から、顔の形へ進む
FaceDetectorの長方形は「顔がある範囲」を示します。FaceLandmarkerは、その範囲の内側にある顔の形を細かな点で表します。このモデルは顔1つあたり478点を推定します。各点には決まった番号があり、目の周囲や唇を結ぶことで加工の位置を決められます。モデルの出力と設定は公式の顔ランドマーク概要を参照してください。
今回扱うのは点の座標だけです。表情に対応するblendshapeや変換行列も出力できますが、最初は無効にしてデータを小さくします。顔の点が取れても、その人が誰か、どんな感情かを確定したわけではありません。サンプルは静止画の1人を対象とし、numFaces: 1で作ります。
2. AI生成人物で実行する
顔ランドマークのサンプルを開くと、既存の証明写真ツールで使っているAI生成人物が表示されます。実行ボタンを押し、顔に緑の点、唇に赤い線が重なることを確認します。結果の人数と点の数、JSONのfaceLandmarksも見ます。

自分の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顔ランドマークガイドを参照し、次は画像を画素ごとの領域へ分けるセグメンテーションを試しましょう。