ドーナツの写真を「白背景」モードで抜いた。外周のふちはきれいに透けたのに、中心の穴だけが白いまま残っている。同じことが、弓の弦と本体の隙間でも、マグカップの取っ手の内側でも起きる。四隅から色を染み込ませていく FloodFill は、被写体にぐるりと囲まれた背景まで手が届かない。逆に黒背景のエンブレムを抜くと、今度は抜けてほしくない内側の黒い模様まで透けてしまう。第2回で作った古典CVエンジンは単純背景では速くて正確だが、この「囲まれた背景」と「アイコンの穴」という相反する2つの難所で必ずつまずく。今回はここを実測で解く。被写体に囲まれた背景同色は「消すべきポケット」と「残すべき絵柄の中身」が同居するので、片方に決め打ちせず、2つのオプションで両方を手元に持つ。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。うまくいく話より、この回の「どちらかに全振りすると必ず破綻する」の方が役に立つはずだ。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴(この記事)
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する
第3回のゴール
この記事を読み終えると、被写体に囲まれた背景(弓の内側・布の裏の緑)を追加で透過する remove_background_pockets と、アイコン内部の穴を守る block_narrow_corridors の2つが手元で動く。ゴールは矛盾している。弓の内側の背景は消したい。エンブレム内部の黒い模様は残したい。どちらも「被写体に囲まれた背景色の領域」という点では見分けがつかない。この矛盾を、1つの魔法の関数で解こうとすると必ず壊れる。今回は「消す側の専用関数」と「守る側の専用関数」を別々に作り、プリセットでどちらを効かせるか切り替える設計にたどり着く。まず、四隅FloodFillがなぜ届かないのかを、絵で確かめるところから始める。
Step 1. なぜ四隅FloodFillは届かないか
第2回の flood_background_mask は、画像の四隅を起点に「外側から到達できる背景色」だけを透明にする。ドーナツで言えば、生地の外側をぐるりと囲む白は四隅とつながっているので抜ける。中心の穴の白は、生地の輪でぐるりと囲まれていて、四隅のどの点からも色が連続してたどり着けない。FloodFill は水を流す処理に近い。外周から流し込んだ水は、被写体という堤防の外側しか浸せない。堤防の内側にある池(囲まれた背景)には、外から水が回り込めない。
def flood_background_mask(bgr, tolerance):
"""四隅FloodFillで「外側から到達できる背景色」だけを透明にする。
白に限らず、四隅の色を基準に色距離 tolerance 以内の連結領域を背景とする。
被写体内部の同色(穴)は残る。
...
"""
docstring が「被写体内部の同色(穴)は残る」とはっきり書いているとおり、これは FloodFill の欠陥に見えて、設計どおりの仕様だ。この性質があるからこそ、白シャツの上の白いボタンや、金属の白いハイライトを誤って抜かずに済む。囲まれた背景を消したいケースと、囲まれた同色を守りたいケースは、同じ「外から届かない領域」として現れる。四隅FloodFillの一段外側に、この2つを見分ける処理を足していく。
Step 2. ポケット除去の2条件:隣接か、ほぼ純背景色か
囲まれた背景(ポケット)を追加で消す remove_background_pockets は、残った被写体マスクのうち「背景代表色に近い連結成分」を調べ、2つの条件のどちらかを満たす成分だけを背景側に倒す。背景代表色は、外周2px帯の中央値で決める。画像のいちばん外側は、まず間違いなく背景だからだ。
def remove_background_pockets(bgr, mask, near=45, core=12):
"""flood_background_mask が残した「背景色の閉じたポケット」を追加で透明にする。
四隅FloodFillは被写体に囲まれた背景(弓と弦の間・布の裏の緑など)に
到達できない。本関数は残存画素のうち背景代表色(外周2px帯の中央値)
に近い連結成分を調べ、以下のいずれかを満たす成分だけを背景側に倒す:
1. 除去済み背景に隣接(シルエット上のフォグ/AAリムの掃除)
2. ほぼ背景色そのものの画素を含む(色距離 min <= core 実効値)
被写体内の明るいハイライト(白シャツの縞・金属の摩耗・骨の頭頂など)
は純背景色まで届かないため保全される。
...
"""
2つの条件には、それぞれ役割がある。条件1「除去済み背景に隣接」は、シルエットの縁に薄く残るフォグやアンチエイリアスのリムを掃除する。すでに透明になった背景のすぐ隣で、背景色に近い成分は、ほぼ確実に背景の取りこぼしだ。条件2「ほぼ背景色そのものの画素を含む」は、周囲から孤立した囲まれ背景を拾う。ドーナツの穴のように、四隅ともシルエットの縁とも連続していない池は、条件1では拾えない。穴の中に「純粋な背景色の画素」が含まれていれば、それは絵柄の一部ではありえず、背景の取りこぼしだと判断できる。
成分ごとの色距離の最小値を求める部分に、少し工夫がある。連結成分ごとに「その中で最も背景色に近い画素の距離」を出したい。ループを回さず、np.minimum.at で成分ラベルをインデックスにして一発で集計する。
kept = mask > 128
removed = ~kept
if removed.any():
rd = dist[removed]
near = max(near, float(np.percentile(rd, 99.9)) + 8.0)
core = max(core, float(np.percentile(rd, 99.0)) + 5.0)
cand = (dist <= near) & kept
if not cand.any():
return mask
n, lbl = cv2.connectedComponents(cand.astype(np.uint8), 8)
# 成分ごとの min dist(ベクトル化)
mins = np.full(n, 999, np.int32)
flat = cand.ravel()
np.minimum.at(mins, lbl.ravel()[flat], dist.ravel()[flat])
# 条件1: 除去済み背景に隣接する成分
removed_dil = cv2.dilate(removed.astype(np.uint8),
np.ones((3, 3), np.uint8)) > 0
remove = np.zeros(n, bool)
remove[np.unique(lbl[removed_dil & cand])] = True
# 条件2: ほぼ背景色の画素を含む成分
remove |= mins <= core
ここで守られるものにも注目してほしい。docstring の「被写体内の明るいハイライト(白シャツの縞・金属の摩耗・骨の頭頂など)は純背景色まで届かない」という一文だ。白背景で撮った白シャツの、光が当たった白い縞。これは背景と同じ「白っぽい色」でも、純白の背景色ちょうどには届かない。条件2の core 判定に引っかからないので、被写体として残る。ポケットを消す処理と、囲まれた同色を守る処理は、この「純背景色にどこまで近いか」という一線で分けている。


Step 3. ビネット対応の自動拡張:near と core を分布から広げる
ここまでの説明には嘘がある。背景が完全に均一なら near=45 と core=12 の固定値でうまくいく。現実の背景は均一ではない。グリーンバックの布は、照明のムラやレンズのビネット(周辺減光)で、中央と四隅で緑の濃さが違う。外周2px帯から取った代表色を基準にすると、画面中央のやや暗い緑が「代表色から遠い」と判定され、ポケットとして拾えなくなる。
そこで、実際に除去された背景の色距離分布を見て、しきい値を自動で広げる。除去済み背景の画素の色距離のうち、99.9パーセンタイルより少し上まで near を、99パーセンタイルより少し上まで core を引き上げる。
if removed.any():
rd = dist[removed]
near = max(near, float(np.percentile(rd, 99.9)) + 8.0)
core = max(core, float(np.percentile(rd, 99.0)) + 5.0)
意味は素直だ。すでに背景と確定した画素が、代表色からどれだけばらついているか。そのばらつきの上端(ほぼ最大)を、ポケット判定の許容範囲に取り込む。背景がビネットで揺れている画像では near と core が自然に広がり、揺れの中の池も拾える。docstring の言葉を借りれば near_eff = max(near, p99.9+8), core_eff = max(core, p99+5) で、「背景が均一な画像では指定値のまま動く」。max() を挟んでいるので、自動拡張はしきい値を広げる方向にしか効かない。均一な背景でうっかり値が縮んで、守るべき被写体を巻き込む事故は起きない。
Step 4. 実測で確認した限界:消せない影付きポケットと、透ける絵具
ここが今回いちばん書きたかったところだ。この設計には、局所統計では原理的に越えられない壁がある。ごまかさずに書く。まず docstring に、開発中に実測で確認した限界がそのまま残っている。
※「純背景画素を含まない影付きポケット」と「暗部に隣接する明るい
被写体」は局所統計では分離できない(実測で確認)。core を上げると
前者を取れるが後者を壊すリスクを負う。白背景は明るい被写体が
多いため core=12、グリーンバック等の有彩色背景では純背景色に近い
被写体が稀なため core=30 程度まで上げられる。
影が落ちた囲まれ背景は、純粋な背景色の画素を1つも含まないことがある。全体がうっすら暗い緑や灰色になっていて、条件2の core 判定に引っかからない。これを取ろうと core を上げると、今度は「暗部に隣接する明るい被写体」が巻き込まれて壊れる。両者は色距離の局所的な統計だけでは見分けがつかない。だから白背景では明るい被写体が多いことを見越して core=12 に抑え、被写体が背景色に近づきにくいグリーンバックでは core=30 まで上げる、という背景ごとの判断でしのいでいる。
もう一つ、プリセットのしきい値そのものにも実測の傷跡がある。scripts/tune_presets.py のテスト対象画像に、こんなコメントが残っている。
# 既知の限界(2026-06-12 採用 tol=22 時): バナー絵画にクロマキー緑と
# ほぼ同色の絵具が使われており、Floodの勾配クロールで微小斑点
# ((628,765)付近 ≈8x5px / (686,886) 6x14px)が透過する。tol=20 に
# 下げると絵は無傷だが MAI の背景緑が目に見えて残るため tol=22 を採用。
# 必要ならアプリの復元ブラシで補修できる。
テスト画像の中に、背景のクロマキー緑とほとんど同じ色の絵具で描かれたバナー絵があった。FloodFill の許容差 tol を22にすると、その絵具の微小な斑点(座標(628,765)付近の約8×5pxと、(686,886)の6×14px)まで背景と誤認して透けてしまう。tol を20に下げると絵は無傷になるが、今度は別の画像で背景の緑が目に見えて残る。どちらを立てても、どちらかが立たない。全画像を1つのしきい値で完璧に抜く値は存在しなかった。だから背景緑の残りの方を嫌って tol=22 を採用し、8×5pxの取りこぼしは第5回で作る復元ブラシで手当てする、という割り切りを選んだ。
自動で9割、手で1割。これがこのアプリの正直な立ち位置だ。
Step 5. アイコン形状維持:erode して外周成分だけ dilate し直す
ここまでは「囲まれた背景を消す」側だった。アイコンシートやエンブレムでは、逆の要求が来る。細い装飾線の隙間から FloodFill が内部へ侵入し、本来アイコンの一部である黒い内部まで抜けてしまう。block_narrow_corridors は、この「細い通路」を塞いで内部を守る。
def block_narrow_corridors(mask, radius=3):
"""幅 2*radius+1 px 未満の通路の先にある背景を被写体側に戻す。
アイコン/エンブレム画像では、細い装飾線の隙間から FloodFill が内部へ
侵入し、本来アイコンの一部である黒い内部が抜けてしまう。
背景マスクを radius で侵食し、画像外周と連結する成分だけを残して
同じ radius で膨張し直す(境界連結 opening)。細い通路の先
(アイコン内部)は被写体側に保たれ、広く開いた背景
(アイコン間の余白)はそのまま透明になる。
Args:
mask: uint8 (H, W)。flood_background_mask の出力(255=被写体)。
radius: 0 で無効。3 なら幅7px未満の通路を遮断。
Returns:
uint8 (H, W) アルファ。
"""
if radius <= 0:
return mask
bg = (mask < 128).astype(np.uint8)
kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
eroded = cv2.erode(bg, kernel)
n, lbl = cv2.connectedComponents(eroded, 8)
border_labels = (set(lbl[0, :]) | set(lbl[-1, :]) |
set(lbl[:, 0]) | set(lbl[:, -1])) - {0}
if not border_labels:
return mask # 外周に届く背景が無い(全面被写体など)→ 変更しない
seed = np.isin(lbl, list(border_labels)).astype(np.uint8)
wide_bg = cv2.dilate(seed, kernel) & bg
return np.where(wide_bg > 0, 0, 255).astype(np.uint8)
やっていることは形態学の opening を、外周連結という条件付きで回すだけだ。手順を追う。まず背景を radius で侵食する。エンブレムの外側の広い背景は、半径3ぶん痩せても消えずに残る。細い装飾線の隙間に染み込んだ「幅の狭い背景」は、侵食で消えてなくなる。次に、侵食後も生き残った背景成分のうち、画像の外周(上下左右の端)に触れているものだけをシードに選ぶ。そのシードを同じ radius で膨張し直す。広く開いた本物の背景は元の太さに戻り、細い通路の先にあった内部は背景シードとつながらないまま被写体側に保たれる。
radius=3 なら遮断できる通路の幅は7px未満だ。カーネルサイズが 2*radius+1 だから、半径3は直径7pxの円。これより細い隙間は侵食で断ち切られ、これより広い背景は生き残る。しきい値は幅そのもので直感的に効く。border_labels が空、つまり外周に届く背景が無い全面被写体の画像では、docstring どおり「変更しない」で素通しする。安全側に倒してある。

Step 6. 白/黒/緑でオプションが逆になる理由
ポケット除去とアイコン形状維持は、背景色によってどちらを効かせるかが逆になる。この判断を毎回手で切り替えるのは面倒だし、間違える。プリセットに畳み込んでおく。BG_PRESETS の3つを並べると、方針の違いがそのまま数値になっているのが見える。
BG_PRESETS = {
"白背景": {"flood_tol": 8, "threshold": 128, "softness": 30,
"edge_shift": 0, "noise": 1, "blur": 1,
"pocket_removal": True, "pocket_near": 45, "pocket_core": 12,
"icon_keep": False},
"黒背景": {"flood_tol": 8, "threshold": 128, "softness": 30,
"edge_shift": 0, "noise": 1, "blur": 1,
"pocket_removal": False, "pocket_near": 45, "pocket_core": 12,
"icon_keep": True},
"グリーンバック": {"flood_tol": 22, "threshold": 128, "softness": 30,
"edge_shift": -1, "noise": 1, "blur": 1,
"pocket_removal": True, "pocket_near": 45,
"pocket_core": 30, "icon_keep": False},
}
3つのプリセットの違いを、今回の2関数に絞って読む。「白背景」は pocket_removal: True かつ icon_keep: False。白背景の被写体写真は、囲まれた白い背景(ドーナツの穴)を消したい場面が多く、細い通路を守る必要は薄い。「黒背景」は真逆で pocket_removal: False かつ icon_keep: True。黒背景はエンブレムやアイコンで使われることが多く、内部の黒い模様を守る方が優先される。「グリーンバック」は pocket_removal: True で、pocket_core だけが12から30に上がっている。Step 4で見たとおり、有彩色背景では純背景色に近い被写体が稀なので、core を上げても被写体を壊しにくい。
プリセットをどう適用するかは flood_base_mask にまとまっている。フラグを見て、必要な後処理だけを順に足していく素直な関数だ。
def flood_base_mask(bgr, settings):
"""FloodFill ベースマスク + プリセット由来の追加処理(UI/一括処理共用)。
settings: flood_tol / flood_icon / flood_pockets / pocket_near /
pocket_core を持つ dict(collect_settings 互換)。
"""
base = flood_background_mask(bgr, tolerance=settings["flood_tol"])
if settings.get("flood_icon"):
base = block_narrow_corridors(base, radius=3)
if settings.get("flood_pockets"):
base = remove_background_pockets(
bgr, base, near=settings.get("pocket_near", 45),
core=settings.get("pocket_core", 12))
return base
第2回の flood_background_mask でベースを作り、flood_icon が立っていれば block_narrow_corridors を、flood_pockets が立っていれば remove_background_pockets を重ねる。この関数はUIの単発処理と一括処理の両方から呼ばれる共用部品だ。プリセットで既定を決めておいて、うまくいかない画像だけスライダーで手を入れる。矛盾する2つの要求を、1つの関数で無理に両立させず、独立した3つの純関数と3つのプリセットに分けたことで、どちらの被写体にも破綻せず届く形になった。
まとめ:第3回の要点
- 四隅FloodFillの限界:外側から到達できる背景しか抜けない。被写体に囲まれた池(穴)は仕様として残る。これが白いボタンやハイライトを守る性質でもある。
- ポケット除去の2条件:
remove_background_pocketsは「除去済み背景に隣接」または「ほぼ純背景色を含む」成分だけを倒す。np.minimum.atで成分別の最小色距離を一括集計。 - 自動拡張:ビネットで揺れる背景に対し、除去済み画素の分布から
near_eff=max(near, p99.9+8)・core_eff=max(core, p99+5)でしきい値を広げる方向にだけ効かせる。 - 実測の限界:影付きポケットと明るい被写体は局所統計で分離不能。バナー絵具の8×5px斑点は
tol=22で透け、tol=20では背景緑が残る。取りこぼしは復元ブラシで手当てする割り切り。 - アイコン形状維持:
block_narrow_corridorsは erode→外周連結成分だけ dilate の境界連結 opening。radius=3で幅7px未満の通路を遮断。 - プリセットで逆転:白は消す(pocket_removal)、黒は守る(icon_keep)、緑は消しつつ
core=30。背景色ごとの判断をBG_PRESETSに畳んでおく。
次回(第4回)は、この連載でずっと避けてきたAIをローカルに持ち込む。古典CVでは背景と被写体の色が近い画像や、髪の毛のような半透明の境界で限界が来る。BiRefNet という高精度の背景除去モデルを、PyTorch を同梱せず ONNX+onnxruntime-directml で動かす。GPUがあれば速く、無くても動く。この「1コードで両対応」をどう書くか、モデルごとに違う前処理と DirectML→CPU のフォールバックを、実測値つきで開いていく。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。