c748fc22a4
The single asyncio lock serialized the whole request (chroma -> seg ->
trimap -> ViTMatte -> cross-check -> foreground -> despill), so the GPU
sat idle ~70-80% of each request while the CPU stages ran. Replace it
with a thread pool that runs several requests concurrently, and move the
mutex down to just the GPU forwards: when one request steps off the GPU
to do CPU work, another steps on, keeping the device fed.
Two opt-in env knobs, both defaulting to the old strictly-serial
behaviour:
* BGFILTER_CPU_WORKERS (pool size) attacks the CPU-bound regime -- with
cross-check off, throughput went 0.91 -> 1.67 req/s (1.84x) at pool=8
on a 4080S, vs a mere +6% from a second worker process, at half the
VRAM (one model copy).
* BGFILTER_GPU_CONCURRENCY caps how many forwards may co-schedule on the
SMs. batch-1 underfills the GPU, so =2 recovers the SM overlap a lock
forgoes: cross-check on went 0.83 -> 1.01 req/s, beating two worker
processes (0.92) with one model copy (~19 GB vs 24 GB).
The CLI/single-thread path passes no lock and uses a nullcontext, so it
stays allocation- and contention-free. Concurrent output verified
identical (10x same-frame -> one hash). A per-worker admission semaphore
bounds in-flight decoded images to the pool size.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""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 hmac
|
|
import io
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from fastapi import Depends, FastAPI, File, Form, Header, 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
|
|
|
|
# Optional app-level auth. When BGFILTER_AUTH_TOKEN is set, protected endpoints
|
|
# require the X-Genarrative-Image-Token header to equal it (constant-time compare) --
|
|
# the same header the production nginx uses, so callers only swap the URL + token.
|
|
# When unset the check is a no-op (open), matching pre-auth behaviour and leaving
|
|
# auth to an upstream proxy (e.g. nginx in production).
|
|
AUTH_TOKEN = os.environ.get("BGFILTER_AUTH_TOKEN", "").strip()
|
|
|
|
|
|
async def require_auth(x_genarrative_image_token: str | None = Header(default=None)):
|
|
if not AUTH_TOKEN:
|
|
return
|
|
if not x_genarrative_image_token or not hmac.compare_digest(
|
|
x_genarrative_image_token, AUTH_TOKEN
|
|
):
|
|
raise HTTPException(status_code=401, detail="missing or invalid auth token")
|
|
|
|
|
|
_manager: PipelineManager | None = None
|
|
|
|
# Request pipelining. Each request's process() runs on a small thread pool so the
|
|
# CPU stages (chroma / trimap / alpha_post / foreground) of concurrent requests
|
|
# overlap, while a GPU mutex inside the pipeline keeps the model forwards mutually
|
|
# exclusive on the single device -- i.e. when one request steps off the GPU to do
|
|
# CPU work, another steps on, keeping the GPU fed instead of idle.
|
|
# BGFILTER_CPU_WORKERS=1 (default) gives a 1-thread pool == the old strictly serial
|
|
# behaviour. A semaphore bounds in-flight requests (hence decoded-image RAM) to the
|
|
# pool size so a burst cannot pile unbounded decoded images before the pool drains.
|
|
CPU_WORKERS = max(1, _int_env("BGFILTER_CPU_WORKERS", 1))
|
|
_executor = ThreadPoolExecutor(max_workers=CPU_WORKERS, thread_name_prefix="bgf")
|
|
_sem = asyncio.Semaphore(CPU_WORKERS)
|
|
|
|
|
|
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)
|
|
# The cross-model veto is default behaviour (config default: on). With
|
|
# reuse-as-seg it replaces the primary seg forward rather than adding to it,
|
|
# so the earlier CPU-cost concern no longer applies. BGFILTER_CROSS_CHECK
|
|
# can still force it either way; unset = honour the config.
|
|
cc_env = os.environ.get("BGFILTER_CROSS_CHECK")
|
|
if cc_env is not None and cc_env.strip() != "":
|
|
cross_check = cc_env.strip().lower() in ("1", "true", "yes", "on")
|
|
settings = override_settings(settings, cross_check=cross_check)
|
|
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 cross_check=%s preload=%s max_pixels=%d auth=%s",
|
|
settings.model.device,
|
|
settings.segmentation.backend,
|
|
settings.cross_check.enabled,
|
|
preload,
|
|
MAX_IMAGE_PIXELS,
|
|
"enabled" if AUTH_TOKEN else "DISABLED(open)",
|
|
)
|
|
if not AUTH_TOKEN:
|
|
logger.warning(
|
|
"BGFILTER_AUTH_TOKEN not set: /remove-background is OPEN (no app-level auth)"
|
|
)
|
|
_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,
|
|
"crossCheck": _manager.cross_check_enabled,
|
|
}
|
|
|
|
|
|
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 _normalize_cross_check(value: str | None) -> bool | None:
|
|
"""None/empty -> use the service default; otherwise a boolean string."""
|
|
if value is None:
|
|
return None
|
|
v = value.strip().lower()
|
|
if v == "":
|
|
return None
|
|
if v in ("1", "true", "yes", "on"):
|
|
return True
|
|
if v in ("0", "false", "no", "off"):
|
|
return False
|
|
raise HTTPException(status_code=400, detail="invalid cross_check")
|
|
|
|
|
|
def _normalize_background_mode(value: str | None) -> str | None:
|
|
"""None/empty -> service default (flat); else "flat" or "complex".
|
|
|
|
"flat" (default) is the colour-keyed pipeline; "complex" is non-flat/scene
|
|
matting with no colour key (segmentation drives the trimap). We deliberately
|
|
do not surface the internal "chroma" wording here.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
v = value.strip().lower()
|
|
if v == "":
|
|
return None
|
|
if v in ("flat", "complex"):
|
|
return v
|
|
raise HTTPException(status_code=400, detail="invalid background_mode")
|
|
|
|
|
|
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),
|
|
cross_check: str | None = Form(None),
|
|
background_mode: str | None = Form(None),
|
|
_auth: None = Depends(require_auth),
|
|
):
|
|
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
|
|
normalized_cc = _normalize_cross_check(cross_check)
|
|
# None/"flat" -> flat (chroma on); "complex" -> non-flat (chroma off).
|
|
normalized_bg = _normalize_background_mode(background_mode)
|
|
chroma_enabled = normalized_bg != "complex"
|
|
effective_bg = "complex" if not chroma_enabled else "flat"
|
|
# Complex mode defaults the cross-check veto OFF (it costs the HR-matting
|
|
# forward and only clears residue near strands); an explicit cross_check=on
|
|
# still enables its hue-free gate.
|
|
if not chroma_enabled and normalized_cc is None:
|
|
normalized_cc = False
|
|
# Honors the lane lock (a locked cpu-fast box reports/runs "off" even on cross_check=on).
|
|
effective_cc = _manager.effective_cross_check(normalized_cc)
|
|
|
|
# Bound in-flight requests to the pool size, then run on the pool. The GPU
|
|
# sections are serialized inside the pipeline (a shared GPU mutex); the CPU
|
|
# sections of concurrent requests overlap across the pool threads.
|
|
async with _sem:
|
|
rgb = _decode_image(await file.read())
|
|
start = time.perf_counter()
|
|
loop = asyncio.get_running_loop()
|
|
try:
|
|
result = await loop.run_in_executor(
|
|
_executor,
|
|
lambda: _manager.process(
|
|
rgb,
|
|
screen_color=normalized_color,
|
|
seg_model=normalized_seg,
|
|
cross_check=normalized_cc,
|
|
chroma=chroma_enabled,
|
|
),
|
|
)
|
|
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",
|
|
"X-BGFilter-Cross-Check": "on" if effective_cc else "off",
|
|
"X-BGFilter-Background-Mode": effective_bg,
|
|
}
|
|
return Response(content=png, media_type="image/png", headers=headers)
|