File size: 6,020 Bytes
cd24b4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"""Retrain the deployed ReStraV checkpoint with the corrected (official) recipe.

The previous restrav.pth was trained on ImageNet-normalized frames but is *served* raw [0,1]
(registry video_norm = identity) -- a train/serve mismatch that collapsed it (AUROC ~0.50,
real_acc ~2%). This retrains the SynthScope `SynthScopeReStraVModel` head end-to-end on raw [0,1] frames,
so training and serving use the exact same DINOv2 features.

Key fidelity choices:
  * Features come from the model's OWN ``_encode`` + ``_geometry_features`` (so they are
    bit-identical to what the deployed forward computes, incl. the official [mean,min,max,var]
    summary order), with frames decoded by the SAME serving pipeline (media.video_clip_tensor,
    center window + BICUBIC, raw [0,1]).
  * Official training recipe: standardize (BatchNorm1d running stats, train mode), MLP
    21->64->32->1, BCEWithLogits, plain Adam lr 1e-3 (no weight decay), 10 epochs, batch 32.
  * Save the FINAL-epoch state_dict (no val-loss checkpoint selection).

Run: conda run -n SynthScope python interface/scripts/retrain_restrav.py [--n 3000]
"""
import argparse
import glob
import random
import sys
import time
from pathlib import Path

import torch
import torch.nn as nn

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))   # interface/
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))   # repo root (config_utils)
from backend.registry import get_spec           # noqa: E402
from backend import media                        # noqa: E402
from backend.engine import _build_model          # noqa: E402

DEV = "cuda" if torch.cuda.is_available() else "cpu"
TRAIN_ROOT = "/hddstore/ssalehi/SynthScope/restrav_data/train/genvideo"
OUT_DIR = Path("/hddstore/ssalehi/SynthScope/checkpoints/_train/restrav_fixed")
DEPLOY_LINK = Path("/hddstore/ssalehi/SynthScope/checkpoints/restrav.pth")


def build_model(spec):
    from config_utils import compose_config
    cfg = compose_config(spec.overrides)
    model = _build_model(cfg).to(DEV).float()
    model.eval()                      # frozen backbone stays in eval
    return model


@torch.no_grad()
def extract_features(model, spec, items, tag):
    X, y = [], []
    t0 = time.time()
    for i, (p, lab) in enumerate(items):
        try:
            clip = media.video_clip_tensor(
                p, spec.num_frames, spec.fps, spec.frame_size,
                window_center=spec.window_center, interp=spec.resize_interp,
            )                                                # [T,3,H,W] raw [0,1]
            z = model._encode(clip.unsqueeze(0).to(DEV))     # [1,T,D]
            feat = model._geometry_features(z)[0]            # [21]
            if torch.isfinite(feat).all():
                X.append(feat.cpu()); y.append(lab)
        except Exception as e:
            if i < 5:
                print(f"  skip {Path(p).name}: {e}", flush=True)
        if (i + 1) % 300 == 0:
            print(f"  [{tag}] {i+1}/{len(items)}  ({(time.time()-t0)/(i+1):.2f}s/vid)", flush=True)
    return torch.stack(X), torch.tensor(y, dtype=torch.float32)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--n", type=int, default=3000, help="videos per class")
    ap.add_argument("--epochs", type=int, default=10)
    args = ap.parse_args()

    spec = get_spec("restrav")
    model = build_model(spec)
    print(f"model built on {DEV}; serving recipe: num_frames={spec.num_frames} "
          f"window_center={spec.window_center} interp={spec.resize_interp} video_norm={spec.video_norm}", flush=True)

    reals = sorted(glob.glob(TRAIN_ROOT + "/0_real/*.mp4"))
    fakes = sorted(glob.glob(TRAIN_ROOT + "/1_fake/*.mp4"))
    random.Random(0).shuffle(reals); random.Random(1).shuffle(fakes)
    # SynthScope label convention: 0 = real, 1 = fake.
    items = [(p, 0) for p in reals[:args.n]] + [(p, 1) for p in fakes[:args.n]]
    random.Random(2).shuffle(items)
    print(f"extracting features for {len(items)} videos (raw [0,1], model._encode)...", flush=True)
    X, y = extract_features(model, spec, items, "train")
    X = X.to(DEV); y = y.to(DEV)
    print(f"features: {tuple(X.shape)}  reals={(y==0).sum().item()} fakes={(y==1).sum().item()}", flush=True)

    # --- train head (official recipe). feature_norm is BatchNorm1d(affine=False): no params,
    #     just running stats accumulated in train mode. classifier holds the trainable weights. ---
    model.feature_norm.train(); model.classifier.train()
    opt = torch.optim.Adam(model.classifier.parameters(), lr=1e-3)   # wd=0 (plain Adam)
    crit = nn.BCEWithLogitsLoss()
    for ep in range(args.epochs):
        perm = torch.randperm(len(X), device=DEV)
        tot = 0.0
        for i in range(0, len(X), 32):
            b = perm[i:i+32]
            opt.zero_grad()
            logit = model.classifier(model.feature_norm(X[b])).squeeze(-1)   # fake logit
            loss = crit(logit, y[b])
            loss.backward(); opt.step()
            tot += loss.item() * len(b)
        print(f"  epoch {ep+1}/{args.epochs}  loss={tot/len(X):.4f}", flush=True)

    # --- save FINAL state_dict (matches what engine._load_checkpoint expects) ---
    model.eval()
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    out = OUT_DIR / "model_final.pth"
    torch.save(model.state_dict(), out)
    print(f"saved {out}", flush=True)

    # quick in-sample sanity (real_acc / fake_acc on the train features)
    with torch.no_grad():
        p_fake = torch.sigmoid(model.classifier(model.feature_norm(X)).squeeze(-1))
        pred = (p_fake > 0.5).float()
        racc = (pred[y == 0] == 0).float().mean().item()
        facc = (pred[y == 1] == 1).float().mean().item()
    print(f"in-sample: real_acc={racc*100:.1f} fake_acc={facc*100:.1f}", flush=True)

    # relink the deployed checkpoint
    if DEPLOY_LINK.is_symlink() or DEPLOY_LINK.exists():
        DEPLOY_LINK.unlink()
    DEPLOY_LINK.symlink_to(out)
    print(f"relinked {DEPLOY_LINK} -> {out}", flush=True)


if __name__ == "__main__":
    main()