エンジンがどれだけ賢くても、ボタンを押した瞬間に画面が固まって砂時計になるアプリを、筆者は二度と開かない。ずっとそう思ってきた。第5回までで抜き方はぜんぶそろった。AI推論、古典CVの4エンジン、5スライダーの後処理、消去/復元ブラシ。道具箱は満杯だ。最終回のひとつ手前になるこの第6回は、その中身を「触って気持ちいい道具」に仕立てる回になる。十数秒かかるBiRefNetの推論、数十枚をまとめて焼くD&D一括処理、毛先の透過境界を確かめるホイールズーム、クリック1発のスポイト。狙いはただ一つ、これらを走らせているあいだもUIを一度たりとも固めないこと。仕掛けは2枚。重い仕事をQThreadへ逃がし、古くなった結果を世代番号で捨てる。この2枚で、アプリは最後の使い勝手を手に入れる。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。エンジンが揃っても、UIが固まるアプリは使われない。最後の使い勝手を作る回だ。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理(この記事)
- ⑦ 完結 — 品質を数値で担保して配布する
第6回のゴール
この記事を読み終えると、重い処理をUIスレッドから切り離す基本形が身につく。ズームできるビュー、クリックの意味がモードで切り替わるスポイト/ワンド、進行中でも固まらないAI推論、ドラッグ描画のなめらかさ、そしてフォルダから画像をまとめて放り込む一括処理。ここまで作ってきた5層のエンジンが、はじめて「アプリ」として一体に動く。
まずは完成した操作感を1つ見てほしい。抜けの良し悪しは、毛先の1px単位で決まる。

Step 1. ImageView:ズームとマウス翻訳を1クラスに閉じ込める
左に元画像、右にプレビュー。この2枚のビューは、同じImageViewクラスの実体だ。QGraphicsViewを継承し、ホイールズーム・マウス座標の通知・ブラシの円ガイドという3つの役目を1クラスに閉じ込めてある。左右で違うのは、渡すinteractiveフラグだけになる。
class ImageView(QGraphicsView):
"""ズーム可能ビュー。interactive=True ならマウス操作を画像座標で通知する。"""
mouse_pressed = pyqtSignal(float, float, object) # x, y, modifiers
mouse_moved = pyqtSignal(float, float)
mouse_released = pyqtSignal()
mouse_hovered = pyqtSignal(float, float)
mouse_left = pyqtSignal()
def wheelEvent(self, event: QWheelEvent):
factor = 1.15 if event.angleDelta().y() > 0 else 0.85
self.scale(factor, factor)
def _scene_xy(self, event):
p = self.mapToScene(event.position().toPoint())
return p.x(), p.y()
ズームは1行で足りる。ホイールを前に回せば1.15倍、後ろなら0.85倍でscale()を掛けるだけだ。何倍ズームしていようと、拡大率はビュー側の状態として持たれる。
肝心なのは_scene_xyのほうになる。マウスが返すのはウィンドウ上のピクセル位置で、その値はズーム倍率やスクロール位置で毎回ずれる。mapToScene()を通せば、この生の座標が画像そのものの座標へ変換される。3倍にズームした状態で毛先の1pxをクリックしても、ビューは正しい画像座標に翻訳してから外へ知らせる。
def mousePressEvent(self, event):
if self.interactive and event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
x, y = self._scene_xy(event)
self.mouse_pressed.emit(x, y, event.modifiers())
return
super().mousePressEvent(event)
ビュー自身は、クリックが「スポイト」なのか「ワンドのシード」なのか「ブラシの一筆」なのかを知らない。画像座標と修飾キー(Shiftなど)をmouse_pressedシグナルに載せて放り投げるだけだ。意味づけは受け取る側の仕事になる。この割り切りが、左右で同じクラスを使い回せる理由になっている。
もう1つ、ブラシの円ガイドもこのクラスが描く。ズームしても線の太さが変わらないよう、ペンにはcosmetic指定を使う。
def update_brush_cursor(self, x, y, radius, visible):
"""ブラシの円ガイドをカーソル位置に表示/非表示する。"""
if not visible or radius <= 0:
if self._cursor_item is not None:
self.scene().removeItem(self._cursor_item)
self._cursor_item = None
return
if self._cursor_item is None:
pen = QPen(QColor(78, 161, 255, 220))
pen.setCosmetic(True) # ズーム倍率に依らず線幅を1.5pxに保つ
pen.setWidthF(1.5)
self._cursor_item = self.scene().addEllipse(0, 0, 1, 1, pen)
self._cursor_item.setZValue(10)
self._cursor_item.setRect(x - radius, y - radius,
2 * radius, 2 * radius)
cosmetic penは、シーンをどれだけ拡大しても線幅を画面上の1.5pxに固定する。円の半径(=ブラシの太さ)は画像座標のままズームと一緒に伸縮し、輪郭線だけは細いまま。5倍に拡大して毛先を塗るときも、青い円が太い帯にならず、塗り込む範囲を正確に見せてくれる。
Step 2. 1クリックの意味を、モードとトグルで切り替える
元画像ビューのmouse_pressedは、すべてon_original_clickedという1つのハンドラに集まる。ここで、いま何モードか・どのスポイトがトグルONかによって、まったく同じ1クリックの意味が枝分かれする。
def on_original_clicked(self, x, y, modifiers):
if self.original_bgr is None:
return
h, w = self.original_bgr.shape[:2]
xi, yi = int(x), int(y)
if not (0 <= xi < w and 0 <= yi < h):
return
# スポイト(クロマキーモードでトグルON時)
if self.current_mode() == MODE_CHROMA and self.btn_eyedrop.isChecked():
pixel = self.original_bgr[yi:yi + 1, xi:xi + 1]
hue = int(cv2.cvtColor(pixel, cv2.COLOR_BGR2HSV)[0, 0, 0])
self.btn_eyedrop.setChecked(False)
self.hue_center.setValue(hue) # valueChanged 経由で再生成される
self.statusBar().showMessage(f"スポイト: 色相 {hue} を設定")
return
クロマキーのスポイトをONにして緑の一点をクリックすると、そのピクセルのHue(色相)を吸い取ってスライダーへ流し込む。値を入れた瞬間にスライダーのvalueChangedが発火し、ベースマスクが自動で作り直される。スポイトは1回吸えば役目を終えるので、その場でトグルをFalseに戻す。同じ構造で、指定カラー透過モードなら3チャンネルのBGRを吸って透過色に設定する。
マジックワンドは、クリックの積み重ね方が違う。
# マジックワンドのシード点
if self.current_mode() == MODE_WAND:
if modifiers is not None and \
modifiers & Qt.KeyboardModifier.ShiftModifier:
self.wand_seeds.append((xi, yi))
else:
self.wand_seeds = [(xi, yi)]
self.wand_info.setText(
f"シード点: {len(self.wand_seeds)} 個"
"(元画像をクリック / Shift+クリックで追加)")
self.rebuild_base_mask()
ふつうのクリックはシードを1点に置き換える。Shiftを押しながらのクリックは、appendで点を足していく。背景が2色に分かれている素材なら、明るい側を1点、暗い側をShift+クリックで1点、と種を撒くように増やせる。Step 1でビューが修飾キーを一緒に運んでくれたおかげで、この分岐がハンドラ側にきれいに収まった。座標の翻訳はビュー、意味づけはハンドラ、という役割分担がそのまま形になっている。
Step 3. AI推論を別スレッドへ逃がし、古い結果は世代番号で捨てる
BiRefNetの推論は、RTX 2070 SUPERでも1枚あたり14.7秒かかる(第4回の実測値)。これをUIスレッドで走らせたら、その14秒間ウィンドウは完全に無反応になる。逃がし先がAiInferenceThreadだ。中身は驚くほど小さい。
class AiInferenceThread(QThread):
"""非同期でAI推論を1回実行し、soft mask を signal で返す。"""
finished_mask = pyqtSignal(np.ndarray) # 成功: soft mask (uint8)
failed = pyqtSignal(str) # 失敗: エラーメッセージ
def run(self):
try:
mask = self.session.predict(self.bgr)
self.finished_mask.emit(mask)
except Exception as e:
self.failed.emit(str(e))
別スレッドでpredictを1回回し、結果のマスクをシグナルで投げ返すだけ。推論中もUIスレッドは自由なので、ユーザーはスライダーを触ろうと別の画像を読もうと待たされない。
ここに落とし穴がある。14秒待つ間にユーザーが別の画像を読み込んだら、いま返ってきた結果はもう古い画像に対する答えだ。それを黙ってプレビューへ流し込むと、表示中の画像と抜きマスクがズレる。防ぎ方が世代番号(ai_generation)になる。
def start_ai_inference(self):
models = ai_engine.list_models()
if not models or self.ai_busy:
return
# ... AiSession を用意 ...
self.ai_busy = True
self.ai_status.setText(f"AI推論中... [{self.ai_session.provider}]")
self.ai_thread = ai_engine.AiInferenceThread(
self.ai_session, self.original_bgr.copy(), parent=self)
self.ai_thread.generation = self.ai_generation # 発射時の世代を刻印
self.ai_thread.finished_mask.connect(self.on_ai_finished)
self.ai_thread.failed.connect(self.on_ai_failed)
self.ai_thread.start()
スレッドを起動するとき、いまのai_generationをそのスレッドに刻印する。画像を読み込むたび、AIモデルを切り替えるたびに、本体側のai_generationは+1される。結果が返ってきたら、刻印と現在の世代を突き合わせる。
def _ai_result_is_stale(self):
"""送信元スレッドの世代が現在と違えば True(破棄すべき結果)。"""
sender = self.sender()
return (sender is None or
getattr(sender, "generation", None) != self.ai_generation)
def on_ai_finished(self, mask):
if self._ai_result_is_stale():
return # 古い画像/モデルに対する結果。ai_busy は無効化時にリセット済み
self.ai_busy = False
self.ai_mask = mask
provider = self.ai_session.provider if self.ai_session else "?"
self.ai_status.setText(f"AI推論 完了 [{provider}]")
if self.current_mode() == MODE_AI:
self.base_mask = mask
self.update_preview()
刻印が現在の世代と食い違えば、その結果は捨てる。QThreadを途中で強制停止する必要はない。走らせておいて、返ってきた答えの賞味期限だけを世代番号で判定する。この単純さが効く。モデルをbirefnetからu2netへ切り替えた瞬間、進行中だったbirefnetの結果は_ai_result_is_staleで自動的に無効化され、画面には最新のモデルの答えだけが乗る。
スレッドを途中で殺す設計は、一見すると素直に見えて厄介だ。推論のどの段階で止めるか、途中のGPUメモリをどう解放するか、止めた瞬間にクラッシュしないか。考えることが一気に増える。世代番号は、この重い問いを丸ごと回避する。走り切らせて結果だけを黙って捨てる方式なら、スレッド側は自分がもう用済みだと知る必要すらない。判定は受け手側の1行で済む。単調な整数を1つ増やすだけで、非同期の面倒がほとんど消える。この軽さこそが、実装を最後まで保守可能に保つ。
Step 4. ドラッグ描画は「描き込み毎回・再表示だけ間引き」で軽くする
プレビュー側のブラシ描画には別の重さがある。マウスをドラッグするとmouse_movedが秒間数十回飛んでくる。1イベントごとに全画面のアルファ合成とチェッカーボード描画をやり直したら、大きな画像ではドラッグがカクつく。on_preview_draggedは、描き込みと再表示を分けて扱う。
def on_preview_dragged(self, x, y):
# 描き込みは毎回行い、重い再表示だけ約25fpsに制限(最終結果はreleaseで反映)
if not self._paint_at(x, y):
return
now = time.monotonic()
if now - self._last_drag_repaint >= 0.04:
self._last_drag_repaint = now
self.update_preview()
ブラシレイヤーへの塗り込み(_paint_at)はイベントごとに毎回やる。ここを間引くと線が途切れるからだ。重いのは塗った結果を画面に反映するupdate_previewのほうなので、こちらだけtime.monotonic()で前回から0.04秒(約25fps)経ったときにしか呼ばない。指を離した瞬間のon_preview_releasedで最終状態を1回描き直すので、間引きで見えなかった最後のひと筆もちゃんと反映される。塗りの精度は落とさず、描画コストだけを25fpsに抑える。この線引きが、大画像でもドラッグをなめらかに保つ。
時刻の基準にtime.monotonic()を選んでいるのは、システム時計の補正でマイナスの経過時間が出て間引きが壊れるのを避けるためだ。単調増加が保証された時計は、こういう間隔計測にちょうど向いている。壁時計のtime.time()だと、時刻同期のたびに値が飛ぶ危うさがつきまとう。
Step 5. D&D一括処理:1枚なら開く、複数なら焼く
ここまでの操作は画像1枚を丁寧に仕上げる話だった。実務では「同じ設定で30枚まとめて抜きたい」がふつうに来る。ウィンドウへのドラッグ&ドロップが、その入口になる。
def dropEvent(self, event):
paths = [u.toLocalFile() for u in event.mimeData().urls()
if u.isLocalFile()]
exts = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
paths = [p for p in paths if p.lower().endswith(exts)]
if not paths:
return
if len(paths) == 1:
self.load_image(paths[0])
return
self.start_batch(paths)
落とされたファイルが1枚なら、いつものload_imageで開いて手作業に入る。2枚以上なら「一括処理してくれ」という意思表示とみなし、確認ダイアログを挟んでBatchThreadを起動する。枚数で分岐するこの1行が、同じD&Dに2つの役割を持たせている。
一括処理の本体も、当然UIスレッドから逃がす。BatchThread.runは現在の設定で各ファイルを順に焼き、元ファイルの隣に{元名}_nobg.pngを書き出す。
def run(self):
ok, ng = 0, 0
for path in self.paths:
try:
data = np.fromfile(path, dtype=np.uint8)
img = cv2.imdecode(data, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError("読み込み失敗")
# ... BGR/アルファ分離 ...
mode = self.s["mode"]
if mode == MODE_AI:
base = self.ai_session.predict(bgr)
elif mode == MODE_FLOOD:
base = flood_base_mask(bgr, self.s)
# ... chroma / color ...
else: # MODE_WAND は画像ごとのクリックが前提なので四隅をシードに
h, w = bgr.shape[:2]
corners = [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]
base = magic_wand_mask(bgr, corners,
self.s["wand_tol"], True)
processed = postprocess_mask(base, ...)
zeros = np.zeros(processed.shape, np.uint8)
alpha = compose_final_alpha(processed, zeros, zeros, orig_alpha)
# ... _nobg.png として保存 ...
self.progress.emit(f"保存: {os.path.basename(save_path)}")
except Exception as e:
ng += 1
self.progress.emit(f"[失敗] {os.path.basename(path)}: {e}")
self.finished_all.emit(ok, ng)
このrunには、実装で下した判断が3つ埋まっている。
1つ目、読み込みにnp.fromfile+cv2.imdecodeを使う。OpenCVのcv2.imreadは日本語やマルチバイトを含むパスで無言の読み込み失敗を起こす。ファイルをバイト列としてnp.fromfileで読み、メモリ上でimdecodeする経路なら、「C:\ユーザー\写真\商品.png」のような日本語パスでも確実に開ける。単体読み込みのload_imageもまったく同じ経路を使っている。
2つ目、一括処理ではブラシ編集を適用しない。消去/復元ブラシは画像1枚ごとに人が手で描くレイヤーなので、30枚に同じストロークを当てても意味をなさない。だから合成に渡すブラシレイヤーはzeros(空)で固定し、ベースマスク生成+スライダー後処理+既存アルファANDだけを回す。自動でやれる部分だけを一括に載せる、という割り切りだ。
3つ目、ワンドモードのフォールバック。マジックワンドは本来クリックでシードを置く操作なので、一括では置きようがない。ここでは画像の四隅を種にしてmagic_wand_maskを回す。四隅は背景である確率が高いという、古典CVエンジンと同じ前提を借りた妥協案になる。
各ファイルの成否はprogressシグナルでステータスバーへ流れ、1枚焼けるたびに「保存: item0_nobg.png」と表示が更新される。失敗した1枚があってもtry/exceptで握って次へ進むので、30枚のうち1枚が壊れていても残り29枚はちゃんと焼き上がる。

Step 6. closeEvent:走っているスレッドを待ってから閉じる
スレッドを使うアプリには、最後の1手が要る。一括処理やAI推論が走っている最中にウィンドウの×を押されたらどうなるか。QThreadのPythonオブジェクトが、まだ動いているネイティブスレッドを道連れに破棄され、アプリが落ちる。防ぐには、閉じる前に走っているスレッドの完了を待つ。
def closeEvent(self, event):
# 実行中にウィンドウを閉じると QThread 破棄でクラッシュするため待機する
for t in (self.ai_thread, self.batch_thread):
if t is not None and t.isRunning():
t.wait()
super().closeEvent(event)
ai_threadとbatch_threadを順に見て、走っていればwait()で終わるまで待ってから閉じる。30枚の一括処理の途中で閉じても、その1枚が焼き終わってからウィンドウが消える。派手さはないが、この5行があるかないかで「たまに落ちるアプリ」と「落ちないアプリ」が分かれる。非同期を導入した以上、後始末までがワンセットになる。
まとめ:第6回の要点
- ImageViewは翻訳機:
QGraphicsViewを継承し、ホイールで1.15倍ズーム、マウス座標をmapToSceneで画像座標へ翻訳してシグナルで放つ。左右ビューは同じ1クラスの使い回し。 - 1クリックの意味はハンドラが決める:
on_original_clickedがモードとトグルで分岐し、Shift+クリックでワンドのシードを足す。 - 重い推論はQThread+世代番号:
AiInferenceThreadで逃がし、ai_generationの刻印と_ai_result_is_staleで、画像/モデル切替時の古い結果を捨てる。 - ドラッグは描き込み毎回・再表示25fps:
time.monotonic()でupdate_previewだけ間引き、離した瞬間に最終描画。 - D&Dは枚数で分岐:1枚は開く、複数は
BatchThreadで焼く。np.fromfile+imdecodeで日本語パス対応、ブラシ非適用、ワンドは四隅シードにフォールバック。 - closeEventで待つ:走っているスレッドを
wait()してから閉じ、QThread破棄クラッシュを避ける。
これで、5層のエンジンは「触って気持ちいい道具」に仕上がった。ズームで毛先を確かめ、スポイトで色を吸い、AIを待たずにスライダーを動かし、フォルダごと放り込んで焼ける。アプリとしては、ここで完成形になる。
残る第7回は完結編。「綺麗に抜けた」という目視の合格を、MAE・edgeMAE・IoUという数値の基準に翻訳する。プリセットを掃引で決め、pytestで回帰を守り、run.bat+venvで配布する。手応えだけで判断していた品質を、リファクタしても落ちない仕組みに変えて、この連載を締める。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。