vanshpatil16's picture
fix: stream /analyze with heartbeat to survive mobile carrier NAT
960d060
Raw
History Blame Contribute Delete
8.58 kB
"""FastAPI backend for DeepShield detection API.
Endpoints:
POST /analyze — upload image file, get EnsembleResult JSON
GET /health — liveness probe
GET /detectors — list active detectors and their config
GET /history — get analysis history
GET /history/{id} — get analysis by ID
GET /history/stats — get summary statistics
DELETE /history/{id} — delete analysis by ID
Run:
uvicorn backend.main:app --reload --port 8002
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import json
from fastapi import FastAPI, File, HTTPException, UploadFile, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from backend.core.pipeline import DetectionPipeline
from backend.core.schema import EnsembleResult
from backend.db.mongodb import connect_db, close_db, get_db
from backend.crud.analysis import (
save_analysis,
get_analysis_by_id,
get_analysis_history,
get_history_stats,
delete_analysis,
)
app = FastAPI(
title="DeepShield Detection API",
description="Multi-API ensemble AI image detector with history",
version="2.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
_pipelines: dict[str, DetectionPipeline] = {}
_ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
def _warmup_hybrid_model() -> None:
"""Pre-load PyTorch hybrid model weights in a background thread at startup."""
from backend.core.config import settings
if not settings.enable_hybrid_model:
return
try:
from backend.detectors.hybrid_model import _load_bundle, _model_bundles, _load_errors
for version in ("latest",):
if version not in _model_bundles:
bundle, err = _load_bundle(version)
_model_bundles[version] = bundle
_load_errors[version] = err
if err:
print(f" [hybrid_model] warmup skipped ({version}): {err}")
else:
print(f" [hybrid_model] warmed up ({version})")
except Exception as e:
print(f" [hybrid_model] warmup error: {e}")
@app.on_event("startup")
async def startup():
"""Initialize pipeline cache, warm up hybrid model, and connect MongoDB."""
for version in ("latest", "backup"):
_pipelines[version] = DetectionPipeline(model_version=version)
# Warm up hybrid model in background thread (non-blocking)
loop = asyncio.get_event_loop()
loop.run_in_executor(ThreadPoolExecutor(max_workers=1), _warmup_hybrid_model)
try:
await connect_db()
except Exception as e:
print(f"WARNING: MongoDB connection failed: {e}")
print(" Running without history persistence")
@app.on_event("shutdown")
async def shutdown():
"""Close MongoDB on app shutdown."""
await close_db()
def _get_pipeline(model_version: str = "latest") -> DetectionPipeline:
return _pipelines.get(model_version) or _pipelines["latest"]
@app.get("/health")
async def health():
return {"status": "ok", "version": "2.0.0"}
@app.get("/detectors")
async def list_detectors():
pipeline = _get_pipeline()
return {"active_detectors": pipeline.active_detectors}
@app.get("/models")
async def list_models():
"""List available hybrid model versions."""
return {
"available_models": ["latest", "backup"],
"default_model": "latest",
"description": "latest: most recent trained model, backup: previous stable version"
}
# Heartbeat cadence for the streamed /analyze response. Analysis runs 30s+;
# mobile carrier NAT drops connections idle for ~30s, so we emit a whitespace
# byte on this interval to keep the connection active. Leading whitespace is
# ignored by JSON parsers, so the client can parse the final payload directly.
_ANALYZE_HEARTBEAT_SECONDS = 5.0
@app.post("/analyze")
async def analyze(
file: UploadFile = File(...),
model_version: str = Query("latest", pattern="^(latest|backup)$"),
detectors: str | None = Query(None, description="Comma-separated detector names to use. Omit for all enabled detectors."),
fusion_strategy: str | None = Query(None, pattern="^(weighted|voting)$", description="Fusion strategy: 'weighted' (Bayesian log-odds) or 'voting' (majority vote). Overrides server default.")
) -> StreamingResponse:
ext = Path(file.filename or "").suffix.lower()
if ext not in _ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type '{ext}'. Allowed: {_ALLOWED_EXTENSIONS}",
)
enabled_detectors = (
[d.strip() for d in detectors.split(",") if d.strip()]
if detectors else None
)
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
content = await file.read()
tmp.write(content)
tmp_path = tmp.name
filename = file.filename or "unknown"
async def _run() -> EnsembleResult:
pipeline = _get_pipeline(model_version=model_version)
result = await pipeline.run(tmp_path, enabled_detectors=enabled_detectors, fusion_strategy=fusion_strategy)
try:
db = get_db()
await save_analysis(db, filename, result)
except Exception as db_err:
logging.getLogger(__name__).warning("MongoDB save failed: %s", db_err)
return result
async def stream():
# The connection is committed (200 + headers) once streaming starts, so
# errors after this point are delivered as a JSON {"error": ...} payload
# rather than an HTTP error status.
task = asyncio.create_task(_run())
try:
while True:
done, _ = await asyncio.wait({task}, timeout=_ANALYZE_HEARTBEAT_SECONDS)
if done:
break
yield b" " # heartbeat — keeps mobile carrier NAT from dropping the connection
result = task.result()
yield json.dumps(jsonable_encoder(result)).encode()
except FileNotFoundError as e:
yield json.dumps({"error": str(e)}).encode()
except Exception as e:
logging.getLogger(__name__).exception("Pipeline error")
yield json.dumps({"error": str(e)}).encode()
finally:
Path(tmp_path).unlink(missing_ok=True)
return StreamingResponse(stream(), media_type="application/json")
@app.get("/history")
async def get_history(limit: int = Query(100, ge=1, le=1000), skip: int = Query(0, ge=0)):
"""Get analysis history with pagination."""
try:
db = get_db()
history = await get_analysis_history(db, limit=limit, skip=skip)
# Convert ObjectId to string for JSON
for item in history:
item["_id"] = str(item["_id"])
return {"results": history, "limit": limit, "skip": skip}
except RuntimeError:
raise HTTPException(status_code=503, detail="Database not available")
@app.get("/history/stats")
async def get_stats():
"""Get summary statistics of all analyses."""
try:
db = get_db()
stats = await get_history_stats(db)
return {"stats": stats}
except RuntimeError:
raise HTTPException(status_code=503, detail="Database not available")
@app.get("/history/{analysis_id}")
async def get_analysis(analysis_id: str):
"""Get specific analysis by ID."""
try:
db = get_db()
result = await get_analysis_by_id(db, analysis_id)
if not result:
raise HTTPException(status_code=404, detail="Analysis not found")
result["_id"] = str(result["_id"])
return result
except RuntimeError:
raise HTTPException(status_code=503, detail="Database not available")
@app.delete("/history/{analysis_id}")
async def delete_analysis_endpoint(analysis_id: str):
"""Delete analysis by ID."""
try:
db = get_db()
deleted = await delete_analysis(db, analysis_id)
if not deleted:
raise HTTPException(status_code=404, detail="Analysis not found")
return {"message": "Analysis deleted"}
except RuntimeError:
raise HTTPException(status_code=503, detail="Database not available")