| """ |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
| logger = logging.getLogger("api") |
|
|
|
|
| |
| |
| |
| @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 = 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, |
| ) |
|
|
| |
| if HAS_SLOWAPI: |
| app.state.limiter = _limiter |
| app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| @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. |
| """ |
| |
| 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}", |
| ) |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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}") |
|
|
| |
| saved_path = _save_upload(contents, ext) |
|
|
| |
| 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) |
|
|
| |
| 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, |
| } |
|
|
|
|
| |
| if HAS_SLOWAPI: |
| analyze_image = _limiter.limit("10/minute")(analyze_image) |
|
|
|
|
| |
| |
| |
|
|
| @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", |
| ) |
|
|
| |
| 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_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), |
| } |
|
|
|
|
| |
| if HAS_SLOWAPI: |
| analyze_video = _limiter.limit("2/minute")(analyze_video) |
|
|
|
|
| |
| |
| |
|
|
| @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 |
| ], |
| } |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/api/stats", summary="Dashboard statistics") |
| async def stats(): |
| """Return aggregate detection statistics.""" |
| return get_stats() |
|
|
|
|
| |
| |
| |
|
|
| @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), |
| } |
|
|
| |
| 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" |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| @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} |
|
|