| """M1: Forensics-pretrained backbone transfer test on AF data. |
| |
| Strategy: load CLIP ViT-L/14 (forensics literature shows CLIP image features are |
| unexpectedly strong on AI-image-detection because they encode high-level texture |
| statistics), train a tiny LINEAR probe on AF train videos (per-second binary |
| labels), then evaluate per-video gap on held-out AF videos. |
| |
| This is the simplest meaningful "external pretrained backbone + small head" |
| verifier. If it works, M2 is just: replace linear probe with temporal head and |
| push gap higher. If it fails, fall back to from-scratch training. |
| |
| Compare against current Qwen2.5-VL ForgeryHead baseline (per-video gap = +0.009, |
| global AUC = 0.65). Target for M1 go signal: gap > 0.05, AUC > 0.70. |
| """ |
| import os |
| import random |
| import sys |
| import time |
|
|
| import decord |
| import numpy as np |
| import torch |
| import torch.nn.functional as F |
| from decord import VideoReader, cpu |
| from PIL import Image |
| from transformers import CLIPModel, CLIPProcessor |
|
|
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| from src.open_r1.data_loader import TRAIN_GENERATORS, build_examples |
| from src.open_r1.forgery_head import frame_labels_from_segments |
|
|
| |
| N_VIDEOS = 200 |
| ANNOT = "/mnt/local-fast/zhangt/annot/annot" |
| VROOT = "/mnt/local-fast/zhangt/video" |
| MODEL_ID = "openai/clip-vit-large-patch14" |
| DEVICE = "cuda:0" |
| SEED = 42 |
| SAMPLE_FPS = 1.0 |
|
|
|
|
| def decode_video_at_1fps(video_path: str, duration: float): |
| """Return list of PIL images, one per second, at native resolution.""" |
| vr = VideoReader(video_path, ctx=cpu(0)) |
| fps_video = vr.get_avg_fps() |
| n_secs = max(1, int(duration)) |
| idxs = [] |
| for sec in range(n_secs): |
| idx = min(int(sec * fps_video), len(vr) - 1) |
| idxs.append(idx) |
| frames = vr.get_batch(idxs).asnumpy() |
| pil = [Image.fromarray(f) for f in frames] |
| return pil, len(idxs) |
|
|
|
|
| def main(): |
| random.seed(SEED) |
| np.random.seed(SEED) |
| torch.manual_seed(SEED) |
|
|
| print(f"Loading CLIP {MODEL_ID} ...", flush=True) |
| t0 = time.time() |
| clip = CLIPModel.from_pretrained(MODEL_ID, torch_dtype=torch.float32).to(DEVICE).eval() |
| proc = CLIPProcessor.from_pretrained(MODEL_ID) |
| print(f" loaded in {time.time()-t0:.1f}s. hidden={clip.config.projection_dim}", flush=True) |
|
|
| print("Building AF train examples ...", flush=True) |
| examples = build_examples( |
| annot_dir=ANNOT, video_root=VROOT, generators=TRAIN_GENERATORS, |
| split_prefix="train", preprocessed_data_path=None, require_video_exists=True, |
| ) |
| random.shuffle(examples) |
| examples = examples[:N_VIDEOS] |
| print(f" sampled {len(examples)} videos", flush=True) |
|
|
| |
| all_feats, all_labels, gens, vids = [], [], [], [] |
| t0 = time.time() |
| for i, ex in enumerate(examples, 1): |
| try: |
| pil_imgs, n_secs = decode_video_at_1fps(ex["video_path"], ex["durations"]) |
| except Exception as e: |
| print(f" [skip] {ex['video_path']}: {e}", flush=True) |
| continue |
| |
| with torch.no_grad(): |
| inputs = proc(images=pil_imgs, return_tensors="pt").to(DEVICE) |
| feats = clip.get_image_features(**inputs) |
| feats = F.normalize(feats, dim=-1) |
| lbls = frame_labels_from_segments( |
| ex["solution"], n_secs, fps_to_groups=SAMPLE_FPS |
| ).numpy() |
| all_feats.append(feats.cpu().numpy().astype(np.float32)) |
| all_labels.append(lbls.astype(np.float32)) |
| gens.append(ex["generator"]) |
| vids.append(ex["video_path"]) |
|
|
| if i % 20 == 0: |
| print(f" [{i}/{len(examples)}] elapsed={time.time()-t0:.0f}s", flush=True) |
|
|
| |
| n_train = int(0.8 * len(all_feats)) |
| X_tr = np.concatenate(all_feats[:n_train], axis=0) |
| y_tr = np.concatenate(all_labels[:n_train], axis=0) |
| test_feats = all_feats[n_train:] |
| test_labels = all_labels[n_train:] |
| test_gens = gens[n_train:] |
| print(f"\ntrain: {X_tr.shape[0]} frames ({(y_tr>0.5).sum()} pos / {(y_tr<0.5).sum()} neg)", flush=True) |
| print(f"test: {len(test_feats)} videos, {sum(len(x) for x in test_feats)} frames", flush=True) |
|
|
| |
| Xt = torch.tensor(X_tr, dtype=torch.float32, device=DEVICE) |
| yt = torch.tensor(y_tr, dtype=torch.float32, device=DEVICE) |
| probe = torch.nn.Linear(Xt.shape[1], 1).to(DEVICE) |
| opt = torch.optim.AdamW(probe.parameters(), lr=1e-2, weight_decay=1e-4) |
| pos_weight = torch.tensor([(yt < 0.5).sum().item() / max(1, (yt > 0.5).sum().item())]).to(DEVICE) |
| print(f"BCE pos_weight={pos_weight.item():.3f}", flush=True) |
|
|
| for epoch in range(100): |
| logits = probe(Xt).squeeze(-1) |
| loss = F.binary_cross_entropy_with_logits(logits, yt, pos_weight=pos_weight) |
| opt.zero_grad(); loss.backward(); opt.step() |
| if (epoch + 1) % 20 == 0: |
| pred = (logits.sigmoid() > 0.5).float() |
| acc = (pred == yt).float().mean() |
| print(f" epoch {epoch+1:3d} loss={loss.item():.4f} train_acc={acc.item():.3f}", flush=True) |
|
|
| |
| probe.eval() |
| per_video_gap = [] |
| per_gen = {} |
| all_test_scores, all_test_labels = [], [] |
| with torch.no_grad(): |
| for feats, lbls, g in zip(test_feats, test_labels, test_gens): |
| logits = probe(torch.tensor(feats, device=DEVICE)).squeeze(-1).cpu().numpy() |
| scores = 1.0 / (1.0 + np.exp(-logits)) |
| all_test_scores.append(scores) |
| all_test_labels.append(lbls) |
| if lbls.any() and not lbls.all(): |
| m_in = float(scores[lbls > 0.5].mean()) |
| m_out = float(scores[lbls < 0.5].mean()) |
| per_video_gap.append(m_in - m_out) |
| per_gen.setdefault(g, []).append((m_in, m_out)) |
|
|
| |
| print("\n========== CLIP LINEAR-PROBE — TRANSFER ON AF ==========") |
| if all_test_scores: |
| S = np.concatenate(all_test_scores) |
| Y = np.concatenate(all_test_labels) |
| n_pos, n_neg = int((Y > 0.5).sum()), int((Y < 0.5).sum()) |
| print(f"test frames: {len(S)} pos={n_pos} neg={n_neg}") |
| print(f"global score POS={S[Y>0.5].mean():.3f} NEG={S[Y<0.5].mean():.3f} gap={S[Y>0.5].mean()-S[Y<0.5].mean():+.3f}") |
| |
| pos_s = S[Y > 0.5]; neg_s = S[Y < 0.5] |
| if len(pos_s) > 4000 or len(neg_s) > 4000: |
| rng = np.random.default_rng(SEED) |
| pos_s = rng.choice(pos_s, size=min(len(pos_s), 4000), replace=False) |
| neg_s = rng.choice(neg_s, size=min(len(neg_s), 4000), replace=False) |
| cmp = (pos_s[:, None] > neg_s[None, :]).astype(float) |
| eq = (pos_s[:, None] == neg_s[None, :]).astype(float) * 0.5 |
| auc = (cmp + eq).mean() |
| print(f"global AUC (sampled cmp): {auc:.3f}") |
|
|
| if per_video_gap: |
| arr = np.array(per_video_gap) |
| print(f"\nper-video gap (in_GT - out_GT) over {len(arr)} videos:") |
| for q in [0, 10, 25, 50, 75, 90, 100]: |
| print(f" p{q:3d} = {np.percentile(arr, q):+.3f}") |
| print(f" mean = {arr.mean():+.3f} std = {arr.std():.3f}") |
| print(f" gap > 0.05 : {(arr > 0.05).mean():.2%}") |
| print(f" gap > 0.10 : {(arr > 0.10).mean():.2%}") |
| print(f" gap > 0.15 : {(arr > 0.15).mean():.2%}") |
|
|
| if per_gen: |
| print("\nper-generator (test split only):") |
| print(f" {'gen':<12} {'n':>4} {'pos':>6} {'neg':>6} {'gap':>6}") |
| for g in sorted(per_gen.keys()): |
| pairs = per_gen[g] |
| mp = np.mean([p[0] for p in pairs]) |
| mn = np.mean([p[1] for p in pairs]) |
| print(f" {g:<12} {len(pairs):>4} {mp:>6.3f} {mn:>6.3f} {mp-mn:>+6.3f}") |
|
|
| |
| print("\n--- Baseline (Qwen ForgeryHead, from head_sanity): " |
| "per-video gap = +0.009, global AUC = 0.650 ---") |
| print("Target for M1 go: gap mean > +0.05, AUC > 0.70.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|