feat: performance overhaul, client-side report, result cache, streaming cards, UX polish
Browse filesBREAKING: Remove server-side matplotlib report (/api/report deleted)
Performance:
- Replace matplotlib colormapping with cv2.applyColorMap (10-30x faster)
- Replace skimage.resize with cv2.resize (~5x faster)
- JPEG encoding for browser display thumbnails (3-5x faster)
- ThreadPoolExecutor for parallel XAI method execution
- torch.set_num_threads for multi-core CPU utilization
- Remove matplotlib and scikit-image dependencies
Architecture:
- Add SHA256 LRU result cache (50 entries) preventing double inference
- Add /api/predict endpoint for fast verdict-only calls
- /api/analyze now runs XAI + all 5 forensics in parallel, caches results
- /api/submit_run reads from cache — zero re-inference
- Delete report.py and /api/report — replaced by client-side html2canvas
Frontend:
- Streaming card updates: skeleton loaders fill in real-time as methods complete
- Client-side PNG report via html2canvas (instant, zero server cost)
- Heatmap/overlay toggle on each result card
- Human-readable method insight blurbs per card
- Tab renamed: Quick Analysis → Run Analysis
- Rich forensics tool descriptions with what-to-look-for guidance
- Expanded Guide tab with XAI + forensics reference
- Pre-emptive forensics: cached results auto-populate Forensics tab
Bug fixes:
- Wire drag-and-drop events on upload zone
- Add clipboard paste support (Ctrl+V)
- Debounce (300ms) on all slider inputs
- Gate tab auto-trigger on analysisComplete flag
- Hide submit panel in clearImage()
- Fix button label restore in generateReport()
- Round compute_time_ms to integer in templates
- Send image_hash to /api/submit_run for cache lookup
- .gitignore +1 -0
- app.py +136 -27
- index.html +436 -116
- logger.py +12 -4
- report.py +0 -96
- requirements.txt +0 -2
- xai_engine/methods.py +21 -14
- xai_engine/model.py +5 -0
- xai_engine/viz.py +45 -20
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
|
@@ -1,6 +1,11 @@
|
|
| 1 |
import os
|
| 2 |
import base64
|
|
|
|
|
|
|
| 3 |
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
| 4 |
from PIL import Image
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from fastapi.responses import HTMLResponse
|
|
@@ -10,16 +15,47 @@ from gradio import Server
|
|
| 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="DeepfakeDetection-XAI")
|
| 18 |
demo = app
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def b64_to_pil(b64_str: str) -> Image.Image:
|
| 22 |
-
"""
|
| 23 |
if not b64_str or not isinstance(b64_str, str):
|
| 24 |
raise ValueError("Invalid base64 image string provided.")
|
| 25 |
if "," in b64_str:
|
|
@@ -30,7 +66,25 @@ def b64_to_pil(b64_str: str) -> Image.Image:
|
|
| 30 |
return Image.open(BytesIO(image_data)).convert("RGB")
|
| 31 |
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
class AnalyzeReq(BaseModel):
|
| 35 |
image_b64: str
|
| 36 |
alpha: float = 0.6
|
|
@@ -67,27 +121,60 @@ class ForensicReq(BaseModel):
|
|
| 67 |
minmax_radius: int = 2
|
| 68 |
|
| 69 |
|
| 70 |
-
class ReportReq(BaseModel):
|
| 71 |
-
image_b64: str
|
| 72 |
-
|
| 73 |
-
|
| 74 |
class SubmitRunReq(BaseModel):
|
| 75 |
image_b64: str
|
|
|
|
| 76 |
ground_truth: str = "unknown"
|
| 77 |
opt_in_image: bool = True
|
| 78 |
|
| 79 |
|
| 80 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@app.post("/api/analyze")
|
| 82 |
def analyze_post(req: AnalyzeReq) -> dict:
|
|
|
|
| 83 |
try:
|
| 84 |
image = b64_to_pil(req.image_b64)
|
| 85 |
except Exception as e:
|
| 86 |
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
model = get_model()
|
| 89 |
pred_data = model.predict(image)
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
res_dicts = [
|
| 93 |
{
|
|
@@ -98,15 +185,21 @@ def analyze_post(req: AnalyzeReq) -> dict:
|
|
| 98 |
"metadata": r.metadata,
|
| 99 |
"compute_time_ms": r.compute_time_ms,
|
| 100 |
}
|
| 101 |
-
for r in
|
| 102 |
]
|
| 103 |
|
| 104 |
-
|
| 105 |
"status": "success",
|
| 106 |
"prediction": pred_data,
|
| 107 |
"results": res_dicts,
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
@app.post("/api/method_detail")
|
| 112 |
def method_detail_post(req: MethodDetailReq) -> dict:
|
|
@@ -166,29 +259,45 @@ def forensic_post(req: ForensicReq) -> dict:
|
|
| 166 |
return res
|
| 167 |
|
| 168 |
|
| 169 |
-
@app.post("/api/report")
|
| 170 |
-
def report_post(req: ReportReq) -> dict:
|
| 171 |
-
image = b64_to_pil(req.image_b64)
|
| 172 |
-
model = get_model()
|
| 173 |
-
pred_data = model.predict(image)
|
| 174 |
-
results = run_all_methods(model, image)
|
| 175 |
-
report_path = generate_forensic_report(image, pred_data, results)
|
| 176 |
-
with open(report_path, "rb") as f:
|
| 177 |
-
rep_b64 = "data:image/png;base64," + base64.b64encode(f.read()).decode("utf-8")
|
| 178 |
-
return {"status": "success", "report_b64": rep_b64}
|
| 179 |
-
|
| 180 |
-
|
| 181 |
@app.post("/api/submit_run")
|
| 182 |
def submit_run_post(req: SubmitRunReq) -> dict:
|
|
|
|
| 183 |
try:
|
| 184 |
image = b64_to_pil(req.image_b64)
|
| 185 |
except Exception as e:
|
| 186 |
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
return {
|
| 194 |
"status": "success",
|
|
|
|
| 1 |
import os
|
| 2 |
import base64
|
| 3 |
+
import hashlib
|
| 4 |
+
import threading
|
| 5 |
from io import BytesIO
|
| 6 |
+
from collections import OrderedDict
|
| 7 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
+
|
| 9 |
from PIL import Image
|
| 10 |
from pydantic import BaseModel
|
| 11 |
from fastapi.responses import HTMLResponse
|
|
|
|
| 15 |
from xai_engine.model import get_model
|
| 16 |
from xai_engine.methods import run_all_methods, run_single_method, run_per_head_attention
|
| 17 |
from forensics.runner import run_forensic_tool
|
|
|
|
| 18 |
from logger import log_run_dataset
|
| 19 |
|
| 20 |
# Initialize Gradio Server app
|
| 21 |
app = Server(title="DeepfakeDetection-XAI")
|
| 22 |
demo = app
|
| 23 |
|
| 24 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 25 |
+
# Result Cache — SHA256-keyed LRU, server-side RAM
|
| 26 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 27 |
+
_result_cache: OrderedDict[str, dict] = OrderedDict()
|
| 28 |
+
_cache_lock = threading.Lock()
|
| 29 |
+
_CACHE_MAX = 50 # ~120MB max at 6 XAI + 5 forensics per entry
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _img_hash(image: Image.Image) -> str:
|
| 33 |
+
"""SHA256 hash of image pixel content for cache keying."""
|
| 34 |
+
buf = BytesIO()
|
| 35 |
+
image.save(buf, format="PNG")
|
| 36 |
+
return hashlib.sha256(buf.getvalue()).hexdigest()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _cache_get(key: str) -> dict | None:
|
| 40 |
+
with _cache_lock:
|
| 41 |
+
val = _result_cache.get(key)
|
| 42 |
+
if val is not None:
|
| 43 |
+
_result_cache.move_to_end(key) # LRU touch
|
| 44 |
+
return val
|
| 45 |
+
|
| 46 |
|
| 47 |
+
def _cache_set(key: str, val: dict):
|
| 48 |
+
with _cache_lock:
|
| 49 |
+
_result_cache[key] = val
|
| 50 |
+
if len(_result_cache) > _CACHE_MAX:
|
| 51 |
+
_result_cache.popitem(last=False) # Evict oldest
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 55 |
+
# Helpers
|
| 56 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 57 |
def b64_to_pil(b64_str: str) -> Image.Image:
|
| 58 |
+
"""Convert base64 image data URL to PIL Image."""
|
| 59 |
if not b64_str or not isinstance(b64_str, str):
|
| 60 |
raise ValueError("Invalid base64 image string provided.")
|
| 61 |
if "," in b64_str:
|
|
|
|
| 66 |
return Image.open(BytesIO(image_data)).convert("RGB")
|
| 67 |
|
| 68 |
|
| 69 |
+
def _run_all_forensics_defaults(image: Image.Image) -> dict:
|
| 70 |
+
"""Run all 5 classic forensics tools at default parameters. Returns dict keyed by tool id."""
|
| 71 |
+
tools = ["ela", "gradient", "bitplane", "minmax", "wavelet"]
|
| 72 |
+
results = {}
|
| 73 |
+
for t in tools:
|
| 74 |
+
try:
|
| 75 |
+
results[t] = run_forensic_tool(image, t)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"[forensics] Error running {t}: {e}")
|
| 78 |
+
return results
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 82 |
+
# Pydantic Request Schemas
|
| 83 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 84 |
+
class PredictReq(BaseModel):
|
| 85 |
+
image_b64: str
|
| 86 |
+
|
| 87 |
+
|
| 88 |
class AnalyzeReq(BaseModel):
|
| 89 |
image_b64: str
|
| 90 |
alpha: float = 0.6
|
|
|
|
| 121 |
minmax_radius: int = 2
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
class SubmitRunReq(BaseModel):
|
| 125 |
image_b64: str
|
| 126 |
+
image_hash: str | None = None
|
| 127 |
ground_truth: str = "unknown"
|
| 128 |
opt_in_image: bool = True
|
| 129 |
|
| 130 |
|
| 131 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 132 |
+
# REST API Routes
|
| 133 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 134 |
+
@app.post("/api/predict")
|
| 135 |
+
def predict_post(req: PredictReq) -> dict:
|
| 136 |
+
"""Fast prediction — no XAI, just verdict + confidence."""
|
| 137 |
+
try:
|
| 138 |
+
image = b64_to_pil(req.image_b64)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 141 |
+
|
| 142 |
+
model = get_model()
|
| 143 |
+
pred_data = model.predict(image)
|
| 144 |
+
img_hash = _img_hash(image)
|
| 145 |
+
|
| 146 |
+
return {
|
| 147 |
+
"status": "success",
|
| 148 |
+
"prediction": pred_data,
|
| 149 |
+
"image_hash": img_hash,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
@app.post("/api/analyze")
|
| 154 |
def analyze_post(req: AnalyzeReq) -> dict:
|
| 155 |
+
"""Full analysis: XAI + forensics. Results are cached."""
|
| 156 |
try:
|
| 157 |
image = b64_to_pil(req.image_b64)
|
| 158 |
except Exception as e:
|
| 159 |
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 160 |
|
| 161 |
+
key = _img_hash(image)
|
| 162 |
+
|
| 163 |
+
# Check cache
|
| 164 |
+
cached = _cache_get(key)
|
| 165 |
+
if cached:
|
| 166 |
+
return {**cached, "cache_hit": True}
|
| 167 |
+
|
| 168 |
model = get_model()
|
| 169 |
pred_data = model.predict(image)
|
| 170 |
+
|
| 171 |
+
# Run XAI methods and forensics in parallel
|
| 172 |
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
| 173 |
+
xai_future = executor.submit(run_all_methods, model, image, alpha=req.alpha, cmap=req.cmap)
|
| 174 |
+
forensics_future = executor.submit(_run_all_forensics_defaults, image)
|
| 175 |
+
|
| 176 |
+
xai_results = xai_future.result()
|
| 177 |
+
forensics_results = forensics_future.result()
|
| 178 |
|
| 179 |
res_dicts = [
|
| 180 |
{
|
|
|
|
| 185 |
"metadata": r.metadata,
|
| 186 |
"compute_time_ms": r.compute_time_ms,
|
| 187 |
}
|
| 188 |
+
for r in xai_results
|
| 189 |
]
|
| 190 |
|
| 191 |
+
response = {
|
| 192 |
"status": "success",
|
| 193 |
"prediction": pred_data,
|
| 194 |
"results": res_dicts,
|
| 195 |
+
"forensics": forensics_results,
|
| 196 |
+
"image_hash": key,
|
| 197 |
+
"cache_hit": False,
|
| 198 |
}
|
| 199 |
|
| 200 |
+
_cache_set(key, response)
|
| 201 |
+
return response
|
| 202 |
+
|
| 203 |
|
| 204 |
@app.post("/api/method_detail")
|
| 205 |
def method_detail_post(req: MethodDetailReq) -> dict:
|
|
|
|
| 259 |
return res
|
| 260 |
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
@app.post("/api/submit_run")
|
| 263 |
def submit_run_post(req: SubmitRunReq) -> dict:
|
| 264 |
+
"""Submit ground truth feedback. Reads XAI/forensics from cache — zero re-inference."""
|
| 265 |
try:
|
| 266 |
image = b64_to_pil(req.image_b64)
|
| 267 |
except Exception as e:
|
| 268 |
return {"status": "error", "message": f"Image decoding failed: {e}"}
|
| 269 |
|
| 270 |
+
key = req.image_hash or _img_hash(image)
|
| 271 |
+
cached = _cache_get(key)
|
| 272 |
+
|
| 273 |
+
if cached:
|
| 274 |
+
pred_data = cached["prediction"]
|
| 275 |
+
xai_results = cached["results"]
|
| 276 |
+
forensics_results = cached.get("forensics", {})
|
| 277 |
+
else:
|
| 278 |
+
# Fallback: run analysis if cache miss (shouldn't happen in normal flow)
|
| 279 |
+
model = get_model()
|
| 280 |
+
pred_data = model.predict(image)
|
| 281 |
+
xai_result_objs = run_all_methods(model, image)
|
| 282 |
+
xai_results = [
|
| 283 |
+
{
|
| 284 |
+
"name": r.name,
|
| 285 |
+
"description": r.description,
|
| 286 |
+
"overlay_b64": r.overlay_b64,
|
| 287 |
+
"raw_heatmap_b64": r.raw_heatmap_b64,
|
| 288 |
+
"metadata": r.metadata,
|
| 289 |
+
"compute_time_ms": r.compute_time_ms,
|
| 290 |
+
}
|
| 291 |
+
for r in xai_result_objs
|
| 292 |
+
]
|
| 293 |
+
forensics_results = _run_all_forensics_defaults(image)
|
| 294 |
+
|
| 295 |
+
run_id = log_run_dataset(
|
| 296 |
+
image, pred_data, xai_results,
|
| 297 |
+
forensics_results=forensics_results,
|
| 298 |
+
ground_truth=req.ground_truth,
|
| 299 |
+
opt_in_image=req.opt_in_image,
|
| 300 |
+
)
|
| 301 |
|
| 302 |
return {
|
| 303 |
"status": "success",
|
|
@@ -12,6 +12,9 @@
|
|
| 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',
|
|
@@ -43,6 +46,16 @@
|
|
| 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">
|
|
@@ -73,7 +86,7 @@
|
|
| 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> ⚡
|
| 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
|
|
@@ -104,13 +117,13 @@
|
|
| 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>
|
|
@@ -134,7 +147,7 @@
|
|
| 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="generateReport()" 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">
|
|
@@ -147,12 +160,12 @@
|
|
| 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
|
| 153 |
</div>
|
| 154 |
|
| 155 |
-
<!-- TAB 1: ⚡
|
| 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">
|
|
@@ -162,7 +175,7 @@
|
|
| 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>
|
|
@@ -243,7 +256,7 @@
|
|
| 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="
|
| 247 |
<option value="rollout">Attention Rollout</option>
|
| 248 |
<option value="chefer">Gradient-Weighted Rollout (Chefer)</option>
|
| 249 |
<option value="gradcam">GradCAM</option>
|
|
@@ -255,7 +268,7 @@
|
|
| 255 |
|
| 256 |
<div>
|
| 257 |
<label class="text-xs font-medium text-slate-300 block mb-1">Colormap</label>
|
| 258 |
-
<select id="exp-cmap" onchange="
|
| 259 |
<option value="jet">Jet (Standard Forensic)</option>
|
| 260 |
<option value="inferno">Inferno (High Contrast)</option>
|
| 261 |
<option value="viridis">Viridis (Perceptual)</option>
|
|
@@ -265,23 +278,23 @@
|
|
| 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;
|
| 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;
|
| 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;
|
| 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;
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
|
|
@@ -309,7 +322,7 @@
|
|
| 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"
|
| 313 |
</div>
|
| 314 |
</div>
|
| 315 |
|
|
@@ -334,36 +347,42 @@
|
|
| 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="
|
| 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
|
| 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;
|
| 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="
|
| 359 |
</div>
|
| 360 |
|
| 361 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-[
|
| 366 |
-
<img id="for-img-output" src="" class="max-h-
|
| 367 |
<p id="for-placeholder" class="text-xs text-slate-500">Upload an image and run analysis</p>
|
| 368 |
</div>
|
| 369 |
</div>
|
|
@@ -373,23 +392,77 @@
|
|
| 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">
|
| 378 |
-
<p class="text-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
| 380 |
</p>
|
| 381 |
</div>
|
| 382 |
|
| 383 |
-
<div
|
| 384 |
-
<
|
| 385 |
-
|
| 386 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
</div>
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
</div>
|
| 392 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
</div>
|
| 394 |
</div>
|
| 395 |
|
|
@@ -400,57 +473,153 @@
|
|
| 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 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 428 |
-
if (
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
|
| 433 |
function handleFileSelect(evt) {
|
| 434 |
const file = evt.target.files[0];
|
| 435 |
if (!file) return;
|
| 436 |
const reader = new FileReader();
|
| 437 |
-
reader.onload =
|
| 438 |
-
currentImageB64 = e.target.result;
|
| 439 |
-
document.getElementById('image-preview').src = currentImageB64;
|
| 440 |
-
document.getElementById('upload-prompt').classList.add('hidden');
|
| 441 |
-
document.getElementById('preview-container').classList.remove('hidden');
|
| 442 |
-
document.getElementById('btn-analyze').disabled = false;
|
| 443 |
-
};
|
| 444 |
reader.readAsDataURL(file);
|
| 445 |
}
|
| 446 |
|
| 447 |
function clearImage() {
|
| 448 |
currentImageB64 = null;
|
|
|
|
| 449 |
document.getElementById('file-input').value = '';
|
| 450 |
document.getElementById('upload-prompt').classList.remove('hidden');
|
| 451 |
document.getElementById('preview-container').classList.add('hidden');
|
| 452 |
document.getElementById('btn-analyze').disabled = true;
|
| 453 |
document.getElementById('btn-report').disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
}
|
| 455 |
|
| 456 |
async function apiCall(endpoint, payload) {
|
|
@@ -467,43 +636,74 @@
|
|
| 467 |
|
| 468 |
document.getElementById('loading-spinner').classList.remove('hidden');
|
| 469 |
document.getElementById('btn-analyze').disabled = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
try {
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
if (
|
| 475 |
-
const pred =
|
|
|
|
|
|
|
| 476 |
const isFake = pred.prediction === 'FAKE';
|
| 477 |
const badge = document.getElementById('prediction-badge');
|
| 478 |
badge.className = `mt-1 text-sm font-bold flex items-center gap-2 ${isFake ? 'text-rose-400' : 'text-emerald-400'}`;
|
| 479 |
badge.innerHTML = `<span class="w-2.5 h-2.5 rounded-full ${isFake ? 'bg-rose-500' : 'bg-emerald-500'}"></span> VERDICT: ${pred.prediction}`;
|
| 480 |
-
|
| 481 |
document.getElementById('confidence-val').innerText = pred.confidence_pct.toFixed(2) + '%';
|
| 482 |
document.getElementById('confidence-box').classList.remove('hidden');
|
|
|
|
| 483 |
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
res.
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
| 499 |
});
|
|
|
|
| 500 |
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
|
|
|
| 507 |
console.error(err);
|
| 508 |
alert('API Request Failed: ' + err);
|
| 509 |
} finally {
|
|
@@ -512,6 +712,128 @@
|
|
| 512 |
}
|
| 513 |
}
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
async function runExplorerMethod() {
|
| 516 |
if (!currentImageB64) return;
|
| 517 |
const method = document.getElementById('exp-method').value;
|
|
@@ -547,8 +869,7 @@
|
|
| 547 |
async function runPerHeadInspector() {
|
| 548 |
if (!currentImageB64) return;
|
| 549 |
const displayLayer = parseInt(document.getElementById('head-layer-input').value);
|
| 550 |
-
const layer_idx = displayLayer - 1;
|
| 551 |
-
document.getElementById('head-layer-val').innerText = displayLayer;
|
| 552 |
|
| 553 |
const res = await apiCall('heads', {
|
| 554 |
image_b64: currentImageB64,
|
|
@@ -572,17 +893,35 @@
|
|
| 572 |
}
|
| 573 |
}
|
| 574 |
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
if (!currentImageB64) return;
|
| 577 |
const tool = document.getElementById('for-tool').value;
|
| 578 |
const quality = parseInt(document.getElementById('for-quality').value);
|
| 579 |
const bit = parseInt(document.getElementById('for-bit').value);
|
| 580 |
|
|
|
|
|
|
|
| 581 |
const ctrlELA = document.getElementById('ctrl-ela');
|
| 582 |
if (ctrlELA) ctrlELA.classList.toggle('hidden', tool !== 'ela');
|
| 583 |
const ctrlBit = document.getElementById('ctrl-bitplane');
|
| 584 |
if (ctrlBit) ctrlBit.classList.toggle('hidden', tool !== 'bitplane');
|
| 585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
const res = await apiCall('forensic', {
|
| 587 |
image_b64: currentImageB64,
|
| 588 |
tool_name: tool,
|
|
@@ -591,45 +930,27 @@
|
|
| 591 |
});
|
| 592 |
|
| 593 |
if (res.status === 'success' && res.result_b64) {
|
| 594 |
-
|
| 595 |
-
img.src = res.result_b64;
|
| 596 |
-
img.classList.remove('hidden');
|
| 597 |
-
document.getElementById('for-placeholder').classList.add('hidden');
|
| 598 |
-
document.getElementById('for-info').innerText = `${res.tool} analysis complete (${res.compute_time_ms} ms).`;
|
| 599 |
}
|
| 600 |
}
|
| 601 |
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
try {
|
| 609 |
-
const res = await apiCall('report', { image_b64: currentImageB64 });
|
| 610 |
-
if (res.status === 'success' && res.report_b64) {
|
| 611 |
-
openModal('Forensic Report', res.report_b64);
|
| 612 |
-
const a = document.createElement('a');
|
| 613 |
-
a.href = res.report_b64;
|
| 614 |
-
a.download = `forensic_report_${Date.now()}.png`;
|
| 615 |
-
a.click();
|
| 616 |
-
}
|
| 617 |
-
} catch (err) {
|
| 618 |
-
console.error(err);
|
| 619 |
-
} finally {
|
| 620 |
-
btn.disabled = false;
|
| 621 |
-
btn.innerHTML = '<i class="fa-solid fa-file-pdf"></i> Export Forensic Report';
|
| 622 |
-
}
|
| 623 |
}
|
| 624 |
|
| 625 |
async function submitDatasetRun() {
|
| 626 |
-
if (!currentImageB64) return;
|
| 627 |
const gtEl = document.querySelector('input[name="ground_truth"]:checked');
|
| 628 |
const gt = gtEl ? gtEl.value : 'unknown';
|
| 629 |
const optIn = document.getElementById('opt-in-image').checked;
|
| 630 |
|
| 631 |
const res = await apiCall('submit_run', {
|
| 632 |
image_b64: currentImageB64,
|
|
|
|
| 633 |
ground_truth: gt,
|
| 634 |
opt_in_image: optIn
|
| 635 |
});
|
|
@@ -653,4 +974,3 @@
|
|
| 653 |
</script>
|
| 654 |
</body>
|
| 655 |
</html>
|
| 656 |
-
|
|
|
|
| 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 |
+
<!-- html2canvas -->
|
| 16 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 17 |
+
|
| 18 |
<script>
|
| 19 |
tailwind.config = {
|
| 20 |
darkMode: 'class',
|
|
|
|
| 46 |
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 47 |
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
|
| 48 |
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
| 49 |
+
|
| 50 |
+
@keyframes shimmer {
|
| 51 |
+
0% { background-position: -200% 0; }
|
| 52 |
+
100% { background-position: 200% 0; }
|
| 53 |
+
}
|
| 54 |
+
.skeleton-shimmer {
|
| 55 |
+
background: linear-gradient(90deg, rgba(30,41,59,0.5) 25%, rgba(51,65,85,0.8) 50%, rgba(30,41,59,0.5) 75%);
|
| 56 |
+
background-size: 200% 100%;
|
| 57 |
+
animation: shimmer 1.5s infinite;
|
| 58 |
+
}
|
| 59 |
</style>
|
| 60 |
</head>
|
| 61 |
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col selection:bg-teal-500 selection:text-white">
|
|
|
|
| 86 |
<div class="bg-slate-900/50 border-b border-slate-800/80 sticky top-[61px] z-30 backdrop-blur-md">
|
| 87 |
<div class="max-w-7xl mx-auto px-4 flex space-x-2 overflow-x-auto custom-scrollbar">
|
| 88 |
<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">
|
| 89 |
+
<i class="fa-solid fa-bolt text-xs"></i> ⚡ Run Analysis
|
| 90 |
</button>
|
| 91 |
<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">
|
| 92 |
<i class="fa-solid fa-sliders text-xs"></i> 🔍 Method Explorer
|
|
|
|
| 117 |
|
| 118 |
<div id="upload-prompt" class="py-2">
|
| 119 |
<i class="fa-solid fa-cloud-arrow-up text-3xl text-teal-400/80 group-hover:scale-110 transition mb-2"></i>
|
| 120 |
+
<p class="text-sm font-medium text-slate-200">Drag & Drop Image, Paste, or <span class="text-teal-400 underline">Browse</span></p>
|
| 121 |
<p class="text-xs text-slate-500 mt-1">Supports JPG, PNG, WEBP (Auto-cropped to 384×384)</p>
|
| 122 |
</div>
|
| 123 |
|
| 124 |
<div id="preview-container" class="hidden relative inline-block">
|
| 125 |
<img id="image-preview" src="" alt="Input Preview" class="max-h-48 rounded-lg shadow-lg border border-slate-700 object-contain mx-auto" />
|
| 126 |
+
<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 z-10">
|
| 127 |
<i class="fa-solid fa-xmark"></i>
|
| 128 |
</button>
|
| 129 |
</div>
|
|
|
|
| 147 |
|
| 148 |
<!-- Buttons -->
|
| 149 |
<div class="flex flex-wrap items-center gap-3">
|
| 150 |
+
<button id="btn-analyze" onclick="runAnalysis()" disabled 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">
|
| 151 |
<i class="fa-solid fa-bolt"></i> Run Full XAI Suite
|
| 152 |
</button>
|
| 153 |
<button id="btn-report" onclick="generateReport()" 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">
|
|
|
|
| 160 |
</div>
|
| 161 |
|
| 162 |
<!-- Loading Bar / Spinner -->
|
| 163 |
+
<div id="loading-spinner" class="hidden glass-panel rounded-2xl p-6 mb-6 text-center border-t border-teal-500">
|
| 164 |
<div class="inline-block animate-spin w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full mb-3"></div>
|
| 165 |
+
<p id="loading-text" class="text-sm font-medium text-slate-300">Computing ViT Attribution Maps in parallel...</p>
|
| 166 |
</div>
|
| 167 |
|
| 168 |
+
<!-- TAB 1: ⚡ Run Analysis -->
|
| 169 |
<div id="section-quick" class="tab-section">
|
| 170 |
<div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
|
| 171 |
<h2 class="text-base font-bold text-slate-100 mb-4 flex items-center justify-between">
|
|
|
|
| 175 |
|
| 176 |
<!-- Grid of 6 attribution overlays -->
|
| 177 |
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 178 |
+
<!-- Placeholder cards (initial state) -->
|
| 179 |
<div class="bg-slate-900/40 border border-slate-800/80 rounded-xl p-4 text-center py-12 text-slate-600">
|
| 180 |
<i class="fa-regular fa-image text-3xl mb-2"></i>
|
| 181 |
<p class="text-xs">Attention Rollout</p>
|
|
|
|
| 256 |
<div class="lg:col-span-4 space-y-4 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 257 |
<div>
|
| 258 |
<label class="text-xs font-medium text-slate-300 block mb-1">Select XAI Method</label>
|
| 259 |
+
<select id="exp-method" onchange="debouncedRunExplorer()" 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">
|
| 260 |
<option value="rollout">Attention Rollout</option>
|
| 261 |
<option value="chefer">Gradient-Weighted Rollout (Chefer)</option>
|
| 262 |
<option value="gradcam">GradCAM</option>
|
|
|
|
| 268 |
|
| 269 |
<div>
|
| 270 |
<label class="text-xs font-medium text-slate-300 block mb-1">Colormap</label>
|
| 271 |
+
<select id="exp-cmap" onchange="debouncedRunExplorer()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
|
| 272 |
<option value="jet">Jet (Standard Forensic)</option>
|
| 273 |
<option value="inferno">Inferno (High Contrast)</option>
|
| 274 |
<option value="viridis">Viridis (Perceptual)</option>
|
|
|
|
| 278 |
|
| 279 |
<div>
|
| 280 |
<label class="text-xs font-medium text-slate-300 block mb-1">Overlay Transparency (Alpha: <span id="val-alpha">0.6</span>)</label>
|
| 281 |
+
<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; debouncedRunExplorer()" class="w-full text-teal-500" />
|
| 282 |
</div>
|
| 283 |
|
| 284 |
<!-- Conditional Controls -->
|
| 285 |
+
<div id="ctrl-integrated" class="hidden space-y-3 pt-2 border-t border-slate-800">
|
| 286 |
<label class="text-xs font-medium text-slate-300 block mb-1">IG Steps: <span id="val-steps">20</span></label>
|
| 287 |
+
<input type="range" id="exp-steps" min="10" max="60" step="5" value="20" oninput="document.getElementById('val-steps').innerText=this.value; debouncedRunExplorer()" class="w-full" />
|
| 288 |
</div>
|
| 289 |
|
| 290 |
+
<div id="ctrl-occlusion" class="hidden space-y-3 pt-2 border-t border-slate-800">
|
| 291 |
<label class="text-xs font-medium text-slate-300 block mb-1">Patch Size: <span id="val-patch">32</span> px</label>
|
| 292 |
+
<input type="range" id="exp-patch" min="16" max="64" step="8" value="32" oninput="document.getElementById('val-patch').innerText=this.value; debouncedRunExplorer()" class="w-full" />
|
| 293 |
</div>
|
| 294 |
|
| 295 |
+
<div id="ctrl-gradcam" class="hidden space-y-3 pt-2 border-t border-slate-800">
|
| 296 |
<label class="text-xs font-medium text-slate-300 block mb-1">Target Transformer Layer: <span id="val-layer">12</span></label>
|
| 297 |
+
<input type="range" id="exp-layer" min="1" max="12" step="1" value="12" oninput="document.getElementById('val-layer').innerText=this.value; debouncedRunExplorer()" class="w-full" />
|
| 298 |
</div>
|
| 299 |
</div>
|
| 300 |
|
|
|
|
| 322 |
</h2>
|
| 323 |
<div class="flex items-center space-x-3">
|
| 324 |
<span class="text-xs text-slate-400">Layer: <strong id="head-layer-val" class="text-teal-400">12</strong> / 12</span>
|
| 325 |
+
<input type="range" id="head-layer-input" min="1" max="12" value="12" oninput="document.getElementById('head-layer-val').innerText=this.value; debouncedRunHeads()" class="w-32" />
|
| 326 |
</div>
|
| 327 |
</div>
|
| 328 |
|
|
|
|
| 347 |
|
| 348 |
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
| 349 |
<!-- Controls -->
|
| 350 |
+
<div class="lg:col-span-4 space-y-4 bg-slate-900/60 p-4 rounded-xl border border-slate-800 flex flex-col">
|
| 351 |
<div>
|
| 352 |
<label class="text-xs font-medium text-slate-300 block mb-1">Forensic Analysis Tool</label>
|
| 353 |
+
<select id="for-tool" onchange="debouncedRunForensics(true)" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
|
| 354 |
<option value="ela">Error Level Analysis (ELA)</option>
|
| 355 |
<option value="gradient">Spatial Edge Gradient</option>
|
| 356 |
<option value="bitplane">Bit Plane Extractor</option>
|
| 357 |
<option value="minmax">MinMax Local Deviation</option>
|
| 358 |
+
<option value="wavelet">Wavelet Noise Estimation</option>
|
| 359 |
</select>
|
| 360 |
</div>
|
| 361 |
|
| 362 |
<!-- ELA Options -->
|
| 363 |
<div id="ctrl-ela" class="space-y-3 pt-2 border-t border-slate-800">
|
| 364 |
<label class="text-xs font-medium text-slate-300 block mb-1">JPEG Quality: <span id="val-quality">75</span>%</label>
|
| 365 |
+
<input type="range" id="for-quality" min="30" max="95" value="75" oninput="document.getElementById('val-quality').innerText=this.value; debouncedRunForensics(true)" class="w-full" />
|
| 366 |
</div>
|
| 367 |
|
| 368 |
<!-- Bitplane Options -->
|
| 369 |
<div id="ctrl-bitplane" class="hidden space-y-3 pt-2 border-t border-slate-800">
|
| 370 |
<label class="text-xs font-medium text-slate-300 block mb-1">Bit Index (0=LSB, 7=MSB)</label>
|
| 371 |
+
<input type="range" id="for-bit" min="0" max="7" value="0" oninput="debouncedRunForensics(true)" class="w-full" />
|
| 372 |
</div>
|
| 373 |
|
| 374 |
+
<div class="mt-auto pt-4 border-t border-slate-800/50">
|
| 375 |
+
<h4 id="for-desc-title" class="font-bold text-sm text-teal-400 mb-1">Error Level Analysis (ELA)</h4>
|
| 376 |
+
<p id="for-desc-text" class="text-xs text-slate-400 mb-2 leading-relaxed">Re-saves the image at lower JPEG quality and computes the pixel-level difference. Regions that were edited or composited appear brighter because they were saved at a different compression level than the surrounding content.</p>
|
| 377 |
+
<p class="text-xs text-slate-300"><span class="font-semibold text-slate-200">Look for:</span> <span id="for-desc-look">Uniform bright glow across the face region = likely composite. Inconsistent brightness between face and background = editing boundary.</span></p>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<p id="for-info" class="text-[10px] font-mono text-teal-400/90 pt-2"></p>
|
| 381 |
</div>
|
| 382 |
|
| 383 |
<!-- Display Box -->
|
| 384 |
+
<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-[400px]">
|
| 385 |
+
<img id="for-img-output" src="" class="max-h-[450px] rounded-lg shadow border border-slate-700 object-contain hidden" />
|
| 386 |
<p id="for-placeholder" class="text-xs text-slate-500">Upload an image and run analysis</p>
|
| 387 |
</div>
|
| 388 |
</div>
|
|
|
|
| 392 |
<!-- TAB 5: 📖 Guide & Reference -->
|
| 393 |
<div id="section-guide" class="tab-section hidden">
|
| 394 |
<div class="glass-panel rounded-2xl p-6 shadow-xl space-y-6">
|
| 395 |
+
|
| 396 |
<div>
|
| 397 |
+
<h2 class="text-lg font-bold text-slate-100 mb-2 border-b border-slate-800 pb-2">1. How to Read XAI Heatmaps</h2>
|
| 398 |
+
<p class="text-sm text-slate-300 leading-relaxed mb-2">
|
| 399 |
+
Explainable AI (XAI) methods attribute the final prediction score back to the original image pixels.
|
| 400 |
+
<strong>Warm colors (red/yellow)</strong> indicate high attribution, meaning those pixels strongly influenced the model's prediction.
|
| 401 |
+
<strong>Cool colors (blue/purple)</strong> indicate low attribution or minimal influence.
|
| 402 |
+
Each method approaches this problem differently, effectively answering a different question about the model's internal behavior.
|
| 403 |
</p>
|
| 404 |
</div>
|
| 405 |
|
| 406 |
+
<div>
|
| 407 |
+
<h2 class="text-lg font-bold text-slate-100 mb-4 border-b border-slate-800 pb-2">2. XAI Methods</h2>
|
| 408 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-xs text-slate-300">
|
| 409 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 410 |
+
<h3 class="font-bold text-teal-400 mb-1">Attention Rollout</h3>
|
| 411 |
+
<p class="text-slate-400">Averages attention weights across all heads and propagates them through all 12 layers. It highlights where the model focused its spatial attention globally, regardless of the final class prediction.</p>
|
| 412 |
+
</div>
|
| 413 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 414 |
+
<h3 class="font-bold text-teal-400 mb-1">Chefer Gradient-Rollout</h3>
|
| 415 |
+
<p class="text-slate-400">Multiplies attention weights by their backward gradients. This makes the map class-discriminative, showing exactly which regions pushed the probability toward the final verdict (REAL or FAKE).</p>
|
| 416 |
+
</div>
|
| 417 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 418 |
+
<h3 class="font-bold text-teal-400 mb-1">GradCAM</h3>
|
| 419 |
+
<p class="text-slate-400">Uses the gradients of the target concept flowing into the final attention layer. It provides the cleanest, most localized answer to "where did the model decide?", often highlighting the face center.</p>
|
| 420 |
+
</div>
|
| 421 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 422 |
+
<h3 class="font-bold text-teal-400 mb-1">Integrated Gradients</h3>
|
| 423 |
+
<p class="text-slate-400">Computes the integral of gradients along a straight path from a black baseline image to the input. It meticulously captures pixel-level contributions, highlighting fine details that influenced the score.</p>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 426 |
+
<h3 class="font-bold text-teal-400 mb-1">DeepLIFT</h3>
|
| 427 |
+
<p class="text-slate-400">Decomposes the differences in non-linear activations relative to a baseline reference. It is excellent at finding isolated "hotspots" or blending artifacts along face boundaries.</p>
|
| 428 |
+
</div>
|
| 429 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 430 |
+
<h3 class="font-bold text-teal-400 mb-1">Occlusion Sensitivity</h3>
|
| 431 |
+
<p class="text-slate-400">The most faithful, model-agnostic method. It slides a black patch over the image and measures the drop in prediction score. Bright regions directly mean: "if you cover this, the model changes its mind."</p>
|
| 432 |
+
</div>
|
| 433 |
</div>
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<div>
|
| 437 |
+
<h2 class="text-lg font-bold text-slate-100 mb-4 border-b border-slate-800 pb-2">3. Classic Forensics Tools</h2>
|
| 438 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs text-slate-300">
|
| 439 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 440 |
+
<h3 class="font-bold text-teal-400 mb-1">Error Level Analysis (ELA)</h3>
|
| 441 |
+
<p class="text-slate-400">Reveals differing compression levels in an image. Spliced or manipulated areas often stand out as noticeably brighter or darker than the surrounding native pixels when re-compressed.</p>
|
| 442 |
+
</div>
|
| 443 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 444 |
+
<h3 class="font-bold text-teal-400 mb-1">Spatial Edge Gradient</h3>
|
| 445 |
+
<p class="text-slate-400">Calculates high-frequency transitions to expose unnatural edge profiles. Face swaps and AI generations frequently leave subtle halos, double edges, or unnatural sharpness at blending seams.</p>
|
| 446 |
+
</div>
|
| 447 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 448 |
+
<h3 class="font-bold text-teal-400 mb-1">Bit Plane Extractor</h3>
|
| 449 |
+
<p class="text-slate-400">Strips away significant visual data to analyze the least significant bits (LSB). AI synthesis often leaves structured, blocky, or geometrically regular noise patterns in the lowest bit planes.</p>
|
| 450 |
+
</div>
|
| 451 |
+
<div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
| 452 |
+
<h3 class="font-bold text-teal-400 mb-1">MinMax & Wavelet Estimators</h3>
|
| 453 |
+
<p class="text-slate-400">Statistical and frequency domain techniques to find regions where pixel noise variance or intensity drastically deviates from the local neighborhood, typical of pasted imagery.</p>
|
| 454 |
+
</div>
|
| 455 |
</div>
|
| 456 |
</div>
|
| 457 |
+
|
| 458 |
+
<div>
|
| 459 |
+
<h2 class="text-lg font-bold text-slate-100 mb-2 border-b border-slate-800 pb-2">4. Model Architecture</h2>
|
| 460 |
+
<p class="text-sm text-slate-300 leading-relaxed">
|
| 461 |
+
Model: <code class="text-teal-400 font-mono bg-slate-900 px-1 rounded">buildborderless/CommunityForensics-DeepfakeDet-ViT</code><br/>
|
| 462 |
+
Architecture: Vision Transformer (ViT-Small). Inputs are resized to 384×384. It contains 12 transformer blocks (layers) and 6 attention heads per layer. The final layer passes through a binary sigmoid classifier. The model was trained on 2.7M samples across 4,803 distinct generative models and manipulation techniques.
|
| 463 |
+
</p>
|
| 464 |
+
</div>
|
| 465 |
+
|
| 466 |
</div>
|
| 467 |
</div>
|
| 468 |
|
|
|
|
| 473 |
<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()">
|
| 474 |
<div class="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
|
| 475 |
<h3 id="modal-title" class="text-sm font-bold text-slate-200">Attribution Overlay</h3>
|
| 476 |
+
<button onclick="closeModal()" class="text-slate-400 hover:text-slate-100 text-lg transition">
|
| 477 |
<i class="fa-solid fa-xmark"></i>
|
| 478 |
</button>
|
| 479 |
</div>
|
| 480 |
+
<img id="modal-img" src="" class="max-h-[75vh] mx-auto rounded-lg object-contain bg-slate-900/50" />
|
| 481 |
</div>
|
| 482 |
</div>
|
| 483 |
|
| 484 |
<!-- JavaScript App Logic -->
|
| 485 |
<script>
|
| 486 |
+
// State Variables
|
| 487 |
let currentImageB64 = null;
|
| 488 |
+
let currentImageHash = null;
|
| 489 |
+
let analysisComplete = false;
|
| 490 |
+
let currentAnalysisData = null;
|
| 491 |
+
let cachedForensics = null;
|
| 492 |
+
let collectedResults = {};
|
| 493 |
+
|
| 494 |
+
const METHOD_META = {
|
| 495 |
+
rollout: { name: 'Attention Rollout', insight: 'Bright areas = where the model concentrated attention across all 12 layers. Unexpectedly bright regions on backgrounds or hair often indicate GAN artifacts the model caught.' },
|
| 496 |
+
chefer: { name: 'Chefer Gradient-Rollout', insight: 'Class-discriminative — shows specifically what pushed the score toward FAKE (warm) vs REAL (cool). Based on Chefer et al. (CVPR 2021).' },
|
| 497 |
+
gradcam: { name: 'GradCAM', insight: 'Red/yellow = strongest activations driving the final verdict. The cleanest spatial answer to "where did the model decide?"' },
|
| 498 |
+
integrated: { name: 'Integrated Gradients', insight: 'Pixel-level contributions relative to a black baseline. Warm = pushed toward FAKE; Cool = pushed toward REAL.' },
|
| 499 |
+
deeplift: { name: 'DeepLIFT', insight: 'Decomposes non-linear activation differences. Watch for isolated face-boundary hotspots that indicate blending artifacts.' },
|
| 500 |
+
occlusion: { name: 'Occlusion Sensitivity', insight: 'Most faithful — shows what actually changes the score when hidden. Bright = "if you cover this, the model changes its mind."' },
|
| 501 |
+
};
|
| 502 |
+
|
| 503 |
+
const FORENSIC_META = {
|
| 504 |
+
ela: {
|
| 505 |
+
name: 'Error Level Analysis (ELA)',
|
| 506 |
+
desc: 'Re-saves the image at lower JPEG quality and computes the pixel-level difference. Regions that were edited or composited appear brighter because they were saved at a different compression level than the surrounding content.',
|
| 507 |
+
lookFor: 'Uniform bright glow across the face region = likely composite. Inconsistent brightness between face and background = editing boundary.'
|
| 508 |
+
},
|
| 509 |
+
gradient: {
|
| 510 |
+
name: 'Spatial Edge Gradient',
|
| 511 |
+
desc: 'Computes directional edge gradients (Sobel/Scharr filters) to highlight high-frequency transitions. Natural images have smooth, consistent edge profiles; deepfakes often leave unnatural edge artifacts at blending boundaries.',
|
| 512 |
+
lookFor: 'Halos or sharp outlines around the face perimeter. Double edges where a face was pasted onto a different background.'
|
| 513 |
+
},
|
| 514 |
+
bitplane: {
|
| 515 |
+
name: 'Bit Plane Extractor',
|
| 516 |
+
desc: 'Extracts individual bit planes from the image data, from the Least Significant Bit (LSB, bit 0) to the Most Significant Bit (MSB, bit 7). Lower bit planes reveal hidden noise patterns and subtle data anomalies invisible to the naked eye.',
|
| 517 |
+
lookFor: 'Structured geometric patterns in the LSB (bit 0-1) that differ between the face and background regions. AI-generated images often show unusually regular or blocky LSB textures.'
|
| 518 |
+
},
|
| 519 |
+
minmax: {
|
| 520 |
+
name: 'MinMax Local Deviation',
|
| 521 |
+
desc: 'Measures the deviation between each pixel and its local neighborhood minimum/maximum values. This highlights regions where pixel intensity is statistically unusual compared to surrounding context.',
|
| 522 |
+
lookFor: 'Bright spots indicating patches that are "too smooth" or "too noisy" relative to neighbors — a telltale sign of AI-inserted content or localized editing.'
|
| 523 |
+
},
|
| 524 |
+
wavelet: {
|
| 525 |
+
name: 'Wavelet Noise Estimation',
|
| 526 |
+
desc: 'Applies wavelet decomposition to estimate the high-frequency noise variance across different regions of the image. Real camera photos have consistent sensor noise fingerprints; manipulated regions often have a different noise signature.',
|
| 527 |
+
lookFor: 'Significant noise variance differences between the face region and the rest of the image. Swapped faces typically carry noise from a different camera/source.'
|
| 528 |
+
},
|
| 529 |
+
};
|
| 530 |
+
|
| 531 |
+
// Debounce Utility
|
| 532 |
+
function debounce(fn, ms = 300) {
|
| 533 |
+
let timer;
|
| 534 |
+
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
|
| 535 |
+
}
|
| 536 |
|
| 537 |
+
const debouncedRunExplorer = debounce(runExplorerMethod, 300);
|
| 538 |
+
const debouncedRunHeads = debounce(runPerHeadInspector, 300);
|
| 539 |
+
const debouncedRunForensics = debounce(runForensicAnalysis, 300);
|
| 540 |
+
|
| 541 |
+
// Initialization & Event Listeners
|
| 542 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 543 |
+
const dropZone = document.getElementById('drop-zone');
|
| 544 |
+
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-teal-500'); });
|
| 545 |
+
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-teal-500'); });
|
| 546 |
+
dropZone.addEventListener('drop', (e) => {
|
| 547 |
+
e.preventDefault();
|
| 548 |
+
dropZone.classList.remove('border-teal-500');
|
| 549 |
+
const file = e.dataTransfer.files[0];
|
| 550 |
+
if (file && file.type.startsWith('image/')) {
|
| 551 |
+
const reader = new FileReader();
|
| 552 |
+
reader.onload = (ev) => loadNewImage(ev.target.result);
|
| 553 |
+
reader.readAsDataURL(file);
|
| 554 |
+
}
|
| 555 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
+
document.addEventListener('paste', (e) => {
|
| 558 |
+
const items = e.clipboardData?.items;
|
| 559 |
+
if (!items) return;
|
| 560 |
+
for (const item of items) {
|
| 561 |
+
if (item.type.startsWith('image/')) {
|
| 562 |
+
const file = item.getAsFile();
|
| 563 |
+
const reader = new FileReader();
|
| 564 |
+
reader.onload = (ev) => loadNewImage(ev.target.result);
|
| 565 |
+
reader.readAsDataURL(file);
|
| 566 |
+
break;
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
});
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
function loadNewImage(b64) {
|
| 573 |
+
currentImageB64 = b64;
|
| 574 |
+
document.getElementById('image-preview').src = currentImageB64;
|
| 575 |
+
document.getElementById('upload-prompt').classList.add('hidden');
|
| 576 |
+
document.getElementById('preview-container').classList.remove('hidden');
|
| 577 |
+
document.getElementById('btn-analyze').disabled = false;
|
| 578 |
+
analysisComplete = false;
|
| 579 |
}
|
| 580 |
|
| 581 |
function handleFileSelect(evt) {
|
| 582 |
const file = evt.target.files[0];
|
| 583 |
if (!file) return;
|
| 584 |
const reader = new FileReader();
|
| 585 |
+
reader.onload = (e) => loadNewImage(e.target.result);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
reader.readAsDataURL(file);
|
| 587 |
}
|
| 588 |
|
| 589 |
function clearImage() {
|
| 590 |
currentImageB64 = null;
|
| 591 |
+
analysisComplete = false;
|
| 592 |
document.getElementById('file-input').value = '';
|
| 593 |
document.getElementById('upload-prompt').classList.remove('hidden');
|
| 594 |
document.getElementById('preview-container').classList.add('hidden');
|
| 595 |
document.getElementById('btn-analyze').disabled = true;
|
| 596 |
document.getElementById('btn-report').disabled = true;
|
| 597 |
+
document.getElementById('submit-panel').classList.add('hidden');
|
| 598 |
+
document.getElementById('confidence-box').classList.add('hidden');
|
| 599 |
+
const badge = document.getElementById('prediction-badge');
|
| 600 |
+
badge.className = 'mt-1 text-sm font-bold text-slate-500 flex items-center gap-2';
|
| 601 |
+
badge.innerHTML = '<span class="w-2.5 h-2.5 rounded-full bg-slate-600"></span> Awaiting Input Image';
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
function switchTab(tabId) {
|
| 605 |
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
| 606 |
+
btn.classList.remove('border-teal-400', 'text-teal-400');
|
| 607 |
+
btn.classList.add('border-transparent', 'text-slate-400');
|
| 608 |
+
});
|
| 609 |
+
document.querySelectorAll('.tab-section').forEach(sec => sec.classList.add('hidden'));
|
| 610 |
+
|
| 611 |
+
document.getElementById('tab-' + tabId).classList.add('border-teal-400', 'text-teal-400');
|
| 612 |
+
document.getElementById('tab-' + tabId).classList.remove('border-transparent', 'text-slate-400');
|
| 613 |
+
document.getElementById('section-' + tabId).classList.remove('hidden');
|
| 614 |
+
|
| 615 |
+
if (analysisComplete) {
|
| 616 |
+
if (tabId === 'explorer') debouncedRunExplorer();
|
| 617 |
+
if (tabId === 'heads') debouncedRunHeads();
|
| 618 |
+
if (tabId === 'forensics') {
|
| 619 |
+
updateForensicDesc();
|
| 620 |
+
runForensicAnalysis(false);
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
}
|
| 624 |
|
| 625 |
async function apiCall(endpoint, payload) {
|
|
|
|
| 636 |
|
| 637 |
document.getElementById('loading-spinner').classList.remove('hidden');
|
| 638 |
document.getElementById('btn-analyze').disabled = true;
|
| 639 |
+
document.getElementById('btn-report').disabled = true;
|
| 640 |
+
|
| 641 |
+
analysisComplete = false;
|
| 642 |
+
currentImageHash = null;
|
| 643 |
+
cachedForensics = null;
|
| 644 |
+
collectedResults = {};
|
| 645 |
+
currentAnalysisData = null;
|
| 646 |
+
|
| 647 |
+
// a) Clear results, show 6 skeleton cards
|
| 648 |
+
const grid = document.getElementById('results-grid');
|
| 649 |
+
grid.innerHTML = '';
|
| 650 |
+
const methods = ['rollout', 'chefer', 'gradcam', 'integrated', 'deeplift', 'occlusion'];
|
| 651 |
+
|
| 652 |
+
methods.forEach(m => {
|
| 653 |
+
const card = document.createElement('div');
|
| 654 |
+
card.id = `card-${m}`;
|
| 655 |
+
card.className = 'glass-panel rounded-xl p-3 flex flex-col h-full';
|
| 656 |
+
card.innerHTML = `
|
| 657 |
+
<div class="w-full h-44 rounded-lg skeleton-shimmer mb-3"></div>
|
| 658 |
+
<div class="h-4 w-3/4 rounded skeleton-shimmer mb-2"></div>
|
| 659 |
+
<div class="h-3 w-1/4 rounded skeleton-shimmer mb-4"></div>
|
| 660 |
+
<div class="h-8 w-full rounded skeleton-shimmer mt-auto"></div>
|
| 661 |
+
`;
|
| 662 |
+
grid.appendChild(card);
|
| 663 |
+
});
|
| 664 |
|
| 665 |
try {
|
| 666 |
+
// b) Call /api/predict
|
| 667 |
+
const predictRes = await apiCall('predict', { image_b64: currentImageB64 });
|
| 668 |
+
if (predictRes.status === 'success') {
|
| 669 |
+
const pred = predictRes.prediction;
|
| 670 |
+
currentImageHash = predictRes.image_hash;
|
| 671 |
+
currentAnalysisData = pred;
|
| 672 |
const isFake = pred.prediction === 'FAKE';
|
| 673 |
const badge = document.getElementById('prediction-badge');
|
| 674 |
badge.className = `mt-1 text-sm font-bold flex items-center gap-2 ${isFake ? 'text-rose-400' : 'text-emerald-400'}`;
|
| 675 |
badge.innerHTML = `<span class="w-2.5 h-2.5 rounded-full ${isFake ? 'bg-rose-500' : 'bg-emerald-500'}"></span> VERDICT: ${pred.prediction}`;
|
|
|
|
| 676 |
document.getElementById('confidence-val').innerText = pred.confidence_pct.toFixed(2) + '%';
|
| 677 |
document.getElementById('confidence-box').classList.remove('hidden');
|
| 678 |
+
}
|
| 679 |
|
| 680 |
+
// c & e) Fire parallel requests
|
| 681 |
+
const analyzePromise = apiCall('analyze', { image_b64: currentImageB64, alpha: 0.6, cmap: 'jet' }).then(res => {
|
| 682 |
+
if (res.status === 'success') {
|
| 683 |
+
cachedForensics = res.forensics;
|
| 684 |
+
if(!currentImageHash) currentImageHash = res.image_hash;
|
| 685 |
+
}
|
| 686 |
+
});
|
| 687 |
+
|
| 688 |
+
const methodPromises = methods.map(m => {
|
| 689 |
+
return apiCall('method_detail', {
|
| 690 |
+
image_b64: currentImageB64, method_name: m, steps: 20, patch_size: 32, target_layer_idx: 11, alpha: 0.6, cmap: 'jet'
|
| 691 |
+
}).then(res => {
|
| 692 |
+
if (res.status === 'success') {
|
| 693 |
+
// d) Replace skeleton card
|
| 694 |
+
collectedResults[m] = res.result;
|
| 695 |
+
renderResultCard(m, res.result);
|
| 696 |
+
}
|
| 697 |
});
|
| 698 |
+
});
|
| 699 |
|
| 700 |
+
await Promise.all([...methodPromises, analyzePromise]);
|
| 701 |
+
|
| 702 |
+
// f) When all complete
|
| 703 |
+
analysisComplete = true;
|
| 704 |
+
document.getElementById('submit-panel').classList.remove('hidden');
|
| 705 |
+
document.getElementById('btn-report').disabled = false;
|
| 706 |
+
} catch(err) {
|
| 707 |
console.error(err);
|
| 708 |
alert('API Request Failed: ' + err);
|
| 709 |
} finally {
|
|
|
|
| 712 |
}
|
| 713 |
}
|
| 714 |
|
| 715 |
+
function toggleHeatmap(m, e) {
|
| 716 |
+
e.stopPropagation();
|
| 717 |
+
const state = collectedResults[m];
|
| 718 |
+
if(!state) return;
|
| 719 |
+
state.showingRaw = !state.showingRaw;
|
| 720 |
+
const imgEl = document.getElementById(`img-${m}`);
|
| 721 |
+
imgEl.src = state.showingRaw ? state.raw_heatmap_b64 : state.overlay_b64;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
function renderResultCard(m, result) {
|
| 725 |
+
const meta = METHOD_META[m];
|
| 726 |
+
result.showingRaw = false;
|
| 727 |
+
const card = document.getElementById(`card-${m}`);
|
| 728 |
+
if(!card) return;
|
| 729 |
+
card.className = 'glass-panel rounded-xl p-3 hover:border-teal-500/50 transition cursor-pointer group flex flex-col h-full';
|
| 730 |
+
card.onclick = () => openModal(meta.name, result.showingRaw ? result.raw_heatmap_b64 : result.overlay_b64);
|
| 731 |
+
|
| 732 |
+
card.innerHTML = `
|
| 733 |
+
<div class="relative overflow-hidden rounded-lg mb-2 h-44 bg-slate-900/50 flex-shrink-0">
|
| 734 |
+
<img id="img-${m}" src="${result.overlay_b64}" class="w-full h-full object-contain group-hover:scale-105 transition duration-300" />
|
| 735 |
+
<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">${Math.round(result.compute_time_ms)} ms</span>
|
| 736 |
+
<button onclick="toggleHeatmap('${m}', event)" class="absolute top-2 right-2 bg-slate-900/80 text-slate-300 hover:text-white w-7 h-7 rounded-full text-xs flex items-center justify-center border border-slate-600 shadow transition z-10" title="Toggle Raw Heatmap">
|
| 737 |
+
<i class="fa-solid fa-arrows-rotate"></i>
|
| 738 |
+
</button>
|
| 739 |
+
</div>
|
| 740 |
+
<h4 class="text-xs font-bold text-slate-200 mb-1">${meta.name}</h4>
|
| 741 |
+
<p class="text-[10px] text-slate-400 line-clamp-3 leading-relaxed mt-auto">${meta.insight}</p>
|
| 742 |
+
`;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
async function generateReport() {
|
| 746 |
+
if (!currentImageB64 || Object.keys(collectedResults).length < 6) return;
|
| 747 |
+
|
| 748 |
+
const btn = document.getElementById('btn-report');
|
| 749 |
+
btn.disabled = true;
|
| 750 |
+
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Generating...';
|
| 751 |
+
|
| 752 |
+
try {
|
| 753 |
+
const reportDiv = document.createElement('div');
|
| 754 |
+
reportDiv.id = 'report-card';
|
| 755 |
+
reportDiv.style.position = 'absolute';
|
| 756 |
+
reportDiv.style.left = '-9999px';
|
| 757 |
+
reportDiv.style.top = '-9999px';
|
| 758 |
+
reportDiv.style.width = '800px';
|
| 759 |
+
reportDiv.style.backgroundColor = '#0f172a';
|
| 760 |
+
reportDiv.style.color = '#f1f5f9';
|
| 761 |
+
reportDiv.style.padding = '32px';
|
| 762 |
+
reportDiv.style.fontFamily = 'Inter, sans-serif';
|
| 763 |
+
|
| 764 |
+
let verdictColor = currentAnalysisData.prediction === 'FAKE' ? '#fb7185' : '#34d399';
|
| 765 |
+
|
| 766 |
+
let html = `
|
| 767 |
+
<div style="border-bottom: 1px solid #334155; padding-bottom: 16px; margin-bottom: 24px;">
|
| 768 |
+
<h1 style="font-size: 24px; font-weight: bold; margin: 0 0 8px 0; display: flex; align-items: center; gap: 8px;">
|
| 769 |
+
<span style="color: #2dd4bf;">●</span> ViT Deepfake Forensic Analysis Report
|
| 770 |
+
</h1>
|
| 771 |
+
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #94a3b8;">
|
| 772 |
+
<span>Model: buildborderless/CommunityForensics-DeepfakeDet-ViT</span>
|
| 773 |
+
<span>${new Date().toLocaleString()}</span>
|
| 774 |
+
</div>
|
| 775 |
+
</div>
|
| 776 |
+
|
| 777 |
+
<div style="background: rgba(30, 41, 59, 0.5); border: 1px solid #334155; border-radius: 12px; padding: 20px; margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center;">
|
| 778 |
+
<div>
|
| 779 |
+
<span style="font-size: 12px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">Verdict</span>
|
| 780 |
+
<div style="font-size: 20px; font-weight: bold; color: ${verdictColor}; margin-top: 4px;">
|
| 781 |
+
${currentAnalysisData.prediction}
|
| 782 |
+
</div>
|
| 783 |
+
</div>
|
| 784 |
+
<div style="text-align: right;">
|
| 785 |
+
<span style="font-size: 12px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">Confidence</span>
|
| 786 |
+
<div style="font-size: 20px; font-weight: bold; color: #2dd4bf; margin-top: 4px; font-family: monospace;">
|
| 787 |
+
${currentAnalysisData.confidence_pct.toFixed(2)}%
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
</div>
|
| 791 |
+
|
| 792 |
+
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px;">
|
| 793 |
+
`;
|
| 794 |
+
|
| 795 |
+
const methods = ['rollout', 'chefer', 'gradcam', 'integrated', 'deeplift', 'occlusion'];
|
| 796 |
+
methods.forEach(m => {
|
| 797 |
+
const res = collectedResults[m];
|
| 798 |
+
const meta = METHOD_META[m];
|
| 799 |
+
html += `
|
| 800 |
+
<div style="background: #1e293b; border-radius: 8px; padding: 12px;">
|
| 801 |
+
<img src="${res.overlay_b64}" style="width: 100%; aspect-ratio: 1/1; object-fit: contain; background: #0f172a; border-radius: 6px; margin-bottom: 12px;" />
|
| 802 |
+
<div style="font-size: 14px; font-weight: 600; color: #f1f5f9;">${meta.name}</div>
|
| 803 |
+
<div style="font-size: 12px; color: #94a3b8; font-family: monospace;">${Math.round(res.compute_time_ms)} ms</div>
|
| 804 |
+
</div>
|
| 805 |
+
`;
|
| 806 |
+
});
|
| 807 |
+
|
| 808 |
+
html += `
|
| 809 |
+
</div>
|
| 810 |
+
<div style="border-top: 1px solid #334155; padding-top: 16px; font-size: 12px; color: #64748b; text-align: center;">
|
| 811 |
+
Generated by LPX55/DeepfakeDetection-Explainability
|
| 812 |
+
</div>
|
| 813 |
+
`;
|
| 814 |
+
|
| 815 |
+
reportDiv.innerHTML = html;
|
| 816 |
+
document.body.appendChild(reportDiv);
|
| 817 |
+
|
| 818 |
+
const canvas = await html2canvas(reportDiv, { scale: 2, backgroundColor: '#0f172a' });
|
| 819 |
+
const imgData = canvas.toDataURL('image/png');
|
| 820 |
+
|
| 821 |
+
document.body.removeChild(reportDiv);
|
| 822 |
+
|
| 823 |
+
const a = document.createElement('a');
|
| 824 |
+
a.href = imgData;
|
| 825 |
+
a.download = `forensic_report_${Date.now()}.png`;
|
| 826 |
+
a.click();
|
| 827 |
+
|
| 828 |
+
} catch (err) {
|
| 829 |
+
console.error(err);
|
| 830 |
+
alert('Report generation failed: ' + err);
|
| 831 |
+
} finally {
|
| 832 |
+
btn.disabled = false;
|
| 833 |
+
btn.innerHTML = '<i class="fa-solid fa-file-arrow-down"></i> Report PNG';
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
async function runExplorerMethod() {
|
| 838 |
if (!currentImageB64) return;
|
| 839 |
const method = document.getElementById('exp-method').value;
|
|
|
|
| 869 |
async function runPerHeadInspector() {
|
| 870 |
if (!currentImageB64) return;
|
| 871 |
const displayLayer = parseInt(document.getElementById('head-layer-input').value);
|
| 872 |
+
const layer_idx = displayLayer - 1;
|
|
|
|
| 873 |
|
| 874 |
const res = await apiCall('heads', {
|
| 875 |
image_b64: currentImageB64,
|
|
|
|
| 893 |
}
|
| 894 |
}
|
| 895 |
|
| 896 |
+
function updateForensicDesc() {
|
| 897 |
+
const tool = document.getElementById('for-tool').value;
|
| 898 |
+
const meta = FORENSIC_META[tool];
|
| 899 |
+
if(meta) {
|
| 900 |
+
document.getElementById('for-desc-title').innerText = meta.name;
|
| 901 |
+
document.getElementById('for-desc-text').innerText = meta.desc;
|
| 902 |
+
document.getElementById('for-desc-look').innerText = meta.lookFor;
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
async function runForensicAnalysis(force = true) {
|
| 907 |
if (!currentImageB64) return;
|
| 908 |
const tool = document.getElementById('for-tool').value;
|
| 909 |
const quality = parseInt(document.getElementById('for-quality').value);
|
| 910 |
const bit = parseInt(document.getElementById('for-bit').value);
|
| 911 |
|
| 912 |
+
updateForensicDesc();
|
| 913 |
+
|
| 914 |
const ctrlELA = document.getElementById('ctrl-ela');
|
| 915 |
if (ctrlELA) ctrlELA.classList.toggle('hidden', tool !== 'ela');
|
| 916 |
const ctrlBit = document.getElementById('ctrl-bitplane');
|
| 917 |
if (ctrlBit) ctrlBit.classList.toggle('hidden', tool !== 'bitplane');
|
| 918 |
|
| 919 |
+
if (!force && cachedForensics && cachedForensics[tool]) {
|
| 920 |
+
const cached = cachedForensics[tool];
|
| 921 |
+
displayForensicResult(cached.result_b64, cached.compute_time_ms, tool);
|
| 922 |
+
return;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
const res = await apiCall('forensic', {
|
| 926 |
image_b64: currentImageB64,
|
| 927 |
tool_name: tool,
|
|
|
|
| 930 |
});
|
| 931 |
|
| 932 |
if (res.status === 'success' && res.result_b64) {
|
| 933 |
+
displayForensicResult(res.result_b64, res.compute_time_ms, res.tool);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
}
|
| 935 |
}
|
| 936 |
|
| 937 |
+
function displayForensicResult(b64, time, toolName) {
|
| 938 |
+
const img = document.getElementById('for-img-output');
|
| 939 |
+
img.src = b64;
|
| 940 |
+
img.classList.remove('hidden');
|
| 941 |
+
document.getElementById('for-placeholder').classList.add('hidden');
|
| 942 |
+
document.getElementById('for-info').innerText = `${toolName} analysis complete (${Math.round(time)} ms).`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 943 |
}
|
| 944 |
|
| 945 |
async function submitDatasetRun() {
|
| 946 |
+
if (!currentImageB64 || !currentImageHash) return;
|
| 947 |
const gtEl = document.querySelector('input[name="ground_truth"]:checked');
|
| 948 |
const gt = gtEl ? gtEl.value : 'unknown';
|
| 949 |
const optIn = document.getElementById('opt-in-image').checked;
|
| 950 |
|
| 951 |
const res = await apiCall('submit_run', {
|
| 952 |
image_b64: currentImageB64,
|
| 953 |
+
image_hash: currentImageHash,
|
| 954 |
ground_truth: gt,
|
| 955 |
opt_in_image: optIn
|
| 956 |
});
|
|
|
|
| 974 |
</script>
|
| 975 |
</body>
|
| 976 |
</html>
|
|
|
|
@@ -28,10 +28,11 @@ def create_composite_xai_grid(image: Image.Image, results: list) -> Image.Image:
|
|
| 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 =
|
| 35 |
overlay_pil = Image.open(BytesIO(base64.b64decode(b64_data))).resize((w, h))
|
| 36 |
grid_img.paste(overlay_pil, (c * w, r * h))
|
| 37 |
|
|
@@ -93,6 +94,7 @@ 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:
|
|
@@ -109,8 +111,11 @@ def log_run_dataset(
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
record = {
|
| 116 |
"timestamp": now_str,
|
|
@@ -125,6 +130,9 @@ def log_run_dataset(
|
|
| 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
|
|
|
|
| 28 |
for idx, res in enumerate(results, start=1):
|
| 29 |
r = idx // cols
|
| 30 |
c = idx % cols
|
| 31 |
+
overlay_b64 = res.overlay_b64 if hasattr(res, "overlay_b64") else res.get("overlay_b64")
|
| 32 |
+
if overlay_b64:
|
| 33 |
import base64
|
| 34 |
from io import BytesIO
|
| 35 |
+
b64_data = overlay_b64.split(",")[-1]
|
| 36 |
overlay_pil = Image.open(BytesIO(base64.b64decode(b64_data))).resize((w, h))
|
| 37 |
grid_img.paste(overlay_pil, (c * w, r * h))
|
| 38 |
|
|
|
|
| 94 |
image: Image.Image,
|
| 95 |
pred_data: dict,
|
| 96 |
results: list,
|
| 97 |
+
forensics_results: dict = None,
|
| 98 |
ground_truth: str = "unknown",
|
| 99 |
opt_in_image: bool = True,
|
| 100 |
) -> str:
|
|
|
|
| 111 |
pred_clean = pred_data.get("prediction", "").lower()
|
| 112 |
is_correct = (pred_clean == ground_truth)
|
| 113 |
|
| 114 |
+
methods_run = [r.name if hasattr(r, 'name') else r.get('name') for r in results]
|
| 115 |
+
per_method_time = {
|
| 116 |
+
(r.name if hasattr(r, 'name') else r.get('name')): (r.compute_time_ms if hasattr(r, 'compute_time_ms') else r.get('compute_time_ms'))
|
| 117 |
+
for r in results
|
| 118 |
+
}
|
| 119 |
|
| 120 |
record = {
|
| 121 |
"timestamp": now_str,
|
|
|
|
| 130 |
"per_method_time_ms": per_method_time,
|
| 131 |
"total_time_ms": sum(per_method_time.values()),
|
| 132 |
}
|
| 133 |
+
if forensics_results is not None:
|
| 134 |
+
record["forensics_results"] = forensics_results
|
| 135 |
+
|
| 136 |
|
| 137 |
composite_grid = create_composite_xai_grid(image, results)
|
| 138 |
logged_image = image if opt_in_image else None
|
|
@@ -1,96 +0,0 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -3,9 +3,7 @@ transformers>=4.48.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
|
|
|
|
| 3 |
captum>=0.7.0
|
| 4 |
grad-cam>=1.5.0
|
| 5 |
Pillow>=10.0.0
|
|
|
|
| 6 |
opencv-python-headless>=4.8.0
|
|
|
|
| 7 |
numpy>=1.24.0
|
| 8 |
PyWavelets>=1.4.0
|
| 9 |
huggingface_hub>=0.25.0
|
|
@@ -3,13 +3,12 @@ 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
|
|
@@ -242,7 +241,7 @@ def run_single_method(
|
|
| 242 |
|
| 243 |
# Resize heatmap to 384x384 if needed
|
| 244 |
if heatmap.shape != (384, 384):
|
| 245 |
-
heatmap_resized =
|
| 246 |
else:
|
| 247 |
heatmap_resized = heatmap
|
| 248 |
|
|
@@ -262,27 +261,35 @@ def run_single_method(
|
|
| 262 |
description=desc,
|
| 263 |
heatmap=heatmap,
|
| 264 |
heatmap_norm=norm_h,
|
| 265 |
-
overlay_b64=
|
| 266 |
-
raw_heatmap_b64=
|
| 267 |
metadata=metadata,
|
| 268 |
compute_time_ms=round(elapsed_ms, 1),
|
| 269 |
)
|
| 270 |
|
| 271 |
|
|
|
|
|
|
|
| 272 |
def run_all_methods(
|
| 273 |
wrapper,
|
| 274 |
image: Image.Image,
|
| 275 |
alpha: float = 0.6,
|
| 276 |
cmap: str = "jet",
|
| 277 |
) -> list[AttributionResult]:
|
| 278 |
-
"""Run all 6 primary spatial attribution methods
|
| 279 |
methods = ["rollout", "chefer", "gradcam", "integrated", "occlusion", "deeplift"]
|
| 280 |
-
results =
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
import torch
|
| 5 |
from PIL import Image
|
|
|
|
| 6 |
from captum.attr import IntegratedGradients, Occlusion, DeepLift
|
| 7 |
from pytorch_grad_cam import GradCAM
|
| 8 |
from pytorch_grad_cam.utils.model_targets import BinaryClassifierOutputTarget
|
| 9 |
|
| 10 |
from .chefer import chefer_gradient_rollout
|
| 11 |
+
from .viz import create_overlay, render_heatmap_raw, to_b64_png, normalize_heatmap, fast_resize, to_b64_jpeg
|
| 12 |
|
| 13 |
|
| 14 |
@dataclass
|
|
|
|
| 241 |
|
| 242 |
# Resize heatmap to 384x384 if needed
|
| 243 |
if heatmap.shape != (384, 384):
|
| 244 |
+
heatmap_resized = fast_resize(heatmap, (384, 384))
|
| 245 |
else:
|
| 246 |
heatmap_resized = heatmap
|
| 247 |
|
|
|
|
| 261 |
description=desc,
|
| 262 |
heatmap=heatmap,
|
| 263 |
heatmap_norm=norm_h,
|
| 264 |
+
overlay_b64=to_b64_jpeg(overlay),
|
| 265 |
+
raw_heatmap_b64=to_b64_jpeg(raw_render),
|
| 266 |
metadata=metadata,
|
| 267 |
compute_time_ms=round(elapsed_ms, 1),
|
| 268 |
)
|
| 269 |
|
| 270 |
|
| 271 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 272 |
+
|
| 273 |
def run_all_methods(
|
| 274 |
wrapper,
|
| 275 |
image: Image.Image,
|
| 276 |
alpha: float = 0.6,
|
| 277 |
cmap: str = "jet",
|
| 278 |
) -> list[AttributionResult]:
|
| 279 |
+
"""Run all 6 primary spatial attribution methods in parallel with per-method exception handling."""
|
| 280 |
methods = ["rollout", "chefer", "gradcam", "integrated", "occlusion", "deeplift"]
|
| 281 |
+
results = {}
|
| 282 |
+
with ThreadPoolExecutor(max_workers=6) as executor:
|
| 283 |
+
futures = {
|
| 284 |
+
executor.submit(run_single_method, wrapper, image, m, alpha=alpha, cmap=cmap): m
|
| 285 |
+
for m in methods
|
| 286 |
+
}
|
| 287 |
+
for future in as_completed(futures):
|
| 288 |
+
m = futures[future]
|
| 289 |
+
try:
|
| 290 |
+
results[m] = future.result()
|
| 291 |
+
except Exception as err:
|
| 292 |
+
print(f"[xai_engine] Error running method {m}: {err}")
|
| 293 |
+
# Return in original order
|
| 294 |
+
return [results[m] for m in methods if m in results]
|
| 295 |
|
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import time
|
| 2 |
import threading
|
| 3 |
import torch
|
|
@@ -66,6 +67,10 @@ def get_model(device: str = None) -> ViTWrapper:
|
|
| 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)
|
|
|
|
| 1 |
+
import os
|
| 2 |
import time
|
| 3 |
import threading
|
| 4 |
import torch
|
|
|
|
| 67 |
with _model_lock:
|
| 68 |
now = time.time()
|
| 69 |
if _model_instance is None or (now - _model_loaded_at) > MODEL_TTL_SECONDS:
|
| 70 |
+
# Maximize CPU utilization
|
| 71 |
+
torch.set_num_threads(os.cpu_count() or 4)
|
| 72 |
+
torch.set_num_interop_threads(max(1, (os.cpu_count() or 4) // 2))
|
| 73 |
+
|
| 74 |
if device is None:
|
| 75 |
device = "cpu" # Default to CPU for CPU-based HF Space & low VRAM stability
|
| 76 |
wrapper = ViTWrapper(MODEL_REPO)
|
|
@@ -1,56 +1,81 @@
|
|
| 1 |
import base64
|
| 2 |
from io import BytesIO
|
| 3 |
import numpy as np
|
|
|
|
| 4 |
from PIL import Image
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 23 |
-
|
| 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 |
-
|
| 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 =
|
| 43 |
else:
|
| 44 |
-
heatmap_resized = heatmap
|
| 45 |
|
| 46 |
norm_h = normalize_heatmap(heatmap_resized)
|
| 47 |
-
|
| 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
|
|
|
|
| 54 |
buffer = BytesIO()
|
| 55 |
image.save(buffer, format="PNG")
|
| 56 |
b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
|
|
| 1 |
import base64
|
| 2 |
from io import BytesIO
|
| 3 |
import numpy as np
|
| 4 |
+
import cv2
|
| 5 |
from PIL import Image
|
| 6 |
+
|
| 7 |
+
# Pre-built colormap lookup
|
| 8 |
+
_CV2_CMAPS = {
|
| 9 |
+
"jet": cv2.COLORMAP_JET,
|
| 10 |
+
"inferno": cv2.COLORMAP_INFERNO,
|
| 11 |
+
"viridis": cv2.COLORMAP_VIRIDIS,
|
| 12 |
+
"hot": cv2.COLORMAP_HOT,
|
| 13 |
+
}
|
| 14 |
|
| 15 |
|
| 16 |
def normalize_heatmap(heatmap: np.ndarray) -> np.ndarray:
|
| 17 |
"""Min-max normalize a heatmap array to [0, 1]."""
|
| 18 |
h_min, h_max = heatmap.min(), heatmap.max()
|
| 19 |
if h_max - h_min < 1e-8:
|
| 20 |
+
return np.zeros_like(heatmap, dtype=np.float32)
|
| 21 |
+
return ((heatmap - h_min) / (h_max - h_min + 1e-8)).astype(np.float32)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def fast_resize(arr: np.ndarray, size: tuple[int, int] = (384, 384)) -> np.ndarray:
|
| 25 |
+
"""Fast bilinear resize via cv2 — ~5x faster than skimage."""
|
| 26 |
+
return cv2.resize(arr.astype(np.float32), size, interpolation=cv2.INTER_LINEAR)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def apply_cmap(norm_h: np.ndarray, cmap: str = "jet") -> np.ndarray:
|
| 30 |
+
"""Apply colormap to normalized [0,1] heatmap. Returns (H, W, 3) uint8 RGB.
|
| 31 |
+
10-30x faster than matplotlib get_cmap."""
|
| 32 |
+
gray = (norm_h * 255).astype(np.uint8)
|
| 33 |
+
colored_bgr = cv2.applyColorMap(gray, _CV2_CMAPS.get(cmap, cv2.COLORMAP_JET))
|
| 34 |
+
return cv2.cvtColor(colored_bgr, cv2.COLOR_BGR2RGB)
|
| 35 |
|
| 36 |
|
| 37 |
def create_overlay(image: Image.Image, heatmap: np.ndarray, alpha: float = 0.6, cmap: str = "jet") -> Image.Image:
|
| 38 |
"""Blend a heatmap overlay onto a PIL Image."""
|
| 39 |
image_rgb = image.convert("RGB").resize((384, 384))
|
| 40 |
+
img_np = np.array(image_rgb, dtype=np.float32) / 255.0
|
| 41 |
|
| 42 |
+
if heatmap.shape[:2] != (384, 384):
|
| 43 |
+
heatmap_resized = fast_resize(heatmap, (384, 384))
|
|
|
|
| 44 |
else:
|
| 45 |
+
heatmap_resized = heatmap.astype(np.float32)
|
| 46 |
|
| 47 |
norm_h = normalize_heatmap(heatmap_resized)
|
| 48 |
+
colored_heatmap = apply_cmap(norm_h, cmap).astype(np.float32) / 255.0
|
| 49 |
+
|
|
|
|
|
|
|
| 50 |
overlay = (1 - alpha) * img_np + alpha * colored_heatmap
|
| 51 |
overlay = np.clip(overlay * 255, 0, 255).astype(np.uint8)
|
|
|
|
| 52 |
return Image.fromarray(overlay)
|
| 53 |
|
| 54 |
|
| 55 |
def render_heatmap_raw(heatmap: np.ndarray, cmap: str = "jet") -> Image.Image:
|
| 56 |
"""Render raw normalized heatmap as RGB PIL Image using colormap."""
|
| 57 |
+
if heatmap.shape[:2] != (384, 384):
|
| 58 |
+
heatmap_resized = fast_resize(heatmap, (384, 384))
|
| 59 |
else:
|
| 60 |
+
heatmap_resized = heatmap.astype(np.float32)
|
| 61 |
|
| 62 |
norm_h = normalize_heatmap(heatmap_resized)
|
| 63 |
+
colored = apply_cmap(norm_h, cmap)
|
|
|
|
| 64 |
return Image.fromarray(colored)
|
| 65 |
|
| 66 |
|
| 67 |
+
def to_b64_jpeg(image: Image.Image, quality: int = 85) -> str:
|
| 68 |
+
"""Convert PIL Image to base64 JPEG data URL — 3-5x faster than PNG.
|
| 69 |
+
Used for browser display thumbnails only."""
|
| 70 |
+
buffer = BytesIO()
|
| 71 |
+
image.save(buffer, format="JPEG", quality=quality, optimize=True)
|
| 72 |
+
b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 73 |
+
return f"data:image/jpeg;base64,{b64_str}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
def to_b64_png(image: Image.Image) -> str:
|
| 77 |
+
"""Convert PIL Image to base64 PNG data URL — lossless.
|
| 78 |
+
Used for dataset logging and report downloads."""
|
| 79 |
buffer = BytesIO()
|
| 80 |
image.save(buffer, format="PNG")
|
| 81 |
b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
|