ethix commited on
Commit
ebcb290
·
0 Parent(s):

Initial commit for XAI Space

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