1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Canvas API + BFSで証明写真の背景をブラウザだけで自動除去する実装

1
Posted at

はじめに

証明写真の背景除去というと、Remove.bg のような外部APIや、AIモデルを使うことが多いです。しかし Canvas API + BFS(幅優先探索) を使えば、外部API不要・完全ブラウザ完結で背景を除去できます。

  • サーバーへの画像送信なし
  • ライブラリ不要(Canvas API のみ)
  • BFSフラッドフィルで背景ピクセルを検出・透過化
  • エッジをソフトにして自然な仕上がり

実際に動くツール → 証明写真作成ツール - ToolBoxJP


アプローチ:BFSフラッドフィル

背景除去のアルゴリズムには様々なものがあります。

手法 精度 実装コスト
AIモデル(rembg等) ★★★ 高(重いモデル)
GrabCut(OpenCV.js) ★★★ 中
BFSフラッドフィル ★★ 低(標準APIのみ)
閾値による単純二値化 ★ 最低

BFSフラッドフィルは「証明写真のような均一な背景」に効果的です。AIほどの精度はありませんが、外部ライブラリ不要で軽量に動作します。


アルゴリズムの概要

1. 画像の四辺(境界ピクセル)から背景色を推定
2. 境界ピクセルを起点にBFSを実行
3. BFSで訪問したピクセルのうち、背景色と近い色を透過化
4. エッジを距離に応じてソフトに(αを徐々に変化)

実装

1. 背景色の推定

四辺のピクセルを集めて「トリム平均」で背景色を推定します。

function estimateBackgroundColor(
  data: Uint8ClampedArray,
  width: number,
  height: number
): [number, number, number] {
  const border: [number, number, number][] = [];

  // 上辺・下辺のピクセルを収集
  for (let x = 0; x < width; x++) {
    const topIdx = x * 4;
    const bottomIdx = ((height - 1) * width + x) * 4;
    border.push([data[topIdx], data[topIdx + 1], data[topIdx + 2]]);
    border.push([data[bottomIdx], data[bottomIdx + 1], data[bottomIdx + 2]]);
  }

  // 左辺・右辺のピクセルを収集
  for (let y = 1; y < height - 1; y++) {
    const leftIdx = y * width * 4;
    const rightIdx = (y * width + width - 1) * 4;
    border.push([data[leftIdx], data[leftIdx + 1], data[leftIdx + 2]]);
    border.push([data[rightIdx], data[rightIdx + 1], data[rightIdx + 2]]);
  }

  // 輝度でソートして中間60%のトリム平均を取る
  // (髪・服の色が端にかかっていても外れ値として除外できる)
  border.sort((a, b) => (a[0] + a[1] + a[2]) - (b[0] + b[1] + b[2]));

  const s0 = Math.floor(border.length * 0.2);  // 下位20%を除外
  const s1 = Math.floor(border.length * 0.8);  // 上位20%を除外

  let r = 0, g = 0, b = 0;
  for (let i = s0; i < s1; i++) {
    r += border[i][0];
    g += border[i][1];
    b += border[i][2];
  }

  const count = s1 - s0;
  return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
}

トリム平均を使う理由:
証明写真では髪や衣服が画像の端に触れることがあります。単純平均では髪の黒が背景色として混入してしまうため、外れ値を除いたトリム平均を使います。

2. BFSで背景ピクセルを検出・透過化

const TOLERANCE = 50;  // 背景色との許容距離(ユークリッド距離)

function removeBackground(
  imageData: ImageData,
  bgColor: [number, number, number]
): ImageData {
  const { data, width, height } = imageData;
  const [bgR, bgG, bgB] = bgColor;

  // ピクセルの色と背景色のユークリッド距離
  const colorDistance = (pixelIdx: number): number => {
    const dr = data[pixelIdx] - bgR;
    const dg = data[pixelIdx + 1] - bgG;
    const db = data[pixelIdx + 2] - bgB;
    return Math.sqrt(dr * dr + dg * dg + db * db);
  };

  // BFS
  const visited = new Uint8Array(width * height);  // 訪問済みフラグ
  const queue: number[] = [];

  // 境界ピクセルをすべてキューに追加(BFSの開始点)
  const seed = (x: number, y: number) => {
    const idx = y * width + x;
    if (!visited[idx]) {
      visited[idx] = 1;
      queue.push(idx);
    }
  };

  for (let x = 0; x < width; x++) {
    seed(x, 0);
    seed(x, height - 1);
  }
  for (let y = 1; y < height - 1; y++) {
    seed(0, y);
    seed(width - 1, y);
  }

  // BFS実行
  let qi = 0;
  while (qi < queue.length) {
    const idx = queue[qi++];
    const pixelIdx = idx * 4;
    const dist = colorDistance(pixelIdx);

    // 背景色と差が大きい(人物部分)はスキップ
    if (dist > TOLERANCE) continue;

    // エッジをソフトに:距離が小さいほど透明に、大きいほど不透明に
    // (dist / TOLERANCE)^1.5 で滑らかなフォールオフ
    data[pixelIdx + 3] = Math.round((dist / TOLERANCE) ** 1.5 * 255);

    // 4近傍のピクセルをキューに追加
    const x = idx % width;
    const y = (idx / width) | 0;
    const neighbors: [number, number][] = [[-1, 0], [1, 0], [0, -1], [0, 1]];

    for (const [dx, dy] of neighbors) {
      const nx = x + dx;
      const ny = y + dy;
      if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;

      const ni = ny * width + nx;
      if (!visited[ni]) {
        visited[ni] = 1;
        queue.push(ni);
      }
    }
  }

  return imageData;
}

エッジのソフト化:
(dist / TOLERANCE) ** 1.5 の乗数を1.5にすることで、境界に向かってαが徐々に増加します。乗数を1にすると線形、2以上にするとよりシャープなエッジになります。

3. メイン処理:画像をCanvasで処理

async function removeBgFromFile(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      // 大きい画像はリサイズして処理(メモリ節約)
      const MAX = 1500;
      const ratio = Math.min(MAX / img.width, MAX / img.height, 1);
      const w = Math.round(img.width * ratio);
      const h = Math.round(img.height * ratio);

      const canvas = document.createElement("canvas");
      canvas.width = w;
      canvas.height = h;
      const ctx = canvas.getContext("2d")!;

      // 画像をCanvasに描画
      ctx.drawImage(img, 0, 0, w, h);

      // ピクセルデータを取得
      const imageData = ctx.getImageData(0, 0, w, h);

      // 背景色の推定
      const bgColor = estimateBackgroundColor(imageData.data, w, h);

      // BFSで背景除去
      const processed = removeBackground(imageData, bgColor);

      // 処理後のピクセルデータを書き戻し
      ctx.putImageData(processed, 0, 0);

      // PNG(透過あり)としてDataURLに変換
      resolve(canvas.toDataURL("image/png"));
    };
    img.onerror = () => reject(new Error("画像の読み込みに失敗しました"));
    img.src = URL.createObjectURL(file);
  });
}

4. Reactコンポーネントでの使い方

"use client";
import { useState, useCallback } from "react";

export function IdPhotoEditor() {
  const [originalSrc, setOriginalSrc] = useState<string | null>(null);
  const [processedSrc, setProcessedSrc] = useState<string | null>(null);
  const [bgColor, setBgColor] = useState("#ffffff");
  const [isRemoving, setIsRemoving] = useState(false);

  const handleFile = useCallback(async (file: File) => {
    const url = URL.createObjectURL(file);
    setOriginalSrc(url);
    setProcessedSrc(null);
  }, []);

  const handleRemoveBg = async () => {
    if (!originalSrc) return;
    setIsRemoving(true);
    try {
      // FileオブジェクトからBlobURLを経由して処理
      const resp = await fetch(originalSrc);
      const blob = await resp.blob();
      const file = new File([blob], "photo.jpg");
      const result = await removeBgFromFile(file);
      setProcessedSrc(result);
    } finally {
      setIsRemoving(false);
    }
  };

  return (
    <div>
      <input type="file" accept="image/*" onChange={(e) => {
        const f = e.target.files?.[0];
        if (f) handleFile(f);
      }} />

      {originalSrc && (
        <button onClick={handleRemoveBg} disabled={isRemoving}>
          {isRemoving ? "処理中..." : "背景を自動除去"}
        </button>
      )}

      {processedSrc && (
        // 処理後の画像を選択した背景色の上に表示
        <div style={{ backgroundColor: bgColor, padding: 16, display: "inline-block" }}>
          <img src={processedSrc} alt="背景除去済み" style={{ width: 200 }} />
        </div>
      )}

      {/* 背景色の変更 */}
      {processedSrc && (
        <div>
          {["#ffffff", "#e8ecf0", "#a8c4e0"].map((color) => (
            <button
              key={color}
              onClick={() => setBgColor(color)}
              style={{ backgroundColor: color, width: 32, height: 32, borderRadius: "50%",
                border: bgColor === color ? "2px solid blue" : "2px solid #ddd" }}
            />
          ))}
        </div>
      )}
    </div>
  );
}

パフォーマンスの考慮

1500px上限のリサイズ

高解像度の画像(4000×3000px等)をそのままGetImageData/putImageDataすると処理が重くなります。証明写真の用途では1500px以下で十分な品質が出るため、リサイズしてから処理します。

const MAX = 1500;
const ratio = Math.min(MAX / img.width, MAX / img.height, 1);
// ratioが1未満の場合のみリサイズ

BFSのメモリ使用量

BFSのキューは最悪ケースで全ピクセル数(1500×1500 = 225万)分の整数を保持します。number[] の代わりに Int32Array を使うとメモリ効率が上がりますが、実用上は number[] で問題ないことが多いです。


精度の限界と改善案

BFSフラッドフィルの限界:

  • グラデーション背景 には対応が難しい(均一色の背景向け)
  • 背景と髪が似た色 の場合に抜け残りが出やすい
  • 細い毛先 の透過は難しい

精度を上げたい場合の選択肢:

1. MediaPipe Selfie Segmentation(軽量AIモデル、~5MB)
2. ONNX Runtime Web + Matting モデル
3. Remove.bg API(有料・高精度)

証明写真のような「均一な白・青背景」のケースに特化するなら、BFSで十分な実用精度が出ます。


まとめ

項目 内容
アルゴリズム BFSフラッドフィル
背景色推定 四辺のトリム平均
エッジ処理 αを距離に応じてソフト化
出力 PNG(透過あり)
ライブラリ 不要(Canvas API のみ)
プライバシー ファイルはサーバーに送信されない

外部APIやAIモデルなしでも、証明写真のような用途なら十分な精度で背景除去が実現できます。

実際のデモは ToolBoxJP の証明写真作成ツール で確認できます。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?