生成した人物の絵(立ち絵)をゲームの画面やスタンプに使う時は、人物だけを残して背景を透明にします。この作業を背景除去と呼びます。暗い背景に、背景除去のモデルで切り抜いた立ち絵を置いたのが次の図です。

白い背景で描いた髪を切り抜いて暗い背景へ置くと、髪の先に白い縁が残る
中央の絵では、髪の先と細い毛に沿って白い線が残りました。白は、描いた時の背景色です。右の絵は、縁の色から背景の白を差し引いてから置いたもので、白い線は目立たなくなりました。差し引く式は後の節で示します。切り抜いた絵の縁には、描いた時の背景が混ざっています。
本記事では、生成した絵の背景を除去して使おうとしているエンジニアに向けて、背景除去で残るものを手元の実験で検証します。扱う疑問は次の通りです。
- 切り抜いた絵のどこに、描いた時の背景が残るのか。
- 描く時の背景色で、残り方は変わるのか。
- 画像生成ツールの背景除去は、モデルの作者が示す使い方と同じ結果になるのか。
- 残った背景を色で消すと、何が起きるのか。
- 透明にした所には、何が入っているのか。
背景除去のモデルを学習する方法と、写真から人物を切り抜く話は扱いません。
前提: マスクと半透明の縁
今回使った BiRefNet は、画像の画素ごとに、手前に写るもの(今回は人物)である確からしさを返すモデルです。出力に sigmoid という関数を掛けて、0〜1 の値にして使います。この 0〜1 の画像をマスクと呼びます。学習済みモデルのファイルを重みと呼び、BiRefNet の重みは ZhengPeng7/BiRefNet で MIT License の下で公開されています。以下は、表紙と同じ絵から髪の先を切り出し、マスクと並べた図です。

描いた絵とマスク。白が人物、黒が背景、灰色が半透明
髪の先や細い毛の所は、0 と 1 の間にある灰色になります。このマスクを透明度として絵に付けたものが、透過 PNG です。透明度の値はアルファ値(α)と呼び、0 が透明、1 が不透明です。PNG は画素ごとに RGB と α を持てます。α が 0 の画素にも、RGB の値は入っています。本記事では、α が 0.02〜0.98 の画素を半透明と呼びます。
透過 PNG を別の背景に置く時は、画素ごとに次の色を作ります(アルファブレンド)。
置いた後の色 = α × 絵の色 + (1 − α) × 新しい背景色
縁で起きる二重の混ざり
半透明の画素では、描いた時点で、人物の色と背景の色が既に混ざっています。上の式は、その混ざった色を人物の色として扱います。そのため、新しい背景と混ぜた後にも、描いた時の背景が残ります。茶色の髪と白い背景の境目にある画素で、α 0.5 として数値を追うと次の通りです。
| 画素の色(RGB) | 値 |
|---|---|
| 髪の茶色 | (120, 80, 60) |
| 白い背景で描いた縁(茶と白の半々) | (188, 168, 158) |
| 縁を暗い背景 (24, 28, 40) へ置いた色 | (106, 98, 99) |
| 本来なってほしい色(茶と暗い背景の半々) | (72, 54, 50) |
置いた色は、本来の色より 30 以上明るくなります。この明るさが、表紙で中央の絵に出た白い線です。
実験の作り方
絵は、公開されている画像生成モデルの WAI-illustrious-SDXL v17.0 で描きました。服は紺の上着と黒いズボンで、髪は風になびく長い髪にしました。毛の間に背景が見える所を多くするためです。
姿勢は深度マップで揃えました。深度マップは、カメラからの距離を明るさで表した白黒の画像で、生成の時に姿勢を指示する仕組みの ControlNet に渡します。元にしたのは、3D 制作ツールの Blender で作った人体の 3D モデルで、ポーズを付けて動かせます。このモデルは Depth Body Shape のノートで作り、Animation Flicker のノートと同じ手順で深度マップを書き出しました。
変えたのは 2 つです。1 つはプロンプトで頼む背景色で、白・灰色・緑の 3 通りです。もう 1 つは髪の色で、茶色・銀色の 2 通りです。銀色の髪は、白や灰色の背景と色が近い組として選んでいます。シード(seed)は生成に使う乱数を決める値で、同じプロンプトでもシードを変えると別の絵になります。シードを 3 通り変えて、3 × 2 × 3 の 18 枚を描きました。
ComfyUI の背景除去ノード
背景除去には、ノードをつないで生成の流れを組むツールの ComfyUI を使いました。0.35.0 の時点で、背景除去のノードを本体に持っていて(nodes_bg_removal.py)、追加のノードを入れずに使えます。ComfyUI 向けに配られている重み(Comfy-Org/BiRefNet)は、SHA-256 を比べると、作者の重みと同じファイルでした。重みが同じなので、後で出る結果の違いは、重みではなく使い方の違いから来ます。
ComfyUI の API には、ノードを ID で結んだ辞書を送ります。キーはノードの ID で、続き番号でなくても構いません。次のつなぎ方は、マスクを白黒の画像として保存します。
def node_workflow(image):
return {
"1": {"class_type": "LoadImage", "inputs": {"image": image}},
"2": {"class_type": "LoadBackgroundRemovalModel", "inputs": {"bg_removal_name": "birefnet.safetensors"}},
"3": {"class_type": "RemoveBackground", "inputs": {"bg_removal_model": ["2", 0], "image": ["1", 0]}},
"4": {"class_type": "MaskToImage", "inputs": {"mask": ["3", 0]}},
"8": {"class_type": "SaveImage", "inputs": {"images": ["4", 0], "filename_prefix": "debimate_bgremove/mask"}},
}
RemoveBackground は、人物を 1 とするマスクを返します。一方で ComfyUI のマスクは、1 を透明にする所として扱う決まりです。透過 PNG を作るノードの JoinImageWithAlpha も、受け取った値が 1 の所を透明にします。そのため、透過 PNG を作る時は、間にマスクを反転する InvertMask を挟みます。ComfyUI が公式に配るテンプレートも、この順でつないでいます。
このほかに、同じ重みを手元で動かし、前処理のうち正規化だけを作者の使い方に揃えたマスクも作りました。前処理は、モデルに渡す前に画像の大きさと色の値を整える処理で、正規化は色の値がとる範囲を揃える手順です。違いは後の節で説明します。特に断らない限り、数値と図は ComfyUI のノードで作ったマスクのものです。スクリプトは lab/run.py と lab/cutout.py です。
色の数え方
色の差は、人の見た目に近い色空間の CIELAB で、2 色の距離 ΔE(CIE76)として測ります。ΔE がおよそ 2.3 で、並べて見た色の違いが人に分かるとされています(Color difference)。
描いた時の背景色 B は、縁から差し引く時と、色で消す時の基準に使います。B は、絵の上端と左右の端にある画素の中央値で決めました。モデルは頼んだ色をそのまま塗るわけではありません。白い背景は 1 枚がクリーム色に、銀髪で頼んだ灰色の背景は RGB の各値が 120〜139 の暗い灰色になりました。そのため、1 枚ごとに実際に塗られた色を B にします。人物の画素は、α が 0.5 以上の画素として数えます。
縁に、描いた時の背景が混ざる
以下は、白・灰色・緑の背景で描いた茶髪を切り抜いて、同じ暗い背景へ置いた図です。

背景色を変えて描いた茶髪を切り抜き、同じ暗い背景へ置いた(シード 20261116)
縁の色は、描いた時の背景色になりました。緑の背景で描いた絵では、細い毛が緑の点線のように残っています。細い毛は幅が狭く、画素の多くが半透明になるためと考えられます。半透明の画素は、1 枚で 1.4 万〜2.3 万個、人物の画素に対して 6〜9% ありました。
背景色を差し引く
描いた時の背景が無地で、その色 B が分かっていれば、縁の色から B を差し引けます。半透明な画素の色 I は「α × 人物の色 + (1 − α) × B」なので、人物の色は次の式で戻せます。
人物の色 = (I − (1 − α) × B) ÷ α
表紙で右の絵は、この色を使ってから暗い背景へ置いたものです。差し引いた効果を数えるために、暗い背景へ置いた縁の色が、理想ならこう見えるはずの色からどれだけずれたかを測りました。物差しは、半透明の画素ごとの ΔE の平均です。理想の色は、いちばん近い不透明な画素を人物の色とみなし、同じ α で暗い背景と混ぜた色です。

縁の色のずれ。赤がそのまま、青が背景色を差し引いてから置いた時(各 3 シード)
茶髪では、どの背景でも ΔE が 12〜15 から 10 前後(9.6〜11.1)へ下がりました。ただし目安の 2.3 よりは大きく、色のずれは残っています。表紙で差し引いた絵でも、細い毛の線や縁の暗い線は残りました。理想の色は近似で作っているので、0 にはなりません。この数値は、組の間で比べるための目安です。
銀髪では、ほとんど下がりませんでした。銀髪は背景と色が近く、境目でマスクの α そのものが正しくないので、差し引いても人物の色に戻らないと推測しています。
毛の間に背景の塊が残る
縁とは別に、背景が不透明な塊のまま残ることもあります。茶髪の 9 枚で最も大きく残ったのは、灰色の背景で描いた 1 枚です。

灰色の背景で描いた茶髪を ComfyUI のノード(正規化なし)と正規化ありで切り抜いた(シード 20261116)
ComfyUI のノードで切り抜いた左の絵では、頭の後ろに背景の灰色が大きな塊で残りました。右の絵は、正規化だけを作者の使い方に揃えて切り抜いたもので、塊はありません。本記事では、これを「正規化あり」と呼びます。
ComfyUI のノードと作者の推論例で違う所
BiRefNet の作者が示す使い方は、Hugging Face の README にある推論の例です。画像を 1024 x 1024 に縮めた後、色の値から平均を引いて標準偏差で割り、値の範囲を揃えて(正規化して)からモデルに渡します。平均と標準偏差には、ImageNet という画像の集まりで求めた値を使います。
ComfyUI のノードも 1024 x 1024 に縮めますが、設定ファイルの birefnet.json では平均が 0、標準偏差が 1 です。0 を引いて 1 で割っても値は変わらないので、正規化をしないのと同じです。
手元で再現して比べる
手元のスクリプトは、ComfyUI がモデルを読み込む処理と前処理をそのまま使い、平均と標準偏差だけを変えます。画像の縮め方や、出力を元の大きさへ戻す方法は ComfyUI のままで、作者の推論例とは揃えていません。まず、平均 0・標準偏差 1 で作ったマスクがノードのマスクと同じになるかを確認しました。全 18 枚で、差は 0〜255 の 1 段以内でした。手元のスクリプトはノードを再現できているので、作者の値に変えた時の違いは正規化だけから来ます。
残った塊の量は、次の節で使う「色で消す処理」が消した画素の数で比べます。上の 1 枚では、ノードで 1906 個、正規化ありで 98 個でした。茶髪の 9 枚では、どれも正規化ありで少なく、44〜214 個でした。銀髪の 9 枚では、どちらが少ないかは絵によって入れ替わりました。
人物の内側もわずかに透ける
もう 1 つの違いは、人物の内側の α です。完全に不透明(255)な画素は、人物の画素のうち、ノードで 4〜11%、正規化ありで 90〜94% でした。ノードでは人物の大部分が 250〜254 で、わずかに透けています。ただし、人物の内側を黒と白の背景へ置いて比べた差は、ノードで ΔE 0.34〜0.42 で、見た目には分かりません。α が 255 かどうかで不透明を判定する処理に渡す時だけ、気にすれば十分です。
なお、ComfyUI がなぜ正規化を外しているのかは、コードと履歴からは分かりませんでした。今回の 18 枚では、目で見て困る違いは上の 1 枚の塊だけです。
残った背景を色で消すと、髪も消える
残った塊を直す方法として、画像編集ソフトの自動選択と同じ考え方があります。背景色に近い不透明な画素をつながりでたどって選び、透明な所につながる塊だけを消す方法です。今回は次のように実装しました(lab/measure.py の抜粋)。
def leftover(rgb, alpha, back, tol=10.0):
"""色で消す処理の対象: B に近い不透明な画素の塊のうち、透明な所に接するもの。"""
d = np.linalg.norm(lab(rgb) - lab(back[None, None]), axis=-1)
near = (alpha >= 0.5) & (d < tol)
clear = ndimage.binary_dilation(alpha < 0.5, iterations=1)
labels, _ = ndimage.label(near, structure=np.ones((3, 3)))
keep = np.unique(labels[near & clear])
return np.isin(labels, keep[keep > 0])
def clean(alpha, rgb, back):
"""取り残しを透明にしたαを返す(2 回かける。1 回目で開いた穴の奥の塊も透明な所に接するようになる)。"""
a = alpha.copy()
for _ in range(2):
a[leftover(rgb, a, back)] = 0
return a
背景色との ΔE が 10 未満で α が 0.5 以上の画素を塊に分け、透明な所に接する塊を消します。ここでの透明な所は、α が 0.5 未満の所を 1 画素広げた範囲です。透明な所に接することを条件にするのは、人物の中にある、背景と同じ色の所まで消さないためです。
以下は、背景色に近い不透明な画素に色を付けた図です。赤は透明な所につながる塊で、この処理が消す所です。水色はつながらない塊のうち 50 画素以上のもので、消されずに残ります。

背景色に近い不透明な画素。赤は消される塊、水色は透明な所につながらず残る塊(シード 20261116 と 20261115)
水色の画素は、取り残しではなく人物の一部でした。左の絵では白いシャツが、右の絵では銀髪の影が、背景と同じ色です。つまり、色だけでは背景と人物を見分けられません。右の絵では、消される赤に、髪を外から縁取る部分が入っています。
消した後の銀髪
白い背景で描いた銀髪で、色で消す前と後を並べました。この絵の背景は、白ではなくクリーム色に塗られています。

白い背景で描いた銀髪で、色で消す前と後。黄色い丸は消えずに残った塊(シード 20261116)
上の巻き毛の内側にあった塊は消えました。一方で、髪の明るい筋に沿って点の列が開きました。ハイライトの色が背景のクリーム色に近く、透明な所に接していたので、取り残しと一緒に消えました。黄色い丸の塊は残りました。毛に囲まれていて、透明な所に接していないからです。
消えた画素の数は、茶髪で 1 枚 90〜1906 個、銀髪で 337〜3012 個でした。茶髪で消えたのは毛の間にある取り残しで、図で確認した限り髪は欠けていません。銀髪では、消えた画素に髪の縁とハイライトが混ざります。銀髪の中では、髪から遠い緑の背景で描いた 3 枚が最も少なく、337〜575 個でした。人物に背景と近い色がある時は、色で消すと人物も欠け、人物の中に閉じた取り残しには届きません。
透明にした所に残る色
ComfyUI で透過 PNG まで作り、緑の背景で描いた 1 枚の中身を読みました。つなぎ方は LoadImage → RemoveBackground(LoadBackgroundRemovalModel から重みを受け取る)→ InvertMask → JoinImageWithAlpha → SaveImage です。α が 0 の画素は 78.7 万個で、その RGB の中央値は (123, 171, 141) でした。描いた時の緑がそのまま残っています。

ComfyUI で作った透過 PNG。透明度を捨てると、描いた時の緑が戻る(シード 20261115)
透明度を読まない処理に渡すと、この緑が戻ります。例えば、JPEG に変える時です。生成の時に見本として渡す参照画像として、ComfyUI の LoadImage で読む時も同じです。IP-Adapter Color Cast のノートでは、背景を除去した参照の透明な画素の下に黒が残っていて、黒い背景の絵として読まれました。背景を除去した絵を次の処理へ渡す前に、透明な所の RGB は意図した色で塗り直しておきます。
背景を除去する前と後に見ること
作業の順に、ここまでの結果を並べ直します。
描く時の背景
背景は無地にします。無地なら色 B が 1 つに決まり、縁から差し引けます。色は、人物の髪や服から遠い色を選びます。銀髪のような明るい髪で白や灰色の背景を選ぶと、色で消す処理が髪まで消します。緑のように遠い色を選んだ時は、縁に緑が残るので、置く前に B を差し引きます。ただし銀髪では、差し引いても縁のずれはほとんど下がりませんでした。
切り抜いた後の確認
切り抜いた絵は、暗い背景と明るい背景の両方に置いて見ます。表紙の白い縁は、白い背景に置くと見えません。ComfyUI のノードで切り抜いた絵は、頭の後ろや毛の間に塊が残っていないかを確認します。気になる時は、正規化を作者の使い方に揃えたマスクと比べます。
色で消す処理
色で消す処理は、人物に背景と近い色が無い時だけ使います。白いシャツや銀髪のように近い色がある時は、髪が欠けます。毛に囲まれた取り残しは色で消せないので、マスクを手で直すか、描く時の背景色を変えて描き直します。
条件とファイル
- 生成: ComfyUI 0.35.0、WAI-illustrious-SDXL v17.0(LoRA なし)、xinsir の union ControlNet(depth、強さ 0.7、end 0.6)、steps 28、CFG 5.5、832 x 1216。
- 背景除去: birefnet.safetensors(sha256 9ab37426…、ZhengPeng7/BiRefNet の model.safetensors と同じ)。
- プロンプト・ネガティブプロンプト・シード・モデルの入手先は lab/README.txt に書いた。
- 絵ごとの数値は lab/metrics.json。
- 図は figures.py で作った。