Vi0509 commited on
Commit
00a14ec
·
verified ·
1 Parent(s): c9f1a28

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +419 -481
app.py CHANGED
@@ -1,9 +1,9 @@
1
  """Kaeva Verify V10 — Ensemble Deepfake Detection API.
2
 
3
  Endpoints:
4
- POST /image — V10 ensemble deepfake image detection (platform-aware)
5
- POST /audio — Wav2Vec2 V10 deepfake audio detection (3-class: real/tts/vc)
6
- POST /video — Video frame + audio deepfake analysis
7
  GET /health — Health check
8
  """
9
 
@@ -11,428 +11,367 @@ import io, os, traceback, tempfile, subprocess, time, json
11
  import numpy as np
12
  import torch
13
  import torch.nn as nn
14
- import torch.nn.functional as F
15
  from fastapi import FastAPI, UploadFile, File, HTTPException, Query
16
  from fastapi.middleware.cors import CORSMiddleware
17
  from PIL import Image
18
- from torchvision.models import efficientnet_b0, efficientnet_b3, resnet18
 
 
 
19
 
20
- app = FastAPI(title="Kaeva Verify V10", version="2.0.0")
21
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
22
 
23
- # ──────────────────────────────────────────────
24
- # Ensemble configs per platform
25
- # ──────────────────────────────────────────────
26
  ENSEMBLE_CONFIGS = {
27
  "clean": {
28
  "models": ["image_ensemble_v2", "ai_gen", "spectral", "frequency"],
29
  "weights": [0.286, 0.357, 0.214, 0.143],
30
- "threshold": 0.45,
31
  },
32
  "whatsapp": {
33
  "models": ["image_ensemble_v2"],
34
  "weights": [1.0],
35
- "threshold": 0.65,
36
  },
37
  "instagram": {
38
  "models": ["image_ensemble_v2", "ai_gen"],
39
  "weights": [0.6, 0.4],
40
- "threshold": 0.60,
41
  },
42
  "telegram": {
43
  "models": ["image_ensemble_v2", "ai_gen"],
44
  "weights": [0.75, 0.25],
45
- "threshold": 0.60,
46
  },
47
  "screenshot": {
48
  "models": ["image_ensemble_v2", "spectral"],
49
  "weights": [0.667, 0.333],
50
- "threshold": 0.45,
51
- },
52
  }
53
 
54
- # ──────────────────────────────────────────────
55
- # Image models (lazy loaded)
56
- # ──────────────────────────────────────────────
57
- _models = {}
58
- IMG_SIZE = 256
59
- RADIAL_BINS = 128
60
- SPECTRUM_SIZE = 64
61
-
62
- def _get_transform():
63
- """Standard transform for EfficientNet models."""
64
- from torchvision import transforms
65
- return transforms.Compose([
66
- transforms.Resize((224, 224)),
67
- transforms.ToTensor(),
68
- transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
69
- ])
70
 
71
- _transform = None
72
- def get_transform():
73
- global _transform
74
- if _transform is None:
75
- _transform = _get_transform()
76
- return _transform
77
 
78
 
 
 
 
79
  def load_image_ensemble_v2():
80
- if "image_ensemble_v2" in _models:
81
- return _models["image_ensemble_v2"]
82
  print("Loading image_ensemble_v2 (EfficientNet-B0)...", flush=True)
83
  model = efficientnet_b0(weights=None)
84
  model.classifier[1] = nn.Linear(1280, 2)
85
- ckpt = torch.load("image_ensemble_v2.pt", map_location="cpu", weights_only=False)
86
- model.load_state_dict(ckpt)
 
 
87
  model.eval()
88
- _models["image_ensemble_v2"] = model
89
- print("image_ensemble_v2 loaded.", flush=True)
90
- return model
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def load_ai_gen():
94
- if "ai_gen" in _models:
95
- return _models["ai_gen"]
96
- print("Loading ai_gen_detector (EfficientNet-B3)...", flush=True)
97
  model = efficientnet_b3(weights=None)
98
- model.classifier[1] = nn.Linear(model.classifier[1].in_features, 2)
99
- ckpt = torch.load("ai_gen_detector.pt", map_location="cpu", weights_only=False)
100
- model.load_state_dict(ckpt["model_state_dict"])
 
101
  model.eval()
102
- _models["ai_gen"] = model
103
- print("ai_gen loaded.", flush=True)
104
- return model
105
-
106
-
107
- # ──────── Spectral model ────────
108
 
109
- class RadialCNN1D(nn.Module):
110
- def __init__(self, input_len=RADIAL_BINS, embed_dim=128):
111
- super().__init__()
112
- self.net = nn.Sequential(
113
- nn.Conv1d(1, 32, kernel_size=7, padding=3),
114
- nn.BatchNorm1d(32), nn.ReLU(inplace=True), nn.MaxPool1d(2),
115
- nn.Conv1d(32, 64, kernel_size=5, padding=2),
116
- nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(2),
117
- nn.Conv1d(64, 128, kernel_size=3, padding=1),
118
- nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool1d(4),
119
- )
120
- self.fc = nn.Linear(128 * 4, embed_dim)
121
 
122
- def forward(self, x):
123
- x = x.unsqueeze(1)
124
- x = self.net(x)
125
- x = x.flatten(1)
126
- return self.fc(x)
 
 
127
 
128
 
129
- class DualStreamSpectralDetector(nn.Module):
130
- def __init__(self, embed_dim=256, radial_bins=RADIAL_BINS):
 
 
 
 
131
  super().__init__()
132
- r = resnet18(weights=None)
133
- r.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
134
- r.fc = nn.Linear(r.fc.in_features, embed_dim)
135
- self.stream_a = r
136
- self.stream_b = RadialCNN1D(input_len=radial_bins, embed_dim=embed_dim // 2)
137
- fused_dim = embed_dim + embed_dim // 2
138
- self.classifier = nn.Sequential(
139
- nn.LayerNorm(fused_dim), nn.Dropout(0.3),
140
- nn.Linear(fused_dim, 128), nn.ReLU(inplace=True),
141
- nn.Dropout(0.2), nn.Linear(128, 1),
 
 
 
142
  )
143
 
144
- def forward(self, spectrum_img, radial):
145
- a = self.stream_a(spectrum_img)
146
- b = self.stream_b(radial)
147
- fused = torch.cat([a, b], dim=1)
148
- return self.classifier(fused).squeeze(-1)
149
-
150
-
151
- def compute_dct_spectrum(img_gray):
152
- from scipy.fft import dctn
153
- dct = dctn(img_gray.astype(np.float64), type=2, norm="ortho")
154
- log_ps = np.log1p(dct ** 2)
155
- mx = log_ps.max()
156
- if mx > 0:
157
- log_ps /= mx
158
- return log_ps.astype(np.float32)
159
-
160
-
161
- def compute_radial_profile(img_gray, num_bins=RADIAL_BINS):
162
- f = np.fft.fft2(img_gray.astype(np.float64))
163
- f_shift = np.fft.fftshift(f)
164
- magnitude = np.log1p(np.abs(f_shift))
165
- h, w = magnitude.shape
166
- cy, cx = h // 2, w // 2
167
- y, x = np.ogrid[:h, :w]
168
- r = np.sqrt((x - cx) ** 2 + (y - cy) ** 2)
169
- max_r = np.sqrt(cx ** 2 + cy ** 2)
170
- bin_edges = np.linspace(0, max_r, num_bins + 1)
171
- profile = np.zeros(num_bins, dtype=np.float32)
172
- for i in range(num_bins):
173
- mask = (r >= bin_edges[i]) & (r < bin_edges[i + 1])
174
- vals = magnitude[mask]
175
- profile[i] = vals.mean() if vals.size > 0 else 0.0
176
- mx = profile.max()
177
- if mx > 0:
178
- profile /= mx
179
- return profile
180
 
181
 
182
  def load_spectral():
183
- if "spectral" in _models:
184
- return _models["spectral"]
185
- print("Loading spectral_detector (DualStream)...", flush=True)
186
- model = DualStreamSpectralDetector()
187
- ckpt = torch.load("spectral_detector.pt", map_location="cpu", weights_only=False)
188
- model.load_state_dict(ckpt["model"])
 
189
  model.eval()
190
- _models["spectral"] = model
191
- print("spectral loaded.", flush=True)
192
- return model
193
-
194
-
195
- # ──────── Frequency analyzer model ────────
196
-
197
- BENFORD_EXPECTED = np.array([np.log10(1 + 1/d) for d in range(1, 10)])
198
- BENFORD_DIM = 18
199
- DCT_STATS_DIM = 10
200
- WAVELET_DIM = 26
201
- HANDCRAFTED_DIM = 54
202
-
203
-
204
- def extract_dct_benford_features(img_gray):
205
- from scipy.fft import dctn
206
- h, w = img_gray.shape
207
- all_coeffs = []
208
- block_size = 8
209
- for i in range(0, h - block_size + 1, block_size):
210
- for j in range(0, w - block_size + 1, block_size):
211
- block = img_gray[i:i+block_size, j:j+block_size].astype(np.float64)
212
- dct_block = dctn(block, type=2, norm='ortho')
213
- coeffs = dct_block.flatten()[1:]
214
- all_coeffs.extend(coeffs)
215
- all_coeffs = np.abs(np.array(all_coeffs))
216
- all_coeffs = all_coeffs[all_coeffs > 0]
217
- if len(all_coeffs) < 100:
218
- return np.zeros(18, dtype=np.float32)
219
- first_digits = np.floor(all_coeffs / (10 ** np.floor(np.log10(all_coeffs)))).astype(int)
220
- first_digits = first_digits[(first_digits >= 1) & (first_digits <= 9)]
221
- if len(first_digits) < 100:
222
- return np.zeros(18, dtype=np.float32)
223
- observed = np.zeros(9, dtype=np.float64)
224
- for d in range(1, 10):
225
- observed[d-1] = np.sum(first_digits == d) / len(first_digits)
226
- deviation = (observed - BENFORD_EXPECTED) ** 2 / (BENFORD_EXPECTED + 1e-10)
227
- return np.concatenate([observed.astype(np.float32), deviation.astype(np.float32)])
228
-
229
-
230
- def extract_dct_block_stats(img_gray):
231
- from scipy.fft import dctn
232
- h, w = img_gray.shape
233
- block_size = 8
234
- energy_map = np.zeros((block_size, block_size), dtype=np.float64)
235
- count = 0
236
- for i in range(0, h - block_size + 1, block_size):
237
- for j in range(0, w - block_size + 1, block_size):
238
- block = img_gray[i:i+block_size, j:j+block_size].astype(np.float64)
239
- dct_block = dctn(block, type=2, norm='ortho')
240
- energy_map += dct_block ** 2
241
- count += 1
242
- if count > 0:
243
- energy_map /= count
244
- total = energy_map.sum() + 1e-10
245
- energy_map /= total
246
- low = energy_map[:4, :4].sum()
247
- mid = energy_map.sum() - low - energy_map[4:, 4:].sum()
248
- high = energy_map[4:, 4:].sum()
249
- flat = energy_map.flatten()
250
- return np.array([low, mid, high, flat.std(), np.median(flat),
251
- flat[0], flat[-1], np.log1p(flat.max()), np.log1p(flat.min()),
252
- np.sum(flat > 0.01)], dtype=np.float32)
253
-
254
-
255
- def extract_wavelet_features(img_gray):
256
- import pywt
257
- features = []
258
- for wavelet in ['haar', 'db4']:
259
- coeffs = pywt.wavedec2(img_gray.astype(np.float64), wavelet, level=3)
260
- approx_energy = np.sum(coeffs[0] ** 2)
261
- total_energy = approx_energy
262
- detail_energies = []
263
- for level_coeffs in coeffs[1:]:
264
- level_energy = sum(np.sum(c ** 2) for c in level_coeffs)
265
- detail_energies.append(level_energy)
266
- total_energy += level_energy
267
- total_energy = max(total_energy, 1e-10)
268
- features.append(approx_energy / total_energy)
269
- for de in detail_energies:
270
- features.append(de / total_energy)
271
- finest = coeffs[-1]
272
- for c in finest:
273
- features.extend([
274
- np.std(c),
275
- np.mean(np.abs(c)),
276
- float(np.sum(np.abs(c) > np.std(c) * 3)) / max(c.size, 1),
277
- ])
278
- return np.array(features, dtype=np.float32)
279
-
280
-
281
- class FrequencyAnalyzerModel(nn.Module):
282
- def __init__(self, handcrafted_dim=HANDCRAFTED_DIM, spectrum_size=SPECTRUM_SIZE):
283
- super().__init__()
284
- self.mlp = nn.Sequential(
285
- nn.Linear(handcrafted_dim, 128), nn.BatchNorm1d(128),
286
- nn.ReLU(inplace=True), nn.Dropout(0.3),
287
- nn.Linear(128, 64), nn.ReLU(inplace=True),
288
- )
289
- self.cnn = nn.Sequential(
290
- nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2),
291
- nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2),
292
- nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(4),
293
- )
294
- self.cnn_fc = nn.Linear(128 * 4 * 4, 128)
295
- self.classifier = nn.Sequential(
296
- nn.LayerNorm(64 + 128), nn.Dropout(0.3),
297
- nn.Linear(64 + 128, 64), nn.ReLU(inplace=True),
298
- nn.Dropout(0.2), nn.Linear(64, 1),
299
- )
300
 
301
- def forward(self, handcrafted, spectrum):
302
- h = self.mlp(handcrafted)
303
- s = self.cnn(spectrum)
304
- s = s.flatten(1)
305
- s = self.cnn_fc(s)
306
- fused = torch.cat([h, s], dim=1)
307
- return self.classifier(fused).squeeze(-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
 
310
- def extract_dct_spectrum_image(img_gray, size=SPECTRUM_SIZE):
311
- from scipy.fft import dctn
312
- dct = dctn(img_gray.astype(np.float64), type=2, norm='ortho')
313
- log_ps = np.log1p(np.abs(dct))
314
- mx = log_ps.max()
315
- if mx > 0:
316
- log_ps /= mx
317
- img = Image.fromarray((log_ps * 255).astype(np.uint8))
318
- img = img.resize((size, size), Image.LANCZOS)
319
- return np.array(img, dtype=np.float32) / 255.0
 
 
 
 
 
 
 
 
 
320
 
321
 
322
  def load_frequency():
323
- if "frequency" in _models:
324
- return _models["frequency"]
325
- print("Loading frequency_analyzer...", flush=True)
326
- model = FrequencyAnalyzerModel()
327
- ckpt = torch.load("frequency_analyzer.pt", map_location="cpu", weights_only=False)
328
- model.load_state_dict(ckpt["model"])
 
 
 
329
  model.eval()
330
- _models["frequency"] = model
331
- print("frequency loaded.", flush=True)
332
- return model
333
-
334
-
335
- # ──────── Model inference functions ────────
336
-
337
- def infer_efficientnet(model, img_pil):
338
- """Run inference on an EfficientNet model, return fake probability."""
339
- tensor = get_transform()(img_pil).unsqueeze(0)
340
- with torch.no_grad():
341
- logits = model(tensor)
342
- probs = torch.softmax(logits, dim=-1)
343
- return float(probs[0][1]) # fake class probability
344
-
345
-
346
- def infer_spectral(model, img_pil):
347
- """Run spectral model on image."""
348
- gray = np.array(img_pil.convert("L").resize((IMG_SIZE, IMG_SIZE), Image.LANCZOS))
349
- spectrum = compute_dct_spectrum(gray)
350
- radial = compute_radial_profile(gray)
351
- spec_tensor = torch.from_numpy(spectrum).unsqueeze(0).unsqueeze(0) # (1,1,H,W)
352
- rad_tensor = torch.from_numpy(radial).unsqueeze(0) # (1, bins)
353
- with torch.no_grad():
354
- logit = model(spec_tensor, rad_tensor)
355
- return float(torch.sigmoid(logit).item())
356
 
357
 
358
- def infer_frequency(model, img_pil):
359
- """Run frequency analyzer on image."""
360
- gray = np.array(img_pil.convert("L").resize((IMG_SIZE, IMG_SIZE), Image.LANCZOS))
361
- benford = extract_dct_benford_features(gray)
362
- dct_stats = extract_dct_block_stats(gray)
363
- wavelet = extract_wavelet_features(gray)
364
- handcrafted = np.concatenate([benford, dct_stats, wavelet])
365
- spectrum_img = extract_dct_spectrum_image(gray)
366
-
367
- hc_tensor = torch.from_numpy(handcrafted).unsqueeze(0)
368
- sp_tensor = torch.from_numpy(spectrum_img).unsqueeze(0).unsqueeze(0) # (1,1,64,64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  with torch.no_grad():
370
- logit = model(hc_tensor, sp_tensor)
371
- return float(torch.sigmoid(logit).item())
 
372
 
373
 
374
- # Model loader dispatch
375
- MODEL_LOADERS = {
376
- "image_ensemble_v2": load_image_ensemble_v2,
377
- "ai_gen": load_ai_gen,
378
- "spectral": load_spectral,
379
- "frequency": load_frequency,
380
- }
381
-
382
- MODEL_INFER = {
383
- "image_ensemble_v2": lambda img: infer_efficientnet(load_image_ensemble_v2(), img),
384
- "ai_gen": lambda img: infer_efficientnet(load_ai_gen(), img),
385
- "spectral": lambda img: infer_spectral(load_spectral(), img),
386
- "frequency": lambda img: infer_frequency(load_frequency(), img),
387
- }
388
-
389
-
390
- # ──────────────────────────────────────────────
391
- # Audio Model V10
392
- # ──────────────────────────────────────────────
393
- audio_model_v10 = None
394
  audio_feature_extractor = None
 
395
 
396
- class AudioModelV10(nn.Module):
397
- """Wav2Vec2 backbone + classifier head for 3-class audio deepfake detection."""
398
- def __init__(self, num_classes=3):
399
  super().__init__()
400
- from transformers import Wav2Vec2Model
401
- self.backbone = Wav2Vec2Model.from_pretrained("facebook/wav2vec2-base")
402
- self.head = nn.Sequential(
403
- nn.LayerNorm(768),
404
- nn.Linear(768, 256),
405
- nn.ReLU(inplace=True),
406
  nn.Dropout(0.3),
407
- nn.Linear(256, 128),
408
- nn.ReLU(inplace=True),
409
  nn.Dropout(0.2),
410
- nn.Linear(128, num_classes),
411
  )
412
-
413
- def forward(self, input_values):
414
- outputs = self.backbone(input_values)
415
- hidden = outputs.last_hidden_state.mean(dim=1)
416
- return self.head(hidden)
417
 
418
 
419
- def load_audio_v10():
420
- global audio_model_v10, audio_feature_extractor
421
- if audio_model_v10 is not None:
422
  return
423
- print("Loading Audio V10 model...", flush=True)
424
- from transformers import Wav2Vec2FeatureExtractor
425
  audio_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained("facebook/wav2vec2-base")
426
- audio_model_v10 = AudioModelV10(num_classes=3)
427
- ckpt = torch.load("audio_deepfake_v10.pt", map_location="cpu", weights_only=False)
428
- audio_model_v10.load_state_dict(ckpt["model_state_dict"])
429
- audio_model_v10.eval()
430
- print("Audio V10 loaded.", flush=True)
431
-
432
-
433
- # ──────────────────────────────────────────────
434
- # CLIP (legacy, for /video endpoint)
435
- # ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
436
  clip_model = None
437
  clip_preprocess = None
438
  clip_probe = None
@@ -442,82 +381,95 @@ def load_clip():
442
  if clip_model is not None:
443
  return
444
  if not os.path.exists("clip_deepfake_probe.pt"):
445
- print("CLIP probe not found, skipping.", flush=True)
446
  return
447
  print("Loading CLIP ViT-L/14...", flush=True)
448
  import open_clip
449
  clip_model, _, clip_preprocess = open_clip.create_model_and_transforms('ViT-L-14', pretrained='openai')
450
  clip_model.eval()
451
  clip_probe = torch.load("clip_deepfake_probe.pt", map_location="cpu", weights_only=False)
452
- print(f"CLIP probe loaded. Val accuracy: {clip_probe['val_accuracy']:.4f}", flush=True)
453
 
454
 
455
- # ──────────────────────────────────────────────
456
- # Endpoints
457
- # ──────────────���───────────────────────────────
458
 
459
  @app.get("/health")
460
  def health():
461
  return {
462
  "status": "ok",
463
- "version": "2.0.0-v10",
464
- "models": ["image_ensemble_v2", "ai_gen", "spectral", "frequency", "audio_v10", "clip-legacy"],
465
- "endpoints": ["/image", "/audio", "/video"],
466
  "platforms": list(ENSEMBLE_CONFIGS.keys()),
 
467
  }
468
 
469
 
470
  @app.post("/image")
471
  async def analyze_image(
472
  file: UploadFile = File(...),
473
- platform: str = Query("clean", description="Platform: clean, whatsapp, instagram, telegram, screenshot"),
474
  ):
475
- """Analyze image with V10 ensemble, platform-aware weighting."""
476
  try:
477
- platform = platform.lower()
478
- if platform not in ENSEMBLE_CONFIGS:
479
- platform = "clean"
480
- config = ENSEMBLE_CONFIGS[platform]
481
-
482
  data = await file.read()
483
  img = Image.open(io.BytesIO(data)).convert("RGB")
484
-
 
 
 
 
 
485
  # Run each model in the ensemble
486
  ensemble_scores = {}
487
- for model_name in config["models"]:
488
- if model_name not in MODEL_INFER:
489
- continue
490
- score = MODEL_INFER[model_name](img)
491
- ensemble_scores[model_name] = round(score, 4)
492
-
493
- # Weighted combination
494
- weighted_score = 0.0
495
- total_weight = 0.0
496
- for model_name, weight in zip(config["models"], config["weights"]):
497
- if model_name in ensemble_scores:
498
- weighted_score += weight * ensemble_scores[model_name]
499
- total_weight += weight
500
-
 
 
 
 
 
 
 
 
 
 
 
501
  if total_weight > 0:
502
- weighted_score /= total_weight
503
-
504
- threshold = config["threshold"]
505
- verdict = "fake" if weighted_score > threshold else "real"
506
- fake_prob = weighted_score
507
- real_prob = 1.0 - fake_prob
508
-
 
509
  return {
510
  "verdict": verdict,
511
- "confidence": round(max(fake_prob, real_prob), 4),
512
  "scores": {
513
  "real": round(real_prob, 4),
514
- "fake": round(fake_prob, 4),
515
  },
516
- "ensemble_scores": ensemble_scores,
517
  "platform": platform,
518
- "models_used": config["models"],
519
  "threshold": threshold,
520
  "model": "kaeva-v10-ensemble",
 
521
  }
522
  except Exception as e:
523
  traceback.print_exc()
@@ -526,42 +478,35 @@ async def analyze_image(
526
 
527
  @app.post("/audio")
528
  async def analyze_audio(file: UploadFile = File(...)):
529
- """Analyze audio for deepfake (3-class: real/tts/vc)."""
530
  try:
531
- load_audio_v10()
532
- import librosa
533
  data = await file.read()
534
  audio_np, sr = librosa.load(io.BytesIO(data), sr=16000, mono=True)
535
-
536
- # Truncate/pad to 10 seconds
537
- max_len = 16000 * 10
538
  if len(audio_np) > max_len:
539
  audio_np = audio_np[:max_len]
540
- elif len(audio_np) < 16000:
541
- audio_np = np.pad(audio_np, (0, 16000 - len(audio_np)))
542
-
543
  inputs = audio_feature_extractor(audio_np, sampling_rate=16000, return_tensors="pt", padding=True)
544
-
545
  with torch.no_grad():
546
- logits = audio_model_v10(inputs["input_values"])
 
 
547
  probs = torch.softmax(logits, dim=-1)
548
-
549
- classes = ["real", "tts", "vc"]
550
- class_probs = {c: round(float(probs[0][i]), 4) for i, c in enumerate(classes)}
551
- pred_idx = int(probs[0].argmax())
552
- verdict = "real" if pred_idx == 0 else "fake"
553
- fake_type = classes[pred_idx] if pred_idx > 0 else None
554
-
555
  return {
556
  "verdict": verdict,
557
- "confidence": round(float(probs[0][pred_idx]), 4),
558
- "scores": {
559
- "real": class_probs["real"],
560
- "fake": round(1.0 - class_probs["real"], 4),
561
- },
562
- "class_scores": class_probs,
563
- "fake_type": fake_type,
564
- "model": "wav2vec2-v10-3class",
565
  }
566
  except Exception as e:
567
  traceback.print_exc()
@@ -571,30 +516,26 @@ async def analyze_audio(file: UploadFile = File(...)):
571
  @app.post("/video")
572
  async def analyze_video(
573
  file: UploadFile = File(...),
574
- platform: str = Query("clean", description="Platform for frame analysis"),
575
  ):
576
- """Analyze video: extract frames → V10 ensemble per frame, extract audio → audio model."""
577
  start_time = time.time()
578
  try:
579
  data = await file.read()
580
-
581
  with tempfile.TemporaryDirectory() as tmpdir:
582
  video_path = os.path.join(tmpdir, "input_video")
583
  with open(video_path, "wb") as f:
584
  f.write(data)
585
-
586
  # Get video info
587
- probe_cmd = [
588
- "ffprobe", "-v", "quiet", "-print_format", "json",
589
- "-show_format", "-show_streams", video_path,
590
- ]
591
  probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=15)
592
  video_info = json.loads(probe_result.stdout) if probe_result.returncode == 0 else {}
593
-
594
  duration = float(video_info.get("format", {}).get("duration", 0))
595
  resolution = "unknown"
596
  fps = 30.0
597
- has_audio_stream = False
598
  for stream in video_info.get("streams", []):
599
  if stream.get("codec_type") == "video":
600
  resolution = f"{stream.get('width', '?')}x{stream.get('height', '?')}"
@@ -602,97 +543,95 @@ async def analyze_video(
602
  num, den = stream.get("r_frame_rate", "30/1").split("/")
603
  fps = float(num) / float(den)
604
  except:
605
- fps = 30.0
606
  elif stream.get("codec_type") == "audio":
607
- has_audio_stream = True
608
-
609
  total_frames = int(duration * fps) if duration > 0 else 0
610
-
611
- # Extract keyframes
612
  max_frames = min(8, max(1, int(duration))) if duration > 0 else 5
613
  frame_interval = max(1.0, duration / max_frames) if duration > 0 else 1.0
614
  frame_dir = os.path.join(tmpdir, "frames")
615
  os.makedirs(frame_dir)
616
-
617
  ffmpeg_cmd = [
618
  "ffmpeg", "-i", video_path,
619
  "-vf", f"fps=1/{frame_interval}",
620
  "-frames:v", str(max_frames),
621
  "-q:v", "2",
622
  os.path.join(frame_dir, "frame_%03d.jpg"),
623
- "-y", "-loglevel", "error",
624
  ]
625
  subprocess.run(ffmpeg_cmd, timeout=30, check=True)
626
-
627
  # Run V10 ensemble on each frame
628
- platform_lower = platform.lower()
629
- if platform_lower not in ENSEMBLE_CONFIGS:
630
- platform_lower = "clean"
631
- config = ENSEMBLE_CONFIGS[platform_lower]
632
-
633
  frame_files = sorted([f for f in os.listdir(frame_dir) if f.endswith(".jpg")])
634
  frame_scores = []
 
 
635
  for fname in frame_files:
636
  fpath = os.path.join(frame_dir, fname)
637
  img = Image.open(fpath).convert("RGB")
 
638
  scores = {}
639
- for model_name in config["models"]:
640
- if model_name in MODEL_INFER:
641
- scores[model_name] = MODEL_INFER[model_name](img)
642
- weighted = sum(
643
- w * scores.get(m, 0) for m, w in zip(config["models"], config["weights"])
644
- )
645
- tw = sum(w for m, w in zip(config["models"], config["weights"]) if m in scores)
646
- if tw > 0:
647
- weighted /= tw
648
- frame_scores.append(round(weighted, 4))
649
-
 
 
 
 
 
 
 
 
650
  temporal_consistency = 1.0 - float(np.std(frame_scores)) if len(frame_scores) > 1 else 1.0
651
-
 
652
  # Audio analysis
653
  audio_verdict = None
654
  audio_confidence = None
655
  audio_scores = None
656
- audio_class_scores = None
657
- if has_audio_stream:
658
  audio_path = os.path.join(tmpdir, "audio.wav")
659
- audio_cmd = [
660
- "ffmpeg", "-i", video_path,
661
- "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
662
- audio_path, "-y", "-loglevel", "error",
663
- ]
664
  audio_extract = subprocess.run(audio_cmd, timeout=20)
665
  if audio_extract.returncode == 0 and os.path.exists(audio_path):
666
  try:
667
- load_audio_v10()
668
- import librosa
669
  audio_np, sr = librosa.load(audio_path, sr=16000, mono=True)
670
  max_audio_len = 16000 * 10
671
  if len(audio_np) > max_audio_len:
672
  audio_np = audio_np[:max_audio_len]
673
  elif len(audio_np) < 16000:
674
  audio_np = np.pad(audio_np, (0, 16000 - len(audio_np)))
675
-
676
  inputs = audio_feature_extractor(audio_np, sampling_rate=16000, return_tensors="pt", padding=True)
677
  with torch.no_grad():
678
- logits = audio_model_v10(inputs["input_values"])
 
 
679
  probs = torch.softmax(logits, dim=-1)
680
-
681
- classes = ["real", "tts", "vc"]
682
- audio_class_scores = {c: round(float(probs[0][i]), 4) for i, c in enumerate(classes)}
683
- pred_idx = int(probs[0].argmax())
684
- audio_verdict = "real" if pred_idx == 0 else "fake"
685
- audio_confidence = round(float(probs[0][pred_idx]), 4)
686
- audio_scores = {"real": audio_class_scores["real"], "fake": round(1.0 - audio_class_scores["real"], 4)}
687
  except Exception as ae:
688
  print(f"Audio analysis error: {ae}", flush=True)
689
-
690
- avg_frame_score = float(np.mean(frame_scores)) if frame_scores else 0.5
691
  if audio_scores:
692
  overall_score = avg_frame_score * 0.7 + audio_scores["fake"] * 0.3
693
  else:
694
  overall_score = avg_frame_score
695
-
696
  flags = []
697
  if avg_frame_score > 0.7:
698
  flags.append("HIGH_FAKE_SCORE_ACROSS_FRAMES")
@@ -702,10 +641,9 @@ async def analyze_video(
702
  flags.append("AUDIO_FAKE_DETECTED")
703
  if audio_scores and ((avg_frame_score > 0.5) != (audio_scores["fake"] > 0.5)):
704
  flags.append("AUDIO_VISUAL_DISAGREEMENT")
705
-
706
  verdict = "fake" if overall_score > config["threshold"] else "real"
707
- processing_time_ms = int((time.time() - start_time) * 1000)
708
-
709
  return {
710
  "frame_scores": frame_scores,
711
  "temporal_consistency": round(temporal_consistency, 4),
@@ -716,13 +654,13 @@ async def analyze_video(
716
  "fps": round(fps, 2),
717
  "resolution": resolution,
718
  "flags": flags,
719
- "processing_time_ms": processing_time_ms,
720
  "audio_verdict": audio_verdict,
721
  "audio_confidence": audio_confidence,
722
  "audio_scores": audio_scores,
723
- "audio_class_scores": audio_class_scores,
724
- "platform": platform_lower,
725
- "model": "kaeva-v10-ensemble + wav2vec2-v10",
726
  }
727
  except subprocess.TimeoutExpired:
728
  raise HTTPException(504, "Video processing timed out")
 
1
  """Kaeva Verify V10 — Ensemble Deepfake Detection API.
2
 
3
  Endpoints:
4
+ POST /image — V10 ensemble image detection (platform-aware)
5
+ POST /audio — Wav2Vec2-based deepfake audio detection
6
+ POST /video — Video frame + audio analysis
7
  GET /health — Health check
8
  """
9
 
 
11
  import numpy as np
12
  import torch
13
  import torch.nn as nn
 
14
  from fastapi import FastAPI, UploadFile, File, HTTPException, Query
15
  from fastapi.middleware.cors import CORSMiddleware
16
  from PIL import Image
17
+ from torchvision import transforms
18
+ from torchvision.models import efficientnet_b0, efficientnet_b3
19
+ from transformers import Wav2Vec2Model, Wav2Vec2FeatureExtractor
20
+ import librosa
21
 
22
+ app = FastAPI(title="Kaeva Verify V10", version="10.0.0")
23
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
24
 
25
+ DEVICE = torch.device("cpu")
26
+
27
+ # ── Ensemble configs (from optimizer) ──
28
  ENSEMBLE_CONFIGS = {
29
  "clean": {
30
  "models": ["image_ensemble_v2", "ai_gen", "spectral", "frequency"],
31
  "weights": [0.286, 0.357, 0.214, 0.143],
32
+ "threshold": 0.45
33
  },
34
  "whatsapp": {
35
  "models": ["image_ensemble_v2"],
36
  "weights": [1.0],
37
+ "threshold": 0.65
38
  },
39
  "instagram": {
40
  "models": ["image_ensemble_v2", "ai_gen"],
41
  "weights": [0.6, 0.4],
42
+ "threshold": 0.60
43
  },
44
  "telegram": {
45
  "models": ["image_ensemble_v2", "ai_gen"],
46
  "weights": [0.75, 0.25],
47
+ "threshold": 0.60
48
  },
49
  "screenshot": {
50
  "models": ["image_ensemble_v2", "spectral"],
51
  "weights": [0.667, 0.333],
52
+ "threshold": 0.45
53
+ }
54
  }
55
 
56
+ # ── Standard transforms ──
57
+ img_transform = transforms.Compose([
58
+ transforms.Resize((224, 224)),
59
+ transforms.ToTensor(),
60
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
61
+ ])
62
+
63
+ img_transform_300 = transforms.Compose([
64
+ transforms.Resize((300, 300)),
65
+ transforms.ToTensor(),
66
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
67
+ ])
 
 
 
 
68
 
69
+ # ── Model registry ──
70
+ models = {}
 
 
 
 
71
 
72
 
73
+ # ══════════════════════════════════════════════
74
+ # IMAGE ENSEMBLE V2 (EfficientNet-B0, 16MB)
75
+ # ══════════════════════════════════════════════
76
  def load_image_ensemble_v2():
77
+ if "image_ensemble_v2" in models:
78
+ return
79
  print("Loading image_ensemble_v2 (EfficientNet-B0)...", flush=True)
80
  model = efficientnet_b0(weights=None)
81
  model.classifier[1] = nn.Linear(1280, 2)
82
+ sd = torch.load("image_ensemble_v2.pt", map_location=DEVICE, weights_only=False)
83
+ if isinstance(sd, dict) and "model_state_dict" in sd:
84
+ sd = sd["model_state_dict"]
85
+ model.load_state_dict(sd)
86
  model.eval()
87
+ models["image_ensemble_v2"] = model
88
+ print(" ✓ image_ensemble_v2 loaded", flush=True)
 
89
 
90
 
91
+ def infer_image_ensemble_v2(img: Image.Image) -> float:
92
+ load_image_ensemble_v2()
93
+ tensor = img_transform(img).unsqueeze(0).to(DEVICE)
94
+ with torch.no_grad():
95
+ logits = models["image_ensemble_v2"](tensor)
96
+ prob = torch.softmax(logits, dim=1)[0, 1].item()
97
+ return prob
98
+
99
+
100
+ # ══════════════════════════════════════════════
101
+ # AI GEN DETECTOR (EfficientNet-B3, 46MB)
102
+ # ══════════════════════════════════════════════
103
  def load_ai_gen():
104
+ if "ai_gen" in models:
105
+ return
106
+ print("Loading ai_gen (EfficientNet-B3)...", flush=True)
107
  model = efficientnet_b3(weights=None)
108
+ model.classifier[1] = nn.Linear(1536, 2)
109
+ ckpt = torch.load("ai_gen_detector.pt", map_location=DEVICE, weights_only=False)
110
+ sd = ckpt.get("model_state_dict", ckpt)
111
+ model.load_state_dict(sd)
112
  model.eval()
113
+ models["ai_gen"] = model
114
+ print(" ✓ ai_gen loaded", flush=True)
 
 
 
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ def infer_ai_gen(img: Image.Image) -> float:
118
+ load_ai_gen()
119
+ tensor = img_transform_300(img).unsqueeze(0).to(DEVICE)
120
+ with torch.no_grad():
121
+ logits = models["ai_gen"](tensor)
122
+ prob = torch.softmax(logits, dim=1)[0, 1].item()
123
+ return prob
124
 
125
 
126
+ # ══════════════════════════════════════════════
127
+ # SPECTRAL DETECTOR (Dual-stream ResNet, 137MB)
128
+ # ══════════════════════════════════════════════
129
+ class DualStreamSpectral(nn.Module):
130
+ """Dual-stream spectral detector: stream_a and stream_b on frequency features."""
131
+ def __init__(self):
132
  super().__init__()
133
+ from torchvision.models import resnet34
134
+ self.stream_a = resnet34(weights=None)
135
+ self.stream_a.conv1 = nn.Conv2d(1, 64, 7, stride=2, padding=3, bias=False)
136
+ self.stream_b = resnet34(weights=None)
137
+ self.stream_b.conv1 = nn.Conv2d(1, 64, 7, stride=2, padding=3, bias=False)
138
+ feat_dim = 512 + 512
139
+ self.stream_a.fc = nn.Identity()
140
+ self.stream_b.fc = nn.Identity()
141
+ self.head = nn.Sequential(
142
+ nn.Linear(feat_dim, 256),
143
+ nn.ReLU(),
144
+ nn.Dropout(0.3),
145
+ nn.Linear(256, 2)
146
  )
147
 
148
+ def forward(self, dct_input, fft_input):
149
+ a = self.stream_a(dct_input)
150
+ b = self.stream_b(fft_input)
151
+ combined = torch.cat([a, b], dim=1)
152
+ return self.head(combined)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
 
155
  def load_spectral():
156
+ if "spectral" in models:
157
+ return
158
+ print("Loading spectral detector...", flush=True)
159
+ model = DualStreamSpectral()
160
+ ckpt = torch.load("spectral_detector.pt", map_location=DEVICE, weights_only=False)
161
+ sd = ckpt.get("model", ckpt.get("model_state_dict", ckpt))
162
+ model.load_state_dict(sd, strict=False)
163
  model.eval()
164
+ models["spectral"] = model
165
+ print(" ✓ spectral loaded", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
+
168
+ def extract_spectral_features(img: Image.Image):
169
+ """Convert image to DCT and FFT magnitude spectral maps."""
170
+ gray = np.array(img.convert("L").resize((224, 224)), dtype=np.float32) / 255.0
171
+
172
+ # DCT via scipy if available, else approximate with FFT
173
+ try:
174
+ from scipy.fftpack import dct
175
+ dct_map = dct(dct(gray, axis=0, norm='ortho'), axis=1, norm='ortho')
176
+ except ImportError:
177
+ dct_map = np.real(np.fft.fft2(gray))
178
+
179
+ # FFT magnitude spectrum
180
+ fft = np.fft.fft2(gray)
181
+ fft_shift = np.fft.fftshift(fft)
182
+ magnitude = np.log1p(np.abs(fft_shift))
183
+
184
+ # Normalize
185
+ dct_norm = (dct_map - dct_map.mean()) / (dct_map.std() + 1e-8)
186
+ mag_norm = (magnitude - magnitude.mean()) / (magnitude.std() + 1e-8)
187
+
188
+ dct_tensor = torch.from_numpy(dct_norm).unsqueeze(0).unsqueeze(0).float()
189
+ fft_tensor = torch.from_numpy(mag_norm).unsqueeze(0).unsqueeze(0).float()
190
+ return dct_tensor, fft_tensor
191
+
192
+
193
+ def infer_spectral(img: Image.Image) -> float:
194
+ load_spectral()
195
+ dct_t, fft_t = extract_spectral_features(img)
196
+ with torch.no_grad():
197
+ logits = models["spectral"](dct_t.to(DEVICE), fft_t.to(DEVICE))
198
+ prob = torch.softmax(logits, dim=1)[0, 1].item()
199
+ return prob
200
 
201
 
202
+ # ══════════════════════════════════════════════
203
+ # FREQUENCY ANALYZER (MLP on handcrafted, 1.6MB)
204
+ # ══════════════════════════════════════════════
205
+ class FrequencyMLP(nn.Module):
206
+ def __init__(self, input_dim=54):
207
+ super().__init__()
208
+ self.mlp = nn.Sequential(
209
+ nn.Linear(input_dim, 128),
210
+ nn.BatchNorm1d(128),
211
+ nn.ReLU(),
212
+ nn.Dropout(0.3),
213
+ nn.Linear(128, 64),
214
+ nn.BatchNorm1d(64),
215
+ nn.ReLU(),
216
+ nn.Dropout(0.2),
217
+ nn.Linear(64, 2)
218
+ )
219
+ def forward(self, x):
220
+ return self.mlp(x)
221
 
222
 
223
  def load_frequency():
224
+ if "frequency" in models:
225
+ return
226
+ print("Loading frequency analyzer...", flush=True)
227
+ ckpt = torch.load("frequency_analyzer.pt", map_location=DEVICE, weights_only=False)
228
+ dims = ckpt.get("feature_dims", {})
229
+ input_dim = dims.get("handcrafted_total", 54)
230
+ model = FrequencyMLP(input_dim)
231
+ sd = ckpt.get("model", ckpt.get("model_state_dict", ckpt))
232
+ model.load_state_dict(sd, strict=False)
233
  model.eval()
234
+ models["frequency"] = model
235
+ models["frequency_dims"] = dims
236
+ print(f" ✓ frequency loaded (input_dim={input_dim})", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
 
239
+ def extract_frequency_features(img: Image.Image) -> torch.Tensor:
240
+ """Extract handcrafted frequency features: Benford, DCT stats, wavelet."""
241
+ gray = np.array(img.convert("L").resize((256, 256)), dtype=np.float32)
242
+ features = []
243
+
244
+ # Benford's Law analysis on DCT coefficients
245
+ try:
246
+ from scipy.fftpack import dct
247
+ dct_coeffs = dct(dct(gray, axis=0, norm='ortho'), axis=1, norm='ortho').flatten()
248
+ except ImportError:
249
+ dct_coeffs = np.real(np.fft.fft2(gray)).flatten()
250
+
251
+ abs_coeffs = np.abs(dct_coeffs[dct_coeffs != 0])
252
+ if len(abs_coeffs) > 0:
253
+ first_digits = (abs_coeffs / (10 ** np.floor(np.log10(abs_coeffs + 1e-10)))).astype(int)
254
+ first_digits = np.clip(first_digits, 1, 9)
255
+ benford_dist = np.zeros(9)
256
+ for d in range(1, 10):
257
+ benford_dist[d-1] = np.sum(first_digits == d) / len(first_digits)
258
+ benford_expected = np.log10(1 + 1/np.arange(1, 10))
259
+ features.extend(benford_dist.tolist())
260
+ features.append(float(np.sum(np.abs(benford_dist - benford_expected))))
261
+ else:
262
+ features.extend([0.0] * 10)
263
+
264
+ # DCT statistics
265
+ dct_flat = dct_coeffs
266
+ dct_stats = [
267
+ float(np.mean(np.abs(dct_flat))),
268
+ float(np.std(dct_flat)),
269
+ float(np.median(np.abs(dct_flat))),
270
+ float(np.percentile(np.abs(dct_flat), 25)),
271
+ float(np.percentile(np.abs(dct_flat), 75)),
272
+ float(np.percentile(np.abs(dct_flat), 95)),
273
+ float(np.mean(dct_flat ** 2)),
274
+ float(np.max(np.abs(dct_flat))),
275
+ ]
276
+ # Energy in frequency bands
277
+ n = len(dct_flat)
278
+ band_size = n // 4
279
+ for i in range(4):
280
+ band = dct_flat[i*band_size:(i+1)*band_size]
281
+ dct_stats.append(float(np.mean(band ** 2)))
282
+ features.extend(dct_stats)
283
+
284
+ # Wavelet-like features (Haar approximation)
285
+ h, w = gray.shape
286
+ ll = (gray[0::2, 0::2] + gray[1::2, 0::2] + gray[0::2, 1::2] + gray[1::2, 1::2]) / 4
287
+ lh = (gray[0::2, 0::2] - gray[1::2, 0::2] + gray[0::2, 1::2] - gray[1::2, 1::2]) / 4
288
+ hl = (gray[0::2, 0::2] + gray[1::2, 0::2] - gray[0::2, 1::2] - gray[1::2, 1::2]) / 4
289
+ hh = (gray[0::2, 0::2] - gray[1::2, 0::2] - gray[0::2, 1::2] + gray[1::2, 1::2]) / 4
290
+ for sub in [ll, lh, hl, hh]:
291
+ features.extend([
292
+ float(np.mean(np.abs(sub))),
293
+ float(np.std(sub)),
294
+ float(np.max(np.abs(sub))),
295
+ float(np.mean(sub ** 2)),
296
+ ])
297
+
298
+ # FFT magnitude stats
299
+ fft = np.fft.fft2(gray)
300
+ mag = np.abs(np.fft.fftshift(fft))
301
+ log_mag = np.log1p(mag)
302
+ features.extend([
303
+ float(np.mean(log_mag)),
304
+ float(np.std(log_mag)),
305
+ float(np.median(log_mag)),
306
+ float(np.max(log_mag)),
307
+ ])
308
+
309
+ # Pad or truncate to expected dimension
310
+ dims = models.get("frequency_dims", {})
311
+ expected = dims.get("handcrafted_total", 54)
312
+ if len(features) < expected:
313
+ features.extend([0.0] * (expected - len(features)))
314
+ features = features[:expected]
315
+
316
+ return torch.tensor([features], dtype=torch.float32)
317
+
318
+
319
+ def infer_frequency(img: Image.Image) -> float:
320
+ load_frequency()
321
+ feat = extract_frequency_features(img).to(DEVICE)
322
  with torch.no_grad():
323
+ logits = models["frequency"](feat)
324
+ prob = torch.softmax(logits, dim=1)[0, 1].item()
325
+ return prob
326
 
327
 
328
+ # ══════════════════════════════════════════════
329
+ # AUDIO MODEL (Wav2Vec2 + classifier)
330
+ # ══════════════════════════════════════════════
331
+ audio_model = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  audio_feature_extractor = None
333
+ audio_classifier = None
334
 
335
+ class AudioClassifier(nn.Module):
336
+ def __init__(self, input_dim=768, hidden_dim=256):
 
337
  super().__init__()
338
+ self.classifier = nn.Sequential(
339
+ nn.Linear(input_dim, hidden_dim),
340
+ nn.ReLU(),
 
 
 
341
  nn.Dropout(0.3),
342
+ nn.Linear(hidden_dim, 64),
343
+ nn.ReLU(),
344
  nn.Dropout(0.2),
345
+ nn.Linear(64, 2)
346
  )
347
+ def forward(self, x):
348
+ return self.classifier(x)
 
 
 
349
 
350
 
351
+ def load_audio():
352
+ global audio_model, audio_feature_extractor, audio_classifier
353
+ if audio_classifier is not None:
354
  return
355
+ print("Loading Wav2Vec2 + audio classifier...", flush=True)
 
356
  audio_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained("facebook/wav2vec2-base")
357
+ audio_model = Wav2Vec2Model.from_pretrained("facebook/wav2vec2-base")
358
+ audio_model.eval()
359
+ audio_classifier = AudioClassifier()
360
+ state = torch.load("audio_deepfake_model.pt", map_location="cpu", weights_only=False)
361
+ if isinstance(state, dict) and "classifier_state_dict" in state:
362
+ sd = state["classifier_state_dict"]
363
+ else:
364
+ sd = state
365
+ if isinstance(sd, dict) and any(k[0].isdigit() for k in sd.keys()):
366
+ sd = {f"classifier.{k}": v for k, v in sd.items()}
367
+ audio_classifier.load_state_dict(sd)
368
+ audio_classifier.eval()
369
+ print(" ✓ audio loaded", flush=True)
370
+
371
+
372
+ # ══════════════════════════════════════════════
373
+ # LEGACY CLIP PROBE (for /video frame analysis)
374
+ # ══════════════════════════════════════════════
375
  clip_model = None
376
  clip_preprocess = None
377
  clip_probe = None
 
381
  if clip_model is not None:
382
  return
383
  if not os.path.exists("clip_deepfake_probe.pt"):
384
+ print("CLIP probe not found, skipping", flush=True)
385
  return
386
  print("Loading CLIP ViT-L/14...", flush=True)
387
  import open_clip
388
  clip_model, _, clip_preprocess = open_clip.create_model_and_transforms('ViT-L-14', pretrained='openai')
389
  clip_model.eval()
390
  clip_probe = torch.load("clip_deepfake_probe.pt", map_location="cpu", weights_only=False)
391
+ print(f" ✓ CLIP probe loaded (val_acc={clip_probe.get('val_accuracy', 'N/A')})", flush=True)
392
 
393
 
394
+ # ══════════════════════════════════════════════
395
+ # ENDPOINTS
396
+ # ══════════════════════════════════════════════
397
 
398
  @app.get("/health")
399
  def health():
400
  return {
401
  "status": "ok",
402
+ "version": "10.0.0",
403
+ "models": list(models.keys()),
 
404
  "platforms": list(ENSEMBLE_CONFIGS.keys()),
405
+ "endpoints": ["/image", "/audio", "/video"]
406
  }
407
 
408
 
409
  @app.post("/image")
410
  async def analyze_image(
411
  file: UploadFile = File(...),
412
+ platform: str = Query("clean", regex="^(clean|whatsapp|instagram|telegram|screenshot)$")
413
  ):
414
+ """Analyze image using V10 ensemble with platform-specific config."""
415
  try:
 
 
 
 
 
416
  data = await file.read()
417
  img = Image.open(io.BytesIO(data)).convert("RGB")
418
+
419
+ config = ENSEMBLE_CONFIGS[platform]
420
+ model_names = config["models"]
421
+ weights = config["weights"]
422
+ threshold = config["threshold"]
423
+
424
  # Run each model in the ensemble
425
  ensemble_scores = {}
426
+ infer_fns = {
427
+ "image_ensemble_v2": infer_image_ensemble_v2,
428
+ "ai_gen": infer_ai_gen,
429
+ "spectral": infer_spectral,
430
+ "frequency": infer_frequency,
431
+ }
432
+
433
+ for name in model_names:
434
+ fn = infer_fns.get(name)
435
+ if fn:
436
+ try:
437
+ ensemble_scores[name] = fn(img)
438
+ except Exception as e:
439
+ print(f"Model {name} failed: {e}", flush=True)
440
+ traceback.print_exc()
441
+ ensemble_scores[name] = 0.5 # neutral fallback
442
+
443
+ # Weighted average
444
+ total_weight = 0
445
+ weighted_score = 0
446
+ for name, w in zip(model_names, weights):
447
+ if name in ensemble_scores:
448
+ weighted_score += ensemble_scores[name] * w
449
+ total_weight += w
450
+
451
  if total_weight > 0:
452
+ fake_prob = weighted_score / total_weight
453
+ else:
454
+ fake_prob = 0.5
455
+
456
+ real_prob = 1 - fake_prob
457
+ verdict = "fake" if fake_prob > threshold else "real"
458
+ confidence = max(fake_prob, real_prob)
459
+
460
  return {
461
  "verdict": verdict,
462
+ "confidence": round(confidence, 4),
463
  "scores": {
464
  "real": round(real_prob, 4),
465
+ "fake": round(fake_prob, 4)
466
  },
467
+ "ensemble_scores": {k: round(v, 4) for k, v in ensemble_scores.items()},
468
  "platform": platform,
469
+ "models_used": model_names,
470
  "threshold": threshold,
471
  "model": "kaeva-v10-ensemble",
472
+ "version": "10.0.0"
473
  }
474
  except Exception as e:
475
  traceback.print_exc()
 
478
 
479
  @app.post("/audio")
480
  async def analyze_audio(file: UploadFile = File(...)):
481
+ """Analyze audio for deepfake indicators using Wav2Vec2 probe."""
482
  try:
483
+ load_audio()
 
484
  data = await file.read()
485
  audio_np, sr = librosa.load(io.BytesIO(data), sr=16000, mono=True)
486
+ max_len = 16000 * 5
 
 
487
  if len(audio_np) > max_len:
488
  audio_np = audio_np[:max_len]
489
+ elif len(audio_np) < max_len:
490
+ audio_np = np.pad(audio_np, (0, max_len - len(audio_np)))
491
+
492
  inputs = audio_feature_extractor(audio_np, sampling_rate=16000, return_tensors="pt", padding=True)
 
493
  with torch.no_grad():
494
+ outputs = audio_model(**inputs)
495
+ hidden = outputs.last_hidden_state.mean(dim=1)
496
+ logits = audio_classifier(hidden)
497
  probs = torch.softmax(logits, dim=-1)
498
+
499
+ real_prob = float(probs[0][0])
500
+ fake_prob = float(probs[0][1])
501
+ verdict = "fake" if fake_prob > 0.5 else "real"
502
+ confidence = max(real_prob, fake_prob)
503
+
 
504
  return {
505
  "verdict": verdict,
506
+ "confidence": round(confidence, 4),
507
+ "scores": {"real": round(real_prob, 4), "fake": round(fake_prob, 4)},
508
+ "model": "wav2vec2-audio-probe",
509
+ "version": "10.0.0"
 
 
 
 
510
  }
511
  except Exception as e:
512
  traceback.print_exc()
 
516
  @app.post("/video")
517
  async def analyze_video(
518
  file: UploadFile = File(...),
519
+ platform: str = Query("clean", regex="^(clean|whatsapp|instagram|telegram|screenshot)$")
520
  ):
521
+ """Analyze video: extract frames → V10 ensemble, extract audio → audio probe."""
522
  start_time = time.time()
523
  try:
524
  data = await file.read()
 
525
  with tempfile.TemporaryDirectory() as tmpdir:
526
  video_path = os.path.join(tmpdir, "input_video")
527
  with open(video_path, "wb") as f:
528
  f.write(data)
529
+
530
  # Get video info
531
+ probe_cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", video_path]
 
 
 
532
  probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=15)
533
  video_info = json.loads(probe_result.stdout) if probe_result.returncode == 0 else {}
534
+
535
  duration = float(video_info.get("format", {}).get("duration", 0))
536
  resolution = "unknown"
537
  fps = 30.0
538
+ has_audio = False
539
  for stream in video_info.get("streams", []):
540
  if stream.get("codec_type") == "video":
541
  resolution = f"{stream.get('width', '?')}x{stream.get('height', '?')}"
 
543
  num, den = stream.get("r_frame_rate", "30/1").split("/")
544
  fps = float(num) / float(den)
545
  except:
546
+ pass
547
  elif stream.get("codec_type") == "audio":
548
+ has_audio = True
549
+
550
  total_frames = int(duration * fps) if duration > 0 else 0
 
 
551
  max_frames = min(8, max(1, int(duration))) if duration > 0 else 5
552
  frame_interval = max(1.0, duration / max_frames) if duration > 0 else 1.0
553
  frame_dir = os.path.join(tmpdir, "frames")
554
  os.makedirs(frame_dir)
555
+
556
  ffmpeg_cmd = [
557
  "ffmpeg", "-i", video_path,
558
  "-vf", f"fps=1/{frame_interval}",
559
  "-frames:v", str(max_frames),
560
  "-q:v", "2",
561
  os.path.join(frame_dir, "frame_%03d.jpg"),
562
+ "-y", "-loglevel", "error"
563
  ]
564
  subprocess.run(ffmpeg_cmd, timeout=30, check=True)
565
+
566
  # Run V10 ensemble on each frame
 
 
 
 
 
567
  frame_files = sorted([f for f in os.listdir(frame_dir) if f.endswith(".jpg")])
568
  frame_scores = []
569
+ config = ENSEMBLE_CONFIGS[platform]
570
+
571
  for fname in frame_files:
572
  fpath = os.path.join(frame_dir, fname)
573
  img = Image.open(fpath).convert("RGB")
574
+
575
  scores = {}
576
+ infer_fns = {
577
+ "image_ensemble_v2": infer_image_ensemble_v2,
578
+ "ai_gen": infer_ai_gen,
579
+ "spectral": infer_spectral,
580
+ "frequency": infer_frequency,
581
+ }
582
+ for name in config["models"]:
583
+ fn = infer_fns.get(name)
584
+ if fn:
585
+ try:
586
+ scores[name] = fn(img)
587
+ except:
588
+ scores[name] = 0.5
589
+
590
+ weighted = sum(scores.get(n, 0.5) * w for n, w in zip(config["models"], config["weights"]))
591
+ total_w = sum(w for n, w in zip(config["models"], config["weights"]) if n in scores)
592
+ frame_score = weighted / total_w if total_w > 0 else 0.5
593
+ frame_scores.append(round(frame_score, 4))
594
+
595
  temporal_consistency = 1.0 - float(np.std(frame_scores)) if len(frame_scores) > 1 else 1.0
596
+ avg_frame_score = float(np.mean(frame_scores)) if frame_scores else 0.5
597
+
598
  # Audio analysis
599
  audio_verdict = None
600
  audio_confidence = None
601
  audio_scores = None
602
+ if has_audio:
 
603
  audio_path = os.path.join(tmpdir, "audio.wav")
604
+ audio_cmd = ["ffmpeg", "-i", video_path, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", audio_path, "-y", "-loglevel", "error"]
 
 
 
 
605
  audio_extract = subprocess.run(audio_cmd, timeout=20)
606
  if audio_extract.returncode == 0 and os.path.exists(audio_path):
607
  try:
608
+ load_audio()
 
609
  audio_np, sr = librosa.load(audio_path, sr=16000, mono=True)
610
  max_audio_len = 16000 * 10
611
  if len(audio_np) > max_audio_len:
612
  audio_np = audio_np[:max_audio_len]
613
  elif len(audio_np) < 16000:
614
  audio_np = np.pad(audio_np, (0, 16000 - len(audio_np)))
 
615
  inputs = audio_feature_extractor(audio_np, sampling_rate=16000, return_tensors="pt", padding=True)
616
  with torch.no_grad():
617
+ outputs = audio_model(**inputs)
618
+ hidden = outputs.last_hidden_state.mean(dim=1)
619
+ logits = audio_classifier(hidden)
620
  probs = torch.softmax(logits, dim=-1)
621
+ rp = float(probs[0][0])
622
+ fp = float(probs[0][1])
623
+ audio_verdict = "fake" if fp > 0.5 else "real"
624
+ audio_confidence = round(max(rp, fp), 4)
625
+ audio_scores = {"real": round(rp, 4), "fake": round(fp, 4)}
 
 
626
  except Exception as ae:
627
  print(f"Audio analysis error: {ae}", flush=True)
628
+
629
+ # Overall score
630
  if audio_scores:
631
  overall_score = avg_frame_score * 0.7 + audio_scores["fake"] * 0.3
632
  else:
633
  overall_score = avg_frame_score
634
+
635
  flags = []
636
  if avg_frame_score > 0.7:
637
  flags.append("HIGH_FAKE_SCORE_ACROSS_FRAMES")
 
641
  flags.append("AUDIO_FAKE_DETECTED")
642
  if audio_scores and ((avg_frame_score > 0.5) != (audio_scores["fake"] > 0.5)):
643
  flags.append("AUDIO_VISUAL_DISAGREEMENT")
644
+
645
  verdict = "fake" if overall_score > config["threshold"] else "real"
646
+
 
647
  return {
648
  "frame_scores": frame_scores,
649
  "temporal_consistency": round(temporal_consistency, 4),
 
654
  "fps": round(fps, 2),
655
  "resolution": resolution,
656
  "flags": flags,
657
+ "processing_time_ms": int((time.time() - start_time) * 1000),
658
  "audio_verdict": audio_verdict,
659
  "audio_confidence": audio_confidence,
660
  "audio_scores": audio_scores,
661
+ "platform": platform,
662
+ "model": "kaeva-v10-ensemble",
663
+ "version": "10.0.0"
664
  }
665
  except subprocess.TimeoutExpired:
666
  raise HTTPException(504, "Video processing timed out")