Files
BGfilter/app.py
lhk229 78ef411ad2 Silence httpx per-request INFO lines on the prose log stream
httpx logs every outbound fetch at INFO with the full presigned image_url,
leaking per-request records (and OSS credential/signature params) into the
event stream that is meant for low-volume prose only. The access JSONL
already records the URL, so cap the httpx logger at WARNING.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:35:54 +00:00

550 lines
23 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 contextvars
import datetime
import hmac
import io
import json
import logging
import os
import re
import time
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from logging.handlers import RotatingFileHandler
from pathlib import Path
from urllib.parse import urlparse
import httpx
import numpy as np
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Request, Response, UploadFile
from PIL import Image, ImageOps, UnidentifiedImageError
from bgfilter.config import load_settings, override_settings
from bgfilter.service import SEG_MODELS, PipelineManager
# Per-request correlation id. A middleware sets it; a logging filter injects it into
# every record so all logs for one request share the id. contextvars (not thread-
# locals) so a pooled worker thread never inherits the previous request's id.
_request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
_RID_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def _make_request_id(incoming: str | None) -> str:
"""Reuse a sanitized inbound X-Request-ID (e.g. nginx $request_id), else generate."""
if incoming and _RID_RE.match(incoming):
return incoming
return uuid.uuid4().hex[:16]
class _RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = _request_id_var.get()
return True
class _JsonFormatter(logging.Formatter):
"""One JSON object per record; per-request access fields ride in `extra={"fields"}`."""
def format(self, record: logging.LogRecord) -> str:
out = {
"ts": datetime.datetime.fromtimestamp(record.created, datetime.timezone.utc)
.isoformat(timespec="milliseconds")
.replace("+00:00", "Z"),
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "-"),
"msg": record.getMessage(),
}
fields = getattr(record, "fields", None)
if isinstance(fields, dict):
out.update(fields)
if record.exc_info:
out["exc"] = self.formatException(record.exc_info)
return json.dumps(out, ensure_ascii=False)
# Two log streams with distinct jobs (never mixed into one file):
#
# * Event stream -- prose for humans, low volume: startup summary, warnings, error
# tracebacks. Goes to stderr (text format) where supervisord/journald captures it.
# Per-request records must NOT be logged here: bulk structured content does not
# belong in the prose file.
# * Access stream -- structured JSONL, one record per request (plus one "startup"
# meta record): everything with analysis value. Written to the file named by
# BGFILTER_ACCESS_LOG (rotated by the app itself: supervisord can't rotate a file
# it doesn't own); when unset (dev), the JSON lines fall back to stderr.
#
# Both streams stamp the same request_id, so a traceback in the event stream joins
# back to its access record (which also carries a truncated `exc` copy).
_event_handler = logging.StreamHandler()
_event_handler.addFilter(_RequestIdFilter())
_event_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s [%(request_id)s] %(message)s")
)
logging.basicConfig(level=os.environ.get("BGFILTER_LOG_LEVEL", "INFO"), handlers=[_event_handler])
# httpx logs one INFO line per outbound request (full presigned image_url included);
# that is per-request noise on the prose stream -- the access JSONL already records
# the URL. Keep only its warnings/errors.
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("bgfilter.app")
ACCESS_LOG_PATH = os.environ.get("BGFILTER_ACCESS_LOG", "").strip()
access_logger = logging.getLogger("bgfilter.access")
access_logger.propagate = False # never leak per-request records into the prose stream
if ACCESS_LOG_PATH:
Path(ACCESS_LOG_PATH).parent.mkdir(parents=True, exist_ok=True)
_access_handler: logging.Handler = RotatingFileHandler(
ACCESS_LOG_PATH, maxBytes=50 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
else:
_access_handler = logging.StreamHandler()
_access_handler.addFilter(_RequestIdFilter())
_access_handler.setFormatter(_JsonFormatter())
access_logger.addHandler(_access_handler)
def _emit_access(event: str, fields: dict) -> None:
"""One JSONL record on the access stream (`msg` = record type)."""
access_logger.info(event, extra={"fields": fields})
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)
# /remove-background accepts either an uploaded `file` or an `image_url` the service
# fetches itself. These bound the fetch so a huge or slow URL cannot hang a worker or
# blow RAM. NOTE: no SSRF hardening here (no host/IP allowlist) -- the endpoint is
# guarded by token auth and assumes trusted callers; do NOT expose it to untrusted
# clients without adding private-address / redirect filtering to _fetch_image.
FETCH_MAX_BYTES = _int_env("BGFILTER_FETCH_MAX_BYTES", 25 * 1024 * 1024)
FETCH_TIMEOUT_S = float(os.environ.get("BGFILTER_FETCH_TIMEOUT", "15"))
# Outbound fetch concurrency = 2 x cpu_workers, hard-capped here so a burst can never
# exhaust file descriptors / ephemeral ports (each fetch = one socket).
FETCH_CONCURRENCY_CAP = 64
# 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.
# The pool size defaults to config (server.cpu_workers, default 1 == strictly serial)
# and is overridable by BGFILTER_CPU_WORKERS. Both are resolved in lifespan once the
# config is loaded; a same-sized semaphore bounds in-flight requests (hence
# decoded-image RAM) so a burst cannot pile unbounded decoded images before the pool
# drains.
_executor: ThreadPoolExecutor | None = None
_sem: asyncio.Semaphore | None = None
# Outbound image_url fetches: a FIFO admission bound (restores the throttle a direct
# upload has implicitly) and a shared client for connection reuse. Both built in
# lifespan once the pool size is known. See _fetch_image / §image_url mode.
_fetch_sem: asyncio.Semaphore | None = None
_http_client: httpx.AsyncClient | None = None
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, _executor, _sem, _fetch_sem, _http_client
settings = _build_settings()
preload = os.environ.get("BGFILTER_PRELOAD", "1").strip().lower() not in ("0", "false", "no", "")
# Pool size: config default (server.cpu_workers), env override wins.
cpu_workers = max(1, _int_env("BGFILTER_CPU_WORKERS", settings.server.cpu_workers))
_executor = ThreadPoolExecutor(max_workers=cpu_workers, thread_name_prefix="bgf")
_sem = asyncio.Semaphore(cpu_workers)
# Outbound-fetch bound: 2x the pool (a small prefetch buffer), hard-capped. The
# shared client's max_connections matches so httpx never opens more sockets than
# the semaphore admits, and reuses connections across fetches.
fetch_conc = min(2 * cpu_workers, FETCH_CONCURRENCY_CAP)
_fetch_sem = asyncio.Semaphore(fetch_conc)
_http_client = httpx.AsyncClient(
follow_redirects=True,
max_redirects=3,
timeout=httpx.Timeout(connect=5.0, read=FETCH_TIMEOUT_S, write=10.0, pool=None),
limits=httpx.Limits(max_connections=fetch_conc),
)
gpu_concurrency = max(1, _int_env("BGFILTER_GPU_CONCURRENCY", settings.server.gpu_concurrency))
logger.info(
"starting bgfilter service: device=%s default_seg=%s cross_check=%s preload=%s "
"cpu_workers=%d gpu_concurrency=%d max_pixels=%d auth=%s",
settings.model.device,
settings.segmentation.backend,
settings.cross_check.enabled,
preload,
cpu_workers,
gpu_concurrency,
MAX_IMAGE_PIXELS,
"enabled" if AUTH_TOKEN else "DISABLED(open)",
)
# Same facts as a structured record on the access stream, so per-instance config
# is queryable next to the requests it served (no prose parsing).
_emit_access(
"startup",
{
"version": VERSION,
"device": settings.model.device,
"default_seg": settings.segmentation.backend,
"cross_check": settings.cross_check.enabled,
"reuse_as_seg": settings.cross_check.reuse_as_seg,
"preload": preload,
"cpu_workers": cpu_workers,
"gpu_concurrency": gpu_concurrency,
"max_pixels": MAX_IMAGE_PIXELS,
"auth": "enabled" if AUTH_TOKEN else "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)
try:
yield
finally:
await _http_client.aclose()
app = FastAPI(title="BGfilter", version=VERSION, lifespan=lifespan)
@app.middleware("http")
async def _access_log_mw(request: Request, call_next):
"""Bind a request id, echo it back, and emit one structured access line per request.
The handler accumulates its fields on ``request.state.fields``; here we add the
id, client, status and total time -- so even a request that errors out (the
handler raised before finishing) still produces a log line.
"""
rid = _make_request_id(request.headers.get("x-request-id"))
_request_id_var.set(rid)
request.state.fields = {}
t0 = time.perf_counter()
status = 500
try:
response = await call_next(request)
status = response.status_code
response.headers["X-Request-ID"] = rid
return response
except Exception:
# Unhandled escape (HTTPExceptions were already turned into responses by the
# inner exception middleware). setdefault: a more precise traceback recorded
# at the raise site wins over this generic one.
request.state.fields.setdefault("exc", traceback.format_exc()[-2000:])
raise
finally:
if request.url.path != "/healthz":
fields = {"request_id": rid, "client": request.client.host if request.client else "-"}
fields.update(getattr(request.state, "fields", {}) or {})
fields["status"] = status
fields["t_total_ms"] = round((time.perf_counter() - t0) * 1000)
_emit_access("request", fields)
@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")
async def _fetch_image(url: str) -> bytes:
"""Download image bytes from a URL, bounded by size and time.
Robustness only -- no SSRF filtering (see FETCH_* note above): callers are
trusted via the auth token. Streams the body so an over-large response is
rejected without being fully buffered. Uses the shared client (connection reuse);
the caller holds _fetch_sem so outbound concurrency stays bounded.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="image_url must be an http(s) URL")
try:
async with _http_client.stream("GET", url) as resp:
if resp.status_code != 200:
raise HTTPException(
status_code=400,
detail=f"image_url fetch failed (status {resp.status_code})",
)
buf = bytearray()
async for chunk in resp.aiter_bytes():
buf += chunk
if len(buf) > FETCH_MAX_BYTES:
raise HTTPException(status_code=413, detail="image_url body too large")
return bytes(buf)
except httpx.HTTPError:
raise HTTPException(status_code=400, detail="image_url fetch failed")
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(
request: Request,
file: UploadFile | None = File(None),
image_url: str | None = Form(None),
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")
# Access-log fields accumulate here; the middleware emits the line (with status /
# total time) even if we raise below. Record the error detail on the way out.
log = request.state.fields
try:
# Exactly one image source. (Auth already ran, so only authenticated callers
# can trigger an outbound fetch.)
url = image_url.strip() if image_url else None
if (file is None) == (not url):
raise HTTPException(
status_code=400, detail="provide exactly one of 'file' or 'image_url'"
)
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)
log["source"] = "url" if url else "file"
if url:
log["url_host"] = urlparse(url).hostname or "-"
# Full URL for debugging. NOTE: presigned URLs put their auth token in
# the query string, so this lands credentials in the log -- acceptable
# here (trusted callers, private log); drop the query if that changes.
log["url"] = url[:512]
log["seg_model"] = normalized_seg
log["cross_check"] = "on" if effective_cc else "off"
log["background_mode"] = effective_bg
log["screen_color"] = normalized_color if normalized_color else "auto"
log["auth"] = "ok" if AUTH_TOKEN else "open"
# Acquire the raw bytes BEFORE the compute gate: a slow fetch must not hold a
# pool/compute slot. The upload body is already buffered by Starlette (fast);
# a URL is fetched under its own looser bound (_fetch_sem).
if url is not None:
t = time.perf_counter()
async with _fetch_sem:
raw = await _fetch_image(url)
log["t_fetch_ms"] = round((time.perf_counter() - t) * 1000)
else:
raw = await file.read()
# 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.
t_wait = time.perf_counter()
async with _sem:
log["t_queue_ms"] = round((time.perf_counter() - t_wait) * 1000)
t_decode = time.perf_counter()
rgb = _decode_image(raw)
log["t_decode_ms"] = round((time.perf_counter() - t_decode) * 1000)
log["input_bytes"] = len(raw)
log["input_h"], log["input_w"] = int(rgb.shape[0]), int(rgb.shape[1])
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")
log["exc"] = traceback.format_exc()[-2000:]
raise HTTPException(status_code=500, detail="inference failed")
except Exception:
logger.exception("inference failed")
log["exc"] = traceback.format_exc()[-2000:]
raise HTTPException(status_code=500, detail="inference failed")
elapsed_ms = int((time.perf_counter() - start) * 1000)
log["t_process_ms"] = elapsed_ms
png = _encode_png(result.rgb, result.alpha)
log["output_bytes"] = len(png)
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)
except HTTPException as exc:
log["error"] = str(exc.detail)[:200]
raise