diff --git a/bgfilter/alpha_post.py b/bgfilter/alpha_post.py index 5151c32..468929d 100644 --- a/bgfilter/alpha_post.py +++ b/bgfilter/alpha_post.py @@ -45,6 +45,36 @@ def suppress_alpha_by_chroma( return np.clip(out, 0.0, 1.0) +def carve_background_holes( + alpha: np.ndarray, rgb: np.ndarray, settings: AlphaPostSettings +) -> np.ndarray: + """Remove green-dominant pixels that are embedded inside the foreground. + + Targets background green that pokes into the silhouette (gaps between fingers, + pockets between hair strands) where the green is too dark/desaturated for the + chroma confidence to flag, so the matte wrongly fills it as foreground. A pixel + is carved when it is clearly green-dominant *and* mostly surrounded by genuine + (non-green) foreground, which separates such an intrusion from the outer hair + edge (background on one side) that must be preserved. + """ + if not settings.hole_carve: + return alpha + cv2 = require_cv2() + rgb_f = rgb.astype(np.float32) / 255.0 + dom = rgb_f[..., 1] - np.maximum(rgb_f[..., 0], rgb_f[..., 2]) + green = dom > settings.hole_green_dominance + # Foreground that is genuinely opaque and not itself green, so the surround + # measure reflects real body/hair rather than the green we want to remove. + solid_fg = ((alpha > settings.hole_fg_alpha) & ~green).astype(np.float32) + radius = max(1, int(round(max(alpha.shape) * settings.hole_surround_radius_ratio))) + ksize = radius * 2 + 1 + surround = cv2.GaussianBlur(solid_fg, (ksize, ksize), 0) + carve = green & (surround > settings.hole_surround_min) + out = alpha.astype(np.float32).copy() + out[carve] = 0.0 + return np.clip(out, 0.0, 1.0) + + 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) diff --git a/bgfilter/pipeline.py b/bgfilter/pipeline.py index 298d50f..3612b2f 100644 --- a/bgfilter/pipeline.py +++ b/bgfilter/pipeline.py @@ -6,7 +6,12 @@ from pathlib import Path import numpy as np -from .alpha_post import clean_alpha, enforce_trimap, suppress_alpha_by_chroma +from .alpha_post import ( + carve_background_holes, + clean_alpha, + enforce_trimap, + suppress_alpha_by_chroma, +) from .chroma import compute_bg_confidence from .despill import despill_green from .foreground import estimate_foreground_rgb @@ -73,6 +78,7 @@ def _run_image( alpha = enforce_trimap(alpha, trimap) alpha = suppress_alpha_by_chroma(alpha, bg_confidence, trimap, settings.alpha_post) alpha = clean_alpha(alpha, trimap, settings.alpha_post) + alpha = carve_background_holes(alpha, rgb, settings.alpha_post) foreground = estimate_foreground_rgb( rgb, alpha, bg_confidence, model, settings.foreground ) diff --git a/bgfilter/settings.py b/bgfilter/settings.py index 9fb2c30..f68716d 100644 --- a/bgfilter/settings.py +++ b/bgfilter/settings.py @@ -39,6 +39,14 @@ class AlphaPostSettings: chroma_suppress_bg_low: float = 0.35 chroma_suppress_bg_high: float = 0.80 chroma_suppress_strength: float = 1.0 + # Background-hole carving (doc section 10): remove green-dominant pixels that + # are embedded in the foreground (finger gaps, inter-strand pockets) where the + # green is too dark for the chroma confidence to flag. + hole_carve: bool = True + hole_green_dominance: float = 0.10 + hole_fg_alpha: float = 0.5 + hole_surround_radius_ratio: float = 0.012 + hole_surround_min: float = 0.4 @dataclass(frozen=True) diff --git a/configs/default.yaml b/configs/default.yaml index 2ec8849..f0e8abc 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -15,6 +15,11 @@ alpha_post: chroma_suppress_bg_low: 0.35 chroma_suppress_bg_high: 0.80 chroma_suppress_strength: 1.0 + hole_carve: true + hole_green_dominance: 0.10 + hole_fg_alpha: 0.5 + hole_surround_radius_ratio: 0.012 + hole_surround_min: 0.4 foreground: enabled: true