白い机に置いた商品を1枚、背景だけ抜きたい。AI(BiRefNet)に投げると、1枚あたり14.7秒待つ(README実測値)。同じ写真を四隅FloodFillの古典CVに通すと、スライダーを動かした瞬間に結果が返る。配列演算だけで完結するので待ち時間を感じない。しかも白背景やグリーンバックのような単純な背景では、古典CVの方が速くて境界も素直だ。第2回のテーマは、この古典CVエンジンを4つ、「BGR画像を渡すと uint8 アルファが返る純関数」としてそろえること。FloodFill・クロマキー・マジックワンド・Color-to-Alpha。AIの回(第4回)を待たなくても、この回だけで白背景の商品写真は抜けるようになる。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。AIの回を待たなくても、この回だけで白背景の商品写真は抜けるようになる。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha(この記事)
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する
第2回のゴール
この記事を読み終えると、removal_engines.py の4エンジンが単体で動く。白背景の商品写真は四隅FloodFillで、グリーンバックはクロマキーで抜ける。クリックした色に近い領域だけを狙うマジックワンド、指定色をなめらかに透過するColor-to-Alphaもそろう。共通しているのは形が同じことだ。入力は BGR の uint8 画像、出力は同じ大きさの uint8 アルファ(255=被写体、0=背景)。第1回で決めた「共通通貨」に、4本の抜き方をそろえて流し込む。
この回のコードは、Qtもファイルダイアログも一切知らない。
だから後半の Step6 で見るように、100×100 の合成画像を numpy で作って関数に渡し、特定の座標のアルファ値を assert するだけで挙動を固定できる。GUIを起動しなくても「白背景が抜けるか」「被写体内部の白い穴が残るか」をテストで守れる。ファイル冒頭のコメントが、この回の設計方針をそのまま宣言している。
# ==========================================
# removal_engines.py
# 古典CVのベースマスク生成(純関数)
# 戻り値はすべて uint8 アルファ(255=被写体, 0=背景)
# ==========================================
import cv2
import numpy as np
4エンジンは、前提の強い順に並べると読みやすい。四隅FloodFillは「背景は四隅に触れている」という一番強い前提で、白背景の商品写真をワンボタンで抜く。クロマキーは「背景の色相が決まっている」前提でグリーンバックを抜く。マジックワンドは前提を捨て、ユーザーがクリックした色を種にする。Color-to-Alphaは2値をやめ、指定色との距離で半透明の境界を作る。前提が強いほど自動で、弱いほど手で狙う。この順に1本ずつ組んでいく。
Step 1. 四隅からFloodFillで「外から届く背景」を塗る
白背景の商品写真を思い浮かべてほしい。背景の白は画像の四隅すべてに触れている。被写体は真ん中にあって、四隅には来ない。この性質を使う。四隅を種にして、色が近い連結領域を外側から塗りつぶす。塗れた場所が背景、塗れなかった場所が被写体だ。cv2.floodFill をそのまま使う。
def flood_background_mask(bgr, tolerance):
"""四隅FloodFillで「外側から到達できる背景色」だけを透明にする。
白に限らず、四隅の色を基準に色距離 tolerance 以内の連結領域を背景とする。
被写体内部の同色(穴)は残る。
...
"""
h, w = bgr.shape[:2]
bg_accum = np.zeros((h, w), np.uint8)
tol = (int(tolerance),) * 3
corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]
for pt in corners:
if bg_accum[pt[1], pt[0]]:
continue # 既に背景判定済みの隅はスキップ
ff_mask = np.zeros((h + 2, w + 2), np.uint8)
cv2.floodFill(bgr.copy(), ff_mask, pt, 0, tol, tol,
cv2.FLOODFILL_MASK_ONLY | (255 << 8) | 4)
bg_accum = cv2.bitwise_or(bg_accum, ff_mask[1:-1, 1:-1])
肝は FLOODFILL_MASK_ONLY フラグだ。これを付けると、floodFill は元画像を書き換えず、塗った範囲を別のマスク画像に記録する。塗り値は (255 << 8) で255を指定し、4 は4近傍連結を意味する。マスクは floodFill の仕様で周囲1pxの縁が要るため (h+2, w+2) で確保し、記録後に ff_mask[1:-1, 1:-1] で内側だけを取り出す。bgr.copy() を渡すのも、元画像を汚さないための保険だ。
4隅をループしつつ、各隅の結果を cv2.bitwise_or で bg_accum に重ねていく。ここで効くのが if bg_accum[pt[1], pt[0]]: continue の一行だ。白背景なら最初の左上隅の塗りつぶしで四隅すべてがひとつながりに埋まる。残り3隅はもう塗り済みなので、無駄な floodFill を呼ばずにスキップする。背景が2色に割れている画像(左半分が白、右半分がグレーなど)では、スキップされずに別々の隅から塗られ、両方が背景になる。
tol = (int(tolerance),) * 3 は、floodFill の loDiff(種の色より暗い方向の許容差)と upDiff(明るい方向の許容差)に、同じ値を3チャンネル分そろえて渡している。塗り進む条件は「いま塗ろうとする画素が、隣の塗り済み画素から tol 以内か」だ。tolerance を上げると連結が伸びやすくなり、背景の濃淡や影を1枚につなげて拾えるようになる。上げすぎると被写体との境界がぼやけた画像で被写体側へ食い込むので、この局所の暴走を止める役目を Step2 のカラーゲートが担う。連結を追う floodFill と、大域の距離で歯止めをかけるゲートは、同じ tolerance を別の効き方で使う二枚看板だ。
Step 2. カラーゲート:グラデーションの橋で被写体を食わせない
FloodFillには弱点がある。背景から被写体へ色がなだらかに変わっていく「橋」があると、そこを伝って被写体の内部まで塗り進んでしまう。緑の背景から白い被写体へ、40pxかけて少しずつ色が変わる帯があったとする。隣り合う画素の差は毎回 tolerance 以内なので、FloodFillは橋を渡りきり、本来残すべき白い被写体まで背景に倒す。これを止めるのが関数後半のカラーゲートだ。
# カラーゲート: 背景代表色(外周2px帯の中央値)から遠い画素は背景にしない
band = np.concatenate([bgr[:2].reshape(-1, 3), bgr[-2:].reshape(-1, 3),
bgr[:, :2].reshape(-1, 3), bgr[:, -2:].reshape(-1, 3)])
bg_color = np.median(band, axis=0).astype(np.int16)
dist = np.abs(bgr.astype(np.int16) - bg_color).max(axis=2)
gate = max(60, int(tolerance) * 4)
bg_accum[dist > gate] = 0
return cv2.bitwise_not(bg_accum)
やっていることは3手だ。画像の外周2px帯(上下端と左右端)を np.concatenate でつなぎ、その中央値を背景の代表色 bg_color とする。中央値を使うのは、外周にわずかに被写体やゴミが写り込んでも代表色が引っぱられないためだ。次に全画素について、代表色とのチャンネル別絶対差の最大値を dist として求める。最後に gate = max(60, tolerance*4) を超える画素、つまり背景色から明らかに遠い画素は、FloodFillが塗っていても bg_accum を0に戻して背景判定を取り消す。
gate の式 max(60, tolerance*4) にも意味がある。tolerance が小さいうちは下限の60が効き、tolerance を上げるとゲートも4倍で緩む。tolerance=25 なら tolerance*4=100 がゲートになる。連結の許容差(局所的な色差)とは別に、「背景代表色からどこまで離れたら被写体とみなすか」という全体の物差しを持たせている。最後の cv2.bitwise_not で、塗った背景(255)を反転し、被写体=255・背景=0 のアルファに変換して返す。
💡 tolerance を1つで2役こなさせない:素朴に作ると tolerance は「連結の許容差」だけになり、上げれば橋を渡って被写体を食い、下げれば背景の濃淡を取り逃す。ここでは連結の許容差(FloodFillの loDiff/upDiff)と、全体の距離ゲート(tolerance*4)を分けて持たせた。局所ではゆるく連結を追い、大域では代表色から遠い画素を守る。この二段構えが、白背景の縁のアンチエイリアスを拾いつつ被写体を残す効き方の正体だ。
Step 3. クロマキー:緑も青も、Hueの循環まで面倒を見る
グリーンバックのように背景の色相がはっきり決まっているなら、色相(Hue)で切るのが素直だ。chroma_key_mask は BGR を HSV に変換し、hue_center ± hue_width の色相かつ彩度が sat_min 以上の画素を背景にする。緑なら hue_center=60、青なら hue_center=110 あたりを指定する。
def chroma_key_mask(bgr, hue_center, hue_width, sat_min):
"""任意色相のクロマキー。hue_center±hue_width かつ彩度>=sat_min を背景に。"""
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
lo = int(hue_center - hue_width)
hi = int(hue_center + hue_width)
if lo < 0: # Hue は循環するので2区間で判定
m1 = cv2.inRange(hsv, (0, sat_min, 30), (hi, 255, 255))
m2 = cv2.inRange(hsv, (180 + lo, sat_min, 30), (179, 255, 255))
bg = cv2.bitwise_or(m1, m2)
elif hi > 179:
m1 = cv2.inRange(hsv, (lo, sat_min, 30), (179, 255, 255))
m2 = cv2.inRange(hsv, (0, sat_min, 30), (hi - 180, 255, 255))
bg = cv2.bitwise_or(m1, m2)
else:
bg = cv2.inRange(hsv, (lo, sat_min, 30), (hi, 255, 255))
return cv2.bitwise_not(bg)
単純な緑や青なら else の1区間 inRange で済む。厄介なのは赤だ。OpenCVの Hue は0〜179で循環し、赤は0付近と179付近の両方にまたがる。hue_center=0、hue_width=10 と指定すると lo=-10 になり、素朴な inRange では取りこぼす。そこで lo < 0 のときは (0〜hi) と (180+lo〜179) の2区間で判定して bitwise_or で合流させる。hi > 179 側も対称に処理する。色相の輪をまたいでも取り逃さない。
sat_min は白やグレーを守る番人だ。彩度が低い画素(白・グレー・黒)は色相が不安定なので、sat_min 未満なら背景にしない。緑バックの前に白いロゴがあっても、ロゴは彩度が低いので背景に落ちず残る。inRange の下限にある明度 30 は、真っ黒に近い画素を対象から外す最低ラインだ。テストでは hue_center=110(青)で青背景が抜け、hue_width=10 と狭めれば緑Hueの範囲外にある青は残る、という具合に幅で効きが変わる。
🔎 四隅FloodFillとクロマキーの使い分け:どちらも背景を2値で切るが、見ている軸が違う。FloodFillは位置と連結(四隅から届くか)で切り、被写体内部の同色(穴)を残せる。クロマキーは色相で切り、位置を問わず同じ色相を消す。グリーンバックの撮影素材のように背景の色相が均一で、被写体に緑が少ない写真ならクロマキーが素直に決まる。背景が白でも被写体に白が混じる商品写真なら、位置で守れるFloodFillの方が穴を残せて安全だ。抜きたい被写体の色構成を見て選ぶ。
Step 4. マジックワンド:クリックした色だけを狙う
四隅もクロマキーも「背景はこういう色/位置だろう」という前提を置く。前提が崩れる画像もある。背景が四隅に来ていない構図、色相で切れない複雑な背景。そこでユーザーがクリックした点の色を種にするのがマジックワンドだ。seeds はクリック座標のリストで、contiguous が抜き方を切り替える。
def magic_wand_mask(bgr, seeds, tolerance, contiguous):
"""クリック指定したシード点の色に近い領域を背景にする。"""
h, w = bgr.shape[:2]
if not seeds:
return np.full((h, w), 255, np.uint8)
bg_accum = np.zeros((h, w), np.uint8)
tol = (int(tolerance),) * 3
for (x, y) in seeds:
x, y = int(x), int(y)
if not (0 <= x < w and 0 <= y < h):
continue
if contiguous:
ff_mask = np.zeros((h + 2, w + 2), np.uint8)
cv2.floodFill(bgr.copy(), ff_mask, (x, y), 0, tol, tol,
cv2.FLOODFILL_MASK_ONLY | (255 << 8) | 4)
bg_accum = cv2.bitwise_or(bg_accum, ff_mask[1:-1, 1:-1])
else:
seed_color = bgr[y, x].astype(np.int16)
dist = np.abs(bgr.astype(np.int16) - seed_color).max(axis=2)
bg_accum = cv2.bitwise_or(
bg_accum, (dist <= tolerance).astype(np.uint8) * 255)
return cv2.bitwise_not(bg_accum)
先頭の if not seeds: return np.full((h, w), 255, np.uint8) が地味に効く。種が1つも無ければ全面を被写体(255)として返すので、まだ誰もクリックしていない起動直後でも安全に呼べる。座標が画像の外なら continue で無視する。純関数として、どんな入力でもクラッシュさせない作りだ。
contiguous が True なら、四隅FloodFillと同じ FLOODFILL_MASK_ONLY でクリック点から連結する領域だけを塗る。イラストの背景をクリックすれば、そこにつながった背景だけが消え、離れた場所にある同じ色は残る。False なら seed_color とのチャンネル別最大距離を全画素で計算し、dist <= tolerance の画素をまとめて背景にする。連結を無視して画面じゅうの同色を一気に抜く。複数の種を渡せば bitwise_or で結果が足し合わさるので、背景が2色でもShift+クリックで種を足して両方を消せる。
この2モードの差は、テストにそのまま表れている。黄色の孤立領域を足した画像で被写体の黄色を種にクリックすると、連結モードでは種につながった被写体だけが消え、離れた同色の孤立領域は残る。連結を切れば同じ黄色が全域から消える。どちらが正しいかは画像しだいなので、片方に決め打ちせず両方をユーザーの手に残す。tolerance は種の色からどこまでを「同色」とみなすかのつまみで、ロゴのようなベタ塗りなら小さめ、写真の背景なら大きめに寄せる。

Step 5. Color-to-Alpha:境界をなめらかに透過させる
ここまでの3つは背景を2値(背景か被写体か)で切る。写真やアンチエイリアスのかかったイラストでは、境界に半透明のグラデーションが欲しい。指定色にどれだけ近いかで、透明から不透明へなめらかに変える。それが color_to_alpha_mask だ。スポイトで拾った色を渡す使い方を想定している。
def color_to_alpha_mask(bgr, color_bgr, tolerance, soft_width):
"""指定色を Color-to-Alpha でソフト透過するベースマスクを返す。
指定色とのユークリッド色距離 d に対し:
d <= tol -> 0 (完全透明)
d >= tol + soft -> 255 (不透明)
中間 -> 線形ランプ
...
"""
diff = bgr.astype(np.float32) - np.array(color_bgr, np.float32)
dist = np.sqrt((diff * diff).sum(axis=2))
tol = float(tolerance) * 2.0
soft = max(float(soft_width) * 2.0, 1.0)
alpha = np.clip((dist - tol) / soft, 0.0, 1.0) * 255.0
return alpha.astype(np.uint8)
指定色とのユークリッド色距離 distを全画素で求め、tol 以下なら完全透明(0)、tol + soft 以上なら不透明(255)、その間を線形に補間する。tolerance と soft_width は ×2.0 して距離の単位(0〜200相当)にそろえる。UIのスライダーは0〜100で扱いやすく、内部では距離スケールに直す。
見落としやすいのが soft = max(soft_width * 2.0, 1.0) の 最低値1.0だ。soft はこのあと割り算の分母になる。soft_width=0 をそのまま使うとゼロ除算になるので、下限1.0を噛ませる。この下限のおかげで soft_width=0 は「ほぼ2値」として振る舞い、指定色ちょうどの画素は透明、そこから1距離でも離れれば不透明、というくっきりした切り方になる。soft_width を上げるほど境界の半透明帯が広がり、毛先やアンチエイリアスの縁がなじむ。
ここで dist にユークリッド距離(np.sqrt((diff*diff).sum(axis=2)))を使っているのも意図がある。四隅FloodFillやマジックワンドはチャンネル別の最大絶対差で色の近さを測っていた。判定を速く単純にしたい2値の抜きにはそれで足る。Color-to-Alphaは半透明のランプを作るので、3チャンネルをまとめた1本の距離軸が要る。ユークリッド距離なら、指定色を中心にした球状の「近さ」で滑らかにアルファが減っていく。同じ「色の近さ」でも、2値で切るか階調で渡すかで測り方を変えている。
使い方はスポイトと組む。プレビュー上で背景の代表色をクリックすると、その BGR が color_bgr として渡り、色見本ボタンに反映される。白背景でも実際はわずかに色かぶりしているので、その「実際の背景色」をスポイトで拾えば、tolerance を小さく保ったまま境界をきれいに透過できる。単色に近い背景の商品写真やロゴ素材で、この回のエンジンのなかではもっとも境界がなめらかに仕上がる。

soft_width を持たせているので、アンチエイリアスのかかった境界がギザつかずに透過する。Step 6. 純関数だから、GUIなしでテストできる
4エンジンの戻り値が「BGR画像→uint8アルファ」でそろっていることの見返りが、ここで回収できる。tests/test_removal_engines.py は Qt を一切起動しない。numpy で小さな合成画像を作り、関数に渡し、特定座標のアルファ値を assert するだけで挙動を固定する。白背景に赤い矩形、その内部に白い穴を置いた画像で、外側の白が抜けて内側の白い穴が残るかを確かめる例がこれだ。
def _img_white_bg():
"""白背景 + 中央に赤い矩形 + 被写体内部に白い穴。BGR uint8。"""
img = np.full((100, 100, 3), 255, np.uint8)
img[30:70, 30:70] = (0, 0, 200) # 赤被写体
img[45:55, 45:55] = (255, 255, 255) # 被写体内部の白(残すべき)
return img
class TestFloodBackgroundMask:
def test_white_bg_becomes_subject_mask(self):
alpha = flood_background_mask(_img_white_bg(), tolerance=30)
assert alpha[5, 5] < 50 # 外側の白=背景=透明
assert alpha[50, 35] > 200 # 被写体=不透明
def test_inner_white_is_kept(self):
alpha = flood_background_mask(_img_white_bg(), tolerance=30)
assert alpha[50, 50] > 200 # 被写体内部の白は残す
Step2のカラーゲートも、専用のテストで守られている。緑背景から白い被写体へ40pxのグラデーションの橋を架けた合成画像を作り、橋でつながっていても被写体側の白(alpha[50, 80])が残ることを assert する。カラーゲートを外せばこのテストが落ちる。挙動の理由がテストの名前(test_gradient_bridge_does_not_leak_into_subject)に残っているので、後から読んでも「なぜこの一行が要るのか」が分かる。
def test_gradient_bridge_does_not_leak_into_subject(self):
img = np.zeros((100, 100, 3), np.uint8)
img[:, :] = (40, 200, 40) # 緑背景
img[30:70, 60:95] = (255, 255, 255) # 白い被写体
for i, x in enumerate(range(20, 60)): # 橋: 緑→白へ40pxで滑らかに遷移
t = i / 39.0
color = (np.array([40, 200, 40]) * (1 - t) +
np.array([255, 255, 255]) * t).astype(np.uint8)
img[48:52, x] = color
alpha = flood_background_mask(img, tolerance=25)
assert alpha[5, 5] < 50 # 背景は除去
assert alpha[50, 80] > 200 # 橋で繋がっていても白被写体は残る
Color-to-Alphaの soft_width=0 がほぼ2値になることも、距離19の画素は0・距離22の画素は255になる、という1行の assert で固定してある。閾値のわずかな挙動まで、数字で境界を刻んで守れるのが純関数の強みだ。GUIを触らずに、抜き方のロジックだけを回帰テストで囲える。
4エンジンそれぞれに専用のテストクラスが並ぶ。TestFloodBackgroundMask は白背景・任意隅色・グラデーションの橋を、TestChromaKeyMask は緑と青の除去と幅を狭めたときの取り残しを、TestMagicWandMask は連結の有無と種の累積・空の種を、TestColorToAlphaMask は完全透明・完全不透明・中間アルファ・ランプの単調性を確かめる。1つの抜き方につき挙動の分岐ぶんだけ assert があるので、あとで内部を書き換えても「白背景は抜けるが被写体の穴は残す」という契約が崩れれば即座に赤になる。抜き方のアイデアを試すたびにGUIで目視する手間を、テストが肩代わりする。
まとめ:第2回の要点
- 四隅FloodFill:
FLOODFILL_MASK_ONLYで元画像を汚さず背景を塗り、判定済みの隅はスキップ。白背景の商品写真はこれ1本で抜ける。 - カラーゲート:外周2px帯の中央値を背景代表色にし、
max(60, tolerance*4)の距離ゲートでグラデーションの橋を渡らせない。連結の許容差と全体の物差しを分けて持つ。 - クロマキー:HSVで色相を切り、Hueの循環は2区間
inRangeで合流。sat_minが白・グレーを守る。 - マジックワンド:クリック色を種に、
contiguousで「連結だけ」と「全画面の同色」を切り替え。種ゼロなら全面被写体を返す安全設計。 - Color-to-Alpha:ユークリッド色距離の線形ランプで半透明境界を作り、
softの最低1.0でゼロ除算を回避。 - 純関数だからテストできる:BGR入力→uint8アルファ出力にそろえたので、numpyの合成画像とpytestで挙動を数値で固定できる。
次回(第3回)は、この四隅FloodFillが届かない2つの難所に踏み込む。弓と弦の間のように被写体に囲まれた背景色は、外側から塗るFloodFillでは到達できずに残る。逆に、アイコンシートの細い隙間からは背景が内部へ漏れ込み、残すべきアイコンの黒い中身まで抜けてしまう。「消したいのに残る」と「残したいのに消える」という相反する難所を、連結成分と色距離、そして境界連結のopeningで解く回だ。局所統計では分離できない限界も、実測 log をそのまま添えて正直に書く。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。