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:
+53
-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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
+53
-8
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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