synthsenses-api / model_a /extract_features.py
parina004
Initial HF deployment
e6efe7e
Raw
History Blame Contribute Delete
19.3 kB
## Model A — Feature Extraction (Step 1 of 2)
## Reads manifest.csv, runs every video's frames through 3 parallel streams,
## and saves per-video feature vectors as NPZ files for model_a/train.py.
## Stream 1 EfficientNet-B4 pretrained on ImageNet, backbone frozen.
## GlobalAvgPool output = 1792-dim. Averaged across all frames.
## Stream 2 Forensic CNN trained from scratch on training frames.
## Input = 6-channel tensor: 3 DCT channels (RGB) + 3 SRM noise
## residual channels. GlobalAvgPool output = 256-dim.
## Trained as 3-class classifier (real / deepfake / ai_generated)
## then used as a feature extractor (head discarded).
## Stream 3 MediaPipe FaceMesh 468 landmarks -> 16 geometric ratios.
## Averaged across frames. Zero vector when no face is detected.
## Final per-video vector: 1792 + 256 + 16 = 2064-dim.
## Outputs saved to model_a/:
## forensic_cnn.pth — trained Forensic CNN weights (Stream 2)
## features_train.npz — X (N,2064), y, video_ids, sources
## features_test.npz — same structure, held-out test split
## Checkpointed every video — safe to interrupt and resume.
## Run with: uv run model_a/extract_features.py
import csv
import json
import logging
import time
from pathlib import Path
import cv2
import mediapipe as mp
import numpy as np
import scipy.fft
import timm
import torch
import torch.nn as nn
import torch.nn.functional as F
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from tqdm import tqdm
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)
ROOT = Path(__file__).parent.parent
MANIFEST = ROOT / "data" / "model_a_datasets" / "frames" / "manifest.csv"
OUT_DIR = ROOT / "model_a"
OUT_DIR.mkdir(parents=True, exist_ok=True)
CNN_PATH = OUT_DIR / "forensic_cnn.pth"
CKPT_PATH = OUT_DIR / ".extract_checkpoint.json"
TRAIN_NPZ = OUT_DIR / "features_train.npz"
TEST_NPZ = OUT_DIR / "features_test.npz"
## label encoding (consistent across both submodels)
LABEL_MAP = {"real": 0, "deepfake": 1, "ai_generated": 2}
## training settings for the Forensic CNN
CNN_EPOCHS = 10
CNN_BATCH = 32
CNN_LR = 1e-3
CNN_VAL_SPLIT = 0.10 ## 10% of train frames held out for CNN validation
## pause for this many seconds every PAUSE_EVERY videos to let the CPU/MPS breathe
PAUSE_EVERY = 500
PAUSE_SECS = 10
## device — CPU only during inference (MPS conflicts with MediaPipe's Metal/GL context on Apple Silicon)
## Change back to MPS/CUDA if running standalone training without MediaPipe loaded.
DEVICE = (
torch.device("cuda") if torch.cuda.is_available() else
torch.device("cpu")
)
## ImageNet normalisation for EfficientNet
IMAGENET_NORM = transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225],
)
TO_TENSOR = transforms.ToTensor()
## SRM high-pass noise residual filters (3 key kernels from Fridrich 2012)
## These suppress image content and reveal noise patterns left by generators.
_SRM_K = [
torch.tensor([[0, 0, 0], [ 0.5, -1, 0.5], [0, 0, 0]], dtype=torch.float32), ## horiz
torch.tensor([[0, 0.5, 0], [0, -1, 0], [0, 0.5, 0]], dtype=torch.float32), ## vert
torch.tensor([[0.25, -0.5, 0.25], [-0.5, 1, -0.5], [0.25, -0.5, 0.25]], dtype=torch.float32),
]
## shape: (3, 1, 3, 3) — applied per grayscale channel via groups
SRM_KERNELS = torch.stack([k.unsqueeze(0).unsqueeze(0) for k in _SRM_K]).to(DEVICE)
def srm_residuals(gray_tensor: torch.Tensor) -> torch.Tensor:
## gray_tensor: (B, 1, H, W) or (1, H, W)
if gray_tensor.dim() == 3:
gray_tensor = gray_tensor.unsqueeze(0)
out = []
for k in SRM_KERNELS.unbind(0):
r = F.conv2d(gray_tensor, k, padding=1)
out.append(r)
return torch.cat(out, dim=1) ## (B, 3, H, W)
def compute_dct_channels(img_np: np.ndarray) -> np.ndarray:
## img_np: (H, W, 3) uint8 -> returns (3, H, W) float32
out = []
for c in range(3):
ch = img_np[:, :, c].astype(np.float32) / 255.0
dct = np.abs(scipy.fft.dctn(ch, norm="ortho"))
dct = np.log1p(dct)
lo, hi = dct.min(), dct.max()
dct = (dct - lo) / (hi - lo + 1e-8)
out.append(dct)
return np.stack(out).astype(np.float32) ## (3, H, W)
def frame_to_forensic_tensor(img_np: np.ndarray) -> torch.Tensor:
## img_np: (H, W, 3) BGR uint8 -> (6, H, W) forensic tensor
rgb = cv2.cvtColor(img_np, cv2.COLOR_BGR2RGB)
dct = torch.tensor(compute_dct_channels(rgb)) ## (3, H, W)
gray = torch.tensor(
cv2.cvtColor(img_np, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
).unsqueeze(0).unsqueeze(0) ## (1, 1, H, W)
srm = srm_residuals(gray.to(DEVICE)).squeeze(0).cpu() ## (3, H, W)
return torch.cat([dct, srm], dim=0) ## (6, H, W)
## mediapipe key landmark indices
_LM = {
"left_eye_outer": 33, "right_eye_outer": 263,
"left_eye_inner": 133, "right_eye_inner": 362,
"nose_tip": 1, "nose_bridge": 168,
"left_mouth": 61, "right_mouth": 291,
"upper_lip": 13, "lower_lip": 14,
"chin": 18, "forehead": 10,
"left_cheek": 234, "right_cheek": 454,
"left_brow_outer": 70, "right_brow_outer": 300,
}
_mp_face_mesh = mp.solutions.face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
refine_landmarks=False,
min_detection_confidence=0.5,
)
def _dist(lm, a, b, w, h):
pa = np.array([lm[a].x * w, lm[a].y * h])
pb = np.array([lm[b].x * w, lm[b].y * h])
return float(np.linalg.norm(pa - pb))
def geometric_features(img_np: np.ndarray) -> np.ndarray:
## img_np: (H, W, 3) BGR uint8 -> (16,) float32, zeros if no face
H, W = img_np.shape[:2]
rgb = cv2.cvtColor(img_np, cv2.COLOR_BGR2RGB)
res = _mp_face_mesh.process(rgb)
if not res.multi_face_landmarks:
return np.zeros(16, dtype=np.float32)
lm = res.multi_face_landmarks[0].landmark
L = _LM
face_h = _dist(lm, L["forehead"], L["chin"], W, H) + 1e-6
face_w = _dist(lm, L["left_cheek"], L["right_cheek"], W, H) + 1e-6
eye_d = _dist(lm, L["left_eye_outer"], L["right_eye_outer"], W, H)
nose_w = _dist(lm, L["left_mouth"], L["right_mouth"], W, H) * 0.6
mouth_w= _dist(lm, L["left_mouth"], L["right_mouth"], W, H)
mouth_h= _dist(lm, L["upper_lip"], L["lower_lip"], W, H)
l_eye_w= _dist(lm, L["left_eye_outer"], L["left_eye_inner"], W, H)
r_eye_w= _dist(lm, L["right_eye_outer"], L["right_eye_inner"], W, H)
brow_h_l = abs(lm[L["left_brow_outer"]].y - lm[L["left_eye_outer"]].y) * H
brow_h_r = abs(lm[L["right_brow_outer"]].y - lm[L["right_eye_outer"]].y) * H
chin_mouth= _dist(lm, L["chin"], L["lower_lip"], W, H)
nose_h = _dist(lm, L["nose_bridge"], L["nose_tip"], W, H)
left_x = lm[L["left_cheek"]].x * W
right_x = lm[L["right_cheek"]].x * W
mid_x = (left_x + right_x) / 2
nose_x = lm[L["nose_tip"]].x * W
symmetry = abs(nose_x - mid_x) / (face_w + 1e-6)
feats = np.array([
eye_d / face_h, ## 0 inter-eye dist ratio
l_eye_w / face_h, ## 1 left eye width ratio
r_eye_w / face_h, ## 2 right eye width ratio
nose_w / face_w, ## 3 nose width ratio
nose_h / face_h, ## 4 nose height ratio
mouth_w / face_w, ## 5 mouth width ratio
mouth_h / face_h, ## 6 mouth openness ratio
face_w / face_h, ## 7 face aspect ratio
brow_h_l / face_h, ## 8 left brow height ratio
brow_h_r / face_h, ## 9 right brow height ratio
chin_mouth / face_h, ## 10 chin-to-mouth ratio
abs(l_eye_w - r_eye_w) / (l_eye_w + r_eye_w + 1e-6), ## 11 eye size symmetry
abs(brow_h_l - brow_h_r) / (face_h + 1e-6), ## 12 brow symmetry
symmetry, ## 13 face horizontal symmetry
nose_w / face_h, ## 14 nose proportion
eye_d / face_w, ## 15 eye span ratio
], dtype=np.float32)
return np.clip(feats, 0.0, 5.0) ## clamp outliers
## Forensic CNN — definition and training logic
class ForensicCNN(nn.Module):
def __init__(self, n_classes: int = 3):
super().__init__()
self.backbone = nn.Sequential(
nn.Conv2d(6, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(64, 128, 3, padding=1),nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(128, 256, 3, padding=1),nn.BatchNorm2d(256),nn.ReLU(),
nn.AdaptiveAvgPool2d(1),
)
self.head = nn.Linear(256, n_classes)
def forward(self, x):
feat = self.backbone(x).squeeze(-1).squeeze(-1)
return self.head(feat), feat ## (logits, 256-dim features)
class ForensicFrameDataset(Dataset):
def __init__(self, frame_paths: list[Path], labels: list[int]):
self.paths = frame_paths
self.labels = labels
def __len__(self):
return len(self.paths)
def __getitem__(self, idx):
img = cv2.imread(str(self.paths[idx]))
if img is None:
return torch.zeros(6, 224, 224), self.labels[idx]
return frame_to_forensic_tensor(img), self.labels[idx]
def train_forensic_cnn(rows_train: list[dict]) -> ForensicCNN:
if CNN_PATH.exists():
log.info("Forensic CNN weights found — loading cached model")
model = ForensicCNN().to(DEVICE)
model.load_state_dict(torch.load(CNN_PATH, map_location=DEVICE))
model.eval()
return model
log.info("Training Forensic CNN from scratch ...")
## collect all frame paths + frame-level labels from training videos
all_paths, all_labels = [], []
for row in rows_train:
frame_dir = ROOT / row["frame_dir"]
label = LABEL_MAP[row["label"]]
for jpg in sorted(frame_dir.glob("*.jpg")):
all_paths.append(jpg)
all_labels.append(label)
log.info(f" {len(all_paths)} training frames across {len(rows_train)} videos")
## train / val split
n_val = max(1, int(len(all_paths) * CNN_VAL_SPLIT))
idx = np.random.permutation(len(all_paths))
val_idx = idx[:n_val]
trn_idx = idx[n_val:]
trn_ds = ForensicFrameDataset([all_paths[i] for i in trn_idx], [all_labels[i] for i in trn_idx])
val_ds = ForensicFrameDataset([all_paths[i] for i in val_idx], [all_labels[i] for i in val_idx])
trn_dl = DataLoader(trn_ds, batch_size=CNN_BATCH, shuffle=True, num_workers=2, pin_memory=False)
val_dl = DataLoader(val_ds, batch_size=CNN_BATCH, shuffle=False, num_workers=2, pin_memory=False)
model = ForensicCNN().to(DEVICE)
optimizer = torch.optim.Adam(model.parameters(), lr=CNN_LR)
criterion = nn.CrossEntropyLoss()
best_val_loss = float("inf")
for epoch in range(1, CNN_EPOCHS + 1):
model.train()
trn_loss = 0.0
for x, y in tqdm(trn_dl, desc=f" Epoch {epoch}/{CNN_EPOCHS} [train]", leave=False):
x, y = x.to(DEVICE), y.to(DEVICE)
logits, _ = model(x)
loss = criterion(logits, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
trn_loss += loss.item()
model.eval()
val_loss = 0.0
correct = 0
with torch.no_grad():
for x, y in tqdm(val_dl, desc=f" Epoch {epoch}/{CNN_EPOCHS} [val] ", leave=False):
x, y = x.to(DEVICE), y.to(DEVICE)
logits, _ = model(x)
val_loss += criterion(logits, y).item()
correct += (logits.argmax(1) == y).sum().item()
avg_trn = trn_loss / len(trn_dl)
avg_val = val_loss / len(val_dl)
val_acc = correct / len(val_ds)
log.info(f" Epoch {epoch}/{CNN_EPOCHS} train_loss={avg_trn:.4f} val_loss={avg_val:.4f} val_acc={val_acc:.4f}")
if avg_val < best_val_loss:
best_val_loss = avg_val
torch.save(model.state_dict(), CNN_PATH)
log.info(f" -> saved best model (val_loss={avg_val:.4f})")
log.info(f"Forensic CNN training complete. Best val_loss: {best_val_loss:.4f}")
model.load_state_dict(torch.load(CNN_PATH, map_location=DEVICE))
model.eval()
return model
## EfficientNet-B4 pretrained feature extractor (Stream 1)
def build_efficientnet() -> nn.Module:
model = timm.create_model("efficientnet_b4", pretrained=True, num_classes=0, global_pool="avg")
for p in model.parameters():
p.requires_grad = False
model.eval()
return model.to(DEVICE)
## runs all 3 streams on every frame in a video folder and returns the averaged vector
@torch.no_grad()
def extract_video_features(
frame_dir: Path,
efficientnet: nn.Module,
forensic_cnn: ForensicCNN,
) -> np.ndarray:
jpgs = sorted(frame_dir.glob("*.jpg"))
if not jpgs:
return np.zeros(2064, dtype=np.float32)
s1_vecs, s2_vecs, s3_vecs = [], [], []
log.info(f"Processing {len(jpgs)} frames...")
for i, jpg in enumerate(jpgs):
img = cv2.imread(str(jpg))
if img is None:
continue
log.info(f" Frame {i+1}/{len(jpgs)}: running EfficientNet-B4 (RGB stream)...")
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
t = IMAGENET_NORM(TO_TENSOR(pil)).unsqueeze(0).to(DEVICE)
s1_vec = efficientnet(t).squeeze().cpu().numpy()
s1_vecs.append(s1_vec)
log.info(f" Frame {i+1}/{len(jpgs)}: scanning DCT/SRM frequency artifacts (Forensic CNN)...")
forensic_t = frame_to_forensic_tensor(img).unsqueeze(0).to(DEVICE)
_, s2_vec = forensic_cnn(forensic_t)
s2_vecs.append(s2_vec.squeeze().cpu().numpy())
log.info(f" Frame {i+1}/{len(jpgs)}: extracting facial geometry (MediaPipe)...")
s3_vecs.append(geometric_features(img))
if not s1_vecs:
return np.zeros(2064, dtype=np.float32)
s1 = np.mean(s1_vecs, axis=0) ## (1792,)
s2 = np.mean(s2_vecs, axis=0) ## (256,)
s3 = np.mean(s3_vecs, axis=0) ## (16,)
return np.concatenate([s1, s2, s3]).astype(np.float32) ## (2064,)
## checkpoint helpers so a long run can be interrupted and resumed safely
def load_checkpoint() -> dict[str, np.ndarray]:
if CKPT_PATH.exists():
raw = json.loads(CKPT_PATH.read_text())
return {k: np.array(v) for k, v in raw.items()}
return {}
def save_checkpoint(done: dict[str, np.ndarray]):
CKPT_PATH.write_text(json.dumps({k: v.tolist() for k, v in done.items()}))
def main():
log.info("=" * 62)
log.info("Model A — Feature Extraction")
log.info(f"Device: {DEVICE}")
log.info("=" * 62)
with open(MANIFEST, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
rows_train = [r for r in rows if r["split"] == "train"]
rows_test = [r for r in rows if r["split"] == "test"]
log.info(f"Videos — train: {len(rows_train)} test: {len(rows_test)}")
log.info("")
## log a per-source breakdown so it's clear which datasets are included
from collections import Counter
source_counts = Counter(r["dataset_source"] for r in rows)
label_counts = Counter(r["label"] for r in rows)
log.info("Dataset sources:")
for src, n in sorted(source_counts.items()):
log.info(f" {src:<35} {n}")
log.info("")
log.info("Label distribution:")
for lbl, n in sorted(label_counts.items()):
log.info(f" {lbl:<15} {n}")
log.info("")
## build models
log.info("Loading EfficientNet-B4 (pretrained, frozen) ...")
efficientnet = build_efficientnet()
log.info("Preparing Forensic CNN (Stream 2) ...")
forensic_cnn = train_forensic_cnn(rows_train)
forensic_cnn.eval()
## load any previous checkpoint
done = load_checkpoint()
log.info(f"Resuming from checkpoint: {len(done)} videos already done")
log.info("")
results: dict[str, list] = {
"train": {"X": [], "y": [], "video_ids": [], "sources": []},
"test": {"X": [], "y": [], "video_ids": [], "sources": []},
}
## add already-done videos back into results (they're in the checkpoint)
## checkpoint stores video_id -> feature vector
## we need to match them back to their split/label for the NPZ
id_to_row = {f"{r['dataset_source']}__{r['video_id']}": r for r in rows}
for uid, feat in done.items():
row = id_to_row.get(uid)
if row is None:
continue
split = row["split"]
results[split]["X"].append(feat)
results[split]["y"].append(LABEL_MAP[row["label"]])
results[split]["video_ids"].append(row["video_id"])
results[split]["sources"].append(row["dataset_source"])
t_start = time.time()
n_total = len(rows)
n_done = len(done)
for row in tqdm(rows, desc="Extracting features"):
uid = f"{row['dataset_source']}__{row['video_id']}"
if uid in done:
continue
frame_dir = ROOT / row["frame_dir"]
feat = extract_video_features(frame_dir, efficientnet, forensic_cnn)
split = row["split"]
results[split]["X"].append(feat)
results[split]["y"].append(LABEL_MAP[row["label"]])
results[split]["video_ids"].append(row["video_id"])
results[split]["sources"].append(row["dataset_source"])
done[uid] = feat
n_done += 1
## checkpoint every 100 videos
if n_done % 100 == 0:
save_checkpoint(done)
elapsed = time.time() - t_start
rate = elapsed / max(n_done - len(results["train"]["X"]) - len(results["test"]["X"]) + len(done), 1)
eta = (n_total - n_done) * (elapsed / n_done)
log.info(f" {n_done}/{n_total} elapsed={elapsed/60:.1f}min ETA~{eta/60:.0f}min")
## every PAUSE_EVERY videos take a short break so the machine doesn't overheat
if n_done % PAUSE_EVERY == 0:
log.info(f" [pause] cooling down for {PAUSE_SECS}s ...")
time.sleep(PAUSE_SECS)
## save NPZ files
for split, npz_path in [("train", TRAIN_NPZ), ("test", TEST_NPZ)]:
d = results[split]
np.savez(
npz_path,
X = np.array(d["X"], dtype=np.float32),
y = np.array(d["y"], dtype=np.int32),
video_ids = np.array(d["video_ids"], dtype=object),
sources = np.array(d["sources"], dtype=object),
)
log.info(f"Saved {split}: {len(d['X'])} videos -> {npz_path.name}")
## clean up checkpoint
CKPT_PATH.unlink(missing_ok=True)
log.info("Checkpoint removed.")
log.info("")
log.info("=" * 62)
log.info("Feature extraction complete. Run model_a/train.py next.")
log.info("=" * 62)
if __name__ == "__main__":
main()