Merge master: matte-confidence gate + cross-model hair-gap veto

Brings in the two algorithm optimisations from master:
- Gate chroma suppression by the matte's own confidence (639fdf0)
- Add cross-model veto of hair-gap background residue (adbbb9d)

Server-edition integration on top of the plain merge:
- PipelineManager caches the cross-check model (BiRefNet_HR-matting) and
  injects it into MattingPipeline, so HTTP requests never reload it.
- MattingResult carries cross_check_alpha; debug output matches master.
- /healthz reports crossCheck; README lists models/BiRefNet_HR-matting
  in the local-weights bundle.

Verified: CLI --no-cross-check baseline and full cross-check run both
pass on samples/TestImage3.png; cross_check_alpha.png written to debug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:37:07 +08:00
13 changed files with 547 additions and 22 deletions
+53 -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:
@@ -23,6 +23,7 @@ def suppress_alpha_by_chroma(
bg_confidence: np.ndarray,
trimap: np.ndarray,
settings: AlphaPostSettings,
raw_alpha: np.ndarray | None = None,
) -> np.ndarray:
"""Pull alpha toward 0 where background-colour confidence is high.
@@ -30,6 +31,12 @@ def suppress_alpha_by_chroma(
pixels are never thinned. Background colour that survived in hair gaps / hole
pockets becomes transparent; this also clears the colour fringe that
over-unmixing leaves on those low-alpha pixels, since alpha ~ 0 hides it.
``raw_alpha`` is the matting model's own prediction before any constraint.
When given, suppression fades where the model is confident the pixel is
opaque (ramp ``suppress_raw_lo -> suppress_raw_hi``): colour evidence may
only veto the matte where the matte is unsure. Background pockets it rates
low stay suppressible; a background-coloured subject it rates ~1 is kept.
"""
if not settings.chroma_suppress:
return alpha
@@ -38,6 +45,10 @@ def suppress_alpha_by_chroma(
settings.chroma_suppress_bg_low,
settings.chroma_suppress_bg_high,
)
if raw_alpha is not None and settings.suppress_raw_lo < 1.0:
suppress = suppress * (
1.0 - _smoothstep(raw_alpha, settings.suppress_raw_lo, settings.suppress_raw_hi)
)
factor = 1.0 - settings.chroma_suppress_strength * suppress
out = alpha.astype(np.float32).copy()
band = trimap == 128
@@ -45,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),
+53 -8
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,
@@ -37,6 +47,7 @@ class MattingResult:
bg_confidence: np.ndarray
trimap: np.ndarray
seg_mask: np.ndarray | None
cross_check_alpha: np.ndarray | None
color_mask: np.ndarray
input_rgb: np.ndarray
@@ -48,12 +59,14 @@ class MattingPipeline:
*,
runner: ViTMatteRunner | None = None,
segmenter=None,
cross_checker=None,
):
self.settings = settings
# Pre-loaded models can be injected so a long-lived service shares them
# across requests instead of reloading per call; None = lazy-load on first use.
self._runner: ViTMatteRunner | None = runner
self._segmenter = segmenter
self._cross_checker = cross_checker
def _segment(self, rgb: np.ndarray) -> np.ndarray:
if self._segmenter is None:
@@ -62,6 +75,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"
@@ -114,8 +141,6 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
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
@@ -128,11 +153,28 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
else:
seg_mask = None
trimap, trimap_stats = generate_trimap(bg_confidence, settings.trimap)
alpha, alpha_source = pipeline._predict_alpha(rgb, trimap, bg_confidence)
raw_alpha, alpha_source = pipeline._predict_alpha(rgb, trimap, bg_confidence)
alpha = enforce_trimap(alpha, trimap)
alpha = suppress_alpha_by_chroma(alpha, bg_confidence, trimap, settings.alpha_post)
alpha = enforce_trimap(raw_alpha, trimap)
alpha = suppress_alpha_by_chroma(
alpha, bg_confidence, trimap, settings.alpha_post,
# The matte-confidence gate only makes sense for a real matting prediction;
# a chroma-seeded alpha is itself colour evidence, so no gate there.
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
)
@@ -151,6 +193,7 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
bg_confidence=bg_confidence,
trimap=trimap,
seg_mask=seg_mask,
cross_check_alpha=second_alpha,
color_mask=color_mask,
input_rgb=rgb,
)
@@ -174,6 +217,8 @@ def _run_image(
save_gray(debug / "trimap.png", res.trimap)
if res.seg_mask is not None:
save_gray(debug / "seg_mask.png", res.seg_mask)
if res.cross_check_alpha is not None:
save_gray(debug / "cross_check_alpha.png", res.cross_check_alpha)
save_gray(debug / "alpha.png", res.alpha)
save_rgb(debug / "foreground_rgb.png", res.rgb)
save_gray(debug / "color_mask.png", res.color_mask)
+36 -3
View File
@@ -8,8 +8,8 @@ import numpy as np
from .config import _SEG_MODELS, override_settings
from .pipeline import MattingPipeline, MattingResult
from .segmentation import make_segmenter
from .settings import PipelineSettings
from .segmentation import BiRefNetSegmenter, make_segmenter
from .settings import PipelineSettings, SegmentationSettings
from .vitmatte_infer import ViTMatteRunner
logger = logging.getLogger("bgfilter.service")
@@ -33,12 +33,15 @@ class PipelineManager:
self._base = base_settings
self._runner: ViTMatteRunner | None = None
self._segmenters: dict[str, object] = {}
self._cross_checker: BiRefNetSegmenter | None = None
self._load_lock = threading.Lock()
if preload:
if base_settings.model.matting_method == "vitmatte":
self._get_runner()
if base_settings.segmentation.enabled:
self._get_segmenter(base_settings.segmentation.backend)
if base_settings.cross_check.enabled:
self._get_cross_checker()
@property
def default_seg_model(self) -> str:
@@ -48,6 +51,10 @@ class PipelineManager:
def device(self) -> str:
return self._base.model.device
@property
def cross_check_enabled(self) -> bool:
return self._base.cross_check.enabled
def _get_runner(self) -> ViTMatteRunner:
if self._runner is None:
with self._load_lock:
@@ -81,6 +88,27 @@ class PipelineManager:
self._segmenters[backend] = seg
return seg
def _get_cross_checker(self) -> BiRefNetSegmenter:
# The cross-check model depends only on base settings (never on per-request
# params), so one shared instance serves all requests.
if self._cross_checker is None:
with self._load_lock:
if self._cross_checker is None:
check = self._base.cross_check
logger.info(
"loading cross-check model: model=%s device=%s",
check.model_name,
self._base.segmentation.device,
)
self._cross_checker = BiRefNetSegmenter(
SegmentationSettings(
model_name=check.model_name,
device=self._base.segmentation.device,
input_size=check.input_size,
)
)
return self._cross_checker
def process(
self, rgb: np.ndarray, *, screen_color: str | None, seg_model: str
) -> MattingResult:
@@ -97,5 +125,10 @@ class PipelineManager:
segmenter = (
self._get_segmenter(seg_model) if settings.segmentation.enabled else None
)
pipeline = MattingPipeline(settings, runner=runner, segmenter=segmenter)
cross_checker = (
self._get_cross_checker() if settings.cross_check.enabled else None
)
pipeline = MattingPipeline(
settings, runner=runner, segmenter=segmenter, cross_checker=cross_checker
)
return pipeline.run_rgb(rgb)
+26
View File
@@ -54,6 +54,31 @@ class AlphaPostSettings:
chroma_suppress_bg_low: float = 0.35
chroma_suppress_bg_high: float = 0.80
chroma_suppress_strength: float = 1.0
# Matte-confidence gate: fade the suppression where ViTMatte itself is confident
# the pixel is opaque (raw alpha ramps lo -> hi), so colour evidence only vetoes
# the matte where the matte is unsure. Protects background-coloured subjects
# (white shirt on pastel blue, blush on pink) that chroma alone cannot tell from
# background residue. Set suppress_raw_lo >= 1.0 to disable the gate.
suppress_raw_lo: float = 0.85
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)
@@ -110,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)