SynthScope / benchmark /bench_eval.py
SMSD75's picture
Update SynthScope Space from the GitHub repository
463a482 verified
Raw History Blame Contribute Delete
14.8 kB
"""Unified benchmark eval for SynthScope detectors — reproduce a paper's per-generator table.
Scores a detector over a benchmark's per-generator real/fake folders through the SAME verified
synthscope inference path the web app uses (engine + registry + per-model preprocessing + fake-prob),
computes per-generator + mean acc@0.5 / AP / AUROC, writes a JSON, and APPENDS a verified line to
``repro/MASTER_RESULTS.jsonl`` so every reported number traces to this run + its job log.
python benchmark/bench_eval.py --model clip --benchmark forensynths \
--data /hddstore/ssalehi/SynthScope/CNNDetection/test --tag clip_synthscope_forensynths
# one step: download the dataset if needed (its built-in loader arranges it), pick the profile, evaluate
python benchmark/bench_eval.py --model npr --dataset commgen15 --root /data/CommGen15
Benchmark profiles encode each dataset's correct generator grouping + real-pairing:
forensynths CNNDetection/test per top-level generator; reals+fakes collected recursively
(handles progan/<class>/{0_real,1_fake} nesting)
ufd UniversalFakeDetect fakes per generator; reals paired Ojha-style
(guided->imagenet, ldm/glide/dalle->laion)
pergen freqnet/aigibench/ each <gen>/{0_real,1_fake}; optional shared --reals
cospybench/generic
pool GenImage/single-pool one group from <root>/{real,fake} or {0_real,1_fake}
Use --ckpt to override the checkpoint (official vs SynthScope-trained, for side-by-side runs).
"""
from __future__ import annotations
import argparse
import datetime
import json
from pathlib import Path
import torch
from PIL import Image, ImageFile
import synthscope
from models.image.cure.preprocess import SRC_FORMAT_KEY
from datasets_util import ensure_dataset, find_eval_root
ImageFile.LOAD_TRUNCATED_IMAGES = True
Image.MAX_IMAGE_PIXELS = None # benchmark data is trusted; some real photos (e.g. RealChain) exceed PIL's 179 MP bomb guard
IMAGE_EXTS = {".jpg", ".jpeg", ".jfif", ".png", ".webp", ".bmp", ".tif", ".tiff", ".JPEG"}
REAL_DIRNAMES = ("0_real", "real", "reals")
FAKE_DIRNAMES = ("1_fake", "fake", "fakes", "ai")
SHARED_REAL_DIRS = {"imagenet", "laion", "_real_pool", "real", "0_real", "reals"}
RESULTS_LOG = Path(__file__).resolve().parents[1] / "repro" / "MASTER_RESULTS.jsonl"
# Ojha UniversalFakeDetect real pairing (verified against the official dataset_paths.py).
UFD_REAL = {"guided": "imagenet"} # everything else -> laion
UFD_DEFAULT_REAL = "laion"
def _images(d: Path) -> list[Path]:
if not d or not d.is_dir():
return []
return sorted(p for p in d.rglob("*") if p.suffix.lower() in {e.lower() for e in IMAGE_EXTS})
def _collect(gen_dir: Path, dirnames: tuple[str, ...]) -> list[Path]:
"""All images under any subdir of gen_dir whose name matches one of dirnames (any depth)."""
out: list[Path] = []
for name in dirnames:
for sub in gen_dir.rglob(name):
if sub.is_dir():
out += [p for p in sub.rglob("*") if p.suffix.lower() in {e.lower() for e in IMAGE_EXTS}]
# de-dup while keeping order
seen, uniq = set(), []
for p in out:
if p not in seen:
seen.add(p); uniq.append(p)
return sorted(uniq)
def discover(benchmark: str, root: Path, shared_reals: Path | None, select: set[str] | None):
"""Return [(generator, real_paths, fake_paths)] for the benchmark."""
shared = _images(shared_reals) if shared_reals else []
if benchmark == "pool":
reals = _collect(root, REAL_DIRNAMES) or shared
fakes = _collect(root, FAKE_DIRNAMES)
return [("all", reals, fakes)] if reals and fakes else []
if benchmark == "ufd":
out = []
gens = sorted(p.name for p in root.iterdir() if p.is_dir() and p.name not in SHARED_REAL_DIRS)
for gen in gens:
if select and gen not in select:
continue
real_src = root / UFD_REAL.get(gen, UFD_DEFAULT_REAL)
reals = _collect(real_src, REAL_DIRNAMES)
fakes = _collect(root / gen, FAKE_DIRNAMES)
if reals and fakes:
out.append((gen, reals, fakes))
else:
print(f" skip {gen}: reals={len(reals)} fakes={len(fakes)}")
return out
if benchmark == "forensynths":
out = []
for gen_dir in sorted(p for p in root.iterdir() if p.is_dir()):
if select and gen_dir.name not in select:
continue
reals = _collect(gen_dir, REAL_DIRNAMES) or shared
fakes = _collect(gen_dir, FAKE_DIRNAMES)
if reals and fakes:
out.append((gen_dir.name, reals, fakes))
else:
print(f" skip {gen_dir.name}: reals={len(reals)} fakes={len(fakes)}")
return out
# pergen / generic: each top-level dir (excluding shared-real dirs) is a generator
out = []
for gen_dir in sorted(p for p in root.iterdir() if p.is_dir()):
if gen_dir.name in SHARED_REAL_DIRS and gen_dir.name != root.name:
continue
if select and gen_dir.name not in select:
continue
reals = _collect(gen_dir, REAL_DIRNAMES) or shared
fakes = _collect(gen_dir, FAKE_DIRNAMES)
if reals and fakes:
out.append((gen_dir.name, reals, fakes))
else:
print(f" skip {gen_dir.name}: reals={len(reals)} fakes={len(fakes)}")
return out
def native_res_transform(tf):
"""Drop the up-scaling Resize/CenterCrop from a model's eval transform, keeping
ToTensor + Normalize, so images are scored at NATIVE resolution.
Some methods (e.g. NPR on GANGen-Detection/freqnet) detect up-sampling artifacts and
MUST NOT be fed a bilinearly up-scaled image — resizing a 128x128 GAN crop to 256x256
injects the exact artifact they key on, collapsing the real class. The official NPR
GANGen recipe runs with ``no_resize=True``; this reproduces it. Safe only when every
image in a per-generator batch shares one resolution (true for freqnet's per-gen sets),
since bench_eval stacks a batch into a single tensor."""
import torchvision.transforms as T
if not isinstance(tf, T.Compose):
return tf
kept = [t for t in tf.transforms if not isinstance(t, (T.Resize, T.CenterCrop))]
return T.Compose(kept)
@torch.no_grad()
def _open_rgb(path: Path) -> Image.Image:
"""Open ``path`` as RGB, recording the source file format in ``img.info`` so format-aware eval
transforms (CuRe's PNG->JPEG96 step) see what the official loader sees."""
source = Image.open(path)
source_format = source.format
image = source.convert("RGB")
image.info[SRC_FORMAT_KEY] = source_format
return image
def score(det, model, spec, paths: list[Path], bs: int, transform=None) -> tuple[torch.Tensor, list[int]]:
"""Fake probabilities for the images that could be read, and the indices (into ``paths``) they belong to."""
device = det._engine.device
tf = transform if transform is not None else spec.transform
# Native-resolution specs (SPAI any-res) produce variable-size tensors that cannot be stacked,
# so they MUST be scored one image at a time.
if getattr(spec, "native_res", False):
bs = 1
probs, kept = [], []
for i in range(0, len(paths), bs):
batch = []
for j, p in enumerate(paths[i:i + bs], start=i):
try:
batch.append(tf(_open_rgb(p)))
kept.append(j)
except Exception as e: # noqa: BLE001
print(f" bad image {p}: {e}")
if not batch:
continue
x = torch.stack(batch).to(device)
probs.append(det._engine._fake_prob(model, spec, x).detach().float().cpu())
return (torch.cat(probs) if probs else torch.empty(0)), kept
def metrics(fake_probs: torch.Tensor, labels: torch.Tensor) -> dict | None:
from torchmetrics.functional import auroc, average_precision
if labels.unique().numel() < 2:
return None
ap = float(average_precision(fake_probs, labels, task="binary")) * 100
au = float(auroc(fake_probs, labels, task="binary")) * 100
preds = (fake_probs >= 0.5).int()
real_acc = float((preds[labels == 0] == 0).float().mean()) * 100
fake_acc = float((preds[labels == 1] == 1).float().mean()) * 100
return {"ap": round(ap, 2), "auroc": round(au, 2),
"real_acc": round(real_acc, 2), "fake_acc": round(fake_acc, 2),
"acc": round((real_acc + fake_acc) / 2, 2)}
def main(argv=None) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("-m", "--model", required=True, help="SynthScope detector key (see `synthscope list`)")
ap.add_argument("--benchmark", choices=["forensynths", "ufd", "pergen", "pool"],
help="how the folders are grouped (chosen automatically with --dataset)")
ap.add_argument("--data", help="benchmark root (not needed with --dataset)")
ap.add_argument("--dataset", help="a supported dataset (see `python benchmark/download_dataset.py --list`): downloads it "
"if missing, finds the folder to evaluate and the right --benchmark profile")
ap.add_argument("--root", help="with --dataset: where the dataset lives / should be downloaded (default: root_dir in its yaml)")
ap.add_argument("--no-download", action="store_true", help="with --dataset: fail instead of downloading missing data")
ap.add_argument("--dry-run", action="store_true", help="with --dataset: only show the resolved folder and profile")
ap.add_argument("--reals", default=None, help="shared real dir (pergen/forensynths fallback)")
ap.add_argument("--ckpt", default=None, help="checkpoint override (official vs SynthScope weights)")
ap.add_argument("--select", default=None, help="comma list: only these generators")
ap.add_argument("--limit", type=int, default=None, help="cap images per class per generator")
ap.add_argument("--bs", type=int, default=32)
ap.add_argument("--native-res", action="store_true",
help="score at native resolution (strip up-scaling Resize/CenterCrop); "
"for artifact-based methods like NPR on low-res GAN sets (freqnet)")
ap.add_argument("--device", default=None)
ap.add_argument("--tag", help="run tag (output filename + log); default <model>_<dataset>")
ap.add_argument("--out", default=None)
args = ap.parse_args(argv)
if args.dataset:
root = ensure_dataset(args.dataset, "image", args.root, download=not args.no_download and not args.dry_run)
eval_root, profile = find_eval_root(args.dataset, root)
args.data = args.data or str(eval_root)
args.benchmark = args.benchmark or profile
args.tag = args.tag or f"{args.model}_{args.dataset}"
print(f"[dataset] {args.dataset}: evaluating {args.data} with the '{args.benchmark}' profile", flush=True)
if args.dry_run:
return 0
if not (args.benchmark and args.data):
ap.error("give --dataset NAME, or both --benchmark and --data")
if not args.tag:
ap.error("--tag is required without --dataset")
load_kw = {}
if args.ckpt:
load_kw["checkpoint"] = args.ckpt
det = synthscope.load(args.model, device=args.device, **load_kw)
det._ensure_weights()
spec = det.spec
model = det._engine._get_model(spec)
eval_transform = native_res_transform(spec.transform) if args.native_res else spec.transform
if args.native_res:
print(f"[native-res] scoring at native resolution; transform={eval_transform}", flush=True)
root = Path(args.data).expanduser()
select = set(args.select.split(",")) if args.select else None
gens = discover(args.benchmark, root,
Path(args.reals).expanduser() if args.reals else None, select)
if not gens:
print(f"No generators with real+fake under {root} (benchmark={args.benchmark})")
return 1
print(f"=== bench_eval {args.tag} | model={args.model} ckpt={args.ckpt or 'registry-default'} "
f"| {args.benchmark} | {len(gens)} generators | device={det._engine.device} ===", flush=True)
per_gen, agg = {}, {"ap": [], "auroc": [], "acc": []}
for name, reals, fakes in gens:
if args.limit:
reals, fakes = reals[:args.limit], fakes[:args.limit]
labels = torch.tensor([0] * len(reals) + [1] * len(fakes))
print(f" {name}: {len(reals)} real + {len(fakes)} fake ...", flush=True)
probs, kept = score(det, model, spec, reals + fakes, args.bs, transform=eval_transform)
if len(kept) != labels.numel(): # unreadable images are skipped: keep each score paired with its own label
print(f" {labels.numel() - len(kept)} unreadable image(s) left out of {name}", flush=True)
labels = labels[kept]
m = metrics(probs, labels)
if m is None:
print(f" skip {name}: one class only")
continue
per_gen[name] = m
for k in agg:
agg[k].append(m[k])
print(f" {name}: acc={m['acc']} AP={m['ap']} AUROC={m['auroc']} "
f"(real_acc={m['real_acc']} fake_acc={m['fake_acc']})", flush=True)
if not per_gen:
print("No generator produced valid metrics.")
return 1
def mean(xs):
return round(sum(xs) / len(xs), 2)
means = {"acc": mean(agg["acc"]), "map": mean(agg["ap"]), "mauroc": mean(agg["auroc"])}
result = {
"tag": args.tag,
"model": args.model,
"ckpt": args.ckpt or "registry-default",
"benchmark": args.benchmark,
"data_root": str(root),
"n_generators": len(per_gen),
"means": means,
"per_generator": per_gen,
"date": datetime.datetime.now().isoformat(timespec="seconds"),
"synthscope_version": getattr(synthscope, "__version__", "?"),
}
out = Path(args.out).expanduser() if args.out else (
RESULTS_LOG.parent / "bench_results" / f"{args.tag}.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, indent=2) + "\n")
RESULTS_LOG.parent.mkdir(parents=True, exist_ok=True)
with RESULTS_LOG.open("a") as f:
f.write(json.dumps({k: result[k] for k in
("date", "tag", "model", "ckpt", "benchmark", "data_root",
"n_generators", "means")}) + "\n")
print(f"\n=== RESULT {args.tag}: mAP={means['map']} mAUROC={means['mauroc']} acc={means['acc']} "
f"over {len(per_gen)} generators -> {out} ===", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())