Files changed (6) hide show
  1. Dockerfile +13 -13
  2. app.py +431 -229
  3. best_model_v5.pth +3 -0
  4. features_v5.npz +3 -0
  5. rbf_svm_v5.onnx +3 -0
  6. requirements.txt +8 -8
Dockerfile CHANGED
@@ -1,13 +1,13 @@
1
- FROM python:3.10-slim
2
-
3
- WORKDIR /app
4
-
5
- COPY . .
6
-
7
- RUN pip install --no-cache-dir flask flask-cors onnxruntime numpy Pillow scipy torch timm opencv-python-headless scikit-learn joblib
8
-
9
- ENV PORT=7860
10
-
11
- EXPOSE 7860
12
-
13
- CMD ["python", "app.py"]
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . .
6
+
7
+ RUN pip install --no-cache-dir flask flask-cors onnxruntime numpy Pillow scipy torch timm opencv-python-headless scikit-learn joblib
8
+
9
+ ENV PORT=7860
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["python", "app.py"]
app.py CHANGED
@@ -1,229 +1,431 @@
1
- """
2
- Deepfake detector backend β€” with tuned threshold.
3
-
4
- Pipeline:
5
- image (BGR)
6
- β”œβ”€β”€ 4-channel tensor (RGB normalized + FFT power spectrum) β†’ EfficientNet-B0 β†’ 1280
7
- β”œβ”€β”€ FFT azimuthal avg at 256Γ—256 β†’ 128
8
- └── noise (gray - GaussianBlur) FFT azimuthal avg at 256Γ—256 β†’ 128
9
- ────
10
- 1536 β†’ StandardScaler β†’ SVM (ONNX)
11
-
12
- IMPORTANT: The SVM was trained on StandardScaler-normalized features.
13
- scaler.pkl must be present in the backend folder.
14
- Without it, predictions collapse to 0%/100%.
15
-
16
- Threshold tuned to 0.35 based on independent test set analysis.
17
-
18
- Run:
19
- pip install flask flask-cors onnxruntime torch timm opencv-python-headless scipy numpy pillow scikit-learn
20
- python app.py
21
- """
22
-
23
- from flask import Flask, request, jsonify
24
- from flask_cors import CORS
25
- import onnxruntime as ort
26
- import numpy as np
27
- import cv2
28
- import io
29
- import torch
30
- import torch.nn as nn
31
- import timm
32
- from torchvision import transforms
33
- from scipy import ndimage
34
- from PIL import Image
35
- import joblib
36
- import os
37
-
38
- app = Flask(__name__)
39
- CORS(app)
40
-
41
- # ---------------------------------------------------------------------------
42
- # Config
43
- # ---------------------------------------------------------------------------
44
- FFT_SIZE = 256
45
- AZ_BINS = FFT_SIZE // 2 # 128
46
-
47
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
48
- CNN_WEIGHTS_PATH = os.path.join(BASE_DIR, "best_model.pth")
49
- SVM_ONNX_PATH = os.path.join(BASE_DIR, "svm_linear_model.onnx")
50
- SCALER_PATH = os.path.join(BASE_DIR, "scaler.pkl")
51
-
52
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
53
-
54
- DECISION_THRESHOLD = 0.35
55
- CALIBRATION_TEMPERATURE = 3.0
56
-
57
- # ---------------------------------------------------------------------------
58
- # CNN feature extractor (4-channel EfficientNet-B0)
59
- # ---------------------------------------------------------------------------
60
- class DeepfakeDetector(nn.Module):
61
- def __init__(self):
62
- super().__init__()
63
- self.backbone = timm.create_model("efficientnet_b0", pretrained=False, num_classes=0)
64
- old_conv = self.backbone.conv_stem
65
- new_conv = nn.Conv2d(
66
- 4, old_conv.out_channels,
67
- kernel_size=old_conv.kernel_size,
68
- stride=old_conv.stride,
69
- padding=old_conv.padding,
70
- bias=old_conv.bias is not None,
71
- )
72
- self.backbone.conv_stem = new_conv
73
- self.head = nn.Sequential(
74
- nn.Dropout(0.3), nn.Linear(1280, 256), nn.ReLU(),
75
- nn.Dropout(0.2), nn.Linear(256, 1),
76
- )
77
-
78
- def forward(self, x):
79
- return self.backbone(x)
80
-
81
-
82
- feature_model = DeepfakeDetector().to(DEVICE)
83
- state = torch.load(CNN_WEIGHTS_PATH, map_location=DEVICE, weights_only=False)
84
- feature_model.load_state_dict(state, strict=False)
85
- feature_model.eval()
86
- print("βœ… CNN feature extractor loaded")
87
-
88
- svm_session = ort.InferenceSession(SVM_ONNX_PATH, providers=["CPUExecutionProvider"])
89
- SVM_INPUT_NAME = svm_session.get_inputs()[0].name
90
- print(f"βœ… SVM ONNX loaded β€” input '{SVM_INPUT_NAME}', expects {svm_session.get_inputs()[0].shape}")
91
- print(f"βœ… Decision threshold: {DECISION_THRESHOLD} (lowered from default 0.5)")
92
- print("\n--- SVM ONNX Outputs ---")
93
- for out in svm_session.get_outputs():
94
- print(f" name={out.name!r} shape={out.shape} type={out.type}")
95
- print("------------------------\n")
96
-
97
- if os.path.exists(SCALER_PATH):
98
- feature_scaler = joblib.load(SCALER_PATH)
99
- print(f"βœ… StandardScaler loaded from {SCALER_PATH}")
100
- print(f" mean range: [{feature_scaler.mean_.min():.3f}, {feature_scaler.mean_.max():.3f}]")
101
- print(f" scale range: [{feature_scaler.scale_.min():.3f}, {feature_scaler.scale_.max():.3f}]")
102
- else:
103
- feature_scaler = None
104
- print(f"⚠️ WARNING: {SCALER_PATH} not found β€” predictions will be 0% or 100%!")
105
-
106
- torch_transform = transforms.Compose([
107
- transforms.ToPILImage(),
108
- transforms.Resize((224, 224)),
109
- transforms.ToTensor(),
110
- transforms.Normalize(mean=[0.485, 0.456, 0.406],
111
- std=[0.229, 0.224, 0.225]),
112
- ])
113
-
114
-
115
- def compute_azimuthal_average(spectrum_2d: np.ndarray) -> np.ndarray:
116
- h, w = spectrum_2d.shape
117
- cy, cx = h // 2, w // 2
118
- Y, X = np.ogrid[:h, :w]
119
- r = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2).astype(int)
120
- max_r = min(cy, cx)
121
- return ndimage.mean(spectrum_2d, labels=r, index=np.arange(0, max_r))
122
-
123
-
124
- def extract_combined_features(img_bgr: np.ndarray) -> np.ndarray:
125
- img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
126
- tensor_3ch = torch_transform(img_rgb)
127
-
128
- gray224 = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
129
- gray224 = cv2.resize(gray224, (224, 224)).astype(np.float32)
130
- f = np.fft.fft2(gray224)
131
- f_shift = np.fft.fftshift(f)
132
- ps = np.log1p(np.abs(f_shift) ** 2)
133
- ps_norm = (ps - ps.min()) / (ps.max() - ps.min() + 1e-8)
134
- fft_ch = torch.tensor(ps_norm, dtype=torch.float32).unsqueeze(0)
135
- tensor_4ch = torch.cat([tensor_3ch, fft_ch], dim=0).unsqueeze(0).to(DEVICE)
136
-
137
- with torch.no_grad():
138
- cnn_feat = feature_model(tensor_4ch).cpu().numpy().flatten()
139
-
140
- gray = cv2.resize(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY),
141
- (FFT_SIZE, FFT_SIZE)).astype(np.float32)
142
-
143
- f2 = np.fft.fft2(gray)
144
- f2_shift = np.fft.fftshift(f2)
145
- power = np.log1p(np.abs(f2_shift) ** 2)
146
- az_avg = compute_azimuthal_average(power)
147
-
148
- blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
149
- noise = gray - blurred
150
- nf = np.fft.fft2(noise)
151
- nf_shift = np.fft.fftshift(nf)
152
- noise_power = np.log1p(np.abs(nf_shift) ** 2)
153
- noise_az = compute_azimuthal_average(noise_power)
154
-
155
- combined = np.concatenate([cnn_feat, az_avg, noise_az]).astype(np.float32)
156
- assert combined.shape == (1536,), f"Bad feature dim: {combined.shape}"
157
- return combined
158
-
159
-
160
- def get_confidence_level(p_fake: float) -> str:
161
- distance = abs(p_fake - DECISION_THRESHOLD)
162
- if distance < 0.10:
163
- return "uncertain"
164
- elif distance < 0.25:
165
- return "low"
166
- elif distance < 0.40:
167
- return "medium"
168
- else:
169
- return "high"
170
-
171
-
172
- @app.route("/", methods=["GET"])
173
- def health():
174
- return jsonify({"status": "ok", "message": "Deepfake detector backend is running."})
175
-
176
-
177
- @app.route("/analyze", methods=["POST"])
178
- def analyze():
179
- if "image" not in request.files:
180
- return jsonify({"error": "No image"}), 400
181
-
182
- try:
183
- raw = request.files["image"].read()
184
- pil = Image.open(io.BytesIO(raw)).convert("RGB")
185
- img_rgb = np.array(pil)
186
- img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
187
-
188
- features = extract_combined_features(img_bgr).reshape(1, -1)
189
-
190
- if feature_scaler is not None:
191
- features = feature_scaler.transform(features).astype(np.float32)
192
-
193
- label_arr, prob_list = svm_session.run(None, {SVM_INPUT_NAME: features})
194
- for out in svm_session.get_outputs():
195
- print(out.name, out.shape, out.type)
196
- prob_dict = prob_list[0]
197
- p_fake_raw = float(prob_dict[1])
198
-
199
- eps = 1e-6
200
- p_clipped = float(np.clip(p_fake_raw, eps, 1.0 - eps))
201
- logit_p = np.log(p_clipped / (1.0 - p_clipped))
202
- p_fake = float(1.0 / (1.0 + np.exp(-logit_p / CALIBRATION_TEMPERATURE)))
203
- p_real = 1.0 - p_fake
204
-
205
- is_fake = bool(int(label_arr[0]) == 1)
206
- confidence = get_confidence_level(p_fake)
207
-
208
- display_percent = round(p_fake * 100, 2)
209
- print(f"DEBUG: raw={p_fake_raw:.4f} temp_scaled={p_fake:.4f} "
210
- f"label={'FAKE' if is_fake else 'REAL'} confidence={confidence}")
211
-
212
- return jsonify({
213
- "probability": display_percent,
214
- "label": "AI Generated / Fake" if is_fake else "Authentic Media",
215
- "is_fake": is_fake,
216
- "confidence": confidence,
217
- "p_real": round(p_real * 100, 2),
218
- "p_fake": round(p_fake * 100, 2),
219
- "threshold_used": DECISION_THRESHOLD,
220
- })
221
-
222
- except Exception as e:
223
- import traceback; traceback.print_exc()
224
- return jsonify({"error": f"Failed: {e}"}), 500
225
-
226
-
227
- if __name__ == "__main__":
228
- port = int(os.environ.get("PORT", 5000))
229
- app.run(host="0.0.0.0", port=port, 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("/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(port=5000, debug=False)
best_model_v5.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:102862de59fce8bdc15cac6a2051ca08c07b0bdcf78d9b744777315dd427c763
3
+ size 17647947
features_v5.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2d3f9894d35be3eaad954519c7e0572aa2b54c1459f19cb33dfdc70507c4681
3
+ size 488851454
rbf_svm_v5.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b3727241fb5ad29a6e82f52006aafa7abd750b7604eaeaa24d1b5552d73f1842
3
+ size 9445459
requirements.txt CHANGED
@@ -1,9 +1,9 @@
1
- flask
2
- flask-cors
3
- onnxruntime
4
- numpy
5
- Pillow
6
- scipy
7
- torch
8
- timm
9
  opencv-python-headless
 
1
+ flask
2
+ flask-cors
3
+ onnxruntime
4
+ numpy
5
+ Pillow
6
+ scipy
7
+ torch
8
+ timm
9
  opencv-python-headless