Spaces:
Running
Running
| """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}") | |
| 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") | |
| 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"] | |
| async def health(): | |
| return {"status": "ok", "version": "2.0.0"} | |
| async def list_detectors(): | |
| pipeline = _get_pipeline() | |
| return {"active_detectors": pipeline.active_detectors} | |
| 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 | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |
| 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") | |