Files
MariusKMandClaude Opus 4.7 e353aae491 Add Color Match, Chroma Clean, and Tiled Background Removal nodes
Ports four nodes from the EmpireTitan pack into the Badman naming
convention: Color Match (with preset save/load + live preview), Color
Match Combine Presets, Chroma Clean matte refinement, and a tiled
BiRefNet/RMBG wrapper that recovers fine-edge detail. Updates README,
bumps version to 1.3.0, adds .gitignore, and fixes a stale display-name
key for IO Config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:41:32 +02:00

166 lines
6.3 KiB
Python

"""
Chroma Clean node for ComfyUI (Badman).
Refines a primary matte (from BiRefNet / RMBG / similar segmentation nets)
for a known-color backdrop. Two operations in one pass:
1. Chroma punch-out — pixels inside the matte that are actually the
backdrop color (LAB distance below threshold) are removed. Fixes the
classic "segmentation net filled in a cavity" failure, e.g. green
leaking through a hole between hair strands.
2. Despill — limits the dominant channel of the backdrop color so it
cannot exceed the other two. Kills the color tint on edge halos
where the backdrop bled into the subject.
Input mask convention: 1=foreground, 0=background (BiRefNet / RMBG default).
Flip invert_input_mask if wiring from LoadImage's alpha instead.
"""
from __future__ import annotations
import numpy as np
import torch
import cv2
def _hex_to_rgb(hex_str: str, default=(0, 254, 0)) -> tuple[int, int, int]:
s = (hex_str or "").strip().lstrip("#")
try:
if len(s) == 3:
s = "".join(c * 2 for c in s)
if len(s) != 6:
return default
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
except ValueError:
return default
def _bg_similarity_lab(rgb_u8: np.ndarray, bg_rgb_u8: tuple[int, int, int],
threshold: float, feather: float) -> np.ndarray:
"""
Per-pixel 0..1 where 1 = pixel is essentially bg_color, 0 = far from it.
Distance measured in LAB so "how close to the backdrop" is perceptual
rather than raw-RGB (which would over-count hue and under-count luminance).
dist <= threshold -> 1
threshold < dist < threshold + feather -> linear ramp
dist >= threshold + feather -> 0
"""
lab = cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
bg_patch = np.array([[bg_rgb_u8]], dtype=np.uint8)
bg_lab = cv2.cvtColor(bg_patch, cv2.COLOR_RGB2LAB).astype(np.float32)[0, 0]
diff = lab - bg_lab
dist = np.sqrt((diff * diff).sum(axis=-1))
ramp = max(float(feather), 1e-3)
return np.clip((threshold + ramp - dist) / ramp, 0.0, 1.0)
def _despill_channel_limit(rgb_f: np.ndarray, bg_rgb_u8: tuple[int, int, int],
strength: float) -> np.ndarray:
"""
Standard channel-limit despill: where the dominant backdrop channel
exceeds the other two, pull it down toward that ceiling.
Applied unconditionally — a character pixel whose dominant channel
matches the backdrop's will also be dampened. Drop strength toward 0
on characters whose palette overlaps the backdrop hue.
"""
if strength <= 0:
return rgb_f
dom = int(np.argmax(bg_rgb_u8)) # 0=R, 1=G, 2=B
others = [i for i in range(3) if i != dom]
other_max = np.maximum(rgb_f[..., others[0]], rgb_f[..., others[1]])
excess = np.maximum(rgb_f[..., dom] - other_max, 0.0)
out = rgb_f.copy()
out[..., dom] = rgb_f[..., dom] - float(strength) * excess
return out
class ChromaCleanNode:
"""Punch out chroma leaks inside a matte and despill color contamination."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"alpha_mask": ("MASK",),
"bg_color": ("STRING", {
"default": "#00FE00",
"tooltip": "Hex of the backdrop color to remove (e.g. #00FE00 greenscreen, #0047AB bluescreen).",
}),
"chroma_threshold": ("FLOAT", {
"default": 28.0, "min": 0.0, "max": 120.0, "step": 0.5,
"tooltip": "LAB distance: pixels closer than this to bg_color are treated as backdrop.",
}),
"chroma_feather": ("FLOAT", {
"default": 12.0, "min": 0.1, "max": 60.0, "step": 0.5,
"tooltip": "Width of the soft ramp above threshold. Higher = softer edge.",
}),
"despill_strength": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "0 = off. 1 = clamp dominant bg channel to max of the other two.",
}),
"invert_input_mask": ("BOOLEAN", {
"default": False,
"tooltip": "BiRefNet/RMBG use 1=foreground (leave OFF). LoadImage alpha uses 1=background (turn ON).",
}),
"invert_output_mask": ("BOOLEAN", {
"default": False,
"tooltip": "Output as 1=foreground (OFF) or 1=background (ON, for chaining into Color Match).",
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "MASK")
RETURN_NAMES = ("image", "mask", "chroma_mask")
FUNCTION = "run"
CATEGORY = "Badman/matte"
def run(self, image, alpha_mask, bg_color, chroma_threshold, chroma_feather,
despill_strength, invert_input_mask, invert_output_mask):
bg_rgb = _hex_to_rgb(bg_color)
imgs = image.detach().cpu().numpy() # [B, H, W, 3] float 0..1, RGB
b, h, w, _ = imgs.shape
m = alpha_mask.detach().cpu().numpy()
if m.ndim == 2:
m = m[None]
out_img = np.empty_like(imgs)
out_mask = np.empty((b, h, w), dtype=np.float32)
out_chroma = np.empty((b, h, w), dtype=np.float32)
for i in range(b):
rgb_f = imgs[i]
rgb_u8 = np.clip(rgb_f * 255.0 + 0.5, 0, 255).astype(np.uint8)
mi = m[i] if i < m.shape[0] else m[-1]
mi = mi.astype(np.float32)
if mi.shape != (h, w):
mi = cv2.resize(mi, (w, h), interpolation=cv2.INTER_LINEAR)
fg = (1.0 - mi) if invert_input_mask else mi
fg = np.clip(fg, 0.0, 1.0)
sim = _bg_similarity_lab(rgb_u8, bg_rgb, chroma_threshold, chroma_feather)
refined = fg * (1.0 - sim)
despilled = _despill_channel_limit(rgb_f, bg_rgb, despill_strength)
out_img[i] = despilled
out_mask[i] = refined
out_chroma[i] = sim
if invert_output_mask:
out_mask = 1.0 - out_mask
return (
torch.from_numpy(np.ascontiguousarray(out_img.astype(np.float32))),
torch.from_numpy(out_mask),
torch.from_numpy(out_chroma),
)