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
+42 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import numpy as np
from .deps import require_cv2
from .settings import AlphaPostSettings
from .settings import AlphaPostSettings, CrossCheckSettings
def enforce_trimap(alpha: np.ndarray, trimap: np.ndarray) -> np.ndarray:
@@ -56,6 +56,47 @@ def suppress_alpha_by_chroma(
return np.clip(out, 0.0, 1.0)
def cross_check_alpha(
alpha: np.ndarray,
second_alpha: np.ndarray,
proj: np.ndarray,
lightness: np.ndarray,
trimap: np.ndarray,
settings: CrossCheckSettings,
) -> np.ndarray:
"""Veto falsely-confident alpha with an independent second matting opinion.
Background residue trapped between hair strands drifts in colour until the
chroma key reads it as foreground, the segmenter backs it, and the primary
matte rates it opaque -- every single-signal defence fails. A second model
that does separate it (see docs/hair_gap_artifacts.md) may pull alpha DOWN
(min-fusion; it can never raise it), and only where
- the pixel is background-hued and not dark (the suspect zone), and
- the primary alpha is high (``gate_lo -> gate_hi`` ramp): pixels the
pipeline already renders soft (outer wisps) are exempt by construction.
Inside the zone this deliberately overrides the trimap-FG clamp -- the
residue it exists to clear is mostly trimap-FG. Sure background cannot be
disturbed: min-fusion keeps alpha 0 at 0.
"""
if not settings.enabled:
return alpha
cv2 = require_cv2()
zone = (
(proj >= settings.proj_min)
& (lightness >= settings.l_min)
& (trimap != 0)
)
weight = zone.astype(np.float32)
if settings.feather_sigma > 0:
blur = cv2.GaussianBlur(weight, (0, 0), settings.feather_sigma)
weight = np.where(zone, 1.0, np.clip(blur, 0.0, 1.0)).astype(np.float32)
gate = weight * _smoothstep(alpha, settings.gate_lo, settings.gate_hi)
out = alpha * (1.0 - gate) + np.minimum(alpha, second_alpha) * gate
return np.clip(out, 0.0, 1.0).astype(np.float32)
def _remove_small_components(mask: np.ndarray, min_area: int) -> np.ndarray:
cv2 = require_cv2()
count, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
+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("#")
+2
View File
@@ -29,6 +29,7 @@ def main(
unknown_radius_ratio: float | None = typer.Option(None, "--unknown-radius-ratio", min=0.0),
fg_safe_radius_ratio: float | None = typer.Option(None, "--fg-safe-radius-ratio", min=0.0),
despill: bool | None = typer.Option(None, "--despill/--no-despill"),
cross_check: bool | None = typer.Option(None, "--cross-check/--no-cross-check", help="Second-opinion veto of background-hued residue between hair strands (default: on; costs one extra model inference)"),
trimap_mode: str | None = typer.Option(None, "--trimap-mode", help="Trimap mode (segmentation pipeline): directional | seg | directional-hard-bg"),
seg_backend: str | None = typer.Option(None, "--seg-backend", help="Segmentation backend: birefnet (default) | anime-seg"),
) -> None:
@@ -45,6 +46,7 @@ def main(
unknown_radius_ratio=unknown_radius_ratio,
fg_safe_radius_ratio=fg_safe_radius_ratio,
despill=despill,
cross_check=cross_check,
trimap_mode=trimap_mode,
seg_backend=seg_backend,
)
+7 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, TypeVar
from .settings import (
AlphaPostSettings,
ChromaSettings,
CrossCheckSettings,
DespillSettings,
ForegroundSettings,
ModelSettings,
@@ -48,7 +49,7 @@ def _update_dataclass(instance: T, values: dict[str, Any] | None) -> T:
def settings_from_dict(data: dict[str, Any]) -> PipelineSettings:
data = dict(data)
screen_color = data.pop("screen_color", None)
allowed_sections = {"chroma", "trimap", "alpha_post", "foreground", "despill", "model", "segmentation"}
allowed_sections = {"chroma", "trimap", "alpha_post", "cross_check", "foreground", "despill", "model", "segmentation"}
unknown_sections = sorted(set(data) - allowed_sections)
if unknown_sections:
raise ValueError(f"Unknown config section(s): {', '.join(unknown_sections)}")
@@ -57,6 +58,7 @@ def settings_from_dict(data: dict[str, Any]) -> PipelineSettings:
chroma=_update_dataclass(ChromaSettings(), data.get("chroma")),
trimap=_update_dataclass(TrimapSettings(), data.get("trimap")),
alpha_post=_update_dataclass(AlphaPostSettings(), data.get("alpha_post")),
cross_check=_update_dataclass(CrossCheckSettings(), data.get("cross_check")),
foreground=_update_dataclass(ForegroundSettings(), data.get("foreground")),
despill=_update_dataclass(DespillSettings(), data.get("despill")),
model=_update_dataclass(ModelSettings(), data.get("model")),
@@ -100,6 +102,9 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
despill_updates: dict[str, Any] = {}
if overrides.get("despill") is not None:
despill_updates["enabled"] = overrides["despill"]
cross_check_updates: dict[str, Any] = {}
if overrides.get("cross_check") is not None:
cross_check_updates["enabled"] = overrides["cross_check"]
seg_updates: dict[str, Any] = {}
if overrides.get("device") is not None:
seg_updates["device"] = overrides["device"]
@@ -121,6 +126,7 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
chroma=chroma,
trimap=_update_dataclass(trimap, trimap_updates),
alpha_post=alpha_post,
cross_check=_update_dataclass(settings.cross_check, cross_check_updates),
foreground=foreground,
despill=_update_dataclass(despill, despill_updates),
model=_update_dataclass(model, model_updates),
+42 -5
View File
@@ -6,13 +6,23 @@ from pathlib import Path
import numpy as np
from .alpha_post import clean_alpha, enforce_trimap, suppress_alpha_by_chroma
from .chroma import compute_bg_confidence, parse_hex_color
from .alpha_post import (
clean_alpha,
cross_check_alpha,
enforce_trimap,
suppress_alpha_by_chroma,
)
from .chroma import (
bg_hue_projection,
compute_bg_confidence,
convert_color_spaces,
parse_hex_color,
)
from .despill import despill
from .foreground import estimate_foreground_rgb
from .io import load_rgb, save_gray, save_rgb, save_rgba, write_text
from .qa import BACKGROUND_COLORS, composite, make_qa_grid, save_previews
from .settings import PipelineSettings
from .settings import PipelineSettings, SegmentationSettings
from .trimap import (
fuse_trimap,
fuse_trimap_directional,
@@ -27,6 +37,7 @@ class MattingPipeline:
self.settings = settings
self._runner: ViTMatteRunner | None = None
self._segmenter = None
self._cross_checker = None
def _segment(self, rgb: np.ndarray) -> np.ndarray:
if self._segmenter is None:
@@ -35,6 +46,20 @@ class MattingPipeline:
self._segmenter = make_segmenter(self.settings.segmentation)
return self._segmenter.mask(rgb)
def _second_opinion(self, rgb: np.ndarray) -> np.ndarray:
if self._cross_checker is None:
from .segmentation import BiRefNetSegmenter
check = self.settings.cross_check
self._cross_checker = BiRefNetSegmenter(
SegmentationSettings(
model_name=check.model_name,
device=self.settings.segmentation.device,
input_size=check.input_size,
)
)
return self._cross_checker.mask(rgb)
def _predict_alpha(self, rgb: np.ndarray, trimap: np.ndarray, bg_confidence: np.ndarray) -> tuple[np.ndarray, str]:
if self.settings.model.matting_method == "chroma":
return trimap_to_alpha_seed(trimap, bg_confidence), "chroma"
@@ -89,8 +114,6 @@ def _run_image(
if mode == "seg":
trimap, trimap_stats = fuse_trimap(seg_mask, bg_confidence, settings.trimap)
elif mode in ("directional", "directional-hard-bg"):
from .chroma import convert_color_spaces
lab = convert_color_spaces(rgb)[1]
trimap, trimap_stats = fuse_trimap_directional(
seg_mask, bg_confidence, lab, model.lab_center, settings.trimap
@@ -113,6 +136,18 @@ def _run_image(
raw_alpha=raw_alpha if alpha_source == "vitmatte" else None,
)
alpha = clean_alpha(alpha, trimap, settings.alpha_post)
second_alpha = None
if settings.cross_check.enabled:
lab = convert_color_spaces(rgb)[1]
second_alpha = pipeline._second_opinion(rgb)
alpha = cross_check_alpha(
alpha,
second_alpha,
bg_hue_projection(lab, model.lab_center),
lab[..., 0],
trimap,
settings.cross_check,
)
foreground = estimate_foreground_rgb(
rgb, alpha, bg_confidence, model, settings.foreground
)
@@ -137,6 +172,8 @@ def _run_image(
save_gray(debug / "trimap.png", trimap)
if seg_mask is not None:
save_gray(debug / "seg_mask.png", seg_mask)
if second_alpha is not None:
save_gray(debug / "cross_check_alpha.png", second_alpha)
save_gray(debug / "alpha.png", alpha)
save_rgb(debug / "foreground_rgb.png", corrected_rgb)
save_gray(debug / "color_mask.png", color_mask)
+19
View File
@@ -63,6 +63,24 @@ class AlphaPostSettings:
suppress_raw_hi: float = 0.98
@dataclass(frozen=True)
class CrossCheckSettings:
# Cross-model veto: a trimap-free matting model gives a second opinion that
# may only LOWER alpha (min-fusion), only on background-hued, non-dark pixels
# (the suspect zone) where the primary result is confident (gate_lo->gate_hi
# alpha ramp). Clears colour-drifted background residue between hair strands
# that chroma, segmentation and the primary matte all read as foreground;
# already-soft wisps and dark hair are exempt by construction.
enabled: bool = True
model_name: str = "ZhengPeng7/BiRefNet_HR-matting"
input_size: int = 2048
proj_min: float = 3.0 # bg-hue projection above which a pixel is suspect
l_min: float = 45.0 # Lab lightness below which a pixel is exempt (dark hair)
feather_sigma: float = 2.0 # Gaussian feather of the zone boundary, in px
gate_lo: float = 0.70 # primary alpha below this -> fully exempt
gate_hi: float = 0.95 # primary alpha above this -> fully vetoable
@dataclass(frozen=True)
class ForegroundSettings:
enabled: bool = True
@@ -117,6 +135,7 @@ class PipelineSettings:
chroma: ChromaSettings = ChromaSettings()
trimap: TrimapSettings = TrimapSettings()
alpha_post: AlphaPostSettings = AlphaPostSettings()
cross_check: CrossCheckSettings = CrossCheckSettings()
foreground: ForegroundSettings = ForegroundSettings()
despill: DespillSettings = DespillSettings()
model: ModelSettings = ModelSettings()
+3 -8
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import numpy as np
from .chroma import bg_hue_projection
from .deps import require_cv2
from .settings import TrimapSettings
@@ -137,14 +138,8 @@ def fuse_trimap_directional(
chroma_unknown = ~chroma_bg & ~chroma_fg
core = seg_mask >= settings.seg_core_threshold
# Directional chroma: projection of (a*, b*) onto the background chroma
# direction. Positive = colour displaced toward the background hue.
lab_c = np.asarray(lab_center, dtype=np.float32)
mag = float(np.hypot(lab_c[1], lab_c[2]))
if mag < 1e-3:
proj = np.zeros(shape, dtype=np.float32)
else:
proj = (lab[..., 1] * lab_c[1] + lab[..., 2] * lab_c[2]) / mag
# Directional chroma: positive = colour displaced toward the background hue.
proj = bg_hue_projection(lab, lab_center)
bg_hued = proj >= settings.bg_hue_proj_min
bg = chroma_bg | ((seg_mask < settings.seg_low) & ~chroma_fg)