猫の手ツール

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

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

MediaPipe背景透過入門:人物マスクを作り、透明PNGへ変換する

画像の背景を消すには、顔の位置ではなく「どの画素を人物として残すか」が必要です。ImageSegmenterの人物マスクを途中画像として表示し、その透明度を使って背景を消します。AIの出力と画像加工を分けて理解できるサンプルです。

1. 検出した長方形から、画素ごとの領域へ

顔検出では四角い枠の内側に背景も含まれます。セグメンテーションは画像の位置ごとに領域を分類するため、髪や肩に沿って人物を残せます。その結果を表す画像状のデータが「マスク」です。マスクの明るさや透明度を使って、別の画像に効果をかけます。

ImageSegmenterは使うモデルによって分けられる対象が変わります。この記事はSelfie Segmenterを使い、人物と背景を分けます。猫を切り抜くためのモデルではありません。髪だけや服だけを扱う場合は髪・衣服のモデル比較へ進みます。モデルの用途は公式ImageSegmenter概要にまとまっています。

2. 入力・マスク・背景透過の3つを見る

背景透過サンプルを開く → AI生成人物を選ぶ → 実行ボタンを押します。入力画像、背景透過結果、人物確信度マスクを見比べてください。市松模様は透明な部分を確認するための画面背景で、出力画像に描き込んでいる柄ではありません。

AI生成人物の入力と、背景を透明にした結果を並べたサンプル実行画面
WindowsのChrome・GPU、SDK 0.10.34とSelfie Segmenter float16版1での実行画面。入力と透過結果を表示しています。市松模様は透明な領域を示す画面の背景です。
同じAI生成人物から得た人物確信度を、白いマスクの濃淡として描画した実行画面
同じ実行の人物確信度マスクを表示した画面。このモデルのconfidenceMasks[0]をアルファへ変換し、白い部分ほど人物確信度が高いことを確認します。人物の輪郭の推定と、透明画像の合成を分けて見られます。

自分の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セグメンテーションガイドを参照してください。

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

制作時に確かめたこと

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

人物用モデルで、手元の猫も切り抜けたという観察

一枚で得られた結果と、モデルの対象を分ける

背景透過の制作中、人物だけを扱うのか、犬や猫などは除かれるのかを確認した後、「なんか、猫でも切り取れるけど」と手元の結果を報告しました。予想と違った結果も、入力とモデルを調べる手掛かりになります。

これは当時の背景ツールと特定の猫画像についての観察です。現在のTasks教材で同じ猫画像を再検証した記録でも、人物向けモデルが猫全般へ対応する証明でもありません。モデルの対象を説明する時は、手元の一例を一般的な性能保証へ広げないようにしています。