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
- 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 338 |
-
|
| 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.
|
| 346 |
"frames_analyzed": len(frames),
|
| 347 |
"frames_with_faces": 0,
|
| 348 |
}
|
| 349 |
|
| 350 |
probs = [s["fake_probability"] for s in frame_scores]
|
| 351 |
|
| 352 |
-
#
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
|
| 357 |
logger.info(
|
| 358 |
-
f"Scores β mean: {
|
| 359 |
-
f"
|
|
|
|
|
|
|
| 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.
|
| 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":
|
| 395 |
-
"frames_with_faces":
|
| 396 |
"video_duration_sec": metadata.get("duration_sec", 0),
|
| 397 |
-
"video_fps":
|
| 398 |
-
"resolution":
|
| 399 |
},
|
| 400 |
}
|
| 401 |
|
| 402 |
@staticmethod
|
| 403 |
def _calibrate(prob: float) -> float:
|
| 404 |
"""
|
| 405 |
-
|
| 406 |
-
|
| 407 |
"""
|
| 408 |
-
|
| 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 |
|