MediaPipe背景透過入門:人物マスクを作り、透明PNGへ変換する
画像の背景を消すには、顔の位置ではなく「どの画素を人物として残すか」が必要です。ImageSegmenterの人物マスクを途中画像として表示し、その透明度を使って背景を消します。AIの出力と画像加工を分けて理解できるサンプルです。
1. 検出した長方形から、画素ごとの領域へ
顔検出では四角い枠の内側に背景も含まれます。セグメンテーションは画像の位置ごとに領域を分類するため、髪や肩に沿って人物を残せます。その結果を表す画像状のデータが「マスク」です。マスクの明るさや透明度を使って、別の画像に効果をかけます。
ImageSegmenterは使うモデルによって分けられる対象が変わります。この記事はSelfie Segmenterを使い、人物と背景を分けます。猫を切り抜くためのモデルではありません。髪だけや服だけを扱う場合は髪・衣服のモデル比較へ進みます。モデルの用途は公式ImageSegmenter概要にまとまっています。
2. 入力・マスク・背景透過の3つを見る
背景透過サンプルを開く → AI生成人物を選ぶ → 実行ボタンを押します。入力画像、背景透過結果、人物確信度マスクを見比べてください。市松模様は透明な部分を確認するための画面背景で、出力画像に描き込んでいる柄ではありません。


自分の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>人物マスクから背景を透明にする | 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"><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><p><button id="download-button" type="button" disabled>結果をPNGで保存</button></p><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 };
}
async function prepare() {
if (!task) await createTask(module.ImageSegmenter,
"https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/1/selfie_segmenter.tflite",
{ outputCategoryMask: true, outputConfidenceMasks: true });
}
function run() {
const result = task.segment(inputCanvas);
try {
// このSelfieモデルは単一の人物確信度を返す。モデル交換時は添字も再確認する。
const confidence = result.confidenceMasks[0];
const summary = renderConfidence(confidence);
resultContext.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultContext.drawImage(inputCanvas, 0, 0);
resultContext.globalCompositeOperation = "destination-in";
resultContext.drawImage(maskCanvas, 0, 0);
resultContext.globalCompositeOperation = "source-over";
$("#count").textContent = `人物確信度0.5以上:${summary.selectedPercentAt05.toFixed(1)}%`;
const category = result.categoryMask?.getAsUint8Array();
json({ model: "selfie_segmenter", labels: task.getLabels(), confidenceMaskCount: result.confidenceMasks.length,
confidenceIndexUsed: 0, ...summary, categoryValuesObserved: category ? [...new Set(category)] : [] });
} finally { result.close(); }
}
$("#download-button").addEventListener("click", () => {
if (!successful || busy) return;
resultCanvas.toBlob(blob => {
if (!blob) return;
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = "person-transparent.png"; link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, "image/png");
});
prepare()ではImageSegmenterをIMAGEモードで作り、outputCategoryMaskとoutputConfidenceMasksをtrueにします。両方を返すのは、JSONで出力の違いを確認するためです。合成には確信度だけを使います。GPU初期化に失敗した場合はCPUで再試行します。
run()はsegment(inputCanvas)で推論し、renderConfidence()でマスクを描きます。最後に入力画像とマスクを合成します。JSONは何十万個もの値を表示する代わりに、マスクの幅・高さ、チャンネル数、使った添字、観測したカテゴリ値、確信度0.5以上の画素割合を示します。人物の人数を数えるタスクではないので、割合を人数と読み替えないでください。
4. カテゴリマスクと確信度マスクは別のデータ
| 出力 | 値 | 用途 |
|---|---|---|
| categoryMask | 画素ごとに選ばれたカテゴリの整数 | 特定の領域を二値で選ぶ |
| confidenceMasks | カテゴリごとの0〜1の確信度 | 境界を含む濃淡や透明度へ変換 |
カテゴリ番号は全モデルに共通ではありません。この記事のSDKと配布Selfieモデルでは、合成にconfidenceMasks[0]の人物確信度を使います。サイトの背景透過も同じ配布ファイルに対するこの実装です。公式のモデル分類表だけを見て、すべてのSelfieモデルで「背景0・人物1の2枚が返る」と決めつけると、存在しない[1]を読んだり背景を残したりすることがあります。
モデルを交換したらgetLabels()、confidenceMasks.length、カテゴリの実値を確認し、マスク単体を描いて対象と一致するか見ます。観測した数値にどんな意味を付けるかをコードで明示します。猫の画像に人物らしい領域が出ても、猫用の切り抜きができたという根拠にはなりません。
const result = segmenter.segment(inputCanvas);
try {
const mask = result.confidenceMasks[0]; // このSelfieモデルの人物確信度
const data = mask.getAsFloat32Array();
for (let i = 0; i < data.length; i++) {
pixels.data[i * 4 + 3] = Math.round(Math.max(0, Math.min(1, data[i])) * 255);
}
// マスクをCanvasへ描いてから拡大し、destination-inで画像を残す。
} finally {
result.close();
} 確信度を0〜255へ変換すると、Canvasのアルファ値に使えます。0なら透明、255なら不透明です。0.5以上だけ255へする二値化もできますが、髪などの境界が硬くなります。確信度をそのまま透明度へ変える処理は簡単ですが、薄い背景が残る場合があるため、どちらが目的に合うか結果を比べます。
5. マスクの寸法を確認し、寿命を守る
マスクの幅と高さが入力Canvasと一致するとは限りません。サンプルはmask.width・mask.heightの小さなCanvasに一度マスクを描き、入力Canvasの大きさへ拡大します。確信度は連続値なので補間を有効にしています。カテゴリの整数を拡大する場合は、異なるカテゴリの間に存在しない値を作らないよう、最近傍補間を使います。
マスクの配列を読むときは、返却結果の寿命も意識します。getAsFloat32Array()で受け取った配列を結果解放後までそのまま持ち回る設計は避けます。この例は推論結果が生きている間にCanvasへ転写し、finallyでresult.close()します。後からブラシで編集したい場合は、解放前にnew Float32Array(data)などで自分が所有する配列へコピーします。
タスク本体と推論結果は別の資源です。タスクは何枚かの画像で再利用し、結果は推論ごとに読み終えたら解放します。これを混同すると、画像を選ぶたびに重い初期化を繰り返したり、マスクがメモリに残ったりします。
6. destination-inで人物を残し、PNGで保存する
resultContext.clearRect(0, 0, width, height);
resultContext.drawImage(inputCanvas, 0, 0);
resultContext.globalCompositeOperation = "destination-in";
resultContext.drawImage(maskCanvas, 0, 0);
resultContext.globalCompositeOperation = "source-over"; destination-inは、すでに描いた画像を新しい描画のアルファで残す合成です。入力を描き、マスクを重ねることで、人物の部分だけが残ります。設定をsource-overへ戻すのは、次の描画へ意図しない合成が引き継がれないようにするためです。
「結果をPNGで保存」を押すと、toBlob(..., "image/png")で画像を作ります。JPEGは透明度を保存できないので、背景透過を使いたい場合はPNGなど透明度対応形式を選びます。ダウンロードに使ったObject URLは、クリック後に解放します。背景を別画像へ交換するときは、新しい背景を先に描き、その上に透明な人物画像を描けます。
保存ボタンは成功した結果があるときだけ有効になります。画像を選び直した時は無効へ戻します。これは、まだ推論していない元画像を「背景透過結果」として保存しないためです。保存したPNGを別の背景色の上へ置くと、画面の市松模様が画像に入っていないことと、髪の周囲に背景の色が薄く残っていないかを確認できます。
7. 背景透過ツールの制作から学ぶ
サイトの背景透過ツールでは、モデルの結果をそのまま最終回答にせず、消しゴムや復元ブラシで修正できる流れにしています。細い髪、腕の間、服と背景の近い色は自動推定だけで完全に分かれるとは限らないからです。編集用のマスクを自前で持つことで、モデル結果を解放した後も修正と再描画ができます。
背景透過の制作解説には、マスク寸法の調整や配列のコピーを含む実用品の処理がつながります。この教材は仕組みを見せるため、手動補正、境界処理、出力の高解像度化を省いた最小例です。輪郭が荒い場合に入力だけを巨大化しても、モデルの出力解像度や得意不得意は変わらない点に注意します。
失敗を調べるときは、最終画像より先にマスクを見ます。マスクが逆ならチャンネルの選択、マスクがずれるなら寸法、透明にならないなら合成設定と出力形式を確認します。SDKの取得失敗はConsole・Networkへ。APIの詳細は公式Webセグメンテーションガイドを参照してください。