Deepfake Authenticator commited on
Commit
d893104
Β·
1 Parent(s): 9c22ae9

fix: reduce false positives on real compressed videos

Browse files

- Add face quality gate (skip blurry crops, blur_score < 40)
- Switch frame aggregation from max() to mean() of valid faces
- Change p75 blend to gentler p60 blend (70/30 split)
- Raise FAKE_THRESHOLD from 0.55 to 0.65
- Dampen results when fewer than 3 valid frames available
- Lean toward REAL (0.45) when no usable face crops found
- Gentler tanh calibration to avoid over-inflating borderline scores

Files changed (1) hide show
  1. backend/detector.py +59 -23
backend/detector.py CHANGED
@@ -308,7 +308,18 @@ class DecisionAgent:
308
  return float(np.mean(scores))
309
 
310
  def analyze_face(self, face_crop: np.ndarray) -> float:
311
- """Analyze a single face crop. Returns fake probability (0-1)."""
 
 
 
 
 
 
 
 
 
 
 
312
  if self.use_hf_model:
313
  try:
314
  return self._hf_predict(face_crop)
@@ -324,39 +335,66 @@ class DecisionAgent:
324
  ) -> dict:
325
  """
326
  Aggregate predictions across all frames and faces.
327
- Scoring: 60% mean + 40% 75th-percentile so that a cluster of
328
- highly-fake frames pushes the overall score up decisively.
 
 
 
 
329
  """
330
  frame_scores = []
331
  frames_with_faces = 0
 
332
 
333
  for i, crops in enumerate(face_crops_per_frame):
334
  if not crops:
335
  continue
 
 
 
 
 
 
 
 
 
 
 
336
  frames_with_faces += 1
337
- face_probs = [self.analyze_face(crop) for crop in crops]
338
- # Use max face score per frame (worst-case face wins)
339
- frame_score = float(max(face_probs))
340
  frame_scores.append({"frame_index": i, "fake_probability": round(frame_score, 4)})
341
 
 
 
 
342
  if not frame_scores:
343
  return {
344
  "frame_scores": [],
345
- "overall_fake_probability": 0.5,
346
  "frames_analyzed": len(frames),
347
  "frames_with_faces": 0,
348
  }
349
 
350
  probs = [s["fake_probability"] for s in frame_scores]
351
 
352
- # Robust aggregation: blend mean with 75th percentile
353
- mean_prob = float(np.mean(probs))
354
- p75_prob = float(np.percentile(probs, 75))
355
- overall = round(mean_prob * 0.60 + p75_prob * 0.40, 4)
 
 
 
 
 
 
 
356
 
357
  logger.info(
358
- f"Scores β€” mean: {mean_prob:.3f}, p75: {p75_prob:.3f}, "
359
- f"final: {overall:.3f} ({frames_with_faces}/{len(frames)} frames had faces)"
 
 
360
  )
361
 
362
  return {
@@ -372,11 +410,10 @@ class DecisionAgent:
372
  # Builds the final human-readable report
373
  # ─────────────────────────────────────────────
374
  class ReportGeneratorAgent:
375
- FAKE_THRESHOLD = 0.55 # Slightly lower threshold β€” better recall
376
 
377
  def generate(self, analysis: dict, metadata: dict) -> dict:
378
  prob = analysis["overall_fake_probability"]
379
- # Calibrate: stretch confidence away from 50% for clearer display
380
  calibrated = self._calibrate(prob)
381
  confidence = round(calibrated * 100, 1)
382
  is_fake = prob >= self.FAKE_THRESHOLD
@@ -391,22 +428,21 @@ class ReportGeneratorAgent:
391
  "details": details,
392
  "frame_timeline": frame_timeline,
393
  "metadata": {
394
- "frames_analyzed": analysis.get("frames_analyzed", 0),
395
- "frames_with_faces": analysis.get("frames_with_faces", 0),
396
  "video_duration_sec": metadata.get("duration_sec", 0),
397
- "video_fps": metadata.get("fps", 0),
398
- "resolution": f"{metadata.get('width', 0)}x{metadata.get('height', 0)}",
399
  },
400
  }
401
 
402
  @staticmethod
403
  def _calibrate(prob: float) -> float:
404
  """
405
- Stretch probability away from 0.5 so the confidence bar feels decisive.
406
- Maps [0,1] β†’ [0,1] with a sigmoid-like curve centered at 0.5.
407
  """
408
- # Shift to [-1, 1], apply tanh sharpening, shift back
409
- x = (prob - 0.5) * 3.5 # amplify
410
  stretched = np.tanh(x) * 0.5 + 0.5
411
  return float(np.clip(stretched, 0.01, 0.99))
412
 
 
308
  return float(np.mean(scores))
309
 
310
  def analyze_face(self, face_crop: np.ndarray) -> float:
311
+ """
312
+ Analyze a single face crop. Returns fake probability (0-1).
313
+ Returns None if the crop is too blurry/low-quality to be reliable.
314
+ """
315
+ # ── Quality gate: skip blurry or tiny crops ──────────────────
316
+ gray = cv2.cvtColor(face_crop, cv2.COLOR_BGR2GRAY)
317
+ blur_score = cv2.Laplacian(gray, cv2.CV_64F).var()
318
+ if blur_score < 40:
319
+ # Too blurry β€” motion blur, compression, side-profile
320
+ logger.debug(f"Skipping low-quality crop (blur={blur_score:.1f})")
321
+ return None # type: ignore[return-value]
322
+
323
  if self.use_hf_model:
324
  try:
325
  return self._hf_predict(face_crop)
 
335
  ) -> dict:
336
  """
337
  Aggregate predictions across all frames and faces.
338
+
339
+ Scoring strategy (balanced for precision AND recall):
340
+ - Skip blurry/low-quality face crops
341
+ - Use MEAN of valid face scores per frame (not max β€” max causes false positives)
342
+ - Final score = 70% mean + 30% p60 (mild upward nudge for genuinely fake videos)
343
+ - Require at least 3 valid frames before trusting the result
344
  """
345
  frame_scores = []
346
  frames_with_faces = 0
347
+ frames_skipped_quality = 0
348
 
349
  for i, crops in enumerate(face_crops_per_frame):
350
  if not crops:
351
  continue
352
+
353
+ valid_probs = []
354
+ for crop in crops:
355
+ score = self.analyze_face(crop)
356
+ if score is not None:
357
+ valid_probs.append(score)
358
+
359
+ if not valid_probs:
360
+ frames_skipped_quality += 1
361
+ continue
362
+
363
  frames_with_faces += 1
364
+ # Mean across valid faces in this frame (not max)
365
+ frame_score = float(np.mean(valid_probs))
 
366
  frame_scores.append({"frame_index": i, "fake_probability": round(frame_score, 4)})
367
 
368
+ if frames_skipped_quality > 0:
369
+ logger.info(f"Skipped {frames_skipped_quality} frames due to low face quality")
370
+
371
  if not frame_scores:
372
  return {
373
  "frame_scores": [],
374
+ "overall_fake_probability": 0.45, # lean toward REAL when no data
375
  "frames_analyzed": len(frames),
376
  "frames_with_faces": 0,
377
  }
378
 
379
  probs = [s["fake_probability"] for s in frame_scores]
380
 
381
+ # Need at least 3 valid frames for a reliable result
382
+ if len(probs) < 3:
383
+ logger.info(f"Only {len(probs)} valid frames β€” low confidence result")
384
+ overall = float(np.mean(probs)) * 0.85 # dampen uncertain results
385
+ else:
386
+ mean_prob = float(np.mean(probs))
387
+ p60_prob = float(np.percentile(probs, 60))
388
+ # 70% mean + 30% p60 β€” mild nudge, won't over-amplify outliers
389
+ overall = mean_prob * 0.70 + p60_prob * 0.30
390
+
391
+ overall = round(float(np.clip(overall, 0.0, 1.0)), 4)
392
 
393
  logger.info(
394
+ f"Scores β€” mean: {float(np.mean(probs)):.3f}, "
395
+ f"p60: {float(np.percentile(probs, 60)):.3f}, "
396
+ f"final: {overall:.3f} "
397
+ f"({frames_with_faces}/{len(frames)} frames had usable faces)"
398
  )
399
 
400
  return {
 
410
  # Builds the final human-readable report
411
  # ─────────────────────────────────────────────
412
  class ReportGeneratorAgent:
413
+ FAKE_THRESHOLD = 0.65 # Higher threshold = fewer false positives on real videos
414
 
415
  def generate(self, analysis: dict, metadata: dict) -> dict:
416
  prob = analysis["overall_fake_probability"]
 
417
  calibrated = self._calibrate(prob)
418
  confidence = round(calibrated * 100, 1)
419
  is_fake = prob >= self.FAKE_THRESHOLD
 
428
  "details": details,
429
  "frame_timeline": frame_timeline,
430
  "metadata": {
431
+ "frames_analyzed": analysis.get("frames_analyzed", 0),
432
+ "frames_with_faces": analysis.get("frames_with_faces", 0),
433
  "video_duration_sec": metadata.get("duration_sec", 0),
434
+ "video_fps": metadata.get("fps", 0),
435
+ "resolution": f"{metadata.get('width', 0)}x{metadata.get('height', 0)}",
436
  },
437
  }
438
 
439
  @staticmethod
440
  def _calibrate(prob: float) -> float:
441
  """
442
+ Gentle calibration β€” only stretch scores that are clearly above/below 0.5.
443
+ Avoids over-inflating borderline scores (0.55-0.65 range).
444
  """
445
+ x = (prob - 0.5) * 2.5 # gentler amplification than before
 
446
  stretched = np.tanh(x) * 0.5 + 0.5
447
  return float(np.clip(stretched, 0.01, 0.99))
448