mojiotoo commited on
Commit
4d1e70c
Β·
verified Β·
1 Parent(s): 1367c3e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +434 -430
app.py CHANGED
@@ -1,431 +1,435 @@
1
- """
2
- Pipeline:
3
- image (BGR)
4
- β”œβ”€β”€ 4-channel tensor (RGB normalized + FFT power spectrum) β†’ EfficientNet-B0 β†’ 1280
5
- β”œβ”€β”€ FFT azimuthal avg at 256Γ—256 β†’ 128
6
- └── noise (gray - GaussianBlur) FFT azimuthal avg at 256Γ—256 β†’ 128
7
- ────
8
- 1536 β†’ StandardScaler (inside Pipeline) β†’ SVM / RF
9
-
10
- Model files : v5 (preferred):
11
- best_model_v5.pth CNN weights (EfficientNet-B0 4-ch, v5 head: GELU + Dropout 0.4/0.3)
12
- rbf_svm_v5.onnx sklearn Pipeline (StandardScaler + RBF SVC)
13
- features_v5.npz training features for k-NN type attribution
14
-
15
- Run:
16
- pip install flask flask-cors onnxruntime torch timm opencv-python-headless scipy numpy pillow scikit-learn joblib
17
- python app.py
18
- """
19
-
20
- from flask import Flask, request, jsonify
21
- from flask_cors import CORS
22
- import numpy as np
23
- import cv2
24
- import io
25
- import torch
26
- import torch.nn as nn
27
- import timm
28
- from scipy import ndimage
29
- from PIL import Image
30
- import joblib
31
- import os
32
-
33
- app = Flask(__name__)
34
- CORS(app)
35
-
36
- # ---------------------------------------------------------------------------
37
- # Config
38
- # ---------------------------------------------------------------------------
39
- FFT_SIZE = 256
40
- AZ_BINS = FFT_SIZE // 2 # 128
41
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
42
-
43
- # v5 paths
44
- CNN_V5_PATH = "best_model_v5.pth"
45
- SKL_PKL_PATHS = ["linear_svm_v5.pkl", "rbf_svm_v5.pkl", "rf_model_v5.pkl"]
46
- FEATURES_NPZ = "features_v5.npz"
47
-
48
- # Used only in ONNX fallback mode
49
- DECISION_THRESHOLD = 0.35
50
- CALIBRATION_TEMPERATURE = 3.0
51
-
52
- IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
53
- IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
54
-
55
- # ---------------------------------------------------------------------------
56
- # Manipulation type human-readable labels & descriptions
57
- # ---------------------------------------------------------------------------
58
- MANIPULATION_LABELS = {
59
- "faceswap_autoencoder": "Face Swap (Autoencoder)",
60
- "stylegan2": "StyleGAN2 Synthesis",
61
- "stylegan_ffhq": "StyleGAN (FFHQ-trained)",
62
- "stylegan_celeba": "StyleGAN (CelebA-trained)",
63
- "stylegan_variant": "StyleGAN Variant",
64
- "gan_mixed": "GAN Synthesis (Mixed)",
65
- "faceswap_dfd": "Face Swap (DeepFaceLab-style)",
66
- "stargan": "StarGAN Attribute Editing",
67
- "pggan_v1": "Progressive GAN v1",
68
- "pggan_v2": "Progressive GAN v2",
69
- "faceapp": "FaceApp AI Manipulation",
70
- }
71
-
72
- MANIPULATION_DESCRIPTIONS = {
73
- "faceswap_autoencoder": "This replaces a person's face using an AI autoencoder. It often leaves behind subtle blending lines and mismatched lighting.",
74
- "stylegan2": "This generates entirely fake, photorealistic faces. It usually leaves hidden grid-like artifacts in the image pixels.",
75
- "stylegan_ffhq": "This creates highly realistic fake faces, leaving behind subtle AI frequency traces.",
76
- "stylegan_celeba": "This generates fake faces based on celebrity photos, showing specific AI noise patterns.",
77
- "stylegan_variant": "This is a variant of the StyleGAN family, which typically leaves repeated upsampling artifacts hidden in the image.",
78
- "gan_mixed": "This looks like a mix of different AI generators, showing unusual and diverse artificial noise patterns.",
79
- "faceswap_dfd": "This is a DeepFaceLab-style face swap. It usually leaves tiny seam artifacts around the edges of the face.",
80
- "stargan": "This edits specific facial features like hair, gender, or age, often leaving behind a slight checkerboard effect.",
81
- "pggan_v1": "This progressively generates fake faces from low to high resolution, which often leaves blurry artifacts in the background.",
82
- "pggan_v2": "An improved AI generator, but it still leaves faint artificial traces around hair and backgrounds.",
83
- "faceapp": "This applies heavy AI filters to a real photo. It drastically smooths out the skin and flattens natural textures.",
84
- }
85
-
86
- # CNN : v5 architecture
87
- class DeepfakeDetector(nn.Module):
88
- def __init__(self):
89
- super().__init__()
90
- self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
91
- old_conv = self.backbone.conv_stem
92
- new_conv = nn.Conv2d(
93
- 4, old_conv.out_channels,
94
- kernel_size=old_conv.kernel_size,
95
- stride=old_conv.stride,
96
- padding=old_conv.padding,
97
- bias=old_conv.bias is not None,
98
- )
99
- self.backbone.conv_stem = new_conv
100
- d = self.backbone.num_features # 1280
101
- self.head = nn.Sequential(
102
- nn.Dropout(0.4), nn.Linear(d, 256), nn.GELU(),
103
- nn.Dropout(0.3), nn.Linear(256, 1),
104
- )
105
-
106
- def forward(self, x):
107
- return self.head(self.backbone(x))
108
-
109
- def get_features(self, x):
110
- """Return 1280-d backbone embedding (no head). Used for SVM + k-NN."""
111
- return self.backbone(x)
112
-
113
-
114
- # ---------------------------------------------------------------------------
115
- # Load CNN weights : prefer v5, fall back to v4
116
- # ---------------------------------------------------------------------------
117
- _cnn_path = CNN_V5_PATH
118
- if not os.path.exists(_cnn_path):
119
- raise FileNotFoundError(
120
- f"CNN weights not found. Expected '{CNN_V5_PATH}'."
121
- )
122
-
123
- feature_model = DeepfakeDetector().to(DEVICE)
124
- state = torch.load(_cnn_path, map_location=DEVICE, weights_only=False)
125
- feature_model.load_state_dict(state, strict=False)
126
- feature_model.eval()
127
- print(f"βœ… CNN loaded from {_cnn_path}")
128
-
129
-
130
- USE_SKL = False
131
- skl_clf = None
132
- svm_session = None
133
- SVM_INPUT_NAME = None
134
- feature_scaler = None
135
-
136
- for pkl_path in SKL_PKL_PATHS:
137
- if os.path.exists(pkl_path):
138
- skl_clf = joblib.load(pkl_path)
139
- USE_SKL = True
140
- print(f"βœ… Sklearn classifier loaded from {pkl_path} (StandardScaler embedded in pipeline)")
141
- break
142
-
143
- if not USE_SKL:
144
- import onnxruntime as rt
145
-
146
- ONNX_PATH = "rbf_svm_v5.onnx" # ← change to your actual filename
147
-
148
- if os.path.exists(ONNX_PATH):
149
- svm_session = rt.InferenceSession(ONNX_PATH)
150
- SVM_INPUT_NAME = svm_session.get_inputs()[0].name
151
- print(f"βœ… ONNX classifier loaded from {ONNX_PATH}")
152
- else:
153
- print(f"❌ ONNX file not found at '{ONNX_PATH}'")
154
-
155
- _knn = None
156
- _fake_types_train = None
157
-
158
- if os.path.exists(FEATURES_NPZ):
159
- try:
160
- from sklearn.neighbors import NearestNeighbors
161
- _d = np.load(FEATURES_NPZ, allow_pickle=True)
162
- X_tr = _d["X_train"]
163
- y_tr = _d["y_train"].astype(int)
164
- dt_tr = _d["dtype_train"]
165
- _d.close()
166
- _fake_mask = y_tr == 1
167
- _knn = NearestNeighbors(n_neighbors=min(15, int(_fake_mask.sum())))
168
- _knn.fit(X_tr[_fake_mask])
169
- _fake_types_train = np.asarray(dt_tr)[_fake_mask]
170
- print(f"βœ… k-NN type attributor ready")
171
- except Exception as exc:
172
- print(f"⚠️ k-NN setup failed ({exc})")
173
- else:
174
- print(f"ℹ️ {FEATURES_NPZ} not found")
175
-
176
-
177
- def compute_azimuthal_average(spectrum_2d: np.ndarray) -> np.ndarray:
178
- h, w = spectrum_2d.shape
179
- cy, cx = h // 2, w // 2
180
- Y, X = np.ogrid[:h, :w]
181
- r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
182
- max_r = min(cy, cx)
183
- return ndimage.mean(spectrum_2d, labels=r, index=np.arange(0, max_r))
184
-
185
-
186
- def extract_combined_features(img_bgr: np.ndarray):
187
- """
188
- Extract the 1536-d feature vector that was used to train the SVM.
189
-
190
- Returns
191
- -------
192
- feat : np.ndarray shape (1536,)
193
- spectral : dict 'az' and 'nz' lists for explanation / debug
194
- """
195
- # ── 4-channel input tensor ─────────────────────────────────────────────
196
- img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
197
- r224 = cv2.resize(img_rgb, (224, 224), interpolation=cv2.INTER_LINEAR)
198
- norm = (r224.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
199
- t3 = torch.from_numpy(norm.transpose(2, 0, 1))
200
-
201
- gray224 = cv2.cvtColor(r224, cv2.COLOR_RGB2GRAY).astype(np.float32)
202
- fs0 = np.fft.fftshift(np.fft.fft2(gray224))
203
- ps = np.log1p(np.abs(fs0) ** 2)
204
- ps = (ps - ps.min()) / (ps.max() - ps.min() + 1e-8)
205
- fft_ch = torch.from_numpy(ps.astype(np.float32)).unsqueeze(0)
206
-
207
- tensor_4ch = torch.cat([t3, fft_ch], dim=0).unsqueeze(0).to(DEVICE)
208
-
209
- # ── CNN backbone β†’ 1280-d (matches notebook Cell 11 / Cell 17) ─────────
210
- with torch.no_grad():
211
- cnn_feat = feature_model.get_features(tensor_4ch).float().cpu().numpy() # (1, 1280)
212
-
213
- # ── FFT azimuthal profile at FFT_SIZE ──────────────────────────────────
214
- gray = cv2.resize(
215
- cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY), (FFT_SIZE, FFT_SIZE)
216
- ).astype(np.float32)
217
- fs = np.fft.fftshift(np.fft.fft2(gray))
218
- power = np.log1p(np.abs(fs) ** 2)
219
- az = compute_azimuthal_average(power).astype(np.float32) # 128-d
220
-
221
- # ── Noise-residual FFT azimuthal profile ───────────────────────────────
222
- blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
223
- noise = gray - blurred
224
- nfs = np.fft.fftshift(np.fft.fft2(noise))
225
- noise_power = np.log1p(np.abs(nfs) ** 2)
226
- nz = compute_azimuthal_average(noise_power).astype(np.float32) # 128-d
227
-
228
- combined = np.concatenate([cnn_feat[0], az, nz]).astype(np.float32)
229
- assert combined.shape == (1536,), f"Bad feature dim: {combined.shape}"
230
-
231
- spectral = {"az": az.tolist(), "nz": nz.tolist()}
232
- return combined, spectral
233
-
234
- def attribute_type(feat_1d: np.ndarray) -> list:
235
- # Vote among 15 nearest fake-training-set neighbours.
236
- if _knn is None:
237
- return []
238
- _, idx = _knn.kneighbors(feat_1d.reshape(1, -1))
239
- neigh = _fake_types_train[idx[0]]
240
- vals, counts = np.unique(neigh, return_counts=True)
241
- order = np.argsort(-counts)
242
- return [(str(vals[o]), float(counts[o] / len(neigh) * 100)) for o in order]
243
-
244
- def analyze_spectral_profile(az: list, nz: list) -> tuple[list, str]:
245
- # Inspect the azimuthal power and noise-residual profiles.
246
- az_arr = np.array(az, dtype=np.float32)
247
- nz_arr = np.array(nz, dtype=np.float32)
248
- n = len(az_arr)
249
- if n < 8:
250
- return [], "Image is too small for a clear frequency analysis."
251
-
252
- low_end = max(1, n // 8)
253
- mid_s = n // 4
254
- mid_e = n // 2
255
- hi_s = 3 * n // 4
256
-
257
- low_mean = float(az_arr[:low_end].mean())
258
- hi_mean = float(az_arr[hi_s:].mean())
259
- nz_low = float(nz_arr[:low_end].mean())
260
- nz_hi = float(nz_arr[hi_s:].mean())
261
-
262
- flags = []
263
-
264
- # ── Flag 1: elevated high-frequency power (GAN upsampling) ────────────
265
- decay_ratio = hi_mean / (low_mean + 1e-6)
266
- if decay_ratio > 0.55:
267
- flags.append("high_freq_elevation")
268
-
269
- # ── Flag 2: noise-residual elevation in high bands (blend / re-encode) ─
270
- noise_ratio = nz_hi / (nz_low + 1e-6)
271
- if noise_ratio > 0.75:
272
- flags.append("noise_floor_elevated")
273
-
274
- # ── Flag 3: non-monotonic spectral profile (GAN periodic spikes) ───────
275
- diffs = np.diff(az_arr[mid_s:])
276
- sign_changes = int(((diffs[:-1] * diffs[1:]) < 0).sum())
277
- if sign_changes > n // 6:
278
- flags.append("spectral_oscillation")
279
-
280
- # ── Flag 4: flat mid-band (over-smoothing : FaceApp / inpainting) ──────
281
- mid_std = float(az_arr[mid_s:mid_e].std())
282
- if mid_std < 0.04 * (low_mean + 1e-6):
283
- flags.append("mid_band_flat")
284
- return flags
285
-
286
- def build_explanation(is_fake: bool, p_fake: float, manip_types: list, spectral_flags: list) -> str:
287
- pct_fake = round(p_fake * 100, 1)
288
- pct_real = round((1.0 - p_fake) * 100, 1)
289
-
290
- if not is_fake:
291
- if not spectral_flags:
292
- return f"This image looks authentic. We are {pct_real}% confident it is real. The underlying frequency patterns are completely natural with no obvious AI traces."
293
- else:
294
- return f"This image looks authentic. We are {pct_real}% confident it is real. We picked up a few minor frequency quirks, but our core model confirms the overall image structure is natural and not manipulated."
295
-
296
- parts = [f"We detected that this image is AI-generated or manipulated (estimated {pct_fake}% probability)."]
297
-
298
- if manip_types:
299
- top_type, top_pct = manip_types[0]
300
- label = MANIPULATION_LABELS.get(top_type, top_type)
301
- desc = MANIPULATION_DESCRIPTIONS.get(top_type, "")
302
- parts.append(f"The patterns closely match {label}. {desc}")
303
- elif spectral_flags:
304
- if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
305
- parts.append("The image contains distinct hidden pixel patterns that are a dead giveaway for GAN-style AI generators.")
306
- elif "noise_floor_elevated" in spectral_flags:
307
- parts.append("The background noise levels are uneven, which is usually a strong sign of a face-swap.")
308
- elif "mid_band_flat" in spectral_flags:
309
- parts.append("The textures are unnaturally smooth, which usually points to a pyt AI filter like FaceApp.")
310
- else:
311
- parts.append("While the frequency traces are subtle, our deep learning model strongly recognized features from known deepfake datasets.")
312
-
313
- return " ".join(parts)
314
-
315
- def get_confidence_level(p_fake: float) -> str:
316
- threshold = 0.5 if USE_SKL else DECISION_THRESHOLD
317
- distance = abs(p_fake - threshold)
318
- if distance < 0.10:
319
- return "uncertain"
320
- elif distance < 0.25:
321
- return "low"
322
- elif distance < 0.40:
323
- return "medium"
324
- else:
325
- return "high"
326
-
327
-
328
- # ---------------------------------------------------------------------------
329
- # /analyze endpoint
330
- # ---------------------------------------------------------------------------
331
- @app.route("/analyze", methods=["POST"])
332
- def analyze():
333
- if "image" not in request.files:
334
- return jsonify({"error": "No image provided."}), 400
335
-
336
- try:
337
- raw = request.files["image"].read()
338
- pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
339
- img_rgb = np.array(pil_img)
340
- img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
341
-
342
- # ── Feature extraction ─────────────────────────────────────────────
343
- features, spectral = extract_combined_features(img_bgr)
344
- feat_2d = features.reshape(1, -1)
345
-
346
- # ── Classification ─────────────────────────────────────────────────
347
- if USE_SKL:
348
- # sklearn Pipeline handles scaling internally
349
- p_fake = float(skl_clf.predict_proba(feat_2d)[0, 1])
350
- p_real = 1.0 - p_fake
351
- is_fake = p_fake >= 0.5
352
-
353
- else:
354
- # ONNX
355
- scaled = (
356
- feature_scaler.transform(feat_2d).astype(np.float32)
357
- if feature_scaler is not None
358
- else feat_2d.astype(np.float32)
359
- )
360
- label_arr, prob_list = svm_session.run(None, {SVM_INPUT_NAME: scaled})
361
- p_fake_raw = float(prob_list[0][1])
362
-
363
- # Temperature scaling (smooths isotonic step-function probabilities)
364
- eps = 1e-6
365
- p_clip = float(np.clip(p_fake_raw, eps, 1.0 - eps))
366
- logit_p = np.log(p_clip / (1.0 - p_clip))
367
- p_fake = float(1.0 / (1.0 + np.exp(-logit_p / CALIBRATION_TEMPERATURE)))
368
- p_real = 1.0 - p_fake
369
- is_fake = bool(int(label_arr[0]) == 1)
370
-
371
- confidence = get_confidence_level(p_fake)
372
- manip_types = attribute_type(features) if is_fake else []
373
- spectral_flags = analyze_spectral_profile(spectral["az"], spectral["nz"])
374
- spectral_summary = ""
375
- explanation = build_explanation(
376
- is_fake, p_fake, manip_types, spectral_flags
377
- )
378
-
379
- manip_label = None
380
- manip_scores = []
381
-
382
- if is_fake:
383
- if manip_types:
384
- top_type, _ = manip_types[0]
385
- manip_label = MANIPULATION_LABELS.get(top_type, top_type)
386
- manip_scores = [
387
- {
388
- "type": t,
389
- "label": MANIPULATION_LABELS.get(t, t),
390
- "confidence": round(p, 1),
391
- }
392
- for t, p in manip_types[:5]
393
- ]
394
- elif spectral_flags:
395
- if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
396
- manip_label = "GAN Synthesis"
397
- elif "noise_floor_elevated" in spectral_flags:
398
- manip_label = "Face Swap / Re-encoding"
399
- elif "mid_band_flat" in spectral_flags:
400
- manip_label = "AI Filter / Smoothing"
401
- else:
402
- manip_label = "AI Manipulation Type Unknown"
403
-
404
- print(
405
- f"DEBUG: p_fake={p_fake:.4f} label={'FAKE' if is_fake else 'REAL'} "
406
- f"confidence={confidence} manip={manip_label}"
407
- )
408
-
409
- return jsonify({
410
- "probability": round(p_fake * 100, 2),
411
- "label": "AI Generated / Fake" if is_fake else "Authentic Media",
412
- "is_fake": is_fake,
413
- "confidence": confidence,
414
- "p_real": round(p_real * 100, 2),
415
- "p_fake": round(p_fake * 100, 2),
416
- "explanation": explanation,
417
- "manipulation_type": manip_label,
418
- "manipulation_scores": manip_scores,
419
- "spectral_flags": spectral_flags,
420
- "spectral_summary": spectral_summary,
421
- "threshold_used": 0.5 if USE_SKL else DECISION_THRESHOLD,
422
- })
423
-
424
- except Exception as exc:
425
- import traceback
426
- traceback.print_exc()
427
- return jsonify({"error": f"Analysis failed: {exc}"}), 500
428
-
429
-
430
- if __name__ == "__main__":
 
 
 
 
431
  app.run(host="0.0.0.0", port=7860, debug=False)
 
1
+ """
2
+ Pipeline:
3
+ image (BGR)
4
+ β”œβ”€β”€ 4-channel tensor (RGB normalized + FFT power spectrum) β†’ EfficientNet-B0 β†’ 1280
5
+ β”œβ”€β”€ FFT azimuthal avg at 256Γ—256 β†’ 128
6
+ └── noise (gray - GaussianBlur) FFT azimuthal avg at 256Γ—256 β†’ 128
7
+ ────
8
+ 1536 β†’ StandardScaler (inside Pipeline) β†’ SVM / RF
9
+
10
+ Model files : v5 (preferred):
11
+ best_model_v5.pth CNN weights (EfficientNet-B0 4-ch, v5 head: GELU + Dropout 0.4/0.3)
12
+ rbf_svm_v5.onnx sklearn Pipeline (StandardScaler + RBF SVC)
13
+ features_v5.npz training features for k-NN type attribution
14
+
15
+ Run:
16
+ pip install flask flask-cors onnxruntime torch timm opencv-python-headless scipy numpy pillow scikit-learn joblib
17
+ python app.py
18
+ """
19
+
20
+ from flask import Flask, request, jsonify
21
+ from flask_cors import CORS
22
+ import numpy as np
23
+ import cv2
24
+ import io
25
+ import torch
26
+ import torch.nn as nn
27
+ import timm
28
+ from scipy import ndimage
29
+ from PIL import Image
30
+ import joblib
31
+ import os
32
+
33
+ app = Flask(__name__)
34
+ CORS(app)
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Config
38
+ # ---------------------------------------------------------------------------
39
+ FFT_SIZE = 256
40
+ AZ_BINS = FFT_SIZE // 2 # 128
41
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
42
+
43
+ # v5 paths
44
+ CNN_V5_PATH = "best_model_v5.pth"
45
+ SKL_PKL_PATHS = ["linear_svm_v5.pkl", "rbf_svm_v5.pkl", "rf_model_v5.pkl"]
46
+ FEATURES_NPZ = "features_v5.npz"
47
+
48
+ # Used only in ONNX fallback mode
49
+ DECISION_THRESHOLD = 0.35
50
+ CALIBRATION_TEMPERATURE = 3.0
51
+
52
+ IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
53
+ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Manipulation type human-readable labels & descriptions
57
+ # ---------------------------------------------------------------------------
58
+ MANIPULATION_LABELS = {
59
+ "faceswap_autoencoder": "Face Swap (Autoencoder)",
60
+ "stylegan2": "StyleGAN2 Synthesis",
61
+ "stylegan_ffhq": "StyleGAN (FFHQ-trained)",
62
+ "stylegan_celeba": "StyleGAN (CelebA-trained)",
63
+ "stylegan_variant": "StyleGAN Variant",
64
+ "gan_mixed": "GAN Synthesis (Mixed)",
65
+ "faceswap_dfd": "Face Swap (DeepFaceLab-style)",
66
+ "stargan": "StarGAN Attribute Editing",
67
+ "pggan_v1": "Progressive GAN v1",
68
+ "pggan_v2": "Progressive GAN v2",
69
+ "faceapp": "FaceApp AI Manipulation",
70
+ }
71
+
72
+ MANIPULATION_DESCRIPTIONS = {
73
+ "faceswap_autoencoder": "This replaces a person's face using an AI autoencoder. It often leaves behind subtle blending lines and mismatched lighting.",
74
+ "stylegan2": "This generates entirely fake, photorealistic faces. It usually leaves hidden grid-like artifacts in the image pixels.",
75
+ "stylegan_ffhq": "This creates highly realistic fake faces, leaving behind subtle AI frequency traces.",
76
+ "stylegan_celeba": "This generates fake faces based on celebrity photos, showing specific AI noise patterns.",
77
+ "stylegan_variant": "This is a variant of the StyleGAN family, which typically leaves repeated upsampling artifacts hidden in the image.",
78
+ "gan_mixed": "This looks like a mix of different AI generators, showing unusual and diverse artificial noise patterns.",
79
+ "faceswap_dfd": "This is a DeepFaceLab-style face swap. It usually leaves tiny seam artifacts around the edges of the face.",
80
+ "stargan": "This edits specific facial features like hair, gender, or age, often leaving behind a slight checkerboard effect.",
81
+ "pggan_v1": "This progressively generates fake faces from low to high resolution, which often leaves blurry artifacts in the background.",
82
+ "pggan_v2": "An improved AI generator, but it still leaves faint artificial traces around hair and backgrounds.",
83
+ "faceapp": "This applies heavy AI filters to a real photo. It drastically smooths out the skin and flattens natural textures.",
84
+ }
85
+
86
+ # CNN : v5 architecture
87
+ class DeepfakeDetector(nn.Module):
88
+ def __init__(self):
89
+ super().__init__()
90
+ self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
91
+ old_conv = self.backbone.conv_stem
92
+ new_conv = nn.Conv2d(
93
+ 4, old_conv.out_channels,
94
+ kernel_size=old_conv.kernel_size,
95
+ stride=old_conv.stride,
96
+ padding=old_conv.padding,
97
+ bias=old_conv.bias is not None,
98
+ )
99
+ self.backbone.conv_stem = new_conv
100
+ d = self.backbone.num_features # 1280
101
+ self.head = nn.Sequential(
102
+ nn.Dropout(0.4), nn.Linear(d, 256), nn.GELU(),
103
+ nn.Dropout(0.3), nn.Linear(256, 1),
104
+ )
105
+
106
+ def forward(self, x):
107
+ return self.head(self.backbone(x))
108
+
109
+ def get_features(self, x):
110
+ """Return 1280-d backbone embedding (no head). Used for SVM + k-NN."""
111
+ return self.backbone(x)
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Load CNN weights : prefer v5, fall back to v4
116
+ # ---------------------------------------------------------------------------
117
+ _cnn_path = CNN_V5_PATH
118
+ if not os.path.exists(_cnn_path):
119
+ raise FileNotFoundError(
120
+ f"CNN weights not found. Expected '{CNN_V5_PATH}'."
121
+ )
122
+
123
+ feature_model = DeepfakeDetector().to(DEVICE)
124
+ state = torch.load(_cnn_path, map_location=DEVICE, weights_only=False)
125
+ feature_model.load_state_dict(state, strict=False)
126
+ feature_model.eval()
127
+ print(f"βœ… CNN loaded from {_cnn_path}")
128
+
129
+
130
+ USE_SKL = False
131
+ skl_clf = None
132
+ svm_session = None
133
+ SVM_INPUT_NAME = None
134
+ feature_scaler = None
135
+
136
+ for pkl_path in SKL_PKL_PATHS:
137
+ if os.path.exists(pkl_path):
138
+ skl_clf = joblib.load(pkl_path)
139
+ USE_SKL = True
140
+ print(f"βœ… Sklearn classifier loaded from {pkl_path} (StandardScaler embedded in pipeline)")
141
+ break
142
+
143
+ if not USE_SKL:
144
+ import onnxruntime as rt
145
+
146
+ ONNX_PATH = "rbf_svm_v5.onnx" # ← change to your actual filename
147
+
148
+ if os.path.exists(ONNX_PATH):
149
+ svm_session = rt.InferenceSession(ONNX_PATH)
150
+ SVM_INPUT_NAME = svm_session.get_inputs()[0].name
151
+ print(f"βœ… ONNX classifier loaded from {ONNX_PATH}")
152
+ else:
153
+ print(f"❌ ONNX file not found at '{ONNX_PATH}'")
154
+
155
+ _knn = None
156
+ _fake_types_train = None
157
+
158
+ if os.path.exists(FEATURES_NPZ):
159
+ try:
160
+ from sklearn.neighbors import NearestNeighbors
161
+ _d = np.load(FEATURES_NPZ, allow_pickle=True)
162
+ X_tr = _d["X_train"]
163
+ y_tr = _d["y_train"].astype(int)
164
+ dt_tr = _d["dtype_train"]
165
+ _d.close()
166
+ _fake_mask = y_tr == 1
167
+ _knn = NearestNeighbors(n_neighbors=min(15, int(_fake_mask.sum())))
168
+ _knn.fit(X_tr[_fake_mask])
169
+ _fake_types_train = np.asarray(dt_tr)[_fake_mask]
170
+ print(f"βœ… k-NN type attributor ready")
171
+ except Exception as exc:
172
+ print(f"⚠️ k-NN setup failed ({exc})")
173
+ else:
174
+ print(f"ℹ️ {FEATURES_NPZ} not found")
175
+
176
+
177
+ def compute_azimuthal_average(spectrum_2d: np.ndarray) -> np.ndarray:
178
+ h, w = spectrum_2d.shape
179
+ cy, cx = h // 2, w // 2
180
+ Y, X = np.ogrid[:h, :w]
181
+ r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
182
+ max_r = min(cy, cx)
183
+ return ndimage.mean(spectrum_2d, labels=r, index=np.arange(0, max_r))
184
+
185
+
186
+ def extract_combined_features(img_bgr: np.ndarray):
187
+ """
188
+ Extract the 1536-d feature vector that was used to train the SVM.
189
+
190
+ Returns
191
+ -------
192
+ feat : np.ndarray shape (1536,)
193
+ spectral : dict 'az' and 'nz' lists for explanation / debug
194
+ """
195
+ # ── 4-channel input tensor ─────────────────────────────────────────────
196
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
197
+ r224 = cv2.resize(img_rgb, (224, 224), interpolation=cv2.INTER_LINEAR)
198
+ norm = (r224.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
199
+ t3 = torch.from_numpy(norm.transpose(2, 0, 1))
200
+
201
+ gray224 = cv2.cvtColor(r224, cv2.COLOR_RGB2GRAY).astype(np.float32)
202
+ fs0 = np.fft.fftshift(np.fft.fft2(gray224))
203
+ ps = np.log1p(np.abs(fs0) ** 2)
204
+ ps = (ps - ps.min()) / (ps.max() - ps.min() + 1e-8)
205
+ fft_ch = torch.from_numpy(ps.astype(np.float32)).unsqueeze(0)
206
+
207
+ tensor_4ch = torch.cat([t3, fft_ch], dim=0).unsqueeze(0).to(DEVICE)
208
+
209
+ # ── CNN backbone β†’ 1280-d (matches notebook Cell 11 / Cell 17) ─────────
210
+ with torch.no_grad():
211
+ cnn_feat = feature_model.get_features(tensor_4ch).float().cpu().numpy() # (1, 1280)
212
+
213
+ # ── FFT azimuthal profile at FFT_SIZE ──────────────────────────────────
214
+ gray = cv2.resize(
215
+ cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY), (FFT_SIZE, FFT_SIZE)
216
+ ).astype(np.float32)
217
+ fs = np.fft.fftshift(np.fft.fft2(gray))
218
+ power = np.log1p(np.abs(fs) ** 2)
219
+ az = compute_azimuthal_average(power).astype(np.float32) # 128-d
220
+
221
+ # ── Noise-residual FFT azimuthal profile ───────────────────────────────
222
+ blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
223
+ noise = gray - blurred
224
+ nfs = np.fft.fftshift(np.fft.fft2(noise))
225
+ noise_power = np.log1p(np.abs(nfs) ** 2)
226
+ nz = compute_azimuthal_average(noise_power).astype(np.float32) # 128-d
227
+
228
+ combined = np.concatenate([cnn_feat[0], az, nz]).astype(np.float32)
229
+ assert combined.shape == (1536,), f"Bad feature dim: {combined.shape}"
230
+
231
+ spectral = {"az": az.tolist(), "nz": nz.tolist()}
232
+ return combined, spectral
233
+
234
+ def attribute_type(feat_1d: np.ndarray) -> list:
235
+ # Vote among 15 nearest fake-training-set neighbours.
236
+ if _knn is None:
237
+ return []
238
+ _, idx = _knn.kneighbors(feat_1d.reshape(1, -1))
239
+ neigh = _fake_types_train[idx[0]]
240
+ vals, counts = np.unique(neigh, return_counts=True)
241
+ order = np.argsort(-counts)
242
+ return [(str(vals[o]), float(counts[o] / len(neigh) * 100)) for o in order]
243
+
244
+ def analyze_spectral_profile(az: list, nz: list) -> tuple[list, str]:
245
+ # Inspect the azimuthal power and noise-residual profiles.
246
+ az_arr = np.array(az, dtype=np.float32)
247
+ nz_arr = np.array(nz, dtype=np.float32)
248
+ n = len(az_arr)
249
+ if n < 8:
250
+ return [], "Image is too small for a clear frequency analysis."
251
+
252
+ low_end = max(1, n // 8)
253
+ mid_s = n // 4
254
+ mid_e = n // 2
255
+ hi_s = 3 * n // 4
256
+
257
+ low_mean = float(az_arr[:low_end].mean())
258
+ hi_mean = float(az_arr[hi_s:].mean())
259
+ nz_low = float(nz_arr[:low_end].mean())
260
+ nz_hi = float(nz_arr[hi_s:].mean())
261
+
262
+ flags = []
263
+
264
+ # ── Flag 1: elevated high-frequency power (GAN upsampling) ────────────
265
+ decay_ratio = hi_mean / (low_mean + 1e-6)
266
+ if decay_ratio > 0.55:
267
+ flags.append("high_freq_elevation")
268
+
269
+ # ── Flag 2: noise-residual elevation in high bands (blend / re-encode) ─
270
+ noise_ratio = nz_hi / (nz_low + 1e-6)
271
+ if noise_ratio > 0.75:
272
+ flags.append("noise_floor_elevated")
273
+
274
+ # ── Flag 3: non-monotonic spectral profile (GAN periodic spikes) ───────
275
+ diffs = np.diff(az_arr[mid_s:])
276
+ sign_changes = int(((diffs[:-1] * diffs[1:]) < 0).sum())
277
+ if sign_changes > n // 6:
278
+ flags.append("spectral_oscillation")
279
+
280
+ # ── Flag 4: flat mid-band (over-smoothing : FaceApp / inpainting) ──────
281
+ mid_std = float(az_arr[mid_s:mid_e].std())
282
+ if mid_std < 0.04 * (low_mean + 1e-6):
283
+ flags.append("mid_band_flat")
284
+ return flags
285
+
286
+ def build_explanation(is_fake: bool, p_fake: float, manip_types: list, spectral_flags: list) -> str:
287
+ pct_fake = round(p_fake * 100, 1)
288
+ pct_real = round((1.0 - p_fake) * 100, 1)
289
+
290
+ if not is_fake:
291
+ if not spectral_flags:
292
+ return f"This image looks authentic. We are {pct_real}% confident it is real. The underlying frequency patterns are completely natural with no obvious AI traces."
293
+ else:
294
+ return f"This image looks authentic. We are {pct_real}% confident it is real. We picked up a few minor frequency quirks, but our core model confirms the overall image structure is natural and not manipulated."
295
+
296
+ parts = [f"We detected that this image is AI-generated or manipulated (estimated {pct_fake}% probability)."]
297
+
298
+ if manip_types:
299
+ top_type, top_pct = manip_types[0]
300
+ label = MANIPULATION_LABELS.get(top_type, top_type)
301
+ desc = MANIPULATION_DESCRIPTIONS.get(top_type, "")
302
+ parts.append(f"The patterns closely match {label}. {desc}")
303
+ elif spectral_flags:
304
+ if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
305
+ parts.append("The image contains distinct hidden pixel patterns that are a dead giveaway for GAN-style AI generators.")
306
+ elif "noise_floor_elevated" in spectral_flags:
307
+ parts.append("The background noise levels are uneven, which is usually a strong sign of a face-swap.")
308
+ elif "mid_band_flat" in spectral_flags:
309
+ parts.append("The textures are unnaturally smooth, which usually points to a pyt AI filter like FaceApp.")
310
+ else:
311
+ parts.append("While the frequency traces are subtle, our deep learning model strongly recognized features from known deepfake datasets.")
312
+
313
+ return " ".join(parts)
314
+
315
+ def get_confidence_level(p_fake: float) -> str:
316
+ threshold = 0.5 if USE_SKL else DECISION_THRESHOLD
317
+ distance = abs(p_fake - threshold)
318
+ if distance < 0.10:
319
+ return "uncertain"
320
+ elif distance < 0.25:
321
+ return "low"
322
+ elif distance < 0.40:
323
+ return "medium"
324
+ else:
325
+ return "high"
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # /analyze endpoint
330
+ # ---------------------------------------------------------------------------
331
+ @app.route("/")
332
+ def index():
333
+ return "OK", 200
334
+
335
+ @app.route("/analyze", methods=["POST"])
336
+ def analyze():
337
+ if "image" not in request.files:
338
+ return jsonify({"error": "No image provided."}), 400
339
+
340
+ try:
341
+ raw = request.files["image"].read()
342
+ pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
343
+ img_rgb = np.array(pil_img)
344
+ img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
345
+
346
+ # ── Feature extraction ─────────────────────────────────────────────
347
+ features, spectral = extract_combined_features(img_bgr)
348
+ feat_2d = features.reshape(1, -1)
349
+
350
+ # ── Classification ─────────────────────────────────────────────────
351
+ if USE_SKL:
352
+ # sklearn Pipeline handles scaling internally
353
+ p_fake = float(skl_clf.predict_proba(feat_2d)[0, 1])
354
+ p_real = 1.0 - p_fake
355
+ is_fake = p_fake >= 0.5
356
+
357
+ else:
358
+ # ONNX
359
+ scaled = (
360
+ feature_scaler.transform(feat_2d).astype(np.float32)
361
+ if feature_scaler is not None
362
+ else feat_2d.astype(np.float32)
363
+ )
364
+ label_arr, prob_list = svm_session.run(None, {SVM_INPUT_NAME: scaled})
365
+ p_fake_raw = float(prob_list[0][1])
366
+
367
+ # Temperature scaling (smooths isotonic step-function probabilities)
368
+ eps = 1e-6
369
+ p_clip = float(np.clip(p_fake_raw, eps, 1.0 - eps))
370
+ logit_p = np.log(p_clip / (1.0 - p_clip))
371
+ p_fake = float(1.0 / (1.0 + np.exp(-logit_p / CALIBRATION_TEMPERATURE)))
372
+ p_real = 1.0 - p_fake
373
+ is_fake = bool(int(label_arr[0]) == 1)
374
+
375
+ confidence = get_confidence_level(p_fake)
376
+ manip_types = attribute_type(features) if is_fake else []
377
+ spectral_flags = analyze_spectral_profile(spectral["az"], spectral["nz"])
378
+ spectral_summary = ""
379
+ explanation = build_explanation(
380
+ is_fake, p_fake, manip_types, spectral_flags
381
+ )
382
+
383
+ manip_label = None
384
+ manip_scores = []
385
+
386
+ if is_fake:
387
+ if manip_types:
388
+ top_type, _ = manip_types[0]
389
+ manip_label = MANIPULATION_LABELS.get(top_type, top_type)
390
+ manip_scores = [
391
+ {
392
+ "type": t,
393
+ "label": MANIPULATION_LABELS.get(t, t),
394
+ "confidence": round(p, 1),
395
+ }
396
+ for t, p in manip_types[:5]
397
+ ]
398
+ elif spectral_flags:
399
+ if "high_freq_elevation" in spectral_flags or "spectral_oscillation" in spectral_flags:
400
+ manip_label = "GAN Synthesis"
401
+ elif "noise_floor_elevated" in spectral_flags:
402
+ manip_label = "Face Swap / Re-encoding"
403
+ elif "mid_band_flat" in spectral_flags:
404
+ manip_label = "AI Filter / Smoothing"
405
+ else:
406
+ manip_label = "AI Manipulation Type Unknown"
407
+
408
+ print(
409
+ f"DEBUG: p_fake={p_fake:.4f} label={'FAKE' if is_fake else 'REAL'} "
410
+ f"confidence={confidence} manip={manip_label}"
411
+ )
412
+
413
+ return jsonify({
414
+ "probability": round(p_fake * 100, 2),
415
+ "label": "AI Generated / Fake" if is_fake else "Authentic Media",
416
+ "is_fake": is_fake,
417
+ "confidence": confidence,
418
+ "p_real": round(p_real * 100, 2),
419
+ "p_fake": round(p_fake * 100, 2),
420
+ "explanation": explanation,
421
+ "manipulation_type": manip_label,
422
+ "manipulation_scores": manip_scores,
423
+ "spectral_flags": spectral_flags,
424
+ "spectral_summary": spectral_summary,
425
+ "threshold_used": 0.5 if USE_SKL else DECISION_THRESHOLD,
426
+ })
427
+
428
+ except Exception as exc:
429
+ import traceback
430
+ traceback.print_exc()
431
+ return jsonify({"error": f"Analysis failed: {exc}"}), 500
432
+
433
+
434
+ if __name__ == "__main__":
435
  app.run(host="0.0.0.0", port=7860, debug=False)