Run the cross-check veto in complex-background mode with a hue-free gate

The veto's suspect zone was defined by background-hue projection, so
--no-chroma silently skipped it. Without a key colour there is no hue
test to lean on, so in complex mode the gate instead requires the second
opinion itself to be confidently near-empty (second_lo -> second_hi ramp,
default 0.15 -> 0.40): regions the HR-matting model decisively rejects
can be cleared, while thin strands it merely blurs to mid-alpha are
untouched -- protecting exactly the crisp-strand advantage the pipeline
has over a raw BiRefNet mask. Flat mode's gate is unchanged.

Validation (BG_IMAGE01-04, cuda fp32): flat default path bit-identical;
complex-mode veto touches 0.007-0.025% of pixels, visibly clearing
blurred residue near strands and milky specks in hair gaps with no
strand erosion. Costs the HR-matting forward in --no-chroma runs
(0.61 -> 1.6 s/image warm GPU); disable with --no-cross-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:09:57 +08:00
parent e61fbe84f0
commit 2b3b62ed3f
5 changed files with 48 additions and 25 deletions
+8 -6
View File
@@ -65,12 +65,14 @@ Pass `--no-chroma` (or `chroma.enabled: false`) to matte images whose background
**not** one flat colour — gradients, textures, scenes. There is no colour key in
this mode: the segmentation mask alone drives the trimap (mode forced to `seg`),
ViTMatte still refines the unknown band at full resolution, and pymatting still
estimates edge foreground colour. Every colour-keyed stage is bypassed: background
auto-detection, the directional hue split, chroma alpha suppression, despill, and
the cross-check veto (with `--cross-check-as-seg` the second model still provides
the segmentation mask). Requires the segmentation pipeline; quality then rests
entirely on the segmenter's mask, so expect flat-background results to stay
stronger on hair-level detail.
estimates edge foreground colour. The colour-keyed stages are bypassed: background
auto-detection, the directional hue split, chroma alpha suppression, and despill.
The cross-model veto still runs (disable with `--no-cross-check`): without a key
colour its suspect zone drops the hue test and instead only vetoes where the
second opinion is itself confidently near-empty (`cross_check.second_lo/hi`), so
thin strands the second model merely blurs are never eroded. Requires the
segmentation pipeline; quality then rests entirely on the segmenter's mask, so
expect flat-background results to stay stronger on hair-level detail.
## Background colour
+13 -6
View File
@@ -59,7 +59,7 @@ def suppress_alpha_by_chroma(
def cross_check_alpha(
alpha: np.ndarray,
second_alpha: np.ndarray,
proj: np.ndarray,
proj: np.ndarray | None,
lightness: np.ndarray,
trimap: np.ndarray,
settings: CrossCheckSettings,
@@ -76,6 +76,13 @@ def cross_check_alpha(
- the primary alpha is high (``gate_lo -> gate_hi`` ramp): pixels the
pipeline already renders soft (outer wisps) are exempt by construction.
``proj`` is None in complex-background mode (chroma disabled): no key colour
means no hue-defined suspect zone, so instead the veto requires the second
opinion itself to be confidently near-empty (full strength at/below
``second_lo``, none at/above ``second_hi``). A thin strand the downsampled
second model merely blurs to mid-alpha is left untouched; only regions it
decisively rejects can be cleared.
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.
@@ -83,16 +90,16 @@ def cross_check_alpha(
if not settings.enabled:
return alpha
cv2 = require_cv2()
zone = (
(proj >= settings.proj_min)
& (lightness >= settings.l_min)
& (trimap != 0)
)
zone = (lightness >= settings.l_min) & (trimap != 0)
if proj is not None:
zone &= proj >= settings.proj_min
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)
if proj is None:
gate = gate * (1.0 - _smoothstep(second_alpha, settings.second_lo, settings.second_hi))
out = alpha * (1.0 - gate) + np.minimum(alpha, second_alpha) * gate
return np.clip(out, 0.0, 1.0).astype(np.float32)
+4 -4
View File
@@ -199,16 +199,16 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
raw_alpha=raw_alpha if alpha_source == "vitmatte" else None,
)
alpha = clean_alpha(alpha, trimap, settings.alpha_post)
# The veto's suspect zone is defined by background hue, so it needs the key
# colour; without chroma the second opinion still serves as seg (reuse above).
if settings.cross_check.enabled and settings.chroma.enabled:
if settings.cross_check.enabled:
lab = convert_color_spaces(rgb)[1]
if second_alpha is None:
second_alpha = pipeline._second_opinion(rgb)
alpha = cross_check_alpha(
alpha,
second_alpha,
bg_hue_projection(lab, model.lab_center),
# No key colour in complex-background mode: proj=None switches the
# veto to its second-opinion-confidence gate (see cross_check_alpha).
bg_hue_projection(lab, model.lab_center) if model is not None else None,
lab[..., 0],
trimap,
settings.cross_check,
+13 -6
View File
@@ -6,12 +6,12 @@ from dataclasses import dataclass
@dataclass(frozen=True)
class ChromaSettings:
# False = non-flat-background mode (--no-chroma): no colour key at all.
# Segmentation alone drives the trimap (mode forced to "seg"), and every
# colour-keyed stage is bypassed: background auto-detect, the directional
# hue split, chroma alpha suppression, despill, the cross-check veto (its
# suspect zone is background-hued by definition; with reuse_as_seg the
# second opinion still serves as the segmenter), and the unmix foreground
# fallback. Requires segmentation.enabled and matting_method 'vitmatte'.
# Segmentation alone drives the trimap (mode forced to "seg"), and the
# colour-keyed stages are bypassed: background auto-detect, the directional
# hue split, chroma alpha suppression, despill, and the unmix foreground
# fallback. The cross-check veto still runs (if enabled) with a hue-free
# gate -- see CrossCheckSettings.second_lo/hi. Requires
# segmentation.enabled and matting_method 'vitmatte'.
enabled: bool = True
border_ratio: float = 0.04
min_samples: int = 2048
@@ -107,6 +107,13 @@ class CrossCheckSettings:
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
# Complex-background mode only (chroma disabled -> no hue-defined suspect
# zone): the veto instead requires the second opinion itself to be
# confidently near-empty -- full strength at/below second_lo, none at/above
# second_hi. Thin structures the downsampled second model merely blurs to
# mid-alpha stay untouched; only decisively-rejected regions can be cleared.
second_lo: float = 0.15
second_hi: float = 0.40
@dataclass(frozen=True)
+10 -3
View File
@@ -5,9 +5,10 @@ screen_color: null
chroma:
# false = non-flat background mode (--no-chroma): no colour key; segmentation
# alone drives the trimap (mode forced to "seg") and every colour-keyed stage
# is bypassed (auto-detect, hue split, chroma suppression, despill, the
# cross-check veto). Needs segmentation.enabled and matting_method vitmatte.
# alone drives the trimap (mode forced to "seg") and the colour-keyed stages
# are bypassed (auto-detect, hue split, chroma suppression, despill). The
# cross-check veto still runs if enabled, using its hue-free gate (see
# cross_check.second_lo/hi). Needs segmentation.enabled + matting vitmatte.
enabled: true
model:
@@ -98,6 +99,12 @@ cross_check:
feather_sigma: 2.0 # zone-boundary feather, px
gate_lo: 0.70 # primary alpha below this -> fully exempt
gate_hi: 0.95 # primary alpha above this -> fully vetoable
# Complex-background (no-chroma) mode only: no hue-defined suspect zone, so
# the veto requires the second opinion itself to be confidently near-empty --
# full strength at/below second_lo, fading to none at/above second_hi. Thin
# strands the 2048-input second model merely blurs stay untouched.
second_lo: 0.15
second_hi: 0.40
foreground:
enabled: true