abrar6024 Claude Sonnet 5 commited on
Commit
a7745ec
·
1 Parent(s): 518aba3

Add classical ML tie-breaker + face-crop fix for face-swap blind spot

Browse files

training/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 CHANGED
@@ -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/
configs/models.json CHANGED
@@ -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",
core/pipeline.py CHANGED
@@ -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,
core_models/forensic_features.py ADDED
@@ -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
+ )
core_models/forensic_ml.py ADDED
@@ -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)
training/diagnose_insight.py ADDED
@@ -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()
training/train_dinov2.py CHANGED
@@ -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=False matches this model's original whole-image (not
49
- # face-cropped) training.
 
 
 
 
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=False,
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)}")
training/train_efficientnet_auth.py CHANGED
@@ -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=False,
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)}")
training/train_face_deepfake_hf.py CHANGED
@@ -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=False,
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)}")
training/train_forensic_ml.py ADDED
@@ -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()