BiRefNet の ONNX ファイルは約930MB ある。この1ファイルを手元の GPU に載せ、RTX 2070 SUPER+DirectML で1枚あたり14.7秒、MAE 0.0271・IoU 0.906 という数値で背景を抜く。クラウドにも学習フレームワークにも頼らず、画像を1枚も外へ出さずに実行する。第3回までで古典CVエンジンは白背景もグリーンバックも抜けるようになった。長毛の猫や、木の枝ごしの人物といった「境界が数千本の毛でできている被写体」だけは、四隅FloodFillでもクロマキーでも歯が立たない。ここでAIを入れる。ただし配布アプリなので、PyTorch も CUDA も同梱しない。onnxruntime-directml 1本で「GPUがあれば速く、無くても動く」を1つのコードから実現する回だ。
▶ この連載の全体像と完成アプリの使い分けガイドは連載ハブ(背景除去アプリの作り方)から。画像を1枚も外に出さずに、SOTA級のAI切り抜きを手元のGPUで回す回だ。
目次
この連載で作るもの(全7回の地図)
- ① 土台と全体設計 — PyQt6と3枚の純関数レイヤ
- ② 古典CVエンジン — FloodFill・クロマキー・Color-to-Alpha
- ③ 2つの難所 — 囲まれた背景ポケットとアイコンの穴
- ④ AIをローカルで動かす — ONNX+BiRefNetとDirectMLフォールバック(この記事)
- ⑤ 仕上げ — 5スライダー後処理・ブラシ・20段Undo
- ⑥ UI/UX — ズーム・スポイト・非同期・D&D一括処理
- ⑦ 完結 — 品質を数値で担保して配布する
第4回のゴール
この回を読み終えると、ai_engine.py の AiSession が BiRefNet・ISNet・U2Net の3モデルを差し替えながら推論し、どのモデルでも「BGR画像 → 元サイズの soft mask(uint8)」という同じ形の出力を返すところまで理解できる。この soft mask が、第5回で作る5スライダー後処理へそのまま流れていく。まず到達点を見てほしい。

DirectML が使えた環境では、ステータスに AI推論 完了 [DirectML(GPU)] と出る。GPU が無い、あるいは DirectML が初期化できない環境では、同じコードが CPU に落ちて [CPU] と出す。ユーザーは何も設定しない。この「1コードで両対応」を、以降のStepで分解していく。
Step 1. なぜ ONNX で推論するのか
背景除去アプリは、モデルを使うだけで学習は一切しない。学習の仕組みを丸ごと同梱する理由がない。
PyTorch を配布物に入れると、それだけで数百MB〜GB級になる。CUDA 版を選べば環境依存も跳ね上がり、GPU が無いユーザーの手元では起動すらしないことがある。推論だけに必要なのは「モデルの計算グラフを実行するランタイム」で、その役をするのが onnxruntime だ。
採用したのは onnxruntime-directml の1パッケージだけ。理由は3つある。
- 配布サイズが軽い:推論エンジンと ONNX モデルだけを持てばよく、学習系の依存を持ち込まない。requirements は PyQt6 / opencv-python / numpy / onnxruntime-directml / pytest の5つで足りる。
- 推論に特化している:ONNX は学習済みモデルを可搬な計算グラフとして固めた形式で、フレームワークをまたいで同じ重みを実行できる。BiRefNet も ISNet も U2Net も、rembg 配布の
.onnxを落とすだけで動く。 - DirectML で GPU の間口が広い:DirectML は DirectX 12 経由でGPUを叩くので、NVIDIA だけでなく AMD や Intel の GPU でも同じバイナリが加速する。CUDA を要求しない。GPU が無ければ CPU 実行に落ちる。
🔎 「学習しない」が設計をここまで軽くする:配布アプリの前提が「使うだけ・学習しない・完全オフライン」だと決まると、依存の選択が一気に絞れる。第1回で ai_engine.py を独立レイヤに切り出しておいたので、この回はそのファイル1枚(本文110行)に閉じている。UI もエンジンも、モデルが PyTorch 製か ONNX 製かを知らない。
Step 2. モデルごとに前処理が違う — _MODEL_CONFIGS
3つのモデルは、入力の解像度も正規化のやり方も違う。BiRefNet と U2Net は ImageNet 統計(mean/std)で正規化する前提で学習され、BiRefNet の出力は 0-1 に収まっていない logit なので sigmoid を通す必要がある。ISNet は mean 0.5 / std 1.0 の素朴な正規化で学習されている。入力の一辺も、BiRefNet と ISNet は1024px、U2Net は320px だ。
この差を、モデルごとの設定辞書として持つ。
# ai_engine.py
# モデルごとの前処理設定(ファイル名の部分一致で判定)
# sigmoid: 出力が logit のモデル(BiRefNet)は sigmoid で 0-1 に写してから正規化
_MODEL_CONFIGS = {
"birefnet": {"size": 1024, "mean": (0.485, 0.456, 0.406),
"std": (0.229, 0.224, 0.225), "sigmoid": True},
"isnet": {"size": 1024, "mean": (0.5, 0.5, 0.5), "std": (1.0, 1.0, 1.0)},
"u2net": {"size": 320,
"mean": (0.485, 0.456, 0.406), "std": (0.229, 0.224, 0.225)},
}
_DEFAULT_CONFIG = {"size": 1024, "mean": (0.5, 0.5, 0.5), "std": (1.0, 1.0, 1.0)}
どの設定を使うかは、ファイル名の部分一致で決める。birefnet-general.onnx なら小文字化した名前に "birefnet" が含まれるので BiRefNet の設定が当たる。isnet-general-use.onnx は "isnet"、u2net.onnx は "u2net" でそれぞれ引く。
def _config_for(model_path):
name = os.path.basename(model_path).lower()
for key, cfg in _MODEL_CONFIGS.items():
if key in name:
return cfg
return _DEFAULT_CONFIG
どの鍵にも一致しない未知の .onnx を置かれた場合は _DEFAULT_CONFIG(1024px・素朴な正規化)へ落ちる。表示は崩れても、少なくとも例外で落ちずに何かを返す。ファイル名という緩い手がかりで設定を選ぶので、リネームすれば別モデルとして扱われる点は割り切りだ。
Step 3. predict の中身 — BGRから元サイズのマスクまで
推論の本体は predict 1メソッドに収まる。入力は OpenCV 由来の BGR 画像、出力は元画像と同じ縦横の soft mask(uint8、被写体ほど白い)だ。処理は一直線に進む。
def predict(self, bgr):
"""BGR画像から soft mask (uint8, 元画像サイズ) を返す。"""
h, w = bgr.shape[:2]
size = self.cfg["size"]
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (size, size), interpolation=cv2.INTER_AREA)
x = resized.astype(np.float32) / 255.0
mean = np.array(self.cfg["mean"], np.float32)
std = np.array(self.cfg["std"], np.float32)
x = (x - mean) / std
x = x.transpose(2, 0, 1)[np.newaxis] # NCHW
out = self.sess.run(None, {self.input_name: x})[0]
pred = out[0, 0] # (size, size)
if self.cfg.get("sigmoid"):
pred = 1.0 / (1.0 + np.exp(-np.clip(pred, -50.0, 50.0)))
mn, mx = float(pred.min()), float(pred.max())
if mx - mn > 1e-6:
pred = (pred - mn) / (mx - mn)
mask = (pred * 255).astype(np.uint8)
return cv2.resize(mask, (w, h), interpolation=cv2.INTER_LINEAR)
順番に追う。まず元の高さ幅 h, w を控えてから、COLOR_BGR2RGB で色順を入れ替える。OpenCV は BGR、学習時は RGB なので、ここを間違えると青と赤が入れ替わったまま推論して精度が落ちる。次にモデル指定の一辺へ INTER_AREA で縮小する。縮小には面積平均の INTER_AREA が向く。
正規化は /255 で 0-1 にしてから (x - mean) / std。この mean/std が Step 2 でモデルごとに変えていた値だ。そのあと transpose(2, 0, 1) で HWC を CHW に並べ替え、[np.newaxis] で先頭にバッチ次元を足して NCHW(1枚, 3ch, size, size)にする。ONNX の入力名は __init__ で取得済みの self.input_name を使い、sess.run に辞書で渡す。
出力 out[0, 0] は size×size の1chマップだ。BiRefNet だけは sigmoid を通す。ここで np.clip(pred, -50.0, 50.0) と値域を絞ってから exp に入れているのが効く。logit が極端に大きいと exp(-x) がオーバーフローして警告や NaN を招くので、±50 で頭打ちにする。sigmoid(50) は 1 に、sigmoid(-50) は 0 に十分近く、可視結果は変わらない。
最後に min-max 正規化でマップを 0-1 に引き伸ばす。mx - mn > 1e-6 の条件を噛ませているのは、全面がほぼ同じ値(真っ黒や真っ白の入力など)でゼロ除算になるのを避けるためだ。差がほぼ無ければ正規化をスキップして値をそのまま使う。*255 で uint8 に戻し、INTER_LINEAR で元の (w, h) へ拡大して返す。拡大は線形補間の INTER_LINEAR でマスクの縁をなめらかにする。
💡 返すのは soft mask(2値化しない):predict はしきい値を切らず、0〜255 の連続値をそのまま返す。境界のグレーは第5回の後処理(マスクしきい値・境界ソフト幅・ぼかし)で切る。AIの役目は「どこが被写体か」の連続的な確からしさを出すところまでで、白黒の判定はユーザーが触れる後処理レイヤに委ねる。役割を分けておくと、同じ推論結果に対してスライダーを動かすだけで境界を追い込める。
Step 4. DirectML → CPU の自動フォールバック
「GPUがあれば速く、無くても動く」の中身は AiSession.__init__ にある。providers のリストと、その初期化に失敗したときの再構築だけで実現している。
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"])
self.input_name = self.sess.get_inputs()[0].name
onnxruntime は providers を優先順のリストで受け取る。["DmlExecutionProvider", "CPUExecutionProvider"] と渡すと、DirectML が使える演算子は DirectML で、対応できない演算子は CPU で実行する。セッションが立ったあと get_providers() に "DmlExecutionProvider" が実際に入っているかを確かめ、入っていれば表示を DirectML(GPU) に上げる。
DirectML の初期化そのものが例外を投げる環境(対応GPUが無い、ドライバが古いなど)では except Exception が受けて、["CPUExecutionProvider"] だけでセッションを組み直す。self.provider は最初から "CPU" で初期化してあるので、この経路では表示も CPU のままになる。最後に get_inputs()[0].name で入力名を取り、predict の sess.run で使う。
この self.provider をUIがステータスに出す。ユーザーは [DirectML(GPU)] か [CPU] かで、いま自分の環境がGPU加速に乗ったかを一目で確認できる。設定画面もチェックボックスも要らない。
Step 5. モデルは手動配置 — 数KBの「.onnx」という罠
このアプリはモデルを自動ダウンロードしない。完全オフラインを掲げる以上、初回起動時にこっそり930MBを取りに行く、という挙動をしないと決めた。models/ フォルダに置かれた .onnx を拾うだけだ。
MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
def list_models():
"""models/ 内の .onnx ファイル名一覧(ソート済み)。"""
paths = glob.glob(os.path.join(MODELS_DIR, "*.onnx"))
return sorted(os.path.basename(p) for p in paths)
list_models は models/*.onnx を glob してファイル名だけをソートして返す。UIのモデル選択コンボは、この一覧をそのまま並べる。置いた分だけ選択肢が増える素朴な作りだ。
配置するファイルは README に表で示している。入手元は rembg のリリース資産だ。ライセンスは配布元リポジトリで確認済みで、BiRefNet が MIT、U2Net と ISNet(DIS)が Apache-2.0。いずれも各自ダウンロードして使う分に制約は無い(詳しい条件と PyQt6 の注意は第7回のライセンス節にまとめた)。
| ファイル | 用途 |
|---|---|
birefnet-general.onnx |
既定・最高精度(約930MB、1枚十数秒) |
isnet-general-use.onnx |
高速・高品質汎用 |
u2net.onnx |
代替・軽量 |
ここに実際にハマった罠がある。ブラウザや curl でモデルを落とすとき、リンク切れやリダイレクト、レート制限に当たると、930MBのモデルの代わりに、数KBのエラーページ(HTML)が birefnet-general.onnx という名前で保存される。拡張子は .onnx なので list_models はそれを候補に並べ、選ぶと onnxruntime が「壊れたモデル」として例外を投げる。
対策は単純で、README にも書いた。ダウンロード後にファイルサイズが表相応にあるかを確認する。BiRefNet が数KBなら中身はモデルではない。rembg を使ったことのある環境なら %USERPROFILE%\.u2net\ に同じファイルがキャッシュされていることがあり、そこからコピーして流用できる。
🔎 「拡張子が正しい」は「中身が正しい」を意味しない:大容量ファイルのダウンロード失敗は、エラー本文をそのままファイルに書き込む形で起きることがある。名前とサイズが食い違ったら中身を疑う。930MBを期待して4KBが来ていないか、まずサイズを見る。
Step 6. 3モデルの実測比較
どのモデルを既定にするかは、実測で決めた。scripts/eval_ai_models.py で、サンプル6枚・承認済みプリセット出力を正解とみなして、出荷時の既定値で3モデルを比較した数値がこれだ。速度は RTX 2070 SUPER・DirectML 実行で計測している。
| モデル | 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/枚 |
MAE は誤差なので小さいほど良く、IoU は重なりなので大きいほど良い。BiRefNet は MAE 0.0271・IoU 0.906 で頭ひとつ抜けているが、1枚14.7秒と重い。ISNet は BiRefNet の約100分の1、0.14秒で回り、精度も MAE 0.0374・IoU 0.869 と実用域だ。U2Net は0.05秒と最速だが、MAE 0.1416・IoU 0.695 と数値がはっきり落ちる。

使い分けの指針も数値から素直に出る。じっくり1枚の毛物や複雑な境界を抜くなら BiRefNet。数十枚を一括で速く処理したい、あるいはGPUが非力でBiRefNetが分単位になるなら ISNet。この選択を、Step 5 のモデル選択コンボでいつでも切り替えられる。既定は精度を採って BiRefNet に置いた。
💡 速度は必ず環境を添えて言う:14.7秒も0.14秒も、RTX 2070 SUPER・DirectML という具体的な計測環境の数字だ。GPUが変われば速度は動く。CPUフォールバックならBiRefNetは分単位になり得る。数字を出すときは測定機と実行プロバイダを添える。それが無い速度表記は、読者の環境では当てにならない。
まとめ:第4回の要点
- 配布アプリはONNXで軽くする:学習しないのだから PyTorch を同梱せず、onnxruntime-directml 1本にする。DirectML でNVIDIA以外のGPUも同じバイナリで加速する。
- 前処理はモデルごとに違う:
_MODEL_CONFIGSで解像度・mean/std・sigmoid有無を持ち、ファイル名の部分一致で選ぶ。BiRefNet=1024/ImageNet/sigmoid、ISNet=1024、U2Net=320。 - 出力は連続値のsoft mask:
predictは BGR→RGB→リサイズ→正規化→NCHW→(sigmoidは±50クリップ)→min-max→元サイズと進み、白黒の判定は後処理へ渡す。 - 1コードでGPU/CPU両対応:providers を優先順リストで渡し、DirectML初期化が失敗したら例外を受けてCPUで組み直す。
self.providerをUIに出す。 - モデルは手動配置・サイズを疑う:自動DLしない。数KBのエラーHTMLが
.onnxとして保存される罠に、ファイルサイズ確認で対処する。 - 既定は実測で決めた:MAE/IoU/速度の3モデル比較でBiRefNetを既定に採用。速度は測定機とプロバイダを添えて語る。
次回(第5回)は、この AI が返した soft mask を「実用で切り抜ける画像」へ仕上げる。5枚のスライダー後処理でしきい値と境界を追い込み、消去/復元ブラシで自動が外した1割を手で直し、20段のUndoで安心して試せるようにする。自動で9割・手で1割、を成立させる仕上げの層を、実コードで開く。
※ この連載について:Background Removal Studio Pro は、設計・実装の各工程でAIを併用して開発した個人プロジェクトです。掲載するコードは実際のソースからの抜粋で、動作は自動テスト(pytest)と実機で検証しています。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。