Add cross-model veto of hair-gap background residue (default on)

Colour-drifted background trapped between hair strands defeats every
single-signal defence: the chroma key reads it as foreground (bgc ~0.07),
the segmenter backs it, ViTMatte rates it opaque, and post-hoc removal is
a proven dead end (it shreds the hair volume the same pixels belong to).
A second, trimap-free matting model (BiRefNet_HR-matting) is the only
tested model that separates this residue from the subject, so its opinion
is fused in as a veto: min-fusion that may only LOWER alpha, restricted to
the background-hued bright suspect zone (proj >= 3, L >= 45, feathered)
and gated by primary-alpha confidence (0.70 -> 0.95 ramp) so soft wisps
and dark hair are exempt by construction.

- settings/config/CLI: cross_check block, --cross-check/--no-cross-check
- alpha_post.cross_check_alpha after clean_alpha; second opinion reuses
  BiRefNetSegmenter; saved to debug as cross_check_alpha.png
- chroma.bg_hue_projection extracted and shared with the trimap
- docs: methodology.md (new), hair_gap_artifacts.md (investigation log)

Verified: cross-check ON reproduces the visually-reviewed B1gate
prototype byte-for-byte on TestImage3; --no-cross-check reproduces the
previous baseline byte-for-byte; pink-bg FixImage1 face untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:52:24 +08:00
parent 639fdf01b6
commit adbbb9d620
11 changed files with 474 additions and 15 deletions
+15
View File
@@ -39,6 +39,21 @@ def convert_color_spaces(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
return rgb_f, lab
def bg_hue_projection(
lab: np.ndarray, lab_center: tuple[float, float, float]
) -> np.ndarray:
"""Per-pixel projection of Lab chroma (a*, b*) onto the background hue direction.
Positive = displaced toward the background hue, ~0 for neutral pixels. Returns
zeros when the background is near-neutral (no meaningful hue direction).
"""
center = np.asarray(lab_center, dtype=np.float32)
mag = float(np.hypot(center[1], center[2]))
if mag < 1e-3:
return np.zeros(lab.shape[:2], dtype=np.float32)
return (lab[..., 1] * center[1] + lab[..., 2] * center[2]) / mag
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("#")