Simplify to single-segmenter pipelines; add screen_color prior; drop rescue + recolor

Strategic pivot: the dropout-fill rescue, the anime-seg fill backend, the
BiRefNet+anime-seg intersection, and the recolor pass all existed to repair
source images whose hair was already green-contaminated or broken at generation
time. Fixing the source instead (clean pastel-background generation) makes the
matte high-contrast, so that whole compensation layer is unnecessary. Remove it.

- Two pipelines via segmentation.enabled:
    false -> chroma-only (Chroma + ViTMatte)
    true  -> single segmenter (default anime-seg, switchable birefnet) -> trimap
  then ViTMatte refines, pymatting estimates foreground, despill cleans spill.

- Generalise the green-hardcoded colour logic to a screen_color prior (hex, e.g.
  "#CFEFFF"; default null = green auto-detect, byte-identical). chroma keys off
  Lab/RGB distance to the colour; despill removes chroma along its Lab direction.
  Added --screen-color CLI flag.

- Removed dead code: fill_seg_dropouts + seg_fill*/intersection params,
  SegmentationSettings.fill_backend/fill_model_name/sharpen, unsharp_mask,
  recolor.py + RecolorSettings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:33:58 +08:00
parent 87359ca304
commit 7555321cfc
10 changed files with 142 additions and 245 deletions
+42 -8
View File
@@ -52,6 +52,21 @@ def convert_color_spaces(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.nd
return rgb_f, hsv, lab
def parse_hex_color(text: str) -> tuple[float, float, float]:
"""Parse ``#RRGGBB`` (or ``RRGGBB``) into an RGB triple normalised to 0..1."""
s = text.strip().lstrip("#")
if len(s) != 6:
raise ValueError(f"screen_color must be a #RRGGBB hex string, got '{text}'")
return tuple(int(s[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
def rgb01_to_lab(color: tuple[float, float, float]) -> np.ndarray:
"""Lab (L 0..100, a/b ~ -127..127) of a single normalised-RGB colour."""
cv2 = require_cv2()
arr = np.array([[list(color)]], dtype=np.float32)
return cv2.cvtColor(arr, cv2.COLOR_RGB2LAB)[0, 0]
def initial_green_candidates(
rgb_f: np.ndarray, hsv: np.ndarray, settings: ChromaSettings
) -> np.ndarray:
@@ -71,10 +86,18 @@ def initial_green_candidates(
def estimate_background_model(
rgb: np.ndarray, settings: ChromaSettings
rgb: np.ndarray,
settings: ChromaSettings,
screen_color: tuple[float, float, float] | None = None,
) -> tuple[BackgroundModel, tuple[np.ndarray, np.ndarray, np.ndarray]]:
rgb_f, hsv, lab = convert_color_spaces(rgb)
candidates = initial_green_candidates(rgb_f, hsv, settings)
if screen_color is None:
candidates = initial_green_candidates(rgb_f, hsv, settings)
else:
# Candidates = pixels near the given colour; the border sample then refines
# the exact observed shade (compression / lighting drift) around it.
target_lab = rgb01_to_lab(screen_color)
candidates = np.linalg.norm(lab - target_lab, axis=2) <= 25.0
h, w = candidates.shape
border_candidates = candidates & _border_mask(h, w, settings.border_ratio)
@@ -82,10 +105,13 @@ def estimate_background_model(
if int(sample_mask.sum()) < settings.min_samples:
sample_mask = candidates
if int(sample_mask.sum()) < max(64, settings.min_samples // 16):
# Last-resort fallback: choose pixels with strongest green dominance.
dominance = rgb_f[..., 1] - np.maximum(rgb_f[..., 0], rgb_f[..., 2])
cutoff = np.percentile(dominance, 90.0)
sample_mask = dominance >= cutoff
# Last-resort fallback: the pixels closest to the target (or greenest).
if screen_color is None:
score = rgb_f[..., 1] - np.maximum(rgb_f[..., 0], rgb_f[..., 2])
sample_mask = score >= np.percentile(score, 90.0)
else:
dist = np.linalg.norm(lab - rgb01_to_lab(screen_color), axis=2)
sample_mask = dist <= np.percentile(dist, 10.0)
rgb_samples = rgb_f[sample_mask]
hsv_samples = hsv[sample_mask]
@@ -124,10 +150,11 @@ def compute_bg_confidence(
rgb: np.ndarray,
model: BackgroundModel | None = None,
settings: ChromaSettings | None = None,
screen_color: tuple[float, float, float] | None = None,
) -> tuple[np.ndarray, BackgroundModel]:
settings = settings or ChromaSettings()
if model is None:
model, spaces = estimate_background_model(rgb, settings)
model, spaces = estimate_background_model(rgb, settings, screen_color)
else:
spaces = convert_color_spaces(rgb)
rgb_f, hsv, lab = spaces
@@ -152,5 +179,12 @@ def compute_bg_confidence(
rgb_dist = np.linalg.norm((rgb_f - rgb_center) / rgb_sigma, axis=2)
rgb_conf = np.exp(-0.5 * (rgb_dist / 2.5) ** 2)
conf = hue_conf * sat_conf * dominance_conf * np.maximum(lab_conf, rgb_conf * 0.85)
if screen_color is None:
# Green screen: gate closeness by green hue / saturation / channel dominance.
conf = hue_conf * sat_conf * dominance_conf * np.maximum(lab_conf, rgb_conf * 0.85)
else:
# Arbitrary colour: rely on perceptual closeness to the estimated colour.
# Downstream thresholds (trimap 0.92, suppress 0.35-0.80) keep the weaker
# pastel signal from touching the seg-driven subject.
conf = np.maximum(lab_conf, rgb_conf * 0.85)
return np.clip(conf, 0.0, 1.0).astype(np.float32), model