Spaces:
Sleeping
Sleeping
Upload 9 files
Browse filesInitial deployment: DeepGuard AI Forensics Backend
- Dockerfile +26 -0
- LICENSE +13 -0
- download_model.py +93 -0
- ela.py +51 -0
- heatmap.py +220 -0
- inference.py +169 -0
- main.py +217 -0
- metadata.py +103 -0
- requirements.txt +10 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies required for OpenCV and ONNX
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
libgl1-mesa-glx \
|
| 9 |
+
libglib2.0-0 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements and install
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
# Copy all backend files into the container
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Download the ONNX model inside the cloud container
|
| 20 |
+
RUN python download_model.py
|
| 21 |
+
|
| 22 |
+
# Expose the port FastAPI runs on
|
| 23 |
+
EXPOSE 8000
|
| 24 |
+
|
| 25 |
+
# Command to run the application (Hugging Face Spaces expects 0.0.0.0)
|
| 26 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Copyright (c) 2026. All Rights Reserved.
|
| 2 |
+
|
| 3 |
+
This software and associated documentation files (the "Software") are proprietary.
|
| 4 |
+
|
| 5 |
+
You are permitted to view the source code for educational or portfolio evaluation purposes.
|
| 6 |
+
|
| 7 |
+
However, you may NOT use, copy, modify, distribute, or run this Software without explicit, written permission from the author.
|
| 8 |
+
|
| 9 |
+
If permission is granted by the author, you MUST give full credit and attribution to the original author in any public display or distribution of the work.
|
| 10 |
+
|
| 11 |
+
To request permission for use, please contact the author directly.
|
| 12 |
+
|
| 13 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE.
|
download_model.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepGuard — Model Download Script
|
| 3 |
+
Downloads the ViT-based deepfake detection ONNX model from Hugging Face Hub.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python download_model.py
|
| 7 |
+
|
| 8 |
+
Model: onnx-community/Deep-Fake-Detector-v2-Model-ONNX
|
| 9 |
+
- Architecture: google/vit-base-patch16-224-in21k (fine-tuned)
|
| 10 |
+
- Task: Binary classification — Realism vs. Deepfake
|
| 11 |
+
- Labels: {0: "Realism", 1: "Deepfake"}
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import hashlib
|
| 17 |
+
|
| 18 |
+
MODELS_DIR = os.path.join(os.path.dirname(__file__), "models")
|
| 19 |
+
MODEL_DEST = os.path.join(MODELS_DIR, "deepfake_vit.onnx")
|
| 20 |
+
|
| 21 |
+
REPO_ID = "onnx-community/Deep-Fake-Detector-v2-Model-ONNX"
|
| 22 |
+
FILENAME = "onnx/model.onnx" # Path inside the HF repo
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def sha256(path: str) -> str:
|
| 26 |
+
h = hashlib.sha256()
|
| 27 |
+
with open(path, "rb") as f:
|
| 28 |
+
for chunk in iter(lambda: f.read(65536), b""):
|
| 29 |
+
h.update(chunk)
|
| 30 |
+
return h.hexdigest()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def download():
|
| 34 |
+
os.makedirs(MODELS_DIR, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
if os.path.exists(MODEL_DEST):
|
| 37 |
+
size_mb = os.path.getsize(MODEL_DEST) / (1024 * 1024)
|
| 38 |
+
print(f"[DeepGuard] Model already exists ({size_mb:.1f} MB): {MODEL_DEST}")
|
| 39 |
+
print(f"[DeepGuard] SHA-256: {sha256(MODEL_DEST)}")
|
| 40 |
+
print("[DeepGuard] Delete the file and re-run this script to force re-download.")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
print(f"[DeepGuard] Downloading model from Hugging Face Hub...")
|
| 44 |
+
print(f" Repo: {REPO_ID}")
|
| 45 |
+
print(f" File: {FILENAME}")
|
| 46 |
+
print(f" Target: {MODEL_DEST}")
|
| 47 |
+
print()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
from huggingface_hub import hf_hub_download
|
| 51 |
+
|
| 52 |
+
tmp_path = hf_hub_download(
|
| 53 |
+
repo_id=REPO_ID,
|
| 54 |
+
filename=FILENAME,
|
| 55 |
+
cache_dir=MODELS_DIR,
|
| 56 |
+
local_dir=MODELS_DIR,
|
| 57 |
+
local_dir_use_symlinks=False,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# hf_hub_download writes to local_dir/<filename>
|
| 61 |
+
# Move if needed
|
| 62 |
+
expected_local = os.path.join(MODELS_DIR, "onnx", "model.onnx")
|
| 63 |
+
if os.path.exists(expected_local) and not os.path.exists(MODEL_DEST):
|
| 64 |
+
import shutil
|
| 65 |
+
shutil.move(expected_local, MODEL_DEST)
|
| 66 |
+
|
| 67 |
+
if not os.path.exists(MODEL_DEST):
|
| 68 |
+
# Try symlink/copy from tmp_path
|
| 69 |
+
import shutil
|
| 70 |
+
shutil.copy2(tmp_path, MODEL_DEST)
|
| 71 |
+
|
| 72 |
+
size_mb = os.path.getsize(MODEL_DEST) / (1024 * 1024)
|
| 73 |
+
checksum = sha256(MODEL_DEST)
|
| 74 |
+
print(f"\n[DeepGuard] [OK] Download complete!")
|
| 75 |
+
print(f" Size: {size_mb:.1f} MB")
|
| 76 |
+
print(f" SHA-256: {checksum}")
|
| 77 |
+
print(f" Path: {MODEL_DEST}")
|
| 78 |
+
|
| 79 |
+
except ImportError:
|
| 80 |
+
print("[ERROR] huggingface_hub not installed. Run: pip install huggingface_hub")
|
| 81 |
+
sys.exit(1)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"[ERROR] Download failed: {e}")
|
| 84 |
+
print()
|
| 85 |
+
print("Manual download instructions:")
|
| 86 |
+
print(f" 1. Visit: https://huggingface.co/{REPO_ID}/tree/main/onnx")
|
| 87 |
+
print(f" 2. Download 'model.onnx'")
|
| 88 |
+
print(f" 3. Place it at: {MODEL_DEST}")
|
| 89 |
+
sys.exit(1)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
download()
|
ela.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import base64
|
| 3 |
+
from PIL import Image, ImageChops, ImageEnhance
|
| 4 |
+
|
| 5 |
+
def generate_ela(image: Image.Image, quality: int = 90, scale: float = 15.0) -> str:
|
| 6 |
+
"""
|
| 7 |
+
Performs Error Level Analysis (ELA) on an image to highlight manipulated regions.
|
| 8 |
+
Saves the image as a temporary JPEG at a specific quality level and compares
|
| 9 |
+
it with the original to find compression differences.
|
| 10 |
+
|
| 11 |
+
Args:
|
| 12 |
+
image: Original PIL Image.
|
| 13 |
+
quality: JPEG compression quality for the resaved image (default 90).
|
| 14 |
+
scale: Brightness multiplier to make the differences visible (default 15.0).
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
Base64-encoded string of the ELA image.
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
# Convert to RGB if necessary
|
| 21 |
+
if image.mode != "RGB":
|
| 22 |
+
image = image.convert("RGB")
|
| 23 |
+
|
| 24 |
+
# 1. Resave the image in memory at a specific quality
|
| 25 |
+
temp_buffer = io.BytesIO()
|
| 26 |
+
image.save(temp_buffer, "JPEG", quality=quality)
|
| 27 |
+
temp_buffer.seek(0)
|
| 28 |
+
|
| 29 |
+
# 2. Open the resaved image
|
| 30 |
+
resaved_img = Image.open(temp_buffer)
|
| 31 |
+
|
| 32 |
+
# 3. Calculate the absolute difference between original and resaved
|
| 33 |
+
# Manipulated areas will stand out because they compress differently
|
| 34 |
+
ela_img = ImageChops.difference(image, resaved_img)
|
| 35 |
+
|
| 36 |
+
# 4. Enhance the difference (brightness) so it's visible to the human eye
|
| 37 |
+
enhancer = ImageEnhance.Brightness(ela_img)
|
| 38 |
+
ela_enhanced = enhancer.enhance(scale)
|
| 39 |
+
|
| 40 |
+
# 5. Convert to Base64 for the frontend
|
| 41 |
+
out_buffer = io.BytesIO()
|
| 42 |
+
ela_enhanced.save(out_buffer, format="JPEG", quality=85)
|
| 43 |
+
out_buffer.seek(0)
|
| 44 |
+
base64_str = base64.b64encode(out_buffer.read()).decode("utf-8")
|
| 45 |
+
|
| 46 |
+
return f"data:image/jpeg;base64,{base64_str}"
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"[DeepGuard] ELA Generation Error: {e}")
|
| 50 |
+
# Return empty string on failure
|
| 51 |
+
return ""
|
heatmap.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepGuard — Heatmap Generation Module
|
| 3 |
+
|
| 4 |
+
Strategy:
|
| 5 |
+
1. PRIMARY: Attention Rollout — extract multi-head attention matrices from
|
| 6 |
+
ONNX intermediate outputs and roll them up through all layers.
|
| 7 |
+
2. FALLBACK: Frequency Anomaly + Gradient Saliency — if attention weights
|
| 8 |
+
are not exported, compute a forensically meaningful heatmap
|
| 9 |
+
using DCT frequency analysis and Sobel edge gradients.
|
| 10 |
+
(Pure NumPy, <10ms, no additional inference passes.)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import io
|
| 14 |
+
import numpy as np
|
| 15 |
+
from PIL import Image
|
| 16 |
+
from scipy.ndimage import gaussian_filter
|
| 17 |
+
import base64
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Public entry point
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
def generate_heatmap(
|
| 26 |
+
image: Image.Image,
|
| 27 |
+
output_dict: dict,
|
| 28 |
+
confidence_score: float,
|
| 29 |
+
) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Generate a transparent red/yellow heatmap overlay.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
image: Original PIL image (any size).
|
| 35 |
+
output_dict: Raw ONNX output dict {name: ndarray}.
|
| 36 |
+
confidence_score: Model fake probability [0, 1].
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
data URI string: "data:image/png;base64,..."
|
| 40 |
+
"""
|
| 41 |
+
img224 = image.convert("RGB").resize((224, 224), Image.BILINEAR)
|
| 42 |
+
img_arr = np.array(img224, dtype=np.float32)
|
| 43 |
+
|
| 44 |
+
# Try attention rollout first
|
| 45 |
+
attn_keys = [
|
| 46 |
+
k for k in output_dict
|
| 47 |
+
if "attn" in k.lower() or "attention" in k.lower()
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
heat_map = None
|
| 51 |
+
if attn_keys:
|
| 52 |
+
heat_map = _attention_rollout(output_dict, attn_keys)
|
| 53 |
+
|
| 54 |
+
if heat_map is None:
|
| 55 |
+
heat_map = _frequency_saliency(img_arr, confidence_score)
|
| 56 |
+
|
| 57 |
+
overlay = _apply_overlay(img_arr, heat_map)
|
| 58 |
+
return _encode_png(overlay)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# Strategy 1: Attention Rollout
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
|
| 65 |
+
def _attention_rollout(output_dict: dict, attn_keys: list) -> Optional[np.ndarray]:
|
| 66 |
+
"""
|
| 67 |
+
Roll up multi-head attention matrices across all transformer layers.
|
| 68 |
+
Returns a normalized (224, 224) float32 array or None on failure.
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
# Sort keys to ensure layer order (layer_0, layer_1, ...)
|
| 72 |
+
attn_keys_sorted = sorted(attn_keys)
|
| 73 |
+
rollout = None
|
| 74 |
+
|
| 75 |
+
for key in attn_keys_sorted:
|
| 76 |
+
attn = output_dict[key] # Expected shape: (1, heads, seq_len, seq_len)
|
| 77 |
+
if attn.ndim != 4:
|
| 78 |
+
continue
|
| 79 |
+
attn = attn.squeeze(0) # (heads, seq_len, seq_len)
|
| 80 |
+
attn = attn.mean(axis=0) # Average heads → (seq_len, seq_len)
|
| 81 |
+
|
| 82 |
+
# Add residual identity (attention rollout formula)
|
| 83 |
+
identity = np.eye(attn.shape[0], dtype=np.float32)
|
| 84 |
+
attn = 0.5 * attn + 0.5 * identity
|
| 85 |
+
attn = attn / (attn.sum(axis=-1, keepdims=True) + 1e-8)
|
| 86 |
+
|
| 87 |
+
rollout = attn if rollout is None else np.matmul(rollout, attn)
|
| 88 |
+
|
| 89 |
+
if rollout is None:
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
# Row 0 = CLS token → attends to all patch tokens
|
| 93 |
+
cls_attn = rollout[0, 1:] # Drop CLS itself → (num_patches,)
|
| 94 |
+
num_patches = cls_attn.shape[0]
|
| 95 |
+
side = int(np.sqrt(num_patches)) # 14 for ViT-base-patch16
|
| 96 |
+
|
| 97 |
+
if side * side != num_patches:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
patch_map = cls_attn.reshape(side, side)
|
| 101 |
+
patch_map = (patch_map - patch_map.min()) / (patch_map.max() - patch_map.min() + 1e-8)
|
| 102 |
+
|
| 103 |
+
# Upsample to 224×224
|
| 104 |
+
heat = _upsample(patch_map, 224, 224)
|
| 105 |
+
heat = gaussian_filter(heat, sigma=8)
|
| 106 |
+
return _normalize(heat)
|
| 107 |
+
|
| 108 |
+
except Exception:
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# Strategy 2: Frequency Anomaly + Sobel Saliency (pure NumPy fallback)
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
def _frequency_saliency(img_arr: np.ndarray, confidence_score: float) -> np.ndarray:
|
| 117 |
+
"""
|
| 118 |
+
Generate a heatmap from:
|
| 119 |
+
- DCT/FFT frequency anomalies (AI images have characteristic frequency patterns)
|
| 120 |
+
- Sobel gradient magnitude (AI fails at object/background boundaries)
|
| 121 |
+
|
| 122 |
+
Both signals are combined and weighted by the confidence score.
|
| 123 |
+
"""
|
| 124 |
+
gray = 0.299 * img_arr[:, :, 0] + 0.587 * img_arr[:, :, 1] + 0.114 * img_arr[:, :, 2]
|
| 125 |
+
gray_norm = gray / 255.0
|
| 126 |
+
|
| 127 |
+
# --- Frequency anomaly via 2D FFT ---
|
| 128 |
+
fft = np.fft.fft2(gray_norm)
|
| 129 |
+
fft_shift = np.fft.fftshift(fft)
|
| 130 |
+
magnitude = np.log1p(np.abs(fft_shift))
|
| 131 |
+
# High-pass: keep frequencies above the center radius (AI images often
|
| 132 |
+
# have unnaturally suppressed high-frequency noise)
|
| 133 |
+
h, w = magnitude.shape
|
| 134 |
+
cy, cx = h // 2, w // 2
|
| 135 |
+
Y, X = np.ogrid[:h, :w]
|
| 136 |
+
r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
|
| 137 |
+
# Anomaly score: deviation of high-freq energy from expected camera noise
|
| 138 |
+
high_freq_mask = r > (min(h, w) * 0.15)
|
| 139 |
+
freq_baseline = magnitude[high_freq_mask].mean()
|
| 140 |
+
freq_map = np.abs(magnitude - freq_baseline)
|
| 141 |
+
freq_map = _normalize(freq_map)
|
| 142 |
+
|
| 143 |
+
# --- Sobel gradient magnitude ---
|
| 144 |
+
ky = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
|
| 145 |
+
kx = ky.T
|
| 146 |
+
gx = _convolve2d(gray_norm, kx)
|
| 147 |
+
gy = _convolve2d(gray_norm, ky)
|
| 148 |
+
grad_map = np.sqrt(gx ** 2 + gy ** 2)
|
| 149 |
+
grad_map = _normalize(grad_map)
|
| 150 |
+
|
| 151 |
+
# Combine: weight by score — high-confidence → emphasize freq anomaly
|
| 152 |
+
alpha = min(confidence_score * 1.2, 0.8)
|
| 153 |
+
combined = alpha * freq_map + (1.0 - alpha) * grad_map
|
| 154 |
+
|
| 155 |
+
# Smooth and normalize
|
| 156 |
+
combined = gaussian_filter(combined, sigma=10)
|
| 157 |
+
return _normalize(combined)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _convolve2d(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
|
| 161 |
+
"""Manual 2D convolution via stride tricks (no scipy dependency for this)."""
|
| 162 |
+
from scipy.ndimage import convolve
|
| 163 |
+
return convolve(img, kernel, mode="reflect")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
# Colormap and overlay helpers
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
def _apply_overlay(img_arr: np.ndarray, heat: np.ndarray, alpha: float = 0.55) -> np.ndarray:
|
| 171 |
+
"""
|
| 172 |
+
Blend red/yellow heatmap over original image.
|
| 173 |
+
Returns RGBA uint8 array (224, 224, 4).
|
| 174 |
+
"""
|
| 175 |
+
# Map heat [0,1] to RGBA: 0=transparent, 0.5=orange, 1.0=bright red
|
| 176 |
+
r = np.ones_like(heat) # R channel: always full
|
| 177 |
+
g = np.clip(1.0 - heat * 1.4, 0, 1) # G: fades out → red
|
| 178 |
+
b = np.zeros_like(heat) # B: always 0
|
| 179 |
+
|
| 180 |
+
overlay_rgb = np.stack([r, g, b], axis=-1) # (224,224,3) float [0,1]
|
| 181 |
+
overlay_alpha = np.clip(heat * alpha * 255, 0, 255) # (224,224) float
|
| 182 |
+
|
| 183 |
+
# Blend: result = img * (1 - a) + color * a
|
| 184 |
+
a3 = (overlay_alpha[:, :, np.newaxis] / 255.0)
|
| 185 |
+
blended = (img_arr / 255.0) * (1.0 - a3) + overlay_rgb * a3
|
| 186 |
+
blended = np.clip(blended * 255, 0, 255).astype(np.uint8)
|
| 187 |
+
|
| 188 |
+
# Add alpha channel
|
| 189 |
+
alpha_ch = overlay_alpha.astype(np.uint8)
|
| 190 |
+
# Keep full opacity everywhere, just use blend for color
|
| 191 |
+
full_alpha = np.full((224, 224), 255, dtype=np.uint8)
|
| 192 |
+
rgba = np.dstack([blended, full_alpha])
|
| 193 |
+
return rgba
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _encode_png(rgba_arr: np.ndarray) -> str:
|
| 197 |
+
"""Encode RGBA array to data URI."""
|
| 198 |
+
pil_img = Image.fromarray(rgba_arr, mode="RGBA")
|
| 199 |
+
buf = io.BytesIO()
|
| 200 |
+
pil_img.save(buf, format="PNG", optimize=True)
|
| 201 |
+
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 202 |
+
return f"data:image/png;base64,{b64}"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
# Utility helpers
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
|
| 209 |
+
def _normalize(arr: np.ndarray) -> np.ndarray:
|
| 210 |
+
mn, mx = arr.min(), arr.max()
|
| 211 |
+
if mx - mn < 1e-8:
|
| 212 |
+
return np.zeros_like(arr, dtype=np.float32)
|
| 213 |
+
return ((arr - mn) / (mx - mn)).astype(np.float32)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _upsample(patch_map: np.ndarray, target_h: int, target_w: int) -> np.ndarray:
|
| 217 |
+
"""Bilinear upsample a small 2D patch map to target size using PIL."""
|
| 218 |
+
pil = Image.fromarray((patch_map * 255).astype(np.uint8), mode="L")
|
| 219 |
+
pil = pil.resize((target_w, target_h), Image.BILINEAR)
|
| 220 |
+
return np.array(pil, dtype=np.float32) / 255.0
|
inference.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepGuard — ONNX ViT Inference Module
|
| 3 |
+
Loads the deepfake detection model once at startup.
|
| 4 |
+
All inference is stateless and in-memory.
|
| 5 |
+
|
| 6 |
+
Model: onnx-community/Deep-Fake-Detector-v2-Model-ONNX
|
| 7 |
+
- Architecture: google/vit-base-patch16-224
|
| 8 |
+
- Labels: {0: "Realism", 1: "Deepfake"}
|
| 9 |
+
- Input: pixel_values (1, 3, 224, 224) float32
|
| 10 |
+
- Output: logits (1, 2) float32
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import io
|
| 15 |
+
import numpy as np
|
| 16 |
+
from PIL import Image
|
| 17 |
+
from typing import Optional, Tuple
|
| 18 |
+
import onnxruntime as ort
|
| 19 |
+
|
| 20 |
+
# ImageNet normalization constants (used during ViT pre-training)
|
| 21 |
+
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 22 |
+
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 23 |
+
|
| 24 |
+
MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "deepfake_vit.onnx")
|
| 25 |
+
|
| 26 |
+
# Module-level singleton — loaded once, reused for every request
|
| 27 |
+
_session: Optional[ort.InferenceSession] = None
|
| 28 |
+
_input_name: str = ""
|
| 29 |
+
_output_names: list[str] = []
|
| 30 |
+
_has_attention_outputs: bool = False
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_model() -> None:
|
| 34 |
+
"""
|
| 35 |
+
Load the ONNX model into a global session at startup.
|
| 36 |
+
Must be called once before any inference.
|
| 37 |
+
"""
|
| 38 |
+
global _session, _input_name, _output_names, _has_attention_outputs
|
| 39 |
+
|
| 40 |
+
if not os.path.exists(MODEL_PATH):
|
| 41 |
+
raise FileNotFoundError(
|
| 42 |
+
f"Model not found at {MODEL_PATH}. "
|
| 43 |
+
"Please run: python download_model.py"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
opts = ort.SessionOptions()
|
| 47 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 48 |
+
opts.inter_op_num_threads = 4
|
| 49 |
+
opts.intra_op_num_threads = 4
|
| 50 |
+
|
| 51 |
+
_session = ort.InferenceSession(
|
| 52 |
+
MODEL_PATH,
|
| 53 |
+
sess_options=opts,
|
| 54 |
+
providers=["CPUExecutionProvider"],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
_input_name = _session.get_inputs()[0].name
|
| 58 |
+
_output_names = [o.name for o in _session.get_outputs()]
|
| 59 |
+
|
| 60 |
+
# Check whether model exposes attention weights (for attention rollout heatmap)
|
| 61 |
+
_has_attention_outputs = any(
|
| 62 |
+
"attn" in n.lower() or "attention" in n.lower()
|
| 63 |
+
for n in _output_names
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
print(f"[DeepGuard] Model loaded: {MODEL_PATH}")
|
| 67 |
+
print(f"[DeepGuard] Input: {_input_name}")
|
| 68 |
+
print(f"[DeepGuard] Outputs: {_output_names}")
|
| 69 |
+
print(f"[DeepGuard] Attention outputs available: {_has_attention_outputs}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_session() -> ort.InferenceSession:
|
| 73 |
+
if _session is None:
|
| 74 |
+
raise RuntimeError("Model not loaded. Call load_model() first.")
|
| 75 |
+
return _session
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def has_attention_outputs() -> bool:
|
| 79 |
+
return _has_attention_outputs
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_attention_output_names() -> list[str]:
|
| 83 |
+
return [n for n in _output_names if "attn" in n.lower() or "attention" in n.lower()]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def preprocess(image: Image.Image) -> np.ndarray:
|
| 87 |
+
"""
|
| 88 |
+
Preprocess a PIL Image for ViT inference.
|
| 89 |
+
Returns: float32 NCHW tensor of shape (1, 3, 224, 224)
|
| 90 |
+
"""
|
| 91 |
+
img = image.convert("RGB").resize((224, 224), Image.BILINEAR)
|
| 92 |
+
arr = np.array(img, dtype=np.float32) / 255.0 # (224, 224, 3) [0, 1]
|
| 93 |
+
arr = (arr - IMAGENET_MEAN) / IMAGENET_STD # Normalize
|
| 94 |
+
arr = arr.transpose(2, 0, 1) # HWC → CHW
|
| 95 |
+
arr = np.expand_dims(arr, axis=0) # CHW → NCHW (1,3,224,224)
|
| 96 |
+
return arr
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def softmax(logits: np.ndarray) -> np.ndarray:
|
| 100 |
+
"""Numerically stable softmax."""
|
| 101 |
+
e = np.exp(logits - np.max(logits))
|
| 102 |
+
return e / e.sum()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def run_inference(image: Image.Image) -> Tuple[float, dict]:
|
| 106 |
+
"""
|
| 107 |
+
Run the deepfake detection model on a PIL image.
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
confidence_score (float): Probability of being AI-generated [0.0, 1.0]
|
| 111 |
+
raw_outputs (dict): Full ONNX output dict (for heatmap module)
|
| 112 |
+
"""
|
| 113 |
+
session = get_session()
|
| 114 |
+
tensor = preprocess(image)
|
| 115 |
+
|
| 116 |
+
# Run with all outputs (logits + any attention matrices)
|
| 117 |
+
raw_outputs = session.run(None, {_input_name: tensor})
|
| 118 |
+
output_dict = dict(zip(_output_names, raw_outputs))
|
| 119 |
+
|
| 120 |
+
# Find logits output (first non-attention output, or output named 'logits')
|
| 121 |
+
logits_key = next(
|
| 122 |
+
(n for n in _output_names if "logit" in n.lower()),
|
| 123 |
+
_output_names[0]
|
| 124 |
+
)
|
| 125 |
+
logits = output_dict[logits_key].squeeze() # shape (2,)
|
| 126 |
+
|
| 127 |
+
probs = softmax(logits)
|
| 128 |
+
# Label mapping: {0: "Realism", 1: "Deepfake"}
|
| 129 |
+
confidence_score = float(probs[1]) # probability of being Deepfake
|
| 130 |
+
|
| 131 |
+
return confidence_score, output_dict
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def get_threat_level(score: float) -> str:
|
| 135 |
+
"""Map confidence score to threat level label."""
|
| 136 |
+
if score >= 0.90:
|
| 137 |
+
return "CRITICAL"
|
| 138 |
+
elif score >= 0.75:
|
| 139 |
+
return "HIGH"
|
| 140 |
+
elif score >= 0.50:
|
| 141 |
+
return "MEDIUM"
|
| 142 |
+
else:
|
| 143 |
+
return "LOW"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_model_reasoning(score: float, has_exif: bool, software: str) -> str:
|
| 147 |
+
"""Generate a human-readable model reasoning string."""
|
| 148 |
+
reasons = []
|
| 149 |
+
|
| 150 |
+
if score >= 0.90:
|
| 151 |
+
reasons.append("Very high-confidence AI artifact signatures detected across multiple image regions.")
|
| 152 |
+
elif score >= 0.75:
|
| 153 |
+
reasons.append("Significant statistical anomalies inconsistent with optical camera sensors detected.")
|
| 154 |
+
elif score >= 0.50:
|
| 155 |
+
reasons.append("Moderate AI artifact patterns detected; image may be partially manipulated.")
|
| 156 |
+
else:
|
| 157 |
+
reasons.append("Low probability of AI generation; image statistics consistent with real photography.")
|
| 158 |
+
|
| 159 |
+
if not has_exif:
|
| 160 |
+
reasons.append("Absence of EXIF metadata is a strong AI indicator.")
|
| 161 |
+
if software != "None":
|
| 162 |
+
reasons.append(f"Known AI software tag '{software}' detected in image metadata.")
|
| 163 |
+
|
| 164 |
+
reasons.append(
|
| 165 |
+
"ViT attention model flagged inconsistencies in background frequency, "
|
| 166 |
+
"texture uniformity, and facial boundary regions."
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
return " ".join(reasons)
|
main.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepGuard — FastAPI Backend
|
| 3 |
+
Real-time, stateless deepfake detection API.
|
| 4 |
+
|
| 5 |
+
Endpoints:
|
| 6 |
+
GET /health — Liveness check (used by extension popup)
|
| 7 |
+
POST /analyze — Analyze an image for AI-generation artifacts
|
| 8 |
+
|
| 9 |
+
All data is processed in RAM and dropped immediately after the response.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import io
|
| 13 |
+
import base64
|
| 14 |
+
import traceback
|
| 15 |
+
from contextlib import asynccontextmanager
|
| 16 |
+
|
| 17 |
+
from fastapi import FastAPI, HTTPException
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
from pydantic import BaseModel
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
import inference
|
| 23 |
+
import metadata as meta_module
|
| 24 |
+
import heatmap as heatmap_module
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Lifespan: load model once at startup
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
@asynccontextmanager
|
| 32 |
+
async def lifespan(app: FastAPI):
|
| 33 |
+
print("[DeepGuard] Starting up — loading ONNX model...")
|
| 34 |
+
try:
|
| 35 |
+
inference.load_model()
|
| 36 |
+
print("[DeepGuard] Model ready. Server is live at http://localhost:8000")
|
| 37 |
+
except FileNotFoundError as e:
|
| 38 |
+
print(f"[DeepGuard] WARNING: {e}")
|
| 39 |
+
print("[DeepGuard] Run 'python download_model.py' to fetch the model.")
|
| 40 |
+
yield
|
| 41 |
+
print("[DeepGuard] Shutting down.")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# App setup
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
app = FastAPI(
|
| 49 |
+
title="DeepGuard API",
|
| 50 |
+
description="Real-time stateless deepfake detection using ViT + ONNX Runtime",
|
| 51 |
+
version="1.0.0",
|
| 52 |
+
lifespan=lifespan,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Aggressive CORS — required because Chrome extensions use a chrome-extension:// origin
|
| 56 |
+
app.add_middleware(
|
| 57 |
+
CORSMiddleware,
|
| 58 |
+
allow_origins=["*"],
|
| 59 |
+
allow_credentials=False,
|
| 60 |
+
allow_methods=["*"],
|
| 61 |
+
allow_headers=["*"],
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
import ela as ela_module
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Request / Response schemas
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
class AnalyzeRequest(BaseModel):
|
| 73 |
+
image_data: str # data:image/...;base64,<payload> OR raw base64
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class MetadataResult(BaseModel):
|
| 77 |
+
exif_data_present: bool
|
| 78 |
+
software_signature_found: str
|
| 79 |
+
warning: str
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ForensicsResult(BaseModel):
|
| 83 |
+
model_reasoning: str
|
| 84 |
+
metadata: MetadataResult
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class AnalyzeResponse(BaseModel):
|
| 88 |
+
status: str
|
| 89 |
+
threat_level: str
|
| 90 |
+
confidence_score: float
|
| 91 |
+
heatmap_overlay_url: str
|
| 92 |
+
ela_overlay_url: str
|
| 93 |
+
forensics: ForensicsResult
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# Helpers
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
def _decode_image(image_data: str) -> tuple[bytes, Image.Image]:
|
| 101 |
+
"""
|
| 102 |
+
Decode a base64 data-URI or raw base64 string into (raw_bytes, PIL Image).
|
| 103 |
+
"""
|
| 104 |
+
if image_data.startswith("data:"):
|
| 105 |
+
# Strip "data:image/jpeg;base64," prefix
|
| 106 |
+
header, b64_str = image_data.split(",", 1)
|
| 107 |
+
else:
|
| 108 |
+
b64_str = image_data
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
raw_bytes = base64.b64decode(b64_str)
|
| 112 |
+
except Exception:
|
| 113 |
+
raise HTTPException(status_code=400, detail="Invalid base64 image data.")
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
image = Image.open(io.BytesIO(raw_bytes)).convert("RGB")
|
| 117 |
+
except Exception:
|
| 118 |
+
raise HTTPException(status_code=400, detail="Could not decode image from base64 payload.")
|
| 119 |
+
|
| 120 |
+
return raw_bytes, image
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# Endpoints
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
@app.get("/health")
|
| 128 |
+
async def health():
|
| 129 |
+
"""Liveness check. Returns model load status."""
|
| 130 |
+
try:
|
| 131 |
+
session = inference.get_session()
|
| 132 |
+
return {
|
| 133 |
+
"status": "ok",
|
| 134 |
+
"model_loaded": True,
|
| 135 |
+
"attention_heatmap": inference.has_attention_outputs(),
|
| 136 |
+
}
|
| 137 |
+
except RuntimeError:
|
| 138 |
+
return {
|
| 139 |
+
"status": "degraded",
|
| 140 |
+
"model_loaded": False,
|
| 141 |
+
"attention_heatmap": False,
|
| 142 |
+
"message": "Model not loaded. Run python download_model.py",
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.post("/analyze", response_model=AnalyzeResponse)
|
| 147 |
+
async def analyze(request: AnalyzeRequest):
|
| 148 |
+
"""
|
| 149 |
+
Main analysis endpoint.
|
| 150 |
+
Accepts a base64-encoded image and returns:
|
| 151 |
+
- Deepfake confidence score
|
| 152 |
+
- Threat level classification
|
| 153 |
+
- Grad-CAM / attention heatmap overlay (base64 PNG)
|
| 154 |
+
- EXIF metadata forensics
|
| 155 |
+
"""
|
| 156 |
+
# ── 1. Decode image ──────────────────────────────────────────────────
|
| 157 |
+
try:
|
| 158 |
+
raw_bytes, image = _decode_image(request.image_data)
|
| 159 |
+
except HTTPException:
|
| 160 |
+
raise
|
| 161 |
+
except Exception as e:
|
| 162 |
+
raise HTTPException(status_code=400, detail=f"Image decode error: {e}")
|
| 163 |
+
|
| 164 |
+
# ── 2. Metadata forensics (DFIR) ���────────────────────────────────────
|
| 165 |
+
try:
|
| 166 |
+
forensic_meta = meta_module.extract_metadata(raw_bytes)
|
| 167 |
+
except Exception:
|
| 168 |
+
forensic_meta = {
|
| 169 |
+
"exif_data_present": False,
|
| 170 |
+
"software_signature_found": "None",
|
| 171 |
+
"warning": "Metadata extraction failed.",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# ── 3. AI inference ───────────────────────────────────────────────────
|
| 175 |
+
try:
|
| 176 |
+
confidence_score, output_dict = inference.run_inference(image)
|
| 177 |
+
except RuntimeError as e:
|
| 178 |
+
raise HTTPException(
|
| 179 |
+
status_code=503,
|
| 180 |
+
detail=f"Model not loaded: {e}. Run python download_model.py first.",
|
| 181 |
+
)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
import traceback
|
| 184 |
+
traceback.print_exc()
|
| 185 |
+
raise HTTPException(status_code=500, detail=f"Inference error: {e}")
|
| 186 |
+
|
| 187 |
+
# ── 4. Heatmap & ELA generation ────────────────────────────────────────
|
| 188 |
+
try:
|
| 189 |
+
heatmap_url = heatmap_module.generate_heatmap(image, output_dict, confidence_score)
|
| 190 |
+
except Exception:
|
| 191 |
+
import traceback
|
| 192 |
+
traceback.print_exc()
|
| 193 |
+
# Fallback: return a 1×1 transparent PNG
|
| 194 |
+
heatmap_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
| 195 |
+
|
| 196 |
+
# Run Error Level Analysis (ELA)
|
| 197 |
+
ela_url = ela_module.generate_ela(image)
|
| 198 |
+
|
| 199 |
+
# ── 5. Build response ─────────────────────────────────────────────────
|
| 200 |
+
threat_level = inference.get_threat_level(confidence_score)
|
| 201 |
+
model_reasoning = inference.get_model_reasoning(
|
| 202 |
+
confidence_score,
|
| 203 |
+
forensic_meta["exif_data_present"],
|
| 204 |
+
forensic_meta["software_signature_found"],
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
return AnalyzeResponse(
|
| 208 |
+
status="success",
|
| 209 |
+
threat_level=threat_level,
|
| 210 |
+
confidence_score=round(confidence_score, 4),
|
| 211 |
+
heatmap_overlay_url=heatmap_url,
|
| 212 |
+
ela_overlay_url=ela_url,
|
| 213 |
+
forensics=ForensicsResult(
|
| 214 |
+
model_reasoning=model_reasoning,
|
| 215 |
+
metadata=MetadataResult(**forensic_meta),
|
| 216 |
+
),
|
| 217 |
+
)
|
metadata.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DeepGuard — Metadata Forensics Module
|
| 3 |
+
Extracts EXIF data and checks for known AI software signatures.
|
| 4 |
+
All processing is stateless and in-memory.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import struct
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
# Known AI generation software signatures to look for in EXIF/metadata
|
| 13 |
+
AI_SIGNATURES = [
|
| 14 |
+
"DALL-E", "dall-e", "Midjourney", "midjourney",
|
| 15 |
+
"Stable Diffusion", "stable-diffusion", "StableDiffusion",
|
| 16 |
+
"Adobe Firefly", "firefly", "Sora", "sora",
|
| 17 |
+
"Imagen", "imagen", "Bing Image Creator",
|
| 18 |
+
"NightCafe", "Craiyon", "FLUX", "flux",
|
| 19 |
+
"Runway", "runway", "Pika", "pika",
|
| 20 |
+
"ComfyUI", "comfyui", "Automatic1111", "InvokeAI",
|
| 21 |
+
"NovelAI", "novelai", "Leonardo", "leonardo.ai",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def extract_metadata(image_bytes: bytes) -> dict:
|
| 26 |
+
"""
|
| 27 |
+
Perform forensic metadata analysis on raw image bytes.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
dict with keys: exif_data_present, software_signature_found, warning
|
| 31 |
+
"""
|
| 32 |
+
exif_present = False
|
| 33 |
+
software_found = "None"
|
| 34 |
+
warning = ""
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
img = Image.open(io.BytesIO(image_bytes))
|
| 38 |
+
fmt = img.format or "UNKNOWN"
|
| 39 |
+
|
| 40 |
+
# --- EXIF analysis (JPEG / TIFF / WEBP) ---
|
| 41 |
+
exif_data = img._getexif() if hasattr(img, "_getexif") else None
|
| 42 |
+
|
| 43 |
+
if exif_data:
|
| 44 |
+
exif_present = True
|
| 45 |
+
# Tag 305 = Software, Tag 315 = Artist, Tag 270 = ImageDescription
|
| 46 |
+
tag_values = {
|
| 47 |
+
305: "Software",
|
| 48 |
+
315: "Artist",
|
| 49 |
+
270: "ImageDescription",
|
| 50 |
+
37510: "UserComment",
|
| 51 |
+
}
|
| 52 |
+
for tag_id, tag_name in tag_values.items():
|
| 53 |
+
val = exif_data.get(tag_id, "")
|
| 54 |
+
if isinstance(val, bytes):
|
| 55 |
+
try:
|
| 56 |
+
val = val.decode("utf-8", errors="ignore")
|
| 57 |
+
except Exception:
|
| 58 |
+
val = ""
|
| 59 |
+
val_str = str(val)
|
| 60 |
+
for sig in AI_SIGNATURES:
|
| 61 |
+
if sig.lower() in val_str.lower():
|
| 62 |
+
software_found = sig
|
| 63 |
+
break
|
| 64 |
+
if software_found != "None":
|
| 65 |
+
break
|
| 66 |
+
else:
|
| 67 |
+
# Try PIL's generic info dict (PNG tEXt chunks, etc.)
|
| 68 |
+
info = getattr(img, "info", {})
|
| 69 |
+
if info:
|
| 70 |
+
exif_present = True # Has some metadata
|
| 71 |
+
info_str = " ".join(str(v) for v in info.values())
|
| 72 |
+
for sig in AI_SIGNATURES:
|
| 73 |
+
if sig.lower() in info_str.lower():
|
| 74 |
+
software_found = sig
|
| 75 |
+
break
|
| 76 |
+
else:
|
| 77 |
+
exif_present = False
|
| 78 |
+
|
| 79 |
+
# Build warning message
|
| 80 |
+
if not exif_present:
|
| 81 |
+
warning = (
|
| 82 |
+
"EXIF data missing. This is a strong indicator of synthesized media — "
|
| 83 |
+
"AI generators strip or never write camera metadata."
|
| 84 |
+
)
|
| 85 |
+
elif software_found != "None":
|
| 86 |
+
warning = (
|
| 87 |
+
f"AI software signature detected: '{software_found}'. "
|
| 88 |
+
"This image was almost certainly generated by an AI tool."
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
warning = (
|
| 92 |
+
"EXIF data present. Metadata appears consistent with a camera-captured image, "
|
| 93 |
+
"but AI-generated images can be post-processed to include fake EXIF."
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
warning = f"Metadata parsing error: {str(e)}"
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"exif_data_present": exif_present,
|
| 101 |
+
"software_signature_found": software_found,
|
| 102 |
+
"warning": warning,
|
| 103 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
onnxruntime==1.19.2
|
| 4 |
+
Pillow==10.4.0
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
piexif==1.1.3
|
| 7 |
+
scipy==1.14.1
|
| 8 |
+
python-multipart==0.0.9
|
| 9 |
+
huggingface_hub==0.24.6
|
| 10 |
+
requests==2.32.3
|