背景除去アプリで最初に固めるべきは、UIの見た目でも採用するAIモデルでもない。「抜き方(エンジン)」をUIから引き剥がすことだ。AI・四隅FloodFill・クロマキー・指定カラー透過と、背景を消す手段はいくつもある。それを全部UIのボタン処理に直書きすると、手段を1つ足すたびにUIのコードが膨らみ、どこが「抜く計算」でどこが「画面の都合」なのか混ざって、やがて自分でも触れなくなる。だからこの連載の第1回では、全エンジンの出力を「uint8アルファマスク(255=被写体・0=背景)」という1種類のデータに統一し、UIはその1種類だけを相手にする土台を先に組む。PyQt6 + OpenCV + ONNX で、完全オフラインで動く Background Removal Studio Pro を、実際のコードで1から作っていく。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。完成アプリの使い方だけ知りたい人はハブだけで足りる。作りたい人はここからだ。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ(この記事)
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する
第1回のゴール
この記事を読み終えると、QMainWindow の骨格が立ち、「ベースマスク生成 → スライダー後処理 → 合成 → プレビュー」という一方向のデータフローが動くところまでが手元に出来上がる。以降の回でエンジンを1つずつ差し込んでいくが、そのとき土台側は書き換えない。差し込み口だけ用意しておく。
コードそのものより先に、全体の骨組みを頭に入れてほしい。アプリは大きく4ファイルで構成する。うち3枚は画面もPyQtも知らない「純関数」の層で、残り1枚がその3枚を呼び出すPyQtのUIだ。下の図がその関係を表している。
Step 1. なぜ4ファイルに分けるのか
結論から書く。このアプリは役割ごとに4ファイルへ割る。removal_engines.py(古典CVのベースマスク生成)・ai_engine.py(ONNX推論)・mask_pipeline.py(後処理とブラシ合成)・bg_remover_ui_ux.py(PyQt6のUI)。README にもこの構成をそのまま書いてある。
- bg_remover_ui_ux.py — メインUI(エントリポイント)
- removal_engines.py — 古典CVエンジン(純関数)
- mask_pipeline.py — マスク後処理・ブラシ合成(純関数)
- ai_engine.py — ONNX推論(モデル別前処理、DirectML→CPU自動フォールバック)
- scripts/ — チューニング/評価ハーネス(アプリ実行時には不要)
- tests/ — pytest(プリセット回帰・AI推論・エンジン単体)
肝は、上の3枚(removal_engines・ai_engine・mask_pipeline)がPyQtもウィンドウも一切知らないという点だ。各ファイル冒頭のコメントが、その約束を宣言している。
# removal_engines.py
# 古典CVのベースマスク生成(純関数)
# 戻り値はすべて uint8 アルファ(255=被写体, 0=背景)
# mask_pipeline.py
# マスク後処理 + ブラシレイヤー合成(純関数)
# 入出力はすべて uint8 のアルファマスク
# (255=被写体/不透明, 0=背景/透明)
この分け方には、あとで効いてくる理由がある。
- 画面なしでテストできる:純関数の層はNumPy配列を入れてNumPy配列が返るだけなので、ウィンドウを開かなくても pytest が一瞬で回る。実際このプロジェクトのテストは、プリセット回帰・AI推論・エンジン単体の3系統に分かれていて、ロジックの正しさを目視でなく自動テストで守っている(数値での担保は第7回で扱う)。
- UIの都合がロジックに漏れない:スライダーの範囲やボタンの並びをどう変えても、抜く計算のコードは1行も動かない。逆に抜き方のアルゴリズムを改良しても、UI側は差し込み口を通じて受け取るだけで済む。
- 1ファイルずつAIに任せられる:層の境界がはっきりしていると、各ファイルは小さく独立するので、生成AIに「このファイルだけ直して」と安全に頼める。境界がないと、1か所の修正が思わぬ場所を壊し、原因も追えなくなる。
💡 設計の勘所:バイブコーディングでAIにコードを書かせるほど、この「層の境界」を人間が握る価値は上がる。境界さえ守られていれば、中身の実装をどれだけAIに任せても全体は崩れない。
Step 2. 共通通貨は uint8 アルファマスク
4ファイルに割っただけでは、まだ土台にならない。3枚の純関数レイヤがてんでバラバラの形でデータを返したら、UI側が結局それぞれを場合分けして扱うはめになり、分けた意味が消える。そこでこのアプリの中心に、1本の約束を通す。背景の抜き具合は、つねに「uint8アルファマスク」1種類で表す。形は画像と同じ縦横の2次元配列で、各画素が0から255の1バイト。255なら被写体(残す)、0なら背景(透明にする)、中間はその割合の半透明。
これを「共通通貨」と呼んでいる。円やドルのように、価値をやりとりする共通の単位だ。古典CVで抜こうがAIで抜こうが、出てくるのは同じ規格のアルファマスク。だからUIは、どのエンジンを使ったかを気にせず、返ってきたマスクを同じ後処理・同じ合成・同じ表示にそのまま流せる。
例えば古典CVの四隅FloodFillエンジンは、docstring で返り値の規格をこう明記している。
def flood_background_mask(bgr, tolerance):
"""四隅FloodFillで「外側から到達できる背景色」だけを透明にする。
...
Args:
bgr: uint8 (H, W, 3)。
tolerance: 0-100。FloodFill の loDiff/upDiff に使う色差。
Returns:
uint8 (H, W) アルファ。
"""
入力は BGR(OpenCVの色順)の3チャンネル画像、出力は1チャンネルの uint8 (H, W) アルファ。第4回で扱うAI推論も、モデルの生の出力を最終的にこの同じ uint8 (H, W) に揃えて返す。共通通貨を最初に決めておくと、あとから増えるエンジンが全部この型に「両替」して合流する形になる。
💡 ここが土台の本体:抜き方が5通りあっても、UIが覚える型は1つだけ。「複数の入口・1つの通貨・1本の処理パイプライン」。この形にしておくと、6回目・7回目でエンジンや後処理が増えても、UIの構造はほぼそのまま使い回せる。
Step 3. モードで被写体を切り替える思想
共通通貨を決めたら、次は「入口」を用意する。ユーザーが直接選ぶのは被写体と背景の関係で、抜き方のアルゴリズムはその選択からアプリが決める。白い机に置いた商品なのか、グリーンバックの人物なのか、クリックで背景を指定したいのか。それをアプリでは5つの「モード」として持つ。定数はメインファイルの先頭でこう定義している。
MODE_AI = "AI自動(どんな背景でも)"
MODE_FLOOD = "白/単色背景"
MODE_CHROMA = "クロマキー(任意色)"
MODE_WAND = "マジックワンド(クリック指定)"
MODE_COLOR = "指定カラー透過"
文字列をそのまま定数にしているのは、この値がコンボボックスの表示ラベルと内部の判定キーを兼ねるからだ。ユーザーが読むラベルと、コードが分岐に使うキーを二重管理しない。モードを増やすときは、この5行に1行足して、ベースマスク生成の分岐を1つ増やせばいい。
5つのモードは、それぞれ別のエンジンに対応する。AIは ai_engine、白/単色背景とマジックワンドとクロマキーと指定カラー透過は removal_engines の各関数だ。だが出口は全部同じ uint8 (H, W) のアルファなので、UIはモードに応じて呼ぶ関数を切り替えるだけで、その先の処理は共通化できる。第2回以降は、この5つの入口の中身を1つずつ実装していく。
Step 4. QMainWindow と左パネル
ここからPyQtのUIを組む。BgRemovalStudio は QMainWindow を継承し、まず画像や作業状態を素直な属性として持つ。ウィンドウを作った直後の __init__ がこの状態宣言だ。
class BgRemovalStudio(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Background Removal Studio Pro v2")
self.resize(1600, 950)
# --- 画像状態 ---
self.original_bgr = None # uint8 (H, W, 3)
self.original_alpha = None # uint8 (H, W) or None
self.base_mask = None # モード別ベースマスク
self.preview_rgba = None # 保存用 BGRA
...
self.setAcceptDrops(True)
self.batch_thread = None
self.setup_ui()
コメントに注目してほしい。base_mask は「モード別ベースマスク」、original_alpha は「uint8 (H, W) or None」。状態の1つひとつが、Step 2 で決めた共通通貨(uint8アルファ)で書かれている。UIが持つデータの単位まで、最初に統一しておく。
画面は左に操作パネル、右に画像ビューを2枚(元画像と結果)並べる横並びレイアウトにする。setup_ui の骨格を抜き出すとこうなる。
def setup_ui(self):
self.setStyleSheet(""" ... """) # ダークテーマのQSS
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout()
central.setLayout(root)
# ---------- 左パネル ----------
left_panel = QFrame()
left_panel.setMaximumWidth(360)
left = QVBoxLayout()
left_panel.setLayout(left)
self.btn_load = QPushButton("画像を読み込む")
self.btn_load.clicked.connect(self.load_image_dialog)
left.addWidget(self.btn_load)
...
# --- モード選択 ---
mode_group = QGroupBox("除去モード")
mode_lay = QVBoxLayout()
self.mode_combo = QComboBox()
self.mode_combo.addItems([MODE_AI, MODE_FLOOD, MODE_CHROMA,
MODE_WAND, MODE_COLOR])
self.mode_combo.currentIndexChanged.connect(self.on_mode_changed)
左パネルは QVBoxLayout にグループを縦積みしていく。除去モードのコンボ、モード別パラメータのグループ、全モード共通の微調整、ブラシ、プリセット。モードのコンボには Step 3 の5定数をそのまま addItems で流し込む。ここでも定数を1か所で管理している効果が出ていて、モードを足せば選択肢に自動で並ぶ。
左パネルにはスライダーが十数本ある。同じ「ラベル+スライダー+値変更で再計算」を毎回手書きすると長くなるので、小さなヘルパーにまとめた。
def _slider(self, lo, hi, val, layout, label, update=True):
layout.addWidget(QLabel(label))
s = QSlider(Qt.Orientation.Horizontal)
s.setRange(lo, hi)
s.setValue(val)
layout.addWidget(s)
if update:
s.valueChanged.connect(self.on_param_changed)
return s
update=True のスライダーは、動かすと on_param_changed が呼ばれて再計算に入る。ブラシのサイズや硬さのように、動かしても即座に再計算したくないスライダーは update=False で配線を外す。この小さな引数1つで、「どのスライダーがパイプラインを起動するか」を宣言的に決められる。
右側の画像エリアは、元画像と結果を左右に並べる。左パネルとの間を QSplitter で区切り、2枚の ImageView を横に置くだけの素直な作りだ。
# ---------- 画像エリア ----------
splitter = QSplitter()
self.original_view = ImageView(interactive=True) # スポイト/ワンド用
self.preview_view = ImageView(interactive=True) # ブラシ用
splitter.addWidget(self.original_view)
splitter.addWidget(self.preview_view)
root.addWidget(splitter)
2枚のビューを分けているのには意味がある。左(元画像)はスポイトやマジックワンドで背景色を指定する入力を受け、右(結果)はブラシで抜き結果を直接修正する。同じ画像を2つの役割で見せることで、「どこを指しているか」と「どう抜けたか」を並べて確認できる。ImageView はホイールで拡大縮小できる自作ビューで、その中身(ズームやクリック座標の通知)は第6回で開く。第1回では、左右2ビューの器を QSplitter で用意するところまでで足りる。

Step 5. 2段階更新の設計
土台の心臓部がここだ。ユーザーがスライダーを動かすたびに毎回AI推論やFloodFillをやり直していたら、重すぎて操作にならない。そこで更新を2段階に分ける。重い「ベースマスク生成」と、軽い「後処理+合成+表示」を切り離す。
どちらを走らせるかは、動かしたスライダーの種類で決める。on_param_changed が、送り主のスライダーを見て振り分ける。
def on_param_changed(self):
# モード固有スライダーはベースマスク再生成、共通スライダーは合成のみ
sender = self.sender()
if sender in (self.flood_tol, self.hue_center, self.hue_width,
self.sat_min, self.wand_tol,
self.color_tol, self.color_soft):
self.rebuild_base_mask()
else:
self.update_preview()
色の許容差や色相のように「抜き方そのもの」を変えるスライダーは rebuild_base_mask(重い側)へ。しきい値やぼかしのように「抜いた後の見せ方」を変えるスライダーは update_preview(軽い側)へ。境界のぼかしを微調整するたびにAIを再実行せずに済むのは、この振り分けのおかげだ。
重い側の rebuild_base_mask は、現在のモードを見てエンジンを呼び分け、結果を self.base_mask(共通通貨のアルファ)に格納して、最後に軽い側の update_preview を呼ぶ。骨格はこうだ。
def rebuild_base_mask(self):
if self.original_bgr is None:
return
mode = self.current_mode()
bgr = self.original_bgr
if mode == MODE_AI:
... # 推論はスレッドへ(第6回)
return
if mode == MODE_FLOOD:
self.base_mask = flood_base_mask(bgr, { ... })
elif mode == MODE_CHROMA:
self.base_mask = chroma_key_mask(bgr, ... )
elif mode == MODE_WAND:
self.base_mask = magic_wand_mask(bgr, ... )
elif mode == MODE_COLOR:
self.base_mask = color_to_alpha_mask(bgr, ... )
self.update_preview()
この関数が、Step 3 の「5つの入口」と Step 2 の「1つの通貨」が合流する場所だ。どの枝を通っても、行き着く先は self.base_mask という同じ変数。だから軽い側は、どのエンジンが埋めたマスクかを知らずに処理できる。
軽い側は、後処理と合成を1本のパイプラインで通す。UI用のまとめ関数 compute_final_alpha が、その2工程を順に呼ぶ。
def compute_final_alpha(self):
"""現在の全パラメータで最終アルファを計算する。"""
processed = postprocess_mask(
self.base_mask,
threshold=self.threshold.value(),
softness=self.softness.value(),
edge_shift=self.edge_shift.value(),
noise=self.noise.value(),
blur=self.blur.value())
return compose_final_alpha(
processed, self.erase_layer, self.restore_layer,
self.original_alpha)
postprocess_mask がスライダー5本ぶんの後処理(しきい値・ソフト幅・エッジ収縮膨張・ノイズ除去・ぼかし)をかけ、compose_final_alpha がそこにブラシの消去・復元レイヤーと、元画像がもともと持っていたアルファを合成する。合成の優先順位も docstring で固定してある。
def compose_final_alpha(processed, erase_layer, restore_layer, original_alpha):
"""後処理済みマスクにブラシレイヤーと既存アルファを合成する。
final = clip(processed + restore - erase) を AND(既存アルファ)。
消去 > 復元 > ベースの優先順位(erase を最後に減算)。
...
"""
これらの中身(後処理の各段・ブラシ合成・元アルファのAND)は第5回でじっくり開く。第1回で押さえるのは、「重い生成」と「軽い後処理」を分け、送り主のスライダーで振り分ける、という更新の骨格だ。update_preview は compute_final_alpha を呼び、結果を表示モード(完成画像・マスク表示・背景検出)に応じて右ビューへ映すだけの薄い関数になる。完成画像モードでは、透過部分をチェッカーボード(市松模様)の上に重ねて見せる。透明な部分がひと目で分かるようにするためで、この市松の生成は大画像でスライダーが重くならないようサイズ別にキャッシュしている。
Step 6. 完全オフライン方針
このアプリは一切ネットワークにアクセスしない。AI推論も、モデルさえ手元に置けばオフラインで完結する。ai_engine.py の冒頭がその方針を宣言している。
# ai_engine.py
# ONNX ローカル推論(完全オフライン)
# AiSession: onnxruntime のみに依存する純粋部分
# AiInferenceThread: PyQt6 QThread ラッパー(Task 6 で追加)
ここに、Step 1 の分離思想がAIレイヤーにも貫かれているのが見える。AiSession は onnxruntime だけに依存する純粋部分で、PyQtには触れない。PyQtと繋ぐ AiInferenceThread は別に切り出す。推論エンジンも、他の純関数レイヤと同じく画面から独立している。
オフラインで「GPUがあれば速く、無ければ動く」を両立させる仕掛けが、プロバイダの自動フォールバックだ。AiSession の初期化がこれを担う。
class AiSession:
"""1モデル分の ONNX セッション。DirectML 失敗時は CPU に自動フォールバック。"""
def __init__(self, model_path):
self.model_path = model_path
self.cfg = _config_for(model_path)
self.provider = "CPU"
try:
self.sess = ort.InferenceSession(
model_path, providers=["DmlExecutionProvider",
"CPUExecutionProvider"])
if "DmlExecutionProvider" in self.sess.get_providers():
self.provider = "DirectML(GPU)"
except Exception:
self.sess = ort.InferenceSession(
model_path, providers=["CPUExecutionProvider"])
まず DirectML(Windowsの多くのGPUで動く推論プロバイダ)を試し、使えれば provider を DirectML(GPU) に、ダメなら例外を捕まえてCPUで開き直す。配布先のPCにGPUがあるか無いかを、利用者が意識せずに済む。モデルは大きいので同梱せず、README の表に沿って利用者が models/ へ手動配置する形にしている(配置の詳細やモデル別の前処理は第4回で扱う)。
オフラインに振り切ると、AdSenseのようなクラウドAPIの従量課金も、画像を外部へ送るプライバシーの懸念も、そもそも発生しない。手元の画像は手元で処理して終わる。
まとめ:第1回の要点
- 抜き方をUIから切り離す:4ファイルに割り、
removal_engines・ai_engine・mask_pipelineの3枚はPyQtを知らない純関数にする。だから画面なしで pytest が回る。 - 共通通貨は uint8 アルファ:全エンジンの出力を
uint8 (H, W)(255=被写体・0=背景)に統一。入口は5モードあっても、UIが扱う型は1つ。 - モードは被写体と背景の関係:
MODE_*の5定数が、表示ラベルと分岐キーを兼ねる。増設はここに1行足すだけ。 - 2段階更新:
on_param_changedが送り主のスライダーで振り分け、重いrebuild_base_maskと軽いupdate_previewを分離。ぼかし調整でAIを再実行しない。 - 完全オフライン:ONNXローカル推論、DirectML→CPUの自動フォールバック。画像を外へ出さない。
次回(第2回)は、この土台に最初のエンジンを差し込む。AIを使わない古典CVの4エンジンだ。四隅FloodFillで単色背景を外側から削り、HSVのクロマキーでグリーンバックを抜き、Color-to-Alphaでアンチエイリアスの境界まできれいに消す。単純な背景なら、十数秒かかるAIより古典CVのほうが速くて正確な場面が多い。その「BGR画像→uint8アルファ」の純関数を、実コードで1本ずつ組み立てて、白背景の商品写真とグリーンバックが実際に抜けるところまで進める。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。