Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 14,802 Bytes
cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a 463a482 cd24b4a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | """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())
|