MediaPipe髪・衣服の領域分け:モデルを切り替えて部分的に色を変える
人物と背景を分ける次は、人物の中の「髪」や「服」を分けます。使うAPIは同じImageSegmenterですが、モデルと読ませるマスクが変わります。この記事では、同じAI生成人物画像に髪用と衣服用のモデルを実行し、領域と色の重なりを確認します。
1. タスク名が同じでも、モデルが対象を決める
ImageSegmenterという名前だけで髪や衣服を分類できるわけではありません。背景透過の記事のSelfieモデルは人物全体を扱いました。髪だけならHair Segmenter、衣服や皮膚も分けたいならSelfie Multiclassを候補にします。モデルの用途とカテゴリ一覧は公式モデル一覧で確認します。
| モデル | 主な領域 | この例の対象 |
|---|---|---|
| Hair Segmenter | 0:背景、1:髪 | 確信度マスク1を髪の色に使う |
| Selfie Multiclass | 0:背景、1:髪、2:身体の皮膚、3:顔の皮膚、4:衣服、5:その他 | 確信度マスク4を衣服の色に使う |
ここでいう背景は「そのモデルの対象カテゴリ以外の領域」という意味でも使われます。Hairモデルでは顔や服も髪以外の側へ入ります。人物と背景のモデルの「背景」と同じ範囲になるとは限りません。入力を猫に変えた場合も、猫の毛を人の髪として正しく扱うモデルではありません。
2. 髪と衣服を切り替えて実行する
髪・衣服サンプルを開く → 最初の「Hair Segmenter:髪」のまま実行 → 対象領域のピンクのマスクを確認、の順に操作します。次に「Selfie Multiclass:衣服」へ切り替えてもう一度実行し、青い領域が服へ変わることを確かめます。モデルを切り替えた直後は、次の実行ボタンで読み込みを始めます。




PCで改造する場合はサンプルZIPを展開します。VS Codeでそのフォルダーを開き、npx.cmd --yes http-server . -p 8080 -c-1を実行。http://127.0.0.1:8080/で試します。Node.jsとコマンドの説明はWeb導入の記事へ。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>髪と衣服のモデルを切り替えて色を重ねる | 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>髪と衣服のモデルを切り替えて色を重ねる</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"><label for="model-select">処理するモデル</label><select id="model-select"><option value="hair">Hair Segmenter:髪</option><option value="clothes">Selfie Multiclass:衣服</option></select><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.jpg");
});
window.addEventListener("pagehide", event => { if (!event.persisted) { task?.close(); task = null; } });
void loadImage("./person.jpg");
// マスクの寸法を使って画像へ拡大する。入力画像と同寸とは限らない。
function renderConfidence(mask, targetColor = null) {
const values = mask.getAsFloat32Array();
const small = document.createElement("canvas");
small.width = mask.width; small.height = mask.height;
const smallContext = small.getContext("2d");
const pixels = smallContext.createImageData(mask.width, mask.height);
let selected = 0;
for (let i = 0; i < values.length; i++) {
const alpha = Math.round(Math.max(0, Math.min(1, values[i])) * 255);
const offset = i * 4;
pixels.data[offset] = targetColor?.[0] ?? 255;
pixels.data[offset + 1] = targetColor?.[1] ?? 255;
pixels.data[offset + 2] = targetColor?.[2] ?? 255;
pixels.data[offset + 3] = alpha;
if (values[i] >= 0.5) selected++;
}
smallContext.putImageData(pixels, 0, 0);
const context = maskCanvas.getContext("2d");
context.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
context.imageSmoothingEnabled = true;
context.drawImage(small, 0, 0, maskCanvas.width, maskCanvas.height);
return { width: mask.width, height: mask.height, selectedPixelsAt05: selected,
totalMaskPixels: values.length, selectedPercentAt05: selected / values.length * 100 };
}
const MODELS = {
hair: { url: "https://storage.googleapis.com/mediapipe-models/image_segmenter/hair_segmenter/float32/1/hair_segmenter.tflite", index: 1, color: [230, 70, 145] },
clothes: { url: "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/1/selfie_multiclass_256x256.tflite", index: 4, color: [40, 145, 225] },
};
async function prepare() {
const selected = $("#model-select").value;
if (task && selected === modelKey) return;
task?.close(); task = null; modelKey = "";
await createTask(module.ImageSegmenter, MODELS[selected].url,
{ outputCategoryMask: true, outputConfidenceMasks: true });
modelKey = selected;
}
function run() {
const config = MODELS[modelKey];
const result = task.segment(inputCanvas);
try {
const confidence = result.confidenceMasks[config.index];
if (!confidence) throw new Error("モデルと確信度マスクの添字が一致しません。");
const summary = renderConfidence(confidence, config.color);
resultContext.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultContext.drawImage(inputCanvas, 0, 0);
// 55%の色レイヤーを重ねる簡単な着色。元画像の明暗は残す。
resultContext.globalAlpha = 0.55;
resultContext.drawImage(maskCanvas, 0, 0);
resultContext.globalAlpha = 1;
$("#count").textContent = `${modelKey === "hair" ? "髪" : "衣服"}の確信度0.5以上:${summary.selectedPercentAt05.toFixed(1)}%`;
json({ model: modelKey, labels: task.getLabels(), confidenceIndexUsed: config.index,
confidenceMaskCount: result.confidenceMasks.length, ...summary });
} finally { result.close(); }
}
$("#model-select").addEventListener("change", () => {
$("#count").textContent = "未実行";
status("モデルを選びました。実行ボタンで新しいモデルを読み込んでください。");
});
モデルのURL、対象マスクの添字、表示色をMODELSへまとめています。入力欄の値をキーとして設定を取り出し、prepare()で適切なモデルを作ります。モデルが変わった時だけ、前のタスクをclose()して作り直します。髪のタスクを持ったまま服用の添字だけ変えると、範囲外のマスクを読んでしまいます。
const MODELS = {
hair: { url: HAIR_MODEL_URL, index: 1, color: [230, 70, 145] },
clothes: { url: MULTICLASS_MODEL_URL, index: 4, color: [40, 145, 225] }
};
const selected = document.querySelector("#model-select").value;
const config = MODELS[selected];
// config.urlでImageSegmenterを作り直してから推論する。
const result = segmenter.segment(inputCanvas);
const targetMask = result.confidenceMasks[config.index]; 上の抜粋では長いURLを名前に置き換えています。完全コードに両モデルの固定URLがあります。コードをそのまま実行したい方は完全版かZIPを使ってください。SDKは0.10.34、WASMも同じ版で、GPU初期化できなければCPUを試します。複数モデルを同時に保持する最適化はせず、初学者が切り替えと寿命を追いやすくしています。
4. categoryMaskとconfidenceMasksの添字
カテゴリマスクは画素ごとにどれか一つのカテゴリ番号が入ります。確信度マスクは、各カテゴリに対する0〜1の値が別々のマスクに入ります。今回のHairモデルは髪の[1]、Multiclassは衣服の[4]を読みます。同じImageSegmenterでも、背景透過のSelfieモデルで使った[0]を、そのままここへコピーしません。
結果のJSONはモデルのキー、ラベル、チャンネル数、使った添字を表示します。先にラベルとマスクを照合すると、意図した領域を使っているか分かります。範囲を間違えたコードでも画像自体は作れてしまうので、「色が出た」だけで成功と判断せず、顔や背景に余計な色が入っていないかを見ます。
マスクの寸法は返ったwidth・heightを読み、小さいCanvasで画像化してから入力寸法へ拡大します。確信度の濃淡は補間を有効にしています。境界を二値化する場合やカテゴリ番号を拡大する場合は、補間方法も変える必要があります。結果を読み終えるとresult.close()で解放し、編集する値を長く残したい場合は解放前に自前の配列へコピーします。
5. このサンプルの着色は透明な色レイヤー
resultContext.clearRect(0, 0, width, height);
resultContext.drawImage(inputCanvas, 0, 0);
resultContext.globalAlpha = 0.55;
resultContext.drawImage(maskCanvas, 0, 0);
resultContext.globalAlpha = 1; マスクのRGBをピンクや青にし、確信度をアルファへ入れます。それを元画像に55%の強さで重ねます。元画像の明暗を一部残しながら、対象の場所を分かりやすくする簡単な合成です。AIが色を変更しているのではありません。AIの役割は「この辺りが髪・衣服に見える」という領域を返すことです。
この方法は新しい色を足すため、黒い髪を明るくした時に実際の染色と同じ見た目にはなりません。先に目的を「領域を学ぶサンプル」か「色を試す実用品」かで決めます。実用品では元の輝度を残す、彩度を変える、暗い領域の色を補正するなどの処理が必要です。最初の改造では0.55を0.3・0.8へ変え、AIを再実行しなくても見た目を変えられる部分を確認します。
全体のglobalAlphaを戻し忘れると、次に元画像を描く時まで薄くなります。描画設定を一時的に変えたら元へ戻すか、save()・restore()で囲みます。また、毎回元画像から合成し直すと、前の色が重なって濃くなり続ける問題を防げます。
6. サイトのヘアカラーと服色変更に使われる技術
サイトのヘアカラーのURLにはint-segが含まれますが、現在のコードはImageSegmenterとhair_segmenter.tfliteを使っています。タップした点を手掛かりに領域を選ぶInteractiveSegmenterとは異なります。URLや古い記事の名称より、現在読み込むクラスとモデルを確認するのが確実です。
服の色変更はMulticlassモデルの衣服カテゴリ4を使います。マスクの解放前に必要なカテゴリ・確信度を入力寸法に合わせてコピーし、後の調整と描画に使います。色や強度の操作をするたびに推論する必要がないので、スマホでも操作の反応を保ちやすくなります。
実用品はサンプルより大きな画像を扱い、色の見え方や補正操作も含みます。記事の最小例でモデル交換の仕組みを学んだ後、ヘアカラーの制作解説・服色変更の制作解説で加工方法の工夫へ進む順序がおすすめです。
7. 誤推定がどこに出るかを確認する
髪の細い毛、帽子、背景と似た色、服の装飾や手が重なる部分は、領域が欠けたり余計に選ばれたりすることがあります。人物全体を正しく分けられる画像でも、髪と皮膚を細かく分ける結果は別途確認が必要です。AI生成画像は見た目の不自然さも含み得るため、サンプル一枚でモデルの一般的な精度は測れません。
「推定領域が変」ならモデルと入力、「場所がずれる」なら寸法、「色だけ濃すぎる」なら合成強度を確認します。JSONの選択割合は確信度0.5以上の画素の割合で、認識精度の点数ではありません。モデル変更前後を比較するときは同じ画像・サイズ・合成強度を揃えます。最新のモデルやAPIの確認は公式Webガイドへ進んでください。