Add classical ML tie-breaker + face-crop fix for face-swap blind spot
Browse filestraining/diagnose_insight.py confirmed InsightFace fakes in
OpenRL/DeepFakeFace are face-swaps applied directly onto the SAME real
IMDB-WIKI photos used as the real baseline (identical filenames in both
zips) - only the face region is manipulated, so whole-image forensic/
frequency signals correctly read mostly-real statistics. Risk scores
clustered right at the 0.60 boundary (0.38-0.68) rather than near zero:
a different failure mode than the models being blind to it.
Two fixes:
1. face_align=True for DINOv2/EfficientNet Auth/Face Deepfake (was False,
preserving each model's original whole-image training) - cropping to
the face isolates the manipulated region instead of diluting it with
untouched background pixels.
2. New classical ML tie-breaker (core_models/forensic_ml.py): RandomForest
on hand-crafted features (LBP texture, color moments, noise residual,
edge density, JPEG blockiness) computed on the whole image, face crop,
and a wider context region around the face to specifically capture
blending-boundary artifacts. Only consulted by core/pipeline.py when
the fused risk lands near the 0.60 boundary - exactly where this
failure mode clusters - never used as a standalone verdict.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- .gitignore +2 -0
- configs/models.json +8 -0
- core/pipeline.py +37 -0
- core_models/forensic_features.py +144 -0
- core_models/forensic_ml.py +48 -0
- training/diagnose_insight.py +80 -0
- training/train_dinov2.py +7 -3
- training/train_efficientnet_auth.py +7 -1
- training/train_face_deepfake_hf.py +7 -1
- training/train_forensic_ml.py +92 -0
|
@@ -3,7 +3,9 @@ models/*.pth
|
|
| 3 |
models/*.keras
|
| 4 |
models/*.onnx
|
| 5 |
models/*.tflite
|
|
|
|
| 6 |
models/face_detector/*.caffemodel
|
|
|
|
| 7 |
|
| 8 |
# HuggingFace cache
|
| 9 |
.hf_cache/
|
|
|
|
| 3 |
models/*.keras
|
| 4 |
models/*.onnx
|
| 5 |
models/*.tflite
|
| 6 |
+
models/*.joblib
|
| 7 |
models/face_detector/*.caffemodel
|
| 8 |
+
models/_backup_*/
|
| 9 |
|
| 10 |
# HuggingFace cache
|
| 11 |
.hf_cache/
|
|
@@ -52,6 +52,14 @@
|
|
| 52 |
"enabled": true,
|
| 53 |
"description": "EfficientNetV2-S authentication"
|
| 54 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"video_lstm": {
|
| 56 |
"type": "local",
|
| 57 |
"class": "VideoTemporalLSTM",
|
|
|
|
| 52 |
"enabled": true,
|
| 53 |
"description": "EfficientNetV2-S authentication"
|
| 54 |
},
|
| 55 |
+
"forensic_ml": {
|
| 56 |
+
"type": "local",
|
| 57 |
+
"class": "ForensicMLClassifier",
|
| 58 |
+
"path": "forensic_ml.joblib",
|
| 59 |
+
"weight": null,
|
| 60 |
+
"enabled": true,
|
| 61 |
+
"description": "Classical RandomForest tie-breaker (hand-crafted forensic features), consulted only when fused risk is near the 0.60 decision boundary"
|
| 62 |
+
},
|
| 63 |
"video_lstm": {
|
| 64 |
"type": "local",
|
| 65 |
"class": "VideoTemporalLSTM",
|
|
@@ -58,6 +58,9 @@ class ModelRegistry:
|
|
| 58 |
self.vit_model = None
|
| 59 |
self.vit_processor = None
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
# Analyzers
|
| 62 |
self.video_analyzer = None
|
| 63 |
self.audio_analyzer = None
|
|
@@ -89,6 +92,9 @@ class ModelRegistry:
|
|
| 89 |
# HuggingFace ViT
|
| 90 |
self._try_load_vit()
|
| 91 |
|
|
|
|
|
|
|
|
|
|
| 92 |
# Frequency analyzer (heuristic fallback)
|
| 93 |
try:
|
| 94 |
from pipeline.video_analyzer import FrequencyAnalyzer
|
|
@@ -170,6 +176,21 @@ class ModelRegistry:
|
|
| 170 |
logger.warning("Could not load CorefakeNet: %s", e)
|
| 171 |
self.missing.append("corefakenet (error)")
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
def _try_load_vit(self) -> None:
|
| 174 |
try:
|
| 175 |
from transformers import ViTForImageClassification, ViTImageProcessor
|
|
@@ -433,6 +454,21 @@ def _analyze_image_ensemble(
|
|
| 433 |
if max_prob > override_thresh:
|
| 434 |
final_risk = max(final_risk, max_prob * 0.9 if n_trained < 3 else max_prob)
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
# Verdict
|
| 437 |
risk_pct = final_risk * 100
|
| 438 |
verdict = Verdict.from_risk_score(final_risk)
|
|
@@ -481,6 +517,7 @@ def _analyze_image_ensemble(
|
|
| 481 |
"model_agreement": model_agreement,
|
| 482 |
"model_scores": scores,
|
| 483 |
"fusion_mode": fusion_mode,
|
|
|
|
| 484 |
"face_detected": has_face,
|
| 485 |
"face_aligned": has_face,
|
| 486 |
"gradcam_image": gradcam_img,
|
|
|
|
| 58 |
self.vit_model = None
|
| 59 |
self.vit_processor = None
|
| 60 |
|
| 61 |
+
# Classical ML tie-breaker (RandomForest on hand-crafted features)
|
| 62 |
+
self.forensic_ml = None
|
| 63 |
+
|
| 64 |
# Analyzers
|
| 65 |
self.video_analyzer = None
|
| 66 |
self.audio_analyzer = None
|
|
|
|
| 92 |
# HuggingFace ViT
|
| 93 |
self._try_load_vit()
|
| 94 |
|
| 95 |
+
# Classical ML tie-breaker
|
| 96 |
+
self._try_load_forensic_ml()
|
| 97 |
+
|
| 98 |
# Frequency analyzer (heuristic fallback)
|
| 99 |
try:
|
| 100 |
from pipeline.video_analyzer import FrequencyAnalyzer
|
|
|
|
| 176 |
logger.warning("Could not load CorefakeNet: %s", e)
|
| 177 |
self.missing.append("corefakenet (error)")
|
| 178 |
|
| 179 |
+
def _try_load_forensic_ml(self) -> None:
|
| 180 |
+
path = self.config.models_dir / "forensic_ml.joblib"
|
| 181 |
+
if not path.exists():
|
| 182 |
+
self.missing.append("forensic_ml")
|
| 183 |
+
return
|
| 184 |
+
try:
|
| 185 |
+
from core_models.forensic_ml import ForensicMLClassifier
|
| 186 |
+
clf = ForensicMLClassifier()
|
| 187 |
+
clf.load(str(path))
|
| 188 |
+
self.forensic_ml = clf
|
| 189 |
+
self.loaded.append("forensic_ml")
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.warning("Could not load forensic ML tie-breaker: %s", e)
|
| 192 |
+
self.missing.append("forensic_ml (error)")
|
| 193 |
+
|
| 194 |
def _try_load_vit(self) -> None:
|
| 195 |
try:
|
| 196 |
from transformers import ViTForImageClassification, ViTImageProcessor
|
|
|
|
| 454 |
if max_prob > override_thresh:
|
| 455 |
final_risk = max(final_risk, max_prob * 0.9 if n_trained < 3 else max_prob)
|
| 456 |
|
| 457 |
+
# Classical-ML tie-breaker: hand-crafted forensic features (LBP, color
|
| 458 |
+
# moments, noise residual, edge density) computed on the whole image,
|
| 459 |
+
# face crop, and blend-boundary context region. Only consulted when the
|
| 460 |
+
# fused score is near the decision boundary - training/diagnose_insight.py
|
| 461 |
+
# found InsightFace-style face-swap-on-real-photo fakes clustering
|
| 462 |
+
# exactly there (0.38-0.68), a different failure mode than the deep
|
| 463 |
+
# ensemble being blind to them outright.
|
| 464 |
+
forensic_ml_score = None
|
| 465 |
+
if reg.forensic_ml is not None and 0.35 <= final_risk <= 0.75:
|
| 466 |
+
try:
|
| 467 |
+
forensic_ml_score = reg.forensic_ml.predict_proba_fake(image_pil)
|
| 468 |
+
final_risk = 0.7 * final_risk + 0.3 * forensic_ml_score
|
| 469 |
+
except Exception as e:
|
| 470 |
+
logger.warning("Forensic ML tie-breaker failed: %s", e)
|
| 471 |
+
|
| 472 |
# Verdict
|
| 473 |
risk_pct = final_risk * 100
|
| 474 |
verdict = Verdict.from_risk_score(final_risk)
|
|
|
|
| 517 |
"model_agreement": model_agreement,
|
| 518 |
"model_scores": scores,
|
| 519 |
"fusion_mode": fusion_mode,
|
| 520 |
+
"forensic_ml_score": forensic_ml_score,
|
| 521 |
"face_detected": has_face,
|
| 522 |
"face_aligned": has_face,
|
| 523 |
"gradcam_image": gradcam_img,
|
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hand-crafted forensic feature extraction for the classical-ML tie-breaker
|
| 3 |
+
(core_models/forensic_ml.py).
|
| 4 |
+
|
| 5 |
+
Not trying to replace the deep ensemble - complementary signal for the
|
| 6 |
+
specific blind spot training/diagnose_insight.py found: InsightFace fakes
|
| 7 |
+
in OpenRL/DeepFakeFace are face-swaps applied directly onto the SAME real
|
| 8 |
+
IMDB-WIKI photos used as the real baseline (identical filenames confirmed
|
| 9 |
+
in both zips), so most of the image genuinely is unmanipulated camera
|
| 10 |
+
output. Whole-image classifiers correctly read that as mostly-authentic;
|
| 11 |
+
the actual tell is localized to the face region and its blending boundary.
|
| 12 |
+
|
| 13 |
+
Extracts the same feature set (color moments, LBP texture histogram, noise
|
| 14 |
+
residual stats, edge density) from three regions per image:
|
| 15 |
+
- whole image
|
| 16 |
+
- tight face crop
|
| 17 |
+
- a wider "context" crop around the face (captures the blend boundary
|
| 18 |
+
and surrounding area a tight crop would exclude)
|
| 19 |
+
plus a whole-image JPEG blockiness ratio and a has_face flag.
|
| 20 |
+
|
| 21 |
+
If no face is detected, face/context features are zero-filled and
|
| 22 |
+
has_face=0 - the classifier learns to weight those accordingly rather than
|
| 23 |
+
receiving fabricated values.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import cv2
|
| 28 |
+
|
| 29 |
+
from utils.gradcam import detect_and_align_face
|
| 30 |
+
|
| 31 |
+
REGION_FEATURE_NAMES = [
|
| 32 |
+
"mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b",
|
| 33 |
+
"noise_mean", "noise_std", "edge_density",
|
| 34 |
+
"lbp_0", "lbp_1", "lbp_2", "lbp_3", "lbp_4", "lbp_5", "lbp_6", "lbp_7",
|
| 35 |
+
]
|
| 36 |
+
N_REGION_FEATURES = len(REGION_FEATURE_NAMES) # 17
|
| 37 |
+
|
| 38 |
+
FEATURE_NAMES = (
|
| 39 |
+
[f"whole_{n}" for n in REGION_FEATURE_NAMES]
|
| 40 |
+
+ [f"face_{n}" for n in REGION_FEATURE_NAMES]
|
| 41 |
+
+ [f"context_{n}" for n in REGION_FEATURE_NAMES]
|
| 42 |
+
+ ["jpeg_blockiness", "has_face"]
|
| 43 |
+
)
|
| 44 |
+
N_FEATURES = len(FEATURE_NAMES) # 53
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _lbp_histogram(gray, n_bins=8):
|
| 48 |
+
"""Vectorized 8-neighbor, radius-1 LBP, binned into a normalized histogram."""
|
| 49 |
+
if gray.shape[0] < 3 or gray.shape[1] < 3:
|
| 50 |
+
return [0.0] * n_bins
|
| 51 |
+
|
| 52 |
+
center = gray[1:-1, 1:-1].astype(np.int16)
|
| 53 |
+
neighbors = [
|
| 54 |
+
gray[0:-2, 0:-2], gray[0:-2, 1:-1], gray[0:-2, 2:],
|
| 55 |
+
gray[1:-1, 2:], gray[2:, 2:], gray[2:, 1:-1],
|
| 56 |
+
gray[2:, 0:-2], gray[1:-1, 0:-2],
|
| 57 |
+
]
|
| 58 |
+
code = np.zeros_like(center, dtype=np.uint8)
|
| 59 |
+
for i, n in enumerate(neighbors):
|
| 60 |
+
code |= ((n.astype(np.int16) >= center).astype(np.uint8) << i)
|
| 61 |
+
|
| 62 |
+
hist, _ = np.histogram(code, bins=n_bins, range=(0, 256))
|
| 63 |
+
total = hist.sum()
|
| 64 |
+
return (hist / total).tolist() if total > 0 else [0.0] * n_bins
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _region_features(pil_crop):
|
| 68 |
+
"""17 features for one image region: color moments, noise, edges, LBP."""
|
| 69 |
+
arr = np.array(pil_crop.convert("RGB"), dtype=np.float64)
|
| 70 |
+
if arr.size == 0 or arr.shape[0] < 3 or arr.shape[1] < 3:
|
| 71 |
+
return [0.0] * N_REGION_FEATURES
|
| 72 |
+
|
| 73 |
+
gray = cv2.cvtColor(arr.astype(np.uint8), cv2.COLOR_RGB2GRAY)
|
| 74 |
+
|
| 75 |
+
pixels = arr.reshape(-1, 3)
|
| 76 |
+
means = pixels.mean(axis=0)
|
| 77 |
+
stds = pixels.std(axis=0)
|
| 78 |
+
|
| 79 |
+
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
|
| 80 |
+
noise_mean = float(np.mean(np.abs(laplacian)))
|
| 81 |
+
noise_std = float(np.std(laplacian))
|
| 82 |
+
|
| 83 |
+
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
|
| 84 |
+
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
|
| 85 |
+
edge_density = float(np.mean(np.sqrt(sobel_x**2 + sobel_y**2)))
|
| 86 |
+
|
| 87 |
+
lbp_hist = _lbp_histogram(gray, n_bins=8)
|
| 88 |
+
|
| 89 |
+
return list(means) + list(stds) + [noise_mean, noise_std, edge_density] + lbp_hist
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _jpeg_blockiness(gray):
|
| 93 |
+
"""Ratio of discontinuity at the 8x8 JPEG grid vs elsewhere - >1 suggests
|
| 94 |
+
block-boundary artifacts (recompression, splicing)."""
|
| 95 |
+
h, w = gray.shape
|
| 96 |
+
if h < 16 or w < 16:
|
| 97 |
+
return 0.0
|
| 98 |
+
gray = gray.astype(np.float64)
|
| 99 |
+
col_idx = np.arange(8, w - 1, 8)
|
| 100 |
+
if len(col_idx) == 0:
|
| 101 |
+
return 0.0
|
| 102 |
+
boundary_diff = np.mean(np.abs(gray[:, col_idx] - gray[:, col_idx - 1]))
|
| 103 |
+
all_diff = np.mean(np.abs(np.diff(gray, axis=1)))
|
| 104 |
+
return float(boundary_diff / (all_diff + 1e-6))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def extract_forensic_features(pil_img):
|
| 108 |
+
"""
|
| 109 |
+
Returns a (N_FEATURES,) float32 vector: whole-image + face + context
|
| 110 |
+
region features, plus JPEG blockiness and has_face.
|
| 111 |
+
"""
|
| 112 |
+
img = pil_img.convert("RGB")
|
| 113 |
+
whole_feats = _region_features(img)
|
| 114 |
+
|
| 115 |
+
gray_whole = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY)
|
| 116 |
+
blockiness = _jpeg_blockiness(gray_whole)
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
face_crop, bbox = detect_and_align_face(img, expand_ratio=0.1)
|
| 120 |
+
except Exception:
|
| 121 |
+
face_crop, bbox = None, None
|
| 122 |
+
|
| 123 |
+
has_face = 1.0 if face_crop is not None else 0.0
|
| 124 |
+
face_feats = _region_features(face_crop) if face_crop is not None else [0.0] * N_REGION_FEATURES
|
| 125 |
+
|
| 126 |
+
context_feats = [0.0] * N_REGION_FEATURES
|
| 127 |
+
if bbox is not None:
|
| 128 |
+
x1, y1, x2, y2 = bbox
|
| 129 |
+
w, h = img.size
|
| 130 |
+
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
| 131 |
+
bw, bh = (x2 - x1), (y2 - y1)
|
| 132 |
+
scale = 1.6 # 60% wider than the face bbox - captures the blend boundary
|
| 133 |
+
nx1 = max(0, int(cx - bw * scale / 2))
|
| 134 |
+
ny1 = max(0, int(cy - bh * scale / 2))
|
| 135 |
+
nx2 = min(w, int(cx + bw * scale / 2))
|
| 136 |
+
ny2 = min(h, int(cy + bh * scale / 2))
|
| 137 |
+
if nx2 > nx1 and ny2 > ny1:
|
| 138 |
+
context_crop = img.crop((nx1, ny1, nx2, ny2))
|
| 139 |
+
context_feats = _region_features(context_crop)
|
| 140 |
+
|
| 141 |
+
return np.array(
|
| 142 |
+
whole_feats + face_feats + context_feats + [blockiness, has_face],
|
| 143 |
+
dtype=np.float32,
|
| 144 |
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Classical ML tie-breaker: RandomForest on hand-crafted forensic features
|
| 3 |
+
(core_models/forensic_features.py), not another deep net.
|
| 4 |
+
|
| 5 |
+
Purpose: training/diagnose_insight.py found InsightFace-style face-swap
|
| 6 |
+
fakes clustering right around the 0.60 decision boundary (0.38-0.68 across
|
| 7 |
+
every held-out sample) - the deep ensemble isn't blind to them, it's
|
| 8 |
+
just uncertain. A RandomForest on texture/noise/color statistics needs far
|
| 9 |
+
less data and is far less prone to overfitting than another deep model
|
| 10 |
+
would be for this narrow purpose, and gives a genuinely independent second
|
| 11 |
+
opinion precisely when the primary signal is weak.
|
| 12 |
+
|
| 13 |
+
Only consulted by core/pipeline.py when the fused risk score lands near
|
| 14 |
+
the boundary - never used as a standalone verdict.
|
| 15 |
+
|
| 16 |
+
Saves as: models/forensic_ml.joblib
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import joblib
|
| 20 |
+
from sklearn.ensemble import RandomForestClassifier
|
| 21 |
+
|
| 22 |
+
from core_models.forensic_features import extract_forensic_features
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ForensicMLClassifier:
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self.model = RandomForestClassifier(
|
| 28 |
+
n_estimators=300,
|
| 29 |
+
max_depth=12,
|
| 30 |
+
min_samples_leaf=3,
|
| 31 |
+
class_weight="balanced",
|
| 32 |
+
random_state=42,
|
| 33 |
+
n_jobs=-1,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def fit(self, X, y):
|
| 37 |
+
self.model.fit(X, y)
|
| 38 |
+
|
| 39 |
+
def predict_proba_fake(self, pil_img):
|
| 40 |
+
"""Returns P(fake) in [0, 1] for a single PIL image."""
|
| 41 |
+
feats = extract_forensic_features(pil_img).reshape(1, -1)
|
| 42 |
+
return float(self.model.predict_proba(feats)[0][1])
|
| 43 |
+
|
| 44 |
+
def save(self, path):
|
| 45 |
+
joblib.dump(self.model, path)
|
| 46 |
+
|
| 47 |
+
def load(self, path):
|
| 48 |
+
self.model = joblib.load(path)
|
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diagnostic: why does the ensemble still miss InsightFace-generated fakes
|
| 3 |
+
(6.7% accuracy across every retrain round, the only category that hasn't
|
| 4 |
+
moved) while SD Inpainting/text2img improved substantially?
|
| 5 |
+
|
| 6 |
+
Pulls a handful of insight.zip samples (past the benchmark's held-out
|
| 7 |
+
slice, same as training does) and prints the FULL per-model score
|
| 8 |
+
breakdown for each one, so we can see whether specific models are
|
| 9 |
+
uniformly blind to it or whether it's more mixed.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
.venv/Scripts/python.exe training/diagnose_insight.py --n 8
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
import os
|
| 17 |
+
import argparse
|
| 18 |
+
import random
|
| 19 |
+
import zipfile
|
| 20 |
+
|
| 21 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 22 |
+
if ROOT_DIR not in sys.path:
|
| 23 |
+
sys.path.insert(0, ROOT_DIR)
|
| 24 |
+
|
| 25 |
+
os.environ.setdefault("HF_HOME", os.path.join(ROOT_DIR, ".hf_cache"))
|
| 26 |
+
|
| 27 |
+
from PIL import Image
|
| 28 |
+
from huggingface_hub import hf_hub_download
|
| 29 |
+
|
| 30 |
+
REPO_ID = "OpenRL/DeepFakeFace"
|
| 31 |
+
IMG_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp")
|
| 32 |
+
BENCHMARK_HOLDOUT = 30 # matches eval_image_benchmark.py's default
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main():
|
| 36 |
+
parser = argparse.ArgumentParser()
|
| 37 |
+
parser.add_argument("--n", type=int, default=8)
|
| 38 |
+
parser.add_argument("--seed", type=int, default=99) # different from benchmark's 42
|
| 39 |
+
args = parser.parse_args()
|
| 40 |
+
|
| 41 |
+
from core.pipeline import analyze_image
|
| 42 |
+
|
| 43 |
+
zip_path = hf_hub_download(repo_id=REPO_ID, repo_type="dataset", filename="insight.zip")
|
| 44 |
+
extract_dir = os.path.join(ROOT_DIR, ".hf_cache", "diagnose_insight")
|
| 45 |
+
os.makedirs(extract_dir, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
with zipfile.ZipFile(zip_path) as zf:
|
| 48 |
+
members = [m for m in zf.namelist() if m.lower().endswith(IMG_EXTS)]
|
| 49 |
+
# exclude the exact slice the benchmark used (seed=42, first 30)
|
| 50 |
+
bench_rng = random.Random(42)
|
| 51 |
+
bench_rng.shuffle(members)
|
| 52 |
+
remaining = members[BENCHMARK_HOLDOUT:]
|
| 53 |
+
|
| 54 |
+
rng = random.Random(args.seed)
|
| 55 |
+
rng.shuffle(remaining)
|
| 56 |
+
chosen = remaining[:args.n]
|
| 57 |
+
|
| 58 |
+
paths = []
|
| 59 |
+
for m in chosen:
|
| 60 |
+
out_path = os.path.join(extract_dir, os.path.basename(m))
|
| 61 |
+
if not os.path.exists(out_path):
|
| 62 |
+
with zf.open(m) as src, open(out_path, "wb") as dst:
|
| 63 |
+
dst.write(src.read())
|
| 64 |
+
paths.append(out_path)
|
| 65 |
+
|
| 66 |
+
print(f"Sampled {len(paths)} insight.zip images -> {extract_dir}\n")
|
| 67 |
+
|
| 68 |
+
for path in paths:
|
| 69 |
+
img = Image.open(path).convert("RGB")
|
| 70 |
+
out = analyze_image(img, mode="ensemble")
|
| 71 |
+
scores = out.get("model_scores", {})
|
| 72 |
+
print(f"{os.path.basename(path)}")
|
| 73 |
+
print(f" risk_score={out['risk_score']:.4f} verdict={out['verdict']}")
|
| 74 |
+
print(f" face_detected={out.get('face_detected')} image_size={img.size}")
|
| 75 |
+
print(f" scores: {scores}")
|
| 76 |
+
print()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
|
@@ -45,11 +45,15 @@ def main():
|
|
| 45 |
# only, which is GAN-style and taught DINOv2 nothing about diffusion
|
| 46 |
# output (training/eval_image_benchmark.py found the whole ensemble at
|
| 47 |
# 30.8% accuracy on diffusion fakes vs 83.3% on real photos).
|
| 48 |
-
# face_align=
|
| 49 |
-
# face-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 51 |
train_samples, val_samples = load_portrait_dataset(
|
| 52 |
-
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=
|
| 53 |
)
|
| 54 |
|
| 55 |
print(f"Train samples: {len(train_samples)}")
|
|
|
|
| 45 |
# only, which is GAN-style and taught DINOv2 nothing about diffusion
|
| 46 |
# output (training/eval_image_benchmark.py found the whole ensemble at
|
| 47 |
# 30.8% accuracy on diffusion fakes vs 83.3% on real photos).
|
| 48 |
+
# face_align=True (changed from False): InsightFace fakes in
|
| 49 |
+
# OpenRL/DeepFakeFace turned out to be face-swaps applied directly onto
|
| 50 |
+
# the SAME real IMDB-WIKI photos used as the real baseline (identical
|
| 51 |
+
# filenames confirmed in both zips) - only the face region is
|
| 52 |
+
# manipulated. Training on the whole image diluted that signal with
|
| 53 |
+
# mostly-real background pixels; cropping to the face isolates it.
|
| 54 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 55 |
train_samples, val_samples = load_portrait_dataset(
|
| 56 |
+
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=True,
|
| 57 |
)
|
| 58 |
|
| 59 |
print(f"Train samples: {len(train_samples)}")
|
|
@@ -71,9 +71,15 @@ def main():
|
|
| 71 |
# accuracy on diffusion fakes vs 83-97% on real photos, and this
|
| 72 |
# model's own average score on that held-out set was just 0.153,
|
| 73 |
# meaning it was still confidently calling diffusion fakes "real."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 75 |
train_data, val_data = load_portrait_dataset(
|
| 76 |
-
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=
|
| 77 |
)
|
| 78 |
|
| 79 |
print(f"Train samples: {len(train_data)}")
|
|
|
|
| 71 |
# accuracy on diffusion fakes vs 83-97% on real photos, and this
|
| 72 |
# model's own average score on that held-out set was just 0.153,
|
| 73 |
# meaning it was still confidently calling diffusion fakes "real."
|
| 74 |
+
# face_align=True (changed from False): InsightFace fakes in
|
| 75 |
+
# OpenRL/DeepFakeFace turned out to be face-swaps applied directly onto
|
| 76 |
+
# the SAME real IMDB-WIKI photos used as the real baseline (identical
|
| 77 |
+
# filenames confirmed in both zips) - only the face region is
|
| 78 |
+
# manipulated. Training on the whole image diluted that signal with
|
| 79 |
+
# mostly-real background pixels; cropping to the face isolates it.
|
| 80 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 81 |
train_data, val_data = load_portrait_dataset(
|
| 82 |
+
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=True,
|
| 83 |
)
|
| 84 |
|
| 85 |
print(f"Train samples: {len(train_data)}")
|
|
@@ -114,9 +114,15 @@ def main():
|
|
| 114 |
# ensemble at 30.8% accuracy on diffusion fakes vs 83-97% on real
|
| 115 |
# photos, and none of this model's prior training data was diffusion-
|
| 116 |
# generated at all.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 118 |
train_data, val_data = load_portrait_dataset(
|
| 119 |
-
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=
|
| 120 |
)
|
| 121 |
|
| 122 |
print(f"Train samples: {len(train_data)}")
|
|
|
|
| 114 |
# ensemble at 30.8% accuracy on diffusion fakes vs 83-97% on real
|
| 115 |
# photos, and none of this model's prior training data was diffusion-
|
| 116 |
# generated at all.
|
| 117 |
+
# face_align=True (changed from False): InsightFace fakes in
|
| 118 |
+
# OpenRL/DeepFakeFace turned out to be face-swaps applied directly onto
|
| 119 |
+
# the SAME real IMDB-WIKI photos used as the real baseline (identical
|
| 120 |
+
# filenames confirmed in both zips) - only the face region is
|
| 121 |
+
# manipulated. Training on the whole image diluted that signal with
|
| 122 |
+
# mostly-real background pixels; cropping to the face isolates it.
|
| 123 |
print("Loading portrait dataset (GAN sources + diffusion source)...")
|
| 124 |
train_data, val_data = load_portrait_dataset(
|
| 125 |
+
max_samples=MAX_SAMPLES, train_split=TRAIN_SPLIT, face_align=True,
|
| 126 |
)
|
| 127 |
|
| 128 |
print(f"Train samples: {len(train_data)}")
|
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train the classical ML tie-breaker (core_models/forensic_ml.py).
|
| 3 |
+
|
| 4 |
+
RandomForest on hand-crafted forensic features - not a deep model, runs on
|
| 5 |
+
CPU in minutes. See core_models/forensic_ml.py and forensic_features.py
|
| 6 |
+
for why this exists: training/diagnose_insight.py found InsightFace-style
|
| 7 |
+
face-swap-on-real-photo fakes clustering right at the 0.60 decision
|
| 8 |
+
boundary (0.38-0.68), which is a different failure mode than "the models
|
| 9 |
+
don't see it at all" - a classical texture/noise classifier focused on the
|
| 10 |
+
face region and its blending boundary is a cheap, independent second
|
| 11 |
+
opinion for exactly that uncertain zone.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
.venv/Scripts/python.exe training/train_forensic_ml.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import sys
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 21 |
+
if ROOT_DIR not in sys.path:
|
| 22 |
+
sys.path.insert(0, ROOT_DIR)
|
| 23 |
+
|
| 24 |
+
os.environ.setdefault("HF_HOME", os.path.join(ROOT_DIR, ".hf_cache"))
|
| 25 |
+
os.environ.setdefault("HF_DATASETS_CACHE", os.path.join(ROOT_DIR, ".hf_cache", "datasets"))
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
from tqdm import tqdm
|
| 29 |
+
from sklearn.model_selection import train_test_split
|
| 30 |
+
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
|
| 31 |
+
|
| 32 |
+
from training.dataset_portraits import load_portrait_dataset
|
| 33 |
+
from core_models.forensic_features import extract_forensic_features
|
| 34 |
+
from core_models.forensic_ml import ForensicMLClassifier
|
| 35 |
+
|
| 36 |
+
MAX_SAMPLES = 3000
|
| 37 |
+
MODEL_PATH = "models/forensic_ml.joblib"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
print("Loading portrait dataset for forensic ML training...")
|
| 42 |
+
# face_align=False: extract_forensic_features does its own face
|
| 43 |
+
# detection internally (it needs the whole image to compute the
|
| 44 |
+
# whole/face/context regions separately).
|
| 45 |
+
train_data, val_data = load_portrait_dataset(
|
| 46 |
+
max_samples=MAX_SAMPLES, train_split=1.0, face_align=False,
|
| 47 |
+
)
|
| 48 |
+
all_data = train_data + val_data
|
| 49 |
+
|
| 50 |
+
print(f"\nExtracting hand-crafted forensic features from {len(all_data)} images...")
|
| 51 |
+
X, y = [], []
|
| 52 |
+
for img, label in tqdm(all_data, desc="Extracting"):
|
| 53 |
+
try:
|
| 54 |
+
feats = extract_forensic_features(img)
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f" WARNING: feature extraction failed on one sample: {e}")
|
| 57 |
+
continue
|
| 58 |
+
X.append(feats)
|
| 59 |
+
y.append(label)
|
| 60 |
+
|
| 61 |
+
X = np.array(X)
|
| 62 |
+
y = np.array(y)
|
| 63 |
+
print(f"Feature matrix: {X.shape}, labels: {(y == 1).sum()} fake, {(y == 0).sum()} real")
|
| 64 |
+
|
| 65 |
+
X_train, X_val, y_train, y_val = train_test_split(
|
| 66 |
+
X, y, test_size=0.2, random_state=42, stratify=y
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
print("\nTraining RandomForest...")
|
| 70 |
+
clf = ForensicMLClassifier()
|
| 71 |
+
clf.fit(X_train, y_train)
|
| 72 |
+
|
| 73 |
+
preds = clf.model.predict(X_val)
|
| 74 |
+
acc = accuracy_score(y_val, preds)
|
| 75 |
+
print(f"\nHeld-out val accuracy: {acc:.4f}")
|
| 76 |
+
print(classification_report(y_val, preds, target_names=["real", "fake"]))
|
| 77 |
+
print("Confusion matrix:\n", confusion_matrix(y_val, preds))
|
| 78 |
+
|
| 79 |
+
importances = clf.model.feature_importances_
|
| 80 |
+
from core_models.forensic_features import FEATURE_NAMES
|
| 81 |
+
top10 = np.argsort(importances)[::-1][:10]
|
| 82 |
+
print("\nTop 10 most important features:")
|
| 83 |
+
for i in top10:
|
| 84 |
+
print(f" {FEATURE_NAMES[i]}: {importances[i]:.4f}")
|
| 85 |
+
|
| 86 |
+
os.makedirs("models", exist_ok=True)
|
| 87 |
+
clf.save(MODEL_PATH)
|
| 88 |
+
print(f"\nSaved to {MODEL_PATH}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
main()
|