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:
@@ -127,6 +127,42 @@ qa_grid.png
|
||||
metadata.json
|
||||
```
|
||||
|
||||
## HTTP service
|
||||
|
||||
An HTTP wrapper (`app.py` + `bgfilter/service.py`) exposes the pipeline as a
|
||||
long-running FastAPI service. Models load once and are reused across requests.
|
||||
|
||||
```powershell
|
||||
D:\MiniConda\envs\lightML\python.exe -m uvicorn app:app `
|
||||
--host 127.0.0.1 --port 18083 --workers 1
|
||||
```
|
||||
|
||||
Two endpoints:
|
||||
|
||||
- `GET /healthz` — liveness/config JSON.
|
||||
- `POST /remove-background` — `multipart/form-data`, returns `image/png`.
|
||||
- `file` (required) — the source image. The field name is fixed as `file`.
|
||||
- `screen_color` (optional) — `#RRGGBB` prior; omit/empty for auto-detect.
|
||||
- `seg_model` (optional) — `birefnet` (default) or `anime-seg`.
|
||||
|
||||
```bash
|
||||
# default (birefnet + auto background colour)
|
||||
curl -sS -F "file=@input.png" \
|
||||
http://127.0.0.1:18083/remove-background -o output.png
|
||||
|
||||
# explicit background colour + anime segmenter
|
||||
curl -sS \
|
||||
-F "file=@input.png" \
|
||||
-F "screen_color=#CFEFFF" \
|
||||
-F "seg_model=anime-seg" \
|
||||
http://127.0.0.1:18083/remove-background -o output.png
|
||||
```
|
||||
|
||||
Config comes from `BGFILTER_CONFIG` (defaults to `configs/default.yaml` when present);
|
||||
`BGFILTER_DEVICE` overrides both model and segmentation device; `BGFILTER_MAX_IMAGE_PIXELS`
|
||||
caps input size (default ~4MP → `413`); `BGFILTER_PRELOAD=1` loads the default models at
|
||||
startup. Run a single worker (`--workers 1`) — each worker loads its own copy of the models.
|
||||
|
||||
## Quality Check
|
||||
|
||||
The quality checker measures alpha validity and edge spill on semi-transparent edge
|
||||
@@ -153,11 +189,22 @@ D:\MiniConda\envs\lightML\python.exe scripts\smoke_samples.py `
|
||||
## Notes
|
||||
|
||||
- `Samples/` and `Outputs/` are ignored by Git.
|
||||
- ViTMatte and segmentation weights load from Hugging Face on first use. Behind a
|
||||
firewall set `HF_ENDPOINT=https://hf-mirror.com` (and bypass a flaky local proxy).
|
||||
- ViTMatte and segmentation weights load from Hugging Face on first use.
|
||||
`anime-seg` (`skytnt/anime-seg`) is a plain ONNX download; `birefnet`
|
||||
(`ZhengPeng7/BiRefNet`) ships custom modelling code so it needs
|
||||
`trust_remote_code=True` plus `timm` / `einops` / `kornia`.
|
||||
- Downloading the weights behind a firewall — the reliable combination is the
|
||||
`hf-mirror.com` mirror **with the local proxy bypassed** and **Xet disabled**:
|
||||
```powershell
|
||||
$env:HF_ENDPOINT = "https://hf-mirror.com" # domestic mirror
|
||||
$env:NO_PROXY = "*" # bypass the proxy; the mirror is direct
|
||||
$env:HF_HUB_DISABLE_XET = "1" # these repos are Xet-backed; force classic HTTP
|
||||
```
|
||||
Two gotchas this avoids: (1) mirror + an overseas proxy makes the mirror's `resolve`
|
||||
bounce back to `huggingface.co`, which recent `huggingface_hub` rejects with
|
||||
`FileMetadataError`; (2) with `hf-xet` installed the Xet download path fails instantly.
|
||||
Once the weights are cached, run offline with `HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1`
|
||||
(as the service does in production).
|
||||
- Foreground colour estimation uses pymatting's `estimate_foreground_ml` to propagate
|
||||
clean foreground colour into semi-transparent edges before de-spill. Set
|
||||
`foreground.method: unmix` to fall back to the legacy heuristic.
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""FastAPI entry for the BGfilter background-removal service.
|
||||
|
||||
Thin HTTP layer: parameter validation, image decode/encode, error codes, timing,
|
||||
and a global lock. All model handling lives in ``bgfilter.service.PipelineManager``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Response, UploadFile
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from bgfilter.config import load_settings, override_settings
|
||||
from bgfilter.service import SEG_MODELS, PipelineManager
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("BGFILTER_LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("bgfilter.app")
|
||||
|
||||
VERSION = "0.1.0"
|
||||
_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning("ignoring non-integer %s=%r; using %d", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
MAX_IMAGE_PIXELS = _int_env("BGFILTER_MAX_IMAGE_PIXELS", 4_194_304)
|
||||
# We enforce our own pixel budget from the image header before decoding, so disable
|
||||
# Pillow's separate decompression-bomb ceiling (which would otherwise raise its own error).
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
_manager: PipelineManager | None = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _build_settings():
|
||||
config_path = os.environ.get("BGFILTER_CONFIG")
|
||||
if config_path is None:
|
||||
default_cfg = Path("configs/default.yaml")
|
||||
config_path = str(default_cfg) if default_cfg.exists() else None
|
||||
settings = load_settings(config_path)
|
||||
device = os.environ.get("BGFILTER_DEVICE")
|
||||
if device:
|
||||
# Covers both settings.model.device and settings.segmentation.device.
|
||||
settings = override_settings(settings, device=device)
|
||||
return settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global _manager
|
||||
settings = _build_settings()
|
||||
preload = os.environ.get("BGFILTER_PRELOAD", "1").strip().lower() not in ("0", "false", "no", "")
|
||||
logger.info(
|
||||
"starting bgfilter service: device=%s default_seg=%s preload=%s max_pixels=%d",
|
||||
settings.model.device,
|
||||
settings.segmentation.backend,
|
||||
preload,
|
||||
MAX_IMAGE_PIXELS,
|
||||
)
|
||||
_manager = PipelineManager(settings, preload=preload)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="BGfilter", version=VERSION, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
if _manager is None:
|
||||
raise HTTPException(status_code=503, detail="service not ready")
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "bgfilter",
|
||||
"version": VERSION,
|
||||
"defaultSegModel": _manager.default_seg_model,
|
||||
"device": _manager.device,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_screen_color(value: str | None) -> str | None:
|
||||
"""None/empty/"null"/"auto" -> auto-detect; otherwise require #RRGGBB."""
|
||||
if value is None:
|
||||
return None
|
||||
v = value.strip()
|
||||
if v == "" or v.lower() in ("null", "auto"):
|
||||
return None
|
||||
if not _HEX_RE.match(v):
|
||||
raise HTTPException(status_code=400, detail="invalid screen_color")
|
||||
return v.upper()
|
||||
|
||||
|
||||
def _normalize_seg_model(value: str | None) -> str | None:
|
||||
"""None/empty -> use the service default; otherwise must be a known backend."""
|
||||
if value is None:
|
||||
return None
|
||||
v = value.strip().lower()
|
||||
if v == "":
|
||||
return None
|
||||
if v not in SEG_MODELS:
|
||||
raise HTTPException(status_code=400, detail="invalid seg_model")
|
||||
return v
|
||||
|
||||
|
||||
def _decode_image(data: bytes) -> np.ndarray:
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="empty image")
|
||||
try:
|
||||
image = Image.open(io.BytesIO(data))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="invalid image")
|
||||
width, height = image.size
|
||||
if width <= 0 or height <= 0:
|
||||
raise HTTPException(status_code=400, detail="invalid image")
|
||||
if width * height > MAX_IMAGE_PIXELS:
|
||||
raise HTTPException(status_code=413, detail="image too large")
|
||||
try:
|
||||
image = ImageOps.exif_transpose(image).convert("RGB")
|
||||
image.load() # force full decode; truncated files raise here
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="invalid image")
|
||||
return np.asarray(image, dtype=np.uint8)
|
||||
|
||||
|
||||
def _encode_png(rgb: np.ndarray, alpha: np.ndarray) -> bytes:
|
||||
alpha_u8 = np.clip(alpha * 255.0, 0, 255).astype(np.uint8)
|
||||
rgba = np.dstack([np.clip(rgb, 0, 255).astype(np.uint8), alpha_u8])
|
||||
buffer = io.BytesIO()
|
||||
Image.fromarray(rgba, mode="RGBA").save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@app.post("/remove-background")
|
||||
async def remove_background(
|
||||
file: UploadFile = File(...),
|
||||
screen_color: str | None = Form(None),
|
||||
seg_model: str | None = Form(None),
|
||||
):
|
||||
if _manager is None:
|
||||
raise HTTPException(status_code=503, detail="service not ready")
|
||||
|
||||
normalized_color = _normalize_screen_color(screen_color)
|
||||
normalized_seg = _normalize_seg_model(seg_model) or _manager.default_seg_model
|
||||
|
||||
rgb = _decode_image(await file.read())
|
||||
|
||||
start = time.perf_counter()
|
||||
async with _lock: # one inference at a time per process
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _manager.process(
|
||||
rgb, screen_color=normalized_color, seg_model=normalized_seg
|
||||
),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
# No clean flat background and no screen_color given -> client-actionable.
|
||||
if "auto-detect" in str(exc).lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="could not auto-detect background colour; pass screen_color",
|
||||
)
|
||||
logger.exception("inference failed")
|
||||
raise HTTPException(status_code=500, detail="inference failed")
|
||||
except Exception:
|
||||
logger.exception("inference failed")
|
||||
raise HTTPException(status_code=500, detail="inference failed")
|
||||
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
png = _encode_png(result.rgb, result.alpha)
|
||||
headers = {
|
||||
"X-BGFilter-Elapsed-Ms": str(elapsed_ms),
|
||||
"X-BGFilter-Seg-Model": normalized_seg,
|
||||
"X-BGFilter-Screen-Color": normalized_color if normalized_color else "auto",
|
||||
}
|
||||
return Response(content=png, media_type="image/png", headers=headers)
|
||||
+73
-31
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,470 @@
|
||||
# BGfilter 服务版设计与交付需求
|
||||
|
||||
本文档给接手实现服务化的 Agent 使用。目标是在 `BGfilter-server` 仓库内,把现有 BGfilter 抠图算法包装成一个可长期运行的 Python HTTP 服务,接口形态参考当前线上 `birefnet-service`,但算法仍使用本仓库的 BGfilter 管线。
|
||||
|
||||
## 目标
|
||||
|
||||
新增一个服务入口,使调用方可以通过 HTTP 上传图片并得到透明背景 PNG。
|
||||
|
||||
服务首版只暴露两个业务参数:
|
||||
|
||||
- `screen_color`:可选。背景色先验,格式为 `#RRGGBB`。不传或空值时沿用 BGfilter 默认行为,从图片边框自动检测纯色背景。
|
||||
- `seg_model`:可选。分割模型选择,允许值为 `birefnet` 或 `anime-seg`。不传时使用仓库当前默认值 `birefnet`。
|
||||
|
||||
其它算法参数首版不要暴露为 HTTP 参数,直接使用当前默认配置:
|
||||
|
||||
- `trimap.mode = directional`
|
||||
- `despill.enabled = true`
|
||||
- `model.matting_method = vitmatte`
|
||||
- `model.model_name = hustvl/vitmatte-base-composition-1k`
|
||||
- `model.device = cpu`
|
||||
- `segmentation.device = cpu`
|
||||
- `segmentation.input_size = 1024`
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把 BGfilter 算法移植到 Rust。
|
||||
- 不在 HTTP 接口里暴露 `trimap_mode`、`despill`、阈值、设备、模型权重名等高级参数。
|
||||
- 不复用当前线上 `birefnet-service` 的简单 alpha 直出算法;`seg_model=birefnet` 在本服务中只表示 BGfilter 管线里的语义分割后端。
|
||||
- 不在每次请求里加载并释放模型。
|
||||
- 不要求首版支持批量图片。
|
||||
|
||||
## 现有算法边界
|
||||
|
||||
BGfilter 当前核心入口在:
|
||||
|
||||
- `bgfilter/pipeline.py`
|
||||
- `bgfilter/settings.py`
|
||||
- `bgfilter/config.py`
|
||||
- `bgfilter/segmentation.py`
|
||||
- `bgfilter/vitmatte_infer.py`
|
||||
|
||||
完整管线是:
|
||||
|
||||
```text
|
||||
输入 RGB
|
||||
-> 背景色自动检测或使用 screen_color
|
||||
-> chroma 背景置信度
|
||||
-> 可选语义分割 mask,默认开启
|
||||
-> trimap 生成,默认 directional
|
||||
-> ViTMatte 预测 alpha
|
||||
-> trimap 强约束
|
||||
-> chroma-guided alpha suppression
|
||||
-> alpha 小连通域/孔洞清理
|
||||
-> pymatting foreground estimation
|
||||
-> despill 去背景色溢色
|
||||
-> 输出 RGBA PNG
|
||||
```
|
||||
|
||||
当前默认分割后端已经是:
|
||||
|
||||
```text
|
||||
segmentation.backend = birefnet
|
||||
segmentation.model_name = ZhengPeng7/BiRefNet
|
||||
```
|
||||
|
||||
`anime-seg` 对应权重为:
|
||||
|
||||
```text
|
||||
segmentation.backend = anime-seg
|
||||
segmentation.model_name = skytnt/anime-seg
|
||||
```
|
||||
|
||||
`bgfilter/config.py` 里已有 `_SEG_MODELS` 和 `override_settings(..., seg_backend=...)`,服务层应复用这套映射,不要让 HTTP 调用方传 `model_name`。
|
||||
|
||||
## HTTP 接口契约
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
返回 JSON,用于进程存活和基础配置检查。
|
||||
|
||||
建议响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"service": "bgfilter",
|
||||
"version": "0.1.0",
|
||||
"defaultSegModel": "birefnet",
|
||||
"device": "cpu"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /remove-background`
|
||||
|
||||
请求类型:
|
||||
|
||||
```text
|
||||
multipart/form-data
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
```text
|
||||
file: UploadFile, required
|
||||
screen_color: string, optional, "#RRGGBB"
|
||||
seg_model: string, optional, "birefnet" | "anime-seg"
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```text
|
||||
200 image/png
|
||||
```
|
||||
|
||||
响应体是透明背景 PNG。建议附带诊断 header:
|
||||
|
||||
```text
|
||||
X-BGFilter-Elapsed-Ms: 总耗时毫秒
|
||||
X-BGFilter-Seg-Model: 实际使用的 seg_model
|
||||
X-BGFilter-Screen-Color: 实际请求传入的 screen_color,未传可省略或设为 auto
|
||||
```
|
||||
|
||||
错误码建议:
|
||||
|
||||
```text
|
||||
400 empty image
|
||||
400 invalid image
|
||||
400 invalid screen_color
|
||||
400 invalid seg_model
|
||||
413 image too large
|
||||
500 inference failed
|
||||
```
|
||||
|
||||
为兼容现有 Rust 调用方,`file` 字段名必须保持为 `file`。
|
||||
|
||||
## 参数默认与校验
|
||||
|
||||
`screen_color`:
|
||||
|
||||
- 允许省略、空字符串、`null` 等价于自动检测。
|
||||
- 非空时必须匹配 `#RRGGBB`。
|
||||
- 大小写都可以,内部可以统一为大写或小写。
|
||||
- 校验失败返回 `400 invalid screen_color`。
|
||||
|
||||
`seg_model`:
|
||||
|
||||
- 允许省略,默认 `birefnet`。
|
||||
- 允许值只包括 `birefnet`、`anime-seg`。
|
||||
- 可以兼容内部命名 `seg_backend`,但 HTTP 对外字段用 `seg_model`。
|
||||
- 校验失败返回 `400 invalid seg_model`。
|
||||
|
||||
`MAX_IMAGE_PIXELS`:
|
||||
|
||||
- 建议参考线上 `birefnet-service`,通过环境变量控制,例如 `BGFILTER_MAX_IMAGE_PIXELS`。
|
||||
- 默认建议先设为 `4194304`,即约 4MP。
|
||||
- 超限返回 `413 image too large`。
|
||||
|
||||
## 模型加载与缓存要求
|
||||
|
||||
服务必须做到模型只加载一次或按后端首次懒加载一次,不能每次请求都重新加载模型。
|
||||
|
||||
现有 `MattingPipeline` 已经有实例级懒加载:
|
||||
|
||||
- `self._runner` 缓存 `ViTMatteRunner`
|
||||
- `self._segmenter` 缓存当前 settings 下的 segmenter
|
||||
|
||||
但如果每次请求新建 `MattingPipeline(settings)`,缓存会失效。因此服务版需要持有长期对象。
|
||||
|
||||
推荐实现一个 `PipelineManager` 或等价对象:
|
||||
|
||||
```text
|
||||
长期缓存:
|
||||
ViTMatteRunner: 按 model_name + device + matting_method 缓存
|
||||
Segmenter: 按 backend + model_name + device + input_size 缓存
|
||||
|
||||
请求级参数:
|
||||
screen_color: 只影响本次 chroma/despill,不影响模型缓存
|
||||
seg_model: 选择对应 segmenter;首次使用某后端时加载,之后复用
|
||||
```
|
||||
|
||||
推荐行为:
|
||||
|
||||
```text
|
||||
服务启动:
|
||||
可预加载默认 birefnet + ViTMatte,也可懒加载。
|
||||
|
||||
第一次 seg_model=birefnet:
|
||||
加载 BiRefNet segmenter 和 ViTMatte。
|
||||
|
||||
后续 seg_model=birefnet:
|
||||
复用已有 BiRefNet segmenter 和 ViTMatte。
|
||||
|
||||
第一次 seg_model=anime-seg:
|
||||
加载 AnimeSeg segmenter,ViTMatte 继续复用。
|
||||
|
||||
后续 seg_model=anime-seg:
|
||||
复用已有 AnimeSeg segmenter 和 ViTMatte。
|
||||
```
|
||||
|
||||
可接受的首版简化方案:
|
||||
|
||||
- 长期缓存两个 `MattingPipeline` 实例:一个 `birefnet`,一个 `anime-seg`。
|
||||
- 每个实例内部复用自己的 `_runner` 和 `_segmenter`。
|
||||
- 如果这样做导致 ViTMatte 被加载两次,应优先改为共享 `ViTMatteRunner`,因为 ViTMatte 是两条分割后端共同使用的 matting 模型。
|
||||
|
||||
更推荐的实现是把 `MattingPipeline` 改造成可以注入共享 runner/segmenter,或新增服务专用 pipeline runner,避免重复加载 ViTMatte。
|
||||
|
||||
## 并发控制
|
||||
|
||||
首版建议采用全局 `asyncio.Lock`,与当前线上 `birefnet-service` 类似,保证同一进程内一次只跑一个推理请求。
|
||||
|
||||
原因:
|
||||
|
||||
- BGfilter 管线包含 ViTMatte、segmenter、pymatting、OpenCV/NumPy 后处理,CPU 和内存压力较大。
|
||||
- CPU 默认部署时并发推理容易放大延迟和内存。
|
||||
- 后续确认资源充足后,再考虑按模型或设备拆锁。
|
||||
|
||||
如果使用 Uvicorn 多 worker,每个 worker 都会各自加载一份模型,内存会成倍增长。首版建议 `--workers 1`。
|
||||
|
||||
## 内存入口改造建议
|
||||
|
||||
当前 `MattingPipeline.run_image(...)` 主要面向 CLI 文件输入输出。服务可以先用临时文件桥接,但更推荐新增内存入口,减少磁盘 IO 和临时文件清理风险。
|
||||
|
||||
推荐新增内部函数:
|
||||
|
||||
```text
|
||||
run_rgb(rgb: np.ndarray, settings: PipelineSettings, resources/cache) -> result
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```text
|
||||
corrected_rgb: np.ndarray uint8 HxWx3
|
||||
alpha: np.ndarray float32 HxW, 0..1
|
||||
metadata: dict
|
||||
```
|
||||
|
||||
服务层再把它编码成 PNG:
|
||||
|
||||
```text
|
||||
RGBA = corrected_rgb + alpha
|
||||
PIL.Image.save(..., format="PNG")
|
||||
```
|
||||
|
||||
如果首版为了速度使用临时文件:
|
||||
|
||||
- 临时目录应使用 `tempfile.TemporaryDirectory()`。
|
||||
- 文件名不要使用上传原始文件名。
|
||||
- 请求结束必须清理。
|
||||
- 不要把 debug 输出默认打开。
|
||||
|
||||
## 推荐文件结构
|
||||
|
||||
可以参考线上 `birefnet-service` 的简洁形态,在仓库根目录新增:
|
||||
|
||||
```text
|
||||
app.py
|
||||
requirements.txt
|
||||
```
|
||||
|
||||
也可以新增:
|
||||
|
||||
```text
|
||||
bgfilter/service.py
|
||||
```
|
||||
|
||||
用于放 `PipelineManager` 和内存运行逻辑。`app.py` 保持薄入口。
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
app.py
|
||||
FastAPI 路由、参数校验、图片 decode/encode、错误码、计时、锁
|
||||
|
||||
bgfilter/service.py
|
||||
PipelineManager、模型缓存、按请求组装 settings、调用算法管线
|
||||
|
||||
bgfilter/pipeline.py
|
||||
尽量保留原 CLI 兼容行为;必要时新增 run_rgb
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
现有算法依赖保留。服务化需要补充:
|
||||
|
||||
```text
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
```
|
||||
|
||||
现有 `requirements.txt` 中已经有:
|
||||
|
||||
```text
|
||||
opencv-python
|
||||
pillow
|
||||
numpy
|
||||
scipy
|
||||
scikit-image
|
||||
pymatting
|
||||
torch
|
||||
torchvision
|
||||
transformers
|
||||
accelerate
|
||||
safetensors
|
||||
huggingface_hub
|
||||
timm
|
||||
einops
|
||||
kornia
|
||||
onnxruntime
|
||||
tqdm
|
||||
typer
|
||||
rich
|
||||
PyYAML
|
||||
```
|
||||
|
||||
如果部署在无 GUI Linux 服务器,`opencv-python-headless` 通常比 `opencv-python` 更合适,但替换前需要确认现有本地脚本是否依赖 GUI 能力。
|
||||
|
||||
## 环境变量
|
||||
|
||||
建议支持:
|
||||
|
||||
```text
|
||||
BGFILTER_CONFIG=configs/default.yaml
|
||||
BGFILTER_DEVICE=cpu
|
||||
BGFILTER_MAX_IMAGE_PIXELS=4194304
|
||||
BGFILTER_PRELOAD=1
|
||||
HF_HOME=/opt/genarrative-image-host/model-cache/hf
|
||||
HF_HUB_OFFLINE=1
|
||||
TRANSFORMERS_OFFLINE=1
|
||||
OMP_NUM_THREADS=4
|
||||
MKL_NUM_THREADS=4
|
||||
```
|
||||
|
||||
`BGFILTER_DEVICE` 应同时覆盖:
|
||||
|
||||
- `settings.model.device`
|
||||
- `settings.segmentation.device`
|
||||
|
||||
如果机器没有 GPU,默认保持 `cpu`。
|
||||
|
||||
## systemd 部署参考
|
||||
|
||||
参考当前线上 `genarrative-birefnet.service`,可以新增:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Genarrative BGfilter background removal service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/genarrative-image-host/bgfilter-service
|
||||
Environment=HF_HOME=/opt/genarrative-image-host/model-cache/hf
|
||||
Environment=HF_HUB_OFFLINE=1
|
||||
Environment=TRANSFORMERS_OFFLINE=1
|
||||
Environment=BGFILTER_CONFIG=configs/default.yaml
|
||||
Environment=BGFILTER_DEVICE=cpu
|
||||
Environment=BGFILTER_MAX_IMAGE_PIXELS=4194304
|
||||
Environment=BGFILTER_PRELOAD=1
|
||||
Environment=OMP_NUM_THREADS=4
|
||||
Environment=MKL_NUM_THREADS=4
|
||||
ExecStart=/opt/genarrative-image-host/bgfilter-service/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 18083 --workers 1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
MemoryMax=8G
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/genarrative-image-host
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
端口只是建议值。真正接入时由部署方决定。
|
||||
|
||||
## 调用示例
|
||||
|
||||
默认参数:
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-F "file=@input.png" \
|
||||
http://127.0.0.1:18083/remove-background \
|
||||
-o output.png
|
||||
```
|
||||
|
||||
指定背景色:
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-F "file=@input.png" \
|
||||
-F "screen_color=#CFEFFF" \
|
||||
http://127.0.0.1:18083/remove-background \
|
||||
-o output.png
|
||||
```
|
||||
|
||||
切换 Anime Seg:
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-F "file=@input.png" \
|
||||
-F "seg_model=anime-seg" \
|
||||
http://127.0.0.1:18083/remove-background \
|
||||
-o output.png
|
||||
```
|
||||
|
||||
指定背景色并切换 Anime Seg:
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
-F "file=@input.png" \
|
||||
-F "screen_color=#CFEFFF" \
|
||||
-F "seg_model=anime-seg" \
|
||||
http://127.0.0.1:18083/remove-background \
|
||||
-o output.png
|
||||
```
|
||||
|
||||
## 与 Genarrative Rust 服务的接入预期
|
||||
|
||||
当前 Genarrative Rust BFF 已有远端抠图调用边界:
|
||||
|
||||
```text
|
||||
POST {GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL}/remove-background
|
||||
multipart field: file
|
||||
response: image/png
|
||||
```
|
||||
|
||||
BGfilter 服务应保持同样的 `POST /remove-background` 和 `file` 字段名。Rust 侧后续只需要在 multipart 中追加可选文本字段:
|
||||
|
||||
```text
|
||||
screen_color
|
||||
seg_model
|
||||
```
|
||||
|
||||
如果 Rust 侧暂时不传新字段,本服务仍应可用,并按默认 `birefnet + 自动背景色` 执行。
|
||||
|
||||
## 验收要求
|
||||
|
||||
实现完成后至少验证:
|
||||
|
||||
1. `GET /healthz` 返回 `200` 和 JSON。
|
||||
2. `POST /remove-background` 只传 `file` 可返回 `image/png`。
|
||||
3. `screen_color=#CFEFFF` 可正常返回 `image/png`。
|
||||
4. `seg_model=birefnet` 可正常返回 `image/png`。
|
||||
5. `seg_model=anime-seg` 可正常返回 `image/png`。
|
||||
6. 非法 `seg_model` 返回 `400`。
|
||||
7. 非法 `screen_color` 返回 `400`。
|
||||
8. 空文件或非图片返回 `400`。
|
||||
9. 超过 `BGFILTER_MAX_IMAGE_PIXELS` 的图片返回 `413`。
|
||||
10. 同一进程连续请求同一 `seg_model` 时不会重复加载对应模型。
|
||||
11. 从 `birefnet` 切到 `anime-seg` 后,再切回 `birefnet`,已加载模型可复用。
|
||||
12. Uvicorn `--workers 1` 下内存不随请求次数持续增长。
|
||||
|
||||
建议在日志中打印模型加载事件,但不要每个请求打印完整配置或文件内容。日志中不要输出用户上传图片数据。
|
||||
|
||||
## 完成定义
|
||||
|
||||
交付时应包含:
|
||||
|
||||
- 可运行的 FastAPI 服务入口。
|
||||
- 更新后的依赖文件。
|
||||
- 本地启动命令说明。
|
||||
- 至少一组 curl 示例。
|
||||
- 若新增内存入口,应保持原 CLI 文件入口不破坏。
|
||||
- 若为了服务化调整 pipeline,应保证默认 CLI 行为仍按当前配置工作。
|
||||
|
||||
完成后,不看内部代码也应能通过本文档的 HTTP 契约和 curl 示例直接调用服务。
|
||||
@@ -18,3 +18,6 @@ tqdm
|
||||
typer
|
||||
rich
|
||||
PyYAML
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
|
||||
Reference in New Issue
Block a user