Commit ·
ebcb290
0
Parent(s):
Initial commit for XAI Space
Browse files- README.md +25 -0
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/logger.cpython-312.pyc +0 -0
- __pycache__/report.cpython-312.pyc +0 -0
- app.py +214 -0
- forensics/__init__.py +15 -0
- forensics/__pycache__/__init__.cpython-312.pyc +0 -0
- forensics/__pycache__/bitplane.cpython-312.pyc +0 -0
- forensics/__pycache__/ela.cpython-312.pyc +0 -0
- forensics/__pycache__/gradient.cpython-312.pyc +0 -0
- forensics/__pycache__/minmax.cpython-312.pyc +0 -0
- forensics/__pycache__/runner.cpython-312.pyc +0 -0
- forensics/__pycache__/wavelet.cpython-312.pyc +0 -0
- forensics/bitplane.py +34 -0
- forensics/ela.py +64 -0
- forensics/exif.py +11 -0
- forensics/gradient.py +62 -0
- forensics/minmax.py +94 -0
- forensics/runner.py +79 -0
- forensics/wavelet.py +28 -0
- index.html +532 -0
- logger.py +140 -0
- report.py +96 -0
- requirements.txt +11 -0
- xai_engine/__init__.py +13 -0
- xai_engine/__pycache__/__init__.cpython-312.pyc +0 -0
- xai_engine/__pycache__/chefer.cpython-312.pyc +0 -0
- xai_engine/__pycache__/methods.cpython-312.pyc +0 -0
- xai_engine/__pycache__/model.cpython-312.pyc +0 -0
- xai_engine/__pycache__/viz.cpython-312.pyc +0 -0
- xai_engine/chefer.py +116 -0
- xai_engine/methods.py +241 -0
- xai_engine/model.py +79 -0
- xai_engine/viz.py +57 -0
README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DeepfakeDet-ViT XAI & Forensics Suite
|
| 3 |
+
emoji: 🔬
|
| 4 |
+
colorFrom: emerald
|
| 5 |
+
colorTo: slate
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.0.1"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
models:
|
| 12 |
+
- buildborderless/CommunityForensics-DeepfakeDet-ViT
|
| 13 |
+
datasets:
|
| 14 |
+
- buildborderless/deepfake-explainability
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# DeepfakeDet-ViT XAI & Digital Forensics Suite
|
| 18 |
+
|
| 19 |
+
Interactive explainability dashboard and digital forensics suite for the **ViT-Small Deepfake Classifier** (`buildborderless/CommunityForensics-DeepfakeDet-ViT`).
|
| 20 |
+
|
| 21 |
+
## Features
|
| 22 |
+
- **7 ViT Attribution Methods**: Attention Rollout, Chefer Gradient-Rollout, GradCAM, Integrated Gradients, DeepLIFT, Occlusion Sensitivity, Per-Head Attention.
|
| 23 |
+
- **5 Classic Digital Forensics Tools**: Error Level Analysis (ELA), Spatial Edge Gradient, Bit Plane Extractor, MinMax Deviation, Wavelet Noise Estimation.
|
| 24 |
+
- **Forensic Report Generator**: Export multi-panel high-DPI infographic report PNGs.
|
| 25 |
+
- **Dataset Integration**: Log predictions, user ground truth feedback (Real / Fake / FP / FN), and 7-method composite XAI heatmaps to `buildborderless/deepfake-explainability`.
|
__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (9.14 kB). View file
|
|
|
__pycache__/logger.cpython-312.pyc
ADDED
|
Binary file (6.36 kB). View file
|
|
|
__pycache__/report.cpython-312.pyc
ADDED
|
Binary file (4.86 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from gradio import Server
|
| 7 |
+
from gradio.data_classes import FileData
|
| 8 |
+
from fastapi.responses import HTMLResponse
|
| 9 |
+
|
| 10 |
+
from xai_engine.model import get_model
|
| 11 |
+
from xai_engine.methods import run_all_methods, run_single_method, run_per_head_attention
|
| 12 |
+
from forensics.runner import run_forensic_tool
|
| 13 |
+
from report import generate_forensic_report
|
| 14 |
+
from logger import log_run_dataset
|
| 15 |
+
|
| 16 |
+
# Initialize Gradio Server app
|
| 17 |
+
app = Server(title="DeepfakeDet-ViT XAI & Forensics Suite")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def b64_to_pil(b64_str: str) -> Image.Image:
|
| 21 |
+
"""Helper to convert base64 image data URL to PIL Image."""
|
| 22 |
+
if not b64_str or not isinstance(b64_str, str):
|
| 23 |
+
raise ValueError("Invalid base64 image string provided.")
|
| 24 |
+
if "," in b64_str:
|
| 25 |
+
_, data = b64_str.split(",", 1)
|
| 26 |
+
else:
|
| 27 |
+
data = b64_str
|
| 28 |
+
image_data = base64.b64decode(data)
|
| 29 |
+
return Image.open(BytesIO(image_data)).convert("RGB")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@app.api(name="analyze")
|
| 33 |
+
def analyze_api(image_b64: str, alpha: float = 0.6, cmap: str = "jet") -> dict:
|
| 34 |
+
"""
|
| 35 |
+
Run complete ViT Deepfake analysis + 6 attribution methods.
|
| 36 |
+
Returns prediction summary and list of attribution map overlays.
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
image = b64_to_pil(image_b64)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 42 |
+
|
| 43 |
+
model = get_model()
|
| 44 |
+
pred_data = model.predict(image)
|
| 45 |
+
results = run_all_methods(model, image, alpha=alpha, cmap=cmap)
|
| 46 |
+
|
| 47 |
+
res_dicts = [
|
| 48 |
+
{
|
| 49 |
+
"name": r.name,
|
| 50 |
+
"description": r.description,
|
| 51 |
+
"overlay_b64": r.overlay_b64,
|
| 52 |
+
"raw_heatmap_b64": r.raw_heatmap_b64,
|
| 53 |
+
"metadata": r.metadata,
|
| 54 |
+
"compute_time_ms": r.compute_time_ms,
|
| 55 |
+
}
|
| 56 |
+
for r in results
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"status": "success",
|
| 61 |
+
"prediction": pred_data,
|
| 62 |
+
"results": res_dicts,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.api(name="method_detail")
|
| 67 |
+
def method_detail_api(
|
| 68 |
+
image_b64: str,
|
| 69 |
+
method_name: str,
|
| 70 |
+
steps: int = 20,
|
| 71 |
+
patch_size: int = 32,
|
| 72 |
+
stride: int = 16,
|
| 73 |
+
target_layer_idx: int = 11,
|
| 74 |
+
alpha: float = 0.6,
|
| 75 |
+
cmap: str = "jet",
|
| 76 |
+
) -> dict:
|
| 77 |
+
"""Deep-dive analysis for a single specified XAI method with custom parameters."""
|
| 78 |
+
try:
|
| 79 |
+
image = b64_to_pil(image_b64)
|
| 80 |
+
except Exception as e:
|
| 81 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 82 |
+
|
| 83 |
+
model = get_model()
|
| 84 |
+
res = run_single_method(
|
| 85 |
+
model, image, method_name,
|
| 86 |
+
alpha=alpha, cmap=cmap, steps=steps,
|
| 87 |
+
patch_size=patch_size, stride=stride,
|
| 88 |
+
target_layer_idx=target_layer_idx,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"status": "success",
|
| 93 |
+
"result": {
|
| 94 |
+
"name": res.name,
|
| 95 |
+
"description": res.description,
|
| 96 |
+
"overlay_b64": res.overlay_b64,
|
| 97 |
+
"raw_heatmap_b64": res.raw_heatmap_b64,
|
| 98 |
+
"metadata": res.metadata,
|
| 99 |
+
"compute_time_ms": res.compute_time_ms,
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.api(name="heads")
|
| 105 |
+
def heads_api(image_b64: str, layer_idx: int = 11, cmap: str = "jet") -> dict:
|
| 106 |
+
"""Extract and visualize all 6 attention heads for a selected transformer layer."""
|
| 107 |
+
try:
|
| 108 |
+
image = b64_to_pil(image_b64)
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 111 |
+
|
| 112 |
+
model = get_model()
|
| 113 |
+
head_data = run_per_head_attention(model, image, layer_idx=layer_idx, cmap=cmap)
|
| 114 |
+
head_data["status"] = "success"
|
| 115 |
+
return head_data
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.api(name="forensic")
|
| 119 |
+
def forensic_api(
|
| 120 |
+
image_b64: str,
|
| 121 |
+
tool_name: str,
|
| 122 |
+
quality: int = 75,
|
| 123 |
+
scale: int = 50,
|
| 124 |
+
contrast: int = 20,
|
| 125 |
+
gradient_intensity: int = 90,
|
| 126 |
+
blue_mode: str = "Abs",
|
| 127 |
+
channel: str = "Luminance",
|
| 128 |
+
bit: int = 0,
|
| 129 |
+
minmax_radius: int = 2,
|
| 130 |
+
) -> dict:
|
| 131 |
+
"""Run classic digital forensic analysis (ELA, Gradient, BitPlane, MinMax, Wavelet)."""
|
| 132 |
+
try:
|
| 133 |
+
image = b64_to_pil(image_b64)
|
| 134 |
+
except Exception as e:
|
| 135 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 136 |
+
|
| 137 |
+
res = run_forensic_tool(
|
| 138 |
+
image, tool_name,
|
| 139 |
+
quality=quality, scale=scale, contrast=contrast,
|
| 140 |
+
gradient_intensity=gradient_intensity, blue_mode=blue_mode,
|
| 141 |
+
channel=channel, bit=bit, minmax_radius=minmax_radius,
|
| 142 |
+
)
|
| 143 |
+
res["status"] = "success"
|
| 144 |
+
return res
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.api(name="report")
|
| 148 |
+
def report_api(image_b64: str) -> FileData:
|
| 149 |
+
"""Generate and return downloadable multi-panel forensic report PNG."""
|
| 150 |
+
image = b64_to_pil(image_b64)
|
| 151 |
+
model = get_model()
|
| 152 |
+
pred_data = model.predict(image)
|
| 153 |
+
results = run_all_methods(model, image)
|
| 154 |
+
report_path = generate_forensic_report(image, pred_data, results)
|
| 155 |
+
return FileData(path=report_path)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.api(name="submit_run")
|
| 159 |
+
def submit_run_api(
|
| 160 |
+
image_b64: str,
|
| 161 |
+
ground_truth: str = "unknown",
|
| 162 |
+
opt_in_image: bool = True,
|
| 163 |
+
) -> dict:
|
| 164 |
+
"""Log analysis run, prediction, ground truth feedback, and XAI composite heatmaps to dataset."""
|
| 165 |
+
try:
|
| 166 |
+
image = b64_to_pil(image_b64)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 169 |
+
|
| 170 |
+
model = get_model()
|
| 171 |
+
pred_data = model.predict(image)
|
| 172 |
+
results = run_all_methods(model, image)
|
| 173 |
+
run_id = log_run_dataset(image, pred_data, results, ground_truth=ground_truth, opt_in_image=opt_in_image)
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"status": "success",
|
| 177 |
+
"run_id": run_id,
|
| 178 |
+
"message": f"Run {run_id} successfully submitted to dataset!",
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@app.get("/api/config")
|
| 183 |
+
def client_config():
|
| 184 |
+
"""Return model and space configuration for frontend initialization."""
|
| 185 |
+
return {
|
| 186 |
+
"model_name": "buildborderless/CommunityForensics-DeepfakeDet-ViT",
|
| 187 |
+
"model_architecture": "ViT-Small (384x384, 12 layers, 6 heads)",
|
| 188 |
+
"xai_methods": [
|
| 189 |
+
{"id": "rollout", "name": "Attention Rollout"},
|
| 190 |
+
{"id": "chefer", "name": "Gradient Attention Rollout (Chefer)"},
|
| 191 |
+
{"id": "gradcam", "name": "GradCAM"},
|
| 192 |
+
{"id": "integrated", "name": "Integrated Gradients"},
|
| 193 |
+
{"id": "deeplift", "name": "DeepLIFT"},
|
| 194 |
+
{"id": "occlusion", "name": "Occlusion Sensitivity"},
|
| 195 |
+
],
|
| 196 |
+
"forensic_tools": [
|
| 197 |
+
{"id": "ela", "name": "Error Level Analysis (ELA)"},
|
| 198 |
+
{"id": "gradient", "name": "Spatial Edge Gradient"},
|
| 199 |
+
{"id": "bitplane", "name": "Bit Plane Extractor"},
|
| 200 |
+
{"id": "minmax", "name": "MinMax Deviation"},
|
| 201 |
+
{"id": "wavelet", "name": "Wavelet Noise Estimation"},
|
| 202 |
+
]
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@app.get("/", response_class=HTMLResponse)
|
| 207 |
+
async def homepage():
|
| 208 |
+
html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
|
| 209 |
+
with open(html_path, "r", encoding="utf-8") as f:
|
| 210 |
+
return f.read()
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
app.launch(show_error=True)
|
forensics/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .bitplane import bit_plane_extractor
|
| 2 |
+
from .ela import ELA
|
| 3 |
+
# from .exif import exif_full_dump
|
| 4 |
+
from .gradient import gradient_processing
|
| 5 |
+
from .minmax import minmax_process
|
| 6 |
+
from .wavelet import noise_estimation
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
'bit_plane_extractor',
|
| 10 |
+
'ELA',
|
| 11 |
+
# 'exif_full_dump',
|
| 12 |
+
'gradient_processing',
|
| 13 |
+
'minmax_process',
|
| 14 |
+
'noise_estimation'
|
| 15 |
+
]
|
forensics/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (468 Bytes). View file
|
|
|
forensics/__pycache__/bitplane.cpython-312.pyc
ADDED
|
Binary file (2.58 kB). View file
|
|
|
forensics/__pycache__/ela.cpython-312.pyc
ADDED
|
Binary file (3.26 kB). View file
|
|
|
forensics/__pycache__/gradient.cpython-312.pyc
ADDED
|
Binary file (4.44 kB). View file
|
|
|
forensics/__pycache__/minmax.cpython-312.pyc
ADDED
|
Binary file (5.77 kB). View file
|
|
|
forensics/__pycache__/runner.cpython-312.pyc
ADDED
|
Binary file (3.07 kB). View file
|
|
|
forensics/__pycache__/wavelet.cpython-312.pyc
ADDED
|
Binary file (2.62 kB). View file
|
|
|
forensics/bitplane.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2 as cv
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
def bit_plane_extractor(
|
| 6 |
+
image: Image.Image,
|
| 7 |
+
channel: str = "Luminance",
|
| 8 |
+
bit: int = 0,
|
| 9 |
+
filter_type: str = "Disabled"
|
| 10 |
+
) -> Image.Image:
|
| 11 |
+
"""Extract and visualize a bit plane from a selected channel of the image.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
image (Image.Image, string: filepath): The input image to analyze.
|
| 15 |
+
channel (str, optional): The channel to extract. Defaults to "Luminance".
|
| 16 |
+
bit (int, optional): The bit to extract. Defaults to 0.
|
| 17 |
+
filter_type (str, optional): The type of filter to apply. Defaults to "Disabled".
|
| 18 |
+
"""
|
| 19 |
+
img = np.array(image.convert("RGB"))
|
| 20 |
+
if channel == "Luminance":
|
| 21 |
+
img = cv.cvtColor(img, cv.COLOR_RGB2GRAY)
|
| 22 |
+
elif channel == "RGB Norm":
|
| 23 |
+
b, g, r = cv.split(img.astype(np.float64))
|
| 24 |
+
img = np.sqrt(np.power(b, 2) + np.power(g, 2) + np.power(r, 2)).astype(np.uint8)
|
| 25 |
+
else:
|
| 26 |
+
idx = {"Red": 0, "Green": 1, "Blue": 2}[channel]
|
| 27 |
+
img = img[:, :, idx]
|
| 28 |
+
plane = cv.bitwise_and(np.full_like(img, 2 ** bit), img)
|
| 29 |
+
plane = cv.normalize(plane, None, 0, 255, cv.NORM_MINMAX).astype(np.uint8)
|
| 30 |
+
if filter_type == "Median":
|
| 31 |
+
plane = cv.medianBlur(plane, 3)
|
| 32 |
+
elif filter_type == "Gaussian":
|
| 33 |
+
plane = cv.GaussianBlur(plane, (3, 3), 0)
|
| 34 |
+
return Image.fromarray(plane)
|
forensics/ela.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2 as cv
|
| 3 |
+
from time import time
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
def compress_jpg(image, quality):
|
| 7 |
+
"""Compress image using JPEG compression."""
|
| 8 |
+
encode_param = [int(cv.IMWRITE_JPEG_QUALITY), quality]
|
| 9 |
+
_, buffer = cv.imencode('.jpg', image, encode_param)
|
| 10 |
+
return cv.imdecode(buffer, cv.IMREAD_COLOR)
|
| 11 |
+
|
| 12 |
+
def desaturate(image):
|
| 13 |
+
"""Convert image to grayscale."""
|
| 14 |
+
return cv.cvtColor(image, cv.COLOR_BGR2GRAY)
|
| 15 |
+
|
| 16 |
+
def create_lut(contrast, brightness):
|
| 17 |
+
"""Create lookup table for contrast and brightness adjustment."""
|
| 18 |
+
lut = np.arange(256, dtype=np.uint8)
|
| 19 |
+
lut = cv.LUT(lut, lut)
|
| 20 |
+
lut = cv.convertScaleAbs(lut, None, contrast/128, brightness)
|
| 21 |
+
return lut
|
| 22 |
+
|
| 23 |
+
def elapsed_time(start):
|
| 24 |
+
"""Calculate elapsed time since start."""
|
| 25 |
+
return f"{time() - start:.3f}s"
|
| 26 |
+
|
| 27 |
+
def ELA(img, quality=75, scale=50, contrast=20, linear=False, grayscale=False):
|
| 28 |
+
"""
|
| 29 |
+
Perform Error Level Analysis on an image.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
img: Input image (numpy array)
|
| 33 |
+
quality: JPEG compression quality (1-100)
|
| 34 |
+
scale: Output multiplicative gain (1-100)
|
| 35 |
+
contrast: Output tonality compression (0-100)
|
| 36 |
+
linear: Whether to use linear difference
|
| 37 |
+
grayscale: Whether to output grayscale image
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Processed ELA image
|
| 41 |
+
"""
|
| 42 |
+
# Convert image to float32 and normalize
|
| 43 |
+
original = img.astype(np.float32) / 255
|
| 44 |
+
|
| 45 |
+
# Compress image
|
| 46 |
+
compressed = compress_jpg(img, quality)
|
| 47 |
+
compressed = compressed.astype(np.float32) / 255
|
| 48 |
+
|
| 49 |
+
# Calculate difference based on mode
|
| 50 |
+
if not linear:
|
| 51 |
+
difference = cv.absdiff(original, compressed)
|
| 52 |
+
ela = cv.convertScaleAbs(cv.sqrt(difference) * 255, None, scale / 20)
|
| 53 |
+
else:
|
| 54 |
+
ela = cv.convertScaleAbs(cv.subtract(compressed, img), None, scale)
|
| 55 |
+
|
| 56 |
+
# Apply contrast adjustment
|
| 57 |
+
contrast_value = int(contrast / 100 * 128)
|
| 58 |
+
ela = cv.LUT(ela, create_lut(contrast_value, contrast_value))
|
| 59 |
+
|
| 60 |
+
# Convert to grayscale if requested
|
| 61 |
+
if grayscale:
|
| 62 |
+
ela = desaturate(ela)
|
| 63 |
+
|
| 64 |
+
return Image.fromarray(ela)
|
forensics/exif.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# import tempfile
|
| 2 |
+
# import exiftool
|
| 3 |
+
# from PIL import Image
|
| 4 |
+
|
| 5 |
+
# def exif_full_dump(image: Image.Image) -> dict:
|
| 6 |
+
# """Extract all EXIF metadata from an image using exiftool."""
|
| 7 |
+
# with tempfile.NamedTemporaryFile(suffix='.jpg', delete=True) as tmp:
|
| 8 |
+
# image.save(tmp.name)
|
| 9 |
+
# with exiftool.ExifTool() as et:
|
| 10 |
+
# metadata = et.get_metadata(tmp.name)
|
| 11 |
+
# return metadata
|
forensics/gradient.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2 as cv
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
def norm_mat(mat):
|
| 6 |
+
return cv.normalize(mat, None, 0, 255, cv.NORM_MINMAX).astype(np.uint8)
|
| 7 |
+
|
| 8 |
+
def equalize_img(img):
|
| 9 |
+
ycrcb = cv.cvtColor(img, cv.COLOR_BGR2YCrCb)
|
| 10 |
+
ycrcb[:, :, 0] = cv.equalizeHist(ycrcb[:, :, 0])
|
| 11 |
+
return cv.cvtColor(ycrcb, cv.COLOR_YCrCb2BGR)
|
| 12 |
+
|
| 13 |
+
def create_lut(intensity, gamma):
|
| 14 |
+
lut = np.zeros((256, 1, 3), dtype=np.uint8)
|
| 15 |
+
for i in range(256):
|
| 16 |
+
lut[i, 0, 0] = min(255, max(0, i + intensity))
|
| 17 |
+
lut[i, 0, 1] = min(255, max(0, i + intensity))
|
| 18 |
+
lut[i, 0, 2] = min(255, max(0, i + intensity))
|
| 19 |
+
return lut
|
| 20 |
+
|
| 21 |
+
def gradient_processing(image, intensity=90, blue_mode="Abs", invert=False, equalize=False):
|
| 22 |
+
"""Apply gradient processing to an image.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
image (Image.Image, string: filepath): The input image to analyze.
|
| 26 |
+
intensity (int, optional): The intensity of the gradient. Defaults to 90.
|
| 27 |
+
blue_mode (str, optional): The mode to use for the blue channel. Defaults to "Abs".
|
| 28 |
+
invert (bool, optional): Whether to invert the gradient. Defaults to False.
|
| 29 |
+
equalize (bool, optional): Whether to equalize the gradient. Defaults to False.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Image.Image: A PIL image of the gradient.
|
| 33 |
+
"""
|
| 34 |
+
image = np.array(image)
|
| 35 |
+
dx, dy = cv.spatialGradient(cv.cvtColor(image, cv.COLOR_BGR2GRAY))
|
| 36 |
+
intensity = int(intensity / 100 * 127)
|
| 37 |
+
if invert:
|
| 38 |
+
dx = (-dx).astype(np.float32)
|
| 39 |
+
dy = (-dy).astype(np.float32)
|
| 40 |
+
else:
|
| 41 |
+
dx = (+dx).astype(np.float32)
|
| 42 |
+
dy = (+dy).astype(np.float32)
|
| 43 |
+
dx_abs = np.abs(dx)
|
| 44 |
+
dy_abs = np.abs(dy)
|
| 45 |
+
red = ((dx / np.max(dx_abs) * 127) + 127).astype(np.uint8)
|
| 46 |
+
green = ((dy / np.max(dy_abs) * 127) + 127).astype(np.uint8)
|
| 47 |
+
if blue_mode == "None":
|
| 48 |
+
blue = np.zeros_like(red)
|
| 49 |
+
elif blue_mode == "Flat":
|
| 50 |
+
blue = np.full_like(red, 255)
|
| 51 |
+
elif blue_mode == "Abs":
|
| 52 |
+
blue = norm_mat(dx_abs + dy_abs)
|
| 53 |
+
elif blue_mode == "Norm":
|
| 54 |
+
blue = norm_mat(np.linalg.norm(cv.merge((red, green)), axis=2))
|
| 55 |
+
else:
|
| 56 |
+
blue = None
|
| 57 |
+
gradient = cv.merge([blue, green, red])
|
| 58 |
+
if equalize:
|
| 59 |
+
gradient = equalize_img(gradient)
|
| 60 |
+
elif intensity > 0:
|
| 61 |
+
gradient = cv.LUT(gradient, create_lut(intensity, intensity))
|
| 62 |
+
return Image.fromarray(gradient)
|
forensics/minmax.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2 as cv
|
| 3 |
+
from PIL import Image
|
| 4 |
+
|
| 5 |
+
def norm_mat(mat):
|
| 6 |
+
return cv.normalize(mat, None, 0, 255, cv.NORM_MINMAX).astype(np.uint8)
|
| 7 |
+
|
| 8 |
+
def minmax_dev(patch, mask):
|
| 9 |
+
c = patch[1, 1]
|
| 10 |
+
minimum, maximum, _, _ = cv.minMaxLoc(patch, mask)
|
| 11 |
+
if c < minimum:
|
| 12 |
+
return -1
|
| 13 |
+
if c > maximum:
|
| 14 |
+
return +1
|
| 15 |
+
return 0
|
| 16 |
+
|
| 17 |
+
def blk_filter(img, radius):
|
| 18 |
+
result = np.zeros_like(img, np.float32)
|
| 19 |
+
rows, cols = result.shape
|
| 20 |
+
block = 2 * radius + 1
|
| 21 |
+
for i in range(radius, rows, block):
|
| 22 |
+
for j in range(radius, cols, block):
|
| 23 |
+
result[
|
| 24 |
+
i - radius : i + radius + 1, j - radius : j + radius + 1
|
| 25 |
+
] = np.std(
|
| 26 |
+
img[i - radius : i + radius + 1, j - radius : j + radius + 1]
|
| 27 |
+
)
|
| 28 |
+
return cv.normalize(result, None, 0, 127, cv.NORM_MINMAX, cv.CV_8UC1)
|
| 29 |
+
|
| 30 |
+
def minmax_process(image, channel=4, radius=2):
|
| 31 |
+
"""
|
| 32 |
+
Analyzes local pixel value deviations in an image to detect potential manipulation by comparing each pixel with its neighbors. This forensic technique helps identify areas where pixel values deviate significantly from their local context, which can indicate digital tampering or editing.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
image: Union[np.ndarray, Image.Image]: Input image to process
|
| 36 |
+
channel (int): Channel to process (0:Grayscale, 1:Blue, 2:Green, 3:Red, 4:RGB Norm)
|
| 37 |
+
radius (int): Radius for block filtering
|
| 38 |
+
Returns:
|
| 39 |
+
PIL.Image.Image: The processed image showing minmax deviations.
|
| 40 |
+
Raises:
|
| 41 |
+
ValueError: If the input image is invalid or channel/radius parameters are out of valid range.
|
| 42 |
+
"""
|
| 43 |
+
if not isinstance(image, np.ndarray):
|
| 44 |
+
image = np.array(image) # Ensure image is a NumPy array
|
| 45 |
+
if channel == 0:
|
| 46 |
+
img = cv.cvtColor(image, cv.COLOR_BGR2GRAY)
|
| 47 |
+
elif channel == 4:
|
| 48 |
+
b, g, r = cv.split(image.astype(np.float64))
|
| 49 |
+
img = cv.sqrt(cv.pow(b, 2) + cv.pow(g, 2) + cv.pow(r, 2))
|
| 50 |
+
else:
|
| 51 |
+
img = image[:, :, 3 - channel]
|
| 52 |
+
kernel = 3
|
| 53 |
+
border = kernel // 2
|
| 54 |
+
shape = (img.shape[0] - kernel + 1, img.shape[1] - kernel + 1, kernel, kernel)
|
| 55 |
+
strides = 2 * img.strides
|
| 56 |
+
patches = np.lib.stride_tricks.as_strided(img, shape=shape, strides=strides)
|
| 57 |
+
patches = patches.reshape((-1, kernel, kernel))
|
| 58 |
+
mask = np.full((kernel, kernel), 255, dtype=np.uint8)
|
| 59 |
+
mask[border, border] = 0
|
| 60 |
+
blocks = [0] * shape[0] * shape[1]
|
| 61 |
+
for i, patch in enumerate(patches):
|
| 62 |
+
blocks[i] = minmax_dev(patch, mask)
|
| 63 |
+
output = np.array(blocks).reshape(shape[:-2])
|
| 64 |
+
output = cv.copyMakeBorder(
|
| 65 |
+
output, border, border, border, border, cv.BORDER_CONSTANT
|
| 66 |
+
)
|
| 67 |
+
low = output == -1
|
| 68 |
+
high = output == +1
|
| 69 |
+
minmax = np.zeros_like(image)
|
| 70 |
+
if radius > 0:
|
| 71 |
+
radius += 3
|
| 72 |
+
low = blk_filter(low, radius)
|
| 73 |
+
high = blk_filter(high, radius)
|
| 74 |
+
if channel <= 2:
|
| 75 |
+
minmax[:, :, 2 - channel] = low
|
| 76 |
+
minmax[:, :, 2 - channel] += high
|
| 77 |
+
else:
|
| 78 |
+
minmax = np.repeat(low[:, :, np.newaxis], 3, axis=2)
|
| 79 |
+
minmax += np.repeat(high[:, :, np.newaxis], 3, axis=2)
|
| 80 |
+
minmax = norm_mat(minmax)
|
| 81 |
+
else:
|
| 82 |
+
if channel == 0:
|
| 83 |
+
minmax[low] = [0, 0, 255]
|
| 84 |
+
minmax[high] = [0, 0, 255]
|
| 85 |
+
elif channel == 1:
|
| 86 |
+
minmax[low] = [0, 255, 0]
|
| 87 |
+
minmax[high] = [0, 255, 0]
|
| 88 |
+
elif channel == 2:
|
| 89 |
+
minmax[low] = [255, 0, 0]
|
| 90 |
+
minmax[high] = [255, 0, 0]
|
| 91 |
+
elif channel == 3:
|
| 92 |
+
minmax[low] = [255, 255, 255]
|
| 93 |
+
minmax[high] = [255, 255, 255]
|
| 94 |
+
return Image.fromarray(minmax)
|
forensics/runner.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unified runner for classic digital forensics tools.
|
| 3 |
+
Wraps ELA, Spatial Gradient, Bit Plane, MinMax Deviation, and Wavelet Noise.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import time
|
| 7 |
+
import numpy as np
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from .ela import ELA
|
| 10 |
+
from .gradient import gradient_processing
|
| 11 |
+
from .bitplane import bit_plane_extractor
|
| 12 |
+
from .minmax import minmax_process
|
| 13 |
+
from .wavelet import noise_estimation
|
| 14 |
+
from xai_engine.viz import to_b64_png
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run_forensic_tool(
|
| 18 |
+
image: Image.Image,
|
| 19 |
+
tool_name: str,
|
| 20 |
+
quality: int = 75,
|
| 21 |
+
scale: int = 50,
|
| 22 |
+
contrast: int = 20,
|
| 23 |
+
gradient_intensity: int = 90,
|
| 24 |
+
blue_mode: str = "Abs",
|
| 25 |
+
channel: str = "Luminance",
|
| 26 |
+
bit: int = 0,
|
| 27 |
+
minmax_radius: int = 2,
|
| 28 |
+
) -> dict:
|
| 29 |
+
"""
|
| 30 |
+
Execute selected forensic tool on input image.
|
| 31 |
+
|
| 32 |
+
Returns dict: {
|
| 33 |
+
"tool": str,
|
| 34 |
+
"result_b64": str,
|
| 35 |
+
"info": str,
|
| 36 |
+
"compute_time_ms": float
|
| 37 |
+
}
|
| 38 |
+
"""
|
| 39 |
+
t0 = time.perf_counter()
|
| 40 |
+
img_np = np.array(image.convert("RGB"))
|
| 41 |
+
info_text = ""
|
| 42 |
+
|
| 43 |
+
if tool_name == "ela":
|
| 44 |
+
name = "Error Level Analysis (ELA)"
|
| 45 |
+
res_img = ELA(img_np, quality=quality, scale=scale, contrast=contrast)
|
| 46 |
+
info_text = f"JPEG Compression Quality: {quality}%, Scale: {scale}, Contrast: {contrast}%"
|
| 47 |
+
|
| 48 |
+
elif tool_name == "gradient":
|
| 49 |
+
name = "Spatial Gradient Analysis"
|
| 50 |
+
res_img = gradient_processing(image, intensity=gradient_intensity, blue_mode=blue_mode)
|
| 51 |
+
info_text = f"Gradient Intensity: {gradient_intensity}%, Blue Mode: {blue_mode}"
|
| 52 |
+
|
| 53 |
+
elif tool_name == "bitplane":
|
| 54 |
+
name = "Bit Plane Extractor"
|
| 55 |
+
res_img = bit_plane_extractor(image, channel=channel, bit=bit)
|
| 56 |
+
info_text = f"Channel: {channel}, Bit Plane: {bit} (0=LSB, 7=MSB)"
|
| 57 |
+
|
| 58 |
+
elif tool_name == "minmax":
|
| 59 |
+
name = "MinMax Local Deviation"
|
| 60 |
+
res_img = minmax_process(img_np, channel=4, radius=minmax_radius)
|
| 61 |
+
info_text = f"Local Block Radius: {minmax_radius} px"
|
| 62 |
+
|
| 63 |
+
elif tool_name == "wavelet":
|
| 64 |
+
name = "Wavelet Noise Estimation"
|
| 65 |
+
noise_var = noise_estimation(img_np)
|
| 66 |
+
info_text = f"Estimated High-Frequency Noise Variance: {noise_var:.4f}"
|
| 67 |
+
res_img = image.convert("RGB") # Return original with overlay text or noise metric
|
| 68 |
+
|
| 69 |
+
else:
|
| 70 |
+
raise ValueError(f"Unknown forensic tool: {tool_name}")
|
| 71 |
+
|
| 72 |
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"tool": name,
|
| 76 |
+
"result_b64": to_b64_png(res_img),
|
| 77 |
+
"info": info_text,
|
| 78 |
+
"compute_time_ms": round(elapsed_ms, 1),
|
| 79 |
+
}
|
forensics/wavelet.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pywt
|
| 3 |
+
import cv2
|
| 4 |
+
from PIL import Image
|
| 5 |
+
|
| 6 |
+
def noise_estimation(image: Image.Image, blocksize: int = 8) -> Image.Image:
|
| 7 |
+
"""Estimate local noise using wavelet blocking. Returns a PIL image of the noise map.
|
| 8 |
+
|
| 9 |
+
Args:
|
| 10 |
+
image (Image.Image, string: filepath): The input image to analyze.
|
| 11 |
+
blocksize (int): The size of the blocks to use for wavelet blocking.
|
| 12 |
+
|
| 13 |
+
Returns:
|
| 14 |
+
Image.Image: A PIL image of the noise map.
|
| 15 |
+
"""
|
| 16 |
+
im = np.array(image.convert('L'))
|
| 17 |
+
y = np.double(im)
|
| 18 |
+
cA1, (cH, cV, cD) = pywt.dwt2(y, 'db8')
|
| 19 |
+
cD = cD[:cD.shape[0] // blocksize * blocksize, :cD.shape[1] // blocksize * blocksize]
|
| 20 |
+
block = np.zeros((cD.shape[0] // blocksize, cD.shape[1] // blocksize, blocksize ** 2))
|
| 21 |
+
for ii in range(0, cD.shape[0] - blocksize + 1, blocksize):
|
| 22 |
+
for jj in range(0, cD.shape[1] - blocksize + 1, blocksize):
|
| 23 |
+
block_elements = cD[ii:ii+blocksize, jj:jj+blocksize]
|
| 24 |
+
block[ii // blocksize, jj // blocksize, :] = block_elements.flatten()
|
| 25 |
+
noise_map = np.median(np.abs(block), axis=2) / 0.6745
|
| 26 |
+
noise_map_8u = cv2.normalize(noise_map, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U)
|
| 27 |
+
resized_noise_map = cv2.resize(noise_map_8u, (im.shape[1], im.shape[0]), interpolation=cv2.INTER_NEAREST)
|
| 28 |
+
return Image.fromarray(resized_noise_map)
|
index.html
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>DeepfakeDet-ViT — XAI & Digital Forensics Suite</title>
|
| 7 |
+
<!-- Tailwind CSS -->
|
| 8 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
+
<!-- FontAwesome Icons -->
|
| 10 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
| 11 |
+
<!-- Google Fonts -->
|
| 12 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 13 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 14 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 15 |
+
<script>
|
| 16 |
+
tailwind.config = {
|
| 17 |
+
darkMode: 'class',
|
| 18 |
+
theme: {
|
| 19 |
+
extend: {
|
| 20 |
+
fontFamily: {
|
| 21 |
+
sans: ['Inter', 'sans-serif'],
|
| 22 |
+
mono: ['JetBrains Mono', 'monospace'],
|
| 23 |
+
},
|
| 24 |
+
colors: {
|
| 25 |
+
brand: {
|
| 26 |
+
50: '#f0fdfa',
|
| 27 |
+
500: '#14b8a6',
|
| 28 |
+
600: '#0d9488',
|
| 29 |
+
900: '#134e4a',
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
</script>
|
| 36 |
+
<style>
|
| 37 |
+
body { font-family: 'Inter', sans-serif; }
|
| 38 |
+
.glass-panel {
|
| 39 |
+
background: rgba(15, 23, 42, 0.85);
|
| 40 |
+
backdrop-filter: blur(12px);
|
| 41 |
+
border: 1px solid rgba(51, 65, 85, 0.5);
|
| 42 |
+
}
|
| 43 |
+
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 44 |
+
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
|
| 45 |
+
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
| 46 |
+
</style>
|
| 47 |
+
</head>
|
| 48 |
+
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col selection:bg-teal-500 selection:text-white">
|
| 49 |
+
|
| 50 |
+
<!-- Header Banner -->
|
| 51 |
+
<header class="border-b border-slate-800 bg-slate-900/80 sticky top-0 z-40 backdrop-blur-md">
|
| 52 |
+
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
| 53 |
+
<div class="flex items-center space-x-3">
|
| 54 |
+
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-teal-500 to-emerald-400 flex items-center justify-center shadow-lg shadow-teal-500/20">
|
| 55 |
+
<i class="fa-solid fa-microscope text-slate-950 text-lg"></i>
|
| 56 |
+
</div>
|
| 57 |
+
<div>
|
| 58 |
+
<h1 class="font-bold text-lg text-slate-100 tracking-tight flex items-center gap-2">
|
| 59 |
+
DeepfakeDet-ViT <span class="text-xs px-2 py-0.5 rounded-full bg-teal-500/10 text-teal-400 font-mono border border-teal-500/20">XAI & Forensics Suite</span>
|
| 60 |
+
</h1>
|
| 61 |
+
<p class="text-xs text-slate-400">ViT-Small Deepfake Classifier (384×384) — Attribution & Digital Artifact Analysis</p>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="flex items-center space-x-3">
|
| 65 |
+
<a href="https://huggingface.co/buildborderless/CommunityForensics-DeepfakeDet-ViT" target="_blank" class="text-xs px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition flex items-center gap-1.5 border border-slate-700">
|
| 66 |
+
<i class="fa-brands fa-huggingface"></i> HF Model Hub
|
| 67 |
+
</a>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</header>
|
| 71 |
+
|
| 72 |
+
<!-- Navigation Tabs -->
|
| 73 |
+
<div class="bg-slate-900/50 border-b border-slate-800/80 sticky top-[61px] z-30 backdrop-blur-md">
|
| 74 |
+
<div class="max-w-7xl mx-auto px-4 flex space-x-2 overflow-x-auto custom-scrollbar">
|
| 75 |
+
<button onclick="switchTab('quick')" id="tab-quick" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-teal-400 text-teal-400 flex items-center gap-2 transition">
|
| 76 |
+
<i class="fa-solid fa-bolt text-xs"></i> ⚡ Quick Analysis
|
| 77 |
+
</button>
|
| 78 |
+
<button onclick="switchTab('explorer')" id="tab-explorer" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-2 transition">
|
| 79 |
+
<i class="fa-solid fa-sliders text-xs"></i> 🔍 Method Explorer
|
| 80 |
+
</button>
|
| 81 |
+
<button onclick="switchTab('heads')" id="tab-heads" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-2 transition">
|
| 82 |
+
<i class="fa-solid fa-brain text-xs"></i> 🧠 Per-Head Inspector
|
| 83 |
+
</button>
|
| 84 |
+
<button onclick="switchTab('forensics')" id="tab-forensics" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-2 transition">
|
| 85 |
+
<i class="fa-solid fa-user-secret text-xs"></i> 🕵️ Classic Forensics
|
| 86 |
+
</button>
|
| 87 |
+
<button onclick="switchTab('guide')" id="tab-guide" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-2 transition">
|
| 88 |
+
<i class="fa-solid fa-book-open text-xs"></i> 📖 Guide & Reference
|
| 89 |
+
</button>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- Main Container -->
|
| 94 |
+
<main class="max-w-7xl mx-auto px-4 py-6 flex-1 w-full">
|
| 95 |
+
|
| 96 |
+
<!-- Global Image Upload Section -->
|
| 97 |
+
<div class="glass-panel rounded-2xl p-5 mb-6 shadow-xl">
|
| 98 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-center">
|
| 99 |
+
|
| 100 |
+
<!-- Left: Upload Box -->
|
| 101 |
+
<div class="lg:col-span-5">
|
| 102 |
+
<div id="drop-zone" onclick="document.getElementById('file-input').click()" class="border-2 border-dashed border-slate-700 hover:border-teal-500/60 bg-slate-900/60 hover:bg-slate-900 transition rounded-xl p-6 text-center cursor-pointer relative group">
|
| 103 |
+
<input type="file" id="file-input" accept="image/*" class="hidden" onchange="handleFileSelect(event)" />
|
| 104 |
+
|
| 105 |
+
<div id="upload-prompt" class="py-2">
|
| 106 |
+
<i class="fa-solid fa-cloud-arrow-up text-3xl text-teal-400/80 group-hover:scale-110 transition mb-2"></i>
|
| 107 |
+
<p class="text-sm font-medium text-slate-200">Drag & Drop Image or <span class="text-teal-400 underline">Browse</span></p>
|
| 108 |
+
<p class="text-xs text-slate-500 mt-1">Supports JPG, PNG, WEBP (Auto-cropped to 384×384)</p>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div id="preview-container" class="hidden relative inline-block">
|
| 112 |
+
<img id="image-preview" src="" alt="Input Preview" class="max-h-48 rounded-lg shadow-lg border border-slate-700 object-contain mx-auto" />
|
| 113 |
+
<button onclick="event.stopPropagation(); clearImage()" class="absolute -top-2 -right-2 bg-rose-500 text-white w-6 h-6 rounded-full text-xs flex items-center justify-center hover:bg-rose-600 shadow">
|
| 114 |
+
<i class="fa-solid fa-xmark"></i>
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<!-- Right: Control Panel & Status -->
|
| 121 |
+
<div class="lg:col-span-7 flex flex-col justify-between h-full space-y-4">
|
| 122 |
+
<div class="flex items-center justify-between border-b border-slate-800 pb-3">
|
| 123 |
+
<div>
|
| 124 |
+
<span class="text-xs font-mono text-slate-400 uppercase tracking-wider">Analysis Status</span>
|
| 125 |
+
<div id="prediction-badge" class="mt-1 text-sm font-bold text-slate-500 flex items-center gap-2">
|
| 126 |
+
<span class="w-2.5 h-2.5 rounded-full bg-slate-600"></span> Awaiting Input Image
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div id="confidence-box" class="hidden text-right">
|
| 130 |
+
<span class="text-xs text-slate-400">Confidence Score</span>
|
| 131 |
+
<p id="confidence-val" class="font-mono text-lg font-bold text-teal-400">0.00%</p>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<!-- Buttons -->
|
| 136 |
+
<div class="flex flex-wrap items-center gap-3">
|
| 137 |
+
<button id="btn-analyze" onclick="runAnalysis()" class="flex-1 bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-400 hover:to-emerald-400 text-slate-950 font-bold px-6 py-3 rounded-xl shadow-lg shadow-teal-500/20 transition flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 138 |
+
<i class="fa-solid fa-bolt"></i> Run Full XAI Suite
|
| 139 |
+
</button>
|
| 140 |
+
<button id="btn-report" onclick="downloadReport()" disabled class="bg-slate-800 hover:bg-slate-700 text-slate-200 font-semibold px-4 py-3 rounded-xl border border-slate-700 transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
|
| 141 |
+
<i class="fa-solid fa-file-arrow-down"></i> Report PNG
|
| 142 |
+
</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<!-- Loading Bar / Spinner -->
|
| 150 |
+
<div id="loading-spinner" class="hidden glass-panel rounded-2xl p-6 mb-6 text-center">
|
| 151 |
+
<div class="inline-block animate-spin w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full mb-3"></div>
|
| 152 |
+
<p id="loading-text" class="text-sm font-medium text-slate-300">Computing 6 ViT Attribution Maps on CPU...</p>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<!-- TAB 1: ⚡ Quick Analysis -->
|
| 156 |
+
<div id="section-quick" class="tab-section">
|
| 157 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
|
| 158 |
+
<h2 class="text-base font-bold text-slate-100 mb-4 flex items-center justify-between">
|
| 159 |
+
<span><i class="fa-solid fa-layer-group text-teal-400 mr-2"></i> ViT Attribution Overlays</span>
|
| 160 |
+
<span class="text-xs font-normal text-slate-400">Click any map to inspect in fullscreen modal</span>
|
| 161 |
+
</h2>
|
| 162 |
+
|
| 163 |
+
<!-- Grid of 6 attribution overlays -->
|
| 164 |
+
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 165 |
+
<!-- Placeholder cards -->
|
| 166 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 167 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 168 |
+
<p class="text-xs">Attention Rollout</p>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 171 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 172 |
+
<p class="text-xs">Chefer Gradient-Rollout</p>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 175 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 176 |
+
<p class="text-xs">GradCAM</p>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 179 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 180 |
+
<p class="text-xs">Integrated Gradients</p>
|
| 181 |
+
</div>
|
| 182 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 183 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 184 |
+
<p class="text-xs">DeepLIFT</p>
|
| 185 |
+
</div>
|
| 186 |
+
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 187 |
+
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 188 |
+
<p class="text-xs">Occlusion Sensitivity</p>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<!-- Dataset Feedback Submission Panel -->
|
| 194 |
+
<div id="submit-panel" class="hidden glass-panel rounded-2xl p-6 shadow-xl border border-teal-500/20">
|
| 195 |
+
<h3 class="text-sm font-bold text-slate-200 mb-2 flex items-center gap-2">
|
| 196 |
+
<i class="fa-solid fa-database text-teal-400"></i> Contribute Ground Truth to Research Dataset
|
| 197 |
+
</h3>
|
| 198 |
+
<p class="text-xs text-slate-400 mb-4">
|
| 199 |
+
Help improve future forensic models by verifying prediction correctness. Your feedback and the composite XAI heatmaps will be uploaded to <code class="text-teal-400 bg-slate-900 px-1.5 py-0.5 rounded">buildborderless/deepfake-explainability</code>.
|
| 200 |
+
</p>
|
| 201 |
+
|
| 202 |
+
<div class="flex flex-wrap items-center gap-6">
|
| 203 |
+
<div>
|
| 204 |
+
<span class="text-xs text-slate-400 block mb-1">Ground Truth Label:</span>
|
| 205 |
+
<div class="flex items-center space-x-3">
|
| 206 |
+
<label class="inline-flex items-center text-xs text-slate-300 cursor-pointer">
|
| 207 |
+
<input type="radio" name="ground_truth" value="real" class="form-radio text-teal-500 bg-slate-900 border-slate-700" />
|
| 208 |
+
<span class="ml-1.5">Real Image</span>
|
| 209 |
+
</label>
|
| 210 |
+
<label class="inline-flex items-center text-xs text-slate-300 cursor-pointer">
|
| 211 |
+
<input type="radio" name="ground_truth" value="fake" class="form-radio text-rose-500 bg-slate-900 border-slate-700" />
|
| 212 |
+
<span class="ml-1.5">Deepfake</span>
|
| 213 |
+
</label>
|
| 214 |
+
<label class="inline-flex items-center text-xs text-slate-300 cursor-pointer">
|
| 215 |
+
<input type="radio" name="ground_truth" value="unknown" checked class="form-radio text-slate-500 bg-slate-900 border-slate-700" />
|
| 216 |
+
<span class="ml-1.5">Unsure / Unknown</span>
|
| 217 |
+
</label>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
<label class="inline-flex items-center text-xs text-slate-300 cursor-pointer">
|
| 222 |
+
<input type="checkbox" id="opt-in-image" checked class="form-checkbox text-teal-500 bg-slate-900 border-slate-700 rounded" />
|
| 223 |
+
<span class="ml-2">Include raw image in dataset</span>
|
| 224 |
+
</label>
|
| 225 |
+
|
| 226 |
+
<button onclick="submitDatasetRun()" class="bg-teal-600 hover:bg-teal-500 text-slate-950 font-bold text-xs px-4 py-2 rounded-lg transition ml-auto flex items-center gap-1.5">
|
| 227 |
+
<i class="fa-solid fa-paper-plane"></i> Submit Entry
|
| 228 |
+
</button>
|
| 229 |
+
</div>
|
| 230 |
+
<p id="submit-status" class="text-xs font-mono text-teal-400 mt-2 hidden"></p>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
<!-- TAB 2: 🔍 Method Explorer -->
|
| 235 |
+
<div id="section-explorer" class="tab-section hidden">
|
| 236 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
|
| 237 |
+
<h2 class="text-base font-bold text-slate-100 mb-4 flex items-center gap-2">
|
| 238 |
+
<i class="fa-solid fa-sliders text-teal-400"></i> Interactive Method Parameter Explorer
|
| 239 |
+
</h2>
|
| 240 |
+
|
| 241 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
| 242 |
+
<!-- Parameter Controls -->
|
| 243 |
+
<div class="lg:col-span-4 space-y-4 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 244 |
+
<div>
|
| 245 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Select XAI Method</label>
|
| 246 |
+
<select id="exp-method" onchange="runExplorerMethod()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-teal-500">
|
| 247 |
+
<option value="rollout">Attention Rollout</option>
|
| 248 |
+
<option value="chefer">Gradient-Weighted Rollout (Chefer)</option>
|
| 249 |
+
<option value="gradcam">GradCAM</option>
|
| 250 |
+
<option value="integrated">Integrated Gradients</option>
|
| 251 |
+
<option value="deeplift">DeepLIFT</option>
|
| 252 |
+
<option value="occlusion">Occlusion Sensitivity</option>
|
| 253 |
+
</select>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div>
|
| 257 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Colormap</label>
|
| 258 |
+
<select id="exp-cmap" onchange="runExplorerMethod()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
|
| 259 |
+
<option value="jet">Jet (Standard Forensic)</option>
|
| 260 |
+
<option value="inferno">Inferno (High Contrast)</option>
|
| 261 |
+
<option value="viridis">Viridis (Perceptual)</option>
|
| 262 |
+
<option value="hot">Hot (Thermal)</option>
|
| 263 |
+
</select>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div>
|
| 267 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Overlay Transparency (Alpha: <span id="val-alpha">0.6</span>)</label>
|
| 268 |
+
<input type="range" id="exp-alpha" min="0.1" max="1.0" step="0.05" value="0.6" oninput="document.getElementById('val-alpha').innerText=this.value; runExplorerMethod()" class="w-full text-teal-500" />
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<!-- Conditional Controls -->
|
| 272 |
+
<div id="ctrl-integrated" class="space-y-3 pt-2 border-t border-slate-800">
|
| 273 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">IG Steps: <span id="val-steps">20</span></label>
|
| 274 |
+
<input type="range" id="exp-steps" min="10" max="60" step="5" value="20" oninput="document.getElementById('val-steps').innerText=this.value; runExplorerMethod()" class="w-full" />
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
<div id="ctrl-occlusion" class="space-y-3 pt-2 border-t border-slate-800">
|
| 278 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Patch Size: <span id="val-patch">32</span> px</label>
|
| 279 |
+
<input type="range" id="exp-patch" min="16" max="64" step="8" value="32" oninput="document.getElementById('val-patch').innerText=this.value; runExplorerMethod()" class="w-full" />
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<div id="ctrl-gradcam" class="space-y-3 pt-2 border-t border-slate-800">
|
| 283 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Target Transformer Layer: <span id="val-layer">12</span></label>
|
| 284 |
+
<input type="range" id="exp-layer" min="1" max="12" step="1" value="12" oninput="document.getElementById('val-layer').innerText=this.value; runExplorerMethod()" class="w-full" />
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
<!-- Display Box -->
|
| 289 |
+
<div class="lg:col-span-8 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 290 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 text-center">
|
| 291 |
+
<span class="text-xs text-slate-400 block mb-2 font-mono">Saliency Overlay</span>
|
| 292 |
+
<img id="exp-img-overlay" src="" class="max-h-72 rounded-lg mx-auto shadow border border-slate-700 object-contain" />
|
| 293 |
+
</div>
|
| 294 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 text-center">
|
| 295 |
+
<span class="text-xs text-slate-400 block mb-2 font-mono">Raw Heatmap</span>
|
| 296 |
+
<img id="exp-img-raw" src="" class="max-h-72 rounded-lg mx-auto shadow border border-slate-700 object-contain" />
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<!-- TAB 3: 🧠 Per-Head Inspector -->
|
| 304 |
+
<div id="section-heads" class="tab-section hidden">
|
| 305 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
|
| 306 |
+
<div class="flex items-center justify-between mb-4">
|
| 307 |
+
<h2 class="text-base font-bold text-slate-100 flex items-center gap-2">
|
| 308 |
+
<i class="fa-solid fa-brain text-teal-400"></i> Attention Head Disaggregation (6 Heads per Layer)
|
| 309 |
+
</h2>
|
| 310 |
+
<div class="flex items-center space-x-3">
|
| 311 |
+
<span class="text-xs text-slate-400">Layer: <strong id="head-layer-val" class="text-teal-400">12</strong> / 12</span>
|
| 312 |
+
<input type="range" id="head-layer-input" min="1" max="12" value="12" onchange="runPerHeadInspector()" class="w-32" />
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<div id="heads-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
| 317 |
+
<!-- Placeholder cards -->
|
| 318 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 1</div>
|
| 319 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 2</div>
|
| 320 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 3</div>
|
| 321 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 4</div>
|
| 322 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 5</div>
|
| 323 |
+
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-3 text-center py-8 text-slate-600 text-xs">Head 6</div>
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
<!-- TAB 4: 🕵️ Classic Forensics -->
|
| 329 |
+
<div id="section-forensics" class="tab-section hidden">
|
| 330 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
|
| 331 |
+
<h2 class="text-base font-bold text-slate-100 mb-4 flex items-center gap-2">
|
| 332 |
+
<i class="fa-solid fa-user-secret text-teal-400"></i> Classic Digital Image Forensics Suite
|
| 333 |
+
</h2>
|
| 334 |
+
|
| 335 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
| 336 |
+
<!-- Controls -->
|
| 337 |
+
<div class="lg:col-span-4 space-y-4 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 338 |
+
<div>
|
| 339 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Forensic Analysis Tool</label>
|
| 340 |
+
<select id="for-tool" onchange="runForensicAnalysis()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
|
| 341 |
+
<option value="ela">Error Level Analysis (ELA)</option>
|
| 342 |
+
<option value="gradient">Spatial Edge Gradient</option>
|
| 343 |
+
<option value="bitplane">Bit Plane Extractor</option>
|
| 344 |
+
<option value="minmax">MinMax Local Deviation</option>
|
| 345 |
+
<option value="wavelet">Wavelet Noise Estimator</option>
|
| 346 |
+
</select>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<!-- ELA Options -->
|
| 350 |
+
<div id="ctrl-ela" class="space-y-3 pt-2 border-t border-slate-800">
|
| 351 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">JPEG Quality: <span id="val-quality">75</span>%</label>
|
| 352 |
+
<input type="range" id="for-quality" min="30" max="95" value="75" oninput="document.getElementById('val-quality').innerText=this.value; runForensicAnalysis()" class="w-full" />
|
| 353 |
+
</div>
|
| 354 |
+
|
| 355 |
+
<!-- Bitplane Options -->
|
| 356 |
+
<div id="ctrl-bitplane" class="hidden space-y-3 pt-2 border-t border-slate-800">
|
| 357 |
+
<label class="text-xs font-medium text-slate-300 block mb-1">Bit Index (0=LSB, 7=MSB)</label>
|
| 358 |
+
<input type="range" id="for-bit" min="0" max="7" value="0" oninput="runForensicAnalysis()" class="w-full" />
|
| 359 |
+
</div>
|
| 360 |
+
|
| 361 |
+
<p id="for-info" class="text-xs font-mono text-teal-400/90 pt-2"></p>
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
<!-- Display Box -->
|
| 365 |
+
<div class="lg:col-span-8 bg-slate-900/60 p-4 rounded-xl border border-slate-800 text-center flex flex-col justify-center items-center min-h-[300px]">
|
| 366 |
+
<img id="for-img-output" src="" class="max-h-96 rounded-lg shadow border border-slate-700 object-contain hidden" />
|
| 367 |
+
<p id="for-placeholder" class="text-xs text-slate-500">Upload an image and run analysis</p>
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
<!-- TAB 5: 📖 Guide & Reference -->
|
| 374 |
+
<div id="section-guide" class="tab-section hidden">
|
| 375 |
+
<div class="glass-panel rounded-2xl p-6 shadow-xl space-y-6">
|
| 376 |
+
<div>
|
| 377 |
+
<h2 class="text-lg font-bold text-slate-100 mb-2">Model & XAI Documentation</h2>
|
| 378 |
+
<p class="text-xs text-slate-400">
|
| 379 |
+
Model: <code class="text-teal-400 font-mono">buildborderless/CommunityForensics-DeepfakeDet-ViT</code> — trained on 2.7M samples across 4,803 generators.
|
| 380 |
+
</p>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs text-slate-300">
|
| 384 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 space-y-2">
|
| 385 |
+
<h3 class="font-bold text-teal-400">Attention Rollout vs. Chefer Rollout</h3>
|
| 386 |
+
<p class="text-slate-400">Attention Rollout averages attention maps across all heads and multiplies them layer-by-layer. Chefer et al. (CVPR 2021) improves this by multiplying attention weights by their backward gradients <code class="text-teal-400">∇A × A</code>, making the attribution class-discriminative (specifically showing what pushed towards FAKE).</p>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 space-y-2">
|
| 389 |
+
<h3 class="font-bold text-teal-400">Integrated Gradients & DeepLIFT</h3>
|
| 390 |
+
<p class="text-slate-400">Integrated Gradients integrates pixel gradients along a linear path from a black baseline. DeepLIFT decomposes non-linear neural activations relative to baseline to reveal positive vs. negative feature contributions.</p>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
</main>
|
| 397 |
+
|
| 398 |
+
<!-- Fullscreen Modal Viewer -->
|
| 399 |
+
<div id="modal-viewer" class="hidden fixed inset-0 z-50 bg-slate-950/90 backdrop-blur-md flex items-center justify-center p-4" onclick="closeModal()">
|
| 400 |
+
<div class="relative max-w-4xl w-full bg-slate-900 border border-slate-700 rounded-2xl overflow-hidden p-4 shadow-2xl" onclick="event.stopPropagation()">
|
| 401 |
+
<div class="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
|
| 402 |
+
<h3 id="modal-title" class="text-sm font-bold text-slate-200">Attribution Overlay</h3>
|
| 403 |
+
<button onclick="closeModal()" class="text-slate-400 hover:text-slate-100 text-lg">
|
| 404 |
+
<i class="fa-solid fa-xmark"></i>
|
| 405 |
+
</button>
|
| 406 |
+
</div>
|
| 407 |
+
<img id="modal-img" src="" class="max-h-[75vh] mx-auto rounded-lg object-contain" />
|
| 408 |
+
</div>
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
<!-- JavaScript App Logic -->
|
| 412 |
+
<script>
|
| 413 |
+
let currentImageB64 = null;
|
| 414 |
+
|
| 415 |
+
function switchTab(tabId) {
|
| 416 |
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
| 417 |
+
btn.classList.remove('border-teal-400', 'text-teal-400');
|
| 418 |
+
btn.classList.add('border-transparent', 'text-slate-400');
|
| 419 |
+
});
|
| 420 |
+
document.querySelectorAll('.tab-section').forEach(sec => sec.classList.add('hidden'));
|
| 421 |
+
|
| 422 |
+
document.getElementById('tab-' + tabId).classList.add('border-teal-400', 'text-teal-400');
|
| 423 |
+
document.getElementById('tab-' + tabId).classList.remove('border-transparent', 'text-slate-400');
|
| 424 |
+
document.getElementById('section-' + tabId).classList.remove('hidden');
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
function handleFileSelect(evt) {
|
| 428 |
+
const file = evt.target.files[0];
|
| 429 |
+
if (!file) return;
|
| 430 |
+
const reader = new FileReader();
|
| 431 |
+
reader.onload = function(e) {
|
| 432 |
+
currentImageB64 = e.target.result;
|
| 433 |
+
document.getElementById('image-preview').src = currentImageB64;
|
| 434 |
+
document.getElementById('upload-prompt').classList.add('hidden');
|
| 435 |
+
document.getElementById('preview-container').classList.remove('hidden');
|
| 436 |
+
document.getElementById('btn-analyze').disabled = false;
|
| 437 |
+
};
|
| 438 |
+
reader.readAsDataURL(file);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function clearImage() {
|
| 442 |
+
currentImageB64 = null;
|
| 443 |
+
document.getElementById('file-input').value = '';
|
| 444 |
+
document.getElementById('upload-prompt').classList.remove('hidden');
|
| 445 |
+
document.getElementById('preview-container').classList.add('hidden');
|
| 446 |
+
document.getElementById('btn-analyze').disabled = true;
|
| 447 |
+
document.getElementById('btn-report').disabled = true;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
async function apiCall(endpoint, payload) {
|
| 451 |
+
const resp = await fetch('/gradio_api/' + endpoint, {
|
| 452 |
+
method: 'POST',
|
| 453 |
+
headers: { 'Content-Type': 'application/json' },
|
| 454 |
+
body: JSON.stringify({ data: [payload] })
|
| 455 |
+
});
|
| 456 |
+
const data = await resp.json();
|
| 457 |
+
return data.data ? data.data[0] : data;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
async function runAnalysis() {
|
| 461 |
+
if (!currentImageB64) return;
|
| 462 |
+
|
| 463 |
+
document.getElementById('loading-spinner').classList.remove('hidden');
|
| 464 |
+
document.getElementById('btn-analyze').disabled = true;
|
| 465 |
+
|
| 466 |
+
try {
|
| 467 |
+
const resp = await fetch('/api/predict_all', { method: 'POST' }); // Fallback direct fetch
|
| 468 |
+
const res = await apiCall('analyze', currentImageB64);
|
| 469 |
+
|
| 470 |
+
if (res.status === 'success') {
|
| 471 |
+
// Update Prediction Badge
|
| 472 |
+
const pred = res.prediction;
|
| 473 |
+
const isFake = pred.prediction === 'FAKE';
|
| 474 |
+
const badge = document.getElementById('prediction-badge');
|
| 475 |
+
badge.className = `mt-1 text-sm font-bold flex items-center gap-2 ${isFake ? 'text-rose-400' : 'text-emerald-400'}`;
|
| 476 |
+
badge.innerHTML = `<span class="w-2.5 h-2.5 rounded-full ${isFake ? 'bg-rose-500' : 'bg-emerald-500'}"></span> VERDICT: ${pred.prediction}`;
|
| 477 |
+
|
| 478 |
+
document.getElementById('confidence-val').innerText = pred.confidence_pct.toFixed(2) + '%';
|
| 479 |
+
document.getElementById('confidence-box').classList.remove('hidden');
|
| 480 |
+
|
| 481 |
+
// Render Grid
|
| 482 |
+
const grid = document.getElementById('results-grid');
|
| 483 |
+
grid.innerHTML = '';
|
| 484 |
+
res.results.forEach(r => {
|
| 485 |
+
const card = document.createElement('div');
|
| 486 |
+
card.className = 'glass-panel rounded-xl p-3 hover:border-teal-500/50 transition cursor-pointer group';
|
| 487 |
+
card.onclick = () => openModal(r.name, r.overlay_b64);
|
| 488 |
+
card.innerHTML = `
|
| 489 |
+
<div class="relative overflow-hidden rounded-lg mb-2">
|
| 490 |
+
<img src="${r.overlay_b64}" class="w-full h-44 object-cover group-hover:scale-105 transition duration-300" />
|
| 491 |
+
<span class="absolute bottom-2 right-2 bg-slate-950/80 text-teal-400 text-[10px] font-mono px-2 py-0.5 rounded border border-slate-700">${r.compute_time_ms} ms</span>
|
| 492 |
+
</div>
|
| 493 |
+
<h4 class="text-xs font-bold text-slate-200">${r.name}</h4>
|
| 494 |
+
<p class="text-[10px] text-slate-400 line-clamp-1">${r.description}</p>
|
| 495 |
+
`;
|
| 496 |
+
grid.appendChild(card);
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
document.getElementById('submit-panel').classList.remove('hidden');
|
| 500 |
+
document.getElementById('btn-report').disabled = false;
|
| 501 |
+
}
|
| 502 |
+
} catch (err) {
|
| 503 |
+
console.error(err);
|
| 504 |
+
} finally {
|
| 505 |
+
document.getElementById('loading-spinner').classList.add('hidden');
|
| 506 |
+
document.getElementById('btn-analyze').disabled = false;
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
async function submitDatasetRun() {
|
| 511 |
+
if (!currentImageB64) return;
|
| 512 |
+
const gt = document.querySelector('input[name="ground_truth"]:checked').value;
|
| 513 |
+
const optIn = document.getElementById('opt-in-image').checked;
|
| 514 |
+
|
| 515 |
+
const res = await apiCall('submit_run', currentImageB64);
|
| 516 |
+
const statusP = document.getElementById('submit-status');
|
| 517 |
+
statusP.innerText = `Run ${res.run_id} logged to dataset buildborderless/deepfake-explainability!`;
|
| 518 |
+
statusP.classList.remove('hidden');
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
function openModal(title, b64) {
|
| 522 |
+
document.getElementById('modal-title').innerText = title;
|
| 523 |
+
document.getElementById('modal-img').src = b64;
|
| 524 |
+
document.getElementById('modal-viewer').classList.remove('hidden');
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
function closeModal() {
|
| 528 |
+
document.getElementById('modal-viewer').classList.add('hidden');
|
| 529 |
+
}
|
| 530 |
+
</script>
|
| 531 |
+
</body>
|
| 532 |
+
</html>
|
logger.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
import time
|
| 5 |
+
import threading
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 8 |
+
from huggingface_hub import HfApi, get_token
|
| 9 |
+
|
| 10 |
+
DATASET_REPO = "buildborderless/deepfake-explainability"
|
| 11 |
+
LOCAL_LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_composite_xai_grid(image: Image.Image, results: list) -> Image.Image:
|
| 15 |
+
"""Combine original image and all XAI heatmap overlays into a single composite image grid."""
|
| 16 |
+
w, h = 384, 384
|
| 17 |
+
n = len(results) + 1 # Original + N methods
|
| 18 |
+
cols = min(n, 4)
|
| 19 |
+
rows = (n + cols - 1) // cols
|
| 20 |
+
|
| 21 |
+
grid_img = Image.new("RGB", (cols * w, rows * h), color=(15, 23, 42))
|
| 22 |
+
|
| 23 |
+
# Paste original
|
| 24 |
+
orig_resized = image.convert("RGB").resize((w, h))
|
| 25 |
+
grid_img.paste(orig_resized, (0, 0))
|
| 26 |
+
|
| 27 |
+
# Paste XAI overlays
|
| 28 |
+
for idx, res in enumerate(results, start=1):
|
| 29 |
+
r = idx // cols
|
| 30 |
+
c = idx % cols
|
| 31 |
+
if hasattr(res, "overlay_b64"):
|
| 32 |
+
import base64
|
| 33 |
+
from io import BytesIO
|
| 34 |
+
b64_data = res.overlay_b64.split(",")[-1]
|
| 35 |
+
overlay_pil = Image.open(BytesIO(base64.b64decode(b64_data))).resize((w, h))
|
| 36 |
+
grid_img.paste(overlay_pil, (c * w, r * h))
|
| 37 |
+
|
| 38 |
+
return grid_img
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _async_push_to_dataset(record: dict, image: Image.Image, composite_grid: Image.Image):
|
| 42 |
+
"""Background worker function for dataset logging."""
|
| 43 |
+
os.makedirs(LOCAL_LOG_DIR, exist_ok=True)
|
| 44 |
+
log_file = os.path.join(LOCAL_LOG_DIR, "runs.jsonl")
|
| 45 |
+
|
| 46 |
+
# 1. Always append record to local JSONL
|
| 47 |
+
try:
|
| 48 |
+
with open(log_file, "a", encoding="utf-8") as f:
|
| 49 |
+
f.write(json.dumps(record) + "\n")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"[logger] Local log write failed: {e}")
|
| 52 |
+
|
| 53 |
+
# 2. Save images locally
|
| 54 |
+
run_id = record["run_id"]
|
| 55 |
+
if image is not None:
|
| 56 |
+
img_path = os.path.join(LOCAL_LOG_DIR, f"{run_id}_input.png")
|
| 57 |
+
image.save(img_path)
|
| 58 |
+
|
| 59 |
+
grid_path = os.path.join(LOCAL_LOG_DIR, f"{run_id}_xai_composite.png")
|
| 60 |
+
composite_grid.save(grid_path)
|
| 61 |
+
|
| 62 |
+
# 3. Attempt Hub dataset push if token present
|
| 63 |
+
token = get_token() or os.environ.get("HF_TOKEN")
|
| 64 |
+
if token:
|
| 65 |
+
try:
|
| 66 |
+
api = HfApi(token=token)
|
| 67 |
+
# Upload local log files to dataset repo
|
| 68 |
+
api.upload_file(
|
| 69 |
+
path_or_fileobj=grid_path,
|
| 70 |
+
path_in_repo=f"grids/{run_id}_xai_composite.png",
|
| 71 |
+
repo_id=DATASET_REPO,
|
| 72 |
+
repo_type="dataset",
|
| 73 |
+
)
|
| 74 |
+
if image is not None:
|
| 75 |
+
api.upload_file(
|
| 76 |
+
path_or_fileobj=img_path,
|
| 77 |
+
path_in_repo=f"images/{run_id}_input.png",
|
| 78 |
+
repo_id=DATASET_REPO,
|
| 79 |
+
repo_type="dataset",
|
| 80 |
+
)
|
| 81 |
+
api.upload_file(
|
| 82 |
+
path_or_fileobj=log_file,
|
| 83 |
+
path_in_repo="runs.jsonl",
|
| 84 |
+
repo_id=DATASET_REPO,
|
| 85 |
+
repo_type="dataset",
|
| 86 |
+
)
|
| 87 |
+
print(f"[logger] Successfully pushed run {run_id} to dataset {DATASET_REPO}")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f"[logger] Hub push error (fallback to local): {e}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def log_run_dataset(
|
| 93 |
+
image: Image.Image,
|
| 94 |
+
pred_data: dict,
|
| 95 |
+
results: list,
|
| 96 |
+
ground_truth: str = "unknown",
|
| 97 |
+
opt_in_image: bool = True,
|
| 98 |
+
) -> str:
|
| 99 |
+
"""
|
| 100 |
+
Log run details, prediction, ground truth, and XAI composite heatmaps.
|
| 101 |
+
|
| 102 |
+
Returns run_id.
|
| 103 |
+
"""
|
| 104 |
+
run_id = str(uuid.uuid4())[:8]
|
| 105 |
+
now_str = datetime.utcnow().isoformat()
|
| 106 |
+
|
| 107 |
+
is_correct = None
|
| 108 |
+
if ground_truth in ("real", "fake"):
|
| 109 |
+
pred_clean = pred_data.get("prediction", "").lower()
|
| 110 |
+
is_correct = (pred_clean == ground_truth)
|
| 111 |
+
|
| 112 |
+
methods_run = [r.name for r in results]
|
| 113 |
+
per_method_time = {r.name: r.compute_time_ms for r in results}
|
| 114 |
+
|
| 115 |
+
record = {
|
| 116 |
+
"timestamp": now_str,
|
| 117 |
+
"run_id": run_id,
|
| 118 |
+
"prediction": pred_data.get("prediction"),
|
| 119 |
+
"probability": pred_data.get("probability"),
|
| 120 |
+
"confidence_pct": pred_data.get("confidence_pct"),
|
| 121 |
+
"ground_truth": ground_truth,
|
| 122 |
+
"is_correct": is_correct,
|
| 123 |
+
"opt_in_image": opt_in_image,
|
| 124 |
+
"methods_run": methods_run,
|
| 125 |
+
"per_method_time_ms": per_method_time,
|
| 126 |
+
"total_time_ms": sum(per_method_time.values()),
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
composite_grid = create_composite_xai_grid(image, results)
|
| 130 |
+
logged_image = image if opt_in_image else None
|
| 131 |
+
|
| 132 |
+
# Spawn background thread to avoid blocking UI response
|
| 133 |
+
thread = threading.Thread(
|
| 134 |
+
target=_async_push_to_dataset,
|
| 135 |
+
args=(record, logged_image, composite_grid),
|
| 136 |
+
daemon=True
|
| 137 |
+
)
|
| 138 |
+
thread.start()
|
| 139 |
+
|
| 140 |
+
return run_id
|
report.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
from skimage.transform import resize
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def generate_forensic_report(
|
| 10 |
+
image: Image.Image,
|
| 11 |
+
pred_data: dict,
|
| 12 |
+
results: list,
|
| 13 |
+
model_name: str = "buildborderless/CommunityForensics-DeepfakeDet-ViT",
|
| 14 |
+
) -> str:
|
| 15 |
+
"""
|
| 16 |
+
Generate an aesthetic high-DPI multi-panel PDF/PNG report for the forensic analysis.
|
| 17 |
+
|
| 18 |
+
Returns path to generated PNG file.
|
| 19 |
+
"""
|
| 20 |
+
plt.style.use('default')
|
| 21 |
+
|
| 22 |
+
orig_w, orig_h = image.size
|
| 23 |
+
image_resized = image.convert("RGB").resize((384, 384))
|
| 24 |
+
image_np = np.array(image_resized) / 255.0
|
| 25 |
+
|
| 26 |
+
n_methods = len(results)
|
| 27 |
+
n_cols = min(n_methods + 1, 4)
|
| 28 |
+
n_rows = ((n_methods + 1) + n_cols - 1) // n_cols
|
| 29 |
+
|
| 30 |
+
fig = plt.figure(figsize=(4.2 * n_cols, 4.5 * n_rows), facecolor="#ffffff")
|
| 31 |
+
|
| 32 |
+
# Header Title
|
| 33 |
+
pred_str = pred_data.get("prediction", "N/A")
|
| 34 |
+
prob = pred_data.get("probability", 0.5)
|
| 35 |
+
conf_pct = pred_data.get("confidence_pct", 50.0)
|
| 36 |
+
is_fake = pred_str == "FAKE"
|
| 37 |
+
|
| 38 |
+
fig.text(
|
| 39 |
+
0.04, 0.96, "ViT Deepfake Forensic Analysis Report",
|
| 40 |
+
fontsize=16, fontweight="bold", color="#0f172a", fontfamily="sans-serif", va="top"
|
| 41 |
+
)
|
| 42 |
+
meta_sub = f"Model: {model_name} │ Resolution: {orig_w}x{orig_h} px │ Target: {pred_str} (p={prob:.4f})"
|
| 43 |
+
fig.text(
|
| 44 |
+
0.04, 0.925, meta_sub,
|
| 45 |
+
fontsize=9.5, color="#64748b", fontfamily="sans-serif", va="top"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Prediction Badge Top Right
|
| 49 |
+
badge_bg = "#fee2e2" if is_fake else "#d1fae5"
|
| 50 |
+
badge_ec = "#ef4444" if is_fake else "#10b981"
|
| 51 |
+
badge_tc = "#991b1b" if is_fake else "#065f46"
|
| 52 |
+
badge_text = f"VERDICT: {pred_str}\nConfidence: {conf_pct:.2f}%"
|
| 53 |
+
|
| 54 |
+
fig.text(
|
| 55 |
+
0.96, 0.955, badge_text,
|
| 56 |
+
fontsize=9.5, fontweight="bold", color=badge_tc, fontfamily="sans-serif",
|
| 57 |
+
ha="right", va="top", multialignment="right",
|
| 58 |
+
bbox=dict(
|
| 59 |
+
boxstyle="round,pad=0.6,rounding_size=0.4",
|
| 60 |
+
facecolor=badge_bg,
|
| 61 |
+
edgecolor=badge_ec,
|
| 62 |
+
linewidth=1.4
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
gs = fig.add_gridspec(n_rows, n_cols, left=0.04, right=0.96, top=0.88, bottom=0.06, wspace=0.15, hspace=0.35)
|
| 67 |
+
|
| 68 |
+
# Plot 0: Original Image
|
| 69 |
+
ax0 = fig.add_subplot(gs[0, 0])
|
| 70 |
+
ax0.imshow(image_np)
|
| 71 |
+
ax0.set_title("Input Image", fontsize=10.5, fontweight="bold", color="#0f172a", pad=6)
|
| 72 |
+
ax0.axis("off")
|
| 73 |
+
|
| 74 |
+
# Plots 1..N: Method Overlays
|
| 75 |
+
for idx, res in enumerate(results, start=1):
|
| 76 |
+
r_idx = idx // n_cols
|
| 77 |
+
c_idx = idx % n_cols
|
| 78 |
+
ax = fig.add_subplot(gs[r_idx, c_idx])
|
| 79 |
+
|
| 80 |
+
# Convert b64 overlay back or use heatmap
|
| 81 |
+
norm_h = res.heatmap_norm
|
| 82 |
+
overlay = image_np.copy()
|
| 83 |
+
heatmap_colored = plt.cm.jet(norm_h)[:, :, :3]
|
| 84 |
+
overlay = 0.4 * overlay + 0.6 * heatmap_colored
|
| 85 |
+
|
| 86 |
+
ax.imshow(overlay)
|
| 87 |
+
ax.set_title(f"{res.name}\n({res.compute_time_ms:.0f} ms)", fontsize=9.5, fontweight="bold", color="#0f172a", pad=4)
|
| 88 |
+
ax.axis("off")
|
| 89 |
+
|
| 90 |
+
output_dir = tempfile.gettempdir()
|
| 91 |
+
output_path = os.path.join(output_dir, f"forensic_report_{int(prob * 10000)}.png")
|
| 92 |
+
|
| 93 |
+
plt.savefig(output_path, dpi=200, facecolor=fig.get_facecolor(), edgecolor='none', bbox_inches="tight")
|
| 94 |
+
plt.close(fig)
|
| 95 |
+
|
| 96 |
+
return output_path
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
transformers>=5.4.0
|
| 3 |
+
captum>=0.7.0
|
| 4 |
+
grad-cam>=1.5.0
|
| 5 |
+
Pillow>=10.0.0
|
| 6 |
+
scikit-image>=0.21.0
|
| 7 |
+
opencv-python-headless>=4.8.0
|
| 8 |
+
matplotlib>=3.8.0
|
| 9 |
+
numpy>=1.24.0
|
| 10 |
+
PyWavelets>=1.4.0
|
| 11 |
+
huggingface_hub>=0.25.0
|
xai_engine/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .model import get_model, ViTWrapper
|
| 2 |
+
from .methods import run_all_methods, run_single_method, run_per_head_attention
|
| 3 |
+
from .viz import create_overlay, to_b64_png
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"get_model",
|
| 7 |
+
"ViTWrapper",
|
| 8 |
+
"run_all_methods",
|
| 9 |
+
"run_single_method",
|
| 10 |
+
"run_per_head_attention",
|
| 11 |
+
"create_overlay",
|
| 12 |
+
"to_b64_png",
|
| 13 |
+
]
|
xai_engine/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (483 Bytes). View file
|
|
|
xai_engine/__pycache__/chefer.cpython-312.pyc
ADDED
|
Binary file (5.83 kB). View file
|
|
|
xai_engine/__pycache__/methods.cpython-312.pyc
ADDED
|
Binary file (11.9 kB). View file
|
|
|
xai_engine/__pycache__/model.cpython-312.pyc
ADDED
|
Binary file (4.59 kB). View file
|
|
|
xai_engine/__pycache__/viz.cpython-312.pyc
ADDED
|
Binary file (3.46 kB). View file
|
|
|
xai_engine/chefer.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradient-weighted Attention Rollout for Vision Transformers (Chefer et al., CVPR 2021).
|
| 3 |
+
Calculates class-discriminative attention maps by weighting attention probabilities with backward gradients.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import numpy as np
|
| 8 |
+
from skimage.transform import resize
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ViTCheferRollout:
|
| 12 |
+
def __init__(self, model):
|
| 13 |
+
self.model = model
|
| 14 |
+
self.attentions = []
|
| 15 |
+
self.attention_gradients = []
|
| 16 |
+
self.hooks = []
|
| 17 |
+
|
| 18 |
+
def _register_hooks(self):
|
| 19 |
+
self._remove_hooks()
|
| 20 |
+
self.attentions = []
|
| 21 |
+
self.attention_gradients = []
|
| 22 |
+
|
| 23 |
+
for name, module in self.model.named_modules():
|
| 24 |
+
if "attention.attention" in name and not hasattr(module, "_chefer_hooked"):
|
| 25 |
+
def get_attn_hook(module, input, output):
|
| 26 |
+
# output in HF ViT self-attention is (context_layer, attn_probs)
|
| 27 |
+
if isinstance(output, tuple) and len(output) > 1:
|
| 28 |
+
self.attentions.append(output[1])
|
| 29 |
+
elif hasattr(output, "attentions"):
|
| 30 |
+
self.attentions.append(output.attentions)
|
| 31 |
+
|
| 32 |
+
def get_attn_grad_hook(module, grad_input, grad_output):
|
| 33 |
+
# grad_output[1] is gradient w.r.t attention probabilities
|
| 34 |
+
if len(grad_output) > 1 and grad_output[1] is not None:
|
| 35 |
+
self.attention_gradients.append(grad_output[1])
|
| 36 |
+
|
| 37 |
+
h1 = module.register_forward_hook(get_attn_hook)
|
| 38 |
+
h2 = module.register_full_backward_hook(get_attn_grad_hook)
|
| 39 |
+
self.hooks.extend([h1, h2])
|
| 40 |
+
|
| 41 |
+
def _remove_hooks(self):
|
| 42 |
+
for h in self.hooks:
|
| 43 |
+
h.remove()
|
| 44 |
+
self.hooks = []
|
| 45 |
+
|
| 46 |
+
def attribute(self, input_tensor, target_class=0):
|
| 47 |
+
"""
|
| 48 |
+
Compute Chefer Gradient-Weighted Attention Rollout.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
input_tensor: (1, 3, 384, 384) preprocessed input tensor
|
| 52 |
+
target_class: output index (0 for single binary logit)
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
24x24 numpy array of patch attributions
|
| 56 |
+
"""
|
| 57 |
+
self.model.eval()
|
| 58 |
+
self._register_hooks()
|
| 59 |
+
device = input_tensor.device
|
| 60 |
+
|
| 61 |
+
# Enable gradients for backward pass
|
| 62 |
+
input_tensor.requires_grad_(True)
|
| 63 |
+
self.model.zero_grad()
|
| 64 |
+
|
| 65 |
+
# Forward pass with output_attentions=True
|
| 66 |
+
outputs = self.model(input_tensor, output_attentions=True)
|
| 67 |
+
logits = outputs.logits # (1, 1)
|
| 68 |
+
|
| 69 |
+
# Backward pass on logit
|
| 70 |
+
if logits.dim() > 1 and logits.size(1) > target_class:
|
| 71 |
+
score = logits[:, target_class]
|
| 72 |
+
else:
|
| 73 |
+
score = logits.squeeze()
|
| 74 |
+
|
| 75 |
+
score.backward()
|
| 76 |
+
|
| 77 |
+
attentions = outputs.attentions # Tuple of 12 (B, heads, 577, 577) tensors
|
| 78 |
+
|
| 79 |
+
# Cleanup hooks
|
| 80 |
+
self._remove_hooks()
|
| 81 |
+
|
| 82 |
+
if not attentions:
|
| 83 |
+
# Fallback if hooks failed to catch: return flat matrix
|
| 84 |
+
return np.zeros((24, 24))
|
| 85 |
+
|
| 86 |
+
seq_len = attentions[0].size(-1) # 577
|
| 87 |
+
result = torch.eye(seq_len, device=device)
|
| 88 |
+
|
| 89 |
+
# Reverse gradient order because backward hook captures in reverse
|
| 90 |
+
gradients = self.attention_gradients[::-1] if self.attention_gradients else [None] * len(attentions)
|
| 91 |
+
|
| 92 |
+
for i, attn in enumerate(attentions):
|
| 93 |
+
# attn: (1, heads, 577, 577)
|
| 94 |
+
if i < len(gradients) and gradients[i] is not None:
|
| 95 |
+
grad = gradients[i]
|
| 96 |
+
# Elementwise product: gradient * attention
|
| 97 |
+
cam = (grad * attn).clamp(min=0).mean(dim=1) # (1, 577, 577)
|
| 98 |
+
else:
|
| 99 |
+
# Fallback to plain attention if gradient missing
|
| 100 |
+
cam = attn.mean(dim=1)
|
| 101 |
+
|
| 102 |
+
cam = cam[0] # (577, 577)
|
| 103 |
+
cam = cam + torch.eye(seq_len, device=device)
|
| 104 |
+
cam = cam / cam.sum(dim=-1, keepdim=True)
|
| 105 |
+
|
| 106 |
+
result = torch.matmul(cam, result)
|
| 107 |
+
|
| 108 |
+
# Extract CLS token -> patch tokens
|
| 109 |
+
patch_attn = result[0, 1:].reshape(24, 24).detach().cpu().numpy()
|
| 110 |
+
return patch_attn
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def chefer_gradient_rollout(wrapper, input_tensor) -> np.ndarray:
|
| 114 |
+
"""Convenience function for running Chefer rollout on ViTWrapper."""
|
| 115 |
+
explainer = ViTCheferRollout(wrapper.model)
|
| 116 |
+
return explainer.attribute(input_tensor, target_class=0)
|
xai_engine/methods.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from skimage.transform import resize
|
| 7 |
+
from captum.attr import IntegratedGradients, Occlusion, DeepLift
|
| 8 |
+
from pytorch_grad_cam import GradCAM
|
| 9 |
+
from pytorch_grad_cam.utils.model_targets import BinaryClassifierOutputTarget
|
| 10 |
+
|
| 11 |
+
from .chefer import chefer_gradient_rollout
|
| 12 |
+
from .viz import create_overlay, render_heatmap_raw, to_b64_png, normalize_heatmap
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class AttributionResult:
|
| 17 |
+
name: str
|
| 18 |
+
description: str
|
| 19 |
+
heatmap: np.ndarray # 2D or 3D numpy array
|
| 20 |
+
heatmap_norm: np.ndarray # Normalized [0, 1] 2D heatmap (384, 384)
|
| 21 |
+
overlay_b64: str # Base64 PNG data URL
|
| 22 |
+
raw_heatmap_b64: str # Base64 PNG data URL
|
| 23 |
+
metadata: dict
|
| 24 |
+
compute_time_ms: float
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def attention_rollout(wrapper, input_tensor) -> np.ndarray:
|
| 28 |
+
"""Attention Rollout for ViT across 12 transformer layers."""
|
| 29 |
+
model = wrapper.model
|
| 30 |
+
model.eval()
|
| 31 |
+
|
| 32 |
+
with torch.no_grad():
|
| 33 |
+
outputs = model(input_tensor, output_attentions=True)
|
| 34 |
+
attentions = outputs.attentions
|
| 35 |
+
|
| 36 |
+
device = attentions[0].device
|
| 37 |
+
seq_len = attentions[0].size(-1) # 577
|
| 38 |
+
result = torch.eye(seq_len, device=device)
|
| 39 |
+
|
| 40 |
+
for attn in attentions:
|
| 41 |
+
attn_heads = attn.mean(dim=1) # Average over 6 heads -> (B, 577, 577)
|
| 42 |
+
attn_heads = attn_heads + torch.eye(attn_heads.size(-1), device=attn_heads.device)
|
| 43 |
+
attn_heads = attn_heads / attn_heads.sum(dim=-1, keepdim=True)
|
| 44 |
+
result = torch.matmul(attn_heads[0], result)
|
| 45 |
+
|
| 46 |
+
patch_attention = result[0, 1:].reshape(24, 24).cpu().numpy()
|
| 47 |
+
return patch_attention
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def integrated_gradients(wrapper, input_tensor, steps=20) -> tuple[np.ndarray, float]:
|
| 51 |
+
"""Integrated Gradients using Captum with memory-safe mini-batching."""
|
| 52 |
+
ig = IntegratedGradients(wrapper)
|
| 53 |
+
baseline = torch.zeros_like(input_tensor)
|
| 54 |
+
|
| 55 |
+
attributions, delta = ig.attribute(
|
| 56 |
+
input_tensor,
|
| 57 |
+
baselines=baseline,
|
| 58 |
+
target=0,
|
| 59 |
+
n_steps=steps,
|
| 60 |
+
internal_batch_size=2, # Prevents memory spike on CPU/RAM
|
| 61 |
+
return_convergence_delta=True
|
| 62 |
+
)
|
| 63 |
+
heatmap = attributions[0].sum(dim=0).detach().cpu().numpy()
|
| 64 |
+
delta_val = float(delta[0].item()) if hasattr(delta, "__getitem__") else float(delta.item())
|
| 65 |
+
return heatmap, delta_val
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def vit_grad_cam(wrapper, input_tensor, target_layer_idx=11) -> np.ndarray:
|
| 69 |
+
"""GradCAM for ViT using pytorch_grad_cam with custom reshape transform."""
|
| 70 |
+
# Target layer: last encoder layer output
|
| 71 |
+
target_layer = [wrapper.model.vit.encoder.layer[target_layer_idx].output]
|
| 72 |
+
|
| 73 |
+
def reshape_transform(tensor):
|
| 74 |
+
# tensor shape: (B, 577, 384) -> drop CLS token -> (B, 576, 384) -> (B, 24, 24, 384) -> (B, 384, 24, 24)
|
| 75 |
+
result = tensor[:, 1:, :].reshape(tensor.size(0), 24, 24, tensor.size(2))
|
| 76 |
+
result = result.permute(0, 3, 1, 2)
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
cam = GradCAM(
|
| 80 |
+
model=wrapper,
|
| 81 |
+
target_layers=target_layer,
|
| 82 |
+
reshape_transform=reshape_transform,
|
| 83 |
+
)
|
| 84 |
+
targets = [BinaryClassifierOutputTarget(0)]
|
| 85 |
+
grayscale_cam = cam(input_tensor=input_tensor, targets=targets)
|
| 86 |
+
return grayscale_cam[0] # (24, 24) or (384, 384)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def occlusion_sensitivity(wrapper, input_tensor, patch_size=32, stride=16) -> np.ndarray:
|
| 90 |
+
"""Occlusion Sensitivity attribution using Captum with memory-safe mini-batching."""
|
| 91 |
+
occ = Occlusion(wrapper)
|
| 92 |
+
attributions = occ.attribute(
|
| 93 |
+
input_tensor,
|
| 94 |
+
target=0,
|
| 95 |
+
strides=(3, stride, stride),
|
| 96 |
+
sliding_window_shapes=(3, patch_size, patch_size),
|
| 97 |
+
baselines=0.0,
|
| 98 |
+
perturbations_per_eval=4, # Limits memory allocation per evaluation batch
|
| 99 |
+
)
|
| 100 |
+
heatmap = attributions[0].sum(dim=0).detach().cpu().numpy()
|
| 101 |
+
return heatmap
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def deeplift(wrapper, input_tensor) -> np.ndarray:
|
| 105 |
+
"""DeepLIFT attribution using Captum."""
|
| 106 |
+
dl = DeepLift(wrapper)
|
| 107 |
+
baseline = torch.zeros_like(input_tensor)
|
| 108 |
+
attributions = dl.attribute(input_tensor, baselines=baseline, target=0)
|
| 109 |
+
heatmap = attributions[0].sum(dim=0).detach().cpu().numpy()
|
| 110 |
+
return heatmap
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def run_per_head_attention(wrapper, image: Image.Image, layer_idx: int = 11, cmap: str = "jet") -> dict:
|
| 114 |
+
"""Extract and render attention maps for all 6 heads at a specific layer."""
|
| 115 |
+
input_tensor = wrapper.preprocess(image)
|
| 116 |
+
device = next(wrapper.model.parameters()).device
|
| 117 |
+
input_tensor = input_tensor.to(device)
|
| 118 |
+
|
| 119 |
+
with torch.no_grad():
|
| 120 |
+
outputs = wrapper.model(input_tensor, output_attentions=True)
|
| 121 |
+
attns = outputs.attentions[layer_idx][0] # (6, 577, 577)
|
| 122 |
+
|
| 123 |
+
head_results = []
|
| 124 |
+
for h in range(6):
|
| 125 |
+
head_map = attns[h, 0, 1:].reshape(24, 24).cpu().numpy()
|
| 126 |
+
overlay = create_overlay(image, head_map, alpha=0.6, cmap=cmap)
|
| 127 |
+
head_results.append({
|
| 128 |
+
"head": h + 1,
|
| 129 |
+
"overlay_b64": to_b64_png(overlay),
|
| 130 |
+
"raw_b64": to_b64_png(render_heatmap_raw(head_map, cmap=cmap)),
|
| 131 |
+
"min": round(float(head_map.min()), 4),
|
| 132 |
+
"max": round(float(head_map.max()), 4),
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
+
return {
|
| 136 |
+
"layer": layer_idx + 1,
|
| 137 |
+
"heads": head_results,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def run_single_method(
|
| 142 |
+
wrapper,
|
| 143 |
+
image: Image.Image,
|
| 144 |
+
method_name: str,
|
| 145 |
+
alpha: float = 0.6,
|
| 146 |
+
cmap: str = "jet",
|
| 147 |
+
steps: int = 20,
|
| 148 |
+
patch_size: int = 32,
|
| 149 |
+
stride: int = 16,
|
| 150 |
+
target_layer_idx: int = 11,
|
| 151 |
+
) -> AttributionResult:
|
| 152 |
+
"""Execute a single specified XAI method."""
|
| 153 |
+
input_tensor = wrapper.preprocess(image)
|
| 154 |
+
device = next(wrapper.model.parameters()).device
|
| 155 |
+
input_tensor = input_tensor.to(device)
|
| 156 |
+
|
| 157 |
+
t0 = time.perf_counter()
|
| 158 |
+
metadata = {}
|
| 159 |
+
|
| 160 |
+
if method_name == "rollout":
|
| 161 |
+
disp_name = "Attention Rollout"
|
| 162 |
+
desc = "Global multi-layer attention flow across all 12 transformer blocks."
|
| 163 |
+
heatmap = attention_rollout(wrapper, input_tensor)
|
| 164 |
+
|
| 165 |
+
elif method_name == "integrated":
|
| 166 |
+
disp_name = "Integrated Gradients"
|
| 167 |
+
desc = "Path-integral gradient attribution relative to a black baseline."
|
| 168 |
+
heatmap, delta = integrated_gradients(wrapper, input_tensor, steps=steps)
|
| 169 |
+
metadata["delta"] = delta
|
| 170 |
+
metadata["steps"] = steps
|
| 171 |
+
|
| 172 |
+
elif method_name == "gradcam":
|
| 173 |
+
disp_name = "GradCAM"
|
| 174 |
+
desc = f"Gradient-weighted activation map targeting layer {target_layer_idx + 1}."
|
| 175 |
+
heatmap = vit_grad_cam(wrapper, input_tensor, target_layer_idx=target_layer_idx)
|
| 176 |
+
metadata["target_layer"] = target_layer_idx + 1
|
| 177 |
+
|
| 178 |
+
elif method_name == "chefer":
|
| 179 |
+
disp_name = "Gradient Attention Rollout (Chefer)"
|
| 180 |
+
desc = "Gradient-weighted attention rollout combining gradients and self-attention (CVPR 2021)."
|
| 181 |
+
heatmap = chefer_gradient_rollout(wrapper, input_tensor)
|
| 182 |
+
|
| 183 |
+
elif method_name == "occlusion":
|
| 184 |
+
disp_name = "Occlusion Sensitivity"
|
| 185 |
+
desc = f"Sliding window perturbation ({patch_size}x{patch_size} patch, stride {stride})."
|
| 186 |
+
heatmap = occlusion_sensitivity(wrapper, input_tensor, patch_size=patch_size, stride=stride)
|
| 187 |
+
metadata["patch_size"] = patch_size
|
| 188 |
+
metadata["stride"] = stride
|
| 189 |
+
|
| 190 |
+
elif method_name == "deeplift":
|
| 191 |
+
disp_name = "DeepLIFT"
|
| 192 |
+
desc = "Non-linear feature attribution backpropagating differences from baseline."
|
| 193 |
+
heatmap = deeplift(wrapper, input_tensor)
|
| 194 |
+
|
| 195 |
+
else:
|
| 196 |
+
raise ValueError(f"Unknown XAI method: {method_name}")
|
| 197 |
+
|
| 198 |
+
elapsed_ms = (time.perf_counter() - t0) * 1000
|
| 199 |
+
|
| 200 |
+
# Resize heatmap to 384x384 if needed
|
| 201 |
+
if heatmap.shape != (384, 384):
|
| 202 |
+
heatmap_resized = resize(heatmap, (384, 384), order=1)
|
| 203 |
+
else:
|
| 204 |
+
heatmap_resized = heatmap
|
| 205 |
+
|
| 206 |
+
norm_h = normalize_heatmap(heatmap_resized)
|
| 207 |
+
overlay = create_overlay(image, heatmap, alpha=alpha, cmap=cmap)
|
| 208 |
+
raw_render = render_heatmap_raw(heatmap, cmap=cmap)
|
| 209 |
+
|
| 210 |
+
metadata.update({
|
| 211 |
+
"min": round(float(heatmap.min()), 5),
|
| 212 |
+
"max": round(float(heatmap.max()), 5),
|
| 213 |
+
"mean": round(float(heatmap.mean()), 5),
|
| 214 |
+
"std": round(float(heatmap.std()), 5),
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
return AttributionResult(
|
| 218 |
+
name=disp_name,
|
| 219 |
+
description=desc,
|
| 220 |
+
heatmap=heatmap,
|
| 221 |
+
heatmap_norm=norm_h,
|
| 222 |
+
overlay_b64=to_b64_png(overlay),
|
| 223 |
+
raw_heatmap_b64=to_b64_png(raw_render),
|
| 224 |
+
metadata=metadata,
|
| 225 |
+
compute_time_ms=round(elapsed_ms, 1),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def run_all_methods(
|
| 230 |
+
wrapper,
|
| 231 |
+
image: Image.Image,
|
| 232 |
+
alpha: float = 0.6,
|
| 233 |
+
cmap: str = "jet",
|
| 234 |
+
) -> list[AttributionResult]:
|
| 235 |
+
"""Run all 6 primary spatial attribution methods (excluding per-head)."""
|
| 236 |
+
methods = ["rollout", "chefer", "gradcam", "integrated", "deeplift", "occlusion"]
|
| 237 |
+
results = []
|
| 238 |
+
for m in methods:
|
| 239 |
+
res = run_single_method(wrapper, image, m, alpha=alpha, cmap=cmap)
|
| 240 |
+
results.append(res)
|
| 241 |
+
return results
|
xai_engine/model.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import threading
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from transformers import ViTForImageClassification, ViTImageProcessor
|
| 7 |
+
|
| 8 |
+
MODEL_REPO = "buildborderless/CommunityForensics-DeepfakeDet-ViT"
|
| 9 |
+
MODEL_TTL_SECONDS = 45 * 60 # 45 minutes
|
| 10 |
+
|
| 11 |
+
_model_lock = threading.Lock()
|
| 12 |
+
_model_instance = None
|
| 13 |
+
_model_loaded_at = 0.0
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ViTWrapper(nn.Module):
|
| 17 |
+
"""Wrap HF ViT to work with Captum and PyTorch XAI methods."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, model_name=MODEL_REPO):
|
| 20 |
+
super().__init__()
|
| 21 |
+
self.model_name = model_name
|
| 22 |
+
self.model = ViTForImageClassification.from_pretrained(model_name)
|
| 23 |
+
self.model.set_attn_implementation('eager') # Required for output_attentions
|
| 24 |
+
self.model.eval()
|
| 25 |
+
self.processor = ViTImageProcessor.from_pretrained(model_name)
|
| 26 |
+
|
| 27 |
+
def forward(self, x):
|
| 28 |
+
"""Forward pass taking preprocessed tensor (B, C, H, W) and returning logits (B, 1)."""
|
| 29 |
+
outputs = self.model(pixel_values=x)
|
| 30 |
+
return outputs.logits
|
| 31 |
+
|
| 32 |
+
def preprocess(self, image: Image.Image) -> torch.Tensor:
|
| 33 |
+
"""Preprocess PIL image to model input tensor matching preprocessor_config."""
|
| 34 |
+
if image.mode != "RGB":
|
| 35 |
+
image = image.convert("RGB")
|
| 36 |
+
inputs = self.processor(
|
| 37 |
+
images=image,
|
| 38 |
+
return_tensors="pt",
|
| 39 |
+
size={"height": 384, "width": 384},
|
| 40 |
+
)
|
| 41 |
+
return inputs.pixel_values # (1, 3, 384, 384)
|
| 42 |
+
|
| 43 |
+
def predict(self, image: Image.Image) -> dict:
|
| 44 |
+
"""Get prediction label, probability, and raw logit."""
|
| 45 |
+
tensor = self.preprocess(image)
|
| 46 |
+
device = next(self.model.parameters()).device
|
| 47 |
+
tensor = tensor.to(device)
|
| 48 |
+
|
| 49 |
+
with torch.no_grad():
|
| 50 |
+
logit = self.forward(tensor).item()
|
| 51 |
+
prob = float(torch.sigmoid(torch.tensor(logit)).item())
|
| 52 |
+
pred = "FAKE" if prob > 0.5 else "REAL"
|
| 53 |
+
conf = prob if pred == "FAKE" else (1.0 - prob)
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
"prediction": pred,
|
| 57 |
+
"probability": prob,
|
| 58 |
+
"confidence_pct": round(conf * 100, 2),
|
| 59 |
+
"logit": round(logit, 4),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_model(device: str = None) -> ViTWrapper:
|
| 64 |
+
"""Thread-safe singleton model loader with periodic background refresh."""
|
| 65 |
+
global _model_instance, _model_loaded_at
|
| 66 |
+
with _model_lock:
|
| 67 |
+
now = time.time()
|
| 68 |
+
if _model_instance is None or (now - _model_loaded_at) > MODEL_TTL_SECONDS:
|
| 69 |
+
if device is None:
|
| 70 |
+
device = "cpu" # Default to CPU for CPU-based HF Space & low VRAM stability
|
| 71 |
+
wrapper = ViTWrapper(MODEL_REPO)
|
| 72 |
+
try:
|
| 73 |
+
wrapper.to(device)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"[model] Target device {device} failed ({e}), falling back to CPU.")
|
| 76 |
+
wrapper.to("cpu")
|
| 77 |
+
_model_instance = wrapper
|
| 78 |
+
_model_loaded_at = now
|
| 79 |
+
return _model_instance
|
xai_engine/viz.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
from skimage.transform import resize
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def normalize_heatmap(heatmap: np.ndarray) -> np.ndarray:
|
| 10 |
+
"""Min-max normalize a heatmap array to [0, 1]."""
|
| 11 |
+
h_min, h_max = heatmap.min(), heatmap.max()
|
| 12 |
+
if h_max - h_min < 1e-8:
|
| 13 |
+
return np.zeros_like(heatmap)
|
| 14 |
+
return (heatmap - h_min) / (h_max - h_min + 1e-8)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_overlay(image: Image.Image, heatmap: np.ndarray, alpha: float = 0.6, cmap: str = "jet") -> Image.Image:
|
| 18 |
+
"""Blend a heatmap overlay onto a PIL Image."""
|
| 19 |
+
image_rgb = image.convert("RGB").resize((384, 384))
|
| 20 |
+
img_np = np.array(image_rgb) / 255.0
|
| 21 |
+
|
| 22 |
+
# Resize heatmap to match image dimension (384, 384)
|
| 23 |
+
if heatmap.shape != (384, 384):
|
| 24 |
+
heatmap_resized = resize(heatmap, (384, 384), order=1)
|
| 25 |
+
else:
|
| 26 |
+
heatmap_resized = heatmap
|
| 27 |
+
|
| 28 |
+
norm_h = normalize_heatmap(heatmap_resized)
|
| 29 |
+
|
| 30 |
+
colormap = plt.get_cmap(cmap)
|
| 31 |
+
colored_heatmap = colormap(norm_h)[:, :, :3] # Drop alpha channel
|
| 32 |
+
|
| 33 |
+
overlay = (1 - alpha) * img_np + alpha * colored_heatmap
|
| 34 |
+
overlay = np.clip(overlay * 255, 0, 255).astype(np.uint8)
|
| 35 |
+
|
| 36 |
+
return Image.fromarray(overlay)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def render_heatmap_raw(heatmap: np.ndarray, cmap: str = "jet") -> Image.Image:
|
| 40 |
+
"""Render raw normalized heatmap as RGB PIL Image using colormap."""
|
| 41 |
+
if heatmap.shape != (384, 384):
|
| 42 |
+
heatmap_resized = resize(heatmap, (384, 384), order=1)
|
| 43 |
+
else:
|
| 44 |
+
heatmap_resized = heatmap
|
| 45 |
+
|
| 46 |
+
norm_h = normalize_heatmap(heatmap_resized)
|
| 47 |
+
colormap = plt.get_cmap(cmap)
|
| 48 |
+
colored = (colormap(norm_h)[:, :, :3] * 255).astype(np.uint8)
|
| 49 |
+
return Image.fromarray(colored)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def to_b64_png(image: Image.Image) -> str:
|
| 53 |
+
"""Convert a PIL Image to a base64 encoded PNG data URL."""
|
| 54 |
+
buffer = BytesIO()
|
| 55 |
+
image.save(buffer, format="PNG")
|
| 56 |
+
b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 57 |
+
return f"data:image/png;base64,{b64_str}"
|