| """ |
| Hand-crafted forensic feature extraction for the classical-ML tie-breaker |
| (core_models/forensic_ml.py). |
| |
| Not trying to replace the deep ensemble - complementary signal for the |
| specific blind spot training/diagnose_insight.py found: InsightFace fakes |
| in OpenRL/DeepFakeFace are face-swaps applied directly onto the SAME real |
| IMDB-WIKI photos used as the real baseline (identical filenames confirmed |
| in both zips), so most of the image genuinely is unmanipulated camera |
| output. Whole-image classifiers correctly read that as mostly-authentic; |
| the actual tell is localized to the face region and its blending boundary. |
| |
| Extracts the same feature set (color moments, LBP texture histogram, noise |
| residual stats, edge density) from three regions per image: |
| - whole image |
| - tight face crop |
| - a wider "context" crop around the face (captures the blend boundary |
| and surrounding area a tight crop would exclude) |
| plus a whole-image JPEG blockiness ratio and a has_face flag. |
| |
| If no face is detected, face/context features are zero-filled and |
| has_face=0 - the classifier learns to weight those accordingly rather than |
| receiving fabricated values. |
| """ |
|
|
| import numpy as np |
| import cv2 |
|
|
| from utils.gradcam import detect_and_align_face |
|
|
| REGION_FEATURE_NAMES = [ |
| "mean_r", "mean_g", "mean_b", "std_r", "std_g", "std_b", |
| "noise_mean", "noise_std", "edge_density", |
| "lbp_0", "lbp_1", "lbp_2", "lbp_3", "lbp_4", "lbp_5", "lbp_6", "lbp_7", |
| ] |
| N_REGION_FEATURES = len(REGION_FEATURE_NAMES) |
|
|
| FEATURE_NAMES = ( |
| [f"whole_{n}" for n in REGION_FEATURE_NAMES] |
| + [f"face_{n}" for n in REGION_FEATURE_NAMES] |
| + [f"context_{n}" for n in REGION_FEATURE_NAMES] |
| + ["jpeg_blockiness", "has_face"] |
| ) |
| N_FEATURES = len(FEATURE_NAMES) |
|
|
|
|
| def _lbp_histogram(gray, n_bins=8): |
| """Vectorized 8-neighbor, radius-1 LBP, binned into a normalized histogram.""" |
| if gray.shape[0] < 3 or gray.shape[1] < 3: |
| return [0.0] * n_bins |
|
|
| center = gray[1:-1, 1:-1].astype(np.int16) |
| neighbors = [ |
| gray[0:-2, 0:-2], gray[0:-2, 1:-1], gray[0:-2, 2:], |
| gray[1:-1, 2:], gray[2:, 2:], gray[2:, 1:-1], |
| gray[2:, 0:-2], gray[1:-1, 0:-2], |
| ] |
| code = np.zeros_like(center, dtype=np.uint8) |
| for i, n in enumerate(neighbors): |
| code |= ((n.astype(np.int16) >= center).astype(np.uint8) << i) |
|
|
| hist, _ = np.histogram(code, bins=n_bins, range=(0, 256)) |
| total = hist.sum() |
| return (hist / total).tolist() if total > 0 else [0.0] * n_bins |
|
|
|
|
| def _region_features(pil_crop): |
| """17 features for one image region: color moments, noise, edges, LBP.""" |
| arr = np.array(pil_crop.convert("RGB"), dtype=np.float64) |
| if arr.size == 0 or arr.shape[0] < 3 or arr.shape[1] < 3: |
| return [0.0] * N_REGION_FEATURES |
|
|
| gray = cv2.cvtColor(arr.astype(np.uint8), cv2.COLOR_RGB2GRAY) |
|
|
| pixels = arr.reshape(-1, 3) |
| means = pixels.mean(axis=0) |
| stds = pixels.std(axis=0) |
|
|
| laplacian = cv2.Laplacian(gray, cv2.CV_64F) |
| noise_mean = float(np.mean(np.abs(laplacian))) |
| noise_std = float(np.std(laplacian)) |
|
|
| sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) |
| sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3) |
| edge_density = float(np.mean(np.sqrt(sobel_x**2 + sobel_y**2))) |
|
|
| lbp_hist = _lbp_histogram(gray, n_bins=8) |
|
|
| return list(means) + list(stds) + [noise_mean, noise_std, edge_density] + lbp_hist |
|
|
|
|
| def _jpeg_blockiness(gray): |
| """Ratio of discontinuity at the 8x8 JPEG grid vs elsewhere - >1 suggests |
| block-boundary artifacts (recompression, splicing).""" |
| h, w = gray.shape |
| if h < 16 or w < 16: |
| return 0.0 |
| gray = gray.astype(np.float64) |
| col_idx = np.arange(8, w - 1, 8) |
| if len(col_idx) == 0: |
| return 0.0 |
| boundary_diff = np.mean(np.abs(gray[:, col_idx] - gray[:, col_idx - 1])) |
| all_diff = np.mean(np.abs(np.diff(gray, axis=1))) |
| return float(boundary_diff / (all_diff + 1e-6)) |
|
|
|
|
| def extract_forensic_features(pil_img): |
| """ |
| Returns a (N_FEATURES,) float32 vector: whole-image + face + context |
| region features, plus JPEG blockiness and has_face. |
| """ |
| img = pil_img.convert("RGB") |
| whole_feats = _region_features(img) |
|
|
| gray_whole = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY) |
| blockiness = _jpeg_blockiness(gray_whole) |
|
|
| try: |
| face_crop, bbox = detect_and_align_face(img, expand_ratio=0.1) |
| except Exception: |
| face_crop, bbox = None, None |
|
|
| has_face = 1.0 if face_crop is not None else 0.0 |
| face_feats = _region_features(face_crop) if face_crop is not None else [0.0] * N_REGION_FEATURES |
|
|
| context_feats = [0.0] * N_REGION_FEATURES |
| if bbox is not None: |
| x1, y1, x2, y2 = bbox |
| w, h = img.size |
| cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 |
| bw, bh = (x2 - x1), (y2 - y1) |
| scale = 1.6 |
| nx1 = max(0, int(cx - bw * scale / 2)) |
| ny1 = max(0, int(cy - bh * scale / 2)) |
| nx2 = min(w, int(cx + bw * scale / 2)) |
| ny2 = min(h, int(cy + bh * scale / 2)) |
| if nx2 > nx1 and ny2 > ny1: |
| context_crop = img.crop((nx1, ny1, nx2, ny2)) |
| context_feats = _region_features(context_crop) |
|
|
| return np.array( |
| whole_feats + face_feats + context_feats + [blockiness, has_face], |
| dtype=np.float32, |
| ) |
|
|