Improve despill and add quality checks

This commit is contained in:
Codex
2026-06-30 14:29:19 +08:00
parent 8fc53925e0
commit 464a60c733
6 changed files with 190 additions and 14 deletions
+14 -2
View File
@@ -86,9 +86,21 @@ qa_grid.png
metadata.json
```
## Quality Check
The quality checker measures alpha validity and green spill on semi-transparent
edge pixels.
```powershell
D:\MiniConda\envs\lightML\python.exe -m bgfilter.quality_cli `
Outputs\TestImage_rgba.png `
--max-edge-green-excess-p95 0.55
```
## Current Notes
- `Samples/` and `Outputs/` are ignored by Git.
- `ViTMatte` model weights are loaded from Hugging Face on first use.
- Current despill is conservative and edge-local. Strong green spill around hair may
still need parameter tuning or a stronger foreground color estimation pass.
- Current despill is edge-local and includes a lightweight foreground unmixing pass.
Strong green spill around hair may still need more advanced foreground color
estimation.
+40 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import numpy as np
from .chroma import BackgroundModel
from .deps import require_cv2
from .settings import DespillSettings
@@ -17,23 +18,57 @@ 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:
radius = settings.edge_expand_radius
kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (radius * 2 + 1, radius * 2 + 1)
)
edge = cv2.dilate(edge.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)
edge_weight = np.clip((1.0 - np.abs(alpha - 0.5) * 2.0), 0.0, 1.0)
bg_weight = np.clip(bg_confidence, 0.0, 1.0)
mask = edge.astype(np.float32) * edge_weight * np.maximum(bg_weight, 0.25)
alpha_weight = np.maximum(
np.clip((1.0 - np.abs(alpha - 0.5) * 2.0), 0.0, 1.0),
settings.alpha_weight_floor,
)
bg_weight = (
settings.bg_confidence_weight * np.clip(bg_confidence, 0.0, 1.0)
+ (1.0 - settings.bg_confidence_weight)
)
green_weight = np.clip(excess / max(settings.green_excess_margin * 8.0, 1e-6), 0.0, 1.0)
mask = edge.astype(np.float32) * alpha_weight * bg_weight * green_weight
out = rgb_f.copy()
out[..., 1] = g - excess * mask * settings.strength
target_green = np.minimum(g, neutral_green)
out[..., 1] = g * (1.0 - mask * settings.strength) + target_green * (
mask * settings.strength
)
# Very light compensation toward the non-green channels to avoid gray fringes.
# 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)
# 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]
return np.clip(out * 255.0, 0, 255).astype(np.uint8), mask.astype(np.float32)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
from PIL import Image
@dataclass(frozen=True)
class QualityMetrics:
path: str
width: int
height: int
alpha_min: int
alpha_max: int
alpha_mean: float
nonzero_alpha_pixels: int
edge_pixels: int
edge_green_excess_mean: float
edge_green_excess_p95: float
edge_green_excess_max: float
def to_dict(self) -> dict:
return asdict(self)
def measure_rgba(path: str | Path) -> QualityMetrics:
image = Image.open(path).convert("RGBA")
arr = np.asarray(image).astype(np.float32) / 255.0
rgb = arr[..., :3]
alpha = arr[..., 3]
edge = (alpha > 0.02) & (alpha < 0.98)
green_excess = np.maximum(rgb[..., 1] - np.maximum(rgb[..., 0], rgb[..., 2]), 0.0)
edge_values = green_excess[edge]
if edge_values.size:
edge_mean = float(edge_values.mean())
edge_p95 = float(np.percentile(edge_values, 95))
edge_max = float(edge_values.max())
else:
edge_mean = edge_p95 = edge_max = 0.0
alpha_u8 = (alpha * 255.0).round().astype(np.uint8)
return QualityMetrics(
path=str(path),
width=image.width,
height=image.height,
alpha_min=int(alpha_u8.min()),
alpha_max=int(alpha_u8.max()),
alpha_mean=float(alpha_u8.mean()),
nonzero_alpha_pixels=int((alpha_u8 > 0).sum()),
edge_pixels=int(edge.sum()),
edge_green_excess_mean=edge_mean,
edge_green_excess_p95=edge_p95,
edge_green_excess_max=edge_max,
)
def assert_quality(
metrics: QualityMetrics,
max_edge_green_excess_p95: float | None = None,
require_alpha_range: bool = True,
) -> list[str]:
failures: list[str] = []
if require_alpha_range:
if metrics.alpha_min != 0:
failures.append(f"alpha_min is {metrics.alpha_min}, expected 0")
if metrics.alpha_max != 255:
failures.append(f"alpha_max is {metrics.alpha_max}, expected 255")
if metrics.nonzero_alpha_pixels <= 0:
failures.append("nonzero alpha pixel count is 0")
if (
max_edge_green_excess_p95 is not None
and metrics.edge_green_excess_p95 > max_edge_green_excess_p95
):
failures.append(
"edge_green_excess_p95 "
f"{metrics.edge_green_excess_p95:.4f} exceeds {max_edge_green_excess_p95:.4f}"
)
return failures
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import json
from pathlib import Path
import typer
from rich import print
from .quality import assert_quality, measure_rgba
app = typer.Typer(help="Quality checks for generated RGBA PNG files.")
@app.command()
def main(
image: Path = typer.Argument(..., exists=True, file_okay=True, dir_okay=False),
max_edge_green_excess_p95: float | None = typer.Option(
None, "--max-edge-green-excess-p95", min=0.0, max=1.0
),
json_output: bool = typer.Option(False, "--json/--no-json"),
) -> None:
metrics = measure_rgba(image)
failures = assert_quality(metrics, max_edge_green_excess_p95)
if json_output:
print(json.dumps(metrics.to_dict(), indent=2))
else:
print(metrics.to_dict())
if failures:
for failure in failures:
print(f"[red]fail:[/red] {failure}")
raise typer.Exit(code=1)
print("[green]quality check passed[/green]")
if __name__ == "__main__":
app()
+9 -4
View File
@@ -38,10 +38,15 @@ class AlphaPostSettings:
@dataclass(frozen=True)
class DespillSettings:
enabled: bool = True
edge_low: float = 0.02
edge_high: float = 0.98
strength: float = 0.75
green_excess_margin: float = 0.03
edge_low: float = 0.005
edge_high: float = 0.995
strength: float = 0.92
green_excess_margin: float = 0.015
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
@dataclass(frozen=True)
+9 -3
View File
@@ -12,6 +12,12 @@ trimap:
despill:
enabled: true
edge_low: 0.02
edge_high: 0.98
strength: 0.75
edge_low: 0.005
edge_high: 0.995
strength: 0.92
green_excess_margin: 0.015
edge_expand_radius: 2
bg_confidence_weight: 0.65
alpha_weight_floor: 0.35
unmix_strength: 0.55
unmix_alpha_min: 0.08