Spaces:
Sleeping
Sleeping
| ## 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 | |
| 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() | |