deepfake-detection / database.py
KLGrimmReaper's picture
v3 production-ready — full pipeline, API, async video, auth, CI/CD
872adca
Raw
History Blame Contribute Delete
16.8 kB
"""
database.py — SQLite persistence layer (v2).
Schema auto-migrates from v1 → v2 on first import so old data is preserved.
Tables
------
scans — one row per image/video submission
(includes per-face signals, image hash, model version)
scan_cache — SHA-256 → results mapping for instant re-upload replay
Each scan stores a JSON blob of per-face signals so the history tab can
show rich signal breakdowns for past analyses.
"""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from config import settings
logger = logging.getLogger(__name__)
DB_PATH = settings.db_path
CACHE_DB_PATH = settings.cache_db_path
# ── Schema version tracking ────────────────────────────────────────────────
SCHEMA_VERSION = 2 # bump when making backward-incompatible changes
# ======================================================================
# Initialisation & migration
# ======================================================================
def _get_connection(db: str = DB_PATH) -> sqlite3.Connection:
"""Return a connection with WAL mode + row factory."""
conn = sqlite3.connect(db, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db() -> None:
"""Idempotent initialisation — creates / migrates tables."""
conn = _get_connection(DB_PATH)
cursor = conn.cursor()
# ── Create (or migrate) the main scans table ──────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS scans_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
timestamp TEXT NOT NULL,
prediction_label TEXT NOT NULL,
confidence REAL NOT NULL,
faces_detected INTEGER NOT NULL DEFAULT 0,
saved_image_path TEXT,
image_hash TEXT,
model_version TEXT,
file_size_bytes INTEGER,
processing_time_ms REAL,
signals_json TEXT,
media_type TEXT DEFAULT 'image', -- 'image' | 'video'
notes TEXT
)
""")
# Check if old v1 table exists and migrate data if needed
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='scans'")
has_old = cursor.fetchone() is not None
if has_old:
# Migrate: copy old rows to v2 table (safe even if already done)
cursor.execute("""
INSERT OR IGNORE INTO scans_v2
(id, filename, timestamp, prediction_label, confidence,
faces_detected, saved_image_path)
SELECT id, filename, timestamp, prediction_label, confidence,
faces_detected, saved_image_path
FROM scans
""")
logger.info("Migrated rows from old 'scans' table to scans_v2")
# ── Cache table (separate DB for performance) ─────────────────────
cache_conn = _get_connection(CACHE_DB_PATH)
cache_conn.execute("""
CREATE TABLE IF NOT EXISTS scan_cache (
image_hash TEXT PRIMARY KEY,
model_version TEXT NOT NULL,
results_json TEXT NOT NULL,
created_at TEXT NOT NULL,
hit_count INTEGER DEFAULT 1
)
""")
cache_conn.execute("""
CREATE INDEX IF NOT EXISTS idx_cache_created
ON scan_cache(created_at)
""")
cache_conn.commit()
cache_conn.close()
# ── Schema version marker ─────────────────────────────────────────
cursor.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.commit()
conn.close()
logger.info("Database initialised (schema v%d)", SCHEMA_VERSION)
# ======================================================================
# Scan CRUD
# ======================================================================
def log_scan(
filename: str,
label: str,
confidence: float,
faces_detected: int,
saved_image_path: str = "",
image_hash: Optional[str] = None,
model_version: Optional[str] = None,
file_size_bytes: Optional[int] = None,
processing_time_ms: Optional[float] = None,
signals: Optional[List[Dict[str, Any]]] = None,
media_type: str = "image",
notes: str = "",
) -> int:
"""Insert a scan record and return its ID."""
conn = _get_connection()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
signals_json = json.dumps(signals) if signals else None
conn.execute("""
INSERT INTO scans_v2
(filename, timestamp, prediction_label, confidence, faces_detected,
saved_image_path, image_hash, model_version, file_size_bytes,
processing_time_ms, signals_json, media_type, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
filename, timestamp, label, confidence, faces_detected,
saved_image_path, image_hash, model_version, file_size_bytes,
processing_time_ms, signals_json, media_type, notes,
))
conn.commit()
row_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.close()
return row_id
def get_scan_history(
limit: int = 100,
offset: int = 0,
search: Optional[str] = None,
label_filter: Optional[str] = None,
media_type_filter: Optional[str] = None,
days_back: Optional[int] = None,
sort_by: str = "id",
sort_dir: str = "DESC",
) -> List[Tuple]:
"""
Retrieve scan records with optional filtering & pagination.
Parameters
----------
limit, offset : pagination
search : text search across filename & notes
label_filter : 'Real' | 'Fake' | 'No Face Detected'
media_type_filter : 'image' | 'video'
days_back : only records from the last N days
sort_by : column name (id, timestamp, confidence, …)
sort_dir : ASC | DESC
"""
conn = _get_connection()
where_clauses: List[str] = []
params: List[Any] = []
if search:
where_clauses.append("(filename LIKE ? OR notes LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if label_filter:
where_clauses.append("prediction_label = ?")
params.append(label_filter)
if media_type_filter:
where_clauses.append("media_type = ?")
params.append(media_type_filter)
if days_back is not None:
cutoff = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d %H:%M:%S")
where_clauses.append("timestamp >= ?")
params.append(cutoff)
# Validate sort column to prevent SQL injection
allowed_sorts = {"id", "timestamp", "confidence", "prediction_label", "filename", "faces_detected"}
sort_col = sort_by if sort_by in allowed_sorts else "id"
sort_d = "DESC" if sort_dir.upper() == "DESC" else "ASC"
where_sql = " AND ".join(where_clauses) if where_clauses else "1"
query = f"""
SELECT id, filename, timestamp, prediction_label, confidence,
faces_detected, saved_image_path, image_hash, model_version,
file_size_bytes, processing_time_ms, signals_json, media_type
FROM scans_v2
WHERE {where_sql}
ORDER BY {sort_col} {sort_d}
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
conn.close()
return [tuple(r) for r in rows]
def count_scans(
search: Optional[str] = None,
label_filter: Optional[str] = None,
media_type_filter: Optional[str] = None,
days_back: Optional[int] = None,
) -> int:
"""Return count of matching records (same filters as get_scan_history)."""
conn = _get_connection()
where_clauses: List[str] = []
params: List[Any] = []
if search:
where_clauses.append("(filename LIKE ? OR notes LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if label_filter:
where_clauses.append("prediction_label = ?")
params.append(label_filter)
if media_type_filter:
where_clauses.append("media_type = ?")
params.append(media_type_filter)
if days_back:
cutoff = (datetime.now() - timedelta(days=days_back)).strftime("%Y-%m-%d %H:%M:%S")
where_clauses.append("timestamp >= ?")
params.append(cutoff)
where_sql = " AND ".join(where_clauses) if where_clauses else "1"
count = conn.execute(
f"SELECT COUNT(*) FROM scans_v2 WHERE {where_sql}", params
).fetchone()[0]
conn.close()
return count
def delete_scan(scan_id: int) -> bool:
"""Delete a single scan record by ID. Returns True if a row was removed."""
conn = _get_connection()
cursor = conn.execute("DELETE FROM scans_v2 WHERE id = ?", (scan_id,))
deleted = cursor.rowcount > 0
conn.commit()
conn.close()
if deleted:
logger.info("Deleted scan record id=%d", scan_id)
return deleted
def delete_scan_by_image_hash(image_hash: str) -> int:
"""Delete all scans matching an image hash (dedup cleanup)."""
conn = _get_connection()
cursor = conn.execute("DELETE FROM scans_v2 WHERE image_hash = ?", (image_hash,))
count = cursor.rowcount
conn.commit()
conn.close()
if count:
logger.info("Deleted %d scan(s) with hash %s", count, image_hash[:12])
return count
def get_scan_by_id(scan_id: int) -> Optional[Dict[str, Any]]:
"""Return a single scan as a dict, or None."""
conn = _get_connection()
row = conn.execute(
"SELECT * FROM scans_v2 WHERE id = ?", (scan_id,)
).fetchone()
conn.close()
if row is None:
return None
return dict(row)
def get_stats() -> Dict[str, Any]:
"""Return aggregate stats for the dashboard."""
conn = _get_connection()
total = conn.execute("SELECT COUNT(*) FROM scans_v2").fetchone()[0]
fake_count = conn.execute(
"SELECT COUNT(*) FROM scans_v2 WHERE prediction_label = 'Fake'"
).fetchone()[0]
real_count = conn.execute(
"SELECT COUNT(*) FROM scans_v2 WHERE prediction_label = 'Real'"
).fetchone()[0]
noface_count = conn.execute(
"SELECT COUNT(*) FROM scans_v2 WHERE prediction_label = 'No Face Detected'"
).fetchone()[0]
video_count = conn.execute(
"SELECT COUNT(*) FROM scans_v2 WHERE media_type = 'video'"
).fetchone()[0]
# Average confidence
row = conn.execute(
"SELECT AVG(confidence) FROM scans_v2 WHERE prediction_label IN ('Real', 'Fake')"
).fetchone()
avg_conf = round(row[0], 1) if row and row[0] else 0.0
# Today's count
today = datetime.now().strftime("%Y-%m-%d")
today_count = conn.execute(
"SELECT COUNT(*) FROM scans_v2 WHERE timestamp LIKE ?", (f"{today}%",)
).fetchone()[0]
conn.close()
return {
"total_scans": total,
"fake": fake_count,
"real": real_count,
"no_face": noface_count,
"videos": video_count,
"avg_confidence": avg_conf,
"today": today_count,
"model_version": settings.model_version,
}
# ======================================================================
# Image-hash cache
# ======================================================================
def get_cached_result(image_hash: str) -> Optional[Dict[str, Any]]:
"""
Return cached analysis results for an image hash, or None.
Cache is invalidated when model_version changes.
"""
conn = _get_connection(CACHE_DB_PATH)
row = conn.execute(
"SELECT * FROM scan_cache WHERE image_hash = ?",
(image_hash,),
).fetchone()
if row is None:
conn.close()
return None
row = dict(row)
# Invalidate if model changed
if row["model_version"] != settings.model_version:
conn.execute("DELETE FROM scan_cache WHERE image_hash = ?", (image_hash,))
conn.commit()
conn.close()
return None
# Bump hit count
conn.execute(
"UPDATE scan_cache SET hit_count = hit_count + 1 WHERE image_hash = ?",
(image_hash,),
)
conn.commit()
conn.close()
return json.loads(row["results_json"])
def set_cached_result(image_hash: str, results: Dict[str, Any]) -> None:
"""Store analysis results in cache."""
conn = _get_connection(CACHE_DB_PATH)
conn.execute("""
INSERT OR REPLACE INTO scan_cache (image_hash, model_version, results_json, created_at)
VALUES (?, ?, ?, ?)
""", (
image_hash,
settings.model_version,
json.dumps(results),
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
))
conn.commit()
conn.close()
def clear_cache(older_than_days: Optional[int] = None) -> int:
"""Clear cache entries. Returns number of deleted rows."""
conn = _get_connection(CACHE_DB_PATH)
if older_than_days:
cutoff = (datetime.now() - timedelta(days=older_than_days)).strftime("%Y-%m-%d %H:%M:%S")
cursor = conn.execute("DELETE FROM scan_cache WHERE created_at < ?", (cutoff,))
else:
cursor = conn.execute("DELETE FROM scan_cache")
count = cursor.rowcount
conn.commit()
conn.close()
logger.info("Cleared %d cache entries", count)
return count
def get_cache_stats() -> Dict[str, Any]:
"""Return cache hit/miss info."""
conn = _get_connection(CACHE_DB_PATH)
total = conn.execute("SELECT COUNT(*) FROM scan_cache").fetchone()[0]
total_hits = conn.execute("SELECT SUM(hit_count) FROM scan_cache").fetchone()[0] or 0
conn.close()
return {"cached_images": total, "total_cache_hits": total_hits}
# ======================================================================
# Disk usage helper
# ======================================================================
def get_disk_usage() -> Dict[str, Any]:
"""Report size of scans directory and DB files."""
scans_size = 0
if os.path.isdir(settings.scans_dir):
for dirpath, _, filenames in os.walk(settings.scans_dir):
for f in filenames:
try:
scans_size += os.path.getsize(os.path.join(dirpath, f))
except OSError:
pass
db_size = 0
for p in (settings.db_path, settings.cache_db_path):
if os.path.exists(p):
try:
db_size += os.path.getsize(p)
except OSError:
pass
def fmt(b: int) -> str:
if b < 1024:
return f"{b} B"
elif b < 1024 ** 2:
return f"{b / 1024:.1f} KB"
else:
return f"{b / 1024 ** 2:.1f} MB"
return {
"scans_dir": fmt(scans_size),
"database": fmt(db_size),
"scans_dir_bytes": scans_size,
"database_bytes": db_size,
}
# ======================================================================
# Old-scans cleanup (call periodically or at startup)
# ======================================================================
def cleanup_old_scans(dry_run: bool = False) -> int:
"""
Delete scan records older than ``max_scan_age_days`` and their
associated image files. Returns the number of records removed.
"""
cutoff = (datetime.now() - timedelta(days=settings.max_scan_age_days)).strftime("%Y-%m-%d %H:%M:%S")
conn = _get_connection()
rows = conn.execute(
"SELECT id, saved_image_path FROM scans_v2 WHERE timestamp < ?",
(cutoff,),
).fetchall()
removed = 0
for row in rows:
scan_id, img_path = row["id"], row["saved_image_path"]
if not dry_run:
# Delete image file
if img_path and os.path.exists(img_path):
try:
os.remove(img_path)
except OSError as e:
logger.warning("Could not remove %s: %s", img_path, e)
# Delete from DB
conn.execute("DELETE FROM scans_v2 WHERE id = ?", (scan_id,))
removed += 1
if not dry_run:
conn.commit()
conn.close()
if removed:
logger.info("Cleanup: removed %d old scan(s) (cutoff=%s)", removed, cutoff)
return removed
# ── Run init on import so callers don't have to remember ───────────────────
init_db()