Add FastAPI HTTP service for background removal

Wrap the BGfilter matting pipeline in a long-running FastAPI service
(POST /remove-background, GET /healthz) exposing only screen_color and
seg_model, matching the birefnet-service request contract.

- bgfilter/pipeline.py: add in-memory run_rgb entry returning a
  MattingResult; allow injecting a shared runner/segmenter. CLI file
  path (run_image) behaviour is unchanged.
- bgfilter/service.py: PipelineManager caches one shared ViTMatte runner
  and per-backend segmenters, so screen_color/seg_model never reload a
  model.
- app.py: thin FastAPI layer -- param validation, image decode/encode,
  error codes (400/413/500), timing headers, global asyncio lock.
- requirements.txt: fastapi, uvicorn[standard], python-multipart.
- README + design doc: HTTP service usage and HF-mirror/proxy/Xet
  download notes.

Verified end-to-end (base ViTMatte + BiRefNet + anime-seg): 11/11
acceptance checks pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:08:25 +00:00
parent cf0ceec496
commit d24c6ed608
6 changed files with 892 additions and 33 deletions
+73 -31
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from dataclasses import asdict
from dataclasses import asdict, dataclass
from pathlib import Path
import numpy as np
@@ -22,11 +22,38 @@ from .trimap import (
from .vitmatte_infer import ViTMatteRunner
@dataclass
class MattingResult:
"""In-memory result of one matting run.
``rgb`` is the despilled foreground (uint8 HxWx3) and ``alpha`` is float32 HxW in
0..1; together they compose the output RGBA. The remaining arrays are the
intermediate maps used for debug previews.
"""
rgb: np.ndarray
alpha: np.ndarray
metadata: dict
bg_confidence: np.ndarray
trimap: np.ndarray
seg_mask: np.ndarray | None
color_mask: np.ndarray
input_rgb: np.ndarray
class MattingPipeline:
def __init__(self, settings: PipelineSettings):
def __init__(
self,
settings: PipelineSettings,
*,
runner: ViTMatteRunner | None = None,
segmenter=None,
):
self.settings = settings
self._runner: ViTMatteRunner | None = None
self._segmenter = None
# 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
def _segment(self, rgb: np.ndarray) -> np.ndarray:
if self._segmenter is None:
@@ -53,6 +80,10 @@ class MattingPipeline:
raise
return trimap_to_alpha_seed(trimap, bg_confidence), "chroma_fallback"
def run_rgb(self, rgb: np.ndarray) -> MattingResult:
"""Run the full pipeline on an in-memory RGB array (no disk I/O)."""
return _process_rgb(rgb, self)
def run_image(
self,
input_path: str | Path,
@@ -71,14 +102,8 @@ def run_image(
return MattingPipeline(settings).run_image(input_path, output_path, debug_dir)
def _run_image(
input_path: str | Path,
output_path: str | Path,
debug_dir: str | Path | None,
pipeline: MattingPipeline,
) -> dict:
def _process_rgb(rgb: np.ndarray, pipeline: MattingPipeline) -> MattingResult:
settings = pipeline.settings
rgb = load_rgb(input_path)
screen_color = parse_hex_color(settings.screen_color) if settings.screen_color else None
bg_confidence, model = compute_bg_confidence(
rgb, settings=settings.chroma, screen_color=screen_color
@@ -111,39 +136,56 @@ def _run_image(
foreground = estimate_foreground_rgb(
rgb, alpha, bg_confidence, model, settings.foreground
)
corrected_rgb, color_mask = despill(
foreground.rgb, alpha, model, settings.despill
)
save_rgba(output_path, corrected_rgb, alpha)
corrected_rgb, color_mask = despill(foreground.rgb, alpha, model, settings.despill)
result = {
"input": str(input_path),
"output": str(output_path),
metadata = {
"alpha_source": alpha_source,
"background_model": model.to_dict(),
"trimap": trimap_stats,
"settings": asdict(settings),
}
return MattingResult(
rgb=corrected_rgb,
alpha=alpha,
metadata=metadata,
bg_confidence=bg_confidence,
trimap=trimap,
seg_mask=seg_mask,
color_mask=color_mask,
input_rgb=rgb,
)
def _run_image(
input_path: str | Path,
output_path: str | Path,
debug_dir: str | Path | None,
pipeline: MattingPipeline,
) -> dict:
res = _process_rgb(load_rgb(input_path), pipeline)
save_rgba(output_path, res.rgb, res.alpha)
result = {"input": str(input_path), "output": str(output_path), **res.metadata}
if debug_dir is not None:
debug = Path(debug_dir)
debug.mkdir(parents=True, exist_ok=True)
save_gray(debug / "bg_confidence.png", bg_confidence)
save_gray(debug / "trimap.png", trimap)
if seg_mask is not None:
save_gray(debug / "seg_mask.png", seg_mask)
save_gray(debug / "alpha.png", alpha)
save_rgb(debug / "foreground_rgb.png", corrected_rgb)
save_gray(debug / "color_mask.png", color_mask)
save_previews(debug, corrected_rgb, alpha)
save_gray(debug / "bg_confidence.png", res.bg_confidence)
save_gray(debug / "trimap.png", res.trimap)
if res.seg_mask is not None:
save_gray(debug / "seg_mask.png", res.seg_mask)
save_gray(debug / "alpha.png", res.alpha)
save_rgb(debug / "foreground_rgb.png", res.rgb)
save_gray(debug / "color_mask.png", res.color_mask)
save_previews(debug, res.rgb, res.alpha)
tiles = {
"input": rgb,
"bg confidence": np.repeat((bg_confidence[..., None] * 255).astype(np.uint8), 3, axis=2),
"trimap": np.repeat(trimap[..., None], 3, axis=2),
"alpha": np.repeat((alpha[..., None] * 255).astype(np.uint8), 3, axis=2),
"input": res.input_rgb,
"bg confidence": np.repeat((res.bg_confidence[..., None] * 255).astype(np.uint8), 3, axis=2),
"trimap": np.repeat(res.trimap[..., None], 3, axis=2),
"alpha": np.repeat((res.alpha[..., None] * 255).astype(np.uint8), 3, axis=2),
}
for name, color in BACKGROUND_COLORS.items():
tiles[f"preview {name}"] = composite(corrected_rgb, alpha, color)
tiles[f"preview {name}"] = composite(res.rgb, res.alpha, color)
make_qa_grid(debug, tiles)
write_text(debug / "metadata.json", json.dumps(result, indent=2, ensure_ascii=False))
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import logging
import threading
from dataclasses import replace
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 .vitmatte_infer import ViTMatteRunner
logger = logging.getLogger("bgfilter.service")
# HTTP-facing seg_model values, in preference order (first = default).
SEG_MODELS: tuple[str, ...] = tuple(_SEG_MODELS)
class PipelineManager:
"""Long-lived holder of the matting models.
ViTMatte is the matting model shared by both segmentation backends, so it is
loaded once and reused. Each segmentation backend is lazily loaded once on
first use and cached by name. Per-request parameters (``screen_color``,
``seg_model``) never trigger a model reload -- ``screen_color`` only affects
this request's chroma/despill, and ``seg_model`` just selects an already-cached
(or first-time-loaded) segmenter.
"""
def __init__(self, base_settings: PipelineSettings, preload: bool = True):
self._base = base_settings
self._runner: ViTMatteRunner | None = None
self._segmenters: dict[str, object] = {}
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)
@property
def default_seg_model(self) -> str:
return self._base.segmentation.backend
@property
def device(self) -> str:
return self._base.model.device
def _get_runner(self) -> ViTMatteRunner:
if self._runner is None:
with self._load_lock:
if self._runner is None:
logger.info(
"loading ViTMatte: model=%s device=%s",
self._base.model.model_name,
self._base.model.device,
)
self._runner = ViTMatteRunner(self._base.model)
return self._runner
def _get_segmenter(self, backend: str):
seg = self._segmenters.get(backend)
if seg is None:
with self._load_lock:
seg = self._segmenters.get(backend)
if seg is None:
seg_settings = replace(
self._base.segmentation,
backend=backend,
model_name=_SEG_MODELS[backend],
)
logger.info(
"loading segmenter: backend=%s model=%s device=%s",
backend,
seg_settings.model_name,
seg_settings.device,
)
seg = make_segmenter(seg_settings)
self._segmenters[backend] = seg
return seg
def process(
self, rgb: np.ndarray, *, screen_color: str | None, seg_model: str
) -> MattingResult:
if seg_model not in _SEG_MODELS:
raise ValueError(f"invalid seg_model '{seg_model}'")
settings = override_settings(
self._base, screen_color=screen_color, seg_backend=seg_model
)
runner = (
self._get_runner()
if settings.model.matting_method == "vitmatte"
else None
)
segmenter = (
self._get_segmenter(seg_model) if settings.segmentation.enabled else None
)
pipeline = MattingPipeline(settings, runner=runner, segmenter=segmenter)
return pipeline.run_rgb(rgb)