proofyx / core_models /forensic_features.py
abrar6024's picture
Add classical ML tie-breaker + face-crop fix for face-swap blind spot
a7745ec
Raw
History Blame Contribute Delete
5.35 kB
"""
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) # 17
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) # 53
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 # 60% wider than the face bbox - captures the blend boundary
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,
)