AIに投げたら、被写体の9割は一発で抜けた。だが残りの1割が言うことを聞かない。髪の先が背景に溶け、耳の穴だけ透けすぎ、腕の脇に背景の緑がひと筋残る。この「あと少し」を手で直したい人に向けた回だ。完全自動の精度をどこまでも追うより、後処理スライダーとブラシ修正を別レイヤーで持たせて、AIの出力に人の手を1割だけ足す。この記事では、5本のスライダーによる後処理、消去/復元ブラシ、20段のUndo、そして3種類の表示切替を、実際のソースコードから組み立てていく。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。AIが9割抜いた後の「残り1割」を人の手に返す設計の回だ。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo(この記事)
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する
第5回のゴール
第4回までで、古典CVとAIが吐く「ベースマスク」(uint8のアルファ、255=被写体/0=背景)が手に入った。第5回はその生マスクを仕上げの3工程に通す。postprocess_mask でしきい値やぼかしを整え、compose_final_alpha でブラシの手直しと元画像の既存アルファを合成し、update_preview で完成画像・マスク表示・背景検出の3ビューに描き分ける。読み終えるころには、AIが抜いた9割の上に、スライダーとブラシで残り1割を乗せる一本道が動いている状態になる。
設計の判断はひとつだ。エンジン(AIや古典CV)の出力を直接いじって完璧にしようとはしない。ベースマスクは触らず読み取り専用のまま置き、後処理とブラシ修正をそれぞれ独立したレイヤーとして上に重ねる。こうすると「AIをかけ直す」「しきい値を動かす」「ブラシで塗る」がお互いを壊さずに共存できる。まずは後処理スライダーから見ていく。
Step 1. 5本のスライダーで生マスクを整える
後処理は mask_pipeline.py の純関数 postprocess_mask(mask, threshold, softness, edge_shift, noise, blur) にまとまっている。UIの5スライダーがそのまま5引数になる。入力も出力もuint8のアルファマスクだけで、画像本体やUIの状態を一切知らない。だから単体でテストできるし、AIマスクにも古典CVマスクにも同じ関数を通せる。
# mask_pipeline.py
def postprocess_mask(mask, threshold, softness, edge_shift, noise, blur):
m = mask.astype(np.float32)
# (1) しきい値/ソフト幅: threshold を中心としたレベル補正
# softness が小さいときは完全2値化(除算式だと境界画素が中間値になるため)
half_width = softness / 100.0 * 127.0
if half_width < 1.0:
m = np.where(m >= threshold, 255.0, 0.0)
else:
m = (m - (threshold - half_width)) / (2.0 * half_width) * 255.0
m = np.clip(m, 0, 255).astype(np.uint8)
最初の threshold はマスクの境界の中心をどこに置くかを決める。AIのsoft maskは境界がグレーのグラデーションで出る。半透明の帯を「ここから内側は被写体」と切る位置がthresholdだ。値を上げると被写体を細らせ、下げると太らせる。
次の softness は境界の柔らかさを受け持つ。ここにhalf_widthという一手間がある。softness / 100.0 * 127.0 で半値幅を出し、これが 1.0 未満のときは np.where で完全な2値化に切り替える。除算式 (m - (threshold - half_width)) / (2.0 * half_width) をそのまま使うと、softnessが0に近いとき分母が極小になって、境界のたった1画素が中間の灰色として残ってしまう。ロゴやアイコンのように輪郭をパキッと出したい素材で、この1画素の滲みが目立つ。half_width < 1.0 の分岐は、その滲みを断ち切って純粋な白黒を保証するための場合分けだ。
# (2) エッジ収縮↔膨張
if edge_shift != 0:
k = abs(int(edge_shift))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * k + 1, 2 * k + 1))
if edge_shift < 0:
m = cv2.erode(m, kernel)
else:
m = cv2.dilate(m, kernel)
# (3) ノイズ除去
if noise > 0:
kernel = np.ones((3, 3), np.uint8)
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel, iterations=int(noise))
# (4) 境界ぼかし
if blur > 0:
b = int(blur)
if b % 2 == 0:
b += 1
m = cv2.GaussianBlur(m, (b, b), 0)
return m
3本目の edge_shift はエッジの収縮と膨張だ。値が負なら cv2.erode でマスクを内側に食い込ませ、正なら cv2.dilate で外へ広げる。楕円カーネルの一辺を 2 * k + 1 で作るのは、モルフォロジー処理のカーネルを必ず奇数サイズにして中心画素を持たせるためだ。AIが背景をひと筋残したときはマイナス側へ、被写体の縁を削りすぎたときはプラス側へ、1〜2px動かすだけで縁のズレが揃う。
4本目の noise は cv2.MORPH_OPEN(収縮のあと膨張)の反復回数を渡す。オープニングは小さな白い孤立点を消す処理で、背景に飛び散った数画素のゴミを、被写体本体を痩せさせずに掃除する。回数をスライダーで直に持たせているので、ゴミが多い素材では2〜3回、繊細な縁を残したい素材では0回、と現物を見ながら決められる。
最後の blur は cv2.GaussianBlur による境界ぼかしだ。ここに if b % 2 == 0: b += 1 がある。GaussianBlurのカーネルサイズは奇数でなければOpenCVが例外を投げる。スライダーは0〜25の連続値なので偶数も来る。偶数が来たら+1して奇数へ丸める、この一行が無いとblurを偶数値にした瞬間にアプリが落ちる。合成時にジャギーを消してエッジを馴染ませる仕上げの一手だ。
5本を通した結果は、あくまで「整えた後処理マスク」でしかない。ここにはまだブラシの手直しも元画像のアルファも入っていない。それらを重ねるのが次のレイヤーだ。
Step 2. ブラシは結果に直接描かず、別レイヤーへ塗る
後処理でも取りきれない箇所は、最後は人が手で塗る。消去ブラシで消し残しを削り、復元ブラシで消えすぎた部分を戻す。ここで肝になる設計判断が、ブラシは最終画像に直接描かないという点だ。塗った内容は erase_layer と restore_layer という2枚の専用レイヤーに溜める。後処理マスクとは別の場所に持つ。

塗りの本体は mask_pipeline.py の paint_brush_stroke だ。ブラシ1点をレイヤーに描き込む純関数で、円の中心から外周に向かう硬さの減衰(hardness falloff)を計算し、既存のレイヤーとmax合成する。
# mask_pipeline.py
def paint_brush_stroke(layer, center_xy, size, hardness):
h, w = layer.shape[:2]
radius = max(int(size) // 2, 1)
x, y = int(center_xy[0]), int(center_xy[1])
x0, x1 = max(x - radius, 0), min(x + radius + 1, w)
y0, y1 = max(y - radius, 0), min(y + radius + 1, h)
if x0 >= x1 or y0 >= y1:
return
yy, xx = np.mgrid[y0:y1, x0:x1]
dist = np.sqrt((yy - y) ** 2 + (xx - x) ** 2) / radius # 0=中心, 1=外周
hard = hardness / 100.0
# hard 部分(中心〜hard*radius)は255、外側は線形減衰
falloff = np.clip((1.0 - dist) / max(1.0 - hard, 1e-3), 0, 1)
stamp = (np.clip(falloff, 0, 1) * 255).astype(np.uint8)
stamp[dist > 1.0] = 0
region = layer[y0:y1, x0:x1]
np.maximum(region, stamp, out=region)
距離マップ dist は中心で0、外周で1になるよう半径で正規化してある。硬さ hard は0〜1で、falloffは (1.0 - dist) / max(1.0 - hard, 1e-3)。hardが1に近ければ分母が極小になり、円のほぼ全域が255になってハードエッジのスタンプになる。hardが0なら中心から外周までなだらかに減衰する柔らかいスタンプだ。分母を max(..., 1e-3) で下限を切っているのはゼロ除算よけで、hardが100でもクラッシュしない。円の外側(dist > 1.0)は0に落として四角い塗り残しを防ぐ。
そして最後の np.maximum(region, stamp, out=region) がmax合成だ。同じ場所を何度もなぞると、より濃い方(大きい値)が残る。ドラッグで筆を往復させても塗り跡が薄まらず、期待どおり「塗るほど濃くなる」感触になる。out=region でスライスへ直接書き戻すので、レイヤーがその場で更新される。
この2枚のレイヤーに溜める設計の御利益が、スライダーとの独立性だ。復元ブラシで髪の毛先を塗り戻したあとで、しきい値スライダーを動かして全体の切り位置を変えたとする。塗った内容は restore_layer に別で残っているので、後処理をやり直しても毛先の修正は消えない。合成のたびに後処理マスクとブラシレイヤーを重ね直すからだ。「AIをかけ直す」「スライダーを動かす」「ブラシで直す」の3つが、どの順番でも壊れ合わない。UI側の _paint_at は、いま選んでいるツールに応じて塗り先のレイヤーを選ぶだけでいい。
# bg_remover_ui_ux.py
def _active_brush_layer(self):
"""現在のブラシツールに対応するレイヤーを返す(なし=None)。"""
tool = self.brush_tool.currentText()
if tool == "消去ブラシ":
return self.erase_layer
if tool == "復元ブラシ":
return self.restore_layer
return None
Step 3. 合成の優先順位:消去 > 復元 > ベース
後処理マスク・消去レイヤー・復元レイヤー・元画像の既存アルファ、この4枚を1枚の最終アルファにまとめるのが compose_final_alpha だ。ここには明確な優先順位がある。復元でベースを持ち上げ、消去でいちばん最後に削る。塗った手直しを確実に反映させる順序だ。
# mask_pipeline.py
def compose_final_alpha(processed, erase_layer, restore_layer, original_alpha):
f = processed.astype(np.int32) # int16 だと erase*f (最大65025) が溢れる
f = np.maximum(f, restore_layer.astype(np.int32))
f = f - erase_layer.astype(np.int32) * f // 255 # 消去は割合で減算
f = np.clip(f, 0, 255).astype(np.uint8)
if original_alpha is not None:
f = cv2.bitwise_and(f, original_alpha)
return f
処理は3段だ。np.maximum(f, restore_layer) で復元ブラシの値を後処理マスクに重ねる。消えていた被写体が復元レイヤーの濃さまで戻る。続く f - erase_layer * f // 255 が消去で、割合で減算するのがポイントだ。消去レイヤーが255の画素は完全に透明になり、128なら現在値の半分だけ削れる。硬さの弱いブラシで縁をなぞると自然に薄れる。消去を復元より後に置くことで、「消したいものは必ず消える」を保証している。
コメントの int32 にする理由 は見逃せない。erase_layer * f は、両方が最大255なら 255 * 255 = 65025 になる。int16の上限は32767なので、この積はオーバーフローして符号が壊れる。もし気づかずint16のまま計算すると、消去ブラシを強く効かせた瞬間にアルファ値が化けて、透明にしたい場所が逆に真っ白になるような不可解なバグが出る。だから積を作る前に astype(np.int32) で器を広げてある。コメントの一文は、その落とし穴を後で読む人に残した実装メモだ。
最後の cv2.bitwise_and(f, original_alpha) は、元画像がすでに透明部分を持っていた場合の処理だ。PNGなどで既存アルファがある素材では、元から透明だった領域を計算結果とANDして必ず透明に保つ。既存の透明を後処理が塗り潰してしまわないための歯止めになる。
💡 再利用できる設計知見:手直しを効かせる合成は、①足す操作(復元)を先に、削る操作(消去)を後に置く ②削りは0/1でなく割合で引く ③積を作る前に器の型を広げてオーバーフローを断つ。この3点があれば、修正レイヤーを何枚重ねても「消したいものは消える/型化けしない」が守られる。ペイント系の合成を書くときにそのまま持ち込める型だ。
Step 4. 20段のUndo:空振りクリックでスタックを汚さない
ブラシで塗る作業には取り消しが要る。復元も消去も、この undo_stack という1本のスタックで一括して戻せる。1手を戻すたびに2枚のブラシレイヤーが直前の状態へ復帰する。仕組みは素朴で、塗る直前に現在の2レイヤーのコピーをタプルで積む。
# bg_remover_ui_ux.py
def _push_undo(self):
self.undo_stack.append(
(self.erase_layer.copy(), self.restore_layer.copy()))
if len(self.undo_stack) > 20:
self.undo_stack.pop(0)
(erase_layer.copy(), restore_layer.copy()) のタプルを積むので、1手戻すと消去と復元がセットで巻き戻る。上限は20段で、超えたら pop(0) で最古の1件を捨てる。フルサイズの画像レイヤー2枚を無制限に溜めるとメモリを食い続けるので、直近20手だけを保持する割り切りだ。実際のブラシ作業で20手より前に戻したい場面はほとんど来ない。
細かいが効いているのが、いつUndoを積むかの判定だ。マウスを押した瞬間には積まない。実際にキャンバスへ描き込む直前まで待って積む。制御しているのが _stroke_undo_pushed フラグだ。
# bg_remover_ui_ux.py
def _paint_at(self, x, y):
layer = self._active_brush_layer()
if layer is None or self.original_bgr is None:
return False
h, w = layer.shape[:2]
if not (0 <= x < w and 0 <= y < h):
return False
if not self._stroke_undo_pushed:
# 空振りクリックでスタックを汚さないよう、最初に実際に描く直前に積む
self._push_undo()
self._stroke_undo_pushed = True
paint_brush_stroke(layer, (x, y),
size=self.brush_size.value(),
hardness=self.brush_hardness.value())
return True
ストロークの開始時に _stroke_undo_pushed をFalseへ戻し、実際に描く1回目だけUndoを積んでTrueにする。ドラッグ中は何十回も _paint_at が呼ばれるが、Undoが積まれるのはストロークにつき1回だけだ。1ドラッグ=1手として戻せる。もし画像の外をクリックしたり、ツールが「なし」のまま押しただけなら、_paint_at は先頭のガードで抜けてUndoを積まない。この空振り対策が無いと、キャンバスの端を無駄クリックするたびにUndo履歴が同じ状態のコピーで埋まり、20段がすぐに使い果たされる。戻す処理は積んだタプルを取り出して差し替えるだけだ。
# bg_remover_ui_ux.py
def undo_brush(self):
if not self.undo_stack:
return
self.erase_layer, self.restore_layer = self.undo_stack.pop()
self.update_preview()
スタックが空なら黙って返し、あれば末尾のタプルを2レイヤーへ展開して update_preview で再描画する。ブラシ側だけを巻き戻すので、スライダーやAIの状態はそのまま残る。手で塗った1手だけを、他を巻き込まずに取り消せる。
Step 5. 表示切替3種と、チェッカー背景のキャッシュ
仕上げの確認には、完成画像だけでなくマスクそのものを見たい場面がある。update_preview は表示コンボの選択に応じて、完成画像・マスク表示・背景検出の3種を描き分ける。どのモードでも最終アルファは同じ compose_final_alpha から取り、見せ方だけを変える。

# bg_remover_ui_ux.py
def update_preview(self):
if self.original_bgr is None or self.base_mask is None:
return
alpha = self.compute_final_alpha()
rgba = cv2.cvtColor(self.original_bgr, cv2.COLOR_BGR2BGRA)
rgba[:, :, 3] = alpha
self.preview_rgba = rgba
display_mode = self.display_combo.currentText()
if display_mode == "マスク表示":
display = cv2.cvtColor(alpha, cv2.COLOR_GRAY2RGB)
elif display_mode == "背景検出":
display = cv2.cvtColor(255 - alpha, cv2.COLOR_GRAY2RGB)
else:
checker = self.create_checkerboard(rgba.shape[1], rgba.shape[0])
rgb = cv2.cvtColor(self.original_bgr, cv2.COLOR_BGR2RGB)
a = alpha.astype(np.float32)[:, :, np.newaxis] / 255.0
display = (rgb * a + checker * (1 - a)).astype(np.uint8)
self.preview_view.set_image(np_to_qimage_rgb(display))
「マスク表示」はアルファをそのままグレースケールとして出す。白が被写体、黒が背景で、境界のグレーの帯を見ればしきい値やぼかしの効き方が一目で分かる。「背景検出」は 255 - alpha で明暗を反転させ、どこが背景と判定されたかを白く見せる。消し残しを探すときに向く。「完成画像」は透明部分にチェッカー柄を敷き、アルファ a で被写体とチェッカーをアルファブレンドする。rgb * a + checker * (1 - a) が透過合成の式で、半透明の縁が背景に自然に溶ける様子まで確認できる。保存用の preview_rgba はどのモードでも同じ最終アルファで作るので、画面の見せ方に関係なく書き出す絵は一定だ。
チェッカー背景には create_checkerboard でキャッシュを効かせてある。透過を確認する市松模様は、画像サイズが同じなら毎回まったく同じものだ。スライダーをドラッグすると update_preview が毎フレーム走るので、そのたびに市松を生成し直すと大きな画像で目に見えて重くなる。
# bg_remover_ui_ux.py
def create_checkerboard(self, w, h):
# 毎フレーム再生成すると大画像でスライダーが重くなるためサイズ別にキャッシュ
if (self._checker_cache is not None and
self._checker_cache.shape[:2] == (h, w)):
return self._checker_cache
size = 20
img = np.zeros((h, w, 3), dtype=np.uint8)
c1, c2 = 60, 90
yy, xx = np.mgrid[0:h, 0:w]
cells = ((xx // size) + (yy // size)) % 2
img[cells == 0] = c1
img[cells == 1] = c2
self._checker_cache = img
return img
キャッシュ済みの市松があり、しかも現在の (h, w) と形が一致するときだけ、それをそのまま返す。画像を読み替えてサイズが変われば形の比較が外れ、新しい市松を1度だけ作り直してキャッシュを更新する。20px角のマスを (xx // size + yy // size) % 2 で市松にし、明度60と90の2色を敷く。地味な最適化だが、スライダーを動かしたときのプレビュー追従がなめらかになる差は大きい。
設計Q&A:ブラシとレイヤーの疑問
Q. ブラシで塗った結果を、最終画像に直接焼き込んでしまえば楽では?
A. 楽になるのは一瞬だけで、後が破綻する。最終画像に直接描くと、あとからしきい値スライダーを動かした瞬間に画像が再合成されて、塗った跡が消し飛ぶ。erase_layer と restore_layer に分けて溜めるのは、後処理とブラシ修正を何度でも重ね直せるようにするためだ。塗り→スライダー→塗り→AIかけ直し、をどの順で行き来しても手直しが生き残るのは、この分離があるからだ。
Q. Undoが20段で足りるのか。無制限にしないのはなぜ?
A. 1手ぶんがフルサイズのアルファレイヤー2枚のコピーで、無制限に積むとメモリを際限なく食う。実際のブラシ作業で20手より前に戻したい場面はまず来ないので、上限20段で pop(0) する割り切りにした。数値そのものより、空振りクリックで履歴を汚さない _stroke_undo_pushed の仕掛けの方が体感に効く。20段を無駄なコピーで浪費しないので、実効的な取り消し回数が保たれる。
まとめ:第5回の要点
- 5スライダー後処理:
postprocess_maskがthreshold中心のレベル補正・half_width<1.0での完全2値化・erode/dilate・MORPH_OPEN・GaussianBlurの奇数化を1関数で担う。純関数なのでAIにも古典CVにも同じく通る。 - ブラシは別レイヤー:
paint_brush_strokeがhardness falloffとmax合成で塗り、消去/復元の2レイヤーに溜める。だからスライダーを後から動かしても修正が保持される。 - 合成の優先順位:
compose_final_alphaは復元で持ち上げ消去で最後に削る。積のオーバーフローを避けるためint32に広げる。既存アルファはANDで守る。 - 20段Undo:
_push_undoのタプルスタックと_stroke_undo_pushedで、1ドラッグ=1手・空振りクリックは履歴を汚さない。 - 表示切替3種:
update_previewが完成画像・マスク表示・背景検出を描き分け、create_checkerboardのサイズ別キャッシュでプレビュー追従を軽く保つ。
これでAIが抜いた9割の上に、後処理とブラシで残り1割を乗せる仕上げの一本道が通った。次回(第6回)はUI/UXを扱う。QGraphicsViewのズームで毛先を拡大し、スポイトとマジックワンドのクリックを連携させ、十数秒かかるAI推論と数十枚の一括処理をQThreadへ逃がして画面を固まらせない設計、そしてドラッグ&ドロップでの一括保存までを、今回作った仕上げレイヤーの上に組み上げる。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。