Add complex-background matting mode to the service via background_mode

Backports master's non-flat matting (chroma.enabled: false + the hue-free
cross-check gate) into server-edition, and exposes it over HTTP without
surfacing the internal "chroma" wording: /remove-background gains a
background_mode form field (flat, default | complex). complex maps to
chroma disabled -- no colour key, segmentation alone drives the trimap and
every colour-keyed stage (auto-detect, hue split, chroma suppression,
despill) is bypassed. The cross-check veto still works in complex mode via
its second-opinion-confidence gate (cross_check.second_lo/hi) but defaults
OFF there (it costs the HR-matting forward); an explicit cross_check=on
re-enables it.

No new model weights: complex mode reuses the already-provisioned BiRefNet
seg + ViTMatte (+ optional HR-matting cross-check). Flat mode is unchanged
(bit-identical), and server-edition's own extras (cross_check.lock,
foreground.use_gpu CuPy path) are preserved -- the port is surgical, not a
copy of master's files.

- settings: ChromaSettings.enabled, CrossCheckSettings.second_lo/hi
- config: override_settings chroma passthrough
- despill/foreground: model=None safe guards (foreground keeps GPU path)
- alpha_post: cross_check_alpha hue-free gate when proj is None
- pipeline: _process_rgb complex branch (seg-only trimap, skip colour stages)
- service/app: process(chroma=), background_mode field, complex-defaults-off
  cross-check, X-BGFilter-Background-Mode header
- cli: --chroma/--no-chroma
- configs/docs: gpu.yaml + default.yaml + README/README_ZH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:04:58 +08:00
parent 28d3fe8842
commit c56e58affe
13 changed files with 166 additions and 33 deletions
+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)
+2
View File
@@ -24,6 +24,7 @@ def main(
device: str | None = typer.Option(None, "--device"),
precision: str | None = typer.Option(None, "--precision", help="Compute precision for all models (ViTMatte cast + BiRefNet autocast): fp32 (default) | bf16 (faster + halves matting activation memory; needs bf16-capable hardware, else falls back to fp32)"),
screen_color: str | None = typer.Option(None, "--screen-color", help="Background colour prior as #RRGGBB (default: auto-detect the flat background colour)"),
chroma: bool | None = typer.Option(None, "--chroma/--no-chroma", help="Flat-colour background mode (default: on). --no-chroma is non-flat/complex-background mode: no colour key, segmentation drives the trimap; needs a segmentation backend + matting_method vitmatte"),
matting_method: str | None = typer.Option(None, "--matting-method"),
fallback_to_chroma_alpha: bool | None = typer.Option(None, "--fallback-to-chroma-alpha/--no-fallback-to-chroma-alpha"),
sure_bg_threshold: float | None = typer.Option(None, "--sure-bg-threshold", min=0.0, max=1.0),
@@ -43,6 +44,7 @@ def main(
device=device,
precision=precision,
screen_color=screen_color,
chroma=chroma,
matting_method=matting_method,
fallback_to_chroma_alpha=fallback_to_chroma_alpha,
sure_bg_threshold=sure_bg_threshold,
+4 -1
View File
@@ -99,6 +99,9 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
}
if overrides.get("trimap_mode") is not None:
trimap_updates["mode"] = overrides["trimap_mode"]
chroma_updates: dict[str, Any] = {}
if overrides.get("chroma") is not None:
chroma_updates["enabled"] = overrides["chroma"]
despill_updates: dict[str, Any] = {}
if overrides.get("despill") is not None:
despill_updates["enabled"] = overrides["despill"]
@@ -130,7 +133,7 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
else settings.screen_color
)
return PipelineSettings(
chroma=chroma,
chroma=_update_dataclass(chroma, chroma_updates),
trimap=_update_dataclass(trimap, trimap_updates),
alpha_post=alpha_post,
cross_check=_update_dataclass(settings.cross_check, cross_check_updates),
+3 -2
View File
@@ -10,7 +10,7 @@ from .settings import DespillSettings
def despill(
rgb: np.ndarray,
alpha: np.ndarray,
model: BackgroundModel,
model: BackgroundModel | None,
settings: DespillSettings,
) -> tuple[np.ndarray, np.ndarray]:
"""Remove background-colour spill from the foreground.
@@ -24,7 +24,8 @@ def despill(
Returns the corrected RGB (uint8) and the per-pixel despill weight (float32).
"""
if not settings.enabled:
# model is None with chroma disabled: no key colour means no spill direction.
if not settings.enabled or model is None:
return rgb.copy(), np.zeros(alpha.shape, dtype=np.float32)
bg_lab = np.asarray(model.lab_center, dtype=np.float32)
+7 -2
View File
@@ -15,7 +15,7 @@ def estimate_foreground_rgb(
rgb: np.ndarray,
alpha: np.ndarray,
bg_confidence: np.ndarray,
model: BackgroundModel,
model: BackgroundModel | None,
settings: ForegroundSettings,
) -> np.ndarray:
"""Estimated foreground colour F (uint8 HxWx3) for compositing over alpha."""
@@ -27,10 +27,15 @@ def estimate_foreground_rgb(
try:
return _estimate_ml(rgb, alpha, settings)
except RuntimeError:
if not settings.fallback_to_unmix:
if not settings.fallback_to_unmix or model is None:
raise
method = "unmix"
if method == "unmix":
if model is None:
raise RuntimeError(
"Foreground method 'unmix' needs the chroma background model, which "
"does not exist with chroma disabled; use method 'ml'."
)
return _estimate_unmix(rgb, alpha, bg_confidence, model, settings)
raise RuntimeError(
f"Unsupported foreground method '{settings.method}'. Use 'ml' or 'unmix'."
+34 -13
View File
@@ -132,10 +132,26 @@ def run_image(
def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
settings = pipeline.settings
screen_color = parse_hex_color(settings.screen_color) if settings.screen_color else None
bg_confidence, model = compute_bg_confidence(
rgb, settings=settings.chroma, screen_color=screen_color
)
if settings.chroma.enabled:
screen_color = parse_hex_color(settings.screen_color) if settings.screen_color else None
bg_confidence, model = compute_bg_confidence(
rgb, settings=settings.chroma, screen_color=screen_color
)
else:
if not settings.segmentation.enabled:
raise RuntimeError(
"chroma.enabled: false (non-flat background mode) needs the segmentation "
"pipeline; enable segmentation or re-enable chroma."
)
if settings.model.matting_method == "chroma":
raise RuntimeError(
"matting_method 'chroma' needs the chroma key; use 'vitmatte' or re-enable chroma."
)
# Non-flat background: no colour key exists. A zero background-confidence
# map makes every chroma-fused formula degrade to its seg-only form; the
# colour-keyed stages (hue split, suppression, veto, despill) are skipped.
bg_confidence = np.zeros(rgb.shape[:2], dtype=np.float32)
model = None
second_alpha = None
if settings.segmentation.enabled:
reuse = (
@@ -154,7 +170,9 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
seg_mask = second_alpha
else:
seg_mask = pipeline._segment(rgb)
mode = settings.trimap.mode
# The directional modes are colour tests against the key colour; without
# one, the seg-topology trimap is the only meaningful choice.
mode = settings.trimap.mode if settings.chroma.enabled else "seg"
if mode == "seg":
trimap, trimap_stats = fuse_trimap(seg_mask, bg_confidence, settings.trimap)
elif mode in ("directional", "directional-hard-bg"):
@@ -173,12 +191,13 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
raw_alpha, alpha_source = pipeline._predict_alpha(rgb, trimap, bg_confidence)
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,
)
if settings.chroma.enabled:
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)
if settings.cross_check.enabled:
lab = convert_color_spaces(rgb)[1]
@@ -187,7 +206,9 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
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,
@@ -199,7 +220,7 @@ def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
metadata = {
"alpha_source": alpha_source,
"background_model": model.to_dict(),
"background_model": model.to_dict() if model is not None else None,
"trimap": trimap_stats,
"settings": asdict(settings),
}
+7 -3
View File
@@ -25,9 +25,10 @@ class PipelineManager:
ViTMatte is the matting model shared by both segmentation backends, so it is
loaded once and reused. Each segmentation backend is lazily loaded once on
first use and cached by name. Per-request parameters (``screen_color``,
``seg_model``, ``cross_check``) never trigger a model reload -- ``screen_color``
only affects this request's chroma/despill, and ``seg_model``/``cross_check``
select an already-cached (or first-time-loaded, then cached) model.
``seg_model``, ``cross_check``, ``chroma``) never trigger a model reload --
they only re-parametrise the pipeline over already-cached (or first-time-
loaded, then cached) models. ``chroma=False`` selects non-flat/complex
background mode (no colour key); ``screen_color`` is then unused.
"""
def __init__(self, base_settings: PipelineSettings, preload: bool = True):
@@ -142,6 +143,7 @@ class PipelineManager:
screen_color: str | None,
seg_model: str,
cross_check: bool | None = None,
chroma: bool | None = None,
) -> MattingResult:
if seg_model not in _SEG_MODELS:
raise ValueError(f"invalid seg_model '{seg_model}'")
@@ -149,6 +151,8 @@ class PipelineManager:
self._base,
screen_color=screen_color,
seg_backend=seg_model,
# None leaves the base default; False selects non-flat/complex mode.
chroma=chroma,
# Honor the lane lock: a locked lane ignores the per-request override
# (a cpu-fast box never runs cross-check, even on cross_check=on).
cross_check=self.effective_cross_check(cross_check),
+15
View File
@@ -5,6 +5,14 @@ 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 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
lab_sigma_min: float = 10.0
@@ -104,6 +112,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)