スピーカー写真の背景除去は「重い ONNX モデルを積むか / 何も載せないか」の二択になりがちだが、@event/visuals は 契約と実装を分離することでどちらも成立させる。「透過 PNG を返す」契約 (CutoutBackend) だけを固定し、抜き方は差し替え可能にする。同梱するのは依存ゼロで実際に動く canvas フォールバック 1 本、production 品質の MODNet / @imgly は差し込み口だけを用意し実体は未同梱とする。
CutoutBackend 契約
export type CutoutInput = ImageBitmap | HTMLCanvasElement | Blob;
export interface CutoutBackend {
readonly id: string;
removeBackground(input: CutoutInput, opts?: CutoutOpts): Promise<Blob>; // 透過 PNG Blob
}
守るのは 1 点だけ ——「入力を受けて透過 PNG の Blob を返す」。これだけ守れば canvas でも ONNX でも WASM でも同じ穴に差せる。UI 側は契約にしか依存しないので、backend を差し替えても無改修で乗り換わる。
ディスパッチャは既定 backend を canvas fallback に固定する:
export function removeBackground(
input: CutoutInput,
backend: CutoutBackend = canvasLumaKeyBackend, // 既定 = zero-dep
opts?: CutoutOpts,
): Promise<Blob> {
return backend.removeBackground(input, opts);
}
canvas フォールバックの原理(本物・動く)
canvasLumaKeyBackend はブラウザ内蔵の canvas 2D だけで動くカラーキー方式。新規 npm 依存はゼロ:
- 入力を canvas に描き、
getImageDataで RGBA 画素を取り出す - 枠(端)の画素をリング状にサンプルして背景キー色を推定する(被写体は中央・背景は縁に多い、という自然写真の前提)。
opts.keyColorで明示指定も可 - 各画素とキー色の RGB ユークリッド距離 d を出し、alpha を書き換える:
- d ≤ threshold → alpha 0(完全透過) - threshold < d < threshold + feather → alpha × (d - threshold) / feather で線形羽化 - それ以上 → 元の alpha を保持(= 被写体)
putImageData→canvas.toBlob("image/png")で透過 PNG を返す
for (let i = 0; i < data.length; i += 4) {
const dr = (data[i] ?? 0) - kr, dg = (data[i + 1] ?? 0) - kg, db = (data[i + 2] ?? 0) - kb;
const dist = Math.sqrt(dr * dr + dg * dg + db * db);
if (dist <= threshold) data[i + 3] = 0;
else if (dist < threshold + feather) data[i + 3] = Math.round(((data[i + 3] ?? 255) * (dist - threshold)) / feather);
}
単色〜ほぼ単色の背景(スタジオ幕・白ホリ・単色パネル前)では十分機能する。完璧ではない——複雑な背景や髪の毛の半透明な毛先は抜けない。それは production backend の領分であり、canvas fallback は「依存ゼロで dev で今すぐ透過が出る」デモ用と割り切る。
opts(しきい値と羽化)
type CutoutOpts = {
threshold?: number; // 背景キーからの色距離しきい値 (0–441)。既定 72。大きいほど強く抜ける
feather?: number; // ソフト境界幅。既定 32。大きいほど輪郭が滑らか
keyColor?: [number, number, number]; // 背景キー色を明示(未指定は枠からサンプル)
maxSize?: number; // 処理前の最大辺(px)。プレビュー再処理を軽く保つ(DL はフル解像度で撮り直す)
};
ブラウザ専用 = client で呼ぶ
document / createImageBitmap / canvas を使うので client でのみ動く(SSR で呼ぶと明示エラー)。呼び出し側コンポーネントに "use client" を付け、File を createImageBitmap でデコードして渡す:
"use client";
const bmp = await createImageBitmap(file);
const png = await removeBackground(bmp, canvasLumaKeyBackend, { threshold, feather, maxSize: 1600 });
bmp.close();
const url = URL.createObjectURL(png); // <img src> でチェッカーボード背景の上にプレビュー
starter の /admin/cutout(features.isEnabled("visuals") + admin 認証でガード)が参照実装。アップロード → しきい値/羽化スライダ → 透過プレビュー → 透過 PNG ダウンロードまで client で完結する。専用 feature flag は持たず visuals 配下の admin ツールとして扱う。
production backend を差す(MODNet / @imgly・未同梱)
髪境界まで抜く alpha matting は重い依存が要るため本パッケージには同梱しない。代わりに factory + 設定型の差し込み口だけを置き、依存を入れるまで明示的に throw する(vaporware を握らせず、動くのは canvas fallback だけと分かる形にする)。
// いずれも CutoutBackend を返す。実体は未同梱 → 依存導入まで throw。
export function createModnetBackend(config: ModnetBackendConfig): CutoutBackend; // onnxruntime-web + MODNet .onnx
export function createImglyBackend(config?: ImglyBackendConfig): CutoutBackend; // @imgly/background-removal
(A) MODNet (onnxruntime-web):
yarn workspace <consumer> add onnxruntime-web- MODNet の
.onnx(例modnet_photographic_portrait_matting.onnx)をpublic/models/へ createModnetBackendのremoveBackgroundを実装 —— 512×512 に正規化した NCHW テンソル化 →ort.InferenceSessionで推論 → 出力 matte(1ch alpha)を元寸法へ拡大し RGB × alpha で合成 → 透過 PNG
- onnxruntime-web の wasm/threads は COOP/COEP ヘッダと .wasm 配信設定が要る(Next config)
(B) @imgly/background-removal:
yarn workspace <consumer> add @imgly/background-removalcreateImglyBackendのremoveBackgroundでimglyRemove(input, { publicPath, output: { format } })を呼び透過 Blob を返す
- 初回にモデル資産(数十MB)を publicPath から取得する。CDN 直リンクは避け自前配信推奨
どちらも同じ CutoutBackend 契約なので、依存導入後は removeBackground(input, createModnetBackend({ modelUrl })) のように第2引数を差し替えるだけ。UI・章のワークフローは無改修で production 品質へ乗り換わる。
設計の要点
- 契約が薄い(透過 PNG Blob を返すだけ)ので、抜き方の進化(canvas → ONNX → 将来の別モデル)を UI から隔離できる
- 同梱は本物だけ——動く canvas fallback は実装込み、動かせない production backend は差し込み口のみ。「未同梱」を型とエラーで明示し、依存を勝手に増やさない
- 重い依存(onnxruntime-web / @imgly)は消費側アプリが必要になった時点で add する。フレームワーク側(
@event/visuals)は依存ゼロを保つ