SMSD75 commited on
Commit
91d49eb
·
verified ·
1 Parent(s): 031604d

Update SynthScope Space from the GitHub repository

Browse files
LICENSES.md CHANGED
@@ -40,6 +40,7 @@ third-party components, and every **pretrained checkpoint / backbone** SynthScop
40
  | `dinov3` | DINOv3 probe | facebookresearch/dinov3 | DINOv3 License (gated) | DINOv3 backbone | **gated** — DINOv3 License |
41
  | `adapted_dino` | Adapter-tuned DINOv2 (SynthScope) | this repo | Apache-2.0 | — (SynthScope-trained) | n/a |
42
  | `d3` | D3 (ICCV'25) | ⚠ verify (official repo) | ⚠ verify | training-free (no weights) | n/a |
 
43
  | `restrav` | ReStraV | ⚠ verify (official repo) | ⚠ verify | — (SynthScope-trained) | n/a |
44
  | `stall` | STALL (CVPR'26) | ⚠ verify (official repo) | ⚠ verify | training-free; needs DINOv3 backbone | gated (DINOv3) |
45
 
@@ -55,6 +56,7 @@ third-party components, and every **pretrained checkpoint / backbone** SynthScop
55
  | SigLIP | `co_spy` | Apache-2.0 (verify) |
56
  | MFM ViT-B/16 | `spai` | ⚠ verify |
57
  | Stable Diffusion VAE | `co_spy` | CreativeML Open RAIL-M (verify) |
 
58
  | LlamaGen VQ-16 tokenizer (`vq_ds16_c2i.pt`, HF `FoundationVision/LlamaGen`) | `d3qe` | MIT |
59
  | Perception Encoder Core L/14-336 (`facebook/PE-Core-L14-336`) | `cure` | Apache-2.0 (Meta); `official_arch.py` vendors the CuRe/Meta backbone code — see its header |
60
 
 
40
  | `dinov3` | DINOv3 probe | facebookresearch/dinov3 | DINOv3 License (gated) | DINOv3 backbone | **gated** — DINOv3 License |
41
  | `adapted_dino` | Adapter-tuned DINOv2 (SynthScope) | this repo | Apache-2.0 | — (SynthScope-trained) | n/a |
42
  | `d3` | D3 (ICCV'25) | ⚠ verify (official repo) | ⚠ verify | training-free (no weights) | n/a |
43
+ | `split` | SPLIT (ECCV'26) | mldljyh/SPLIT | no LICENSE file in the repo: **no code copied**; `models/video/split/split.py` is SynthScope's own implementation of the paper's equations | training-free (no weights) | n/a |
44
  | `restrav` | ReStraV | ⚠ verify (official repo) | ⚠ verify | — (SynthScope-trained) | n/a |
45
  | `stall` | STALL (CVPR'26) | ⚠ verify (official repo) | ⚠ verify | training-free; needs DINOv3 backbone | gated (DINOv3) |
46
 
 
56
  | SigLIP | `co_spy` | Apache-2.0 (verify) |
57
  | MFM ViT-B/16 | `spai` | ⚠ verify |
58
  | Stable Diffusion VAE | `co_spy` | CreativeML Open RAIL-M (verify) |
59
+ | X-CLIP B/16 (`microsoft/xclip-base-patch16`) | `d3`, `split` | MIT (HF model card) |
60
  | LlamaGen VQ-16 tokenizer (`vq_ds16_c2i.pt`, HF `FoundationVision/LlamaGen`) | `d3qe` | MIT |
61
  | Perception Encoder Core L/14-336 (`facebook/PE-Core-L14-336`) | `cure` | Apache-2.0 (Meta); `official_arch.py` vendors the CuRe/Meta backbone code — see its header |
62
 
benchmark/bench_eval_video.py CHANGED
@@ -27,6 +27,7 @@ import json
27
  import sys
28
  from pathlib import Path
29
 
 
30
  import torch
31
 
32
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "interface"))
@@ -103,20 +104,32 @@ def metrics(fake_probs: torch.Tensor, labels: torch.Tensor):
103
  "fake_acc": round(fake_acc, 2), "acc": round((real_acc + fake_acc) / 2, 2)}
104
 
105
 
 
 
 
106
  @torch.no_grad()
107
- def score_videos(engine, spec, paths):
 
 
108
  probs = []
109
  for p in paths:
110
  try:
111
- probs.append(engine.predict_video_clip(spec, str(p))["fake_probability"])
112
  except Exception as e: # noqa: BLE001
113
  print(f" bad video {p.name}: {e}")
114
  return probs
115
 
116
 
 
 
 
 
 
 
 
117
  def main(argv=None) -> int:
118
  ap = argparse.ArgumentParser()
119
- ap.add_argument("-m", "--model", required=True, choices=["d3", "restrav", "stall"])
120
  ap.add_argument("--benchmark", choices=["genvideo", "fakeonly", "pergen"], help="layout (not needed with --dataset)")
121
  ap.add_argument("--data", help="benchmark root (not needed with --dataset)")
122
  ap.add_argument("--dataset", help="a supported video dataset (genvideo, videophy, vidprom, evalcrafter, ...): downloads "
@@ -155,6 +168,8 @@ def main(argv=None) -> int:
155
  f"| device={engine.device} ===", flush=True)
156
 
157
  per_gen, agg = {}, {"ap": [], "ap_real": [], "auroc": [], "acc": []}
 
 
158
  # score the shared real pool once if it is identical across generators
159
  real_cache = {}
160
  for name, reals, fakes in gens:
@@ -169,11 +184,18 @@ def main(argv=None) -> int:
169
  rp = score_videos(engine, spec, reals)
170
  real_cache[key] = rp
171
  fp = score_videos(engine, spec, fakes)
 
 
 
 
 
172
  probs = torch.tensor(rp + fp)
173
  labels = torch.tensor([0] * len(rp) + [1] * len(fp))
174
  m = metrics(probs, labels)
175
  if m is None:
176
  print(f" skip {name}: one class"); continue
 
 
177
  per_gen[name] = m
178
  for k in agg:
179
  agg[k].append(m[k])
@@ -186,6 +208,12 @@ def main(argv=None) -> int:
186
  return round(sum(xs) / len(xs), 2)
187
  means = {"acc": mean(agg["acc"]), "map": mean(agg["ap"]), "map_real": mean(agg["ap_real"]),
188
  "mauroc": mean(agg["auroc"])}
 
 
 
 
 
 
189
  result = {"tag": args.tag, "model": args.model, "ckpt": "registry-default",
190
  "benchmark": f"video:{args.benchmark}", "data_root": str(root),
191
  "n_generators": len(per_gen), "means": means, "per_generator": per_gen,
 
27
  import sys
28
  from pathlib import Path
29
 
30
+ import numpy as np
31
  import torch
32
 
33
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "interface"))
 
104
  "fake_acc": round(fake_acc, 2), "acc": round((real_acc + fake_acc) / 2, 2)}
105
 
106
 
107
+ FPRS = (0.001, 0.01, 0.05)
108
+
109
+
110
  @torch.no_grad()
111
+ def score_videos(engine, spec, paths, raw=False):
112
+ """P(fake) per video; with ``raw`` (training-free detectors with a ``score``), the raw score oriented so that higher
113
+ means generated instead, which keeps the order at the extremes where probabilities saturate."""
114
  probs = []
115
  for p in paths:
116
  try:
117
+ probs.append(engine.video_raw_score(spec, str(p)) if raw else engine.predict_video_clip(spec, str(p))["fake_probability"])
118
  except Exception as e: # noqa: BLE001
119
  print(f" bad video {p.name}: {e}")
120
  return probs
121
 
122
 
123
+ def fake_recall_at_fpr(real_scores, fake_scores):
124
+ """SPLIT's operating-point metric (official eval.py): the threshold is the (1 - FPR) quantile of the real scores
125
+ (numpy method "higher"); fake recall = share of fakes scoring at or above it. In percent, per FPR."""
126
+ real, fake = np.asarray(real_scores, dtype="float64"), np.asarray(fake_scores, dtype="float64")
127
+ return {f"{a * 100:g}": round(float((fake >= np.quantile(real, 1 - a, method="higher")).mean()) * 100, 2) for a in FPRS}
128
+
129
+
130
  def main(argv=None) -> int:
131
  ap = argparse.ArgumentParser()
132
+ ap.add_argument("-m", "--model", required=True, choices=["d3", "split", "restrav", "stall"])
133
  ap.add_argument("--benchmark", choices=["genvideo", "fakeonly", "pergen"], help="layout (not needed with --dataset)")
134
  ap.add_argument("--data", help="benchmark root (not needed with --dataset)")
135
  ap.add_argument("--dataset", help="a supported video dataset (genvideo, videophy, vidprom, evalcrafter, ...): downloads "
 
168
  f"| device={engine.device} ===", flush=True)
169
 
170
  per_gen, agg = {}, {"ap": [], "ap_real": [], "auroc": [], "acc": []}
171
+ pooled_fake_raw = [] # every generator's fakes, for the pooled ("Overall") recall
172
+ raw = hasattr(engine._get_model(spec), "score") # training-free detectors: also fake recall at fixed FPR
173
  # score the shared real pool once if it is identical across generators
174
  real_cache = {}
175
  for name, reals, fakes in gens:
 
184
  rp = score_videos(engine, spec, reals)
185
  real_cache[key] = rp
186
  fp = score_videos(engine, spec, fakes)
187
+ if raw:
188
+ rraw = real_cache.setdefault(("raw",) + key, None) or score_videos(engine, spec, reals, raw=True)
189
+ real_cache[("raw",) + key] = rraw
190
+ fraw = score_videos(engine, spec, fakes, raw=True)
191
+ pooled_fake_raw += fraw
192
  probs = torch.tensor(rp + fp)
193
  labels = torch.tensor([0] * len(rp) + [1] * len(fp))
194
  m = metrics(probs, labels)
195
  if m is None:
196
  print(f" skip {name}: one class"); continue
197
+ if raw:
198
+ m["fake_recall_at_fpr"] = fake_recall_at_fpr(rraw, fraw)
199
  per_gen[name] = m
200
  for k in agg:
201
  agg[k].append(m[k])
 
208
  return round(sum(xs) / len(xs), 2)
209
  means = {"acc": mean(agg["acc"]), "map": mean(agg["ap"]), "map_real": mean(agg["ap_real"]),
210
  "mauroc": mean(agg["auroc"])}
211
+ if raw:
212
+ means["fake_recall_at_fpr"] = {k: mean([g["fake_recall_at_fpr"][k] for g in per_gen.values()])
213
+ for k in next(iter(per_gen.values()))["fake_recall_at_fpr"]}
214
+ # Overall: all fakes against the real pool(s) (each distinct pool once), as the SPLIT paper's "Overall" row
215
+ pooled_real_raw = [s for k, v in real_cache.items() if k[0] == "raw" for s in v]
216
+ means["overall_fake_recall_at_fpr"] = fake_recall_at_fpr(pooled_real_raw, pooled_fake_raw)
217
  result = {"tag": args.tag, "model": args.model, "ckpt": "registry-default",
218
  "benchmark": f"video:{args.benchmark}", "data_root": str(root),
219
  "n_generators": len(per_gen), "means": means, "per_generator": per_gen,
conf/model/video/split.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defaults:
2
+ - /model/default
3
+ - _self_
4
+
5
+ # SPLIT (mldljyh/SPLIT, ECCV 2026): training-free; frozen X-CLIP patch tokens -> two-step temporal roughness ** gamma
6
+ # x spatial patch-level motion incoherence. Higher score = more likely generated.
7
+ model_type: split
8
+ encoder: xclip-16
9
+ gamma: 8.0
10
+ # Decision threshold on the raw score for argmax accuracy (the engine uses models/video/split/split_calibration.json).
11
+ threshold: 0.0
12
+ num_frames: 16
dataset.py CHANGED
@@ -25,7 +25,7 @@ from decord import VideoReader, cpu
25
  from torch.utils.data import DataLoader
26
  from transformations import AIGITransformations
27
  from torchvision.io import decode_image, ImageReadMode
28
- from PIL import Image, ImageFile
29
  from config_utils import compose_config
30
  from models.image.dda.official_train_data import MEAN as DDA_MEAN, STD as DDA_STD, build_item as dda_build_item, \
31
  create_train_transforms as dda_train_transforms
@@ -71,6 +71,40 @@ def _has_videos(directory: Path) -> bool:
71
  return directory.is_dir() and any(_iter_videos(directory))
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def sample_video_frames(
75
  path: str | Path,
76
  num_frames: int,
@@ -108,7 +142,11 @@ def sample_video_frames(
108
  reader = VideoReader(str(path), ctx=cpu(0))
109
  num_source = len(reader)
110
  except Exception:
111
- return torch.zeros(num_frames, 3, size, size)
 
 
 
 
112
  if num_source == 0:
113
  return torch.zeros(num_frames, 3, size, size)
114
 
 
25
  from torch.utils.data import DataLoader
26
  from transformations import AIGITransformations
27
  from torchvision.io import decode_image, ImageReadMode
28
+ from PIL import Image, ImageFile, ImageSequence
29
  from config_utils import compose_config
30
  from models.image.dda.official_train_data import MEAN as DDA_MEAN, STD as DDA_STD, build_item as dda_build_item, \
31
  create_train_transforms as dda_train_transforms
 
71
  return directory.is_dir() and any(_iter_videos(directory))
72
 
73
 
74
+ class _PILFrames:
75
+ """The part of decord's ``VideoReader`` API that ``sample_video_frames`` uses, for animated images decord cannot open
76
+ (GIFs: decord fails with "broken metadata" on them). Every frame is composited to RGB at the canvas size, and the
77
+ frame rate is the frame count over the summed frame durations (100 ms per frame when a GIF stores none)."""
78
+
79
+ class _Array:
80
+ def __init__(self, a: np.ndarray):
81
+ self._a = a
82
+
83
+ def asnumpy(self) -> np.ndarray:
84
+ return self._a
85
+
86
+ def __init__(self, path):
87
+ with Image.open(path) as im:
88
+ frames, ms = [], 0.0
89
+ for f in ImageSequence.Iterator(im):
90
+ frames.append(np.asarray(f.convert("RGB")))
91
+ ms += float(f.info.get("duration") or 100)
92
+ self._frames = np.stack(frames)
93
+ self._fps = len(frames) / (ms / 1000.0)
94
+
95
+ def __len__(self) -> int:
96
+ return len(self._frames)
97
+
98
+ def get_avg_fps(self) -> float:
99
+ return self._fps
100
+
101
+ def get_batch(self, indices):
102
+ return self._Array(self._frames[indices])
103
+
104
+ def __getitem__(self, i):
105
+ return self._Array(self._frames[i])
106
+
107
+
108
  def sample_video_frames(
109
  path: str | Path,
110
  num_frames: int,
 
142
  reader = VideoReader(str(path), ctx=cpu(0))
143
  num_source = len(reader)
144
  except Exception:
145
+ try: # animated GIFs: decord cannot read them, PIL can
146
+ reader = _PILFrames(path)
147
+ num_source = len(reader)
148
+ except Exception:
149
+ return torch.zeros(num_frames, 3, size, size)
150
  if num_source == 0:
151
  return torch.zeros(num_frames, 3, size, size)
152
 
docs/CAPABILITIES.md CHANGED
@@ -36,6 +36,7 @@ matching `conf/model/<modality>/<key>.yaml`, a `trainer.py._build_model` branch,
36
  | `effort` (+ `effort_chameleon`) | image | ce | official | – | CLIP ViT-L/14 + SVD-residual attention (orthogonal subspace decomposition) |
37
  | `vib_net` | image | vib | synthscope | – | Variational information bottleneck on frozen CLIP ViT-L/14 (trained in SynthScope; reproduces the paper) |
38
  | `d3` | video | video | training-free | – | Disentangled detail dynamics (X-CLIP) |
 
39
  | `restrav` | video | video | synthscope | – | Representation-trajectory geometry (DINOv2) |
40
  | `stall` | video | video | training-free | – | Spatio-temporal whitened log-likelihoods (DINOv3) |
41
 
 
36
  | `effort` (+ `effort_chameleon`) | image | ce | official | – | CLIP ViT-L/14 + SVD-residual attention (orthogonal subspace decomposition) |
37
  | `vib_net` | image | vib | synthscope | – | Variational information bottleneck on frozen CLIP ViT-L/14 (trained in SynthScope; reproduces the paper) |
38
  | `d3` | video | video | training-free | – | Disentangled detail dynamics (X-CLIP) |
39
+ | `split` | video | video | training-free | – | Temporal roughness x spatial patch-level motion incoherence (X-CLIP) |
40
  | `restrav` | video | video | synthscope | – | Representation-trajectory geometry (DINOv2) |
41
  | `stall` | video | video | training-free | – | Spatio-temporal whitened log-likelihoods (DINOv3) |
42
 
interface/backend/build_repro_full.py CHANGED
@@ -118,11 +118,14 @@ VAL = {
118
  # robust metrics (the mAP harness is degenerate: 10000 reals : as few as 56 fakes/generator).
119
  # D3 (training-free): "synthscope" = SynthScope's D3, "official" = the official D3 code end to end on the same data
120
  # (repro/official_evals/d3_official_eval.py). mAP = AP with real as the positive class, as the paper reports.
121
- ("d3", "GenVideo"): {"synthscope": {"map": 99.14, "auroc": 98.46, "acc": 96.15}, "official": {"map": 97.93, "auroc": 97.04}},
122
  ("d3", "EvalCrafter"): {"synthscope": {"map": 99.3, "auroc": 98.62, "acc": 96.59}, "official": {"map": 98.7, "auroc": 97.81}},
123
  ("d3", "VideoPhy"): {"synthscope": {"map": 99.45, "auroc": 98.09, "acc": 95.19}, "official": {"map": 98.84, "auroc": 96.28}},
124
  ("d3", "VidProM"): {"synthscope": {"map": 92.15, "auroc": 90.86, "acc": 84.86}, "official": {"map": 84.91, "auroc": 82.83}},
125
- ("restrav", "GenVidBench"): {"synthscope": {"map": 92.7, "auroc": 95.9, "acc": 86.87}},
 
 
 
126
  ("stall", "GenVideo"): {"synthscope": {"map": 82.38, "auroc": 83.51, "acc": 56.88}},
127
  ("stall", "VideoFeedback"): {"synthscope": {"map": 83.07, "auroc": 85.1, "acc": 62.12}},
128
  ("stall", "ComGenVid"): {"synthscope": {"map": 82.59, "auroc": 84.0, "acc": 57.34}},
 
118
  # robust metrics (the mAP harness is degenerate: 10000 reals : as few as 56 fakes/generator).
119
  # D3 (training-free): "synthscope" = SynthScope's D3, "official" = the official D3 code end to end on the same data
120
  # (repro/official_evals/d3_official_eval.py). mAP = AP with real as the positive class, as the paper reports.
121
+ ("d3", "GenVideo"): {"synthscope": {"map": 98.95, "auroc": 98.23, "acc": 95.68}, "official": {"map": 97.93, "auroc": 97.04}},
122
  ("d3", "EvalCrafter"): {"synthscope": {"map": 99.3, "auroc": 98.62, "acc": 96.59}, "official": {"map": 98.7, "auroc": 97.81}},
123
  ("d3", "VideoPhy"): {"synthscope": {"map": 99.45, "auroc": 98.09, "acc": 95.19}, "official": {"map": 98.84, "auroc": 96.28}},
124
  ("d3", "VidProM"): {"synthscope": {"map": 92.15, "auroc": 90.86, "acc": 84.86}, "official": {"map": 84.91, "auroc": 82.83}},
125
+ # SPLIT (training-free): the paper's metric is fake recall at fixed FPR (in the spec note); these are AP (fake positive) /
126
+ # AUROC / acc at the calibrated threshold. synthscope = job 270384, official = official code end to end (job 270323).
127
+ ("split", "GenVideo"): {"synthscope": {"map": 99.79, "auroc": 99.97, "acc": 99.41}, "official": {"map": 96.12, "auroc": 99.16}},
128
+ ("restrav", "GenVidBench"): {"synthscope": {"map": 92.7, "auroc": 95.9, "acc": 86.87}},
129
  ("stall", "GenVideo"): {"synthscope": {"map": 82.38, "auroc": 83.51, "acc": 56.88}},
130
  ("stall", "VideoFeedback"): {"synthscope": {"map": 83.07, "auroc": 85.1, "acc": 62.12}},
131
  ("stall", "ComGenVid"): {"synthscope": {"map": 82.59, "auroc": 84.0, "acc": 57.34}},
interface/backend/engine.py CHANGED
@@ -125,6 +125,9 @@ def _build_model(cfg):
125
  if model_type == "d3":
126
  from models.video.d3.d3 import SynthScopeD3Model
127
  return SynthScopeD3Model(cfg)
 
 
 
128
  if model_type == "restrav":
129
  from models.video.restrav.restrav import SynthScopeReStraVModel
130
  return SynthScopeReStraVModel(cfg)
@@ -261,25 +264,29 @@ class Engine:
261
  return torch.sigmoid(logit.reshape(x.shape[0]))
262
  raise ValueError(f"Unexpected image predict_kind: {kind}")
263
 
264
- def _d3_calibration(self):
265
- """Load (and cache) the D3 score->probability calibration if present."""
266
- if not hasattr(self, "_d3_cal"):
 
 
 
267
  import json
268
- # A file in the checkpoint folder overrides the one shipped with the model (fitted on GenVideo-Val).
269
- p = config.CHECKPOINTS_DIR / "d3_calibration.json"
270
  if not p.exists():
271
- p = config.REPO_ROOT / "models" / "video" / "d3" / "d3_calibration.json"
272
- self._d3_cal = json.loads(p.read_text()) if p.exists() else None
273
- return self._d3_cal
274
 
275
  @torch.no_grad()
276
  def _video_fake_prob(self, model, spec: ModelSpec, clip: torch.Tensor) -> torch.Tensor:
277
  """Return P(fake) for a single normalized clip tensor [T,3,H,W]."""
278
  frames = clip.unsqueeze(0).to(self.device) # [1,T,3,H,W]
279
- if spec.key == "d3":
280
- cal = self._d3_calibration()
281
  if cal is not None:
282
- s = model.score(frames) # [1] raw volatility score
 
 
283
  z = (s - cal["threshold"]) / cal["scale"]
284
  if not cal["higher_is_fake"]:
285
  z = -z
@@ -408,6 +415,14 @@ class Engine:
408
  res["num_frames"] = int(clip.shape[0])
409
  return res
410
 
 
 
 
 
 
 
 
 
411
  def _result(self, spec: ModelSpec, fake_prob: float, elapsed: float) -> dict:
412
  fake_prob = max(0.0, min(1.0, fake_prob))
413
  return {
 
125
  if model_type == "d3":
126
  from models.video.d3.d3 import SynthScopeD3Model
127
  return SynthScopeD3Model(cfg)
128
+ if model_type == "split":
129
+ from models.video.split.split import SynthScopeSPLITModel
130
+ return SynthScopeSPLITModel(cfg)
131
  if model_type == "restrav":
132
  from models.video.restrav.restrav import SynthScopeReStraVModel
133
  return SynthScopeReStraVModel(cfg)
 
264
  return torch.sigmoid(logit.reshape(x.shape[0]))
265
  raise ValueError(f"Unexpected image predict_kind: {kind}")
266
 
267
+ def _video_calibration(self, key: str):
268
+ """Load (and cache) a training-free video detector's raw score -> probability calibration, if it has one: a
269
+ ``<key>_calibration.json`` in the checkpoint folder overrides the one shipped in ``models/video/<key>/``."""
270
+ if not hasattr(self, "_video_cal"):
271
+ self._video_cal = {}
272
+ if key not in self._video_cal:
273
  import json
274
+ p = config.CHECKPOINTS_DIR / f"{key}_calibration.json"
 
275
  if not p.exists():
276
+ p = config.REPO_ROOT / "models" / "video" / key / f"{key}_calibration.json"
277
+ self._video_cal[key] = json.loads(p.read_text()) if p.exists() else None
278
+ return self._video_cal[key]
279
 
280
  @torch.no_grad()
281
  def _video_fake_prob(self, model, spec: ModelSpec, clip: torch.Tensor) -> torch.Tensor:
282
  """Return P(fake) for a single normalized clip tensor [T,3,H,W]."""
283
  frames = clip.unsqueeze(0).to(self.device) # [1,T,3,H,W]
284
+ if spec.key in ("d3", "split"):
285
+ cal = self._video_calibration(spec.key)
286
  if cal is not None:
287
+ s = model.score(frames) # [1] raw score (D3 volatility, SPLIT roughness x incoherence)
288
+ if cal.get("log_score"): # SPLIT's score spans many orders of magnitude: calibrated on its log
289
+ s = s.clamp_min(1e-30).log()
290
  z = (s - cal["threshold"]) / cal["scale"]
291
  if not cal["higher_is_fake"]:
292
  z = -z
 
415
  res["num_frames"] = int(clip.shape[0])
416
  return res
417
 
418
+ @torch.no_grad()
419
+ def video_raw_score(self, spec: ModelSpec, path) -> float:
420
+ """A training-free video detector's raw score for one clip, oriented so that higher means more likely generated
421
+ (for fake recall at a fixed false-positive rate, where saturated probabilities would tie)."""
422
+ model = self._get_model(spec)
423
+ s = float(model.score(self.video_clip(spec, path).unsqueeze(0).to(self.device)).item())
424
+ return s if getattr(model, "higher_is_fake", True) else -s
425
+
426
  def _result(self, spec: ModelSpec, fake_prob: float, elapsed: float) -> dict:
427
  fake_prob = max(0.0, min(1.0, fake_prob))
428
  return {
interface/backend/registry.py CHANGED
@@ -461,6 +461,17 @@ _add(ModelSpec(
461
  "Official preprocessing (D3 utils/video2frame.py + data/datasets.py): 16 frames at 8 fps (8 for clips under "
462
  "2 s), BGR channels as cv2 reads them, 10% crop of the longer side, resize 224, ImageNet normalization.",
463
  ))
 
 
 
 
 
 
 
 
 
 
 
464
  _add(ModelSpec(
465
  key="restrav", name="ReStraV (video)", paper="2025",
466
  modality="video", predict_kind="video", checkpoint="restrav.pth", ckpt_kind="synthscope",
 
461
  "Official preprocessing (D3 utils/video2frame.py + data/datasets.py): 16 frames at 8 fps (8 for clips under "
462
  "2 s), BGR channels as cv2 reads them, 10% crop of the longer side, resize 224, ImageNet normalization.",
463
  ))
464
+ _add(ModelSpec(
465
+ key="split", name="SPLIT (video)", paper="ECCV'26",
466
+ modality="video", predict_kind="video", checkpoint=None, ckpt_kind="none", weights="training_free",
467
+ overrides=["model@ModelConfig=video/split"],
468
+ num_frames=16, fps=8.0, frame_size=224, video_norm=(IMAGENET_MEAN, IMAGENET_STD), edge_crop=0.1,
469
+ edge_crop_long_side=True, short_clip_frames=8, fps_nearest=True, bgr=True,
470
+ notes="Training-free: frozen X-CLIP-B/16 patch tokens; two-step temporal roughness (gamma 8) times spatial "
471
+ "patch-level motion incoherence is the score. Official preprocessing (the same video2frame / read_video as "
472
+ "D3): 16 frames at 8 fps (8 for clips under 2 s), BGR, 10% crop of the longer side, resize 224, ImageNet "
473
+ "normalization.",
474
+ ))
475
  _add(ModelSpec(
476
  key="restrav", name="ReStraV (video)", paper="2025",
477
  modality="video", predict_kind="video", checkpoint="restrav.pth", ckpt_kind="synthscope",
interface/backend/repro_full.json CHANGED
@@ -346,11 +346,11 @@
346
  "benchmark": "GenVideo (DeMamba GenVideo-Val, Table 1)",
347
  "metric": "AP",
348
  "paper": "mAP=98.46",
349
- "ours": "SynthScope mAP=99.14 / acc=96.15 / AUROC=98.46 \u00b7 official mAP=97.93 / AUROC=97.04",
350
- "ours_synthscope": "mAP=99.14 / acc=96.15 / AUROC=98.46",
351
  "ours_official": "mAP=97.93 / AUROC=97.04",
352
  "status": "reproduced",
353
- "note": "D3 protocol (--limit 1000: first 1000 clips per class per generator; AP with real as the positive class). SynthScope engine 99.14 AP (AUROC 98.46, balanced acc 96.15 at the refitted calibration; tag d3_genvideo, job 252246). Official D3 code end to end: 97.93 (tag d3_official_genvideo, job 252271): 9 of 10 generators within 0.25 of the paper; WildScrape 92.50 vs 96.49 (282 GIFs whose length decord"
354
  },
355
  {
356
  "benchmark": "EvalCrafter (Table 2, 14 generators, fake-only)",
@@ -1113,6 +1113,38 @@
1113
  "note": "SERVED native-res path (official weights in SynthScope class). bench_eval --model spai (tag spai_served_synthbuster, 9 gens, limit 200/gen) = 96.75 mAP / 96.97 mAUROC (was 55.67 via the old fixed-448 path)."
1114
  }
1115
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1116
  "stall": [
1117
  {
1118
  "benchmark": "GenVideo (DeMamba GenVideo-Val, 10 generators + MSR-VTT)",
 
346
  "benchmark": "GenVideo (DeMamba GenVideo-Val, Table 1)",
347
  "metric": "AP",
348
  "paper": "mAP=98.46",
349
+ "ours": "SynthScope mAP=98.95 / acc=95.68 / AUROC=98.23 \u00b7 official mAP=97.93 / AUROC=97.04",
350
+ "ours_synthscope": "mAP=98.95 / acc=95.68 / AUROC=98.23",
351
  "ours_official": "mAP=97.93 / AUROC=97.04",
352
  "status": "reproduced",
353
+ "note": "D3 protocol (--limit 1000: first 1000 clips per class per generator; AP with real as the positive class). SynthScope engine 98.95 AP (AUROC 98.23, balanced acc 95.68 at the refitted calibration; tag d3_genvideo, job 270428, after the GIF decode fix: WildScrape 96.33 vs paper 96.49, was 98.21 when GIFs decoded as black clips). Official D3 code end to end: 97.93 (tag d3_official_genvideo, job 252271"
354
  },
355
  {
356
  "benchmark": "EvalCrafter (Table 2, 14 generators, fake-only)",
 
1113
  "note": "SERVED native-res path (official weights in SynthScope class). bench_eval --model spai (tag spai_served_synthbuster, 9 gens, limit 200/gen) = 96.75 mAP / 96.97 mAUROC (was 55.67 via the old fixed-448 path)."
1114
  }
1115
  ],
1116
+ "split": [
1117
+ {
1118
+ "benchmark": "GenVideo (GenVideo-Val, MSR-VTT real calibration, supp. Table 18)",
1119
+ "metric": "fake recall @ FPR 0.1/1/5% (Overall = all fakes pooled)",
1120
+ "paper": "fake_recall_fpr0.1=82.13 / fake_recall_fpr1=91.69 / fake_recall_fpr5=97.2",
1121
+ "ours": "SynthScope mAP=99.79 / acc=99.41 / AUROC=99.97 \u00b7 official mAP=96.12 / AUROC=99.16",
1122
+ "ours_synthscope": "mAP=99.79 / acc=99.41 / AUROC=99.97",
1123
+ "ours_official": "mAP=96.12 / AUROC=99.16",
1124
+ "status": "reproduced",
1125
+ "note": "Official code on our data (JPEG frame round-trip, job 270323) reproduces the paper: Overall fake recall 82.22/90.17/96.89 vs 82.13/91.69/97.20. SynthScope decodes frames directly (no JPEG) and reaches 98.87/99.86/99.98 (job 270384; mAP 99.79, AUROC 99.97, acc 99.41 at the calibrated threshold)."
1126
+ },
1127
+ {
1128
+ "benchmark": "FakeParts (partial edits; Tables 1, 15-16)",
1129
+ "metric": "fake recall @ FPR",
1130
+ "paper": "fake_recall_fpr0.1=74.45",
1131
+ "ours": "",
1132
+ "ours_synthscope": "",
1133
+ "ours_official": "",
1134
+ "status": "missing",
1135
+ "note": "not in SynthScope yet"
1136
+ },
1137
+ {
1138
+ "benchmark": "ViF-Bench (Tables 3, 19)",
1139
+ "metric": "fake recall @ FPR",
1140
+ "paper": "fake_recall_fpr0.1=84.52",
1141
+ "ours": "",
1142
+ "ours_synthscope": "",
1143
+ "ours_official": "",
1144
+ "status": "missing",
1145
+ "note": "not in SynthScope yet"
1146
+ }
1147
+ ],
1148
  "stall": [
1149
  {
1150
  "benchmark": "GenVideo (DeMamba GenVideo-Val, 10 generators + MSR-VTT)",
interface/scripts/calibrate_split.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibrate the training-free SPLIT video detector so its output is a usable probability.
2
+
3
+ SPLIT's raw score (TTR ** 8 * motion incoherence) spans many orders of magnitude, so the calibration works on its
4
+ logarithm and follows the paper's real-only protocol: the decision threshold is the score at a target false-positive
5
+ rate on real videos alone (default 1%, numpy's ``quantile(..., method="higher")`` as the official ``eval.py``), and the
6
+ logistic scale puts the median real video at P(fake) = 0.05. The engine then reports
7
+ ``sigmoid((log(score) - threshold) / scale)`` as P(fake). Fakes are scored only to report the recall at that threshold.
8
+
9
+ conda run -n SynthScope python interface/scripts/calibrate_split.py [--n-real 2000] [--n-fake 1000] [--fpr 0.01]
10
+ [--data DIR] [--out FILE]
11
+
12
+ Clips go through the engine's own SPLIT preprocessing (``Engine.video_clip``), so the calibration matches what the web
13
+ app and benchmarks feed the model. ``--data`` takes GenVideo-Val as released (``Real/`` + ``Fake/<gen>/``) or as laid out
14
+ by the ``genvideo`` loader (``test/<gen>/{0_real,1_fake}/``).
15
+ """
16
+ import argparse
17
+ import json
18
+ import math
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+
24
+ sys.path[:0] = [str(Path(__file__).resolve().parents[1]), str(Path(__file__).resolve().parent)]
25
+ from backend import config # noqa: E402
26
+ from backend.engine import get_engine # noqa: E402
27
+ from backend.registry import get_spec # noqa: E402
28
+ from calibrate_d3 import GENVIDEO, collect, score_paths # noqa: E402
29
+
30
+ EPS = 1e-30
31
+
32
+
33
+ def main():
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--n-real", type=int, default=2000)
36
+ ap.add_argument("--n-fake", type=int, default=1000)
37
+ ap.add_argument("--fpr", type=float, default=0.01, help="target false-positive rate on real videos")
38
+ ap.add_argument("--data", default=str(GENVIDEO), help="GenVideo-Val root (released layout or the genvideo loader's)")
39
+ ap.add_argument("--out", default=None, help="where to write the JSON (default: <CKPT_DIR>/split_calibration.json)")
40
+ args = ap.parse_args()
41
+
42
+ engine = get_engine()
43
+ spec = get_spec("split")
44
+ model = engine._get_model(spec)
45
+ reals, _ = collect(args.n_real, args.data)
46
+ _, fakes = collect(args.n_fake, args.data)
47
+ print(f"scoring {len(reals)} real + {len(fakes)} fake clips on {engine.device}...")
48
+ rs = np.log(np.maximum(score_paths(engine, spec, model, reals), EPS))
49
+ fs = np.log(np.maximum(score_paths(engine, spec, model, fakes), EPS))
50
+
51
+ threshold = float(np.quantile(rs, 1 - args.fpr, method="higher"))
52
+ scale = max((threshold - float(np.median(rs))) / math.log(19), 1e-6) # median real -> sigmoid(-ln 19) = 0.05
53
+ cal = {"threshold": threshold, "scale": scale, "higher_is_fake": True, "log_score": True,
54
+ "target_fpr": args.fpr, "real_fpr": float((rs >= threshold).mean()),
55
+ "fake_recall": float((fs >= threshold).mean()), "median_log_real": float(np.median(rs)),
56
+ "median_log_fake": float(np.median(fs)), "n_real": int(len(rs)), "n_fake": int(len(fs))}
57
+ out = Path(args.out) if args.out else config.CHECKPOINTS_DIR / "split_calibration.json"
58
+ out.write_text(json.dumps(cal, indent=2) + "\n")
59
+ print(f"threshold(log)={threshold:.4f} scale={scale:.4f} real FPR={cal['real_fpr']:.4f} "
60
+ f"fake recall={cal['fake_recall']:.4f}")
61
+ print(f"wrote {out}")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
models/video/d3/README.md CHANGED
@@ -149,7 +149,7 @@ frame extraction, reader and model run end to end on the same data (`repro/offic
149
 
150
  | Benchmark (AP, real positive) | Paper | Official code, our data | SynthScope | Status |
151
  |---|---:|---:|---:|---|
152
- | GenVideo-Val (Table 1, 10 generators) | 98.46 | 97.93 | **99.14** | reproduced |
153
  | EvalCrafter (Table 2, 14 generators) | 98.87 | 98.70 | **99.30** | reproduced |
154
  | VideoPhy (Table 3; 8 of 10 generators still downloadable) | 99.16 (8 shown: 99.22) | 98.84 | **99.45** | reproduced |
155
  | VidProM (Table 4; 4 of 6 generators, first 1000 clips each) | 88.46 (those 4: 84.95) | 84.91 | 92.15 | reference (subsample) |
@@ -158,11 +158,14 @@ frame extraction, reader and model run end to end on the same data (`repro/offic
158
  are within 0.25 AP; VidProM's four generators average 84.91 vs 84.95 (Text2Video-Zero 46.36 vs 45.11, the paper's
159
  noted failure case). The one outlier is GenVideo's WildScrape (92.50 vs 96.49): its 282 GIFs cannot be timed by
160
  decord, so their frames start at 0 instead of the official random offset.
 
 
 
161
  - **SynthScope's D3 is the official model**: on the frames the official code extracts it returns identical scores
162
  (max |diff| 0). Its numbers differ from the official column only because the official pipeline writes frames to JPEG
163
  with ffmpeg and reads them back; SynthScope decodes frames directly. That matters most for low-quality clips
164
  (VidProM Text2Video-Zero: 71.28 in SynthScope vs 46.36 with the official JPEG round-trip).
165
- - AUROC and balanced accuracy (at the calibrated threshold) for SynthScope: GenVideo 98.46 / 96.15, EvalCrafter
166
  98.62 / 96.59, VideoPhy 98.09 / 95.19, VidProM 90.86 / 84.86. Per-generator numbers are in
167
  `repro/benchmark_specs/d3.json` and `repro/bench_results/d3_*.json`.
168
 
 
149
 
150
  | Benchmark (AP, real positive) | Paper | Official code, our data | SynthScope | Status |
151
  |---|---:|---:|---:|---|
152
+ | GenVideo-Val (Table 1, 10 generators) | 98.46 | 97.93 | **98.95** | reproduced |
153
  | EvalCrafter (Table 2, 14 generators) | 98.87 | 98.70 | **99.30** | reproduced |
154
  | VideoPhy (Table 3; 8 of 10 generators still downloadable) | 99.16 (8 shown: 99.22) | 98.84 | **99.45** | reproduced |
155
  | VidProM (Table 4; 4 of 6 generators, first 1000 clips each) | 88.46 (those 4: 84.95) | 84.91 | 92.15 | reference (subsample) |
 
158
  are within 0.25 AP; VidProM's four generators average 84.91 vs 84.95 (Text2Video-Zero 46.36 vs 45.11, the paper's
159
  noted failure case). The one outlier is GenVideo's WildScrape (92.50 vs 96.49): its 282 GIFs cannot be timed by
160
  decord, so their frames start at 0 instead of the official random offset.
161
+ - **GIFs (2026-11-01):** decord cannot open GIFs at all, and SynthScope's decoder used to return a black clip for them, which
162
+ D3 (flat = generated) called fake for free. Decoded with PIL now, WildScrape is 96.33 AP (paper 96.49; it was 98.21 on
163
+ black clips) and GenVideo 98.95 (was 99.14); the other nine generators are unchanged (job 270428).
164
  - **SynthScope's D3 is the official model**: on the frames the official code extracts it returns identical scores
165
  (max |diff| 0). Its numbers differ from the official column only because the official pipeline writes frames to JPEG
166
  with ffmpeg and reads them back; SynthScope decodes frames directly. That matters most for low-quality clips
167
  (VidProM Text2Video-Zero: 71.28 in SynthScope vs 46.36 with the official JPEG round-trip).
168
+ - AUROC and balanced accuracy (at the calibrated threshold) for SynthScope: GenVideo 98.23 / 95.68, EvalCrafter
169
  98.62 / 96.59, VideoPhy 98.09 / 95.19, VidProM 90.86 / 84.86. Per-generator numbers are in
170
  `repro/benchmark_specs/d3.json` and `repro/bench_results/d3_*.json`.
171
 
models/video/d3/d3.py CHANGED
@@ -100,6 +100,8 @@ class _ResnetEncoder(nn.Module):
100
  class SynthScopeD3Model(nn.Module):
101
  """Training-free second-order video detector (D3)."""
102
 
 
 
103
  def __init__(self, config: DictConfig):
104
  super().__init__()
105
  self.config = config
 
100
  class SynthScopeD3Model(nn.Module):
101
  """Training-free second-order video detector (D3)."""
102
 
103
+ higher_is_fake = False # real videos have the larger second-order volatility
104
+
105
  def __init__(self, config: DictConfig):
106
  super().__init__()
107
  self.config = config
models/video/split/README.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPLIT (`split`)
2
+
3
+ **SPLIT: Training-Free AI-Generated and Partially Edited Video Detection via Spatial Patch-Level Incoherence and
4
+ Temporal Roughness** (Jongyeop Hyun, Hyounghun Kim; ECCV 2026) — [paper (arXiv 2607.02886)](https://arxiv.org/abs/2607.02886) ·
5
+ [official code](https://github.com/mldljyh/SPLIT)
6
+
7
+ A training-free video detector. Real motion is smooth in a frozen encoder's patch-feature space and moves coherently
8
+ across neighbouring patches; generated (or partially edited) video jitters from frame to frame and moves incoherently.
9
+ SPLIT measures both on patch tokens and multiplies them. The paper evaluates it where deployment needs it: fake recall
10
+ at a fixed, very low false-positive rate on real videos (0.1 / 1 / 5%), with the threshold calibrated on real videos only.
11
+
12
+ ## Pipeline
13
+
14
+ ```
15
+ video ─ 16 frames at 8 fps (8 for clips under 2 s), BGR, 10% crop of the longer side, resize 224, ImageNet norm
16
+ → frozen X-CLIP B/16 vision tower → patch tokens P[t, n] (CLS dropped)
17
+ → TTR = mean over patches of log2(L1 / L2) L1 = Σ‖P[t+1] − P[t]‖ (1-frame steps)
18
+ L2 = Σ‖P[t+2] − P[t]‖ / 2 · (T−1)/(T−2) (2-frame steps)
19
+ → LSMI = mean ‖spatial difference of the motion field P[t+1] − P[t] on the patch grid‖ (x and y averaged)
20
+ → score = TTR ** 8 · LSMI (higher = more likely generated)
21
+ ```
22
+
23
+ ## SynthScope adaptations
24
+
25
+ | choice | reason |
26
+ |---|---|
27
+ | `SynthScopeSPLITModel` is SynthScope's own implementation of the paper's equations | The official repository has no license, so no code is copied. It returns the official `SPLIT_model.forward_score` on the same frames (relative difference ≤ 7e-6, job 269966). |
28
+ | Registry entry reuses D3's video preprocessing | SPLIT's released code reads frames exactly as D3 does (the same `video2frame` / `read_video`): 16 frames at 8 fps, BGR, 10% crop of the long side, resize 224, ImageNet normalization. |
29
+ | Frames are decoded directly (decord; PIL for GIFs), not written to JPEG by ffmpeg and read back | The official pipeline's lossy JPEG round-trip is the one remaining difference from the official code, and it changes the numbers a lot (see Results). |
30
+ | Clips longer than 3 s start at 0 | The official extraction picks a random start in a multiprocessing pool, so its frames are not reproducible anyway. |
31
+ | `split_calibration.json` (P(fake) for the web app) | The raw score spans many orders of magnitude, so it is calibrated on its log, by the paper's real-only rule (see below). |
32
+ | One dummy `_noop` parameter | Lets the SynthScope trainer run on a training-free model; it has no effect. |
33
+
34
+ ## Results
35
+
36
+ Paper metric: **fake recall (%) at FPR 0.1 / 1 / 5%**, the threshold being the (1 − FPR) quantile of the real scores
37
+ (official `eval.py`). GenVideo-Val: its 10,000 MSR-VTT real videos calibrate the threshold, and all 8,584 fakes of the
38
+ 10 generators are scored (the official reader drops 11 clips with fewer than 8 frames) (paper Table 18, "thresholds calibrated using the MSR-VTT real set only").
39
+
40
+ Two columns besides the paper: **official code, our data** runs the official frame extraction (ffmpeg JPEG frames),
41
+ reader, `SPLIT_model` and `evaluate_split` end to end on our copy of GenVideo-Val
42
+ (`repro/official_evals/split_official_eval.py`, job 270323); **SynthScope** is this implementation
43
+ (`benchmark/bench_eval_video.py -m split --dataset genvideo`, job 270384).
44
+
45
+ | GenVideo (fake recall @ FPR 0.1 / 1 / 5%) | Paper | Official code, our data | SynthScope |
46
+ |---|---|---|---|
47
+ | **Overall** | 82.13 / 91.69 / 97.20 | **82.22 / 90.17 / 96.89** | 98.87 / 99.86 / 99.98 |
48
+ | Crafter | 88.84 / 96.78 / 99.64 | 89.99 / 96.21 / 99.64 | 100 / 100 / 100 |
49
+ | Gen2 | 53.26 / 77.61 / 94.71 | 56.16 / 76.30 / 94.64 | 100 / 100 / 100 |
50
+ | HotShot | 99.86 / 100 / 100 | 99.86 / 100 / 100 | 100 / 100 / 100 |
51
+ | Lavie | 92.50 / 96.36 / 98.79 | 93.21 / 96.07 / 98.71 | 99.93 / 99.93 / 100 |
52
+ | ModelScope | 86.71 / 93.57 / 97.86 | 87.29 / 93.14 / 97.86 | 99.57 / 99.86 / 100 |
53
+ | MoonValley | 49.36 / 76.36 / 95.37 | 52.88 / 74.76 / 95.37 | 100 / 100 / 100 |
54
+ | MorphStudio | 94.71 / 98.43 / 99.71 | 95.00 / 98.00 / 99.71 | 100 / 100 / 100 |
55
+ | Show_1 | 96.86 / 99.86 / 100 | 97.86 / 99.71 / 100 | 100 / 100 / 100 |
56
+ | Sora | 87.50 / 98.21 / 100 | 91.07 / 98.21 / 100 | 100 / 100 / 100 |
57
+ | WildScrape | 71.66 / 79.76 / 85.89 | 73.27 / 79.74 / 86.42 | 89.94 / 98.92 / 99.78 |
58
+ | D3 Overall (the paper's baseline row) | 3.00 / 55.75 / 87.42 | — | 0.24 / 63.32 / 96.41 |
59
+
60
+ - **The official code reproduces the paper on our data**: Overall within 1.6 points at every FPR, every generator within
61
+ 0.6 at FPR 5%, and the larger 0.1% differences (Gen2, MoonValley, Sora with 56 fakes) are the random clip start the
62
+ official extraction draws.
63
+ - **SynthScope scores higher because it skips the JPEG round-trip.** The official `video2frame.py` writes every frame
64
+ with ffmpeg's default JPEG settings and reads it back; the compression noise differs from frame to frame, which adds
65
+ temporal roughness to real videos and stretches the real-score tail that sets the threshold. On the same videos,
66
+ decoded directly, SPLIT separates real and generated almost perfectly (AP 99.79, AUROC 99.97 averaged over generators).
67
+ The **official code, our data** column is the like-for-like comparison with the paper.
68
+ - AP / AUROC / balanced accuracy at the calibrated threshold (SynthScope, mean over generators): 99.79 / 99.97 / 99.41.
69
+ Per-generator numbers: `repro/bench_results/split_genvideo_fpr.json`, `split_official_genvideo.json`.
70
+ - Not run: FakeParts and ViF-Bench (the paper's other two benchmarks; not in SynthScope yet), and the cross-real
71
+ threshold transfer to ROVI (needs FakeParts' real set).
72
+
73
+ ## Probability calibration (`split_calibration.json`)
74
+
75
+ The raw score (TTR⁸ · LSMI) has a median of about 1e-10 on real GenVideo videos and about 1 on generated ones, so the engine maps
76
+ its logarithm to P(fake) = sigmoid((log score − threshold) / scale). Following the paper's real-only protocol, the
77
+ threshold is the log score at FPR 1% on 2,000 GenVideo-Val real videos (P(fake) = 0.5 there), and the scale puts the
78
+ median real video at P(fake) = 0.05 (`interface/scripts/calibrate_split.py`, through the engine's own preprocessing; on
79
+ 1,000 random GenVideo fakes the recall at that threshold is 100%). A `split_calibration.json` in `$SYNTHSCOPE_CKPT_DIR`
80
+ overrides the shipped one. AP, AUROC and fake recall at fixed FPR do not depend on it.
81
+
82
+ ## Usage
83
+
84
+ ```bash
85
+ synthscope detect path/to/video.mp4 -m split
86
+ python benchmark/bench_eval_video.py -m split --dataset genvideo --tag split_genvideo_fpr
87
+ ```
88
+
89
+ `bench_eval_video.py` reports fake recall at FPR 0.1 / 1 / 5% (per generator, their mean, and pooled over all
90
+ generators) for training-free detectors that expose a raw `score`.
91
+
92
+ ## License
93
+
94
+ SynthScope's implementation is Apache-2.0. The official SPLIT repository has no license; no code from it is included.
95
+ The X-CLIP weights (`microsoft/xclip-base-patch16`) are MIT.
models/video/split/__init__.py ADDED
File without changes
models/video/split/split.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SPLIT integrated into SynthScope.
2
+
3
+ Paper: "SPLIT: Training-Free AI-Generated and Partially Edited Video Detection via Spatial Patch-Level Incoherence and
4
+ Temporal Roughness" (Hyun & Kim, ECCV 2026). https://github.com/mldljyh/SPLIT
5
+
6
+ Training-free. A frozen vision encoder (X-CLIP B/16 by default) turns every frame into patch tokens P[t, n] (CLS
7
+ dropped). Two signals are computed on them:
8
+
9
+ * Two-step Temporal Roughness (TTR), per patch: the path length of its token trajectory with 1-frame steps,
10
+ L1 = sum_t ||P[t+1] - P[t]||, against the path length with 2-frame steps rescaled to the same number of steps,
11
+ L2 = (sum_t ||P[t+2] - P[t]|| / 2) * (T-1) / (T-2); TTR = log2(L1 / L2). Real motion is smooth (TTR near 0); generated
12
+ video jitters from frame to frame, so 1-frame steps are long compared with 2-frame steps. Averaged over patches.
13
+ * Spatial patch-level motion incoherence: the motion field M[t] = P[t+1] - P[t] on the patch grid and its spatial
14
+ differences between neighbouring patches; the mean of their L2 norms over time and space, averaged over x and y.
15
+
16
+ score = TTR ** gamma * incoherence (gamma = 8); higher means more likely generated.
17
+
18
+ This is SynthScope's own implementation of the paper's equations (the official repository has no license); it is
19
+ checked against the official ``SPLIT_model`` on the same frames (repro/split parity script, identical scores).
20
+ The released code reads frames exactly as D3 does (the same ``video2frame`` / ``read_video``), so the registry entry
21
+ reuses D3's preprocessing: 16 frames at 8 fps, BGR, 10% crop of the long side, resize 224, ImageNet normalization.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ from omegaconf import DictConfig
31
+ from transformers import XCLIPVisionModel
32
+
33
+ ENCODERS = {"xclip-16": "microsoft/xclip-base-patch16", "xclip-32": "microsoft/xclip-base-patch32"}
34
+
35
+
36
+ class SynthScopeSPLITModel(nn.Module):
37
+ """Training-free SPLIT detector on a frozen X-CLIP vision tower."""
38
+
39
+ higher_is_fake = True # orientation of ``score`` (benchmark/bench_eval_video.py fake recall at fixed FPR)
40
+
41
+ def __init__(self, config: DictConfig):
42
+ super().__init__()
43
+ cfg = config.ModelConfig
44
+ self.encoder = XCLIPVisionModel.from_pretrained(ENCODERS[str(cfg.get("encoder", "xclip-16")).lower()])
45
+ self.encoder.requires_grad_(False)
46
+ self.encoder.eval()
47
+ self.gamma = float(cfg.get("gamma", 8.0))
48
+ self.threshold = float(cfg.get("threshold", 0.0))
49
+ self._noop = nn.Parameter(torch.zeros(1)) # the trainer's AdamW needs one parameter; training is a no-op
50
+
51
+ def train(self, mode: bool = True):
52
+ super().train(mode)
53
+ self.encoder.eval() # the encoder stays frozen in eval mode
54
+ return self
55
+
56
+ def patch_tokens(self, frames: torch.Tensor) -> torch.Tensor:
57
+ """``frames`` [B, T, 3, H, W] (normalized) -> patch tokens [B, T, N, D] (CLS dropped)."""
58
+ b, t = frames.shape[:2]
59
+ with torch.no_grad():
60
+ tokens = self.encoder(pixel_values=frames.reshape(b * t, *frames.shape[2:])).last_hidden_state[:, 1:]
61
+ return tokens.reshape(b, t, *tokens.shape[1:]).float()
62
+
63
+ @staticmethod
64
+ def temporal_roughness(p: torch.Tensor) -> torch.Tensor:
65
+ """Mean over patches of TTR = log2(L1 / L2); ``p`` [B, T, N, D] -> [B]."""
66
+ t = p.shape[1]
67
+ l1 = (p[:, 1:] - p[:, :-1]).norm(dim=-1).sum(dim=1) # [B, N]
68
+ l2 = (p[:, 2:] - p[:, :-2]).norm(dim=-1).sum(dim=1) / 2 * (t - 1) / (t - 2)
69
+ return ((torch.log(l1 + 1e-8) - torch.log(l2 + 1e-8)) / math.log(2)).mean(dim=1)
70
+
71
+ @staticmethod
72
+ def motion_incoherence(p: torch.Tensor) -> torch.Tensor:
73
+ """Mean L2 norm of the spatial differences of the motion field on the patch grid; ``p`` [B, T, N, D] -> [B]."""
74
+ b, t, n, d = p.shape
75
+ g = int(round(n ** 0.5))
76
+ m = (p[:, 1:] - p[:, :-1]).reshape(b, t - 1, g, g, d) # motion field on the grid
77
+ dx = (m[:, :, :, :-1] - m[:, :, :, 1:]).norm(dim=-1).mean(dim=(1, 2, 3))
78
+ dy = (m[:, :, :-1] - m[:, :, 1:]).norm(dim=-1).mean(dim=(1, 2, 3))
79
+ return (dx + dy) / 2
80
+
81
+ def score(self, frames: torch.Tensor) -> torch.Tensor:
82
+ """Per-video SPLIT score [B] (higher = more likely generated)."""
83
+ p = self.patch_tokens(frames)
84
+ return self.temporal_roughness(p) ** self.gamma * self.motion_incoherence(p)
85
+
86
+ def forward(self, frames: torch.Tensor) -> torch.Tensor:
87
+ s = self.score(frames)
88
+ return torch.stack([self.threshold - s, s - self.threshold], dim=1)
89
+
90
+ def compute_loss(self, batch):
91
+ frames, _labels, _folder_ids = batch
92
+ return {"loss": (self._noop * 0.0).sum(), "outputs": self.forward(frames)}
models/video/split/split_calibration.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "threshold": -12.128589335463142,
3
+ "scale": 3.5924194383097205,
4
+ "higher_is_fake": true,
5
+ "log_score": true,
6
+ "target_fpr": 0.01,
7
+ "real_fpr": 0.01,
8
+ "fake_recall": 1.0,
9
+ "median_log_real": -22.706249159137492,
10
+ "median_log_fake": 0.12425237563573246,
11
+ "n_real": 2000,
12
+ "n_fake": 1000
13
+ }
repro/MASTER_RESULTS.jsonl CHANGED
@@ -370,3 +370,9 @@
370
  {"date": "2026-10-30T22:33:19", "tag": "effort_evalgen", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/EvalGEN/test", "n_generators": 5, "means": {"acc": 92.16, "map": 99.55, "mauroc": 99.16}}
371
  {"date": "2026-10-30T22:48:42", "tag": "effort_realchain", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/RealChain/test", "n_generators": 7, "means": {"acc": 50.56, "map": 60.38, "mauroc": 64.22}}
372
  {"date": "2026-10-30T23:19:04", "tag": "effort_rrdataset", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/RRDataset/test", "n_generators": 3, "means": {"acc": 64.59, "map": 69.78, "mauroc": 68.6}}
 
 
 
 
 
 
 
370
  {"date": "2026-10-30T22:33:19", "tag": "effort_evalgen", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/EvalGEN/test", "n_generators": 5, "means": {"acc": 92.16, "map": 99.55, "mauroc": 99.16}}
371
  {"date": "2026-10-30T22:48:42", "tag": "effort_realchain", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/RealChain/test", "n_generators": 7, "means": {"acc": 50.56, "map": 60.38, "mauroc": 64.22}}
372
  {"date": "2026-10-30T23:19:04", "tag": "effort_rrdataset", "model": "effort", "ckpt": "registry-default", "benchmark": "pergen", "data_root": "/ssdstore/ssalehi/SynthScope/RRDataset/test", "n_generators": 3, "means": {"acc": 64.59, "map": 69.78, "mauroc": 68.6}}
373
+ {"date": "2026-10-31T17:58:11", "tag": "split_genvideo_fpr", "model": "split", "ckpt": "registry-default", "benchmark": "video:pergen", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"acc": 50.0, "map": 97.05, "map_real": 99.72, "mauroc": 98.42, "fake_recall_at_fpr": {"0.1": 96.66, "1": 96.91, "5": 96.93}}}
374
+ {"date": "2026-10-31T19:33:09", "tag": "split_official_genvideo", "model": "split", "ckpt": "official SPLIT code (XCLIP-16), training-free", "benchmark": "video:official-split", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"overall_fake_recall_at_fpr": {"0.1": 82.22, "1": 90.17, "5": 96.89}, "mean_fake_recall_at_fpr": {"0.1": 83.66, "1": 91.21, "5": 97.23}, "map": 96.12}}
375
+ {"date": "2026-10-31T20:38:46", "tag": "split_genvideo_fpr", "model": "split", "ckpt": "registry-default", "benchmark": "video:pergen", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"acc": 50.0, "map": 97.05, "map_real": 99.72, "mauroc": 98.42, "fake_recall_at_fpr": {"0.1": 96.66, "1": 96.91, "5": 96.93}, "overall_fake_recall_at_fpr": {"0.1": 96.41, "1": 96.67, "5": 96.69}}}
376
+ {"date": "2026-10-31T22:35:53", "tag": "split_genvideo_fpr", "model": "split", "ckpt": "registry-default", "benchmark": "video:pergen", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"acc": 99.41, "map": 99.79, "map_real": 100.0, "mauroc": 99.97, "fake_recall_at_fpr": {"0.1": 98.94, "1": 99.87, "5": 99.98}, "overall_fake_recall_at_fpr": {"0.1": 98.87, "1": 99.86, "5": 99.98}}}
377
+ {"date": "2026-10-31T23:42:33", "tag": "d3_genvideo_fpr", "model": "d3", "ckpt": "registry-default", "benchmark": "video:pergen", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"acc": 95.53, "map": 68.84, "map_real": 99.87, "mauroc": 98.45, "fake_recall_at_fpr": {"0.1": 0.21, "1": 62.03, "5": 96.52}, "overall_fake_recall_at_fpr": {"0.1": 0.24, "1": 63.32, "5": 96.41}}}
378
+ {"date": "2026-11-01T00:56:39", "tag": "d3_genvideo", "model": "d3", "ckpt": "registry-default", "benchmark": "video:pergen", "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test", "n_generators": 10, "means": {"acc": 95.68, "map": 91.6, "map_real": 98.95, "mauroc": 98.23, "fake_recall_at_fpr": {"0.1": 0.0, "1": 33.77, "5": 96.71}, "overall_fake_recall_at_fpr": {"0.1": 0.0, "1": 35.2, "5": 96.34}}}
repro/RESULTS_SUMMARY.md CHANGED
@@ -23,7 +23,7 @@
23
  | DDA | AIGCDetect / ForenSynths / EvalGEN / SynthWildX / WildRF / GenImage | 87.8 / 81.4 / 97.2 / 90.9 / 90.3 / 91.7 acc | 87.84 / 81.43 / 96.86 / 90.80 / 90.20 / 89.37 acc | 89.58 / 80.79 / 96.79 / 88.00 / 90.56 / **91.35** acc | ✅ official match; trained in SynthScope reproduces 6 of 7 incl. GenImage (SynthWildX 2.9 below) |
24
  | D3QE | ARForensics / ForenSynths-7 / GenImage-6 | 82.11 / 83.73 / 78.61 acc | 81.74 / 83.45 / 79.44 acc | 79.83 / 82.14 / 77.01 acc | ✅ official match; trained in SynthScope: AP matches, acc 1.6-2.3 below |
25
  | OmniAID | GenImage / AIGCDetect / Mirage-Test (GenImage release) | 95.94 / 88.87 / 51.10 acc | 95.82 / 88.87 / 51.10 acc | — | ✅ official match (v1 release too: 97.16 / 92.88 / 88.39 vs 97.24 / 92.88 / 88.39) |
26
- | D3 (video) | GenVideo-Val | 98.5 AP | 97.93 (official code) | 99.14 | ✅ reproduced (D3 protocol; preprocessing matched to the official code) |
27
  | ReStraV (video) | GenVidBench | 93.0 | — | 92.70 | ✅ match |
28
  | STALL (video) | GenVideo-Val | ~80 AUC | — | 83.5 AUROC | ✅ match |
29
 
@@ -185,7 +185,7 @@ Fidelity: SynthScope's class == the official `OmniAID` for all three releases (s
185
  ### Video detectors (no official weights released)
186
  | Method · Benchmark | Paper | SynthScope_train |
187
  |---|---:|---:|
188
- | D3 · GenVideo-Val-10 | 98.46 | 99.14 (96.15); official code 97.93 |
189
  | D3 · EvalCrafter-14 | 98.87 | 99.30 (96.59); official code 98.70 |
190
  | D3 · VideoPhy (8 of 10 generators online) | 99.16 | 99.45 (95.19); official code 98.84 |
191
  | D3 · VidProM (4 of 6 generators, 1000-clip subsample) | 88.46 (84.95 on those 4) | 92.15 (84.86); official code 84.91 |
 
23
  | DDA | AIGCDetect / ForenSynths / EvalGEN / SynthWildX / WildRF / GenImage | 87.8 / 81.4 / 97.2 / 90.9 / 90.3 / 91.7 acc | 87.84 / 81.43 / 96.86 / 90.80 / 90.20 / 89.37 acc | 89.58 / 80.79 / 96.79 / 88.00 / 90.56 / **91.35** acc | ✅ official match; trained in SynthScope reproduces 6 of 7 incl. GenImage (SynthWildX 2.9 below) |
24
  | D3QE | ARForensics / ForenSynths-7 / GenImage-6 | 82.11 / 83.73 / 78.61 acc | 81.74 / 83.45 / 79.44 acc | 79.83 / 82.14 / 77.01 acc | ✅ official match; trained in SynthScope: AP matches, acc 1.6-2.3 below |
25
  | OmniAID | GenImage / AIGCDetect / Mirage-Test (GenImage release) | 95.94 / 88.87 / 51.10 acc | 95.82 / 88.87 / 51.10 acc | — | ✅ official match (v1 release too: 97.16 / 92.88 / 88.39 vs 97.24 / 92.88 / 88.39) |
26
+ | D3 (video) | GenVideo-Val | 98.5 AP | 97.93 (official code) | 98.95 | ✅ reproduced (D3 protocol; preprocessing matched to the official code) |
27
  | ReStraV (video) | GenVidBench | 93.0 | — | 92.70 | ✅ match |
28
  | STALL (video) | GenVideo-Val | ~80 AUC | — | 83.5 AUROC | ✅ match |
29
 
 
185
  ### Video detectors (no official weights released)
186
  | Method · Benchmark | Paper | SynthScope_train |
187
  |---|---:|---:|
188
+ | D3 · GenVideo-Val-10 | 98.46 | 98.95 (95.68); official code 97.93 |
189
  | D3 · EvalCrafter-14 | 98.87 | 99.30 (96.59); official code 98.70 |
190
  | D3 · VideoPhy (8 of 10 generators online) | 99.16 | 99.45 (95.19); official code 98.84 |
191
  | D3 · VidProM (4 of 6 generators, 1000-clip subsample) | 88.46 (84.95 on those 4) | 92.15 (84.86); official code 84.91 |
repro/bench_results/d3_genvideo.json CHANGED
@@ -6,10 +6,20 @@
6
  "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test",
7
  "n_generators": 10,
8
  "means": {
9
- "acc": 96.15,
10
- "map": 92.15,
11
- "map_real": 99.14,
12
- "mauroc": 98.46
 
 
 
 
 
 
 
 
 
 
13
  },
14
  "per_generator": {
15
  "Crafter": {
@@ -18,7 +28,12 @@
18
  "auroc": 98.69,
19
  "real_acc": 94.3,
20
  "fake_acc": 98.8,
21
- "acc": 96.55
 
 
 
 
 
22
  },
23
  "Gen2": {
24
  "ap": 97.01,
@@ -26,7 +41,12 @@
26
  "auroc": 98.99,
27
  "real_acc": 94.3,
28
  "fake_acc": 100.0,
29
- "acc": 97.15
 
 
 
 
 
30
  },
31
  "HotShot": {
32
  "ap": 93.95,
@@ -34,7 +54,12 @@
34
  "auroc": 98.42,
35
  "real_acc": 94.3,
36
  "fake_acc": 98.71,
37
- "acc": 96.51
 
 
 
 
 
38
  },
39
  "Lavie": {
40
  "ap": 96.21,
@@ -42,7 +67,12 @@
42
  "auroc": 98.32,
43
  "real_acc": 94.3,
44
  "fake_acc": 97.4,
45
- "acc": 95.85
 
 
 
 
 
46
  },
47
  "ModelScope": {
48
  "ap": 92.89,
@@ -50,7 +80,12 @@
50
  "auroc": 97.7,
51
  "real_acc": 94.3,
52
  "fake_acc": 94.71,
53
- "acc": 94.51
 
 
 
 
 
54
  },
55
  "MoonValley": {
56
  "ap": 96.08,
@@ -58,7 +93,12 @@
58
  "auroc": 98.98,
59
  "real_acc": 94.3,
60
  "fake_acc": 99.84,
61
- "acc": 97.07
 
 
 
 
 
62
  },
63
  "MorphStudio": {
64
  "ap": 94.1,
@@ -66,7 +106,12 @@
66
  "auroc": 98.49,
67
  "real_acc": 94.3,
68
  "fake_acc": 99.14,
69
- "acc": 96.72
 
 
 
 
 
70
  },
71
  "Show_1": {
72
  "ap": 94.62,
@@ -74,7 +119,12 @@
74
  "auroc": 98.72,
75
  "real_acc": 94.3,
76
  "fake_acc": 99.57,
77
- "acc": 96.94
 
 
 
 
 
78
  },
79
  "Sora": {
80
  "ap": 62.39,
@@ -82,17 +132,27 @@
82
  "auroc": 98.45,
83
  "real_acc": 94.3,
84
  "fake_acc": 98.21,
85
- "acc": 96.26
 
 
 
 
 
86
  },
87
  "WildScrape": {
88
- "ap": 97.29,
89
- "ap_real": 98.21,
90
- "auroc": 97.83,
91
  "real_acc": 94.3,
92
- "fake_acc": 93.51,
93
- "acc": 93.9
 
 
 
 
 
94
  }
95
  },
96
- "date": "2026-10-11T18:03:10",
97
  "synthscope_version": "video"
98
  }
 
6
  "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test",
7
  "n_generators": 10,
8
  "means": {
9
+ "acc": 95.68,
10
+ "map": 91.6,
11
+ "map_real": 98.95,
12
+ "mauroc": 98.23,
13
+ "fake_recall_at_fpr": {
14
+ "0.1": 0.0,
15
+ "1": 33.77,
16
+ "5": 96.71
17
+ },
18
+ "overall_fake_recall_at_fpr": {
19
+ "0.1": 0.0,
20
+ "1": 35.2,
21
+ "5": 96.34
22
+ }
23
  },
24
  "per_generator": {
25
  "Crafter": {
 
28
  "auroc": 98.69,
29
  "real_acc": 94.3,
30
  "fake_acc": 98.8,
31
+ "acc": 96.55,
32
+ "fake_recall_at_fpr": {
33
+ "0.1": 0.0,
34
+ "1": 44.6,
35
+ "5": 98.5
36
+ }
37
  },
38
  "Gen2": {
39
  "ap": 97.01,
 
41
  "auroc": 98.99,
42
  "real_acc": 94.3,
43
  "fake_acc": 100.0,
44
+ "acc": 97.15,
45
+ "fake_recall_at_fpr": {
46
+ "0.1": 0.0,
47
+ "1": 56.4,
48
+ "5": 100.0
49
+ }
50
  },
51
  "HotShot": {
52
  "ap": 93.95,
 
54
  "auroc": 98.42,
55
  "real_acc": 94.3,
56
  "fake_acc": 98.71,
57
+ "acc": 96.51,
58
+ "fake_recall_at_fpr": {
59
+ "0.1": 0.0,
60
+ "1": 23.29,
61
+ "5": 98.43
62
+ }
63
  },
64
  "Lavie": {
65
  "ap": 96.21,
 
67
  "auroc": 98.32,
68
  "real_acc": 94.3,
69
  "fake_acc": 97.4,
70
+ "acc": 95.85,
71
+ "fake_recall_at_fpr": {
72
+ "0.1": 0.0,
73
+ "1": 36.3,
74
+ "5": 96.7
75
+ }
76
  },
77
  "ModelScope": {
78
  "ap": 92.89,
 
80
  "auroc": 97.7,
81
  "real_acc": 94.3,
82
  "fake_acc": 94.71,
83
+ "acc": 94.51,
84
+ "fake_recall_at_fpr": {
85
+ "0.1": 0.0,
86
+ "1": 19.57,
87
+ "5": 93.86
88
+ }
89
  },
90
  "MoonValley": {
91
  "ap": 96.08,
 
93
  "auroc": 98.98,
94
  "real_acc": 94.3,
95
  "fake_acc": 99.84,
96
+ "acc": 97.07,
97
+ "fake_recall_at_fpr": {
98
+ "0.1": 0.0,
99
+ "1": 59.58,
100
+ "5": 99.84
101
+ }
102
  },
103
  "MorphStudio": {
104
  "ap": 94.1,
 
106
  "auroc": 98.49,
107
  "real_acc": 94.3,
108
  "fake_acc": 99.14,
109
+ "acc": 96.72,
110
+ "fake_recall_at_fpr": {
111
+ "0.1": 0.0,
112
+ "1": 25.14,
113
+ "5": 99.0
114
+ }
115
  },
116
  "Show_1": {
117
  "ap": 94.62,
 
119
  "auroc": 98.72,
120
  "real_acc": 94.3,
121
  "fake_acc": 99.57,
122
+ "acc": 96.94,
123
+ "fake_recall_at_fpr": {
124
+ "0.1": 0.0,
125
+ "1": 38.71,
126
+ "5": 99.57
127
+ }
128
  },
129
  "Sora": {
130
  "ap": 62.39,
 
132
  "auroc": 98.45,
133
  "real_acc": 94.3,
134
  "fake_acc": 98.21,
135
+ "acc": 96.26,
136
+ "fake_recall_at_fpr": {
137
+ "0.1": 0.0,
138
+ "1": 23.21,
139
+ "5": 98.21
140
+ }
141
  },
142
  "WildScrape": {
143
+ "ap": 91.88,
144
+ "ap_real": 96.33,
145
+ "auroc": 95.55,
146
  "real_acc": 94.3,
147
+ "fake_acc": 84.09,
148
+ "acc": 89.2,
149
+ "fake_recall_at_fpr": {
150
+ "0.1": 0.0,
151
+ "1": 10.93,
152
+ "5": 83.01
153
+ }
154
  }
155
  },
156
+ "date": "2026-11-01T00:56:39",
157
  "synthscope_version": "video"
158
  }
repro/bench_results/d3_genvideo_fpr.json ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tag": "d3_genvideo_fpr",
3
+ "model": "d3",
4
+ "ckpt": "registry-default",
5
+ "benchmark": "video:pergen",
6
+ "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test",
7
+ "n_generators": 10,
8
+ "means": {
9
+ "acc": 95.53,
10
+ "map": 68.84,
11
+ "map_real": 99.87,
12
+ "mauroc": 98.45,
13
+ "fake_recall_at_fpr": {
14
+ "0.1": 0.21,
15
+ "1": 62.03,
16
+ "5": 96.52
17
+ },
18
+ "overall_fake_recall_at_fpr": {
19
+ "0.1": 0.24,
20
+ "1": 63.32,
21
+ "5": 96.41
22
+ }
23
+ },
24
+ "per_generator": {
25
+ "Crafter": {
26
+ "ap": 84.96,
27
+ "ap_real": 99.86,
28
+ "auroc": 98.9,
29
+ "real_acc": 93.99,
30
+ "fake_acc": 98.93,
31
+ "acc": 96.46,
32
+ "fake_recall_at_fpr": {
33
+ "0.1": 0.57,
34
+ "1": 69.24,
35
+ "5": 98.57
36
+ }
37
+ },
38
+ "Gen2": {
39
+ "ap": 85.37,
40
+ "ap_real": 99.9,
41
+ "auroc": 99.17,
42
+ "real_acc": 93.99,
43
+ "fake_acc": 100.0,
44
+ "acc": 96.99,
45
+ "fake_recall_at_fpr": {
46
+ "0.1": 0.07,
47
+ "1": 84.28,
48
+ "5": 100.0
49
+ }
50
+ },
51
+ "HotShot": {
52
+ "ap": 69.37,
53
+ "ap_real": 99.91,
54
+ "auroc": 98.65,
55
+ "real_acc": 93.99,
56
+ "fake_acc": 98.71,
57
+ "acc": 96.35,
58
+ "fake_recall_at_fpr": {
59
+ "0.1": 0.0,
60
+ "1": 56.29,
61
+ "5": 98.29
62
+ }
63
+ },
64
+ "Lavie": {
65
+ "ap": 82.22,
66
+ "ap_real": 99.79,
67
+ "auroc": 98.52,
68
+ "real_acc": 93.99,
69
+ "fake_acc": 97.43,
70
+ "acc": 95.71,
71
+ "fake_recall_at_fpr": {
72
+ "0.1": 0.36,
73
+ "1": 62.36,
74
+ "5": 96.29
75
+ }
76
+ },
77
+ "ModelScope": {
78
+ "ap": 66.32,
79
+ "ap_real": 99.85,
80
+ "auroc": 97.93,
81
+ "real_acc": 93.99,
82
+ "fake_acc": 94.71,
83
+ "acc": 94.35,
84
+ "fake_recall_at_fpr": {
85
+ "0.1": 0.0,
86
+ "1": 48.57,
87
+ "5": 93.86
88
+ }
89
+ },
90
+ "MoonValley": {
91
+ "ap": 76.11,
92
+ "ap_real": 99.95,
93
+ "auroc": 99.15,
94
+ "real_acc": 93.99,
95
+ "fake_acc": 99.84,
96
+ "acc": 96.92,
97
+ "fake_recall_at_fpr": {
98
+ "0.1": 1.12,
99
+ "1": 81.95,
100
+ "5": 99.84
101
+ }
102
+ },
103
+ "MorphStudio": {
104
+ "ap": 69.42,
105
+ "ap_real": 99.91,
106
+ "auroc": 98.71,
107
+ "real_acc": 93.99,
108
+ "fake_acc": 99.14,
109
+ "acc": 96.57,
110
+ "fake_recall_at_fpr": {
111
+ "0.1": 0.0,
112
+ "1": 51.57,
113
+ "5": 98.57
114
+ }
115
+ },
116
+ "Show_1": {
117
+ "ap": 72.83,
118
+ "ap_real": 99.93,
119
+ "auroc": 98.93,
120
+ "real_acc": 93.99,
121
+ "fake_acc": 99.57,
122
+ "acc": 96.78,
123
+ "fake_recall_at_fpr": {
124
+ "0.1": 0.0,
125
+ "1": 68.29,
126
+ "5": 99.57
127
+ }
128
+ },
129
+ "Sora": {
130
+ "ap": 19.13,
131
+ "ap_real": 99.99,
132
+ "auroc": 98.69,
133
+ "real_acc": 93.99,
134
+ "fake_acc": 98.21,
135
+ "acc": 96.1,
136
+ "fake_recall_at_fpr": {
137
+ "0.1": 0.0,
138
+ "1": 64.29,
139
+ "5": 98.21
140
+ }
141
+ },
142
+ "WildScrape": {
143
+ "ap": 62.68,
144
+ "ap_real": 99.58,
145
+ "auroc": 95.82,
146
+ "real_acc": 93.99,
147
+ "fake_acc": 84.09,
148
+ "acc": 89.04,
149
+ "fake_recall_at_fpr": {
150
+ "0.1": 0.0,
151
+ "1": 33.44,
152
+ "5": 82.03
153
+ }
154
+ }
155
+ },
156
+ "date": "2026-10-31T23:42:33",
157
+ "synthscope_version": "video"
158
+ }
repro/bench_results/split_genvideo_fpr.json ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tag": "split_genvideo_fpr",
3
+ "model": "split",
4
+ "ckpt": "registry-default",
5
+ "benchmark": "video:pergen",
6
+ "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test",
7
+ "n_generators": 10,
8
+ "means": {
9
+ "acc": 99.41,
10
+ "map": 99.79,
11
+ "map_real": 100.0,
12
+ "mauroc": 99.97,
13
+ "fake_recall_at_fpr": {
14
+ "0.1": 98.94,
15
+ "1": 99.87,
16
+ "5": 99.98
17
+ },
18
+ "overall_fake_recall_at_fpr": {
19
+ "0.1": 98.87,
20
+ "1": 99.86,
21
+ "5": 99.98
22
+ }
23
+ },
24
+ "per_generator": {
25
+ "Crafter": {
26
+ "ap": 99.98,
27
+ "ap_real": 100.0,
28
+ "auroc": 100.0,
29
+ "real_acc": 98.87,
30
+ "fake_acc": 100.0,
31
+ "acc": 99.43,
32
+ "fake_recall_at_fpr": {
33
+ "0.1": 100.0,
34
+ "1": 100.0,
35
+ "5": 100.0
36
+ }
37
+ },
38
+ "Gen2": {
39
+ "ap": 99.84,
40
+ "ap_real": 100.0,
41
+ "auroc": 99.98,
42
+ "real_acc": 98.87,
43
+ "fake_acc": 100.0,
44
+ "acc": 99.43,
45
+ "fake_recall_at_fpr": {
46
+ "0.1": 100.0,
47
+ "1": 100.0,
48
+ "5": 100.0
49
+ }
50
+ },
51
+ "HotShot": {
52
+ "ap": 100.0,
53
+ "ap_real": 100.0,
54
+ "auroc": 100.0,
55
+ "real_acc": 98.87,
56
+ "fake_acc": 100.0,
57
+ "acc": 99.43,
58
+ "fake_recall_at_fpr": {
59
+ "0.1": 100.0,
60
+ "1": 100.0,
61
+ "5": 100.0
62
+ }
63
+ },
64
+ "Lavie": {
65
+ "ap": 99.96,
66
+ "ap_real": 100.0,
67
+ "auroc": 99.99,
68
+ "real_acc": 98.87,
69
+ "fake_acc": 99.93,
70
+ "acc": 99.4,
71
+ "fake_recall_at_fpr": {
72
+ "0.1": 99.93,
73
+ "1": 99.93,
74
+ "5": 100.0
75
+ }
76
+ },
77
+ "ModelScope": {
78
+ "ap": 99.89,
79
+ "ap_real": 100.0,
80
+ "auroc": 99.99,
81
+ "real_acc": 98.87,
82
+ "fake_acc": 100.0,
83
+ "acc": 99.43,
84
+ "fake_recall_at_fpr": {
85
+ "0.1": 99.57,
86
+ "1": 99.86,
87
+ "5": 100.0
88
+ }
89
+ },
90
+ "MoonValley": {
91
+ "ap": 99.62,
92
+ "ap_real": 100.0,
93
+ "auroc": 99.98,
94
+ "real_acc": 98.87,
95
+ "fake_acc": 100.0,
96
+ "acc": 99.43,
97
+ "fake_recall_at_fpr": {
98
+ "0.1": 100.0,
99
+ "1": 100.0,
100
+ "5": 100.0
101
+ }
102
+ },
103
+ "MorphStudio": {
104
+ "ap": 99.97,
105
+ "ap_real": 100.0,
106
+ "auroc": 100.0,
107
+ "real_acc": 98.87,
108
+ "fake_acc": 100.0,
109
+ "acc": 99.43,
110
+ "fake_recall_at_fpr": {
111
+ "0.1": 100.0,
112
+ "1": 100.0,
113
+ "5": 100.0
114
+ }
115
+ },
116
+ "Show_1": {
117
+ "ap": 99.96,
118
+ "ap_real": 100.0,
119
+ "auroc": 100.0,
120
+ "real_acc": 98.87,
121
+ "fake_acc": 100.0,
122
+ "acc": 99.43,
123
+ "fake_recall_at_fpr": {
124
+ "0.1": 100.0,
125
+ "1": 100.0,
126
+ "5": 100.0
127
+ }
128
+ },
129
+ "Sora": {
130
+ "ap": 99.63,
131
+ "ap_real": 100.0,
132
+ "auroc": 100.0,
133
+ "real_acc": 98.87,
134
+ "fake_acc": 100.0,
135
+ "acc": 99.43,
136
+ "fake_recall_at_fpr": {
137
+ "0.1": 100.0,
138
+ "1": 100.0,
139
+ "5": 100.0
140
+ }
141
+ },
142
+ "WildScrape": {
143
+ "ap": 99.09,
144
+ "ap_real": 99.97,
145
+ "auroc": 99.79,
146
+ "real_acc": 98.87,
147
+ "fake_acc": 99.57,
148
+ "acc": 99.22,
149
+ "fake_recall_at_fpr": {
150
+ "0.1": 89.94,
151
+ "1": 98.92,
152
+ "5": 99.78
153
+ }
154
+ }
155
+ },
156
+ "date": "2026-10-31T22:35:53",
157
+ "synthscope_version": "video"
158
+ }
repro/bench_results/split_official_genvideo.json ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tag": "split_official_genvideo",
3
+ "model": "split",
4
+ "ckpt": "official SPLIT code (XCLIP-16), training-free",
5
+ "benchmark": "video:official-split",
6
+ "data_root": "/ssdstore/ssalehi/SynthScope/GenVideo/test",
7
+ "n_generators": 10,
8
+ "means": {
9
+ "overall_fake_recall_at_fpr": {
10
+ "0.1": 82.22,
11
+ "1": 90.17,
12
+ "5": 96.89
13
+ },
14
+ "mean_fake_recall_at_fpr": {
15
+ "0.1": 83.66,
16
+ "1": 91.21,
17
+ "5": 97.23
18
+ },
19
+ "map": 96.12
20
+ },
21
+ "overall": {
22
+ "fake_recall_at_fpr": {
23
+ "0.1": 82.22,
24
+ "1": 90.17,
25
+ "5": 96.89
26
+ },
27
+ "ap": 99.19,
28
+ "ap_real": 98.99,
29
+ "auroc": 99.08,
30
+ "n_real": 10000,
31
+ "n_fake": 8573
32
+ },
33
+ "per_generator": {
34
+ "Crafter": {
35
+ "fake_recall_at_fpr": {
36
+ "0.1": 89.99,
37
+ "1": 96.21,
38
+ "5": 99.64
39
+ },
40
+ "ap": 99.25,
41
+ "ap_real": 99.98,
42
+ "auroc": 99.88,
43
+ "n_real": 10000,
44
+ "n_fake": 1398
45
+ },
46
+ "Gen2": {
47
+ "fake_recall_at_fpr": {
48
+ "0.1": 56.16,
49
+ "1": 76.3,
50
+ "5": 94.64
51
+ },
52
+ "ap": 94.42,
53
+ "ap_real": 99.88,
54
+ "auroc": 99.1,
55
+ "n_real": 10000,
56
+ "n_fake": 1380
57
+ },
58
+ "HotShot": {
59
+ "fake_recall_at_fpr": {
60
+ "0.1": 99.86,
61
+ "1": 100.0,
62
+ "5": 100.0
63
+ },
64
+ "ap": 100.0,
65
+ "ap_real": 100.0,
66
+ "auroc": 100.0,
67
+ "n_real": 10000,
68
+ "n_fake": 700
69
+ },
70
+ "Lavie": {
71
+ "fake_recall_at_fpr": {
72
+ "0.1": 93.21,
73
+ "1": 96.07,
74
+ "5": 98.71
75
+ },
76
+ "ap": 98.94,
77
+ "ap_real": 99.96,
78
+ "auroc": 99.73,
79
+ "n_real": 10000,
80
+ "n_fake": 1400
81
+ },
82
+ "ModelScope": {
83
+ "fake_recall_at_fpr": {
84
+ "0.1": 87.29,
85
+ "1": 93.14,
86
+ "5": 97.86
87
+ },
88
+ "ap": 97.2,
89
+ "ap_real": 99.97,
90
+ "auroc": 99.58,
91
+ "n_real": 10000,
92
+ "n_fake": 700
93
+ },
94
+ "MoonValley": {
95
+ "fake_recall_at_fpr": {
96
+ "0.1": 52.88,
97
+ "1": 74.76,
98
+ "5": 95.37
99
+ },
100
+ "ap": 89.71,
101
+ "ap_real": 99.94,
102
+ "auroc": 99.11,
103
+ "n_real": 10000,
104
+ "n_fake": 626
105
+ },
106
+ "MorphStudio": {
107
+ "fake_recall_at_fpr": {
108
+ "0.1": 95.0,
109
+ "1": 98.0,
110
+ "5": 99.71
111
+ },
112
+ "ap": 99.39,
113
+ "ap_real": 100.0,
114
+ "auroc": 99.94,
115
+ "n_real": 10000,
116
+ "n_fake": 700
117
+ },
118
+ "Show_1": {
119
+ "fake_recall_at_fpr": {
120
+ "0.1": 97.86,
121
+ "1": 99.71,
122
+ "5": 100.0
123
+ },
124
+ "ap": 99.85,
125
+ "ap_real": 100.0,
126
+ "auroc": 99.99,
127
+ "n_real": 10000,
128
+ "n_fake": 700
129
+ },
130
+ "Sora": {
131
+ "fake_recall_at_fpr": {
132
+ "0.1": 91.07,
133
+ "1": 98.21,
134
+ "5": 100.0
135
+ },
136
+ "ap": 94.72,
137
+ "ap_real": 100.0,
138
+ "auroc": 99.91,
139
+ "n_real": 10000,
140
+ "n_fake": 56
141
+ },
142
+ "WildScrape": {
143
+ "fake_recall_at_fpr": {
144
+ "0.1": 73.27,
145
+ "1": 79.74,
146
+ "5": 86.42
147
+ },
148
+ "ap": 87.71,
149
+ "ap_real": 99.26,
150
+ "auroc": 94.31,
151
+ "n_real": 10000,
152
+ "n_fake": 913
153
+ }
154
+ },
155
+ "date": "2026-10-31T19:33:09"
156
+ }
repro/benchmark_specs/d3.json CHANGED
@@ -3,7 +3,7 @@
3
  "paper": "D3: Training-Free AI-Generated Video Detection Using Second-Order Features (Zheng et al., ICCV 2025)",
4
  "arxiv_id": "n/a (CVF Open Access ICCV 2025; code github.com/Zig-HS/D3)",
5
  "official_repo": "https://github.com/Zig-HS/D3",
6
- "training_set_in_paper": "NONE \u2014 D3 is training-free (inference only). Baselines (re-implemented for comparison) were trained on real videos from Youku-mPLUG + Pika fakes, following DeMamba (paper Sec 4.1).",
7
  "encoder_in_paper": "XCLIP-B/16 (X-CLIP ViT-B/16, Ni et al. ECCV'22, paper ref [34]) as the frozen per-frame visual encoder; first-order = L2 distance. CLIP-B/16 is the ablation runner-up (~97 on GenVideo, Table 7).",
8
  "paper_protocol": "Extract up to a 2 s segment, sample frames at 8 fps, JPEG; crop 10% of the longer edge then resize to 224x224. Score = std of 2nd-order central-difference of inter-frame L2 distances (Eq.8). Metric reported: AP (real-class); AUROC in supplementary. Hardware RTX 4090. (paper Sec 4.1 'Implementation Details')",
9
  "synthscope_encoder": "X-CLIP-B/16 (microsoft/xclip-base-patch16), as in the paper. SynthScope's D3 gives the same score as the official D3_model on the same frames (max |diff| 0, repro check 2026-10-11).",
@@ -45,11 +45,11 @@
45
  "official_eval_protocol": "XCLIP-B/16 frozen, 8 fps over <=2 s, JPEG, 10% longer-edge crop, resize 224x224, L2 first-order, std of 2nd-order central diff; AP computed real-class (positive=real, score=+std), max_len=1000 per class (official eval.py).",
46
  "our_status": "reproduced",
47
  "our_number": {
48
- "map": 99.14,
49
- "auroc": 98.46,
50
- "acc": 96.15,
51
  "official_code_map": 97.93,
52
- "note": "D3 protocol (--limit 1000: first 1000 clips per class per generator; AP with real as the positive class). SynthScope engine 99.14 AP (AUROC 98.46, balanced acc 96.15 at the refitted calibration; tag d3_genvideo, job 252246). Official D3 code end to end: 97.93 (tag d3_official_genvideo, job 252271): 9 of 10 generators within 0.25 of the paper; WildScrape 92.50 vs 96.49 (282 GIFs whose length decord cannot read start at 0 instead of a random offset). The old 63.93 compared 10,000 reals with as few as 56 fakes and used AP with fake as positive."
53
  },
54
  "our_per_generator_ap": {
55
  "Crafter": 99.14,
@@ -61,10 +61,10 @@
61
  "MorphStudio": 99.27,
62
  "Show_1": 99.37,
63
  "Sora": 99.91,
64
- "WildScrape": 98.21
65
  },
66
  "dataset_source": "on-disk: /hddstore/ssalehi/SynthScope/d3_repro/genvideo_val/GenVideo-Val (14 GB; downloaded from ModelScope cccnju/Gen-Video GenVideo-Val.zip). The real source is MSR-VTT.",
67
- "xclip_weights_downloadable": "YES \u2014 microsoft/xclip-base-patch16 on HuggingFace (Kinetics-400 trained, NOT gated). Matches paper ref [34]. The gap-closing fix (swap CLIP->XCLIP) is unblocked by available weights.",
68
  "official_code_per_generator_ap": {
69
  "Crafter": 98.28,
70
  "Gen2": 99.24,
@@ -281,4 +281,4 @@
281
  "n_benchmarks_reported": 4,
282
  "key_finding": "Since 2026-10-11 SynthScope's D3 follows the official preprocessing (16 frames at 8 fps, 8 for clips under 2 s; BGR; 10% crop of the longer side; ImageNet normalization) and is scored with D3's protocol (first 1000 clips per class, AP with real as the positive class). The official D3 code run end to end on the same data (repro/official_evals/d3_official_eval.py) reproduces GenVideo 97.93, EvalCrafter 98.70, VideoPhy 98.84 (8 of 10 generators) and VidProM 84.91 (4 of 6 generators, paper 84.95 on those 4); SynthScope's engine gives 99.14 / 99.30 / 99.45 / 92.15. The remaining engine-vs-official difference is only the official JPEG frame round-trip."
283
  }
284
- }
 
3
  "paper": "D3: Training-Free AI-Generated Video Detection Using Second-Order Features (Zheng et al., ICCV 2025)",
4
  "arxiv_id": "n/a (CVF Open Access ICCV 2025; code github.com/Zig-HS/D3)",
5
  "official_repo": "https://github.com/Zig-HS/D3",
6
+ "training_set_in_paper": "NONE — D3 is training-free (inference only). Baselines (re-implemented for comparison) were trained on real videos from Youku-mPLUG + Pika fakes, following DeMamba (paper Sec 4.1).",
7
  "encoder_in_paper": "XCLIP-B/16 (X-CLIP ViT-B/16, Ni et al. ECCV'22, paper ref [34]) as the frozen per-frame visual encoder; first-order = L2 distance. CLIP-B/16 is the ablation runner-up (~97 on GenVideo, Table 7).",
8
  "paper_protocol": "Extract up to a 2 s segment, sample frames at 8 fps, JPEG; crop 10% of the longer edge then resize to 224x224. Score = std of 2nd-order central-difference of inter-frame L2 distances (Eq.8). Metric reported: AP (real-class); AUROC in supplementary. Hardware RTX 4090. (paper Sec 4.1 'Implementation Details')",
9
  "synthscope_encoder": "X-CLIP-B/16 (microsoft/xclip-base-patch16), as in the paper. SynthScope's D3 gives the same score as the official D3_model on the same frames (max |diff| 0, repro check 2026-10-11).",
 
45
  "official_eval_protocol": "XCLIP-B/16 frozen, 8 fps over <=2 s, JPEG, 10% longer-edge crop, resize 224x224, L2 first-order, std of 2nd-order central diff; AP computed real-class (positive=real, score=+std), max_len=1000 per class (official eval.py).",
46
  "our_status": "reproduced",
47
  "our_number": {
48
+ "map": 98.95,
49
+ "auroc": 98.23,
50
+ "acc": 95.68,
51
  "official_code_map": 97.93,
52
+ "note": "D3 protocol (--limit 1000: first 1000 clips per class per generator; AP with real as the positive class). SynthScope engine 98.95 AP (AUROC 98.23, balanced acc 95.68 at the refitted calibration; tag d3_genvideo, job 270428, after the GIF decode fix: WildScrape 96.33 vs paper 96.49, was 98.21 when GIFs decoded as black clips). Official D3 code end to end: 97.93 (tag d3_official_genvideo, job 252271): 9 of 10 generators within 0.25 of the paper; WildScrape 92.50 vs 96.49 (282 GIFs whose length decord cannot read start at 0 instead of a random offset). The old 63.93 compared 10,000 reals with as few as 56 fakes and used AP with fake as positive."
53
  },
54
  "our_per_generator_ap": {
55
  "Crafter": 99.14,
 
61
  "MorphStudio": 99.27,
62
  "Show_1": 99.37,
63
  "Sora": 99.91,
64
+ "WildScrape": 96.33
65
  },
66
  "dataset_source": "on-disk: /hddstore/ssalehi/SynthScope/d3_repro/genvideo_val/GenVideo-Val (14 GB; downloaded from ModelScope cccnju/Gen-Video GenVideo-Val.zip). The real source is MSR-VTT.",
67
+ "xclip_weights_downloadable": "YES — microsoft/xclip-base-patch16 on HuggingFace (Kinetics-400 trained, NOT gated). Matches paper ref [34]. The gap-closing fix (swap CLIP->XCLIP) is unblocked by available weights.",
68
  "official_code_per_generator_ap": {
69
  "Crafter": 98.28,
70
  "Gen2": 99.24,
 
281
  "n_benchmarks_reported": 4,
282
  "key_finding": "Since 2026-10-11 SynthScope's D3 follows the official preprocessing (16 frames at 8 fps, 8 for clips under 2 s; BGR; 10% crop of the longer side; ImageNet normalization) and is scored with D3's protocol (first 1000 clips per class, AP with real as the positive class). The official D3 code run end to end on the same data (repro/official_evals/d3_official_eval.py) reproduces GenVideo 97.93, EvalCrafter 98.70, VideoPhy 98.84 (8 of 10 generators) and VidProM 84.91 (4 of 6 generators, paper 84.95 on those 4); SynthScope's engine gives 99.14 / 99.30 / 99.45 / 92.15. The remaining engine-vs-official difference is only the official JPEG frame round-trip."
283
  }
284
+ }
repro/benchmark_specs/split.json ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "split",
3
+ "paper": "SPLIT: Training-Free AI-Generated and Partially Edited Video Detection via Spatial Patch-Level Incoherence and Temporal Roughness (Hyun & Kim, ECCV 2026)",
4
+ "arxiv_id": "2607.02886",
5
+ "official_repo": "https://github.com/mldljyh/SPLIT",
6
+ "training_set_in_paper": "NONE - SPLIT is training-free (frozen encoder, real-only threshold calibration).",
7
+ "encoder_in_paper": "X-CLIP B/16 (microsoft/xclip-base-patch16) patch tokens; CLIP, DINO and CNN encoders are ablations.",
8
+ "paper_protocol": "Official video2frame.py: ffmpeg JPEG frames, 8 fps over 3 s (random start for longer clips); read_video: 16 frames (8 if fewer than 16), cv2 BGR, 10% crop of the longer side, resize 224, ImageNet normalization. Score = TTR**8 * LSMI. Metric: fake recall at FPR 0.1/1/5%, threshold = (1-FPR) quantile of the real scores (eval.py evaluate_split).",
9
+ "synthscope_encoder": "X-CLIP B/16, as in the paper. SynthScope's own implementation (the official repo has no license) returns the official SPLIT_model.forward_score on the same frames (relative difference <= 7e-6, job 269966).",
10
+ "benchmarks": [
11
+ {
12
+ "name": "GenVideo (GenVideo-Val, MSR-VTT real calibration, supp. Table 18)",
13
+ "datasets": [
14
+ "GenVideo-Val"
15
+ ],
16
+ "subsets_or_generators": [
17
+ "Crafter",
18
+ "Gen2",
19
+ "HotShot",
20
+ "Lavie",
21
+ "ModelScope",
22
+ "MoonValley",
23
+ "MorphStudio",
24
+ "Show_1",
25
+ "Sora",
26
+ "WildScrape"
27
+ ],
28
+ "metric": "fake recall @ FPR 0.1/1/5% (Overall = all fakes pooled)",
29
+ "paper_number": {
30
+ "fake_recall_fpr0.1": 82.13,
31
+ "fake_recall_fpr1": 91.69,
32
+ "fake_recall_fpr5": 97.2
33
+ },
34
+ "paper_per_generator": {
35
+ "Crafter": {
36
+ "fpr_0.1": 88.84,
37
+ "fpr_1": 96.78,
38
+ "fpr_5": 99.64
39
+ },
40
+ "Gen2": {
41
+ "fpr_0.1": 53.26,
42
+ "fpr_1": 77.61,
43
+ "fpr_5": 94.71
44
+ },
45
+ "HotShot": {
46
+ "fpr_0.1": 99.86,
47
+ "fpr_1": 100,
48
+ "fpr_5": 100
49
+ },
50
+ "Lavie": {
51
+ "fpr_0.1": 92.5,
52
+ "fpr_1": 96.36,
53
+ "fpr_5": 98.79
54
+ },
55
+ "ModelScope": {
56
+ "fpr_0.1": 86.71,
57
+ "fpr_1": 93.57,
58
+ "fpr_5": 97.86
59
+ },
60
+ "MoonValley": {
61
+ "fpr_0.1": 49.36,
62
+ "fpr_1": 76.36,
63
+ "fpr_5": 95.37
64
+ },
65
+ "MorphStudio": {
66
+ "fpr_0.1": 94.71,
67
+ "fpr_1": 98.43,
68
+ "fpr_5": 99.71
69
+ },
70
+ "Show_1": {
71
+ "fpr_0.1": 96.86,
72
+ "fpr_1": 99.86,
73
+ "fpr_5": 100
74
+ },
75
+ "Sora": {
76
+ "fpr_0.1": 87.5,
77
+ "fpr_1": 98.21,
78
+ "fpr_5": 100
79
+ },
80
+ "WildScrape": {
81
+ "fpr_0.1": 71.66,
82
+ "fpr_1": 79.76,
83
+ "fpr_5": 85.89
84
+ }
85
+ },
86
+ "official_eval_protocol": "All 10,000 MSR-VTT reals calibrate the threshold; all fakes of the 10 generators scored (official eval.py, no max_len).",
87
+ "our_status": "reproduced",
88
+ "our_number": {
89
+ "official_code_our_data": {
90
+ "fpr_0.1": 82.22,
91
+ "fpr_1": 90.17,
92
+ "fpr_5": 96.89
93
+ },
94
+ "synthscope": {
95
+ "fpr_0.1": 98.87,
96
+ "fpr_1": 99.86,
97
+ "fpr_5": 99.98
98
+ },
99
+ "note": "Official code on our data (JPEG frame round-trip, job 270323) reproduces the paper: Overall fake recall 82.22/90.17/96.89 vs 82.13/91.69/97.20. SynthScope decodes frames directly (no JPEG) and reaches 98.87/99.86/99.98 (job 270384; mAP 99.79, AUROC 99.97, acc 99.41 at the calibrated threshold)."
100
+ },
101
+ "official_code_per_generator": {
102
+ "Crafter": {
103
+ "fpr_0.1": 89.99,
104
+ "fpr_1": 96.21,
105
+ "fpr_5": 99.64
106
+ },
107
+ "Gen2": {
108
+ "fpr_0.1": 56.16,
109
+ "fpr_1": 76.3,
110
+ "fpr_5": 94.64
111
+ },
112
+ "HotShot": {
113
+ "fpr_0.1": 99.86,
114
+ "fpr_1": 100.0,
115
+ "fpr_5": 100.0
116
+ },
117
+ "Lavie": {
118
+ "fpr_0.1": 93.21,
119
+ "fpr_1": 96.07,
120
+ "fpr_5": 98.71
121
+ },
122
+ "ModelScope": {
123
+ "fpr_0.1": 87.29,
124
+ "fpr_1": 93.14,
125
+ "fpr_5": 97.86
126
+ },
127
+ "MoonValley": {
128
+ "fpr_0.1": 52.88,
129
+ "fpr_1": 74.76,
130
+ "fpr_5": 95.37
131
+ },
132
+ "MorphStudio": {
133
+ "fpr_0.1": 95.0,
134
+ "fpr_1": 98.0,
135
+ "fpr_5": 99.71
136
+ },
137
+ "Show_1": {
138
+ "fpr_0.1": 97.86,
139
+ "fpr_1": 99.71,
140
+ "fpr_5": 100.0
141
+ },
142
+ "Sora": {
143
+ "fpr_0.1": 91.07,
144
+ "fpr_1": 98.21,
145
+ "fpr_5": 100.0
146
+ },
147
+ "WildScrape": {
148
+ "fpr_0.1": 73.27,
149
+ "fpr_1": 79.74,
150
+ "fpr_5": 86.42
151
+ }
152
+ },
153
+ "our_per_generator": {
154
+ "Crafter": {
155
+ "fpr_0.1": 100.0,
156
+ "fpr_1": 100.0,
157
+ "fpr_5": 100.0
158
+ },
159
+ "Gen2": {
160
+ "fpr_0.1": 100.0,
161
+ "fpr_1": 100.0,
162
+ "fpr_5": 100.0
163
+ },
164
+ "HotShot": {
165
+ "fpr_0.1": 100.0,
166
+ "fpr_1": 100.0,
167
+ "fpr_5": 100.0
168
+ },
169
+ "Lavie": {
170
+ "fpr_0.1": 99.93,
171
+ "fpr_1": 99.93,
172
+ "fpr_5": 100.0
173
+ },
174
+ "ModelScope": {
175
+ "fpr_0.1": 99.57,
176
+ "fpr_1": 99.86,
177
+ "fpr_5": 100.0
178
+ },
179
+ "MoonValley": {
180
+ "fpr_0.1": 100.0,
181
+ "fpr_1": 100.0,
182
+ "fpr_5": 100.0
183
+ },
184
+ "MorphStudio": {
185
+ "fpr_0.1": 100.0,
186
+ "fpr_1": 100.0,
187
+ "fpr_5": 100.0
188
+ },
189
+ "Show_1": {
190
+ "fpr_0.1": 100.0,
191
+ "fpr_1": 100.0,
192
+ "fpr_5": 100.0
193
+ },
194
+ "Sora": {
195
+ "fpr_0.1": 100.0,
196
+ "fpr_1": 100.0,
197
+ "fpr_5": 100.0
198
+ },
199
+ "WildScrape": {
200
+ "fpr_0.1": 89.94,
201
+ "fpr_1": 98.92,
202
+ "fpr_5": 99.78
203
+ }
204
+ },
205
+ "dataset_source": "genvideo loader (ModelScope cccnju/Gen-Video GenVideo-Val.zip); reals = MSR-VTT."
206
+ },
207
+ {
208
+ "name": "FakeParts (partial edits; Tables 1, 15-16)",
209
+ "datasets": [
210
+ "FakeParts"
211
+ ],
212
+ "metric": "fake recall @ FPR",
213
+ "paper_number": {
214
+ "fake_recall_fpr0.1": 74.45
215
+ },
216
+ "our_status": "missing",
217
+ "dataset_source": "not in SynthScope yet"
218
+ },
219
+ {
220
+ "name": "ViF-Bench (Tables 3, 19)",
221
+ "datasets": [
222
+ "ViF-Bench"
223
+ ],
224
+ "metric": "fake recall @ FPR",
225
+ "paper_number": {
226
+ "fake_recall_fpr0.1": 84.52
227
+ },
228
+ "our_status": "missing",
229
+ "dataset_source": "not in SynthScope yet"
230
+ }
231
+ ],
232
+ "summary": "Training-free; exact parity with the official model. The official pipeline on our GenVideo copy reproduces the paper's MSR-VTT-calibrated table; SynthScope's direct decoding (no JPEG round-trip) is much stronger at low FPR."
233
+ }
repro/official_evals/split_official_eval.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the OFFICIAL SPLIT pipeline (Hyun & Kim, ECCV 2026; github.com/mldljyh/SPLIT) on a SynthScope video dataset.
2
+
3
+ SynthScope's SPLIT gives the official ``SPLIT_model`` scores on the same frames (relative difference ~1e-6), but the
4
+ official numbers also depend on the official frame extraction, which SPLIT shares with D3: ``utils/video2frame.py``
5
+ writes JPEG frames with ffmpeg (8 fps over 3 s, random start for longer clips), ``data/datasets.py`` reads them back
6
+ with ``cv2.imread`` (BGR), crops 10% of the longer side, resizes to 224 and normalizes with ImageNet statistics. The
7
+ frames are extracted exactly as ``d3_official_eval.py`` does (same seeded start, same frame folders, so the two share a
8
+ cache), and the metrics come from the official ``eval.py``'s ``evaluate_split`` unchanged: fake recall at FPR 0.1 / 1 /
9
+ 5 % with the threshold set on the real scores alone (``np.quantile(real, 1 - fpr, method="higher")``).
10
+
11
+ Like the paper's GenVideo table, every real clip (GenVideo-Val's MSR-VTT reals) calibrates the threshold and every fake
12
+ clip is scored, per generator and pooled over all generators ("Overall"); there is no per-class limit unless ``--limit``
13
+ is given.
14
+
15
+ python repro/official_evals/split_official_eval.py --dataset-root /data/GenVideo/test --name genvideo \\
16
+ --official-repo /path/to/SPLIT --albumentations-path /path/to/site-packages --ffmpeg /path/to/ffmpeg \\
17
+ --frames /scratch/d3_frames/genvideo
18
+
19
+ Output: repro/bench_results/<tag>.json and a line in repro/MASTER_RESULTS.jsonl.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import datetime
25
+ import hashlib
26
+ import importlib.util
27
+ import json
28
+ import random
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ import torch
34
+
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ from d3_official_eval import extract, videos # noqa: E402 (the shared official frame extraction)
37
+
38
+ REPRO = Path(__file__).resolve().parents[1]
39
+ RESULTS_LOG = REPRO / "MASTER_RESULTS.jsonl"
40
+ FPRS = (0.001, 0.01, 0.05)
41
+
42
+
43
+ def load_official_eval(repo: str):
44
+ """The official ``eval.py`` as a module (its ``evaluate_split`` computes the paper's metrics)."""
45
+ spec = importlib.util.spec_from_file_location("split_official_eval_py", Path(repo) / "eval.py")
46
+ module = importlib.util.module_from_spec(spec)
47
+ spec.loader.exec_module(module)
48
+ return module
49
+
50
+
51
+ @torch.no_grad()
52
+ def scores(model, read_video, trans, paths, frames_root: Path, ffmpeg: str, device) -> list[float]:
53
+ out = []
54
+ for p in paths:
55
+ folder = extract(p, frames_root / hashlib.md5(str(p.resolve()).encode()).hexdigest()[:16], ffmpeg)
56
+ try:
57
+ x = read_video(str(folder), trans=trans).unsqueeze(0).to(device) # official reader: needs >= 8 frames
58
+ except Exception as e: # noqa: BLE001
59
+ print(f" skip {p.name}: {e}")
60
+ continue
61
+ out.append(float(model.forward_score(x).item()))
62
+ return out
63
+
64
+
65
+ def summarize(official, real: list[float], fake: list[float]) -> dict:
66
+ m = official.evaluate_split(np.array(real), np.array(fake))
67
+ return {"fake_recall_at_fpr": {f"{a * 100:g}": round(m["fake_recall_at_fpr"][a] * 100, 2) for a in FPRS},
68
+ "ap": round(m["ap_fake"] * 100, 2), "ap_real": round(m["ap_real"] * 100, 2),
69
+ "auroc": round(m["roc_auc"] * 100, 2), "n_real": len(real), "n_fake": len(fake)}
70
+
71
+
72
+ def main(argv=None) -> int:
73
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
74
+ ap.add_argument("--dataset-root", required=True, help="<generator>/{0_real,1_fake} tree")
75
+ ap.add_argument("--name", required=True, help="dataset name for the tag, e.g. genvideo")
76
+ ap.add_argument("--official-repo", required=True, help="clone of github.com/mldljyh/SPLIT")
77
+ ap.add_argument("--albumentations-path", default=None, help="extra sys.path entry providing albumentations")
78
+ ap.add_argument("--ffmpeg", default="ffmpeg")
79
+ ap.add_argument("--frames", required=True, help="scratch folder for the extracted JPEG frames")
80
+ ap.add_argument("--limit", type=int, default=0, help="clips per class per generator (0 = all)")
81
+ ap.add_argument("--encoder", default="XCLIP-16")
82
+ ap.add_argument("--select", default=None, help="comma list: only these generators")
83
+ ap.add_argument("--tag", default=None)
84
+ args = ap.parse_args(argv)
85
+
86
+ sys.path[:0] = [args.official_repo] + ([args.albumentations_path] if args.albumentations_path else [])
87
+ from data.datasets import read_video, set_preprocessing # official SPLIT
88
+ from models.SPLIT_model import SPLIT_model # official SPLIT
89
+ official = load_official_eval(args.official_repo)
90
+
91
+ random.seed(42); np.random.seed(42); torch.manual_seed(42)
92
+ device = "cuda" if torch.cuda.is_available() else "cpu"
93
+ model = SPLIT_model(encoder_type=args.encoder, loss_type="l2").to(device).eval()
94
+ trans = set_preprocessing(None, None)
95
+ root, frames_root = Path(args.dataset_root), Path(args.frames)
96
+ tag = args.tag or f"split_official_{args.name}"
97
+ cap = (lambda xs: xs[:args.limit]) if args.limit else (lambda xs: xs)
98
+ gens = sorted(p for p in root.iterdir() if p.is_dir()
99
+ and (not args.select or p.name in args.select.split(",")))
100
+ print(f"=== split_official_eval {tag} | {len(gens)} generators | {args.encoder} | device={device} ===", flush=True)
101
+
102
+ cache: dict = {}
103
+ per_gen, all_real, all_fake = {}, None, []
104
+ for g in gens:
105
+ reals, fakes = cap(videos(g / "0_real")), cap(videos(g / "1_fake"))
106
+ if not reals or not fakes:
107
+ continue
108
+ key = tuple(p.name for p in reals) # GenVideo copies the same MSR-VTT reals into every generator folder
109
+ if key not in cache:
110
+ cache[key] = scores(model, read_video, trans, reals, frames_root, args.ffmpeg, device)
111
+ rs = cache[key]
112
+ fs = scores(model, read_video, trans, fakes, frames_root, args.ffmpeg, device)
113
+ per_gen[g.name] = summarize(official, rs, fs)
114
+ all_real = rs if all_real is None or len(rs) > len(all_real) else all_real
115
+ all_fake += fs
116
+ print(f" {g.name}: {len(rs)} real + {len(fs)} fake fake recall @ FPR 0.1/1/5% = "
117
+ f"{list(per_gen[g.name]['fake_recall_at_fpr'].values())} AP={per_gen[g.name]['ap']}", flush=True)
118
+
119
+ overall = summarize(official, all_real, all_fake)
120
+ mean = lambda f: round(sum(v["fake_recall_at_fpr"][f] for v in per_gen.values()) / len(per_gen), 2) # noqa: E731
121
+ means = {"overall_fake_recall_at_fpr": overall["fake_recall_at_fpr"],
122
+ "mean_fake_recall_at_fpr": {f: mean(f) for f in overall["fake_recall_at_fpr"]},
123
+ "map": round(sum(v["ap"] for v in per_gen.values()) / len(per_gen), 2)}
124
+ result = {"tag": tag, "model": "split", "ckpt": f"official SPLIT code ({args.encoder}), training-free",
125
+ "benchmark": "video:official-split", "data_root": str(root), "n_generators": len(per_gen),
126
+ "means": means, "overall": overall, "per_generator": per_gen,
127
+ "date": datetime.datetime.now().isoformat(timespec="seconds")}
128
+ out = REPRO / "bench_results" / f"{tag}.json"
129
+ out.write_text(json.dumps(result, indent=2) + "\n")
130
+ with RESULTS_LOG.open("a") as f:
131
+ f.write(json.dumps({k: result[k] for k in ("date", "tag", "model", "ckpt", "benchmark", "data_root",
132
+ "n_generators", "means")}) + "\n")
133
+ print(f"\n=== RESULT {tag}: Overall fake recall @ FPR 0.1/1/5% = {list(overall['fake_recall_at_fpr'].values())} "
134
+ f"over {len(per_gen)} generators -> {out} ===", flush=True)
135
+ return 0
136
+
137
+
138
+ if __name__ == "__main__":
139
+ raise SystemExit(main())
tests/test_split_smoke.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test for the SPLIT integration (no weight downloads: a tiny randomly initialized X-CLIP vision tower stands in
2
+ for X-CLIP B/16, saved to a temporary directory and loaded through the model's normal ``from_pretrained`` path).
3
+
4
+ Checks, on CPU:
5
+ * temporal roughness: a straight constant-speed token trajectory gives TTR = 0, a back-and-forth (jittery) one gives
6
+ TTR > 0, matching the paper's definition log2(L1 / L2),
7
+ * motion incoherence: a motion field that is the same for every patch gives 0, a varying one gives > 0,
8
+ * the registry entry is training-free and uses D3's video preprocessing (16 frames, 224, BGR, ImageNet normalization),
9
+ * ``forward`` -> [B, 2] logits whose margin is ``score - threshold``; ``compute_loss`` returns a zero loss that
10
+ backpropagates (the trainer runs, nothing is learned) and the encoder stays frozen in train mode.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import tempfile
18
+
19
+ import torch
20
+ from omegaconf import OmegaConf
21
+ from transformers import XCLIPVisionConfig, XCLIPVisionModel
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "interface"))
25
+
26
+ from models.video.split import split as split_module # noqa: E402
27
+ from models.video.split.split import SynthScopeSPLITModel # noqa: E402
28
+ from backend.registry import get_spec # noqa: E402
29
+
30
+ TINY = XCLIPVisionConfig(hidden_size=32, intermediate_size=64, num_hidden_layers=2, num_attention_heads=2,
31
+ image_size=64, patch_size=16, mit_hidden_size=32, mit_intermediate_size=64,
32
+ mit_num_hidden_layers=1, mit_num_attention_heads=2, num_frames=4)
33
+
34
+
35
+ def build_model(tmp: str) -> SynthScopeSPLITModel:
36
+ XCLIPVisionModel(TINY).save_pretrained(tmp)
37
+ split_module.ENCODERS["tiny"] = tmp
38
+ cfg = OmegaConf.create({"ModelConfig": {"model_type": "split", "encoder": "tiny", "gamma": 8.0, "threshold": 0.5},
39
+ "TrainerConfig": {}})
40
+ return SynthScopeSPLITModel(cfg)
41
+
42
+
43
+ def test_temporal_roughness() -> None:
44
+ print("[A] temporal roughness ...")
45
+ t, n, d = 8, 4, 16
46
+ direction = torch.randn(n, d)
47
+ smooth = torch.arange(t, dtype=torch.float32)[None, :, None, None] * direction[None, None] # [1, T, N, D]
48
+ ttr = SynthScopeSPLITModel.temporal_roughness(smooth)
49
+ assert ttr.shape == (1,) and abs(float(ttr)) < 1e-4, float(ttr)
50
+ jitter = smooth.clone()
51
+ jitter[:, 1::2] += 5 * torch.randn(n, d) # odd frames jump away and back: long 1-step, short 2-step paths
52
+ assert float(SynthScopeSPLITModel.temporal_roughness(jitter)) > 0.5
53
+
54
+
55
+ def test_motion_incoherence() -> None:
56
+ print("[B] motion incoherence ...")
57
+ t, g, d = 5, 3, 8
58
+ uniform = torch.arange(t, dtype=torch.float32)[None, :, None, None] * torch.randn(d)[None, None, None]
59
+ uniform = uniform.expand(1, t, g * g, d) # every patch moves by the same vector
60
+ assert float(SynthScopeSPLITModel.motion_incoherence(uniform)) < 1e-5
61
+ assert float(SynthScopeSPLITModel.motion_incoherence(torch.randn(1, t, g * g, d))) > 0.1
62
+
63
+
64
+ def test_registry() -> None:
65
+ print("[C] registry entry ...")
66
+ spec = get_spec("split")
67
+ assert spec.modality == "video" and spec.checkpoint is None and spec.weights == "training_free"
68
+ assert spec.num_frames == 16 and spec.frame_size == 224 and spec.bgr and spec.edge_crop == 0.1
69
+
70
+
71
+ def test_forward_and_loss(model: SynthScopeSPLITModel) -> None:
72
+ print("[D] forward / compute_loss ...")
73
+ frames = torch.randn(2, 6, 3, 64, 64)
74
+ model.eval()
75
+ with torch.no_grad():
76
+ logits, score = model(frames), model.score(frames)
77
+ assert logits.shape == (2, 2) and torch.isfinite(logits).all() and (score >= 0).all()
78
+ assert torch.allclose(logits[:, 1] - logits[:, 0], 2 * (score - model.threshold))
79
+ model.train()
80
+ assert not model.encoder.training and not any(p.requires_grad for p in model.encoder.parameters())
81
+ out = model.compute_loss((frames, torch.tensor([0, 1]), torch.tensor([0, 0])))
82
+ out["loss"].backward()
83
+ assert out["loss"].item() == 0.0 and model._noop.grad is not None
84
+
85
+
86
+ if __name__ == "__main__":
87
+ test_temporal_roughness()
88
+ test_motion_incoherence()
89
+ test_registry()
90
+ with tempfile.TemporaryDirectory() as d:
91
+ test_forward_and_loss(build_model(d))
92
+ print("SPLIT smoke test passed")
tests/test_video_gif_decode.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression test: animated GIFs decode to their real frames, not a black clip.
2
+
3
+ decord cannot open GIFs ("broken metadata"), and ``sample_video_frames`` used to return an all-zero clip for them, so
4
+ every video detector saw GIFs (e.g. 282 of GenVideo's WildScrape fakes) as black, motionless video. The PIL fallback
5
+ must return the GIF's own frames at the requested sampling rate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import tempfile
13
+
14
+ import numpy as np
15
+ from PIL import Image
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from dataset import sample_video_frames # noqa: E402
20
+
21
+
22
+ def make_gif(path: str, n: int = 24, duration_ms: int = 100) -> None:
23
+ """``n`` frames at 10 fps (GIF stores durations in 10 ms steps); frame k is a solid gray of level 10 * k."""
24
+ frames = [Image.fromarray(np.full((64, 96, 3), 10 * k, dtype=np.uint8)) for k in range(n)]
25
+ frames[0].save(path, save_all=True, append_images=frames[1:], duration=duration_ms, loop=0)
26
+
27
+
28
+ def test_gif_frames() -> None:
29
+ with tempfile.TemporaryDirectory() as d:
30
+ path = os.path.join(d, "clip.gif")
31
+ make_gif(path)
32
+ clip = sample_video_frames(path, num_frames=16, fps=10.0, size=32, fps_nearest=True)
33
+ assert clip.shape == (16, 3, 32, 32), clip.shape
34
+ assert float(clip.max()) > 0, "GIF decoded as a black clip"
35
+ levels = (clip[:, 0, 0, 0] * 255).round().tolist()
36
+ assert levels == [10.0 * k for k in range(16)], levels # source fps = target fps: frames 0..15 in order
37
+
38
+
39
+ if __name__ == "__main__":
40
+ test_gif_frames()
41
+ print("GIF decode test passed")
trainer.py CHANGED
@@ -18,6 +18,7 @@ from models.image.effort.effort import SynthScopeEffortModel
18
  from models.image.bfree.bfree import SynthScopeBFreeModel
19
  from models.image.d3qe.d3qe import SynthScopeD3QEModel
20
  from models.video.d3.d3 import SynthScopeD3Model
 
21
  from models.video.restrav.restrav import SynthScopeReStraVModel
22
  from models.video.stall.stall import SynthScopeSTALLModel
23
  from metrics import AIGIMetrics
@@ -105,6 +106,8 @@ class SynthScopeModelTrainer:
105
  return SynthScopeD3QEModel(config)
106
  if model_type == "d3":
107
  return SynthScopeD3Model(config)
 
 
108
  if model_type == "restrav":
109
  return SynthScopeReStraVModel(config)
110
  if model_type == "stall":
 
18
  from models.image.bfree.bfree import SynthScopeBFreeModel
19
  from models.image.d3qe.d3qe import SynthScopeD3QEModel
20
  from models.video.d3.d3 import SynthScopeD3Model
21
+ from models.video.split.split import SynthScopeSPLITModel
22
  from models.video.restrav.restrav import SynthScopeReStraVModel
23
  from models.video.stall.stall import SynthScopeSTALLModel
24
  from metrics import AIGIMetrics
 
106
  return SynthScopeD3QEModel(config)
107
  if model_type == "d3":
108
  return SynthScopeD3Model(config)
109
+ if model_type == "split":
110
+ return SynthScopeSPLITModel(config)
111
  if model_type == "restrav":
112
  return SynthScopeReStraVModel(config)
113
  if model_type == "stall":