自動テストを走らせると、72件がすべて緑になった。この72件は「起動したら落ちなかった」の確認ではない。BiRefNet の MAE が 0.0271、IoU が 0.906、グリーンバックの弓の内側が透け、黒背景エンブレムの内部の黒が残る。その一つ一つを数値の合格ラインで縛っている。6回かけて作ってきた背景除去アプリを配布する前に、最後にやることが残っている。「綺麗に抜けた」という目視の合格を、実測座標ボックスと MAE/edgeMAE/IoU の数値基準に翻訳して、あとからコードをリファクタしても品質が落ちない仕組みにする。目で見て「よし」と言った品質を、機械が毎回同じ厳しさで測り直せる形に落とす。それができて初めて、このアプリは「たぶん動く」から「動くと証明できる」に変わる。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。最終回。「見た目でOK」を数値の合格基準に置き換えて、連載を締める。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する(この記事)
第7回のゴール
この記事を読み終えると、プリセットのチューニング値が scripts/tune_presets.py の掃引で根拠つきに決まり、AIモデルの精度表が scripts/eval_ai_models.py で再現でき、その品質を pytest 72件が回帰として守る、という一連の流れが手元で回る。目視レビューはどうしても主観が混じるし、記憶は薄れる。半年後にコードを整理したとき、自分が過去に「これでよし」とした品質を、もう一度目で見比べる作業はやりたくない。だから今回は、目で確認した合否を数値の基準に固定して、リファクタしても劣化を機械が検出できる状態にする。最後は run.bat と venv での配布、AIとプリセットの使い分けで連載を締める。まず、数値で測るために必要な「正解データ」をどう用意したかから始める。
Step 1. 疑似正解:目視レビュー済みの出力を「正解」に固定する
品質を数値で測るには、比べる相手の「正解マスク」が要る。背景除去の正解は、本来なら人間が1枚ずつ丁寧に切り抜いたアルファを用意する。個人開発でそれをサンプル全枚数ぶん作るのは現実的ではない。そこで別の手を取った。第3回で作った古典CVプリセットの出力を、2026-06-12にユーザーが1枚ずつ目視レビューして「この品質で合格」と承認した。その承認済みの出力そのものを、疑似正解(Ground Truth)として固定する。
def load_gt():
"""プリセットパイプラインで疑似正解アルファを生成する。"""
with open(os.path.join(ROOT, "scripts", "tuned_presets.json"),
encoding="utf-8") as fp:
presets = json.load(fp)
gt = {}
for preset, files in SAMPLES.items():
p = presets[preset]
for f in files:
bgr = load(f)
base = build_base(preset, bgr, p["flood_tol"])
alpha = postprocess_mask(base, p["threshold"], p["softness"],
p["edge_shift"], p["noise"], p["blur"])
bg = bg_representative(bgr)
dist = np.abs(bgr.astype(np.int16) -
bg.astype(np.int16)).max(axis=2)
gt[f] = (bgr, alpha, dist)
return gt
load_gt は tuned_presets.json に保存された確定パラメータでプリセットのパイプラインを再実行し、承認済みと同じアルファを毎回作り直す。ここが疑似正解の肝だ。正解の実体は画像ファイルではない。確定パラメータから毎回再生成する。プリセットのコードを直したのに tuned_presets.json を更新し忘れれば、AIの評価が古い正解に対して行われてしまう。正解を「承認したパラメータ」という形で持てば、パイプラインとGTが常に同じソースから生える。返り値には元画像 bgr、正解アルファ、そして背景代表色からの色距離 dist の3つを詰める。dist は次のStepの座標ボックス判定で使う。
疑似正解には正直な限界がある。GT自体が古典CVの出力なので、古典CVが苦手な半透明の毛先などは、GTの側も完璧ではない。それでも「承認した品質を基準線として固定し、そこからの乖離を測る」目的には十分に使える。AIがGTより綺麗に抜ける箇所は、MAEの数値としては誤差に見える。この評価が測るのは、承認済みプリセット品質からの距離だ。AIそのものを絶対的に格付けする試験だとは考えていない。
Step 2. 4つの指標:MAE・edgeMAE・IoU・corner
正解アルファと評価対象アルファを比べて、品質を4つの数値に落とす。metrics がその中身だ。1枚のアルファに対して、性質の違う4つの角度から採点する。
def metrics(alpha, gt_alpha):
a = alpha.astype(np.float32)
g = gt_alpha.astype(np.float32)
mae = float(np.abs(a - g).mean()) / 255.0
gtb = (gt_alpha >= 128).astype(np.uint8)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
band = (cv2.dilate(gtb, k) - cv2.erode(gtb, k)) > 0
edge_mae = (float(np.abs(a - g)[band].mean()) / 255.0
if band.any() else 0.0)
ab = alpha >= 128
gb = gtb > 0
inter = float(np.logical_and(ab, gb).sum())
union = float(np.logical_or(ab, gb).sum())
iou = inter / union if union > 0 else 1.0
corner = int(np.concatenate([alpha[:2].ravel(), alpha[-2:].ravel(),
alpha[:, :2].ravel(),
alpha[:, -2:].ravel()]).max())
return mae, edge_mae, iou, corner
4つを役割で読む。MAE(平均絶対誤差)は、全画素のアルファ差を255で割った0〜1の値で、画像全体としてどれだけGTからズレているかを表す。小さいほど良い。edgeMAEは、GTの境界を MORPH_ELLIPSE の7×7カーネルで膨張と侵食して作った幅±3px相当の帯(band)の中だけでMAEを取る。背景除去の品質は境界の1〜2pxに出る。髪の縁がガタつく、輪郭が太る、その乱れをエッジ帯に絞って測る指標だ。IoUはしきい値128で2値化した前景の重なり率で、被写体の形がどれだけ一致しているかを見る。cornerは外周2px帯のアルファの最大値で、これが小さければ「画像の縁に背景が残っていない」ことになる。BiRefNet の実測が MAE 0.0271・IoU 0.906、これが数字の合格ラインになる。
4つに分けた理由は単純だ。MAEだけ見ていると、境界がガタついていても面積が大きい内部が合っていれば数字は良く見える。IoUだけ見ていると、境界のソフトさの善し悪しが消える。corner だけ見ていると、背景が抜けていても被写体を削りすぎているのが分からない。1つの数字は必ず何かを隠す。角度の違う4つを並べて、初めて「綺麗に抜けた」の中身が数値になる。
Step 3. 実測座標ボックス:目視で見た場所を数値基準に変える
MAE や IoU は画像全体を平均する。平均は、面積の小さい致命的な失敗を薄めてしまう。弓の内側の8×5pxのポケットが残っていても、画像全体のMAEはほとんど動かない。目視レビューで「ここが残っている」「ここは消してはいけない」と指を差した場所は、平均に埋もれさせず、その座標だけをピンポイントで採点したい。それが BOXES だ。目視で確認した座標を、そのまま数値の合格基準に翻訳する。
# 実測座標ボックス(2026-06-12 の診断セッションで目視確認済み)
# rm: 箱内の背景近似色(dist<=45)画素が α<10 になるべき(残存ポケット)
# keepb: 箱内の背景近似色画素が α>200 のまま残るべき(被写体内の明部)
# keeps: 箱内の被写体色(dist>45)画素が α>200 のまま残るべき(危険要素)
BOXES = {
"image-20260607-132714.png": { # エルフ
"rm": [("弓の内側", 770, 430, 860, 510),
("蔓ループ内", 612, 175, 635, 205),
("弓グリップ脇", 683, 512, 694, 548)],
"keepb": [],
"keeps": [("銀髪", 560, 380, 620, 460),
("肌(顔)", 470, 240, 560, 330)],
},
"ChatGPT Image 2026年6月10日 23_32_41.png": { # 彫金調アイコン
"rm": [("アイコン間余白", 380, 380, 430, 430),
("左上コーナー", 20, 20, 80, 80)],
"keepb": [("Rogue内部の黒", 1010, 600, 1070, 680),
("Archer内部の黒", 225, 535, 285, 575)],
"keeps": [],
},
}
3種類のボックスが、目視の判断をそのまま数値化している。rm は「消えるべき」場所で、箱内の背景近似色(色距離45以下)の画素がアルファ10未満まで抜けていなければ不合格。エルフ画像なら弓の内側・蔓ループの内側・グリップ脇の3か所。keepb は「残すべき背景色」で、彫金アイコンのRogueやArcherの内部の黒がアルファ200超で残っていなければ不合格。背景と同じ黒でも、そこは絵柄だから守る。keeps は「残すべき被写体色」で、銀髪や顔の肌がアルファ200超で残っているかを見る。目で「弓の内側の座標はここ」と読んだ数字が、そのまま定数として埋め込まれている。
採点する側が eval_boxes だ。3種のボックスをそれぞれの合格率で判定し、1つでも落ちれば失敗リストに積む。
def eval_boxes(fname, dist, alpha):
"""実測ボックス基準。(全合格, 失敗リスト) を返す。"""
fails = []
spec = BOXES[fname]
for box in spec["rm"]:
tag, x0, y0, x1, y1 = box[:5]
dmax = box[5] if len(box) > 5 else 45
d = dist[y0:y1, x0:x1]
a = alpha[y0:y1, x0:x1]
sel = d <= dmax
rate = float((a[sel] < 10).mean()) if sel.any() else 1.0
if rate < 0.98:
fails.append(f"rm:{tag}={rate:.3f}")
for tag, x0, y0, x1, y1 in spec["keepb"]:
d = dist[y0:y1, x0:x1]
a = alpha[y0:y1, x0:x1]
sel = d <= 45
rate = float((a[sel] > 200).mean()) if sel.any() else 1.0
if rate < 0.85:
fails.append(f"keepb:{tag}={rate:.3f}")
for tag, x0, y0, x1, y1 in spec["keeps"]:
d = dist[y0:y1, x0:x1]
a = alpha[y0:y1, x0:x1]
sel = d > 45
rate = float((a[sel] > 200).mean()) if sel.any() else 1.0
if rate < 0.98:
fails.append(f"keeps:{tag}={rate:.3f}")
return not fails, fails
合格率のしきい値は種類ごとに違う。rm と keeps は0.98、keepb だけ0.85。keepb(被写体内の明部・内部の黒)は境界近くでソフトになり得るので、少しだけ緩めてある。rm のボックスには第6要素で dmax を渡せる仕込みもあり、ビネットで暗くなった背景緑(色距離45〜90に分布)を測る氷結の町の画像では dmax=90 を指定している。目視で見た1つの場所が、色距離の窓・アルファの窓・合格率のしきい値という3つの数値に翻訳されて、機械が毎回まったく同じ厳しさで再判定する。半年後にコードを触っても、弓の内側が残った瞬間にこの関数が失敗を返す。
Step 4. プリセット掃引:総当たりで根拠つきに値を決める
第3回で出てきたプリセットの許容差22やソフト幅30といった値は、勘で置いたものではない。tune_presets.py が、パラメータ空間を総当たりで掃引し、Step 2・3の基準を全部満たす組み合わせだけを候補に残して決めている。掃引する軸はこの3つだ。
# 掃引空間(threshold/softness/noise はFloodの2値性により固定)
SWEEP_TOL = [8, 10, 12, 15, 18, 20, 22, 25, 30, 35, 40, 50]
SWEEP_EDGE = [0, -1, -2]
SWEEP_BLUR = [1, 3, 5]
FIXED = {"threshold": 128, "softness": 30, "noise": 1}
FloodFillの出力はほぼ2値なので、しきい値・ソフト幅・ノイズ除去は動かしても意味が薄い。効くのは背景色の許容差 tol・エッジの収縮量・境界ぼかしの3軸だから、そこだけを12×3×3の108通りで回す。各組み合わせについて、プリセットごとのパイプラインでベースマスクを作るのが build_base だ。
def build_base(preset, bgr, tol):
"""プリセットのパイプラインでベースマスクを生成する。"""
base = flood_background_mask(bgr, tolerance=tol)
pipe = PRESET_PIPE[preset]
if pipe["icon_keep"]:
base = block_narrow_corridors(base, radius=3)
if pipe["pocket_removal"]:
base = remove_background_pockets(bgr, base, near=pipe["pocket_near"],
core=pipe["pocket_core"])
return base
掃引の本体は main の中の二重ループだ。itertools.product で3軸を展開し、プリセットのサンプル画像すべてに対して、外周が抜けているか(bg_ok)・被写体内部が保たれているか(k_in)・実測ボックスが全合格か(boxes_ok)を確かめる。1枚でも落ちれば、その組み合わせは不合格になる。
for tol, es, blur in itertools.product(SWEEP_TOL, SWEEP_EDGE, SWEEP_BLUR):
ok_all, min_in, fail_all = True, 1.0, []
for f, bgr, dist, interior in imgs:
base = build_base(preset, bgr, tol)
alpha = postprocess_mask(base, FIXED["threshold"],
FIXED["softness"], es,
FIXED["noise"], blur)
bg_ok, k_in = evaluate(alpha, interior)
boxes_ok, fails = eval_boxes(f, dist, alpha)
ok_all &= bg_ok and boxes_ok
min_in = min(min_in, k_in)
fail_all += fails
passed = ok_all and min_in >= 0.999
print(f"{preset} tol={tol:3d} edge={es:+d} blur={blur} "
f"interior={min_in:.5f} "
f"{'PASS' if passed else 'NG ' + ';'.join(fail_all[:3])}")
if passed:
candidates.append(((min_in, -abs(es), -blur, -tol),
dict(flood_tol=tol, edge_shift=es,
blur=blur, **FIXED)))
合格した候補は、被写体内部の保持率が高く、エッジ収縮とぼかしが弱く、許容差が小さい方を優先して並べ替え、先頭を採用する。合格候補が1つも無ければ sys.exit(1) で止まって「掃引範囲か基準の見直しが必要」と告げる。通らないなら止まる。基準を甘くして無理に通す道は用意していない。採用値は tuned_presets.json に書き出され、これが Step 1 の疑似正解の素になる。
掃引が終わると、人間が目で最終確認するための画像も吐く。各サンプルの背景をマゼンタ(255,0,255)で塗りつぶした合成画像だ。透過した箇所だけが毒々しいピンクになるので、抜き残しや抜きすぎが一目で分かる。
# 目視確認用マゼンタ合成
for f, bgr, _, _ in imgs:
base = build_base(preset, bgr, params["flood_tol"])
alpha = postprocess_mask(base, params["threshold"],
params["softness"], params["edge_shift"],
params["noise"], params["blur"])
a = alpha.astype(np.float32)[:, :, np.newaxis] / 255.0
magenta = np.zeros_like(bgr)
magenta[:, :] = (255, 0, 255)
comp = (bgr * a + magenta * (1 - a)).astype(np.uint8)
out_name = f"{preset}_{os.path.splitext(f)[0]}_check.png"
cv2.imencode(".png", comp)[1].tofile(os.path.join(OUT, out_name))
このマゼンタ合成が効くのは、白背景に透明を重ねても見えないからだ。抜けた背景を白で表示すると、元が白背景の商品写真では抜けたのか抜けてないのか区別がつかない。マゼンタなら、被写体の内側にピンクが漏れていれば抜きすぎ、背景にピンクでない部分が残っていれば抜き残し。数値が全部PASSでも、最後にこの画像で目を通す。数値の合格と、目で見た合格を、両方そろえてから採用する。


Step 5. AIの既定値も、同じ掃引で決めた
AIモードの後処理の既定値も勘ではない。eval_ai_models.py の掃引で実測決定した。BiRefNet の soft mask を、どのしきい値・ソフト幅・ノイズ・ぼかしで仕上げるのが疑似正解に最も近いか。それを総当たりで探した結果が AI_DEFAULTS だ。
# UIのAIモード既定値(bg_remover_ui_ux.py MODE_TUNE_DEFAULTS[MODE_AI]。
# 本スクリプトの sweep/finals で 2026-06-12 に実測決定した値)
AI_DEFAULTS = {"threshold": 170, "softness": 0, "edge_shift": 0,
"noise": 1, "blur": 1}
この値にたどり着いた経緯が cmd_sweep に残っている。プリセット掃引と考え方は同じで、AIの推論結果を1回キャッシュしてから、後処理パラメータの空間を総当たりで回す。並べ替えのキーが違う。実測ボックスの合格数、corner が10未満のサンプル数、MAEの平均。この優先順で上位を出す。
def cmd_sweep(model_name):
...
space = dict(threshold=[100, 115, 128, 140, 155, 170, 185],
softness=[0, 5, 15, 30, 50],
edge_shift=[0, -1],
noise=[0, 1],
blur=[0, 1, 3])
results = []
for th, so, es, no, bl in itertools.product(*space.values()):
maes, boxes_pass, corners_ok = [], 0, 0
for f, (bgr, gt_alpha, dist) in gt.items():
alpha = postprocess_mask(softs[f], th, so, es, no, bl)
mae, _, _, corner = metrics(alpha, gt_alpha)
maes.append(mae)
boxes_pass += eval_boxes(f, dist, alpha)[0]
corners_ok += corner < 10
results.append((boxes_pass, corners_ok, -float(np.mean(maes)),
dict(threshold=th, softness=so, edge_shift=es,
noise=no, blur=bl)))
results.sort(key=lambda r: (r[0], r[1], r[2]), reverse=True)
掃引で上位に残った近い候補を、もう一段だけ人間が見比べたのが cmd_finals だ。UI既定の128/ソフト30系、しきい値185まで上げてエッジを1px縮める hard185、しきい値170でソフト0の mid170s0 など5案を並べ、サンプルごとのMAE・edgeMAE・corner・ボックス合否を全部プリントして、チェック画像も吐く。
candidates = [
("UI既定", dict(threshold=128, softness=30, edge_shift=0,
noise=1, blur=3)),
("hard185", dict(threshold=185, softness=0, edge_shift=-1,
noise=0, blur=1)),
("mid170s0", dict(threshold=170, softness=0, edge_shift=0,
noise=1, blur=1)),
("mid170s15", dict(threshold=170, softness=15, edge_shift=0,
noise=1, blur=1)),
("mid160s15", dict(threshold=160, softness=15, edge_shift=0,
noise=1, blur=1)),
]
この5案の中から mid170s0、つまりしきい値170・ソフト幅0・ノイズ1・ぼかし1が選ばれ、そのまま AI_DEFAULTS になった。しきい値を高めの170にしてソフト幅を0に振っているのは、BiRefNet の soft mask が境界で綺麗にグラデーションするので、後処理で追加のソフトを乗せると逆に境界が甘くなるからだ。ぼかし1・ノイズ1で微小な穴とジャギだけ均す。AIモードに切り替えると、この170/0/0/1/1が自動で入る。UIのスライダーに出ている既定値の1つ1つに、この掃引の裏付けがある。
Step 6. pytest回帰:72件がリファクタの安全網になる
ここまでで決めた品質基準を、常時走らせる回帰テストに凝固させる。.venv\Scripts\python.exe -m pytest tests/ -q を実行すると、72件がすべて緑(72 passed)になる。テストは役割ごとに4ファイルに分かれている。
test_removal_engines.py:古典CVエンジンの単体。白背景に赤い矩形と内部の白い穴を置いた合成画像で、flood_background_maskが外周は抜き内部の穴は残すこと、クロマキー・マジックワンド・Color-to-Alphaが仕様どおり動くことを確かめる。第3回のremove_background_pocketsとblock_narrow_corridorsの回帰もここ。test_mask_pipeline.py:後処理の純関数。中央が被写体・外周が背景でなだらかな境界を持つ soft mask を作り、postprocess_maskのしきい値・ソフト幅・収縮膨張・ぼかしが期待どおりアルファを変えること、ブラシ合成の優先順位を検証する。test_ai_engine.py:ONNX推論。models/に実モデルがある環境では BiRefNet/ISNet/U2Net のAiSessionを実際に動かし、無い環境ではそのテストをスキップする。モデル名からの前処理設定の紐付け(_config_for)は、ファイルの有無に関わらず確かめる。test_presets.py:プリセットの回帰。sample_Imageの6枚に対し、チューニング時と同じ合格基準(外周透過・被写体内部保持・実測座標ボックス)を数値で再判定する。ここが緑なら、コードを整理しても弓の内側は消え、内部の黒は残ると保証される。
合計が72件になるのは、いくつかのテストがパラメータ化されて複数ケースに展開されるからだ。test_presets.py と test_ai_engine.py は実サンプル画像やモデルに依存するので、それらが無い環境では対象がスキップされ得る。今回の実測環境では依存がすべて揃い、72件すべてが passed になった。この72件が、Step 1〜5で目視と掃引を重ねて決めた品質を、機械の言葉に翻訳した最終形だ。リファクタで np.minimum.at の集計を書き換えても、AIの前処理を触っても、品質が1px落ちればどれかのテストが赤くなる。目で見た合格を、二度と目で見直さずに守れる。
Step 7. 配布とまとめ:run.bat で置くだけ、AIとプリセットを使い分ける
配布は拍子抜けするほど単純だ。このアプリはサーバーもクラウドも使わない完全オフラインなので、venv を作って run.bat を叩けば動く。
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
run.bat (= .venv\Scripts\python.exe bg_remover_ui_ux.py)
依存はPyQt6・opencv-python・numpy・onnxruntime-directml・pytestだけ。1つだけ手作業が残る。AIモデルの .onnx は容量が大きく(BiRefNetで約930MB)、アプリは自動ダウンロードをしない。models/ フォルダに手で置く。ここに罠がある。配布元のリンク切れやネットワークエラーで、数KBのエラーHTMLが .onnx という名前で保存されることがある。読み込むと意味不明な失敗をするので、ダウンロード後はファイルサイズが相応(BiRefNetなら約930MB)にあるかを必ず確認する。開発者向けのコマンドはREADMEにまとまっている。
.venv\Scripts\python.exe -m pytest tests/ -v # 全テスト(72件)
.venv\Scripts\python.exe scripts/tune_presets.py # プリセット再チューニング
.venv\Scripts\python.exe scripts/eval_ai_models.py # AIモデル精度比較
ライセンスについても書いておく。この配布方式で筆者から再配布しているものは無い。依存ライブラリは読者が各自 pip で公式から取得し、AIモデルも各自が rembg のリリース資産から入手する。モデルのライセンスは筆者が配布元リポジトリの一次情報で確認した。BiRefNet は MIT、U2Net と ISNet(DIS)は Apache-2.0 で、いずれも著作権表示の維持を条件に商用利用と再配布が認められている。1点だけ注意がある。GUI の PyQt6 は GPLv3 で提供されており、この連載のように自分の環境で動かす分には制約は無いが、作ったアプリを GPL 非互換のライセンスで配布・販売する場合は Riverbank の商用ライセンスが必要になる。読者が自作版を配る段になったら、ここだけ思い出してほしい。
eval_ai_models.py を引数なしで走らせると、疑似正解に対する3モデルの精度が表になる。実測値はこうだ。
モデル MAE IoU 速度(RTX 2070 SUPER/DirectML)
birefnet-general(既定) 0.0271 0.906 14.7s/枚
isnet-general-use 0.0374 0.869 0.14s/枚
u2net 0.1416 0.695 0.05s/枚
この表が、アプリのモデル選択の根拠になっている。精度が要るなら BiRefNet、速度が要るなら ISNet。最後に、AIとプリセットの使い分けを3行で締める。
- 人物・キャラ・オブジェクトなど普通の被写体は AI自動(BiRefNet)。囲まれたポケットも緑背景上の緑文字も、意味で正しく処理する。
- エンブレム内部の黒など「隙間から見える背景色」を残したいアイコンシートは 黒背景プリセット。AIは背景と判断して透かすので、形状維持のプリセットで守る。
- 単色背景で大量一括処理(速度重視)は プリセット。1枚あたり1秒未満で捌ける。
まとめ:第7回の要点と、連載7回の総括
- 疑似正解を固定:目視レビュー済みプリセット出力を
load_gtがパラメータから再生成し、GTとパイプラインを同じソースから生やす。 - 4指標で多角採点:
metricsのMAE・edgeMAE(境界±3px帯)・IoU・corner(外周2px帯最大アルファ)。1つの数字が隠すものを、4つで補う。 - 目視を座標に翻訳:
BOXESとeval_boxesで、指を差した場所を色距離・アルファ・合格率の窓に落とし、平均に埋もれる致命傷を捕まえる。 - 掃引で根拠つきに決定:
tune_presets.pyの総当たりで許容差22等が決まり、eval_ai_models.pyのcmd_sweep/cmd_finalsでAI既定値170/0/0/1/1が決まった。マゼンタ合成で最後は目でも見る。 - 72件で凝固:4テストファイルの pytest 72 passed が、目視と掃引の結論をリファクタから守る安全網になる。
この連載は、抜き方をUIから切り離して「uint8アルファ」という共通通貨に統一する土台から始まり、古典CVの4エンジン、囲まれた背景とアイコンの穴という相反する2難所、ONNXローカルAI、後処理とブラシ、非同期と一括処理を経て、最後にその全部の品質を数値で縛る回にたどり着いた。7回を貫いて効いたのは、第1回で引いた層の分離だった。エンジンを純関数に切り出したから、掃引ハーネスもテストもUIを起動せずにマスクだけを検証でき、目視の合格を72件の回帰に凝固できた。AIと一緒に速く作るほど、「綺麗に見える」と「綺麗だと測れる」の線引きが、品質を守る最後の壁になる。ここまで読んでくれてありがとう。自分の手で背景除去アプリを1から作る、その最初の一歩の後押しになれば嬉しい。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。