From 8417653598f5963d152d5c7e5916685d47529465 Mon Sep 17 00:00:00 2001 From: lingh Date: Sat, 4 Jul 2026 22:34:08 +0800 Subject: [PATCH] Add per-request cross_check toggle to the HTTP service (lazy-loaded) POST /remove-background gains an optional cross_check form field (1/0/true/false/yes/no/on/off; empty = service default). The effective state is reported in the X-BGFilter-Cross-Check response header. The cross-check model is now never preloaded at startup: the first request that enables it pays the one-time load (measured ~8 s on top of the ~50 s cc inference), after which the instance is cached and shared. Verified with a functional test against PipelineManager: cc-off requests never load the model, cc=1 lazy-loads then reuses the cached instance, and omitting the field falls back to the service default. Co-Authored-By: Claude Fable 5 --- README.md | 16 +++++++++++++--- app.py | 23 ++++++++++++++++++++++- bgfilter/service.py | 23 ++++++++++++++++------- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3ca2b22..dcc5aad 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ Two endpoints: - `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`. + - `cross_check` (optional) — `1`/`0` (also `true`/`false`/`yes`/`no`/`on`/`off`); + per-request override of the cross-model veto. Omit/empty to use the service + default. The response reports the effective state in `X-BGFilter-Cross-Check`. ```bash # default (birefnet + auto background colour) @@ -166,6 +169,10 @@ curl -sS \ -F "screen_color=#CFEFFF" \ -F "seg_model=anime-seg" \ http://127.0.0.1:18083/remove-background -o output.png + +# enable the cross-model veto for this request only (see below; slow on CPU) +curl -sS -F "file=@input.png" -F "cross_check=1" \ + http://127.0.0.1:18083/remove-background -o output.png ``` Config comes from `BGFILTER_CONFIG` (defaults to `configs/default.yaml` when present); @@ -174,9 +181,12 @@ caps input size (default ~4MP → `413`); `BGFILTER_PRELOAD=1` loads the default startup. Run a single worker (`--workers 1`) — each worker loads its own copy of the models. Unlike the CLI, the service defaults the **cross-model veto to off** — it adds one -extra large-model inference (~35 s/image on CPU, 14 s → 49 s total). Opt in with -`BGFILTER_CROSS_CHECK=1` (worth it on GPU or when hair-gap residue matters more than -latency); `/healthz` reports the effective state as `crossCheck`. +extra large-model inference (~35 s/image on CPU, 14 s → 49 s total). Enable it +per request with the `cross_check` form field, or flip the service default with +`BGFILTER_CROSS_CHECK=1` (worth it on GPU or when hair-gap residue matters more +than latency); `/healthz` reports the default as `crossCheck`. The cross-check +model is lazy-loaded: it is never preloaded at startup, so the first request that +enables it also pays the one-time model load (~1 GB) on top of the inference. ## Quality Check diff --git a/app.py b/app.py index 9130d80..d1b8ff5 100644 --- a/app.py +++ b/app.py @@ -129,6 +129,20 @@ def _normalize_seg_model(value: str | None) -> str | None: 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 _decode_image(data: bytes) -> np.ndarray: if not data: raise HTTPException(status_code=400, detail="empty image") @@ -162,12 +176,15 @@ 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), ): 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) + effective_cc = normalized_cc if normalized_cc is not None else _manager.cross_check_enabled rgb = _decode_image(await file.read()) @@ -178,7 +195,10 @@ async def remove_background( result = await loop.run_in_executor( None, lambda: _manager.process( - rgb, screen_color=normalized_color, seg_model=normalized_seg + rgb, + screen_color=normalized_color, + seg_model=normalized_seg, + cross_check=normalized_cc, ), ) except RuntimeError as exc: @@ -200,5 +220,6 @@ async def remove_background( "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", } return Response(content=png, media_type="image/png", headers=headers) diff --git a/bgfilter/service.py b/bgfilter/service.py index 1f4bea6..532d28f 100644 --- a/bgfilter/service.py +++ b/bgfilter/service.py @@ -24,9 +24,9 @@ class PipelineManager: 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. + ``seg_model``, ``cross_check``) never trigger a model reload -- ``screen_color`` + only affects this request's chroma/despill, and ``seg_model``/``cross_check`` + select an already-cached (or first-time-loaded, then cached) model. """ def __init__(self, base_settings: PipelineSettings, preload: bool = True): @@ -40,8 +40,8 @@ class PipelineManager: self._get_runner() if base_settings.segmentation.enabled: self._get_segmenter(base_settings.segmentation.backend) - if base_settings.cross_check.enabled: - self._get_cross_checker() + # The cross-check model is deliberately NOT preloaded: it is opt-in + # per request, so the first request that enables it pays the load. @property def default_seg_model(self) -> str: @@ -110,12 +110,21 @@ class PipelineManager: return self._cross_checker def process( - self, rgb: np.ndarray, *, screen_color: str | None, seg_model: str + self, + rgb: np.ndarray, + *, + screen_color: str | None, + seg_model: str, + cross_check: bool | None = None, ) -> 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 + self._base, + screen_color=screen_color, + seg_backend=seg_model, + # None = keep the service default; True/False = per-request override. + cross_check=cross_check, ) runner = ( self._get_runner()