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:
+42
-1
@@ -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)
|
||||
|
||||
@@ -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("#")
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user