"""Model registry: the single source of truth for every detector the interface can run. For each detector we record: * how to compose its SynthScope config (Hydra overrides, exactly like ``main.py``), * its evaluation preprocessing (matched to the training/eval recipe or the verified official-weights eval script under repro/), * which checkpoint file backs it and how that checkpoint loads (strict SynthScope vs. an official-weights key remap), and * how to turn a forward pass into a fake-probability. Label convention (dataset.py:_get_label): index 0 = real, index 1 = fake. """ import functools from dataclasses import dataclass, field from typing import Callable, Optional import torchvision.transforms as T # --------------------------------------------------------------------------- # Normalization constants (mirror transformations.py). # --------------------------------------------------------------------------- CLIP_MEAN = (0.48145466, 0.4578275, 0.40821073) CLIP_STD = (0.26862954, 0.26130258, 0.27577711) IMAGENET_MEAN = (0.485, 0.456, 0.406) # == normalize_dinov2 IMAGENET_STD = (0.229, 0.224, 0.225) DINOV3_MEAN = (0.430, 0.411, 0.296) DINOV3_STD = (0.213, 0.156, 0.143) def _norm(mean, std): return T.Normalize(mean=list(mean), std=list(std)) def tf_resize_crop(resize: int, crop: int, mean=None, std=None) -> T.Compose: """Resize shorter side -> ``resize``, center-crop ``crop``, ToTensor, optional Normalize.""" steps = [T.Resize(resize), T.CenterCrop(crop), T.ToTensor()] if mean is not None: steps.append(_norm(mean, std)) return T.Compose(steps) def tf_crop(crop: int, mean=None, std=None) -> T.Compose: """Center-crop ``crop`` with NO resize, ToTensor, optional Normalize. Official SAFE preprocessing (scripts/eval.sh: --input_size 256 --transform_mode 'crop', no normalization) — a resize would alter the high-frequency signal SAFE keys on.""" steps = [T.CenterCrop(crop), T.ToTensor()] if mean is not None: steps.append(_norm(mean, std)) return T.Compose(steps) # AIDE's official native-resolution 5-view preprocessing (DCT patch selection on the native-size # image). Importing this is light (no open_clip at module level); the heavy net is built lazily. from models.image.aide.official_arch import AIDEFiveView # noqa: E402 aide_five_view = AIDEFiveView() # CuRe's official eval preprocessing (PNG->JPEG96, 390 crop, 336 resize, JPEG70, mean/std 0.5), in cv2/PIL. from models.image.cure.preprocess import CuReTransform # noqa: E402 def _cap_long_side(im, max_side: int): """Downscale a PIL image (keeping aspect, min side 224) only if its long side exceeds ``max_side`` — otherwise return it untouched at native resolution.""" w, h = im.size if max(w, h) > max_side: s = max_side / max(w, h) im = im.resize((max(int(w * s), 224), max(int(h * s), 224))) return im def tf_native_cap(max_side: int = 1024, mean=None, std=None) -> T.Compose: """Native/arbitrary-resolution preprocessing: keep the image at its NATIVE size, only downscaling (keeping aspect, min side 224) when the long side exceeds ``max_side`` to bound memory. ToTensor (raw [0,1]), optional Normalize. This is SPAI's official any-resolution path — a fixed Resize/CenterCrop would destroy the native-res spectral statistics it learns. NB: produces variable-size tensors, so a ``native_res=True`` spec must be scored ONE image at a time (bs=1); the web ``predict_image`` already does this, and bench_eval forces bs=1.""" steps = [T.Lambda(functools.partial(_cap_long_side, max_side=max_side)), T.ToTensor()] if mean is not None: steps.append(_norm(mean, std)) return T.Compose(steps) def tf_square_resize(resize: int, crop: Optional[int], mean=None, std=None) -> T.Compose: """Square Resize((resize,resize)), optional center-crop, ToTensor, optional Normalize. Matches the official FatFormer/NPR/AIDE eval preprocessing (a square resize, not a shorter-side resize).""" steps = [T.Resize((resize, resize))] if crop is not None: steps.append(T.CenterCrop(crop)) steps.append(T.ToTensor()) if mean is not None: steps.append(_norm(mean, std)) return T.Compose(steps) # --------------------------------------------------------------------------- # Official-weights -> SynthScope state_dict remaps (copied verbatim from the verified repro # eval scripts in /hddstore/ssalehi/SynthScope/repro/{fatformer,aide,npr}_eval.py). # --------------------------------------------------------------------------- def remap_fatformer(sd: dict) -> dict: drop_pref = ("image_encoder.",) drop_exact = { "language_guided_alignment.token_prefix", "language_guided_alignment.token_suffix", } return { k: v for k, v in sd.items() if not k.startswith(drop_pref) and k not in drop_exact } def remap_aide(sd: dict) -> dict: """Official AIDE checkpoint -> SynthScopeAIDEModel. The SynthScope class now wraps the GENUINE arch as ``self.net`` (key-for-key identical to the official ``AIDE_Model``), so we just strip any DataParallel ``module.`` prefix and add the ``net.`` prefix; every official key loads strict.""" return {("net." + (k[len("module."):] if k.startswith("module.") else k)): v for k, v in sd.items()} def remap_npr(sd: dict) -> dict: out = {} for k, v in sd.items(): if k.startswith("conv1."): nk = "backbone.0." + k[len("conv1."):] elif k.startswith("bn1."): nk = "backbone.1." + k[len("bn1."):] elif k.startswith("layer1."): nk = "backbone.4." + k[len("layer1."):] elif k.startswith("layer2."): nk = "backbone.5." + k[len("layer2."):] elif k.startswith("fc1."): nk = "head." + k[len("fc1."):] else: nk = k out[nk] = v return out def remap_safe(sd: dict) -> dict: """Official SAFE (Ouxiang-Li/SAFE) → SynthScopeSAFEModel. The custom ResNet (conv1/bn1/layer1/layer2) matches our class verbatim; only the classifier head differs: official names it ``fc1``, ours ``head``. (The engine already unwraps the ``model`` wrapper before this.)""" out = {} for k, v in sd.items(): out[("head." + k[len("fc1."):]) if k.startswith("fc1.") else k] = v return out @dataclass class ModelSpec: key: str # menu id / dispatch handle name: str # human-friendly display name paper: str # short citation modality: str # "image" (also runs frame-by-frame on video) or "video" overrides: list[str] # Hydra overrides for config_utils.compose_config predict_kind: str # ce | bce | bce_npr | vib | video checkpoint: Optional[str] # filename inside CHECKPOINTS_DIR (None = no weights file) ckpt_kind: str # synthscope | official_fatformer | official_aide | official_npr | none transform: Optional[T.Compose] = None # image preprocessing (None for pure-video models) # Video-clip sampling (used when modality == "video"). num_frames: int = 16 fps: Optional[float] = None frame_size: int = 224 video_norm: Optional[tuple] = None # (mean, std) applied to the decoded [0,1] clip window_center: bool = False # center the sampling window (ReStraV) vs start (D3/STALL) resize_interp: str = "bilinear" # frame resize interpolation ("bicubic" for ReStraV) edge_crop: float = 0.0 # center edge-crop fraction per frame before resize (D3: 0.1) edge_crop_long_side: bool = False # crop only the longer side (D3's crop_center_by_percentage) short_clip_frames: int = 0 # frames to use when a video is too short for num_frames (D3: 8) fps_nearest: bool = False # frame nearest to each k/fps, as ffmpeg's fps filter (D3) bgr: bool = False # feed BGR channels (D3's official reader never converts cv2's BGR) notes: str = "" # Which weights back the DEFAULT inference checkpoint, surfaced in the web UI: # "official" = authors' released weights (loaded into the SynthScope class) # "synthscope_trained" = our from-scratch SynthScope checkpoint # "training_free" = no weights (frozen-feature / training-free detector) weights: str = "synthscope_trained" # Score at native/arbitrary resolution one image at a time (bs=1) instead of stacking a # fixed-size batch — required by SPAI's any-res spectral path (variable patch count per image). native_res: bool = False # Interpretability map the detector can expose in the web UI (engine._interpret_map dispatches on it): # "" = none (black-box) · "safe_hf" = DWT HH-band artifact map · "npr_residual" = pixel-residual map. interpret: str = "" remap: Optional[Callable[[dict], dict]] = field(default=None, repr=False) # --------------------------------------------------------------------------- # The registry. # --------------------------------------------------------------------------- MODELS: dict[str, ModelSpec] = {} def _add(spec: ModelSpec): MODELS[spec.key] = spec # --- Image detectors --------------------------------------------------------- _add(ModelSpec( key="clip", name="UnivFD / CLIP", paper="Ojha et al., CVPR'23", modality="image", predict_kind="ce", checkpoint="clip.pth", ckpt_kind="synthscope", overrides=["ModelConfig.model_type=clip", "ModelConfig.backbone_name=CLIP:ViT-L/14", "ModelConfig.use_adapter=false", "ModelConfig.bottleneck_dim=64", "ModelConfig.freeze_backbone=true"], transform=tf_resize_crop(256, 224, CLIP_MEAN, CLIP_STD), notes="Frozen CLIP ViT-L/14 features + linear real/fake head (trained on ProGAN).", )) _add(ModelSpec( key="cnn", name="CNNDetection (ResNet-50)", paper="Wang et al., CVPR'20", modality="image", predict_kind="ce", checkpoint="cnn.pth", ckpt_kind="synthscope", overrides=["ModelConfig.model_type=cnn", "ModelConfig.freeze_backbone=false"], transform=tf_resize_crop(256, 224, IMAGENET_MEAN, IMAGENET_STD), notes="ImageNet ResNet-50 fine-tuned as a real/fake classifier (trained on ProGAN).", )) _add(ModelSpec( key="dinov2", name="DINOv2 probe", paper="Oquab et al., 2023", modality="image", predict_kind="ce", checkpoint="dinov2.pth", ckpt_kind="synthscope", overrides=["ModelConfig.model_type=dinov2", "ModelConfig.backbone_name=dinov2_vitl14", "ModelConfig.use_adapter=false", "ModelConfig.bottleneck_dim=64", "ModelConfig.freeze_backbone=true"], transform=tf_resize_crop(256, 224, IMAGENET_MEAN, IMAGENET_STD), notes="Frozen DINOv2 ViT-L/14 + linear head (trained on ProGAN).", )) _add(ModelSpec( key="dinov3", name="DINOv3 probe", paper="Meta AI, 2025", modality="image", predict_kind="ce", checkpoint="dinov3.pth", ckpt_kind="synthscope", overrides=["ModelConfig.model_type=dinov3", "ModelConfig.backbone_name=dinov3_vitl16", "ModelConfig.use_adapter=false", "ModelConfig.bottleneck_dim=64", "ModelConfig.freeze_backbone=true"], transform=tf_resize_crop(256, 256, DINOV3_MEAN, DINOV3_STD), notes="Frozen DINOv3 ViT-L/16 + linear head. Requires DINOv3 backbone weights.", )) _add(ModelSpec( key="adapted_dino", name="Adapter-tuned DINOv2", paper="SynthScope", modality="image", predict_kind="ce", checkpoint="adapted_dino.pth", ckpt_kind="synthscope", overrides=["ModelConfig.model_type=adapted_dino", "ModelConfig.backbone_name=dinov2_vitl14", "ModelConfig.use_adapter=true", "ModelConfig.bottleneck_dim=64", "ModelConfig.freeze_backbone=true"], transform=tf_resize_crop(256, 224, IMAGENET_MEAN, IMAGENET_STD), notes="DINOv2 with per-block bottleneck adapters; backbone frozen.", )) _add(ModelSpec( key="safe", name="SAFE", paper="Li et al., KDD'25", modality="image", predict_kind="ce", checkpoint="safe.pth", ckpt_kind="synthscope", remap=None, weights="synthscope_trained", overrides=["model@ModelConfig=image/safe"], transform=tf_crop(256), interpret="safe_hf", # DWT HH-band high-frequency artifact map notes="High-frequency (DWT bior1.3 HH-band) artifact detector. Serves the SynthScope-TRAINED checkpoint " "(full 40-epoch SAFE crop recipe, safe_full_v2/model_38), which reproduces the paper " "(ForenSynths-8 97.79 / UFD-8 98.44 / GenImage-8 99.51 / DiTFake 99.96 mAP) and beats the official " "checkpoint on GenImage and full-13 ForenSynths. Center-crop-256, no resize/no norm (a resize " "corrupts the high-freq signal -> all-fake).", )) _add(ModelSpec( key="fat_former", name="FatFormer", paper="Liu et al., CVPR'24", modality="image", predict_kind="ce", checkpoint="fat_former.pth", ckpt_kind="official_fatformer", remap=remap_fatformer, weights="official", # The released checkpoint has FAA at blocks [7,15,23] => num_vit_adapter=3 (not the yaml's 8). overrides=["model@ModelConfig=image/fat_former", "ModelConfig.num_vit_adapter=3"], transform=tf_square_resize(256, 224, IMAGENET_MEAN, IMAGENET_STD), notes="Frozen CLIP + forgery-aware adapters + learnable prompts. Official ProGAN weights.", )) _add(ModelSpec( key="aide", name="AIDE", paper="Yan et al., ICLR'25", modality="image", predict_kind="ce", checkpoint="aide.pth", ckpt_kind="official_aide", remap=remap_aide, weights="official", # Genuine 5-view AIDE arch; the official checkpoint supplies the ConvNeXt trunk (build it random # via convnext_pretrained=null to skip the 4.8GB download, then load the ckpt strict). overrides=["model@ModelConfig=image/aide", "ModelConfig.convnext_pretrained=null"], transform=aide_five_view, # NATIVE-res DCT 5-view stack [5,3,256,256] (official preprocessing) interpret="aide_dct", # native-res per-window DCT energy = the patch-selection saliency notes="ConvNeXt-XXL semantic + DCT/SRM low-level cues. The DCT patch selector runs at NATIVE " "resolution in preprocessing (resizing to 256 before the DCT costs ~12 mAP on the same " "weights); only the 4 selected 32x32 patches + the full image are resized to 256.", )) _add(ModelSpec( key="spai", name="SPAI", paper="Karageorgiou et al., CVPR'25", modality="image", predict_kind="bce", checkpoint="spai.pth", ckpt_kind="synthscope", weights="official", # shipped spai.pth = official SPAI weights remapped into the SynthScope class overrides=["model@ModelConfig=image/spai"], transform=tf_native_cap(1024), # NATIVE resolution (raw [0,1], cap long side 1024) — the faithful any-res path native_res=True, # score one image at a time (variable patch count) interpret="spai_patch", # per-patch spectral fake-evidence over the native 224-grid notes="Spectral learning (MFM ViT-B/16) + Spectral Context Attention at NATIVE/arbitrary resolution: each " "image is patchified into a variable 224-grid (five_crop fallback for tiny images). Serves the " "official SPAI weights remapped into the SynthScope class; reproduces the paper (GenImage-8 90.74 / " "Synthbuster 96.07 mAP). A fixed 448 resize/crop destroys the native-res spectral statistics.", )) _add(ModelSpec( key="vib_net", name="VIB-Net", paper="Zhang et al., CVPR'25", modality="image", predict_kind="vib", checkpoint="vib_net.pth", ckpt_kind="synthscope", overrides=["model@ModelConfig=image/vib_net"], transform=tf_resize_crop(256, 224, CLIP_MEAN, CLIP_STD), notes="Variational information bottleneck on frozen CLIP features (trained on GenImage).", )) _add(ModelSpec( key="co_spy", name="CO-SPY", paper="Cheng et al., CVPR'25", modality="image", predict_kind="bce", checkpoint="co_spy.pth", ckpt_kind="synthscope", overrides=["model@ModelConfig=image/co_spy"], transform=tf_resize_crop(384, 384, None, None), # raw [0,1]; per-branch norm inside the model notes="SigLIP semantic + SD-VAE-residual artifact streams, feature fusion (trained on ProGAN).", )) _add(ModelSpec( key="pgc", name="PGC", paper="Zhou et al., 2026", modality="image", predict_kind="bce", checkpoint="pgc.pth", ckpt_kind="synthscope", weights="official", # official PGC ProGAN weights load remap-free (FS 96.25 / hard-gens 94.35 vs our from-scratch 75.87); deploy PGC_train_progan_ckpt.pth as pgc.pth overrides=["model@ModelConfig=image/pgc"], transform=tf_crop(224, IMAGENET_MEAN, IMAGENET_STD), # official PGC eval = PadCenterCrop(224), NO resize interpret="pgc_peak", # peak-guided residual fake-evidence map (per-location score) notes="LoRA-DINOv2 RGB + YCbCr-residual CNN, peak-guided calibration. Uses OFFICIAL released weights. " "Eval is center-crop-224 NO resize (official PadCenterCrop): the YCbCr residual is resolution-" "sensitive, and a Resize wipes it on super-res/low-light generators (san/seeingdark). " "See repro/investigate/pgc_gap_investigation.md.", )) _add(ModelSpec( key="cure", name="CuRe", paper="arXiv 2609.14316 (manic-cui/CuRe)", modality="image", predict_kind="ce", checkpoint="cure.pth", ckpt_kind="synthscope", weights="official", # cure.pth = the official 25MB cure_adapter.pt unchanged; frozen PE-Core base fetched from HF overrides=["model@ModelConfig=image/cure"], transform=CuReTransform(), # official: PNG->JPEG96, SmallestMaxSize(390)+CenterCrop+Resize(336), JPEG70, mean=std=0.5 notes="Frozen Perception-Encoder Core L/14 @336 + Q/V LoRA + source-response subspace + linear head, " "trained by regressing real-generated mixing ratios on GenImage SD-v1.4. Uses the OFFICIAL released " "adapter. Eval preprocessing is part of the method (PNG sources get a JPEG q96 round-trip and every " "image a JPEG q70 pass), so use the registered transform — see models/image/cure/README.md.", )) _add(ModelSpec( key="npr", name="NPR", paper="Tan et al., CVPR'24", modality="image", predict_kind="bce_npr", checkpoint="npr.pth", ckpt_kind="official_npr", remap=remap_npr, weights="official", overrides=["model@ModelConfig=image/npr"], transform=tf_square_resize(256, None, IMAGENET_MEAN, IMAGENET_STD), interpret="npr_residual", # neighboring-pixel residual map (up-sampling artifacts) notes="Neighboring-pixel-residual ResNet. Official ProGAN weights.", )) # --- Video detectors --------------------------------------------------------- _add(ModelSpec( key="d3", name="D3 (video)", paper="ICCV'25", modality="video", predict_kind="video", checkpoint=None, ckpt_kind="none", weights="training_free", overrides=["model@ModelConfig=video/d3", "ModelConfig.backbone_name=xclip", "ModelConfig.first_order=l2"], num_frames=16, fps=8.0, frame_size=224, video_norm=(IMAGENET_MEAN, IMAGENET_STD), edge_crop=0.1, edge_crop_long_side=True, short_clip_frames=8, fps_nearest=True, bgr=True, notes="Training-free: frozen X-CLIP-B/16 per-frame features; 2nd-order trajectory std is the score. " "Official preprocessing (D3 utils/video2frame.py + data/datasets.py): 16 frames at 8 fps (8 for clips under " "2 s), BGR channels as cv2 reads them, 10% crop of the longer side, resize 224, ImageNet normalization.", )) _add(ModelSpec( key="restrav", name="ReStraV (video)", paper="2025", modality="video", predict_kind="video", checkpoint="restrav.pth", ckpt_kind="synthscope", overrides=["model@ModelConfig=video/restrav", "ModelConfig.backbone_name=dinov2_vits14"], num_frames=24, fps=None, frame_size=224, video_norm=((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)), window_center=True, resize_interp="bicubic", notes="Frozen DINOv2 ViT-S/14 trajectory-geometry + standardized MLP. Raw [0,1], center window + BICUBIC (official ReStraV).", )) # DINOv3 ViT-L/16 gated weights (downloaded locally). STALL is training-free but needs them. DINOV3_WEIGHTS = "/hddstore/ssalehi/SynthScope/dinov3_checkpoints/dinov3_vitl16_pretrain_lvd1689m-8aa4cbdd.pth" _add(ModelSpec( key="stall", name="STALL (video)", paper="Ben Hayun et al., CVPR'26", modality="video", predict_kind="video", checkpoint=None, ckpt_kind="none", weights="training_free", overrides=["model@ModelConfig=video/stall", f"ModelConfig.dino_weights={DINOV3_WEIGHTS}"], num_frames=16, fps=None, frame_size=224, video_norm=(IMAGENET_MEAN, IMAGENET_STD), notes="Training-free spatial+temporal likelihood vs real-video stats (DINOv3 ViT-L/16, VATEX calibration).", )) # --------------------------------------------------------------------------- # What each detector does to the input before scoring, shown in the UI as a short label + explanation. These mirror # the transforms above, which in turn mirror each paper's evaluation (changing them would break reproduction). # --------------------------------------------------------------------------- _CENTER = ("Resize + center-crop", "The shorter side is scaled to {r} px and the central {c}×{c} square is used. " "The aspect ratio is kept; anything outside the square is not seen by the detector.") INPUT_NOTES: dict[str, tuple[str, str]] = { "clip": (_CENTER[0] + " 224", _CENTER[1].format(r=256, c=224)), "cnn": (_CENTER[0] + " 224", _CENTER[1].format(r=256, c=224)), "dinov2": (_CENTER[0] + " 224", _CENTER[1].format(r=256, c=224)), "adapted_dino": (_CENTER[0] + " 224", _CENTER[1].format(r=256, c=224)), "vib_net": (_CENTER[0] + " 224", _CENTER[1].format(r=256, c=224)), "dinov3": (_CENTER[0] + " 256", _CENTER[1].format(r=256, c=256)), "co_spy": (_CENTER[0] + " 384", _CENTER[1].format(r=384, c=384)), "safe": ("Center-crop 256, no resize", "The central 256×256 pixels are used exactly as they are - no scaling, so fine " "pixel patterns are preserved. The rest of the image is not seen."), "pgc": ("Center-crop 224, no resize", "The central 224×224 pixels are used exactly as they are - no scaling, so fine " "pixel patterns are preserved. The rest of the image is not seen."), "fat_former": ("Squashed to a square", "The whole image is stretched to 256×256 (the aspect ratio changes), then the " "central 224×224 is used."), "npr": ("Squashed to 256×256", "The whole image is stretched to a 256×256 square (the aspect ratio changes)."), "spai": ("Original resolution", "Scored at its original size and aspect ratio; it is only downscaled if the long side " "exceeds 1024 px."), "aide": ("Original resolution, 5 views", "Builds five 256×256 views, shown side by side: four patches cut from the " "original pixels (picked by their frequency content) plus one resized view of the whole image."), "cure": ("Paper-specific pipeline", "As in the paper: PNG images are re-saved as JPEG, the shorter side is scaled to " "390 and center-cropped, resized to 336, then JPEG-compressed again (quality 70)."), } def input_note(spec: ModelSpec) -> dict: """{label, explanation} describing the preprocessing of ``spec`` (frames for video models).""" if spec.modality == "video": crop = f", after trimming {int(spec.edge_crop * 100)}% off every edge" if spec.edge_crop else "" return {"label": f"{spec.num_frames} sampled frames", "explanation": f"{spec.num_frames} frames are sampled from the clip and resized to " f"{spec.frame_size}×{spec.frame_size}{crop}."} label, expl = INPUT_NOTES.get(spec.key, ("Model-specific", "Preprocessing follows the detector's paper.")) return {"label": label, "explanation": expl + " This matches the paper's evaluation."} # --------------------------------------------------------------------------- # Paper-vs-reproduced numbers, shown per model in the UI. Every "ours" value traces to a logged # eval; official-weights rows are cross-checked against the paper, from-scratch/probe rows are our # reproductions. Honest caveats are kept in `note` (see README repro section + session findings). # --------------------------------------------------------------------------- # Full per-method benchmark table (ALL reported benchmarks: paper vs SynthScope-trained vs official-weights # + status), generated by build_repro_full.py from repro/benchmark_specs/ + repro/bench_results/. # Loaded FIRST so the headline cards below DERIVE from the same data and can never drift out of sync. import json as _json, pathlib as _pathlib try: REPRO_FULL = _json.loads((_pathlib.Path(__file__).resolve().parent / "repro_full.json").read_text()) except Exception: REPRO_FULL = {} # SynthScope-only probe variants (no paper benchmark table of their own) — hand-kept headline cards. _PROBE_REPRO: dict[str, dict] = { "dinov2": {"benchmark": "ForenSynths", "paper": "~97 mAP (CLIP-probe ref.)", "ours": "95.03 mAP / 94.86 AUROC", "match": "close", "note": "frozen DINOv2 ViT-L/14 probe."}, "dinov3": {"benchmark": "ForenSynths-8", "paper": "~97 mAP (CLIP-probe ref.)", "ours": "93.68 mAP / 92.92 AUROC", "match": "close", "note": "frozen DINOv3 ViT-L/16 probe (8 ForenSynths gens, n=400/class)."}, "adapted_dino": {"benchmark": "ForenSynths-8", "paper": "— (SynthScope variant)", "ours": "98.96 mAP / 98.77 AUROC", "match": "ours-only", "note": "DINOv2 + per-block bottleneck adapters (8 gens, n=400/class)."}, } # Each model's headline = its primary paper benchmark, derived from REPRO_FULL (single source of truth). _PRIMARY = { "co_spy": "Co-Spy-Bench", "fat_former": "4-class", "npr": "Table 1", "aide": "GenImage", "clip": "UniversalFakeDetect", "cnn": "ForenSynths", "safe": "GenImage", "spai": "GenImage", "vib_net": "Diffusion-source (SDv1.4-trained), AP", "pgc": "GenImage", "d3": "GenVideo", "restrav": "GenVidBench", "stall": "GenVideo", } _MATCH = {"reproduced": "match", "official": "match", "below": "below", "reference": "partial", "missing": "pending"} def _headline(model: str): rows = REPRO_FULL.get(model, []) if not rows: return None sub = _PRIMARY.get(model, "") row = next((r for r in rows if sub.lower() in r["benchmark"].lower()), rows[0]) return {"benchmark": row["benchmark"], "paper": row["paper"], "ours": row["ours"], "match": _MATCH.get(row["status"], row["status"]), "note": row.get("note", "")} REPRO: dict[str, dict] = dict(_PROBE_REPRO) for _m in REPRO_FULL: _h = _headline(_m) if _h: REPRO[_m] = _h def list_specs() -> list[ModelSpec]: return list(MODELS.values()) def get_spec(key: str) -> ModelSpec: if key not in MODELS: raise KeyError(f"Unknown model '{key}'. Available: {sorted(MODELS)}") return MODELS[key]