ethix commited on
Commit
05f720a
·
1 Parent(s): 9eea112

feat: performance overhaul, client-side report, result cache, streaming cards, UX polish

Browse files

BREAKING: 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

Files changed (9) hide show
  1. .gitignore +1 -0
  2. app.py +136 -27
  3. index.html +436 -116
  4. logger.py +12 -4
  5. report.py +0 -96
  6. requirements.txt +0 -2
  7. xai_engine/methods.py +21 -14
  8. xai_engine/model.py +5 -0
  9. xai_engine/viz.py +45 -20
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ __pycache__/
app.py CHANGED
@@ -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
- """Helper to convert base64 image data URL to PIL Image."""
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
- # Pydantic Schemas for REST API Endpoints
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # REST API Routes mounted on FastAPI app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- results = run_all_methods(model, image, alpha=req.alpha, cmap=req.cmap)
 
 
 
 
 
 
 
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 results
102
  ]
103
 
104
- return {
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
- model = get_model()
189
- pred_data = model.predict(image)
190
- results = run_all_methods(model, image)
191
- run_id = log_run_dataset(image, pred_data, results, ground_truth=req.ground_truth, opt_in_image=req.opt_in_image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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",
index.html CHANGED
@@ -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> ⚡ Quick Analysis
77
  </button>
78
  <button onclick="switchTab('explorer')" id="tab-explorer" class="tab-btn py-3 px-4 text-sm font-medium border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-2 transition">
79
  <i class="fa-solid fa-sliders text-xs"></i> 🔍 Method Explorer
@@ -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 6 ViT Attribution Maps on CPU...</p>
153
  </div>
154
 
155
- <!-- TAB 1: ⚡ Quick Analysis -->
156
  <div id="section-quick" class="tab-section">
157
  <div class="glass-panel rounded-2xl p-6 shadow-xl mb-6">
158
  <h2 class="text-base font-bold text-slate-100 mb-4 flex items-center justify-between">
@@ -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="runExplorerMethod()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-teal-500">
247
  <option value="rollout">Attention Rollout</option>
248
  <option value="chefer">Gradient-Weighted Rollout (Chefer)</option>
249
  <option value="gradcam">GradCAM</option>
@@ -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="runExplorerMethod()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
259
  <option value="jet">Jet (Standard Forensic)</option>
260
  <option value="inferno">Inferno (High Contrast)</option>
261
  <option value="viridis">Viridis (Perceptual)</option>
@@ -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; runExplorerMethod()" class="w-full text-teal-500" />
269
  </div>
270
 
271
  <!-- Conditional Controls -->
272
- <div id="ctrl-integrated" class="space-y-3 pt-2 border-t border-slate-800">
273
  <label class="text-xs font-medium text-slate-300 block mb-1">IG Steps: <span id="val-steps">20</span></label>
274
- <input type="range" id="exp-steps" min="10" max="60" step="5" value="20" oninput="document.getElementById('val-steps').innerText=this.value; runExplorerMethod()" class="w-full" />
275
  </div>
276
 
277
- <div id="ctrl-occlusion" class="space-y-3 pt-2 border-t border-slate-800">
278
  <label class="text-xs font-medium text-slate-300 block mb-1">Patch Size: <span id="val-patch">32</span> px</label>
279
- <input type="range" id="exp-patch" min="16" max="64" step="8" value="32" oninput="document.getElementById('val-patch').innerText=this.value; runExplorerMethod()" class="w-full" />
280
  </div>
281
 
282
- <div id="ctrl-gradcam" class="space-y-3 pt-2 border-t border-slate-800">
283
  <label class="text-xs font-medium text-slate-300 block mb-1">Target Transformer Layer: <span id="val-layer">12</span></label>
284
- <input type="range" id="exp-layer" min="1" max="12" step="1" value="12" oninput="document.getElementById('val-layer').innerText=this.value; runExplorerMethod()" class="w-full" />
285
  </div>
286
  </div>
287
 
@@ -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" onchange="runPerHeadInspector()" class="w-32" />
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="runForensicAnalysis()" class="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-xs text-slate-200">
341
  <option value="ela">Error Level Analysis (ELA)</option>
342
  <option value="gradient">Spatial Edge Gradient</option>
343
  <option value="bitplane">Bit Plane Extractor</option>
344
  <option value="minmax">MinMax Local Deviation</option>
345
- <option value="wavelet">Wavelet Noise Estimator</option>
346
  </select>
347
  </div>
348
 
349
  <!-- ELA Options -->
350
  <div id="ctrl-ela" class="space-y-3 pt-2 border-t border-slate-800">
351
  <label class="text-xs font-medium text-slate-300 block mb-1">JPEG Quality: <span id="val-quality">75</span>%</label>
352
- <input type="range" id="for-quality" min="30" max="95" value="75" oninput="document.getElementById('val-quality').innerText=this.value; runForensicAnalysis()" class="w-full" />
353
  </div>
354
 
355
  <!-- Bitplane Options -->
356
  <div id="ctrl-bitplane" class="hidden space-y-3 pt-2 border-t border-slate-800">
357
  <label class="text-xs font-medium text-slate-300 block mb-1">Bit Index (0=LSB, 7=MSB)</label>
358
- <input type="range" id="for-bit" min="0" max="7" value="0" oninput="runForensicAnalysis()" class="w-full" />
359
  </div>
360
 
361
- <p id="for-info" class="text-xs font-mono text-teal-400/90 pt-2"></p>
 
 
 
 
 
 
362
  </div>
363
 
364
  <!-- Display Box -->
365
- <div class="lg:col-span-8 bg-slate-900/60 p-4 rounded-xl border border-slate-800 text-center flex flex-col justify-center items-center min-h-[300px]">
366
- <img id="for-img-output" src="" class="max-h-96 rounded-lg shadow border border-slate-700 object-contain hidden" />
367
  <p id="for-placeholder" class="text-xs text-slate-500">Upload an image and run analysis</p>
368
  </div>
369
  </div>
@@ -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">Model & XAI Documentation</h2>
378
- <p class="text-xs text-slate-400">
379
- Model: <code class="text-teal-400 font-mono">buildborderless/CommunityForensics-DeepfakeDet-ViT</code> — trained on 2.7M samples across 4,803 generators.
 
 
 
380
  </p>
381
  </div>
382
 
383
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs text-slate-300">
384
- <div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 space-y-2">
385
- <h3 class="font-bold text-teal-400">Attention Rollout vs. Chefer Rollout</h3>
386
- <p class="text-slate-400">Attention Rollout averages attention maps across all heads and multiplies them layer-by-layer. Chefer et al. (CVPR 2021) improves this by multiplying attention weights by their backward gradients <code class="text-teal-400">∇A × A</code>, making the attribution class-discriminative (specifically showing what pushed towards FAKE).</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  </div>
388
- <div class="bg-slate-900/60 p-4 rounded-xl border border-slate-800 space-y-2">
389
- <h3 class="font-bold text-teal-400">Integrated Gradients & DeepLIFT</h3>
390
- <p class="text-slate-400">Integrated Gradients integrates pixel gradients along a linear path from a black baseline. DeepLIFT decomposes non-linear neural activations relative to baseline to reveal positive vs. negative feature contributions.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  </div>
392
  </div>
 
 
 
 
 
 
 
 
 
393
  </div>
394
  </div>
395
 
@@ -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
- function switchTab(tabId) {
416
- document.querySelectorAll('.tab-btn').forEach(btn => {
417
- btn.classList.remove('border-teal-400', 'text-teal-400');
418
- btn.classList.add('border-transparent', 'text-slate-400');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  });
420
- document.querySelectorAll('.tab-section').forEach(sec => sec.classList.add('hidden'));
421
-
422
- document.getElementById('tab-' + tabId).classList.add('border-teal-400', 'text-teal-400');
423
- document.getElementById('tab-' + tabId).classList.remove('border-transparent', 'text-slate-400');
424
- document.getElementById('section-' + tabId).classList.remove('hidden');
425
 
426
- if (currentImageB64) {
427
- if (tabId === 'explorer') runExplorerMethod();
428
- if (tabId === 'heads') runPerHeadInspector();
429
- if (tabId === 'forensics') runForensicAnalysis();
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 = function(e) {
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
- const res = await apiCall('analyze', { image_b64: currentImageB64, alpha: 0.6, cmap: 'jet' });
473
-
474
- if (res.status === 'success') {
475
- const pred = res.prediction;
 
 
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
- const grid = document.getElementById('results-grid');
485
- grid.innerHTML = '';
486
- res.results.forEach(r => {
487
- const card = document.createElement('div');
488
- card.className = 'glass-panel rounded-xl p-3 hover:border-teal-500/50 transition cursor-pointer group';
489
- card.onclick = () => openModal(r.name, r.overlay_b64);
490
- card.innerHTML = `
491
- <div class="relative overflow-hidden rounded-lg mb-2">
492
- <img src="${r.overlay_b64}" class="w-full h-44 object-cover group-hover:scale-105 transition duration-300" />
493
- <span class="absolute bottom-2 right-2 bg-slate-950/80 text-teal-400 text-[10px] font-mono px-2 py-0.5 rounded border border-slate-700">${r.compute_time_ms} ms</span>
494
- </div>
495
- <h4 class="text-xs font-bold text-slate-200">${r.name}</h4>
496
- <p class="text-[10px] text-slate-400 line-clamp-1">${r.description}</p>
497
- `;
498
- grid.appendChild(card);
 
 
499
  });
 
500
 
501
- document.getElementById('submit-panel').classList.remove('hidden');
502
- document.getElementById('btn-report').disabled = false;
503
- } else {
504
- alert('Analysis error: ' + (res.message || 'Unknown error'));
505
- }
506
- } catch (err) {
 
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; // API expects 0-indexed
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
- async function runForensicAnalysis() {
 
 
 
 
 
 
 
 
 
 
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
- const img = document.getElementById('for-img-output');
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
- async function generateReport() {
603
- if (!currentImageB64) return;
604
- const btn = document.getElementById('btn-report');
605
- btn.disabled = true;
606
- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Generating...';
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>
 
logger.py CHANGED
@@ -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 = res.overlay_b64.split(",")[-1]
35
  overlay_pil = Image.open(BytesIO(base64.b64decode(b64_data))).resize((w, h))
36
  grid_img.paste(overlay_pil, (c * w, r * h))
37
 
@@ -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 = {r.name: r.compute_time_ms for r in results}
 
 
 
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
report.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -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
xai_engine/methods.py CHANGED
@@ -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 = resize(heatmap, (384, 384), order=1)
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=to_b64_png(overlay),
266
- raw_heatmap_b64=to_b64_png(raw_render),
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 safely with per-method exception handling."""
279
  methods = ["rollout", "chefer", "gradcam", "integrated", "occlusion", "deeplift"]
280
- results = []
281
- for m in methods:
282
- try:
283
- res = run_single_method(wrapper, image, m, alpha=alpha, cmap=cmap)
284
- results.append(res)
285
- except Exception as err:
286
- print(f"[xai_engine] Error running method {m}: {err}")
287
- return results
 
 
 
 
 
 
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
 
xai_engine/model.py CHANGED
@@ -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)
xai_engine/viz.py CHANGED
@@ -1,56 +1,81 @@
1
  import base64
2
  from io import BytesIO
3
  import numpy as np
 
4
  from PIL import Image
5
- import matplotlib.pyplot as plt
6
- from skimage.transform import resize
 
 
 
 
 
 
7
 
8
 
9
  def normalize_heatmap(heatmap: np.ndarray) -> np.ndarray:
10
  """Min-max normalize a heatmap array to [0, 1]."""
11
  h_min, h_max = heatmap.min(), heatmap.max()
12
  if h_max - h_min < 1e-8:
13
- return np.zeros_like(heatmap)
14
- return (heatmap - h_min) / (h_max - h_min + 1e-8)
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def create_overlay(image: Image.Image, heatmap: np.ndarray, alpha: float = 0.6, cmap: str = "jet") -> Image.Image:
18
  """Blend a heatmap overlay onto a PIL Image."""
19
  image_rgb = image.convert("RGB").resize((384, 384))
20
- img_np = np.array(image_rgb) / 255.0
21
 
22
- # Resize heatmap to match image dimension (384, 384)
23
- if heatmap.shape != (384, 384):
24
- heatmap_resized = resize(heatmap, (384, 384), order=1)
25
  else:
26
- heatmap_resized = heatmap
27
 
28
  norm_h = normalize_heatmap(heatmap_resized)
29
-
30
- colormap = plt.get_cmap(cmap)
31
- colored_heatmap = colormap(norm_h)[:, :, :3] # Drop alpha channel
32
-
33
  overlay = (1 - alpha) * img_np + alpha * colored_heatmap
34
  overlay = np.clip(overlay * 255, 0, 255).astype(np.uint8)
35
-
36
  return Image.fromarray(overlay)
37
 
38
 
39
  def render_heatmap_raw(heatmap: np.ndarray, cmap: str = "jet") -> Image.Image:
40
  """Render raw normalized heatmap as RGB PIL Image using colormap."""
41
- if heatmap.shape != (384, 384):
42
- heatmap_resized = resize(heatmap, (384, 384), order=1)
43
  else:
44
- heatmap_resized = heatmap
45
 
46
  norm_h = normalize_heatmap(heatmap_resized)
47
- colormap = plt.get_cmap(cmap)
48
- colored = (colormap(norm_h)[:, :, :3] * 255).astype(np.uint8)
49
  return Image.fromarray(colored)
50
 
51
 
 
 
 
 
 
 
 
 
 
52
  def to_b64_png(image: Image.Image) -> str:
53
- """Convert a PIL Image to a base64 encoded PNG data URL."""
 
54
  buffer = BytesIO()
55
  image.save(buffer, format="PNG")
56
  b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
 
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")