KLGrimmReaper's picture
v3 production-ready — full pipeline, API, async video, auth, CI/CD
872adca
Raw
History Blame Contribute Delete
14.4 kB
"""
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}