BGSnap ができるまで — ブラウザ内ONNXで画像の背景を切り抜く設計
@imgly/background-removal を使って画像の背景透過をクライアント完結で実現するWebサービスの設計記録。WASM推論パイプライン、UX工夫、メディア処理ラボの3本目の位置づけを解説します。
BGSnap ができるまで — ブラウザ内ONNXで画像の背景を切り抜く設計
BGSnap は、画像の背景を 一切サーバーに送らずに 透過するWebサービスです。 ONNX Runtime Web + WebAssembly でブラウザに被写体抽出モデルを呼び込み、 アップロードを完全に省きました。voice-scribe (音声) と clip-cast (動画) に続く、メディア処理ラボの3本目の柱です。
なぜこの形にしたか
「背景を透過するために、画像をクラウドにアップロードする」 のは技術的にはともかく、心理的にとても引っかかります。 プロフィール写真、商品写真、子供の写真、本人確認書類の切り抜き — これらを得体の知れないSaaSにアップしている人がどれだけ自覚的に同意しているか怪しいです。
WebAssembly + ONNX Runtime Web の組み合わせがブラウザでまともに動くようになって、 「ML推論をブラウザ内で完結させる」 構成が現実的になりました。 @imgly/background-removal はそれを「3行で呼べる」ところまで整えたOSSライブラリです。
このサービスは、その構成を そのまま製品の体感に翻訳した ことが本体です。何も特別なアルゴリズムは無く、ただ「アップロードしない」 を一発で伝える形に落とし込みました。
visual direction
フォトスタジオ / ライトボックス。
- 深いプラム (
#0c0716) を背景に、エレクトリックバイオレット (#a259ff) を主アクセント - ホットマゼンタ (
#ff3dee) との 135° グラデーションを「切り抜き完了」の象徴に使う - ヒーローのタイトルは「一瞬で消す」だけグラデーション化、他はフラット
- スポットライト風の上からの淡い発光を入れて、撮影スタジオ感を出す
- voice-scribe (mint cyan / 縦バー / オシロ) と clip-cast (amber / 横タイムライン / 編集機) に対して、 こちらは紫 / フォトスタジオ という温度・形ともに別軸
実装の見どころ
1. ライブラリ依存を最小限に薄く包む
removeBackground(file) を呼ぶだけで結果の Blob が返るくらい、 @imgly のライブラリは綺麗に出来ています。 bgRunner.ts で薄くラップして、
- 動的import (
_libPromiseで1回だけロード) - progress コールバックで
kind: load / fetch / compute / doneを UI に流す - 結果の
ImageBitmapから width/height を測る - 入力/出力サイズと推論時間を一緒に返す
の最低限だけ持たせました。
const blob = await rb(input, {
output: { format: "image/png", quality: 1 },
progress: (key, current, total) => onProgress({ kind: "compute", ratio: current / total }),
})
これで API 側の都合が変わってもラップ層だけで吸収できます。
2. before/after compare slider
被写体抽出の結果を見せるのに、左右並べるよりも 「同じ位置で1枚の画像が透ける」 体験のほうがインパクトが強い。 Pointer Events で実装した可動式スライダーで、ハンドルをドラッグするとマスクされた境界が左右に動きます。
実装ポイント:
- 後ろに
before画像を常時表示 - 前面に
after画像をwidth: ${pct}%の overflow:hidden コンテナで重ね、 内側の img をwidth: 100vw; max-width: noneにして 位置がぶれない ようにする - ハンドルは
pointermoveで世界座標→相対座標→pct に変換
3. backdrop chips で出力プレビューの色を切替
透過 PNG は、表示する背景色で印象が大きく変わります。 そこでステージの背景を チェッカー / 白 / 黒 / 紫 の4択でその場切替できるようにしました。 商品写真なら白、SNSアイコンなら紫、というふうにユーザーが「どこで使うか」を即座に確かめられます。
4. 動的 import + ssr:false
Next.js 16 の Server Component から @imgly/background-removal を import するとSSR時に死にます。 ContactForm / VoiceScribe / ClipCast と同じく、loader でラップして dynamic({ ssr: false }) を強制しています。
const BGSnapApp = dynamic(() => import("./BGSnapApp").then((m) => m.BGSnapApp), {
ssr: false,
loading: () => null,
})
苦労したところ
- 初回モデルDLが ~80MB。 progress を見せないと「動かない」誤解を生むので、 推論前にローダーUIを出すフローを最初から組んだ。
- 巨大画像 (>8000px) のメモリ占有。 短辺で5000pxを目安にFAQに明記。ライブラリ側にダウンサンプリングも入っているが、限界はある。
- PNG出力の Blob 取り扱い。
BlobPartの型とArrayBufferLikeの所有権周りで一度ハマったが、 入出力とも Blob オブジェクトを直接受け渡す形に統一して回避。
今後の拡張
- 複数オブジェクト分離: 1枚に複数被写体がある場合に切り分ける選択UI
- エッジ精度の改善モード: 別モデルへの切替トグル (精度↔速度のトレードオフ)
- 影 (drop shadow) 復元オプション: アイコンや商品写真の自然さを上げる
- バッチ処理: 複数ファイルをドロップ→キュー実行
- clip-cast / voice-scribe との連携: 動画フレームから切り抜き → サムネ生成、字幕音声から無人ナレ動画への合体など
このサービスから言えること
「メディア (音声・動画・画像) を扱う処理は全部ブラウザだけで完結する」 という主張を、3本目で実証しました。 これでサイトとしての立ち位置がはっきりした気がします。 「アップロードしないと使えない」現状のメディア系SaaSのほとんどは、 そろそろブラウザ完結の代替が技術的に成立する時代に入ったということです。
Next Action
読んだあとに、そのまま使って確かめる。
この開発ログは BGSnap をどう育てているかの記録です。読んだらそのままサービス本体へ戻って、価値を確かめられるようにしています。