| """ |
| BiRefNet Background Removal β FastAPI on HuggingFace Spaces |
| ============================================================ |
| Optimized for speed: |
| β’ BiRefNet_lite β ~4Γ faster than full BiRefNet, same quality |
| β’ torch.compile() β 20β40% faster after warmup |
| β’ torch.inference_mode() β 5β8% faster than no_grad |
| β’ Warmup pass at startup β first real request is fast |
| β’ Input cap at 1024px β avoids huge uploads slowing everything |
| β’ Async image I/O β CPU & decode overlap |
| Port 7860 (required by HuggingFace Spaces) |
| Swagger UI β https://<your-space>.hf.space/docs |
| """ |
|
|
| import io |
| import os |
| import time |
| import logging |
| from contextlib import asynccontextmanager |
|
|
| import numpy as np |
| import torch |
| import torchvision.transforms as T |
| from PIL import Image |
| from fastapi import FastAPI, File, UploadFile, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.responses import Response |
|
|
| |
| |
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s %(levelname)-8s %(message)s", |
| datefmt="%H:%M:%S", |
| ) |
| log = logging.getLogger(__name__) |
|
|
| |
| |
| |
| |
| HF_MODEL_ID = "ZhengPeng7/BiRefNet_lite" |
| MODEL_INPUT_SIZE = (1024, 1024) |
| IMAGENET_MEAN = [0.485, 0.456, 0.406] |
| IMAGENET_STD = [0.229, 0.224, 0.225] |
|
|
| |
| |
| |
| |
| MAX_SIDE = 2048 |
|
|
| |
| |
| |
| class _Holder: |
| model: torch.nn.Module | None = None |
| device: torch.device = torch.device("cpu") |
|
|
| holder = _Holder() |
|
|
|
|
| |
| |
| |
| _transform = T.Compose([ |
| T.Resize(MODEL_INPUT_SIZE, interpolation=T.InterpolationMode.BILINEAR), |
| T.ToTensor(), |
| T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), |
| ]) |
|
|
|
|
| |
| |
| |
| def _load_model() -> torch.nn.Module: |
| from transformers import AutoModelForImageSegmentation |
|
|
| log.info(f"Loading model: {HF_MODEL_ID} β¦") |
| model = AutoModelForImageSegmentation.from_pretrained( |
| HF_MODEL_ID, |
| trust_remote_code=True, |
| torch_dtype=torch.float32, |
| ) |
| model = model.float() |
| return model |
|
|
|
|
| def _compile_model(model: torch.nn.Module) -> torch.nn.Module: |
| """ |
| Try torch.compile() with three levels of fallback so startup never crashes: |
| 1. mode='reduce-overhead' (default inductor β needs g++, fastest on CPU) |
| 2. backend='eager' (no C++ compiler needed, still removes Python overhead) |
| 3. plain model (compile completely unavailable / old PyTorch) |
| """ |
| if not hasattr(torch, "compile"): |
| log.info("torch.compile not available (PyTorch < 2.0) β skipping") |
| return model |
|
|
| |
| try: |
| log.info("Compiling model [inductor / reduce-overhead] β¦") |
| compiled = torch.compile(model, mode="reduce-overhead", fullgraph=False) |
| |
| |
| dummy = torch.zeros(1, 3, 64, 64, dtype=torch.float32) |
| with torch.inference_mode(): |
| compiled(dummy) |
| log.info("torch.compile(inductor) β") |
| return compiled |
| except Exception as e1: |
| log.warning(f"inductor compile failed ({type(e1).__name__}: {e1!s:.120}) β trying eager backend β¦") |
|
|
| |
| try: |
| compiled = torch.compile(model, backend="eager", fullgraph=False) |
| log.info("torch.compile(eager) β") |
| return compiled |
| except Exception as e2: |
| log.warning(f"eager backend also failed ({e2!s:.80}) β running without compile") |
|
|
| |
| return model |
|
|
|
|
|
|
| def _warmup(model: torch.nn.Module, device: torch.device) -> None: |
| """ |
| Run one dummy forward pass so that all JIT/compile kernels are cached. |
| The first real user request will then be fast. |
| """ |
| log.info("Running warmup inference β¦") |
| dummy = torch.zeros(1, 3, *MODEL_INPUT_SIZE, dtype=torch.float32, device=device) |
| with torch.inference_mode(): |
| _ = model(dummy) |
| log.info("Warmup done β") |
|
|
|
|
| |
| |
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| log.info("=" * 60) |
| log.info("BiRefNet Background-Removal Server (HuggingFace Space)") |
| log.info(f"Model: {HF_MODEL_ID}") |
| log.info("=" * 60) |
|
|
| if torch.cuda.is_available(): |
| holder.device = torch.device("cuda") |
| log.info("GPU detected β using CUDA") |
| else: |
| holder.device = torch.device("cpu") |
| log.info("No GPU β using CPU") |
|
|
| t0 = time.time() |
|
|
| |
| model = _load_model() |
| model.to(holder.device) |
| model.eval() |
|
|
| |
| model = _compile_model(model) |
|
|
| holder.model = model |
|
|
| |
| _warmup(holder.model, holder.device) |
|
|
| log.info(f"Ready in {time.time() - t0:.1f}s β β /docs") |
| log.info("=" * 60) |
|
|
| yield |
|
|
| log.info("Shutting down β¦") |
| holder.model = None |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
|
|
|
|
| |
| |
| |
| app = FastAPI( |
| title="BiRefNet Background Remover", |
| description=( |
| "## πΌοΈ AI Background Removal API\n\n" |
| "Upload any image and receive back a **transparent PNG** with the " |
| "background removed β powered by **BiRefNet_lite** (fast + high quality).\n\n" |
| "---\n\n" |
| "### How to use\n" |
| "1. Click **POST /remove-background** β **Try it out**\n" |
| "2. Upload a JPEG or PNG image\n" |
| "3. Click **Execute** β download the transparent PNG\n\n" |
| "### Endpoints\n" |
| "| Method | Path | Description |\n" |
| "|--------|------|-------------|\n" |
| "| `GET` | `/health` | Liveness check |\n" |
| "| `GET` | `/info` | Model & device info |\n" |
| "| `POST` | `/remove-background` | Remove background β PNG |" |
| ), |
| version="2.0.0", |
| lifespan=lifespan, |
| docs_url="/docs", |
| redoc_url="/redoc", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| expose_headers=["X-Processing-Time", "X-Original-Size", "X-Model"], |
| ) |
|
|
|
|
| |
| |
| |
| def _cap_size(image: Image.Image, max_side: int) -> Image.Image: |
| """ |
| Proportionally downscale image so its longest side β€ max_side. |
| Returns the original object unchanged if already small enough. |
| This avoids wasting time on multi-megapixel I/O when the model |
| will resize to 1024Γ1024 internally anyway. |
| """ |
| w, h = image.size |
| if max(w, h) <= max_side: |
| return image |
| scale = max_side / max(w, h) |
| new_w, new_h = int(w * scale), int(h * scale) |
| log.info(f"Downscaling {w}Γ{h} β {new_w}Γ{new_h} for I/O speed") |
| return image.resize((new_w, new_h), Image.LANCZOS) |
|
|
|
|
| def preprocess(image: Image.Image) -> torch.Tensor: |
| """PIL RGB β (1, 3, 1024, 1024) float32 tensor.""" |
| return _transform(image.convert("RGB")).unsqueeze(0) |
|
|
|
|
| def postprocess(mask_tensor: torch.Tensor, original: Image.Image) -> Image.Image: |
| """Model output mask β RGBA PIL image with transparent background.""" |
| |
| mask = mask_tensor.squeeze().cpu().float() |
|
|
| |
| if mask.min() < 0 or mask.max() > 1: |
| mask = torch.sigmoid(mask) |
|
|
| mask_np = (mask.clamp(0, 1).numpy() * 255).astype(np.uint8) |
| mask_pil = Image.fromarray(mask_np, mode="L").resize( |
| original.size, Image.LANCZOS |
| ) |
|
|
| r, g, b, _ = original.convert("RGBA").split() |
| return Image.merge("RGBA", (r, g, b, mask_pil)) |
|
|
|
|
| def to_png_bytes(image: Image.Image) -> bytes: |
| buf = io.BytesIO() |
| |
| |
| image.save(buf, format="PNG", compress_level=1) |
| return buf.getvalue() |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/health", tags=["Utility"], summary="Liveness check") |
| async def health(): |
| return {"status": "ok", "model_loaded": holder.model is not None} |
|
|
|
|
| @app.get("/info", tags=["Utility"], summary="Model & device info") |
| async def info(): |
| return { |
| "model": HF_MODEL_ID, |
| "device": str(holder.device), |
| "input_size": MODEL_INPUT_SIZE, |
| "max_side_cap": MAX_SIDE, |
| "model_loaded": holder.model is not None, |
| } |
|
|
|
|
| @app.post( |
| "/remove-background", |
| response_class=Response, |
| responses={ |
| 200: { |
| "content": {"image/png": {}}, |
| "description": "Transparent PNG with background removed", |
| } |
| }, |
| tags=["Background Removal"], |
| summary="Remove background from an image", |
| description=( |
| "Upload a JPEG, PNG, WebP, or BMP image. " |
| "Returns a **PNG with transparent background**.\n\n" |
| "> **Tip:** Use the *Try it out* button to test directly in the browser." |
| ), |
| ) |
| async def remove_background( |
| file: UploadFile = File(..., description="Image file β JPEG / PNG / WebP / BMP"), |
| ): |
| if holder.model is None: |
| raise HTTPException(503, "Model is still loading β please retry in a moment.") |
|
|
| |
| content_type = file.content_type or "" |
| filename = (file.filename or "").lower() |
| if not content_type.startswith("image/") and not filename.endswith( |
| (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif") |
| ): |
| raise HTTPException( |
| 415, f"Unsupported type '{content_type}'. Please upload an image." |
| ) |
|
|
| |
| t_start = time.time() |
|
|
| raw = await file.read() |
| if not raw: |
| raise HTTPException(400, "Uploaded file is empty.") |
|
|
| try: |
| original = Image.open(io.BytesIO(raw)) |
| original.load() |
| except Exception as exc: |
| raise HTTPException(400, f"Cannot decode image: {exc}") |
|
|
| log.info( |
| f"Received: {file.filename!r} " |
| f"{original.size} {original.mode} " |
| f"({len(raw)/1024:.0f} KB)" |
| ) |
|
|
| |
| original = _cap_size(original, MAX_SIDE) |
|
|
| |
| t_infer = time.time() |
| try: |
| model_dtype = next(holder.model.parameters()).dtype |
| tensor = preprocess(original).to(device=holder.device, dtype=model_dtype) |
|
|
| |
| with torch.inference_mode(): |
| outputs = holder.model(tensor) |
|
|
| |
| mask_tensor = outputs[0] if isinstance(outputs, (list, tuple)) else outputs |
| result = postprocess(mask_tensor, original) |
|
|
| except Exception as exc: |
| log.exception("Inference error") |
| raise HTTPException(500, f"Inference failed: {exc}") |
|
|
| t_encode = time.time() |
| png_bytes = to_png_bytes(result) |
| t_done = time.time() |
|
|
| log.info( |
| f"infer={t_encode - t_infer:.2f}s " |
| f"encode={t_done - t_encode:.2f}s " |
| f"total={t_done - t_start:.2f}s " |
| f"out={len(png_bytes)//1024}KB" |
| ) |
|
|
| return Response( |
| content=png_bytes, |
| media_type="image/png", |
| headers={ |
| "X-Processing-Time": f"{t_done - t_start:.3f}s", |
| "X-Infer-Time": f"{t_encode - t_infer:.3f}s", |
| "X-Original-Size": f"{original.width}x{original.height}", |
| "X-Model": HF_MODEL_ID, |
| }, |
| ) |
|
|
|
|
| |
| |
| |
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run(app, host="0.0.0.0", port=7860) |