Files
BGfilter/app.py
T
lhk229 c748fc22a4 Pipeline concurrent requests over a shared GPU with a narrowed lock
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>
2026-07-14 12:47:50 +00:00

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)