""" api.py — FastAPI REST wrapper for the Deepfake Detection System. Provides programmatic access to all detector functionality so that external services (mobile apps, browser extensions, CI pipelines) can submit images and retrieve results without going through the Streamlit UI. Quick start ----------- pip install fastapi uvicorn python-multipart uvicorn api:app --host 0.0.0.0 --port 8000 --reload Endpoints --------- POST /api/analyze — analyze a single image POST /api/analyze/video — analyze a video file GET /api/history — scan history (with filters) GET /api/stats — aggregate dashboard stats GET /api/health — health check & model status GET /api/cache — cache statistics DELETE /api/cache — clear the image cache """ from __future__ import annotations import io import logging import os import time import uuid from contextlib import asynccontextmanager from typing import Any, Dict, List, Optional from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from config import settings from database import ( cleanup_old_scans, clear_cache, count_scans, get_cache_stats, get_scan_history, get_stats, init_db, log_scan, ) from detector import detect_faces_and_analyze, process_video # ── Rate limiting ────────────────────────────────────────────────────── try: from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address _limiter = Limiter(key_func=get_remote_address) HAS_SLOWAPI = True except ImportError: _limiter = None HAS_SLOWAPI = False # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logger = logging.getLogger("api") # --------------------------------------------------------------------------- # Lifespan (replaces deprecated on_event) # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): logger.info("API starting — model v%s", settings.model_version) init_db() os.makedirs(settings.scans_dir, exist_ok=True) yield logger.info("API shutting down") # --------------------------------------------------------------------------- # App instance # --------------------------------------------------------------------------- app = FastAPI( title="DeepGuard AI — Deepfake Detection API", version=settings.model_version, description="REST API for AI-powered deepfake detection in images and videos.", lifespan=lifespan, ) # ── Rate limiting ───────────────────────────────────────────────────── if HAS_SLOWAPI: app.state.limiter = _limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore # CORS — allow any origin for dev; lock down in production app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ====================================================================== # Helpers # ====================================================================== def _save_upload(file_bytes: bytes, ext: str = ".jpg") -> str: """Save uploaded bytes to the scans directory and return the path.""" filename = f"{uuid.uuid4().hex}{ext}" path = os.path.join(settings.scans_dir, filename) with open(path, "wb") as f: f.write(file_bytes) return path # ====================================================================== # POST /api/analyze # ====================================================================== @app.post("/api/analyze", summary="Analyze a single image") async def analyze_image( request: Request, file: UploadFile = File(..., description="Image file (jpg, png, jpeg)"), ): """ Upload an image and run the full deepfake detection pipeline. Returns per-face results with signal breakdown, processing metadata, and quality warnings. """ # ── Validate extension ────────────────────────────────────────── ext = os.path.splitext(file.filename or "image.jpg")[1].lower() if ext not in settings.allowed_image_extensions: raise HTTPException( status_code=400, detail=f"Unsupported extension '{ext}'. Allowed: {settings.allowed_image_extensions}", ) # ── Read ──────────────────────────────────────────────────────── try: contents = await file.read() except Exception as e: raise HTTPException(status_code=400, detail=f"Could not read file: {e}") if len(contents) > settings.max_upload_size_mb * 1024 * 1024: raise HTTPException( status_code=413, detail=f"File too large ({len(contents) / 1024 / 1024:.1f} MB). Limit: {settings.max_upload_size_mb} MB", ) # ── Analyze ───────────────────────────────────────────────────── try: t0 = time.perf_counter() annotated_img, face_count, results, quality_warnings, metadata = \ detect_faces_and_analyze(contents) elapsed = time.perf_counter() - t0 except Exception as e: logger.exception("Analysis failed for %s", file.filename) raise HTTPException(status_code=500, detail=f"Analysis error: {e}") # ── Save original image ───────────────────────────────────────── saved_path = _save_upload(contents, ext) # ── Save annotated image ──────────────────────────────────────── annotated_filename = f"annotated_{os.path.basename(saved_path)}" annotated_path = os.path.join(settings.scans_dir, annotated_filename) cv2_annotated = annotated_img import cv2 cv2.imwrite(annotated_path, cv2_annotated) # ── Log to DB ─────────────────────────────────────────────────── primary_label = results[0]["label"] if results else "No Face Detected" primary_conf = results[0]["confidence"] if results else 0.0 log_scan( file.filename or "unknown", primary_label, primary_conf, face_count, annotated_path, image_hash=metadata.get("image_hash"), model_version=metadata["model_version"], file_size_bytes=len(contents), processing_time_ms=metadata["processing_time_ms"], signals=results, ) return { "status": "ok", "filename": file.filename, "face_count": face_count, "quality_warnings": quality_warnings, "results": results, "metadata": { **metadata, "total_processing_time_s": round(elapsed, 3), }, "annotated_image_path": annotated_path, } # Apply rate limiting after definition (conditional) if HAS_SLOWAPI: analyze_image = _limiter.limit("10/minute")(analyze_image) # ====================================================================== # POST /api/analyze/video # ====================================================================== @app.post("/api/analyze/video", summary="Analyze a video file") async def analyze_video( file: UploadFile = File(..., description="Video file (mp4, avi, mov, webm, mkv)"), sample_rate: int = Query(30, ge=1, le=120, description="Process every Nth frame"), ): """ Upload a video and run deepfake detection across frames. Returns per-face tracks, temporal aggregation summary, and paths to sample annotated frames. """ ext = os.path.splitext(file.filename or "video.mp4")[1].lower() if ext not in settings.allowed_video_extensions: raise HTTPException( status_code=400, detail=f"Unsupported extension '{ext}'. Allowed: {settings.allowed_video_extensions}", ) try: contents = await file.read() except Exception as e: raise HTTPException(status_code=400, detail=f"Could not read file: {e}") if len(contents) > settings.max_upload_size_mb * 1024 * 1024: raise HTTPException( status_code=413, detail=f"File too large ({len(contents) / 1024 / 1024:.1f} MB). Limit: {settings.max_upload_size_mb} MB", ) # Save to temp file video_path = _save_upload(contents, ext) try: t0 = time.perf_counter() result = process_video(video_path, sample_rate=sample_rate) elapsed = time.perf_counter() - t0 except Exception as e: logger.exception("Video analysis failed for %s", file.filename) raise HTTPException(status_code=500, detail=f"Video analysis error: {e}") summary = result.get("summary", {}) # Log to DB log_scan( file.filename or "unknown_video", summary.get("verdict", "N/A"), summary.get("confidence", 0.0), summary.get("total_tracked_faces", 0), "", model_version=settings.model_version, file_size_bytes=len(contents), media_type="video", ) return { "status": "ok", "filename": file.filename, "video_info": result.get("video_info"), "summary": summary, "face_tracks": [ { "id": t["id"], "majority_label": t.get("majority_label"), "avg_model_score": round(t.get("avg_model_score", 0), 1), "frame_count": t.get("frame_count", 0), "first_frame": t.get("first_frame", 0), "last_frame": t.get("last_frame", 0), } for t in result.get("face_tracks", []) ], "annotated_frame_paths": result.get("annotated_frame_paths", []), "processing_time_s": round(elapsed, 1), } # Apply rate limiting after definition (conditional) if HAS_SLOWAPI: analyze_video = _limiter.limit("2/minute")(analyze_video) # ====================================================================== # GET /api/history # ====================================================================== @app.get("/api/history", summary="Scan history") async def history( limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), search: Optional[str] = Query(None), label: Optional[str] = Query(None), media_type: Optional[str] = Query(None), days: Optional[int] = Query(None, ge=1), ): """ Return scan history with optional filtering and pagination. """ rows = get_scan_history( limit=limit, offset=offset, search=search, label_filter=label, media_type_filter=media_type, days_back=days, ) total = count_scans( search=search, label_filter=label, media_type_filter=media_type, days_back=days, ) return { "total": total, "limit": limit, "offset": offset, "results": [ { "id": r[0], "filename": r[1], "timestamp": r[2], "label": r[3], "confidence": r[4], "faces_detected": r[5], "image_hash": r[7][:16] if r[7] else None, "model_version": r[8], "file_size_bytes": r[9], "processing_time_ms": r[10], "media_type": r[12], } for r in rows ], } # ====================================================================== # GET /api/stats # ====================================================================== @app.get("/api/stats", summary="Dashboard statistics") async def stats(): """Return aggregate detection statistics.""" return get_stats() # ====================================================================== # GET /api/health # ====================================================================== @app.get("/api/health", summary="Health check") async def health(): """Health check endpoint — verifies model loading and DB connectivity.""" from database import _get_connection health_status: Dict[str, Any] = { "status": "ok", "model_version": settings.model_version, "models_loaded": len([m for m in models if m is not None]), "models_configured": len(settings.model_ids), } # DB check try: conn = _get_connection() conn.execute("SELECT 1") conn.close() health_status["database"] = "ok" except Exception as e: health_status["database"] = f"error: {e}" health_status["status"] = "degraded" # Model check from detector import models loaded = sum(1 for m in models if m is not None) health_status["models_loaded"] = loaded health_status["models_configured"] = len(settings.model_ids) if loaded == 0: health_status["status"] = "degraded" status_code = 200 if health_status["status"] == "ok" else 503 return JSONResponse(content=health_status, status_code=status_code) # ====================================================================== # Cache endpoints # ====================================================================== @app.get("/api/cache", summary="Cache statistics") async def cache_stats(): """Return image-cache statistics.""" return get_cache_stats() @app.delete("/api/cache", summary="Clear cache") async def cache_clear(): """Clear the entire image cache.""" count = clear_cache() return {"status": "ok", "cleared_entries": count}