Remove dead colour-model/despill code; simplify despill; refresh stale docs
- chroma: drop BackgroundModel.hsv_center/hue_sigma (unused since the green gating went away), their estimation, _hue_distance, and the now-unused hsv channel from convert_color_spaces (one fewer cvtColor per image). - despill: delete the dead green-channel-algebra path (the pipeline always passes a colour) and fold the remaining Lab-direction despill into a single despill() with no screen_color arg (127 -> 60 lines). Renamed despill_green -> despill. - settings: remove the now-dead ChromaSettings.hue_sigma_min and DespillSettings.green_excess_margin (plus the matching config lines). - docs/comments: generalise stale "green" wording to "background colour" (module docstring, alpha_post/trimap/foreground/settings) and rename the trimap variable green_bg -> screen_bg. Kept: the unmix foreground fallback, --matting-method chroma, and green/blue as illustrative examples. Verified: package compiles, both configs parse, and an auto-detect end-to-end run is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
-86
@@ -3,106 +3,43 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
from .chroma import BackgroundModel
|
||||
from .deps import require_cv2
|
||||
from .settings import DespillSettings
|
||||
|
||||
|
||||
def despill_green(
|
||||
def despill(
|
||||
rgb: np.ndarray,
|
||||
alpha: np.ndarray,
|
||||
model: BackgroundModel,
|
||||
settings: DespillSettings,
|
||||
screen_color: tuple[float, float, float] | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
rgb_f = rgb.astype(np.float32) / 255.0
|
||||
"""Remove background-colour spill from the foreground.
|
||||
|
||||
The background's Lab a*/b* vector gives a chroma direction; foreground chroma
|
||||
projecting along it beyond a margin is treated as spill and pulled back toward
|
||||
neutral, keeping luminance so hair texture and shading survive. This works for
|
||||
any flat background: a saturated primary (green/blue screen) reduces to classic
|
||||
channel suppression, while low-chroma pastels -- where no single RGB channel
|
||||
dominates -- are handled by the same projection.
|
||||
|
||||
Returns the corrected RGB (uint8) and the per-pixel despill weight (float32).
|
||||
"""
|
||||
if not settings.enabled:
|
||||
return rgb.copy(), np.zeros(alpha.shape, dtype=np.float32)
|
||||
if screen_color is not None:
|
||||
# Arbitrary background colour: despill along its Lab chroma direction.
|
||||
return _despill_color(rgb, alpha, model, settings)
|
||||
|
||||
# (1) Despill across the whole foreground, not only the soft edge band.
|
||||
# Green frequently bleeds into pixels ViTMatte marks as solid foreground
|
||||
# (alpha ~ 1); the old upper bound left those uncorrected. Neutral pixels
|
||||
# stay untouched because the green-excess term below is zero for them.
|
||||
foreground = alpha > settings.edge_low
|
||||
if settings.edge_expand_radius > 0:
|
||||
from .deps import require_cv2
|
||||
|
||||
cv2 = require_cv2()
|
||||
radius = settings.edge_expand_radius
|
||||
kernel = cv2.getStructuringElement(
|
||||
cv2.MORPH_ELLIPSE, (radius * 2 + 1, radius * 2 + 1)
|
||||
)
|
||||
foreground = cv2.dilate(foreground.astype(np.uint8), kernel).astype(bool)
|
||||
|
||||
r = rgb_f[..., 0]
|
||||
g = rgb_f[..., 1]
|
||||
b = rgb_f[..., 2]
|
||||
neutral_green = np.maximum(r, b) + settings.green_excess_margin
|
||||
excess = np.maximum(g - neutral_green, 0.0)
|
||||
|
||||
# (2) Keep full strength across the whole opaque range (alpha >= 0.5) and
|
||||
# ease off only where the pixel is mostly transparent. The previous tent
|
||||
# peaked at alpha 0.5 and starved exactly the solid hair that needs despill.
|
||||
alpha_weight = np.maximum(
|
||||
np.clip(alpha * 2.0, 0.0, 1.0), settings.alpha_weight_floor
|
||||
)
|
||||
|
||||
# (3) Localise purely by green excess. bg_confidence is intentionally not a
|
||||
# gate: it is low on the character edge where the spill lives, so using it
|
||||
# suppressed the very correction we want.
|
||||
green_weight = np.clip(excess / max(settings.green_excess_margin * 8.0, 1e-6), 0.0, 1.0)
|
||||
mask = foreground.astype(np.float32) * alpha_weight * green_weight
|
||||
|
||||
out = rgb_f.copy()
|
||||
target_green = np.minimum(g, neutral_green)
|
||||
out[..., 1] = g * (1.0 - mask * settings.strength) + target_green * (
|
||||
mask * settings.strength
|
||||
)
|
||||
|
||||
# Light foreground color approximation: green spill usually removed too much
|
||||
# luminance from thin hair edges, so redistribute part of the removed green
|
||||
# into red/blue without changing fully opaque pixels.
|
||||
bg_green = float(model.rgb_center[1])
|
||||
compensation = excess * mask * settings.strength * min(0.25, bg_green * 0.15)
|
||||
out[..., 0] = np.clip(out[..., 0] + compensation * 0.5, 0.0, 1.0)
|
||||
out[..., 2] = np.clip(out[..., 2] + compensation * 0.5, 0.0, 1.0)
|
||||
|
||||
return np.clip(out * 255.0, 0, 255).astype(np.uint8), mask.astype(np.float32)
|
||||
|
||||
|
||||
def _despill_color(
|
||||
rgb: np.ndarray,
|
||||
alpha: np.ndarray,
|
||||
model: BackgroundModel,
|
||||
settings: DespillSettings,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Despill toward an arbitrary background colour.
|
||||
|
||||
Generalises the green channel algebra: the background's Lab a*/b* vector gives
|
||||
a chroma direction; foreground chroma projecting along it beyond a margin is
|
||||
treated as spill and pulled back, keeping luminance so texture survives. Reduces
|
||||
to a green-style suppression when the background is a saturated primary, but also
|
||||
handles low-chroma pastels where no single RGB channel dominates.
|
||||
"""
|
||||
from .deps import require_cv2
|
||||
|
||||
cv2 = require_cv2()
|
||||
rgb_f = rgb.astype(np.float32) / 255.0
|
||||
|
||||
bg_lab = np.asarray(model.lab_center, dtype=np.float32)
|
||||
va, vb = float(bg_lab[1]), float(bg_lab[2])
|
||||
mag = float(np.hypot(va, vb))
|
||||
if mag < 1e-3:
|
||||
if mag < 1e-3: # near-neutral background: no chroma direction, nothing to despill
|
||||
return rgb.copy(), np.zeros(alpha.shape, dtype=np.float32)
|
||||
da, db = va / mag, vb / mag
|
||||
|
||||
cv2 = require_cv2()
|
||||
rgb_f = rgb.astype(np.float32) / 255.0
|
||||
foreground = alpha > settings.edge_low
|
||||
if settings.edge_expand_radius > 0:
|
||||
radius = settings.edge_expand_radius
|
||||
kernel = cv2.getStructuringElement(
|
||||
cv2.MORPH_ELLIPSE, (radius * 2 + 1, radius * 2 + 1)
|
||||
)
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius * 2 + 1, radius * 2 + 1))
|
||||
foreground = cv2.dilate(foreground.astype(np.uint8), kernel).astype(bool)
|
||||
|
||||
lab = cv2.cvtColor(rgb_f, cv2.COLOR_RGB2LAB)
|
||||
@@ -110,12 +47,9 @@ def _despill_color(
|
||||
proj = A * da + B * db # chroma toward the background hue
|
||||
excess = np.maximum(proj - settings.color_margin_lab, 0.0)
|
||||
|
||||
alpha_weight = np.maximum(
|
||||
np.clip(alpha * 2.0, 0.0, 1.0), settings.alpha_weight_floor
|
||||
)
|
||||
color_weight = np.clip(
|
||||
excess / max(settings.color_margin_lab * 8.0, 1e-6), 0.0, 1.0
|
||||
)
|
||||
# Full strength across the opaque range; ease off only where mostly transparent.
|
||||
alpha_weight = np.maximum(np.clip(alpha * 2.0, 0.0, 1.0), settings.alpha_weight_floor)
|
||||
color_weight = np.clip(excess / max(settings.color_margin_lab * 8.0, 1e-6), 0.0, 1.0)
|
||||
mask = foreground.astype(np.float32) * alpha_weight * color_weight
|
||||
reduce = excess * mask * settings.strength # pull chroma back along the bg axis
|
||||
|
||||
|
||||
Reference in New Issue
Block a user