Add foreground color estimation pass

This commit is contained in:
Codex
2026-06-30 16:25:56 +08:00
parent 1a670088c0
commit d6bb658244
9 changed files with 158 additions and 70 deletions
+5 -1
View File
@@ -8,6 +8,7 @@ from .settings import (
AlphaPostSettings,
ChromaSettings,
DespillSettings,
ForegroundSettings,
ModelSettings,
PipelineSettings,
TrimapSettings,
@@ -41,7 +42,7 @@ def _update_dataclass(instance: T, values: dict[str, Any] | None) -> T:
def settings_from_dict(data: dict[str, Any]) -> PipelineSettings:
allowed_sections = {"chroma", "trimap", "alpha_post", "despill", "model"}
allowed_sections = {"chroma", "trimap", "alpha_post", "foreground", "despill", "model"}
unknown_sections = sorted(set(data) - allowed_sections)
if unknown_sections:
raise ValueError(f"Unknown config section(s): {', '.join(unknown_sections)}")
@@ -50,6 +51,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")),
foreground=_update_dataclass(ForegroundSettings(), data.get("foreground")),
despill=_update_dataclass(DespillSettings(), data.get("despill")),
model=_update_dataclass(ModelSettings(), data.get("model")),
)
@@ -65,6 +67,7 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
chroma = settings.chroma
trimap = settings.trimap
alpha_post = settings.alpha_post
foreground = settings.foreground
despill = settings.despill
model = settings.model
@@ -91,6 +94,7 @@ def override_settings(settings: PipelineSettings, **overrides: Any) -> PipelineS
chroma=chroma,
trimap=_update_dataclass(trimap, trimap_updates),
alpha_post=alpha_post,
foreground=foreground,
despill=_update_dataclass(despill, despill_updates),
model=_update_dataclass(model, model_updates),
)
+3 -40
View File
@@ -3,22 +3,9 @@ from __future__ import annotations
import numpy as np
from .chroma import BackgroundModel
from .deps import require_cv2
from .settings import DespillSettings
def _weighted_blur(values: np.ndarray, weights: np.ndarray, radius: int) -> np.ndarray:
if radius <= 0:
return values
cv2 = require_cv2()
kernel_size = radius * 2 + 1
numerator = cv2.GaussianBlur(
values * weights[..., None], (kernel_size, kernel_size), 0
)
denominator = cv2.GaussianBlur(weights, (kernel_size, kernel_size), 0)
return numerator / np.maximum(denominator[..., None], 1e-6)
def despill_green(
rgb: np.ndarray,
alpha: np.ndarray,
@@ -30,9 +17,11 @@ def despill_green(
if not settings.enabled:
return rgb.copy(), np.zeros(alpha.shape, dtype=np.float32)
cv2 = require_cv2()
edge = (alpha > settings.edge_low) & (alpha < settings.edge_high)
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)
@@ -69,30 +58,4 @@ def despill_green(
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)
# Approximate foreground recovery for semi-transparent contaminated pixels.
# C = alpha * F + (1 - alpha) * B, so F = (C - (1 - alpha) * B) / alpha.
# Blend it only where green excess is present; this is intentionally local
# because incorrect unmixing on opaque clothing would damage normal colors.
bg = np.asarray(model.rgb_center, dtype=np.float32)
a = np.clip(alpha[..., None], settings.unmix_alpha_min, 1.0)
recovered = np.clip((rgb_f - (1.0 - alpha[..., None]) * bg) / a, 0.0, 1.0)
unmix_mask = (
mask
* settings.unmix_strength
* np.clip((1.0 - alpha) / max(1.0 - settings.unmix_alpha_min, 1e-6), 0.0, 1.0)
)
out = out * (1.0 - unmix_mask[..., None]) + recovered * unmix_mask[..., None]
local_fg_weights = (
(alpha >= settings.local_fg_alpha_threshold)
& (bg_confidence <= settings.local_fg_bg_confidence_max)
).astype(np.float32)
local_fg = _weighted_blur(out, local_fg_weights, settings.local_fg_blur_radius)
local_mask = (
mask
* settings.local_fg_strength
* np.clip((1.0 - alpha) / max(1.0 - settings.unmix_alpha_min, 1e-6), 0.0, 1.0)
)
out = out * (1.0 - local_mask[..., None]) + local_fg * local_mask[..., None]
return np.clip(out * 255.0, 0, 255).astype(np.uint8), mask.astype(np.float32)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .chroma import BackgroundModel
from .deps import require_cv2
from .settings import ForegroundSettings
@dataclass(frozen=True)
class ForegroundEstimate:
rgb: np.ndarray
mask: np.ndarray
unmix_mask: np.ndarray
local_mask: np.ndarray
def _weighted_blur(values: np.ndarray, weights: np.ndarray, radius: int) -> np.ndarray:
if radius <= 0:
return values
cv2 = require_cv2()
kernel_size = radius * 2 + 1
numerator = cv2.GaussianBlur(
values * weights[..., None], (kernel_size, kernel_size), 0
)
denominator = cv2.GaussianBlur(weights, (kernel_size, kernel_size), 0)
return numerator / np.maximum(denominator[..., None], 1e-6)
def estimate_foreground_rgb(
rgb: np.ndarray,
alpha: np.ndarray,
bg_confidence: np.ndarray,
model: BackgroundModel,
settings: ForegroundSettings,
) -> ForegroundEstimate:
rgb_f = rgb.astype(np.float32) / 255.0
zero = np.zeros(alpha.shape, dtype=np.float32)
if not settings.enabled:
return ForegroundEstimate(rgb=rgb.copy(), mask=zero, unmix_mask=zero, local_mask=zero)
r = rgb_f[..., 0]
g = rgb_f[..., 1]
b = rgb_f[..., 2]
green_excess = np.maximum(g - (np.maximum(r, b) + settings.green_excess_margin), 0.0)
edge = (alpha > settings.edge_low) & (alpha < settings.edge_high)
green_weight = np.clip(
green_excess / max(settings.green_excess_margin * 8.0, 1e-6), 0.0, 1.0
)
bg_weight = (
settings.bg_confidence_weight * np.clip(bg_confidence, 0.0, 1.0)
+ (1.0 - settings.bg_confidence_weight)
)
base_mask = edge.astype(np.float32) * green_weight * bg_weight
bg = np.asarray(model.rgb_center, dtype=np.float32)
a = np.clip(alpha[..., None], settings.min_unmix_alpha, 1.0)
unmixed = np.clip((rgb_f - (1.0 - alpha[..., None]) * bg) / a, 0.0, 1.0)
low_alpha_weight = np.clip(
(1.0 - alpha) / max(1.0 - settings.min_unmix_alpha, 1e-6), 0.0, 1.0
)
unmix_mask = base_mask * settings.unmix_strength * low_alpha_weight
out = rgb_f * (1.0 - unmix_mask[..., None]) + unmixed * unmix_mask[..., None]
local_fg_weights = (
(alpha >= settings.local_alpha_threshold)
& (bg_confidence <= settings.local_bg_confidence_max)
).astype(np.float32)
local_fg = _weighted_blur(out, local_fg_weights, settings.local_blur_radius)
local_mask = base_mask * settings.local_strength * low_alpha_weight
out = out * (1.0 - local_mask[..., None]) + local_fg * local_mask[..., None]
combined_mask = np.clip(unmix_mask + local_mask, 0.0, 1.0)
return ForegroundEstimate(
rgb=np.clip(out * 255.0, 0, 255).astype(np.uint8),
mask=combined_mask.astype(np.float32),
unmix_mask=unmix_mask.astype(np.float32),
local_mask=local_mask.astype(np.float32),
)
+10 -2
View File
@@ -9,7 +9,8 @@ import numpy as np
from .alpha_post import clean_alpha, enforce_trimap
from .chroma import compute_bg_confidence
from .despill import despill_green
from .io import load_rgb, save_gray, save_rgba, write_text
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 .trimap import generate_trimap, trimap_to_alpha_seed
@@ -71,8 +72,11 @@ def _run_image(
alpha = enforce_trimap(alpha, trimap)
alpha = clean_alpha(alpha, trimap, settings.alpha_post)
foreground = estimate_foreground_rgb(
rgb, alpha, bg_confidence, model, settings.foreground
)
corrected_rgb, despill_mask = despill_green(
rgb, alpha, bg_confidence, model, settings.despill
foreground.rgb, alpha, bg_confidence, model, settings.despill
)
save_rgba(output_path, corrected_rgb, alpha)
@@ -91,6 +95,10 @@ def _run_image(
save_gray(debug / "bg_confidence.png", bg_confidence)
save_gray(debug / "trimap.png", trimap)
save_gray(debug / "alpha.png", alpha)
save_gray(debug / "foreground_mask.png", foreground.mask)
save_gray(debug / "foreground_unmix_mask.png", foreground.unmix_mask)
save_gray(debug / "foreground_local_mask.png", foreground.local_mask)
save_rgb(debug / "foreground_rgb.png", foreground.rgb)
save_gray(debug / "despill_mask.png", despill_mask)
save_previews(debug, corrected_rgb, alpha)
tiles = {
+16 -6
View File
@@ -35,6 +35,21 @@ class AlphaPostSettings:
alpha_ceil: float = 0.998
@dataclass(frozen=True)
class ForegroundSettings:
enabled: bool = True
edge_low: float = 0.005
edge_high: float = 0.995
min_unmix_alpha: float = 0.08
unmix_strength: float = 0.70
local_strength: float = 0.45
local_blur_radius: int = 11
local_alpha_threshold: float = 0.92
local_bg_confidence_max: float = 0.20
green_excess_margin: float = 0.015
bg_confidence_weight: float = 0.70
@dataclass(frozen=True)
class DespillSettings:
enabled: bool = True
@@ -45,12 +60,6 @@ class DespillSettings:
edge_expand_radius: int = 2
bg_confidence_weight: float = 0.65
alpha_weight_floor: float = 0.35
unmix_strength: float = 0.55
unmix_alpha_min: float = 0.08
local_fg_strength: float = 0.35
local_fg_blur_radius: int = 9
local_fg_alpha_threshold: float = 0.92
local_fg_bg_confidence_max: float = 0.20
@dataclass(frozen=True)
@@ -66,5 +75,6 @@ class PipelineSettings:
chroma: ChromaSettings = ChromaSettings()
trimap: TrimapSettings = TrimapSettings()
alpha_post: AlphaPostSettings = AlphaPostSettings()
foreground: ForegroundSettings = ForegroundSettings()
despill: DespillSettings = DespillSettings()
model: ModelSettings = ModelSettings()