saminv81 commited on
Commit
384e467
·
verified ·
1 Parent(s): cf37d57

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/__init__.py +0 -0
  2. src/aigc_detector.egg-info/PKG-INFO +20 -0
  3. src/aigc_detector.egg-info/SOURCES.txt +80 -0
  4. src/aigc_detector.egg-info/dependency_links.txt +1 -0
  5. src/aigc_detector.egg-info/requires.txt +18 -0
  6. src/aigc_detector.egg-info/top_level.txt +8 -0
  7. src/analysis/__init__.py +0 -0
  8. src/analysis/format_probe.py +115 -0
  9. src/analysis/subset_weakness.py +95 -0
  10. src/config.py +81 -0
  11. src/data/__init__.py +0 -0
  12. src/data/aug/__init__.py +0 -0
  13. src/data/aug/augment.py +77 -0
  14. src/data/aug/augment_impls.py +146 -0
  15. src/data/aug/deshortcut.py +85 -0
  16. src/data/aug/random_resolution.py +61 -0
  17. src/data/aug/symmetry_audit.py +53 -0
  18. src/data/aug/warp.py +120 -0
  19. src/data/dataset.py +108 -0
  20. src/data/preprocess.py +76 -0
  21. src/data/provenance/__init__.py +0 -0
  22. src/data/provenance/audit.py +17 -0
  23. src/data/provenance/cleanup.py +60 -0
  24. src/data/provenance/dda_audit.py +16 -0
  25. src/data/provenance/dedup_audit.py +184 -0
  26. src/data/provenance/diskcheck.py +38 -0
  27. src/data/provenance/families.py +32 -0
  28. src/data/provenance/feature_cache.py +36 -0
  29. src/data/provenance/finalize.py +38 -0
  30. src/data/provenance/journal.py +48 -0
  31. src/data/provenance/manifest_build.py +54 -0
  32. src/data/provenance/orchestrate.py +122 -0
  33. src/data/provenance/push.py +11 -0
  34. src/data/provenance/rangezip.py +266 -0
  35. src/data/provenance/resolver.py +53 -0
  36. src/data/provenance/run_curator.py +110 -0
  37. src/data/provenance/selection.py +52 -0
  38. src/data/provenance/sources.py +502 -0
  39. src/data/provenance/splitter.py +118 -0
  40. src/data/provenance/subsample.py +34 -0
  41. src/data/provenance/val_holdout.py +192 -0
  42. src/data/provenance/wildfake.py +393 -0
  43. src/data/provenance/writer.py +98 -0
  44. src/data/shard_reader.py +49 -0
  45. src/eval/__init__.py +0 -0
  46. src/eval/error_gallery.py +69 -0
  47. src/eval/explain.py +99 -0
  48. src/eval/harness.py +210 -0
  49. src/eval/metrics.py +58 -0
  50. src/eval/predictions.py +51 -0
src/__init__.py ADDED
File without changes
src/aigc_detector.egg-info/PKG-INFO ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: aigc-detector
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: torch>=2.2
6
+ Requires-Dist: torchvision>=0.17
7
+ Requires-Dist: numpy>=1.26
8
+ Requires-Dist: pandas>=2.2
9
+ Requires-Dist: pyarrow>=15
10
+ Requires-Dist: pillow>=10
11
+ Requires-Dist: scikit-learn>=1.4
12
+ Requires-Dist: wandb>=0.16
13
+ Requires-Dist: psutil>=5.9
14
+ Requires-Dist: opencv-python-headless>=4.9
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == "dev"
17
+ Provides-Extra: data
18
+ Requires-Dist: datasets>=2.18; extra == "data"
19
+ Requires-Dist: huggingface_hub>=0.23; extra == "data"
20
+ Requires-Dist: modelscope>=1.13; extra == "data"
src/aigc_detector.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ src/__init__.py
4
+ src/config.py
5
+ src/aigc_detector.egg-info/PKG-INFO
6
+ src/aigc_detector.egg-info/SOURCES.txt
7
+ src/aigc_detector.egg-info/dependency_links.txt
8
+ src/aigc_detector.egg-info/requires.txt
9
+ src/aigc_detector.egg-info/top_level.txt
10
+ src/analysis/__init__.py
11
+ src/analysis/format_probe.py
12
+ src/analysis/subset_weakness.py
13
+ src/data/__init__.py
14
+ src/data/dataset.py
15
+ src/data/preprocess.py
16
+ src/data/shard_reader.py
17
+ src/data/aug/__init__.py
18
+ src/data/aug/augment.py
19
+ src/data/aug/augment_impls.py
20
+ src/data/aug/deshortcut.py
21
+ src/data/aug/random_resolution.py
22
+ src/data/aug/symmetry_audit.py
23
+ src/data/aug/warp.py
24
+ src/data/provenance/__init__.py
25
+ src/data/provenance/audit.py
26
+ src/data/provenance/cleanup.py
27
+ src/data/provenance/dda_audit.py
28
+ src/data/provenance/dedup_audit.py
29
+ src/data/provenance/diskcheck.py
30
+ src/data/provenance/families.py
31
+ src/data/provenance/feature_cache.py
32
+ src/data/provenance/finalize.py
33
+ src/data/provenance/journal.py
34
+ src/data/provenance/manifest_build.py
35
+ src/data/provenance/orchestrate.py
36
+ src/data/provenance/push.py
37
+ src/data/provenance/rangezip.py
38
+ src/data/provenance/resolver.py
39
+ src/data/provenance/run_curator.py
40
+ src/data/provenance/selection.py
41
+ src/data/provenance/sources.py
42
+ src/data/provenance/splitter.py
43
+ src/data/provenance/subsample.py
44
+ src/data/provenance/val_holdout.py
45
+ src/data/provenance/wildfake.py
46
+ src/data/provenance/writer.py
47
+ src/eval/__init__.py
48
+ src/eval/error_gallery.py
49
+ src/eval/explain.py
50
+ src/eval/harness.py
51
+ src/eval/metrics.py
52
+ src/eval/predictions.py
53
+ src/eval/stream.py
54
+ src/inference/__init__.py
55
+ src/inference/predict.py
56
+ src/inference/server.py
57
+ src/model/__init__.py
58
+ src/model/_dinov2.py
59
+ src/model/backbone.py
60
+ src/model/detector.py
61
+ src/model/freq.py
62
+ src/model/fusion.py
63
+ src/model/head.py
64
+ src/train/__init__.py
65
+ src/train/checkpoint.py
66
+ src/train/compile_backbone.py
67
+ src/train/driver.py
68
+ src/train/logging_utils.py
69
+ src/train/loop.py
70
+ src/train/loss.py
71
+ src/train/preflight.py
72
+ src/train/watchdog.py
73
+ tests/test_aug.py
74
+ tests/test_evaluation.py
75
+ tests/test_explainability.py
76
+ tests/test_inference.py
77
+ tests/test_model.py
78
+ tests/test_preprocessing.py
79
+ tests/test_provenance.py
80
+ tests/test_training.py
src/aigc_detector.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/aigc_detector.egg-info/requires.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.2
2
+ torchvision>=0.17
3
+ numpy>=1.26
4
+ pandas>=2.2
5
+ pyarrow>=15
6
+ pillow>=10
7
+ scikit-learn>=1.4
8
+ wandb>=0.16
9
+ psutil>=5.9
10
+ opencv-python-headless>=4.9
11
+
12
+ [data]
13
+ datasets>=2.18
14
+ huggingface_hub>=0.23
15
+ modelscope>=1.13
16
+
17
+ [dev]
18
+ pytest>=8
src/aigc_detector.egg-info/top_level.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __init__
2
+ analysis
3
+ config
4
+ data
5
+ eval
6
+ inference
7
+ model
8
+ train
src/analysis/__init__.py ADDED
File without changes
src/analysis/format_probe.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/analysis/format_probe.py
2
+ """Format/codec-shortcut probe — validates that the holdout AUC is content, not codec.
3
+
4
+ A format-only rule ("PNG = fake, JPEG = real") scores ~0.845 on our
5
+ holdout — essentially identical to the model's 0.84. DINOv2 never sees filenames, but it DOES
6
+ see JPEG quantization/blocking/chroma-subsampling residuals, and published detectors have been
7
+ shown to treat JPEG compression as evidence of authenticity ("Fake or JPEG?", arXiv 2403.17608).
8
+ Our resolution defenses do nothing for this axis.
9
+
10
+ The decisive test: restrict to a SINGLE codec (JPEG) present in BOTH classes, size-match, and
11
+ stratify by generator. If the AUC collapses toward 0.5 inside that controlled slice, most of the
12
+ headline is codec/provenance, not detection. If it stays strong, the shortcut is not what's
13
+ driving the score. This module computes exactly that controlled AUC alongside the raw AUC.
14
+
15
+ `format` is derived from the image path extension (verbatim from the sniffed source format in
16
+ save_source_image), so no manifest schema change is needed — .jpg/.jpeg -> JPEG, .png -> PNG.
17
+ """
18
+ import os
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from eval.metrics import roc_auc
24
+ from analysis.subset_weakness import resolution_band
25
+
26
+
27
+ def format_of(path_or_ext) -> str:
28
+ """Map a path or extension to a coarse codec label: JPEG / PNG / WEBP / OTHER."""
29
+ ext = os.path.splitext(str(path_or_ext))[1].lower().lstrip(".") or str(path_or_ext).lower().lstrip(".")
30
+ if ext in ("jpg", "jpeg"):
31
+ return "JPEG"
32
+ if ext == "png":
33
+ return "PNG"
34
+ if ext == "webp":
35
+ return "WEBP"
36
+ return "OTHER"
37
+
38
+
39
+ def _format_col(df) -> pd.Series:
40
+ """Return a 'format' Series for df, using an existing column or deriving from image_path."""
41
+ if "format" in df.columns and df["format"].notna().any():
42
+ return df["format"].astype(str)
43
+ src = "image_path" if "image_path" in df.columns else "path"
44
+ return df[src].map(format_of)
45
+
46
+
47
+ def format_leakage_auc(pred_df, condition="clean") -> dict:
48
+ """How well does codec ALONE separate the classes? Scores format as an ordinal signal
49
+ (JPEG=0, PNG=1) against the label — the 'format-only classifier' baseline.
50
+ A value near the model's AUC means codec could account for most of the headline."""
51
+ df = pred_df[pred_df["condition"] == condition] if "condition" in pred_df else pred_df
52
+ fmt = _format_col(df)
53
+ # PNG=1 leans fake, JPEG=0 leans real (our corpus polarity); WEBP/OTHER map to 0.5.
54
+ score = fmt.map({"PNG": 1.0, "JPEG": 0.0}).fillna(0.5).to_numpy()
55
+ y = df["label"].to_numpy()
56
+ auc = roc_auc(y, score) if len(np.unique(y)) == 2 else float("nan")
57
+ counts = {f"{lab}:{f}": int(n) for (lab, f), n in df.assign(_f=fmt).groupby(["label", "_f"]).size().items()}
58
+ return {"format_only_auc": float(auc), "counts": counts}
59
+
60
+
61
+ def format_controlled_auc(pred_df, keep_format="JPEG", condition="clean", size_match=True,
62
+ min_n=15) -> dict:
63
+ """Model AUC restricted to a single codec present in BOTH classes (default JPEG), optionally
64
+ size-band-matched, so codec cannot be the discriminator. Returns the controlled AUC, the raw
65
+ AUC on the same condition, the drop, and per-generator controlled AUCs.
66
+
67
+ A large drop (controlled << raw, controlled -> ~0.5) is the smoking gun: the headline was
68
+ codec/provenance, not detection. A small drop means the score survives codec control."""
69
+ df = pred_df[pred_df["condition"] == condition].copy() if "condition" in pred_df else pred_df.copy()
70
+ df["_fmt"] = _format_col(df).values
71
+ y_all, p_all = df["label"].to_numpy(), df["prob"].to_numpy()
72
+ raw_auc = roc_auc(y_all, p_all) if len(np.unique(y_all)) == 2 else float("nan")
73
+
74
+ sub = df[df["_fmt"] == keep_format]
75
+ # size-match: keep only resolution bands where BOTH classes are present, so within the kept
76
+ # codec the classes also share the resolution support (removes the residual size confound).
77
+ matched_bands = None
78
+ if size_match and "native_w" in sub.columns and sub["native_w"].notna().any():
79
+ sub = sub.assign(_band=sub["native_w"].map(resolution_band))
80
+ bands_real = set(sub[sub["label"] == 0]["_band"])
81
+ bands_fake = set(sub[sub["label"] == 1]["_band"])
82
+ matched_bands = sorted(bands_real & bands_fake)
83
+ sub = sub[sub["_band"].isin(matched_bands)]
84
+
85
+ y, p = sub["label"].to_numpy(), sub["prob"].to_numpy()
86
+ n_real = int((sub["label"] == 0).sum())
87
+ n_fake = int((sub["label"] == 1).sum())
88
+ ctrl_auc = (roc_auc(y, p) if len(np.unique(y)) == 2 and min(n_real, n_fake) >= min_n
89
+ else float("nan"))
90
+
91
+ per_gen = []
92
+ if "generator_id" in sub.columns:
93
+ reals = sub[sub["label"] == 0]["prob"].to_numpy()
94
+ for gen, g in sub[sub["label"] == 1].groupby("generator_id"):
95
+ fp = g["prob"].to_numpy()
96
+ yy = np.r_[np.zeros(len(reals)), np.ones(len(fp))]
97
+ pp = np.r_[reals, fp]
98
+ a = roc_auc(yy, pp) if len(fp) and len(reals) else float("nan")
99
+ per_gen.append({"generator_id": str(gen), "auc": a, "n_fake": int(len(fp)),
100
+ "reliable": len(fp) >= min_n})
101
+ per_gen_df = pd.DataFrame(per_gen, columns=["generator_id", "auc", "n_fake", "reliable"])
102
+ if not per_gen_df.empty:
103
+ per_gen_df = per_gen_df.sort_values("auc", na_position="last").reset_index(drop=True)
104
+
105
+ return {
106
+ "keep_format": keep_format,
107
+ "raw_auc": float(raw_auc),
108
+ "controlled_auc": float(ctrl_auc),
109
+ "drop": float(raw_auc - ctrl_auc) if not (np.isnan(raw_auc) or np.isnan(ctrl_auc)) else float("nan"),
110
+ "n_real": n_real, "n_fake": n_fake,
111
+ "matched_bands": matched_bands,
112
+ "size_matched": bool(size_match and matched_bands is not None),
113
+ "per_generator": per_gen_df,
114
+ "reliable": min(n_real, n_fake) >= min_n,
115
+ }
src/analysis/subset_weakness.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/analysis/subset_weakness.py
2
+ """Subset-weakness analysis — WHICH slices does the detector fail on?
3
+
4
+ The aggregate AUC hides generalization failures. A slice with AUC < 0.5 (below chance) means
5
+ the model ranks that slice's fakes as MORE real than reals — a genuine failure mode, not just
6
+ noise. This module scores each subset (generator, real_source, resolution band, format) against
7
+ the shared real pool and surfaces the weak ones, always with the sample count N so a real
8
+ failure is distinguished from a small-sample artifact.
9
+
10
+ Motivated by the observed styleGAN ≈ 0.5 slice: GANs are a systematically weaker subset for a
11
+ DINOv2-semantic-feature detector, which averaging into a 0.9+ headline would hide.
12
+ """
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from eval.metrics import roc_auc
17
+
18
+
19
+ def resolution_band(w):
20
+ """Bucket a width into a coarse resolution band (for slicing by resolution)."""
21
+ w = float(w)
22
+ if w < 256:
23
+ return "<256"
24
+ if w < 512:
25
+ return "256-512"
26
+ if w < 1024:
27
+ return "512-1024"
28
+ return ">=1024"
29
+
30
+
31
+ def subset_weakness(pred_df, by="generator_id", condition="clean", min_n=15):
32
+ """Per-subset AUC worst-first, with N and a reliability flag. Auto-detects which class the
33
+ `by` column actually varies on (`by='real_source'` previously grouped FAKE rows silently,
34
+ which all carry real_source='none', so it never measured per-real-source weakness):
35
+
36
+ - FAKE-side attribute (e.g. generator_id): each fake subset vs the SHARED real pool.
37
+ - REAL-side attribute (e.g. real_source): each real subset vs the SHARED fake pool. A LOW AUC
38
+ here means the detector ranks that real source as more fake than average (a real-content
39
+ weakness) — the diagnostic the report intends.
40
+
41
+ Returns a DataFrame [subset, auc, n_fake, n_real, reliable]."""
42
+ df = pred_df[pred_df["condition"] == condition] if "condition" in pred_df else pred_df
43
+ if by not in df.columns:
44
+ return pd.DataFrame(columns=["subset", "auc", "n_fake", "n_real", "reliable"])
45
+ reals = df[df["label"] == 0]
46
+ fakes = df[df["label"] == 1]
47
+ # which class does `by` actually distinguish? Use the side with >1 distinct non-null value.
48
+ real_varies = reals[by].nunique(dropna=True) > 1
49
+ fake_varies = fakes[by].nunique(dropna=True) > 1
50
+ real_side = real_varies and not fake_varies # real_source-type attribute
51
+ rows = []
52
+ if real_side:
53
+ # each REAL subset vs the shared FAKE pool
54
+ fake_probs = fakes["prob"].to_numpy()
55
+ for subset, g in reals.groupby(by):
56
+ rp = g["prob"].to_numpy()
57
+ y = np.r_[np.zeros(len(rp)), np.ones(len(fake_probs))]
58
+ p = np.r_[rp, fake_probs]
59
+ auc = roc_auc(y, p) if len(rp) and len(fake_probs) else float("nan")
60
+ rows.append({"subset": str(subset), "auc": auc, "n_fake": int(len(fake_probs)),
61
+ "n_real": int(len(rp)), "reliable": len(rp) >= min_n})
62
+ else:
63
+ # each FAKE subset vs the shared REAL pool
64
+ real_probs = reals["prob"].to_numpy()
65
+ for subset, g in fakes.groupby(by):
66
+ fp = g["prob"].to_numpy()
67
+ y = np.r_[np.zeros(len(real_probs)), np.ones(len(fp))]
68
+ p = np.r_[real_probs, fp]
69
+ auc = roc_auc(y, p) if len(fp) and len(real_probs) else float("nan")
70
+ rows.append({"subset": str(subset), "auc": auc, "n_fake": int(len(fp)),
71
+ "n_real": int(len(real_probs)), "reliable": len(fp) >= min_n})
72
+ out = pd.DataFrame(rows, columns=["subset", "auc", "n_fake", "n_real", "reliable"])
73
+ return out.sort_values("auc", na_position="last").reset_index(drop=True)
74
+
75
+
76
+ def find_weak_subsets(pred_df, dims=("generator_id", "real_source"), auc_threshold=0.7,
77
+ min_n=15, condition="clean"):
78
+ """Scan multiple slice dimensions and return every subset below `auc_threshold` that has
79
+ enough samples to be reliable. Adds a resolution-band dimension if native_w is present.
80
+ Returns a list of dicts {dim, subset, auc, n_fake} worst-first — the failure-mode report."""
81
+ df = pred_df.copy()
82
+ dims = list(dims)
83
+ if "native_w" in df.columns and "res_band" not in df.columns:
84
+ df["res_band"] = df["native_w"].map(resolution_band)
85
+ dims.append("res_band")
86
+ weak = []
87
+ for dim in dims:
88
+ if dim not in df.columns:
89
+ continue
90
+ rep = subset_weakness(df, by=dim, condition=condition, min_n=min_n)
91
+ for _, r in rep.iterrows():
92
+ if r["reliable"] and r["auc"] < auc_threshold:
93
+ weak.append({"dim": dim, "subset": r["subset"], "auc": float(r["auc"]),
94
+ "n_fake": int(r["n_fake"])})
95
+ return sorted(weak, key=lambda x: x["auc"])
src/config.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/config.py
2
+ from dataclasses import dataclass
3
+
4
+ @dataclass
5
+ class Config:
6
+ resolution: int = 518
7
+ patch_grid: int = 37
8
+ n_patch: int = 1369
9
+ embed_dim: int = 768
10
+ backbone: str = "stub" # "stub" (local CI) | "dinov2_vitb14_reg" (GPU host)
11
+ # REJECTED (kept for reference): RINE-style last-N fusion never beat single-layer readout.
12
+ # n_intermediate_layers: int = 1
13
+ extract_layer: int = 8 # SHIP DEFAULT (train.py trains 8 and 10 for the ensemble) — >0 = read patch tokens after DINOv2 block L (1-indexed) and TRUNCATE the stack to L blocks (real FLOP saving); tests whether localized signal survives earlier. 0 = full depth. Mutually exclusive with n_intermediate_layers>1.
14
+ # REJECTED: MIL top-k image logits (k=16/64 and DC-centered) gave no transfer gain — the
15
+ # head's self-attention broadcasts the verdict into every patch, so top-k of a broadcast
16
+ # is the broadcast. Left commented for the record.
17
+ # mil_top_k: int = 0
18
+ # mil_centered: bool = False
19
+ label_smooth: float = 0.05 # BCE target smoothing eps: targets eps/1−eps instead of 0/1 (bounds optimal logits, calibrates probs). 0 = hard labels.
20
+ # REJECTED: warp minimal-pair augmentation taught the warp artifact, not anatomy
21
+ # (unseen-GAN AUC −0.027). Implementation retained in data/aug/warp.py for reference.
22
+ # warp_strength: float = 1.0
23
+ # p_global_warp: float = 0.0
24
+ corruption_scale: float = 1.0 # train-only: >1 stretches corruption severity ranges toward their harsh end (clamped). Ship default 1.0 — scaling alone measured negative; keep at 1.0 unless re-validated together with lambda_feat.
25
+ lambda_feat: float = 1.5 # feature-consistency weight: cosine drift of the pooled trainable representation across the clean/corrupted pair (label-blind, both classes) — forces the decision path onto corruption-stable semantic/geometric directions.
26
+ fusion_project: bool = True # fusion projection: True=dense DxD (~590K); False=scalar layer-mix (K params, preferred at low data)
27
+ train_impls: dict | None = None # {family:[impl,...]} operator subset for TRAINING — set BEFORE training so held-out impls stay genuinely unseen (the impl-holdout gap is only valid when training is restricted to this subset)
28
+ data_parallel: bool = False # DataParallel across all visible GPUs (e.g. dual T4): splits the heavy frozen-backbone forward for ~2x + halves per-GPU batch memory. No idle GPU regardless of arm count.
29
+ head_hidden: int = 0 # 0 = linear-probe head; >0 = MLP head (Linear-GELU-Linear)
30
+ head_pool: str = "attn" # "attn" (baseline MIL) | "dist" (+mean/std/top-k patch-logit stats) | "nbr" (dist + zero-init 3x3 depthwise conv over the patch grid = local adjacency; density-gate redirect, orthogonal to global-realism signal) | "enc" (dist + zero-init self-attention encoder w/ 2D pos-embed = configural "sequence-not-bag" test)
31
+ # NULLED (never shipped): opt-in spectral branch — JPEG/blur corruption destroys the
32
+ # frequency signal it reads; only viable as a fusion member, which we did not ship.
33
+ # use_freq_branch: bool = False
34
+ enc_layers: int = 2 # EncoderPoolHead: transformer-encoder depth (head_pool="enc")
35
+ enc_heads: int = 8 # EncoderPoolHead: self-attention heads (head_pool="enc")
36
+ use_pos_embed: bool = True # EncoderPoolHead: add fixed 2D sin/cos positional embedding before the encoder
37
+ # SUPERSEDED by deshortcut=True (the full pipeline); kept for ablation only.
38
+ # defeat_resolution: bool = False
39
+ deshortcut: bool = False # train: thumbnail-degrade+contrast+sharpness+codec aug (stronger shortcut defeat)
40
+ p_codec: float = 0.5 # deshortcut: P(random JPEG/PNG re-encode) — kills the codec-history shortcut
41
+ random_resolution: bool = False # train: per-batch random resolution (varied token grid, no res anchor)
42
+ res_lo: int = 392 # random_resolution range lo (mult of 14)
43
+ res_hi: int = 644 # random_resolution range hi (mult of 14)
44
+ fair_eval: bool = False # eval: normalize real+fake to common size+format (no res/format tell)
45
+ feature_mode: str = "jit" # Phase-2: "jit"|"cache" — cache path not yet implemented (no consumer in Phase 1)
46
+ compile: bool = False # torch.compile toggle (GPU-host only)
47
+ amp: bool = False # fp16 autocast on the FROZEN backbone only (T4 tensor cores ~2x); head stays fp32
48
+ gpu_preprocess: bool = False # workers emit uint8 (resize only); float/normalize/res-resize happen ON GPU (halves worker CPU, 4x smaller host transfers). Bilinear resize commutes with per-channel normalize, so numerics match the CPU path.
49
+ lambda_aug: float = 0.8
50
+ lambda_cons_max: float = 0.3
51
+ # NULLED (never shipped): teacher distillation from a pretrained forensics model was cut
52
+ # early on licensing + complexity grounds; the frozen-backbone head needs no teacher.
53
+ # lambda_kd: float = 0.0
54
+ pos_weight: float = 0.0 # 0 = unweighted BCE; >0 scales the fake(1) term (calibration)
55
+ entropy_gate: float = 0.5
56
+ stack_prob: float = 0.1
57
+ seed: int = 0
58
+ epochs: int = 10
59
+ epoch_frac: float = 1.0 # train on this fraction of the loader per epoch (0.5 = half an epoch); shuffle=True makes the half-pass a random subsample
60
+ batch_size: int = 32
61
+ eval_batch_size: int = 32 # eval forward-pass chunk (predict_fn); bounds VRAM on large val sets
62
+ eval_pool_max: int = 64 # max decoded val images resident at once (bounds HOST RAM in streaming eval)
63
+ eval_workers: int = 0 # >0 = thread-pool the eval corruption prep (PIL releases the GIL); 0 = serial (legacy)
64
+ holdout_eval_cap: int = 0 # 0=eval whole holdout; >0=stratified subsample cap (speed)
65
+ val_eval_cap: int = 0 # 0=eval whole val EVERY epoch; >0=stratified per-epoch subsample (a 7.5k val x 19 conds = hours of CPU-bound corruption PER EPOCH on the 47k corpus)
66
+ lr: float = 1e-3
67
+ lr_schedule: str = "none" # "none" | "onecycle" (per-step mid-epoch warmup+cosine)
68
+ num_workers: int = 2
69
+ pin_memory: bool = True
70
+ max_images: int = 0 # 0 = all
71
+ data_root: str = "data"
72
+ cache_dir: str = "cache"
73
+ # Weights & Biases logging (OFF by default; training hosts flip use_wandb=True)
74
+ use_wandb: bool = False
75
+ wandb_project: str = "track5-aigc"
76
+ wandb_run_name: str = "" # empty -> wandb autogenerates
77
+ log_every: int = 10 # log train metrics every N steps
78
+
79
+ @classmethod
80
+ def smoke(cls) -> "Config":
81
+ return cls(max_images=200, epochs=1, compile=False, batch_size=8)
src/data/__init__.py ADDED
File without changes
src/data/aug/__init__.py ADDED
File without changes
src/data/aug/augment.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/augment.py
2
+ import io
3
+ import numpy as np
4
+ from PIL import Image, ImageFilter, ImageEnhance
5
+
6
+ CORRUPTIONS = ["jpeg", "blur", "resize", "noise", "jitter", "crop"]
7
+
8
+ EVAL_GRID = {
9
+ "jpeg": [90, 70, 50, 30],
10
+ "blur": [0.5, 1.0, 2.0],
11
+ "resize": [0.5, 0.25],
12
+ "noise": [0.02, 0.05, 0.10],
13
+ "jitter": [0.2], # ±20%
14
+ "crop": [0.8], # keep 80%
15
+ }
16
+
17
+ # Continuous training ranges (min,max) spanning the eval grid.
18
+ _TRAIN_RANGE = {
19
+ "jpeg": (30.0, 95.0),
20
+ "blur": (0.3, 2.2),
21
+ "resize": (0.25, 0.9),
22
+ "noise": (0.01, 0.12),
23
+ "jitter": (0.05, 0.25),
24
+ "crop": (0.7, 0.95),
25
+ }
26
+
27
+ def apply_corruption(img: Image.Image, name: str, severity: float) -> Image.Image:
28
+ img = img.convert("RGB")
29
+ if name == "jpeg":
30
+ buf = io.BytesIO()
31
+ img.save(buf, format="JPEG", quality=int(round(severity)))
32
+ buf.seek(0)
33
+ return Image.open(buf).convert("RGB")
34
+ if name == "blur":
35
+ return img.filter(ImageFilter.GaussianBlur(radius=float(severity)))
36
+ if name == "resize":
37
+ w, h = img.size
38
+ small = img.resize((max(1, int(w*severity)), max(1, int(h*severity))), Image.BILINEAR)
39
+ return small.resize((w, h), Image.BILINEAR)
40
+ if name == "noise":
41
+ arr = np.asarray(img).astype(np.float32) / 255.0
42
+ rng = np.random.default_rng(int(severity * 1e6)) # deterministic per severity
43
+ arr = arr + rng.normal(0, float(severity), arr.shape)
44
+ return Image.fromarray((np.clip(arr, 0, 1) * 255).astype("uint8"))
45
+ if name == "jitter":
46
+ out = ImageEnhance.Brightness(img).enhance(1 + severity)
47
+ out = ImageEnhance.Contrast(out).enhance(1 + severity)
48
+ out = ImageEnhance.Color(out).enhance(1 + severity)
49
+ return out
50
+ if name == "crop":
51
+ w, h = img.size
52
+ cw, ch = int(w*severity), int(h*severity)
53
+ left, top = (w - cw)//2, (h - ch)//2
54
+ return img.crop((left, top, left+cw, top+ch))
55
+ raise ValueError(f"unknown corruption {name}")
56
+
57
+ # Direction of "harsher" per corruption: -1 = lower value is harsher (jpeg quality,
58
+ # resize/crop keep-fraction), +1 = higher value is harsher (blur radius, noise sigma, jitter).
59
+ _HARSH_DIR = {"jpeg": -1, "blur": +1, "resize": -1, "noise": +1, "jitter": +1, "crop": -1}
60
+ # Hard floors/ceilings for scaled ranges (beyond these, reals become unrecognizable and
61
+ # clean-AUC pays for it).
62
+ _HARSH_LIMIT = {"jpeg": 12.0, "blur": 4.5, "resize": 0.12, "noise": 0.25, "jitter": 0.5,
63
+ "crop": 0.5}
64
+
65
+
66
+ def sample_train_corruption(rng: np.random.Generator, scale: float = 1.0) -> tuple[str, float]:
67
+ """scale>1 stretches each range toward its HARSH end by (scale-1) x range-width, clamped
68
+ at _HARSH_LIMIT — 'drown the residual texture signal' training. scale=1 = original ranges."""
69
+ name = CORRUPTIONS[rng.integers(len(CORRUPTIONS))]
70
+ lo, hi = _TRAIN_RANGE[name]
71
+ if scale > 1.0:
72
+ stretch = (scale - 1.0) * (hi - lo)
73
+ if _HARSH_DIR[name] < 0:
74
+ lo = max(_HARSH_LIMIT[name], lo - stretch)
75
+ else:
76
+ hi = min(_HARSH_LIMIT[name], hi + stretch)
77
+ return name, float(rng.uniform(lo, hi))
src/data/aug/augment_impls.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/augment_impls.py
2
+ """Multi-IMPLEMENTATION corruption bank for the operator-realization holdout.
3
+
4
+ We are scored on 6 corruptions but do NOT control the grader's exact operator (their
5
+ JPEG quantization/subsampling, their resize kernel). Training a detector to be robust to
6
+ ONE implementation risks overfitting that operator's artifact. This bank exposes several
7
+ GENUINELY DIVERGENT implementations per family so we can train on one subset and evaluate
8
+ on a held-out subset — measuring (and defending against) operator overfit.
9
+
10
+ The divergence axes are empirically chosen: naive "PIL-jpeg vs cv2-jpeg" is a FAKE
11
+ holdout (both wrap libjpeg → byte-identical). What actually changes the artifact is the
12
+ PARAMETERIZATION — JPEG chroma subsampling (4:4:4 vs 4:2:0), resize KERNEL (bilinear /
13
+ bicubic / lanczos / area), blur border handling. Those are the implementations here.
14
+
15
+ cv2 is an OPTIONAL backend (may be absent on minimal images); every family has at
16
+ least two PIL-only implementations so the holdout works with the standard library alone.
17
+ """
18
+ import io
19
+ import numpy as np
20
+ from PIL import Image, ImageFilter, ImageEnhance
21
+
22
+ try:
23
+ import cv2 as _cv2
24
+ except Exception: # cv2 optional — PIL-only impls still give a holdout
25
+ _cv2 = None
26
+
27
+
28
+ # ---- JPEG: the artifact axis is chroma subsampling + codec, not the wrapper library ----
29
+ def _jpeg_pil(img, q, subsampling):
30
+ buf = io.BytesIO()
31
+ img.save(buf, format="JPEG", quality=int(round(q)), subsampling=subsampling)
32
+ return Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
33
+
34
+ def _jpeg_444(img, q): return _jpeg_pil(img, q, 0) # no chroma subsampling
35
+ def _jpeg_420(img, q): return _jpeg_pil(img, q, 2) # 2x2 chroma subsampling (very common)
36
+
37
+ def _jpeg_cv2(img, q):
38
+ a = _cv2.cvtColor(np.asarray(img), _cv2.COLOR_RGB2BGR)
39
+ ok, enc = _cv2.imencode(".jpg", a, [_cv2.IMWRITE_JPEG_QUALITY, int(round(q))])
40
+ dec = _cv2.imdecode(enc, _cv2.IMREAD_COLOR)
41
+ return Image.fromarray(_cv2.cvtColor(dec, _cv2.COLOR_BGR2RGB))
42
+
43
+
44
+ # ---- RESIZE: down-then-up; the artifact axis is the KERNEL ----
45
+ def _resize_pil(img, s, flt):
46
+ w, h = img.size
47
+ small = img.resize((max(1, int(w * s)), max(1, int(h * s))), flt)
48
+ return small.resize((w, h), flt).convert("RGB")
49
+
50
+ def _resize_bilinear(img, s): return _resize_pil(img, s, Image.BILINEAR)
51
+ def _resize_bicubic(img, s): return _resize_pil(img, s, Image.BICUBIC)
52
+ def _resize_lanczos(img, s): return _resize_pil(img, s, Image.LANCZOS)
53
+
54
+ def _resize_cv2_area(img, s):
55
+ a = np.asarray(img); h, w = a.shape[:2]
56
+ small = _cv2.resize(a, (max(1, int(w * s)), max(1, int(h * s))), interpolation=_cv2.INTER_AREA)
57
+ up = _cv2.resize(small, (w, h), interpolation=_cv2.INTER_LINEAR)
58
+ return Image.fromarray(up)
59
+
60
+
61
+ # ---- BLUR: PIL Gaussian vs a box-blur approximation vs cv2 (border handling differs) ----
62
+ def _blur_gauss(img, sigma): return img.filter(ImageFilter.GaussianBlur(float(sigma))).convert("RGB")
63
+ def _blur_box(img, sigma):
64
+ # box radius chosen so variance ≈ Gaussian sigma² (uniform kernel, different tail)
65
+ r = max(1, int(round(float(sigma) * 1.7)))
66
+ return img.filter(ImageFilter.BoxBlur(r)).convert("RGB")
67
+ def _blur_cv2(img, sigma):
68
+ a = _cv2.GaussianBlur(np.asarray(img), (0, 0), float(sigma))
69
+ return Image.fromarray(a)
70
+
71
+
72
+ # ---- NOISE: additive gaussian in float vs uint8 domain (clipping order differs) ----
73
+ def _noise_float(img, sigma):
74
+ arr = np.asarray(img).astype(np.float32) / 255.0
75
+ rng = np.random.default_rng(int(sigma * 1e6))
76
+ arr = np.clip(arr + rng.normal(0, float(sigma), arr.shape), 0, 1)
77
+ return Image.fromarray((arr * 255).astype("uint8"))
78
+ def _noise_uint8(img, sigma):
79
+ arr = np.asarray(img).astype(np.int16)
80
+ rng = np.random.default_rng(int(sigma * 1e6) + 1)
81
+ arr = arr + rng.normal(0, float(sigma) * 255.0, arr.shape).astype(np.int16)
82
+ return Image.fromarray(np.clip(arr, 0, 255).astype("uint8"))
83
+
84
+
85
+ # ---- JITTER: order of brightness/contrast/color ops differs the result ----
86
+ def _jitter_bcs(img, s):
87
+ out = ImageEnhance.Brightness(img).enhance(1 + s)
88
+ out = ImageEnhance.Contrast(out).enhance(1 + s)
89
+ return ImageEnhance.Color(out).enhance(1 + s).convert("RGB")
90
+ def _jitter_csb(img, s):
91
+ out = ImageEnhance.Color(img).enhance(1 + s)
92
+ out = ImageEnhance.Contrast(out).enhance(1 + s)
93
+ return ImageEnhance.Brightness(out).enhance(1 + s).convert("RGB")
94
+
95
+
96
+ # ---- CROP: center vs corner (both keep `s` fraction; different pixels retained) ----
97
+ def _crop_center(img, s):
98
+ w, h = img.size; cw, ch = int(w * s), int(h * s)
99
+ l, t = (w - cw) // 2, (h - ch) // 2
100
+ return img.crop((l, t, l + cw, t + ch)).convert("RGB")
101
+ def _crop_corner(img, s):
102
+ w, h = img.size; cw, ch = int(w * s), int(h * s)
103
+ return img.crop((0, 0, cw, ch)).convert("RGB")
104
+
105
+
106
+ # Registry: family -> {impl_name: fn}. cv2 impls added only when cv2 is importable.
107
+ IMPL_BANK = {
108
+ "jpeg": {"pil444": _jpeg_444, "pil420": _jpeg_420},
109
+ "resize": {"bilinear": _resize_bilinear, "bicubic": _resize_bicubic, "lanczos": _resize_lanczos},
110
+ "blur": {"gauss": _blur_gauss, "box": _blur_box},
111
+ "noise": {"float": _noise_float, "uint8": _noise_uint8},
112
+ "jitter": {"bcs": _jitter_bcs, "csb": _jitter_csb},
113
+ "crop": {"center": _crop_center, "corner": _crop_corner},
114
+ }
115
+ if _cv2 is not None:
116
+ IMPL_BANK["jpeg"]["cv2"] = _jpeg_cv2
117
+ IMPL_BANK["resize"]["cv2area"] = _resize_cv2_area
118
+ IMPL_BANK["blur"]["cv2"] = _blur_cv2
119
+
120
+
121
+ def list_impls(family):
122
+ return list(IMPL_BANK[family])
123
+
124
+
125
+ def apply_impl(img, family, impl, severity):
126
+ return IMPL_BANK[family][impl](img.convert("RGB"), severity)
127
+
128
+
129
+ def split_impls_train_test(seed=0):
130
+ """Partition each family's implementations into disjoint train/test sets (the
131
+ implementation holdout). Deterministic; every family with >=2 impls contributes at
132
+ least one impl to each split. The TEST impls are never seen during training, so the
133
+ train→test AUC gap measures operator-overfit."""
134
+ import random
135
+ rng = random.Random(seed)
136
+ train, test = {}, {}
137
+ for fam, impls in IMPL_BANK.items():
138
+ names = sorted(impls)
139
+ rng.shuffle(names)
140
+ if len(names) == 1: # can't hold out; use in both (documented)
141
+ train[fam] = test[fam] = list(names)
142
+ continue
143
+ n_test = max(1, len(names) // 2)
144
+ test[fam] = sorted(names[:n_test])
145
+ train[fam] = sorted(names[n_test:])
146
+ return train, test
src/data/aug/deshortcut.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/deshortcut.py
2
+ """Shortcut-defeating augmentations.
3
+
4
+ The eval exposed low-level tells: the COCO reals are 200px JPEG thumbnails while fakes are
5
+ large/sharp, so absolute resolution/format/sharpness separate the classes perfectly (AUC 1.0)
6
+ without any AI-detection. These augmentations push BOTH classes, label-blindly, through the
7
+ SAME low-level degradations so those statistics carry no label signal and the model must use
8
+ content:
9
+
10
+ - thumbnail_degrade: downsize to a small size -> JPEG re-encode -> upsize back to original.
11
+ This REPRODUCES exactly what a 200px COCO thumbnail underwent, applied to every image, so a
12
+ large sharp fake sometimes looks thumbnail-degraded too. The single most targeted fix.
13
+ - randomize_contrast / randomize_sharpness: jitter the contrast and sharpness so those global
14
+ statistics (which also differ between thumbnail-reals and full-res-fakes) are not a tell.
15
+
16
+ All are seeded by a caller-supplied numpy Generator (the dataset's label-blind rng) so the
17
+ degradation is class-independent and reproducible.
18
+ """
19
+ import io
20
+ import numpy as np
21
+ from PIL import Image, ImageEnhance
22
+
23
+
24
+ def thumbnail_degrade(img, rng, min_side=96, max_side=384, jpeg_q=(40, 95)):
25
+ """Downsize -> JPEG -> upsize back to the original size, reproducing the thumbnail pipeline
26
+ the small COCO reals went through. Random intermediate size + JPEG quality per call."""
27
+ img = img.convert("RGB")
28
+ w, h = img.size
29
+ side = int(rng.integers(min_side, max_side + 1))
30
+ # preserve aspect: scale the SHORT side to `side`
31
+ if w <= h:
32
+ nw, nh = side, max(1, int(round(h * side / w)))
33
+ else:
34
+ nw, nh = max(1, int(round(w * side / h))), side
35
+ small = img.resize((nw, nh), Image.BILINEAR)
36
+ q = int(rng.integers(jpeg_q[0], jpeg_q[1] + 1))
37
+ buf = io.BytesIO(); small.save(buf, format="JPEG", quality=q)
38
+ small = Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
39
+ return small.resize((w, h), Image.BILINEAR)
40
+
41
+
42
+ def randomize_contrast(img, rng, lo=0.7, hi=1.3):
43
+ """Jitter contrast so global contrast is not a real/fake tell."""
44
+ return ImageEnhance.Contrast(img.convert("RGB")).enhance(float(rng.uniform(lo, hi)))
45
+
46
+
47
+ def randomize_sharpness(img, rng, lo=0.4, hi=1.8):
48
+ """Jitter sharpness (blur<1, sharpen>1) so the sharp-fake / soft-thumbnail-real tell is
49
+ randomized out."""
50
+ return ImageEnhance.Sharpness(img.convert("RGB")).enhance(float(rng.uniform(lo, hi)))
51
+
52
+
53
+ def randomize_codec(img, rng, jpeg_q=(45, 95)):
54
+ """Re-encode through a RANDOM codec so compression history carries no label signal.
55
+
56
+ The #1 measured shortcut: our reals are all JPEG, GAN/Imagen fakes all PNG, so 'JPEG⟺real' holds
57
+ on the real side no matter what — and DINOv2 reads JPEG quantization/blocking residuals. This
58
+ picks JPEG (random quality) OR lossless PNG per call, label-blindly, so a real is sometimes
59
+ seen as clean-PNG and a fake sometimes as JPEG-compressed. Deliberately: we do
60
+ NOT blanket-JPEG (that double-compresses the already-JPEG reals while single-compressing PNG
61
+ fakes — a NEW tell); randomizing the codec per image within BOTH classes avoids it."""
62
+ img = img.convert("RGB")
63
+ if rng.random() < 0.5:
64
+ q = int(rng.integers(jpeg_q[0], jpeg_q[1] + 1))
65
+ buf = io.BytesIO(); img.save(buf, format="JPEG", quality=q)
66
+ return Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
67
+ buf = io.BytesIO(); img.save(buf, format="PNG") # lossless re-encode
68
+ return Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
69
+
70
+
71
+ def apply_deshortcut(img, rng, p_thumbnail=0.5, p_contrast=0.5, p_sharpness=0.5, p_codec=0.5):
72
+ """Compose the shortcut-defeating augs, each applied with its probability. Label-blind:
73
+ the caller passes the same rng distribution for real and fake, so none of these low-level
74
+ statistics (resolution, sharpness, contrast, AND codec history) can encode the label.
75
+ Returns an RGB image of the original size."""
76
+ out = img.convert("RGB")
77
+ if rng.random() < p_thumbnail:
78
+ out = thumbnail_degrade(out, rng)
79
+ if rng.random() < p_contrast:
80
+ out = randomize_contrast(out, rng)
81
+ if rng.random() < p_sharpness:
82
+ out = randomize_sharpness(out, rng)
83
+ if rng.random() < p_codec:
84
+ out = randomize_codec(out, rng)
85
+ return out
src/data/aug/random_resolution.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/random_resolution.py
2
+ """Per-batch random resolution (user-directed).
3
+
4
+ Forcing every image to a fixed 518×518 bakes the resolution difference in (thumbnails always
5
+ upscaled, large images always downscaled) and lets the model memorize a fixed 37×37 token
6
+ layout. Instead we resize each BATCH to a random valid resolution (a multiple of the patch
7
+ size), so:
8
+ - the same content is seen at varied token-grid densities (a general robustness aug), and
9
+ - absolute source resolution is randomized across batches, removing an anchor for the
10
+ resolution shortcut.
11
+
12
+ DINOv2 interpolates its position embeddings for arbitrary resolution natively, and the MIL
13
+ head is patch-count-agnostic, so no model change is needed. Per-BATCH (not per-image) because
14
+ a batch is one tensor and must share a size anyway — which is also the cheap, worker-safe
15
+ implementation the user suggested ("the entire batch can resize").
16
+ """
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn.functional as F
20
+ from torch.utils.data.dataloader import default_collate
21
+
22
+
23
+ def valid_resolutions(patch=14, lo=392, hi=644):
24
+ """Resolutions that are multiples of `patch` in [lo, hi] — the sizes DINOv2 accepts."""
25
+ k0 = (lo + patch - 1) // patch
26
+ k1 = hi // patch
27
+ return [k * patch for k in range(k0, k1 + 1)]
28
+
29
+
30
+ class _RandomResolutionCollate:
31
+ """Picklable collate callable (a plain closure isn't picklable, so DataLoader workers with
32
+ num_workers>0 can't use it). Stacks a batch (default_collate) then resizes the image tensors
33
+ to ONE random valid resolution for that batch. Non-image fields pass through unchanged.
34
+ lo==hi disables randomization (deterministic base size). Each worker seeds independently."""
35
+ def __init__(self, patch=14, lo=392, hi=644, seed=0, image_keys=("clean_img", "aug_img")):
36
+ self.sizes = valid_resolutions(patch, lo, hi)
37
+ self.seed = seed
38
+ self.image_keys = image_keys
39
+ self._rng = None # lazily created per-process (post-fork/pickle)
40
+
41
+ def __call__(self, samples):
42
+ if self._rng is None:
43
+ import os
44
+ self._rng = np.random.default_rng((self.seed, os.getpid()))
45
+ batch = default_collate(samples)
46
+ target = int(self.sizes[self._rng.integers(len(self.sizes))]) if len(self.sizes) > 1 else self.sizes[0]
47
+ for k in self.image_keys:
48
+ if k in batch and torch.is_tensor(batch[k]) and batch[k].shape[-1] != target:
49
+ if batch[k].dtype == torch.uint8:
50
+ # gpu_preprocess path: DEFER the resize to the GPU (gpu_finish) —
51
+ # interpolating a big fp32 batch here was pure worker-CPU burn.
52
+ batch["target_res"] = target
53
+ else:
54
+ batch[k] = F.interpolate(batch[k], size=(target, target),
55
+ mode="bilinear", align_corners=False)
56
+ return batch
57
+
58
+
59
+ def random_resolution_collate(patch=14, lo=392, hi=644, seed=0, image_keys=("clean_img", "aug_img")):
60
+ """Build a picklable per-batch random-resolution collate (see _RandomResolutionCollate)."""
61
+ return _RandomResolutionCollate(patch, lo, hi, seed, image_keys)
src/data/aug/symmetry_audit.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/symmetry_audit.py
2
+ """Empirical class-symmetry audit.
3
+
4
+ Class-INDEPENDENT augmentation sampling is necessary but not sufficient for "no shortcut":
5
+ real and fake images can still receive different REALIZED corruptions if the sources differ
6
+ in resolution, format, or transform failure rate. The decisive test is empirical — fit a
7
+ cheap classifier on the augmentation METADATA alone (which corruption, severity, realized
8
+ dims) and check its AUC at predicting the label. If aug metadata predicts the label, the
9
+ detector could learn "was-corrupted-this-way" instead of "is-synthetic" — the shortcut that
10
+ "Fake or JPEG?" exposed. A passing audit shows AUC ≈ 0.5.
11
+ """
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+
16
+ def aug_metadata_leakage_auc(df, seed=0):
17
+ """AUC of a logistic model predicting `label` from augmentation metadata only
18
+ (aug_id one-hot + severity + any realized-dim columns present). ~0.5 = no leakage."""
19
+ from sklearn.linear_model import LogisticRegression
20
+ from sklearn.model_selection import cross_val_predict
21
+ from sklearn.metrics import roc_auc_score
22
+
23
+ y = df["label"].to_numpy().astype(int)
24
+ if len(np.unique(y)) < 2:
25
+ return 0.5
26
+ feats = []
27
+ if "aug_id" in df:
28
+ feats.append(pd.get_dummies(df["aug_id"], prefix="aug"))
29
+ for col in ("severity", "out_w", "out_h", "out_bytes"):
30
+ if col in df:
31
+ feats.append(df[[col]].astype(float))
32
+ if not feats:
33
+ return 0.5
34
+ X = pd.concat(feats, axis=1).to_numpy(dtype=float)
35
+ # cross-validated out-of-fold probabilities -> honest AUC (no train-on-test optimism)
36
+ clf = LogisticRegression(max_iter=500)
37
+ try:
38
+ proba = cross_val_predict(clf, X, y, cv=5, method="predict_proba")[:, 1]
39
+ except Exception:
40
+ clf.fit(X, y); proba = clf.predict_proba(X)[:, 1]
41
+ return float(roc_auc_score(y, proba))
42
+
43
+
44
+ def audit_class_symmetry(df, threshold=0.6, seed=0):
45
+ """Report whether augmentation metadata leaks the label. passed = AUC <= threshold."""
46
+ auc = aug_metadata_leakage_auc(df, seed=seed)
47
+ return {
48
+ "leakage_auc": auc,
49
+ "passed": auc <= threshold,
50
+ "threshold": threshold,
51
+ "note": ("aug metadata should NOT predict the label; AUC>threshold means real/fake "
52
+ "receive systematically different corruptions -> a 'was-corrupted' shortcut."),
53
+ }
src/data/aug/warp.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/warp.py
2
+ """Anatomical-plausibility augmentation: local discontinuous warps.
3
+
4
+ Motivation (run-4..8 + XAI): every reader of DINOv2-B features hits the same GigaGAN
5
+ ceiling — texture separates train classes so cheaply that anatomy/arrangement is never
6
+ learned. A real photo and its LOCALLY-warped twin form a minimal pair: identical camera,
7
+ JPEG history, and texture statistics, differing ONLY in local geometric plausibility. With
8
+ the warped twin labeled FAKE, texture is uninformative on the pair by construction and the
9
+ gradient's only descent direction is the geometry axis (the six-fingers class of wrongness).
10
+
11
+ Two warp families with OPPOSITE roles:
12
+ local_deform -> label-flipping (real -> warped twin labeled fake): 1-3 localized mesh
13
+ displacements — a region's geometry disagrees with its surroundings.
14
+ global_warp -> class-SYMMETRIC aug (both classes, label-blind): mild affine/perspective,
15
+ breaks no anatomy. Its role is to put warp-resampling smear on BOTH
16
+ classes so "was resampled" cannot become the shortcut for "fake".
17
+
18
+ Both re-quantize through JPEG so interpolation smoothness is not the tell either.
19
+ """
20
+ import io
21
+
22
+ import numpy as np
23
+ from PIL import Image
24
+
25
+
26
+ def _jpeg(img: Image.Image, quality: int) -> Image.Image:
27
+ buf = io.BytesIO()
28
+ img.save(buf, "JPEG", quality=int(quality))
29
+ buf.seek(0)
30
+ return Image.open(buf).convert("RGB")
31
+
32
+
33
+ def _remap_mesh(arr: np.ndarray, dx: np.ndarray, dy: np.ndarray) -> np.ndarray:
34
+ """Bilinear remap of HxWx3 uint8 by per-pixel displacement fields (float32 HxW)."""
35
+ h, w = arr.shape[:2]
36
+ yy, xx = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32),
37
+ indexing="ij")
38
+ sx = np.clip(xx + dx, 0, w - 1)
39
+ sy = np.clip(yy + dy, 0, h - 1)
40
+ x0 = np.floor(sx).astype(np.int32); y0 = np.floor(sy).astype(np.int32)
41
+ x1 = np.minimum(x0 + 1, w - 1); y1 = np.minimum(y0 + 1, h - 1)
42
+ fx = (sx - x0)[..., None]; fy = (sy - y0)[..., None]
43
+ a = arr[y0, x0].astype(np.float32); b = arr[y0, x1].astype(np.float32)
44
+ c = arr[y1, x0].astype(np.float32); d = arr[y1, x1].astype(np.float32)
45
+ out = (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy
46
+ return np.clip(out, 0, 255).astype(np.uint8)
47
+
48
+
49
+ def local_deform(img: Image.Image, rng: np.random.Generator,
50
+ strength: float = 1.0, jpeg_q: (int, int) = (70, 92),
51
+ max_side: int = 640) -> Image.Image:
52
+ """1-3 localized smooth-bump displacements — geometry locally inconsistent with the
53
+ surround, texture untouched. strength scales displacement amplitude.
54
+
55
+ CPU note: the numpy remap is O(H*W); native-res warping of a 1500px image is ~6x the
56
+ work of 640px for zero semantic gain (deshortcut crushes to ~200px downstream anyway).
57
+ Warp at <=max_side and restore the ORIGINAL size afterward, so the twin's resolution
58
+ statistics stay identical to its real original (no size leak)."""
59
+ orig_size = img.size
60
+ img = img.convert("RGB")
61
+ if max(img.size) > max_side:
62
+ s = max_side / max(img.size)
63
+ img = img.resize((max(1, int(img.size[0] * s)), max(1, int(img.size[1] * s))),
64
+ Image.BILINEAR)
65
+ arr = np.asarray(img)
66
+ h, w = arr.shape[:2]
67
+ dx = np.zeros((h, w), np.float32); dy = np.zeros((h, w), np.float32)
68
+ n = int(rng.integers(1, 4))
69
+ for _ in range(n):
70
+ # bump center anywhere; radius 8-25% of min side; amplitude 4-12% of radius x strength
71
+ cy, cx = rng.uniform(0.15, 0.85) * h, rng.uniform(0.15, 0.85) * w
72
+ rad = rng.uniform(0.08, 0.25) * min(h, w)
73
+ amp = rng.uniform(0.15, 0.45) * rad * strength
74
+ ang = rng.uniform(0, 2 * np.pi)
75
+ yy, xx = np.meshgrid(np.arange(h, dtype=np.float32), np.arange(w, dtype=np.float32),
76
+ indexing="ij")
77
+ d2 = ((yy - cy) ** 2 + (xx - cx) ** 2) / (rad ** 2)
78
+ bump = np.exp(-d2 * 2.0).astype(np.float32) # smooth, compact support
79
+ dx += np.cos(ang) * amp * bump
80
+ dy += np.sin(ang) * amp * bump
81
+ out_img = Image.fromarray(_remap_mesh(arr, dx, dy))
82
+ if out_img.size != orig_size:
83
+ out_img = out_img.resize(orig_size, Image.BILINEAR) # restore native size (no leak)
84
+ q = int(rng.integers(jpeg_q[0], jpeg_q[1] + 1))
85
+ return _jpeg(out_img, q)
86
+
87
+
88
+ def global_warp(img: Image.Image, rng: np.random.Generator,
89
+ jpeg_q: (int, int) = (70, 92)) -> Image.Image:
90
+ """Mild global perspective/affine jitter (anatomy-preserving), then JPEG. Class-symmetric:
91
+ exists so BOTH classes carry warp-resampling residue."""
92
+ w, h = img.size
93
+ j = 0.04 # corner jitter, fraction of side
94
+ src = [(0, 0), (w, 0), (w, h), (0, h)]
95
+ dst = [(x + rng.uniform(-j, j) * w, y + rng.uniform(-j, j) * h) for x, y in src]
96
+ # solve the 8-dof perspective transform mapping dst -> src (PIL wants inverse coeffs)
97
+ A = []
98
+ for (x, y), (u, v) in zip(dst, src):
99
+ A.append([x, y, 1, 0, 0, 0, -u * x, -u * y])
100
+ A.append([0, 0, 0, x, y, 1, -v * x, -v * y])
101
+ B = np.array([c for (u, v) in src for c in (u, v)], dtype=np.float64)
102
+ coeffs = np.linalg.solve(np.array(A, dtype=np.float64), B)
103
+ out = img.transform((w, h), Image.PERSPECTIVE, tuple(coeffs), resample=Image.BILINEAR)
104
+ q = int(rng.integers(jpeg_q[0], jpeg_q[1] + 1))
105
+ return _jpeg(out, q)
106
+
107
+
108
+ def add_warp_fakes(train_df, frac: float, seed: int = 0):
109
+ """Duplicate a `frac` sample of REAL train rows as warped fakes: label=1, warp_fake=1,
110
+ generator_id='warp_real'. The original real rows stay — the minimal pair is explicit
111
+ (same content under both labels; only the warp separates them)."""
112
+ reals = train_df[train_df.label == 0]
113
+ dup = reals.sample(int(len(reals) * frac), random_state=seed).copy()
114
+ dup["label"] = 1
115
+ dup["warp_fake"] = 1
116
+ dup["generator_id"] = "warp_real"
117
+ out = train_df.copy()
118
+ out["warp_fake"] = 0
119
+ import pandas as pd
120
+ return pd.concat([out, dup], ignore_index=True)
src/data/dataset.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/dataset.py
2
+ import numpy as np
3
+ from pandas import notna as pd_notna
4
+ from PIL import Image
5
+ from torch.utils.data import Dataset
6
+ from data.aug.augment import apply_corruption, sample_train_corruption
7
+ from data.preprocess import preprocess_image
8
+
9
+ class AigcDataset(Dataset):
10
+ def __init__(self, df, cfg, train=True, resolver=None):
11
+ self.df = df.reset_index(drop=True)
12
+ self.cfg = cfg
13
+ self.train = train
14
+ self.res = cfg.resolution
15
+ self.resolver = resolver
16
+ # Epoch folded into the aug seed so each image gets a DIFFERENT corruption realization
17
+ # every epoch (a seed of only (cfg.seed, img_id) froze the aug across epochs,
18
+ # killing augmentation diversity). Epoch is class-INDEPENDENT, so label-blindness holds.
19
+ # The driver calls set_epoch(e) before each epoch's loop.
20
+ self.epoch = 0
21
+
22
+ def set_epoch(self, epoch):
23
+ self.epoch = int(epoch)
24
+
25
+ def __len__(self):
26
+ return len(self.df)
27
+
28
+ def _prep(self, img):
29
+ if getattr(self.cfg, "gpu_preprocess", False):
30
+ from data.preprocess import preprocess_image_uint8
31
+ return preprocess_image_uint8(img, self.res) # float+normalize done on GPU
32
+ return preprocess_image(img, self.res)
33
+
34
+ def __getitem__(self, i):
35
+ row = self.df.iloc[i]
36
+ if self.resolver is not None and "ref_type" in row:
37
+ img = self.resolver.resolve(row)
38
+ else:
39
+ with Image.open(row["path"]) as im:
40
+ img = im.convert("RGB")
41
+ # LABEL-BLIND seed: derive the aug RNG from the IMMUTABLE img_id, not the row index
42
+ # i (which correlates with class when the manifest is label-sorted). This guarantees
43
+ # the realized corruption cannot carry label signal via seeding.
44
+ _idkey = row.get("img_id") if "img_id" in row and pd_notna(row.get("img_id")) else \
45
+ row.get("rel_path", row.get("path", i))
46
+ # epoch is part of the seed (train only) so the realized corruption VARIES across epochs
47
+ # while staying label-blind (epoch does not depend on the label).
48
+ _epoch = self.epoch if self.train else 0
49
+ seed_key = abs(hash((self.cfg.seed, _epoch, str(_idkey)))) % (2**32)
50
+ rng = np.random.default_rng(seed_key)
51
+ # DEFEAT THE RESOLUTION/FORMAT/SHARPNESS SHORTCUT (label-blind, both classes): the eval
52
+ # showed reals (200px JPEG thumbnails) vs fakes (large/sharp) separate by low-level
53
+ # statistics ALONE (resolution-only AUC 1.0). apply_deshortcut pushes every image through
54
+ # the SAME degradations — thumbnail(downsize->JPEG->upsize, reproducing what the COCO
55
+ # reals underwent) + contrast + sharpness jitter — so those stats carry no label signal.
56
+ # Applied BEFORE clean/aug so both views inherit it. (defeat_resolution kept as the
57
+ # lighter resolution-only variant for ablation.)
58
+ # WARP-FAKE rows (label-flipped duplicates of reals, add_warp_fakes): apply the local
59
+ # deform FIRST so the twin then rides the identical deshortcut+corruption pipeline as
60
+ # its real original — the minimal pair differs ONLY in local geometry. Seed offset by a
61
+ # constant so the twin's downstream aug draw still matches nothing label-correlated.
62
+ if row.get("warp_fake", 0) == 1:
63
+ from data.aug.warp import local_deform
64
+ img = local_deform(img, np.random.default_rng((seed_key + 777) % (2**32)),
65
+ strength=getattr(self.cfg, "warp_strength", 1.0))
66
+ # class-SYMMETRIC global warp (both classes, label-blind): puts warp-resampling residue
67
+ # on reals and fakes alike so "was resampled" cannot proxy for "warp_fake".
68
+ if self.train and rng.random() < getattr(self.cfg, "p_global_warp", 0.0):
69
+ from data.aug.warp import global_warp
70
+ img = global_warp(img, rng)
71
+ if self.train and getattr(self.cfg, "deshortcut", False):
72
+ from data.aug.deshortcut import apply_deshortcut
73
+ img = apply_deshortcut(img, rng, p_codec=getattr(self.cfg, "p_codec", 0.5))
74
+ elif self.train and getattr(self.cfg, "defeat_resolution", False):
75
+ from data.preprocess import defeat_resolution_shortcut
76
+ img = defeat_resolution_shortcut(img, self.res, rng)
77
+ name, sev = sample_train_corruption(
78
+ rng, scale=getattr(self.cfg, "corruption_scale", 1.0) if self.train else 1.0)
79
+ # CLASS-SYMMETRIC by construction: corruption is sampled from a class-INDEPENDENT
80
+ # distribution and applied to every image, so "was-corrupted" carries no label
81
+ # signal (prevents the JPEG/resize dataset-shortcut that "Fake or JPEG?" exposed).
82
+ # When train_impls is set, draw from the TRAINING implementation subset only, so the
83
+ # held-out implementations stay genuinely unseen for the invariance measurement.
84
+ train_impls = getattr(self.cfg, "train_impls", None)
85
+ if train_impls:
86
+ from data.aug.augment_impls import apply_impl
87
+ allowed = train_impls.get(name)
88
+ impl = allowed[int(rng.integers(len(allowed)))] if allowed else None
89
+ aug = apply_impl(img, name, impl, sev) if impl else apply_corruption(img, name, sev)
90
+ else:
91
+ aug = apply_corruption(img, name, sev)
92
+ # img_id: the curator manifest identifies images by rel_path, not img_id — fall back
93
+ # to rel_path (then path, then row index) so the dataset reads any manifest schema.
94
+ img_id = row.get("img_id") if "img_id" in row and pd_notna(row.get("img_id")) else \
95
+ row.get("rel_path", row.get("path", i))
96
+ out = {
97
+ "clean_img": self._prep(img),
98
+ "aug_img": self._prep(aug),
99
+ "aug_id": name,
100
+ "severity": float(sev),
101
+ "label": float(row["label"]),
102
+ "source": row["source_dataset"],
103
+ "generator_id": row["generator_id"],
104
+ "img_id": str(img_id),
105
+ }
106
+ if "teacher_prob" in row and row["teacher_prob"] == row["teacher_prob"]: # not NaN
107
+ out["teacher_prob"] = float(row["teacher_prob"])
108
+ return out
src/data/preprocess.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/preprocess.py
2
+ import io
3
+ from PIL import Image
4
+ from torchvision import transforms
5
+
6
+ _MEAN = [0.485, 0.456, 0.406]
7
+ _STD = [0.229, 0.224, 0.225]
8
+ _NORM = transforms.Normalize(mean=_MEAN, std=_STD)
9
+ _TO = transforms.ToTensor()
10
+
11
+
12
+ def preprocess_image_uint8(img, resolution):
13
+ """CPU-cheap half of preprocessing: resize + HWC->CHW uint8 tensor. The float
14
+ conversion + normalization (the expensive per-pixel CPU work) moves to the GPU
15
+ via gpu_finish — workers then ship 4x fewer bytes and burn ~half the CPU."""
16
+ import numpy as np
17
+ import torch
18
+ img = img.convert("RGB").resize((resolution, resolution), Image.BILINEAR)
19
+ arr = np.asarray(img, dtype=np.uint8) # HWC
20
+ return torch.from_numpy(arr).permute(2, 0, 1).contiguous()
21
+
22
+
23
+ def gpu_finish(x, target_res=None):
24
+ """Finish preprocessing ON DEVICE for a uint8 [B,C,H,W] batch: float/255,
25
+ ImageNet-normalize, optional resize to the batch's random resolution. Exactly
26
+ equivalent to ToTensor+Normalize (ToTensor is uint8/255) + collate interpolate,
27
+ minus the worker CPU. No-op passthrough for float inputs (already normalized)."""
28
+ import torch
29
+ import torch.nn.functional as F
30
+ if x.dtype == torch.uint8:
31
+ x = x.float().div_(255.0)
32
+ mean = torch.tensor(_MEAN, device=x.device).view(1, 3, 1, 1)
33
+ std = torch.tensor(_STD, device=x.device).view(1, 3, 1, 1)
34
+ x = x.sub_(mean).div_(std)
35
+ if target_res is not None and x.shape[-1] != int(target_res):
36
+ x = F.interpolate(x, size=(int(target_res), int(target_res)),
37
+ mode="bilinear", align_corners=False)
38
+ return x
39
+
40
+ def preprocess_image(img, resolution):
41
+ img = img.convert("RGB").resize((resolution, resolution), Image.BILINEAR)
42
+ return _NORM(_TO(img))
43
+
44
+
45
+ def defeat_resolution_shortcut(img, resolution, rng):
46
+ """Randomize the resolution PATH so absolute source resolution carries no label signal.
47
+
48
+ The eval exposed a catastrophic shortcut: reals were all 200x200 thumbnails and fakes were
49
+ large, so a plain resize-to-518 leaves reals upscale-blurred and fakes downscale-sharp — a
50
+ 'was-this-upscaled' tell (resolution-only AUC = 1.0). Here every image is first resized to a
51
+ RANDOM intermediate size (thumbnailing large images, and sometimes down-then-up), then to the
52
+ model resolution, with a random interpolation. Applied to BOTH classes, this randomizes out
53
+ the upscale/downscale signature so the model must use content, not resolution."""
54
+ img = img.convert("RGB")
55
+ # random intermediate short-side in [96, 1.4x resolution]; random interp both ways
56
+ inter = int(rng.integers(96, int(resolution * 1.4)))
57
+ filt_down = rng.choice([Image.BILINEAR, Image.BICUBIC, Image.LANCZOS, Image.NEAREST])
58
+ filt_up = rng.choice([Image.BILINEAR, Image.BICUBIC, Image.NEAREST])
59
+ small = img.resize((inter, inter), filt_down)
60
+ return small.resize((resolution, resolution), filt_up)
61
+
62
+
63
+ def fair_eval_normalize(img, resolution, jpeg_quality=90, common_small=200):
64
+ """Fair-eval normalizer: equalize real and fake so absolute resolution/format is not the
65
+ discriminator. Push every image DOWN to a small common size (`common_small`, matching the
66
+ holdout reals' 200px thumbnails) + JPEG re-encode, THEN up to the model resolution.
67
+
68
+ Downsizing to the common-small size is load-bearing: an earlier version that resized straight
69
+ to `resolution` (518) UPSCALED the small reals and AMPLIFIED the sharpness gap (a small real
70
+ upscaled gets even smoother; a large fake downscaled stays sharp) — sharpness-oracle AUC rose
71
+ 0.63→0.77. Matching everything DOWN to the real's information level destroys the fake's detail
72
+ advantage instead (oracle AUC → ~0.4), so any residual AUC is content, not resolution."""
73
+ img = img.convert("RGB").resize((common_small, common_small), Image.BILINEAR)
74
+ buf = io.BytesIO(); img.save(buf, format="JPEG", quality=jpeg_quality) # common format
75
+ img = Image.open(io.BytesIO(buf.getvalue())).convert("RGB")
76
+ return img.resize((resolution, resolution), Image.BILINEAR) # up to model res
src/data/provenance/__init__.py ADDED
File without changes
src/data/provenance/audit.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/audit.py
2
+ from data.provenance.dda_audit import audit_class_separability
3
+
4
+ def audit_corpus(df) -> dict:
5
+ dda = audit_class_separability(df)
6
+ per_family = df.groupby("family").size().to_dict()
7
+ n_real = int((df["label"] == 0).sum())
8
+ n_fake = int((df["label"] == 1).sum())
9
+ if dda["flag"]:
10
+ report = (f"SHORTCUT FLAGGED: resolution_auc={dda['resolution_auc']:.3f} "
11
+ f"compression_auc={dda['compression_auc']:.3f} (>0.65). "
12
+ f"Mitigate: resolution/JPEG-normalize BOTH classes or rebalance sources, then re-audit.")
13
+ else:
14
+ report = (f"clean: resolution_auc={dda['resolution_auc']:.3f} "
15
+ f"compression_auc={dda['compression_auc']:.3f} (both <=0.65).")
16
+ return {**dda, "per_family_counts": per_family,
17
+ "class_balance": {"n_real": n_real, "n_fake": n_fake}, "report": report}
src/data/provenance/cleanup.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/shards/cleanup.py
2
+ """Marker-guarded cleanup. Every recursive delete must target a directory that the
3
+ pipeline itself created (contains .track5_run_root) — filesystem roots, homes, the
4
+ workspace, and anything unmarked are refused. All operations report item count and
5
+ bytes reclaimed so GC is observable, not best-effort."""
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+
10
+ MARKER = ".track5_run_root"
11
+
12
+
13
+ def make_run_root(parent, prefix="run_"):
14
+ root = tempfile.mkdtemp(prefix=prefix, dir=parent)
15
+ open(os.path.join(root, MARKER), "w").close()
16
+ return root
17
+
18
+
19
+ def _tree_stats(target):
20
+ n, b = 0, 0
21
+ for dp, _dirs, files in os.walk(target):
22
+ for f in files:
23
+ n += 1
24
+ try:
25
+ b += os.path.getsize(os.path.join(dp, f))
26
+ except OSError:
27
+ pass
28
+ return n, b
29
+
30
+
31
+ def safe_rmtree(target):
32
+ resolved = os.path.realpath(target)
33
+ home = os.path.realpath(os.path.expanduser("~"))
34
+ cwd = os.path.realpath(os.getcwd())
35
+ if (not resolved or resolved == os.path.sep or os.path.dirname(resolved) == resolved
36
+ or resolved in (home, cwd)):
37
+ raise ValueError(f"refusing to delete unsafe target: {resolved}")
38
+ if not os.path.isfile(os.path.join(resolved, MARKER)):
39
+ raise ValueError(f"refusing: {resolved} has no {MARKER} marker")
40
+ n, b = _tree_stats(resolved)
41
+ shutil.rmtree(resolved)
42
+ return n, b
43
+
44
+
45
+ def reclaim_unreferenced(raw_root, referenced_relpaths):
46
+ """Delete every file under raw_root whose posix relpath is NOT in
47
+ referenced_relpaths; prune empty dirs. Returns (n_deleted, bytes_reclaimed)."""
48
+ referenced = set(referenced_relpaths)
49
+ n, b = 0, 0
50
+ for dp, _dirs, files in os.walk(raw_root, topdown=False):
51
+ for f in files:
52
+ p = os.path.join(dp, f)
53
+ rel = os.path.relpath(p, raw_root).replace(os.sep, "/")
54
+ if rel not in referenced:
55
+ b += os.path.getsize(p)
56
+ os.remove(p)
57
+ n += 1
58
+ if dp != raw_root and not os.listdir(dp):
59
+ os.rmdir(dp)
60
+ return n, b
src/data/provenance/dda_audit.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/manifest/dda_audit.py
2
+ import numpy as np
3
+ from eval.metrics import roc_auc
4
+
5
+ def audit_class_separability(df) -> dict:
6
+ labels = df["label"].to_numpy()
7
+ res = (df["native_w"] * df["native_h"]).to_numpy().astype(float)
8
+ comp = df["jpeg_bytes_per_pixel"].to_numpy().astype(float)
9
+ # AUC of a single scalar predicting label; flip so AUC>=0.5
10
+ def _one_sided(x):
11
+ a = roc_auc(labels, x)
12
+ return max(a, 1 - a) if not np.isnan(a) else float("nan")
13
+ res_auc = _one_sided(res)
14
+ comp_auc = _one_sided(comp)
15
+ flag = bool((res_auc > 0.65) or (comp_auc > 0.65))
16
+ return {"resolution_auc": res_auc, "compression_auc": comp_auc, "flag": flag}
src/data/provenance/dedup_audit.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/dedup_audit.py
2
+ """Near-duplicate audit + dedup for the corpus manifest. OmniFake (the aggregator)
3
+ is known to partially CONTAIN WildFake+GenImage images, so sources overlap; this
4
+ module finds near-identical images across sources via a DCT pHash and collapses
5
+ each duplicate cluster down to one row, per a source-priority policy."""
6
+ import numpy as np
7
+ import pandas as pd
8
+ from PIL import Image
9
+
10
+ from data.provenance.manifest_build import MANIFEST_COLUMNS
11
+
12
+ DEFAULT_PRIORITY = ["wildfake_valset", "wildfake", "sid_set", "commforensics",
13
+ "deepface", "omnifake", "cifake"]
14
+
15
+ def _dct_matrix(n: int) -> np.ndarray:
16
+ """(n,n) DCT-II basis: D[k,n] = cos(pi*(2n+1)*k/(2N)). dct2(img) = D @ img @ D.T."""
17
+ k = np.arange(n).reshape(-1, 1)
18
+ x = np.arange(n).reshape(1, -1)
19
+ return np.cos(np.pi * (2 * x + 1) * k / (2 * n))
20
+
21
+ def phash(image_path, hash_size=8) -> int:
22
+ """DCT-based pHash, numpy-only (no imagehash/scipy dependency). Deterministic:
23
+ same file -> same int every call. Robust to JPEG re-encode (low-freq DCT
24
+ coefficients barely move under quantization noise)."""
25
+ n = hash_size * 4
26
+ with Image.open(image_path) as im:
27
+ img = np.asarray(im.convert("L").resize((n, n), Image.Resampling.LANCZOS), dtype=np.float64)
28
+ d = _dct_matrix(n)
29
+ dct2 = d @ img @ d.T
30
+ block = dct2[:hash_size, :hash_size]
31
+ # Exclude the [0,0] DC term from the median (it's just average brightness,
32
+ # not structure) so the threshold reflects the actual frequency content.
33
+ flat = block.flatten()
34
+ median = np.median(np.delete(flat, 0))
35
+ bits = (flat > median)
36
+ h = 0
37
+ for b in bits:
38
+ h = (h << 1) | int(b)
39
+ return h
40
+
41
+ def hamming(a: int, b: int) -> int:
42
+ return (a ^ b).bit_count()
43
+
44
+ _POP_C1 = np.uint64(0x5555555555555555)
45
+ _POP_C2 = np.uint64(0x3333333333333333)
46
+ _POP_C4 = np.uint64(0x0f0f0f0f0f0f0f0f)
47
+ _POP_H01 = np.uint64(0x0101010101010101)
48
+
49
+ def _popcount64(x):
50
+ """Vectorized 64-bit popcount over a uint64 numpy array (SWAR / bit-twiddle). ~50x faster
51
+ than a Python-per-element loop, so the Hamming all-pairs scan is dominated by SIMD, not
52
+ interpreter overhead."""
53
+ x = x - ((x >> np.uint64(1)) & _POP_C1)
54
+ x = (x & _POP_C2) + ((x >> np.uint64(2)) & _POP_C2)
55
+ x = (x + (x >> np.uint64(4))) & _POP_C4
56
+ return (x * _POP_H01) >> np.uint64(56)
57
+
58
+ def _within_threshold_neighbors(hashes_u64, threshold):
59
+ """Yield (i, j>i) index pairs with Hamming(phash) <= threshold, computed row-at-a-time
60
+ with a VECTORIZED popcount (bounded memory O(n), one XOR-broadcast per row). This is the
61
+ O(n^2)-comparisons scan but with a tiny constant factor — measured faster than LSH banding
62
+ on mostly-unique real-image hashes (banding degrades to millions of chance-collision
63
+ candidates when bands are narrow). Exact: no false negatives, no tuning."""
64
+ n = len(hashes_u64)
65
+ for i in range(n):
66
+ if i + 1 >= n:
67
+ break
68
+ d = _popcount64(hashes_u64[i] ^ hashes_u64[i + 1:])
69
+ for off in np.nonzero(d <= threshold)[0]:
70
+ yield i, i + 1 + int(off)
71
+
72
+
73
+ def _phash_many(paths, workers=8):
74
+ """Compute phash for a list of paths, in PARALLEL over `workers` threads, preserving
75
+ input order. pHash is decode(disk-I/O) + DCT(numpy, releases the GIL), so threads overlap
76
+ the I/O and the released-GIL compute — measured ~20-27x over sequential even on 2 cores.
77
+ workers<=1 runs sequentially."""
78
+ if workers is None or workers <= 1 or len(paths) < 32:
79
+ return [phash(p) for p in paths]
80
+ from concurrent.futures import ThreadPoolExecutor
81
+ with ThreadPoolExecutor(max_workers=workers) as ex:
82
+ return list(ex.map(phash, paths)) # ex.map preserves order
83
+
84
+
85
+ def add_phash(df, workers=8):
86
+ """Copy of df with a uint64 `phash` column (64-bit DCT pHash of each row's file)."""
87
+ out = df.copy()
88
+ hashes = _phash_many(out["path"].tolist(), workers=workers)
89
+ out["phash"] = np.asarray([int(h) & ((1 << 64) - 1) for h in hashes], dtype=np.uint64)
90
+ return out
91
+
92
+
93
+ def cross_leak_mask(cand_hashes, hold_hashes, threshold=6):
94
+ """Boolean mask over cand_hashes: True where Hamming distance to ANY holdout
95
+ hash is <= threshold. Vectorized row-at-a-time over the (smaller) holdout side."""
96
+ cand = np.asarray(cand_hashes, dtype=np.uint64)
97
+ hold = np.asarray(hold_hashes, dtype=np.uint64)
98
+ mask = np.zeros(len(cand), dtype=bool)
99
+ if len(cand) == 0 or len(hold) == 0:
100
+ return mask
101
+ for h in hold:
102
+ d = _popcount64(cand ^ h)
103
+ mask |= d <= threshold
104
+ return mask
105
+
106
+
107
+ def dedup_manifest(df, threshold=6, priority=None, phash_workers=8):
108
+ """Collapse near-duplicate rows (hamming(phash) <= threshold) to one survivor
109
+ per cluster, keeping the row whose source ranks best in `priority`.
110
+
111
+ Complexity: O(n^2) comparisons but VECTORIZED (numpy SWAR popcount, row-at-a-time), so the
112
+ constant factor is ~50x smaller than a Python-per-pair loop — 30k rows in ~10s vs minutes,
113
+ 100k in ~2min. (LSH banding was tried and REJECTED: on mostly-unique real-image hashes,
114
+ narrow bands collide by chance and generate millions of spurious candidates, making it
115
+ SLOWER than the vectorized scan while adding tuning + false-negative risk.) Output is
116
+ identical to an exhaustive scan.
117
+ """
118
+ priority = list(priority) if priority is not None else DEFAULT_PRIORITY
119
+ rank = {src: i for i, src in enumerate(priority)}
120
+ n_sources = len(priority)
121
+
122
+ work = df.copy()
123
+ work["_phash"] = _phash_many(work["path"].tolist(), workers=phash_workers)
124
+ work["_rank"] = work["source_dataset"].map(lambda s: rank.get(s, n_sources))
125
+
126
+ n = len(work)
127
+ hashes = work["_phash"].to_numpy()
128
+ hashes_u64 = np.asarray([int(h) & ((1 << 64) - 1) for h in hashes], dtype=np.uint64)
129
+ ranks = work["_rank"].to_numpy()
130
+ dropped = np.zeros(n, dtype=bool)
131
+
132
+ # Gather within-threshold neighbours per row (vectorized), then the SAME union-find-free
133
+ # clustering: for each surviving row i, its within-threshold matches form a cluster; keep
134
+ # the best-priority survivor, drop the rest. Ties broken by stable first-seen (ascending i).
135
+ from collections import defaultdict
136
+ neighbors = defaultdict(list)
137
+ for i, j in _within_threshold_neighbors(hashes_u64, threshold):
138
+ neighbors[i].append(j)
139
+ for i in range(n):
140
+ if dropped[i]:
141
+ continue
142
+ cluster = [i]
143
+ for j in neighbors.get(i, ()):
144
+ if not dropped[j]:
145
+ cluster.append(j)
146
+ if len(cluster) == 1:
147
+ continue
148
+ best = min(cluster, key=lambda idx: (ranks[idx], idx))
149
+ for idx in cluster:
150
+ if idx != best:
151
+ dropped[idx] = True
152
+
153
+ kept_mask = ~dropped
154
+ cols = [c for c in MANIFEST_COLUMNS if c in df.columns]
155
+ deduped_df = df.loc[kept_mask, cols].reset_index(drop=True)
156
+ # Persist the survivor's OWN pHash so downstream cross-artifact leak subtraction
157
+ # (candidate vs official holdout) is manifest-only — no re-reading 45k images.
158
+ deduped_df["phash"] = hashes_u64[kept_mask]
159
+
160
+ # --- provenance table ---------------------------------------------------
161
+ sources_present = list(dict.fromkeys(df["source_dataset"])) # first-seen order
162
+ sampled = df["source_dataset"].value_counts()
163
+ after = df.loc[kept_mask, "source_dataset"].value_counts()
164
+
165
+ rows = []
166
+ for src in sources_present:
167
+ s = int(sampled.get(src, 0))
168
+ a = int(after.get(src, 0))
169
+ rows.append({"source_dataset": src, "total_available": pd.NA,
170
+ "sampled": s, "after_dedup": a, "dropped_as_dupe": s - a})
171
+
172
+ # Sort by priority order (unlisted sources after all listed, in first-seen order).
173
+ rows.sort(key=lambda r: rank.get(r["source_dataset"], n_sources))
174
+
175
+ total = {"source_dataset": "TOTAL", "total_available": pd.NA,
176
+ "sampled": sum(r["sampled"] for r in rows),
177
+ "after_dedup": sum(r["after_dedup"] for r in rows),
178
+ "dropped_as_dupe": sum(r["dropped_as_dupe"] for r in rows)}
179
+ rows.append(total)
180
+
181
+ provenance_df = pd.DataFrame(
182
+ rows, columns=["source_dataset", "total_available", "sampled", "after_dedup", "dropped_as_dupe"])
183
+
184
+ return deduped_df, provenance_df
src/data/provenance/diskcheck.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/shards/diskcheck.py
2
+ """Multi-filesystem disk accounting. The failed corpus run's telemetry watched ONE
3
+ filesystem while the client archived on another; fs_stats observes every filesystem
4
+ that holds run data (run root, system temp) and assert_headroom
5
+ stops BEFORE an operation that cannot fit its peak + emergency reserve."""
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+
10
+ RESERVE_BYTES = 5 * 2**30
11
+
12
+
13
+ def fs_stats(paths):
14
+ out = {}
15
+ for p in paths:
16
+ p = os.path.realpath(p)
17
+ if not os.path.exists(p):
18
+ continue
19
+ dev = os.stat(p).st_dev
20
+ key = str(dev)
21
+ if key not in out:
22
+ du = shutil.disk_usage(p)
23
+ out[key] = {"paths": [], "used": du.used, "free": du.free, "total": du.total}
24
+ out[key]["paths"].append(p)
25
+ return out
26
+
27
+
28
+ def default_watch_paths(run_root):
29
+ paths = [run_root, tempfile.gettempdir()]
30
+ return paths
31
+
32
+
33
+ def assert_headroom(path, needed_bytes, reserve_bytes=RESERVE_BYTES):
34
+ free = shutil.disk_usage(os.path.realpath(path)).free
35
+ if free < needed_bytes + reserve_bytes:
36
+ raise RuntimeError(
37
+ f"disk headroom: need {needed_bytes + reserve_bytes} bytes on {path}, "
38
+ f"free {free} — stopping before the operation (state preserved)")
src/data/provenance/families.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/families.py
2
+ import re
3
+
4
+ FAMILIES = [
5
+ "sd1x", "sd2x", "sdxl", "sd3", "flux", "dalle", "midjourney",
6
+ "kandinsky", "gan", "other_diffusion", "commercial_other", "unknown",
7
+ ]
8
+
9
+ # Ordered rules: first match wins. (name_lower) -> family.
10
+ # Order matters: sdxl/sd3 before the generic sd1x/sd2x check; specific before generic.
11
+ _RULES = [
12
+ (r"stable-diffusion-xl|sdxl|sd-xl", "sdxl"),
13
+ (r"stable-diffusion-3|sd-?3|sd3", "sd3"),
14
+ (r"stable-diffusion-2|sd-?2|sd2", "sd2x"),
15
+ (r"stable-diffusion-v?1|sd-?1|sd1|stable-diffusion-1", "sd1x"),
16
+ (r"flux", "flux"),
17
+ (r"dall-?e|dalle", "dalle"),
18
+ (r"midjourney|mj[_-]?v?\d", "midjourney"),
19
+ (r"kandinsky", "kandinsky"),
20
+ (r"stylegan|prousgan|progan|biggan|cyclegan|stargan|gaugan|gansformer|projectedgan|\bgan\b", "gan"),
21
+ (r"\badm\b|glide|vqdm|wukong|\bldm\b|ddpm|ddim|denoising.?diffusion", "other_diffusion"),
22
+ (r"firefly|ideogram|\bimagen\b", "commercial_other"), # imagen = Google's commercial model
23
+ ]
24
+
25
+ def classify_family(model_name: str) -> str:
26
+ if not model_name:
27
+ return "unknown"
28
+ n = model_name.lower()
29
+ for pat, fam in _RULES:
30
+ if re.search(pat, n):
31
+ return fam
32
+ return "unknown"
src/data/provenance/feature_cache.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/feature_cache.py
2
+ import hashlib, os
3
+
4
+ def cache_path(img_id, backbone_id, resolution, cache_dir) -> str:
5
+ key = f"{img_id}|{backbone_id}|{resolution}"
6
+ h = hashlib.sha1(key.encode()).hexdigest()[:16]
7
+ return os.path.join(cache_dir, f"{h}.pt")
8
+
9
+ def shard_indices(df, shard_idx, n_shards):
10
+ return list(range(shard_idx, len(df), n_shards))
11
+
12
+ def extract_shard(manifest_df, shard_idx, n_shards, cfg, cache_dir):
13
+ """GPU-host real path: load backbone, forward each shard image, save features.
14
+ Local: exercised only via the pure helpers above (this body needs a backbone)."""
15
+ import torch
16
+ from model.backbone import build_backbone
17
+ from data.preprocess import preprocess_image
18
+ from PIL import Image
19
+ os.makedirs(cache_dir, exist_ok=True)
20
+ bb = build_backbone(cfg)
21
+ if torch.cuda.is_available():
22
+ bb = bb.cuda()
23
+ idxs = shard_indices(manifest_df, shard_idx, n_shards)
24
+ for i in idxs:
25
+ row = manifest_df.iloc[i]
26
+ cp = cache_path(row["img_id"] if "img_id" in row else row["path"],
27
+ cfg.backbone, cfg.resolution, cache_dir)
28
+ if os.path.exists(cp):
29
+ continue
30
+ with Image.open(row["path"]) as im:
31
+ x = preprocess_image(im.convert("RGB"), cfg.resolution).unsqueeze(0)
32
+ if torch.cuda.is_available():
33
+ x = x.cuda()
34
+ with torch.no_grad():
35
+ feat = bb(x).squeeze(0).cpu().half()
36
+ torch.save(feat, cp)
src/data/provenance/finalize.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/finalize.py
2
+ """Cross-artifact finalization (manifest-only; no image I/O, no shard rewrites).
3
+ The official holdout always wins: finalization removes candidate TRAINING rows that
4
+ collide with holdout rows (exact identity first, then pHash<=threshold), never the
5
+ reverse. Splits are assigned only AFTER subtraction, and the finalized manifest pins
6
+ the immutable candidate dataset version — 'latest' resolution is refused."""
7
+ from data.provenance.dedup_audit import cross_leak_mask
8
+ from data.provenance.splitter import assign_mixed_split
9
+
10
+
11
+ def _ident(df):
12
+ col = "img_id" if "img_id" in df.columns else "rel_path"
13
+ return df[col].astype(str)
14
+
15
+
16
+ def finalize_training_manifest(cand_df, holdout_df, candidate_ref, threshold=6,
17
+ val_frac=0.15, seed=0):
18
+ if "@" not in candidate_ref:
19
+ raise ValueError(
20
+ f"candidate_ref must pin a version (slug@version), got {candidate_ref!r}")
21
+ cand = cand_df.copy()
22
+ n0 = len(cand)
23
+ hold_ids = set(_ident(holdout_df)) if len(holdout_df) else set()
24
+ cand = cand[~_ident(cand).isin(hold_ids)]
25
+ n_exact = n0 - len(cand)
26
+ if len(holdout_df) and len(cand):
27
+ mask = cross_leak_mask(cand["phash"].to_numpy(),
28
+ holdout_df["phash"].to_numpy(), threshold=threshold)
29
+ cand = cand[~mask]
30
+ n_phash = n0 - n_exact - len(cand)
31
+ final = assign_mixed_split(cand.reset_index(drop=True), val_frac=val_frac, seed=seed)
32
+ for part in ("train", "val"):
33
+ labs = set(final[final["split"] == part]["label"])
34
+ assert labs == {0, 1}, f"finalize: split {part} lacks both classes ({labs})"
35
+ final["candidate_dataset"] = candidate_ref
36
+ report = {"n_exact_dropped": int(n_exact), "n_phash_dropped": int(n_phash),
37
+ "n_final": int(len(final))}
38
+ return final, report
src/data/provenance/journal.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/shards/journal.py
2
+ """Append-only shard progress journal. States are monotonic; deletion decisions
3
+ downstream depend on the DURABLY persisted state (fsync per append), never on
4
+ in-memory state. A compact parquet snapshot can be regenerated at any time."""
5
+ import json
6
+ import os
7
+ import time
8
+
9
+ STATES = ("planned", "writing", "verified", "indexed", "raw_cleaned", "remote_confirmed")
10
+ _ORD = {s: i for i, s in enumerate(STATES)}
11
+
12
+
13
+ class Journal:
14
+ def __init__(self, run_root):
15
+ self.path = os.path.join(run_root, "journal.jsonl")
16
+ self._latest = {}
17
+ if os.path.exists(self.path):
18
+ with open(self.path, encoding="utf-8") as f:
19
+ for line in f:
20
+ if line.strip():
21
+ rec = json.loads(line)
22
+ self._latest[rec["shard_name"]] = rec
23
+
24
+ def state(self, shard_name):
25
+ rec = self._latest.get(shard_name)
26
+ return rec["state"] if rec else None
27
+
28
+ def load(self):
29
+ return dict(self._latest)
30
+
31
+ def advance(self, shard_name, state, **info):
32
+ if state not in _ORD:
33
+ raise ValueError(f"unknown state {state!r}")
34
+ cur = self.state(shard_name)
35
+ if cur is not None and _ORD[state] <= _ORD[cur]:
36
+ raise ValueError(f"non-monotonic {shard_name}: {cur} -> {state}")
37
+ rec = {"shard_name": shard_name, "state": state, "ts": time.time(), **info}
38
+ prev = self._latest.get(shard_name, {})
39
+ rec = {**{k: v for k, v in prev.items() if k not in ("state", "ts")}, **rec}
40
+ with open(self.path, "a", encoding="utf-8") as f:
41
+ f.write(json.dumps(rec) + "\n")
42
+ f.flush()
43
+ os.fsync(f.fileno())
44
+ self._latest[shard_name] = rec
45
+
46
+ def snapshot_parquet(self, path):
47
+ import pandas as pd
48
+ pd.DataFrame(list(self._latest.values())).to_parquet(path, index=False)
src/data/provenance/manifest_build.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/manifest_build.py
2
+ import os
3
+ import pandas as pd
4
+ from PIL import Image
5
+ from data.provenance.families import classify_family
6
+
7
+ MANIFEST_COLUMNS = ["path","label","source_dataset","generator_id","family",
8
+ "real_source","native_w","native_h","jpeg_bytes_per_pixel",
9
+ "ref_type","mount_slug","rel_path","split"]
10
+
11
+ def jpeg_bytes_per_pixel(path) -> float:
12
+ try:
13
+ size = os.path.getsize(path)
14
+ with Image.open(path) as im:
15
+ w, h = im.size
16
+ return float(size) / float(w * h) if w and h else float("nan")
17
+ except Exception:
18
+ return float("nan")
19
+
20
+
21
+ _EXT = {"JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp", "BMP": ".bmp", "GIF": ".gif", "TIFF": ".tiff"}
22
+
23
+ def save_source_image(raw: bytes, path_noext: str):
24
+ """Persist a source image blob VERBATIM (no re-encode) and return (path, w, h).
25
+
26
+ Re-encoding is the wrong move for this corpus: JPEG bakes a scored corruption into
27
+ the 'clean' ground truth; PNG re-encode of already-JPEG sources ~4x's disk (blows the
28
+ typical working-disk limits) for ZERO fidelity gain — the JPEG artifacts are already in
29
+ the source pixels. Writing the source bytes unchanged adds nothing and preserves the
30
+ original size. We sniff format+dims via PIL (header only, no full decode) to pick the
31
+ extension and fill native_w/h; a corrupt/undecodable blob raises (caller skips it)."""
32
+ import io
33
+ with Image.open(io.BytesIO(raw)) as im: # header sniff; verify() would consume it
34
+ fmt, (w, h) = im.format, im.size
35
+ im.verify() # raises on a truncated/corrupt blob
36
+ ext = _EXT.get(fmt, ".img")
37
+ path = path_noext + ext
38
+ os.makedirs(os.path.dirname(path), exist_ok=True)
39
+ with open(path, "wb") as f:
40
+ f.write(raw)
41
+ return path, w, h
42
+
43
+ def assemble_manifest(records: pd.DataFrame) -> pd.DataFrame:
44
+ df = records.copy()
45
+ df["family"] = df["generator_id"].fillna("none").map(classify_family)
46
+ # Resolution columns come from the sources (Tasks 3/4); default if a record lacks them.
47
+ if "ref_type" not in df.columns:
48
+ df["ref_type"] = "materialized"
49
+ if "mount_slug" not in df.columns:
50
+ df["mount_slug"] = ""
51
+ if "rel_path" not in df.columns:
52
+ df["rel_path"] = df["path"].map(lambda p: os.path.basename(str(p)))
53
+ df["split"] = ""
54
+ return df[MANIFEST_COLUMNS]
src/data/provenance/orchestrate.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/shards/orchestrate.py
2
+ """Per-shard transactions: plan -> write+verify -> journal -> delete raws. Raw files
3
+ for a shard are deleted ONLY after the journal durably records the verified shard,
4
+ so conversion never holds a second corpus-sized copy and a crash at any point leaves
5
+ either the raws or a verified shard (never neither)."""
6
+ import hashlib
7
+ import os
8
+ import shutil
9
+ import zipfile
10
+
11
+ import pandas as pd
12
+
13
+ from data.provenance import diskcheck
14
+ from data.provenance.writer import plan_shards, write_shard
15
+
16
+
17
+ def _sha256_file(path):
18
+ h = hashlib.sha256()
19
+ with open(path, "rb") as f:
20
+ for chunk in iter(lambda: f.read(1 << 20), b""):
21
+ h.update(chunk)
22
+ return h.hexdigest()
23
+
24
+
25
+ def resume_pending(journal, shard_dir, raw_root):
26
+ """Startup/retry recovery: drop stale .partial files, and finish raw deletion for
27
+ shards journaled verified/indexed but not raw_cleaned. A digest mismatch on a
28
+ completed shard is a hard failure."""
29
+ for f in os.listdir(shard_dir):
30
+ if f.endswith(".partial"):
31
+ os.remove(os.path.join(shard_dir, f))
32
+ for name, rec in journal.load().items():
33
+ if rec["state"] in ("verified", "indexed"):
34
+ path = os.path.join(shard_dir, name)
35
+ if _sha256_file(path) != rec["sha256"]:
36
+ raise AssertionError(f"digest mismatch on completed shard {name}")
37
+ _delete_members(raw_root, rec.get("members", []))
38
+ if rec["state"] == "verified":
39
+ journal.advance(name, "indexed")
40
+ journal.advance(name, "raw_cleaned")
41
+
42
+
43
+ def _delete_members(raw_root, member_names):
44
+ freed = 0
45
+ for rel in member_names:
46
+ p = os.path.join(raw_root, rel.replace("/", os.sep))
47
+ if os.path.exists(p):
48
+ freed += os.path.getsize(p)
49
+ os.remove(p)
50
+ # prune empty dirs bottom-up
51
+ for dp, _dirs, _files in os.walk(raw_root, topdown=False):
52
+ if dp != raw_root and not os.listdir(dp):
53
+ os.rmdir(dp)
54
+ return freed
55
+
56
+
57
+ def shard_corpus(manifest_df, raw_root, shard_dir, journal, target_bytes=1 << 30,
58
+ prefix="corpus", log=print):
59
+ """Shard every manifest row (transactionally, resumable via the journal) and
60
+ return (candidates_df, shards_df). candidates_df drops `path` (raws are deleted)
61
+ and carries shard_name/member_name/byte_size/crc32 with ref_type='shard'."""
62
+ df = manifest_df.copy()
63
+ if "byte_size" not in df.columns:
64
+ # Resume path: raws of completed shards are deleted — recover their sizes from
65
+ # the verified shards' central directories so packing stays deterministic.
66
+ sizes = {}
67
+ if os.path.isdir(shard_dir):
68
+ for f in os.listdir(shard_dir):
69
+ if f.endswith(".shard"):
70
+ with zipfile.ZipFile(os.path.join(shard_dir, f)) as zf:
71
+ for zi in zf.infolist():
72
+ sizes[zi.filename] = zi.file_size
73
+ def _size(row):
74
+ if os.path.exists(row["path"]):
75
+ return os.path.getsize(row["path"])
76
+ if row["rel_path"] in sizes:
77
+ return sizes[row["rel_path"]]
78
+ raise FileNotFoundError(
79
+ f"no raw file and no shard member for {row['rel_path']}")
80
+ df["byte_size"] = df.apply(_size, axis=1)
81
+ plan = plan_shards(df, target_bytes=target_bytes, prefix=prefix)
82
+ shard_records = []
83
+ for name, group in plan.groupby("shard_name", sort=True):
84
+ state = journal.state(name)
85
+ if state == "raw_cleaned":
86
+ # resumed past this shard: recover per-member CRCs + ledger row from the
87
+ # verified archive itself (raws are gone; the shard is the source of truth)
88
+ with zipfile.ZipFile(os.path.join(shard_dir, name)) as zf:
89
+ crc = {zi.filename: zi.CRC for zi in zf.infolist()}
90
+ plan.loc[group.index, "crc32"] = group["member_name"].map(crc)
91
+ rec0 = journal.load()[name]
92
+ shard_records.append({"shard_name": name, "n_members": len(crc),
93
+ "payload_bytes": int(group["byte_size"].sum()),
94
+ "zip_bytes": os.path.getsize(os.path.join(shard_dir, name)),
95
+ "sha256": rec0["sha256"]})
96
+ continue
97
+ members = list(zip(group["member_name"], group["path"]))
98
+ diskcheck.assert_headroom(shard_dir, needed_bytes=int(group["byte_size"].sum()))
99
+ if state is None:
100
+ journal.advance(name, "planned", n_members=len(members))
101
+ journal.advance(name, "writing")
102
+ rec = write_shard(os.path.join(shard_dir, name), members)
103
+ journal.advance(name, "verified", sha256=rec["sha256"],
104
+ members=[m["member_name"] for m in rec["members"]])
105
+ journal.advance(name, "indexed")
106
+ freed = _delete_members(raw_root, [m for m, _ in members])
107
+ free_after = shutil.disk_usage(shard_dir).free
108
+ journal.advance(name, "raw_cleaned", bytes_reclaimed=freed,
109
+ free_after=free_after)
110
+ log(f"[shard] {name}: {rec['n_members']} members, "
111
+ f"{rec['zip_bytes']} zip bytes, freed {freed}, free {free_after}")
112
+ crc = {m["member_name"]: m["crc32"] for m in rec["members"]}
113
+ plan.loc[group.index, "crc32"] = group["member_name"].map(crc)
114
+ shard_records.append({"shard_name": name, "n_members": rec["n_members"],
115
+ "payload_bytes": rec["payload_bytes"],
116
+ "zip_bytes": rec["zip_bytes"], "sha256": rec["sha256"]})
117
+ cand = plan.drop(columns=["path"]).copy()
118
+ cand["ref_type"] = "shard"
119
+ cand["crc32"] = cand["crc32"].astype("int64")
120
+ return cand.reset_index(drop=True), pd.DataFrame(
121
+ shard_records, columns=["shard_name", "n_members", "payload_bytes",
122
+ "zip_bytes", "sha256"])
src/data/provenance/push.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/provenance/push.py
2
+ """Manifest write-out. The corpus is built and consumed locally; there is no
3
+ remote publishing step."""
4
+ import os
5
+
6
+
7
+ def write_manifest(df, out_dir) -> str:
8
+ os.makedirs(out_dir, exist_ok=True)
9
+ path = os.path.join(out_dir, "manifest.parquet")
10
+ df.to_parquet(path, index=False)
11
+ return path
src/data/provenance/rangezip.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/rangezip.py
2
+ """Random-access reader for a REMOTE zip over HTTP byte ranges, using ONLY explicit
3
+ ranges (bytes=a-b) — never suffix ranges (bytes=-N), which ModelScope's file endpoint
4
+ rejects with 400 (remotezip uses suffix ranges, hence its failure here).
5
+
6
+ Why this works at all: a ZIP has a Central Directory at the END mapping every member
7
+ to a byte offset, and each member is compressed INDEPENDENTLY. So to pull one image out
8
+ of a 25 GB zip we: (1) range-read the tail to find + parse the central directory, then
9
+ (2) range-read just that member's local-header+data and inflate it. Transfer is ~the
10
+ member size, not the whole archive.
11
+
12
+ Only the subset of ZIP needed for WildFake is implemented: STORED (0) + DEFLATE (8),
13
+ 32-bit offsets, and ZIP64 end-of-central-directory when present. No encryption.
14
+ """
15
+ import struct
16
+ import zlib
17
+ import urllib.request
18
+
19
+ _EOCD_SIG = b"PK\x05\x06"
20
+ _EOCD64_LOC_SIG = b"PK\x06\x07"
21
+ _EOCD64_SIG = b"PK\x06\x06"
22
+ _CEN_SIG = b"PK\x01\x02"
23
+
24
+
25
+ class _ShortBuffer(Exception):
26
+ """Raised by _decode_at when the fetched buffer doesn't hold the full member; carries
27
+ the exact byte span now known so the caller re-fetches precisely (no truncation)."""
28
+ def __init__(self, needed):
29
+ self.needed = needed
30
+ super().__init__(f"buffer short, need {needed} bytes")
31
+
32
+
33
+ class RangeZip:
34
+ def __init__(self, url, headers=None, session=None):
35
+ self.url = url
36
+ self.headers = headers or {}
37
+ # CONNECTION REUSE: one persistent keep-alive session for ALL range reads to this
38
+ # zip's host, so the TLS handshake to ModelScope (China, ~2s) is paid ONCE instead
39
+ # of per request. This is the dominant cost — reusing the SSL/TCP connection is what
40
+ # makes many range reads cheap. Falls back to urllib (still per-request) if requests
41
+ # is unavailable.
42
+ self._session = session if session is not None else self._make_session()
43
+ self.size = self._content_length()
44
+ self._entries = self._read_central_directory() # name -> (offset, comp_size, method)
45
+
46
+ @staticmethod
47
+ def _make_session():
48
+ try:
49
+ import requests
50
+ from requests.adapters import HTTPAdapter
51
+ s = requests.Session()
52
+ # a small pool of persistent connections, reused across range GETs (keep-alive).
53
+ ad = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=3)
54
+ s.mount("https://", ad); s.mount("http://", ad)
55
+ return s
56
+ except Exception:
57
+ return None # urllib fallback (no pooling)
58
+
59
+ # --- HTTP helpers (explicit ranges only) --------------------------------
60
+ def _content_length(self):
61
+ if self._session is not None:
62
+ r = self._session.head(self.url, headers=self.headers, allow_redirects=True, timeout=60)
63
+ r.raise_for_status()
64
+ return int(r.headers["Content-Length"])
65
+ req = urllib.request.Request(self.url, method="HEAD", headers=self.headers)
66
+ with urllib.request.urlopen(req) as r:
67
+ return int(r.headers["Content-Length"])
68
+
69
+ def _get_range(self, lo, hi):
70
+ """Fetch bytes [lo, hi] inclusive via an explicit Range header, over the persistent
71
+ keep-alive session (reused SSL/TCP connection) when available."""
72
+ h = dict(self.headers); h["Range"] = f"bytes={lo}-{hi}"
73
+ if self._session is not None:
74
+ r = self._session.get(self.url, headers=h, timeout=300)
75
+ r.raise_for_status()
76
+ return r.content
77
+ req = urllib.request.Request(self.url, headers=h)
78
+ with urllib.request.urlopen(req) as r:
79
+ return r.read()
80
+
81
+ # Tail window read to bootstrap the directory. Sized so the CENTRAL DIRECTORY of most
82
+ # zips fits inside it (WildFake: reals 3-17MB, typical fakes 5-16MB), letting us slice
83
+ # the directory straight out of this one read — no second GET. Only the giant
84
+ # 200k+-member zips (CD 20-52MB) need a follow-up, and then only for the missing prefix.
85
+ _TAIL = 16 * 1024 * 1024
86
+
87
+ # --- central directory --------------------------------------------------
88
+ def _read_central_directory(self):
89
+ tail_len = min(self._TAIL, self.size)
90
+ tail_start = self.size - tail_len
91
+ tail = self._get_range(tail_start, self.size - 1) # ONE read, big window
92
+ i = tail.rfind(_EOCD_SIG)
93
+ if i < 0:
94
+ raise ValueError("EOCD not found (not a zip or comment too large)")
95
+ eocd = tail[i:i + 22]
96
+ cd_size = struct.unpack("<I", eocd[12:16])[0]
97
+ cd_off = struct.unpack("<I", eocd[16:20])[0]
98
+ total = struct.unpack("<H", eocd[10:12])[0]
99
+ # ZIP64 path: offsets == 0xFFFFFFFF -> find the ZIP64 EOCD via its locator.
100
+ if cd_off == 0xFFFFFFFF or cd_size == 0xFFFFFFFF or total == 0xFFFF:
101
+ j = tail.rfind(_EOCD64_LOC_SIG, 0, i)
102
+ if j < 0:
103
+ raise ValueError("ZIP64 locator not found")
104
+ eocd64_off = struct.unpack("<Q", tail[j + 8:j + 16])[0]
105
+ # the ZIP64 EOCD is itself usually in our tail window; slice if so, else fetch.
106
+ if eocd64_off >= tail_start:
107
+ head = tail[eocd64_off - tail_start: eocd64_off - tail_start + 56]
108
+ else:
109
+ head = self._get_range(eocd64_off, eocd64_off + 56 - 1)
110
+ if head[:4] != _EOCD64_SIG:
111
+ raise ValueError("bad ZIP64 EOCD signature")
112
+ cd_size = struct.unpack("<Q", head[40:48])[0]
113
+ cd_off = struct.unpack("<Q", head[48:56])[0]
114
+ # If the whole central directory is already inside the tail we read, SLICE it —
115
+ # no second request. Otherwise fetch only the prefix we're missing and prepend.
116
+ if cd_off >= tail_start:
117
+ cd = tail[cd_off - tail_start: cd_off - tail_start + cd_size]
118
+ else:
119
+ missing = self._get_range(cd_off, tail_start - 1) # only the part below the tail
120
+ cd = missing + tail[:max(0, (cd_off + cd_size) - tail_start)]
121
+ return self._parse_central(cd)
122
+
123
+ def _parse_central(self, cd):
124
+ entries, p = {}, 0
125
+ while p + 4 <= len(cd) and cd[p:p + 4] == _CEN_SIG:
126
+ method = struct.unpack("<H", cd[p + 10:p + 12])[0]
127
+ comp_size = struct.unpack("<I", cd[p + 20:p + 24])[0]
128
+ n = struct.unpack("<H", cd[p + 28:p + 30])[0]
129
+ m = struct.unpack("<H", cd[p + 30:p + 32])[0]
130
+ k = struct.unpack("<H", cd[p + 32:p + 34])[0]
131
+ lho = struct.unpack("<I", cd[p + 42:p + 46])[0]
132
+ name = cd[p + 46:p + 46 + n].decode("utf-8", "replace")
133
+ extra = cd[p + 46 + n:p + 46 + n + m]
134
+ # ZIP64 extra: patch 0xFFFFFFFF comp_size / offset from the extra field.
135
+ if comp_size == 0xFFFFFFFF or lho == 0xFFFFFFFF:
136
+ comp_size, lho = _zip64_patch(extra, comp_size, lho)
137
+ entries[name] = (lho, comp_size, method)
138
+ p += 46 + n + m + k
139
+ return entries
140
+
141
+ # --- public API ---------------------------------------------------------
142
+ def namelist(self):
143
+ return list(self._entries)
144
+
145
+ def image_members(self, exts=(".png", ".jpg", ".jpeg", ".webp", ".bmp")):
146
+ return [n for n in self._entries if n.lower().endswith(exts)]
147
+
148
+ # Generous slack past comp_size to cover the LOCAL header (30 fixed + name + extra
149
+ # fields). The local extra field can carry ZIP64/alignment/other records and is NOT
150
+ # bounded by 256B — a too-small slack truncates the compressed stream (zlib error -5).
151
+ # 4KB comfortably covers any realistic local header; _decode_at still VERIFIES the
152
+ # buffer actually holds the full member and raises if not (never silently truncates).
153
+ _LH_SLACK = 30 + 4096
154
+
155
+ def _decode_at(self, buf, base, lho, comp_size, method):
156
+ """Decode ONE member from an in-memory buffer `buf` that starts at absolute byte
157
+ `base` and contains the member's local header (at `lho`) + its compressed data.
158
+ Raises _ShortBuffer if the buffer doesn't hold the full compressed payload — the
159
+ caller then re-fetches the exact span (never returns a truncated stream)."""
160
+ off = lho - base
161
+ if off + 30 > len(buf):
162
+ raise _ShortBuffer(30) # not even the fixed header present
163
+ n = struct.unpack("<H", buf[off + 26:off + 28])[0]
164
+ m = struct.unpack("<H", buf[off + 28:off + 30])[0]
165
+ start = off + 30 + n + m
166
+ end = start + comp_size
167
+ if end > len(buf):
168
+ raise _ShortBuffer(30 + n + m + comp_size) # exact span now known
169
+ raw = buf[start:end]
170
+ if method == 0:
171
+ return raw
172
+ if method == 8:
173
+ return zlib.decompress(raw, -15)
174
+ raise ValueError(f"unsupported compression method {method}")
175
+
176
+ def read(self, name):
177
+ """DECOMPRESSED bytes of one member via a range fetch of its local header + data.
178
+ Retries once with the exact span if the first (slack-estimated) window was short."""
179
+ lho, comp_size, method = self._entries[name]
180
+ span = self._LH_SLACK + comp_size
181
+ window = self._get_range(lho, min(lho + span, self.size) - 1)
182
+ try:
183
+ return self._decode_at(window, lho, lho, comp_size, method)
184
+ except _ShortBuffer as e:
185
+ window = self._get_range(lho, min(lho + e.needed, self.size) - 1)
186
+ return self._decode_at(window, lho, lho, comp_size, method)
187
+
188
+ def read_many(self, names, max_gap=1 << 20, workers=16):
189
+ """Fetch MANY members by COALESCING nearby members into contiguous segments, then
190
+ fetching those segments IN PARALLEL over the shared keep-alive session.
191
+
192
+ The parallelism is at the SEGMENT level (not the zip level): a single giant WildFake
193
+ zip has hundreds of segments, and with only ~12 zips, per-zip concurrency alone
194
+ barely distributes — one big zip would serialize. Running its segment GETs across
195
+ `workers` threads (all reusing one pooled SSL connection) is what actually saturates
196
+ the link. Returns {name: decompressed_bytes}.
197
+ """
198
+ from concurrent.futures import ThreadPoolExecutor
199
+ # each member spans [lho, lho + LH_SLACK + comp_size); sort by start offset
200
+ spans = []
201
+ for nm in names:
202
+ lho, comp_size, method = self._entries[nm]
203
+ end = lho + self._LH_SLACK + comp_size
204
+ spans.append((lho, end, nm, comp_size, method))
205
+ spans.sort()
206
+
207
+ # 1) build the list of coalesced segments (each = one contiguous range + its members)
208
+ segments = []
209
+ i = 0
210
+ while i < len(spans):
211
+ seg_lo, seg_hi = spans[i][0], spans[i][1]
212
+ members = [spans[i]]
213
+ j = i + 1
214
+ while j < len(spans) and spans[j][0] <= seg_hi + max_gap:
215
+ seg_hi = max(seg_hi, spans[j][1])
216
+ members.append(spans[j])
217
+ j += 1
218
+ segments.append((seg_lo, min(seg_hi, self.size), members))
219
+ i = j
220
+
221
+ # 2) fetch + decode each segment; segments run in PARALLEL over the pooled session
222
+ out, lock = {}, __import__("threading").Lock()
223
+ def _do(seg):
224
+ seg_lo, seg_hi, members = seg
225
+ buf = self._get_range(seg_lo, seg_hi - 1) # ONE GET per segment
226
+ local = {}
227
+ for lho, _end, nm, comp_size, method in members:
228
+ try:
229
+ local[nm] = self._decode_at(buf, seg_lo, lho, comp_size, method)
230
+ except _ShortBuffer:
231
+ local[nm] = self.read(nm)
232
+ with lock:
233
+ out.update(local)
234
+ if workers > 1 and len(segments) > 1:
235
+ with ThreadPoolExecutor(max_workers=workers) as ex:
236
+ list(ex.map(_do, segments))
237
+ else:
238
+ for seg in segments:
239
+ _do(seg)
240
+ return out
241
+
242
+
243
+ def _zip64_patch(extra, comp_size, lho):
244
+ p = 0
245
+ while p + 4 <= len(extra):
246
+ hid, sz = struct.unpack("<HH", extra[p:p + 4])
247
+ body = extra[p + 4:p + 4 + sz]
248
+ if hid == 0x0001: # ZIP64 extended info
249
+ q = 0
250
+ # order: uncompressed, compressed, offset — only present when the
251
+ # corresponding central field was 0xFFFFFFFF. We only need comp+offset.
252
+ # Uncompressed (skip if present): can't know without the orig flags, so we
253
+ # conservatively read fields in order and assign the ones we need.
254
+ vals = []
255
+ while q + 8 <= len(body):
256
+ vals.append(struct.unpack("<Q", body[q:q + 8])[0]); q += 8
257
+ # Heuristic matching WildFake zips: [uncomp, comp, offset] present.
258
+ if len(vals) >= 3:
259
+ comp_size, lho = vals[1], vals[2]
260
+ elif len(vals) == 2:
261
+ comp_size, lho = vals[0], vals[1]
262
+ elif len(vals) == 1:
263
+ lho = vals[0]
264
+ break
265
+ p += 4 + sz
266
+ return comp_size, lho
src/data/provenance/resolver.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/resolver.py
2
+ import os
3
+ from PIL import Image
4
+
5
+ # Optional per-slug marker sub-path used to DISCOVER a mounted dataset's root when
6
+ # Mount layouts are non-uniform across hosts. The corpus is ALL-MATERIALIZED from
7
+ # official sources (commercial-safe), so no default mount markers are configured;
8
+ # the mechanism is retained for optional reference-mounting of any future
9
+ # commercial-safe corpus directory (pass `markers={slug: marker}` to SourceResolver).
10
+ MOUNT_MARKERS: dict[str, str] = {}
11
+
12
+ class SourceResolver:
13
+ def __init__(self, input_root="./data/raw", fakes_slug="track5-corpus-fakes", markers=None):
14
+ self.input_root = input_root
15
+ self.fakes_slug = fakes_slug
16
+ self.markers = markers if markers is not None else MOUNT_MARKERS
17
+ self._root_cache = {}
18
+
19
+ def discover_root(self, slug) -> str:
20
+ if slug in self._root_cache:
21
+ return self._root_cache[slug]
22
+ marker = self.markers[slug]
23
+ # find a dir whose path ends with the marker; its PARENT-of-marker is the root
24
+ marker_norm = marker.replace("\\", "/")
25
+ for dp, dirs, _ in os.walk(self.input_root):
26
+ rel = os.path.relpath(dp, self.input_root)
27
+ rel_norm = rel.replace("\\", "/")
28
+ # Anchor to a path-component boundary so marker "dataset" does NOT match
29
+ # ".../coco-2017-dataset" (no "/dataset" segment) while ".../lhq-1024/dataset" does.
30
+ if rel_norm == marker_norm or rel_norm.endswith("/" + marker_norm):
31
+ # root = dp with the marker suffix stripped, so root/rel_path (which
32
+ # includes the marker prefix) resolves
33
+ depth = len(marker.replace("\\", "/").split("/"))
34
+ root = dp
35
+ for _ in range(depth):
36
+ root = os.path.dirname(root)
37
+ self._root_cache[slug] = root
38
+ return root
39
+ raise FileNotFoundError(f"resolver: could not discover mount root for {slug} (marker {marker}) under {self.input_root}")
40
+
41
+ def _path(self, row) -> str:
42
+ if row["ref_type"] == "mount":
43
+ return os.path.join(self.discover_root(row["mount_slug"]), row["rel_path"])
44
+ if row["ref_type"] == "materialized":
45
+ return os.path.join(self.input_root, self.fakes_slug, row["rel_path"])
46
+ raise ValueError(f"unknown ref_type {row['ref_type']}")
47
+
48
+ def resolve(self, row) -> Image.Image:
49
+ p = self._path(row)
50
+ if not os.path.exists(p):
51
+ raise FileNotFoundError(f"resolver: {p} (ref_type={row['ref_type']}, slug={row.get('mount_slug')})")
52
+ with Image.open(p) as im:
53
+ return im.convert("RGB").copy()
src/data/provenance/run_curator.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/run_curator.py
2
+ import argparse, os, tempfile
3
+ import pandas as pd
4
+ from data.provenance import sources, subsample, manifest_build, audit, push, dedup_audit
5
+ from data.provenance.splitter import assign_mixed_split
6
+
7
+
8
+ def _write_stage_manifest(frame, out_dir, stage):
9
+ path = os.path.join(out_dir, f"manifest_{stage}.parquet")
10
+ frame.to_parquet(path, index=False)
11
+ counts = (frame.groupby(["label", "source_dataset", "family"], dropna=False)
12
+ .size().rename("count").reset_index())
13
+ counts.insert(0, "stage", stage)
14
+ return counts
15
+
16
+ def _write_build_metadata(tables_dir, **config):
17
+ import json, subprocess, time, uuid
18
+ try:
19
+ commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True,
20
+ text=True).stdout.strip()
21
+ except Exception: # noqa: BLE001
22
+ commit = "unknown"
23
+ meta = {"code_commit": commit or "unknown", "build_id": uuid.uuid4().hex,
24
+ "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
25
+ "schema_version": 1, "license_policy": "commercial-only-2026-08-30",
26
+ "config": config}
27
+ with open(os.path.join(tables_dir, "build_metadata.json"), "w") as f:
28
+ json.dump(meta, f, indent=1)
29
+
30
+
31
+ def run_curator(per_family_cap=30000, seed=0, out_dir=None, input_root="./data/raw",
32
+ do_push=False, allow_shortcut=False, corpus_sources=None, max_examine=None,
33
+ progress_cb=None, sharded=False, run_root=None):
34
+ out_dir = out_dir or tempfile.mkdtemp(prefix="corpus_")
35
+ # Corpus is ALL-MATERIALIZED from official commercial-safe sources (JPEG q95) into
36
+ # a versioned corpus directory. Sources emit BOTH real (label 0) and fake (label 1)
37
+ # rows — e.g. SID_Set/WildFake/OmniFake carry reals; CommForensics is fake-only.
38
+ fakes_out_dir = os.path.join(out_dir, "track5-corpus-fakes")
39
+ if corpus_sources is None:
40
+ corpus_sources = sources.default_corpus_sources(per_family_cap)
41
+ if sharded:
42
+ # Sharded tail (spec: sharded-corpus-pipeline): candidate corpus only — NO split
43
+ # (the finalizer assigns splits after official-holdout subtraction), deterministic
44
+ # ZIP shards + audit tables in a flat publish dir, raws deleted per verified shard.
45
+ from data.provenance import cleanup as _cleanup, diskcheck, journal as _journal
46
+ from data.provenance.orchestrate import shard_corpus
47
+ root = _cleanup.make_run_root(run_root or tempfile.gettempdir())
48
+ tables_dir = os.path.join(root, "publish")
49
+ os.makedirs(tables_dir, exist_ok=True)
50
+ print("[disk]", diskcheck.fs_stats(diskcheck.default_watch_paths(root)), flush=True)
51
+ else:
52
+ tables_dir = out_dir
53
+ raw = sources.pull_all(corpus_sources, fakes_out_dir, per_family_cap, seed,
54
+ max_examine=max_examine, progress_cb=progress_cb)
55
+ # --- pure logic ---
56
+ # assemble_manifest FIRST so `family` (and split/ref columns) exist before
57
+ # fake-family capping, which must never touch explicitly licensed real quotas.
58
+ mani = manifest_build.assemble_manifest(raw)
59
+ stage_counts = [_write_stage_manifest(mani, tables_dir, "raw")]
60
+ mani = subsample.cap_fake_families(mani, per_family_cap, seed)
61
+ stage_counts.append(_write_stage_manifest(mani, tables_dir, "capped"))
62
+ # Dedup AFTER cap (bounds the O(n^2) pHash scan to the capped set) but BEFORE
63
+ # splitting. OmniFake overlaps
64
+ # WildFake/GenImage; near-identical images collapse to one, priority-ordered.
65
+ # provenance = the {sampled, after_dedup, dropped_as_dupe} attribution deliverable.
66
+ mani, provenance = dedup_audit.dedup_manifest(mani)
67
+ stage_counts.append(_write_stage_manifest(mani, tables_dir, "deduped"))
68
+ pd.concat(stage_counts, ignore_index=True).to_parquet(
69
+ os.path.join(tables_dir, "stage_counts.parquet"), index=False)
70
+ prov_path = os.path.join(tables_dir, "provenance.parquet")
71
+ provenance.to_parquet(prov_path, index=False)
72
+ if sharded:
73
+ # GC checkpoint: cap/dedup rejects are unreferenced now — reclaim before sharding
74
+ from data.provenance.cleanup import reclaim_unreferenced
75
+ n_gc, b_gc = reclaim_unreferenced(fakes_out_dir, set(mani["rel_path"]))
76
+ print(f"[gc] post-dedup reclaim: {n_gc} files, {b_gc} bytes", flush=True)
77
+ j = _journal.Journal(root)
78
+ mani_ns = mani.drop(columns=["split"], errors="ignore")
79
+ cand, shards_df = shard_corpus(mani_ns, fakes_out_dir, tables_dir, j)
80
+ cand.to_parquet(os.path.join(tables_dir, "manifest_candidates.parquet"), index=False)
81
+ shards_df.to_parquet(os.path.join(tables_dir, "shards.parquet"), index=False)
82
+ _write_build_metadata(tables_dir, per_family_cap=per_family_cap, seed=seed)
83
+ print("[disk]", diskcheck.fs_stats(diskcheck.default_watch_paths(root)), flush=True)
84
+ return tables_dir
85
+ mani = assign_mixed_split(mani, val_frac=0.15, seed=seed)
86
+ # --- audit gate ---
87
+ rep = audit.audit_corpus(mani)
88
+ print(rep["report"]); print("balance:", rep["class_balance"]); print("families:", rep["per_family_counts"])
89
+ if rep["flag"] and not allow_shortcut:
90
+ raise SystemExit("DDA shortcut flagged — mitigate and re-run, or pass --allow-shortcut.")
91
+ path = push.write_manifest(mani, out_dir)
92
+ print("manifest:", path)
93
+ print("provenance:", prov_path); print(provenance.to_string(index=False))
94
+ return out_dir
95
+
96
+ def main():
97
+ ap = argparse.ArgumentParser()
98
+ ap.add_argument("--per-family-cap", type=int, default=30000)
99
+ ap.add_argument("--seed", type=int, default=0)
100
+ ap.add_argument("--out-dir", default=None)
101
+ ap.add_argument("--push", action="store_true")
102
+ ap.add_argument("--allow-shortcut", action="store_true")
103
+ ap.add_argument("--max-examine", type=int, default=None,
104
+ help="cap examples streamed per source (bandwidth guard)")
105
+ a = ap.parse_args()
106
+ run_curator(per_family_cap=a.per_family_cap, seed=a.seed, out_dir=a.out_dir,
107
+ do_push=a.push, allow_shortcut=a.allow_shortcut, max_examine=a.max_examine)
108
+
109
+ if __name__ == "__main__":
110
+ main()
src/data/provenance/selection.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/selection.py
2
+ """The DECISION between the two passes: given a scan inventory (what we HAVE), compute
3
+ a selection plan (what we'll PULL). Pure logic on the scan DataFrame — no I/O — so the
4
+ sampling policy is reviewable before any image is touched.
5
+
6
+ inventory columns: [source_dataset, group_key, label, count] (from DatasetSource.scan)
7
+ plan: {(source_dataset, group_key, label): quota} (consumed by DatasetSource.pull)
8
+ """
9
+
10
+
11
+ def plan_selection(inventory, per_group=None, balance=False):
12
+ """Cap each (source, group, label) at min(available, per_group); optionally balance
13
+ classes by trimming the majority class to the minority total.
14
+
15
+ per_group=None -> take everything available (quota = count). balance=True -> after
16
+ capping, scale the larger class's quotas down proportionally so n_fake == n_real
17
+ (as evenly as possible), preserving per-group proportions."""
18
+ plan = {}
19
+ for r in inventory.itertuples():
20
+ avail = int(r.count)
21
+ quota = avail if per_group is None else min(avail, per_group)
22
+ if quota > 0:
23
+ plan[(r.source_dataset, r.group_key, int(r.label))] = quota
24
+
25
+ if not balance:
26
+ return plan
27
+
28
+ n = {0: 0, 1: 0}
29
+ for (_s, _g, lbl), q in plan.items():
30
+ n[lbl] += q
31
+ if n[0] == 0 or n[1] == 0:
32
+ return plan # can't balance with a missing class; leave as-is
33
+ keep, drop = (0, 1) if n[0] < n[1] else (1, 0)
34
+ target = n[keep] # trim the majority class down to the minority total
35
+ # Distribute `target` across the majority groups proportionally, largest-remainder.
36
+ maj = {k: v for k, v in plan.items() if k[2] == drop}
37
+ total = sum(maj.values())
38
+ scaled, rema = {}, []
39
+ for k, v in maj.items():
40
+ exact = v * target / total
41
+ base = int(exact)
42
+ scaled[k] = base
43
+ rema.append((exact - base, k))
44
+ short = target - sum(scaled.values())
45
+ for _frac, k in sorted(rema, reverse=True)[:short]:
46
+ scaled[k] += 1
47
+ for k, v in scaled.items():
48
+ if v > 0:
49
+ plan[k] = v
50
+ else:
51
+ plan.pop(k, None) # a group scaled to 0 drops out
52
+ return plan
src/data/provenance/sources.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-dataset source abstraction. Each dataset = a DatasetSource subclass; the
2
+ base class owns the shared record-building + per-family cap loop (DRY). Heavy
3
+ imports (datasets, huggingface_hub) are deferred inside _iter_examples so importing
4
+ this module stays cheap and pytest-collection-safe (no downloads at import)."""
5
+ import abc, os
6
+ import pandas as pd
7
+ from data.provenance.families import classify_family
8
+ from data.provenance.manifest_build import jpeg_bytes_per_pixel, save_source_image
9
+
10
+ RECORD_COLUMNS = ["path","label","source_dataset","generator_id",
11
+ "real_source","native_w","native_h","jpeg_bytes_per_pixel",
12
+ "ref_type","mount_slug","rel_path"]
13
+
14
+ # --- Defensive field readers (shared) ---------------------------------------
15
+ # The commercial-safe sources have heterogeneous / under-documented schemas
16
+ # (ModelScope cards are bare, OmniFake's viewer is broken). These readers coerce
17
+ # a raw example into (label, generator, PIL image) across the likely field names,
18
+ # so subclasses' _to_fields stay small and testable with hand-built dicts.
19
+
20
+ def _as_pil(obj):
21
+ """Coerce a PIL image / bytes / {'bytes'|'path'} dict / path-string -> PIL.Image."""
22
+ import io
23
+ from PIL import Image
24
+ if isinstance(obj, Image.Image):
25
+ return obj
26
+ if isinstance(obj, (bytes, bytearray)):
27
+ return Image.open(io.BytesIO(bytes(obj)))
28
+ if isinstance(obj, dict):
29
+ if obj.get("bytes"):
30
+ return Image.open(io.BytesIO(obj["bytes"]))
31
+ if obj.get("path"):
32
+ return Image.open(obj["path"])
33
+ if isinstance(obj, str):
34
+ return Image.open(obj)
35
+ raise TypeError(f"_as_pil: cannot coerce {type(obj)!r} to a PIL image")
36
+
37
+ def _read_label(ex, default=None):
38
+ """Best-effort 0(real)/1(fake) from an example. Returns None (sentinel) when NO
39
+ label field is resolvable — callers MUST skip such rows, never default them to
40
+ fake (defaulting a real image into the fake class poisons the real/fake boundary)."""
41
+ for k in ("label", "labels", "target", "is_fake", "fake"):
42
+ if ex.get(k) is not None:
43
+ v = ex[k]
44
+ if isinstance(v, str):
45
+ s = v.strip().lower()
46
+ if s in ("real", "0", "authentic", "genuine"): return 0
47
+ if s in ("fake", "1", "synthetic", "ai", "generated"): return 1
48
+ try:
49
+ return 1 if int(v) != 0 else 0
50
+ except (TypeError, ValueError):
51
+ pass
52
+ for k in ("category", "class", "folder", "subset", "type"):
53
+ v = ex.get(k)
54
+ if isinstance(v, str):
55
+ return 0 if v.strip().lower() in ("real", "0_real", "authentic") else 1
56
+ return default
57
+
58
+ def _read_generator(ex):
59
+ """Best-effort generator/model name string (feeds classify_family)."""
60
+ for k in ("generator", "model", "model_name", "gen_model", "method",
61
+ "source_model", "generator_name", "label_name", "category",
62
+ "class", "folder"):
63
+ v = ex.get(k)
64
+ if isinstance(v, str) and v:
65
+ return v
66
+ return ""
67
+
68
+ def _read_image(ex):
69
+ """Return the raw image object from an example (caller passes to _as_pil)."""
70
+ for k in ("image", "img", "image_data", "image_bytes", "picture"):
71
+ if ex.get(k) is not None:
72
+ return ex[k]
73
+ raise KeyError("_read_image: no image field in example")
74
+
75
+ class DatasetSource(abc.ABC):
76
+ name: str = "source"
77
+
78
+ @abc.abstractmethod
79
+ def _iter_examples(self):
80
+ """Yield raw examples (network I/O lives here, in subclasses)."""
81
+ @abc.abstractmethod
82
+ def _to_fields(self, example) -> dict:
83
+ """Map one example -> {label, generator_id, real_source, image: PIL.Image}."""
84
+
85
+ def _group_key(self, f) -> str:
86
+ """The split/selection group for a materialized-field dict: family for fakes
87
+ (label 1), real_source for reals (label 0). This is the unit scan counts and
88
+ plan_selection quotas over."""
89
+ return classify_family(f["generator_id"]) if f["label"] == 1 else f["real_source"]
90
+
91
+ def scan(self, max_examine=None) -> pd.DataFrame:
92
+ """PASS 1 — inventory only, NO images saved. Returns a DataFrame with columns
93
+ [source_dataset, group_key, label, count]: how many examples this source has per
94
+ (group, label). Default implementation walks _iter_examples()/_to_fields() but
95
+ decodes NO pixels beyond what _to_fields needs; subclasses with cheap metadata
96
+ (e.g. WildFake's label CSVs) should OVERRIDE scan() to avoid touching images.
97
+ max_examine bounds examined examples for streaming sources."""
98
+ counts = {}
99
+ for n_seen, ex in enumerate(self._iter_examples()):
100
+ if max_examine is not None and n_seen >= max_examine:
101
+ break
102
+ f = self._to_fields(ex)
103
+ if f is None:
104
+ continue
105
+ key = (self._group_key(f), int(f["label"]))
106
+ counts[key] = counts.get(key, 0) + 1
107
+ rows = [{"source_dataset": self.name, "group_key": g, "label": l, "count": c}
108
+ for (g, l), c in counts.items()]
109
+ return pd.DataFrame(rows, columns=["source_dataset", "group_key", "label", "count"])
110
+
111
+ def pull(self, out_dir, seed=0, per_family_cap=None, max_examine=None,
112
+ selection=None, progress_cb=None, progress_every=100) -> pd.DataFrame:
113
+ # PASS 2 — materialize. Two quota modes:
114
+ # selection (two-pass): {(source, group_key, label): quota} from plan_selection.
115
+ # per_family_cap (legacy one-pass): flat cap per family.
116
+ # max_examine bounds examples EXAMINED (streamed) — the bandwidth guard for a
117
+ # streaming source that would otherwise transfer bytes of every example.
118
+ # progress_cb({done, ...}) fires every progress_every materialized rows so a long
119
+ # network stream (e.g. CF PublicEval) is monitorable, same contract WildFake uses.
120
+ os.makedirs(out_dir, exist_ok=True)
121
+ counts, rows, n_skipped = {}, [], 0
122
+ import time as _t
123
+ _t0 = _t.time()
124
+ for n_seen, ex in enumerate(self._iter_examples()):
125
+ if max_examine is not None and n_seen >= max_examine:
126
+ break
127
+ f = self._to_fields(ex)
128
+ if f is None: # unknown-label row: SKIP, do not
129
+ n_skipped += 1 # materialize and do not count vs cap
130
+ continue # (defaulting to fake would poison reals)
131
+ # Counter keyed by GROUP_KEY (family for fakes, real_source for reals) so
132
+ # per-group quotas don't collide — all reals share fam=="unknown", so a
133
+ # fam-keyed counter would merge distinct real sources under one quota.
134
+ gkey = self._group_key(f)
135
+ if selection is not None:
136
+ quota = selection.get((self.name, gkey, int(f["label"])), 0)
137
+ if counts.get(gkey, 0) >= quota:
138
+ continue
139
+ elif (f["label"] == 1 and per_family_cap is not None
140
+ and counts.get(gkey, 0) >= per_family_cap):
141
+ continue
142
+ elif (f["label"] == 0 and getattr(self, "real_cap", None) is not None
143
+ and counts.get(gkey, 0) >= self.real_cap):
144
+ # per-real-source cap (label 0): bounds a materialized real source (e.g. CF
145
+ # PublicEval reals) without a two-pass selection. Keyed by real_source group,
146
+ # so distinct real sources each get their own quota. None => uncapped (SID
147
+ # controls its own real count via its n_real quota, so it leaves this unset).
148
+ continue
149
+ # Namespace rel_path by source name so it round-trips through the pushed fakes
150
+ # dataset root: file lands at <out_dir>/<name>/fakes/x.<ext> and the resolver
151
+ # opens input_root/<fakes_slug>/<name>/fakes/x.<ext>.
152
+ # PREFER VERBATIM source bytes (f["raw"], set by _to_fields when it has the
153
+ # original blob) — no re-encode, so no baked-in corruption and no PNG bloat. Only
154
+ # fall back to a lossless-PNG re-encode when a source hands us a PIL with no bytes
155
+ # (e.g. SID); JPEG re-encode is never used (it would corrupt the clean ground truth).
156
+ base = f"{self.name}/fakes/{self.name}_{gkey}_{counts.get(gkey,0)}"
157
+ p_noext = os.path.join(out_dir, base)
158
+ raw = f.get("raw")
159
+ if raw is not None:
160
+ p, w, h = save_source_image(raw, p_noext)
161
+ else:
162
+ from PIL import Image
163
+ p = p_noext + ".png"
164
+ os.makedirs(os.path.dirname(p), exist_ok=True)
165
+ f["image"].convert("RGB").save(p, format="PNG", optimize=False)
166
+ with Image.open(p) as im:
167
+ w, h = im.size
168
+ rel = os.path.relpath(p, out_dir).replace(os.sep, "/")
169
+ rows.append({"path": p, "label": f["label"], "source_dataset": self.name,
170
+ "generator_id": f["generator_id"], "real_source": f["real_source"],
171
+ "native_w": w, "native_h": h, "jpeg_bytes_per_pixel": jpeg_bytes_per_pixel(p),
172
+ "ref_type": "materialized", "mount_slug": "", "rel_path": rel})
173
+ counts[gkey] = counts.get(gkey, 0) + 1
174
+ if progress_cb is not None and len(rows) % progress_every == 0:
175
+ dt = max(_t.time() - _t0, 1e-9)
176
+ progress_cb({"done": len(rows), "imgs_per_s": len(rows) / dt})
177
+ print(f"[{self.name}] skipped {n_skipped} unlabeled examples")
178
+ return pd.DataFrame(rows, columns=RECORD_COLUMNS)
179
+
180
+ def _iter_parquet_rows(fileobj, columns, batch_size=256):
181
+ """Yield rows (dicts of `columns`) from a parquet fileobj ONE BATCH AT A TIME.
182
+
183
+ Peak RAM is a single `batch_size`-row batch, NEVER a whole row-group. This is the
184
+ load-bearing memory bound: CommForensics PublicEval shards are packed as a SINGLE
185
+ row-group of up to 6.5GB (8640 rows of raw image blobs), so HF `datasets`
186
+ streaming (which materializes a whole row-group's Arrow table before yielding row 1)
187
+ climbs to ~30GB and OOM-kills a modest host. pyarrow.iter_batches decodes only the
188
+ requested columns and only one batch of rows at a time -> bounded, provably."""
189
+ import pyarrow.parquet as pq
190
+ pf = pq.ParquetFile(fileobj)
191
+ for batch in pf.iter_batches(batch_size=batch_size, columns=columns):
192
+ d = batch.to_pydict()
193
+ for i in range(len(batch)):
194
+ yield {c: d[c][i] for c in columns}
195
+
196
+
197
+ class CommunityForensicsSource(DatasetSource):
198
+ name = "commforensics"
199
+ # LICENSE-MANDATORY (docs/specs/DATASET_LICENSES.md): materialize ONLY the
200
+ # redistribution-safe PublicEval split (schema: image_data / model_name / nsfw_flag /
201
+ # real_source / label "1:Fake 0:Real"). The Systematic / Manual / Commercial splits are
202
+ # FORBIDDEN (fakes paired with reals of unknown redistributability). We read PublicEval's
203
+ # parquet shards DIRECTLY via pyarrow (batch-bounded RAM, see _iter_parquet_rows) rather
204
+ # than HF `datasets` streaming, which OOMs on the single-6.5GB-row-group shard layout.
205
+ # The separate -Small repo is DROPPED: 760GB of 4GB single-row-group shards, same hazard
206
+ # for marginal gain — PublicEval alone is 21 generators (Dalle2/3, FLUX, MidjourneyV5/6,
207
+ # Firefly, Imagen3, Ideogram, kandinsky, stable_cascade, GALIP, DFGAN, ...) + reals.
208
+ HF_REPO = "OwensLab/CommunityForensics"
209
+ HF_SPLIT_DIR = "data/publicEval" # PublicEval shards only
210
+ N_SHARDS = 6
211
+ # HELD-OUT GENERATORS: CF PublicEval contains Dalle3 + Imagen3, the exact families the
212
+ # OOS holdout quarantines (WildFake DALLE3/GigaGAN/Imagen). Training on them would make the
213
+ # "unseen generator" holdout no longer unseen -> excluded here to keep the eval honest.
214
+ # (Dalle2 is kept — a genuinely different, non-held-out model.)
215
+ #
216
+ # CANONICAL match: exact case-sensitive strings were brittle — "Imagen" vs
217
+ # "Imagen3", "DALLE3" vs "Dalle3" vs "dall-e 3" would slip through and leak the holdout. We
218
+ # normalize (lowercase, strip non-alphanumerics) and match the held-out FAMILY prefixes, so
219
+ # every spelling/version of a held-out family is caught. GigaGAN added even though CF likely
220
+ # lacks it — a no-op if absent, a leak-stopper if present.
221
+ _HELD_OUT_CANON = ("dalle3", "imagen", "gigagan") # family prefixes, canonicalized
222
+ _COLUMNS = ["image_data", "model_name", "nsfw_flag", "real_source", "label"]
223
+
224
+ def __init__(self, families_wanted=None, include_reals=True, real_cap=None,
225
+ include_fakes=True):
226
+ self.families_wanted = families_wanted
227
+ self.include_reals = bool(include_reals)
228
+ self.include_fakes = bool(include_fakes) # False = reals-only (additive CF-reals set)
229
+ # real_cap: per-real-source quota (label 0), enforced in the base pull(). Bounds a
230
+ # materialized CF PublicEval real pull without a two-pass selection. None = uncapped.
231
+ self.real_cap = int(real_cap) if real_cap is not None else None
232
+
233
+ @staticmethod
234
+ def _canon(name):
235
+ """Lowercase + strip non-alphanumerics so 'DALL-E 3'/'Dalle3'/'dalle_3' all -> 'dalle3'."""
236
+ import re
237
+ return re.sub(r"[^a-z0-9]", "", str(name or "").lower())
238
+
239
+ def _is_held_out(self, model_name):
240
+ """True if model_name canonicalizes to (or starts with) any held-out family. 'Imagen3'
241
+ and 'Imagen' both match family 'imagen'; 'Dalle3' matches 'dalle3' but 'Dalle2' does NOT
242
+ (canon 'dalle2' has no held-out prefix)."""
243
+ c = self._canon(model_name)
244
+ return any(c.startswith(h) for h in self._HELD_OUT_CANON)
245
+
246
+ def _shards(self):
247
+ """Yield (path, cleanup) per PublicEval parquet shard, smallest-first (a capped
248
+ pull fills its quota from the fewest shards). Each shard is DOWNLOADED TO LOCAL
249
+ DISK first (hf_hub_download: sequential streamed GET with built-in retry/resume),
250
+ then read locally — NOT read over an fsspec https handle. Parquet's columnar
251
+ layout makes iter_batches seek around the file; over HTTP that is hundreds of
252
+ random range reads that time out (FSTimeoutError observed). One sequential
253
+ download to disk + local batched read is fast, resilient, and still RAM-bounded.
254
+ cleanup() deletes the shard so peak disk is ONE shard (~3-6GB), not all 27GB.
255
+ Overridden in tests."""
256
+ from huggingface_hub import hf_hub_download
257
+ from concurrent.futures import ThreadPoolExecutor
258
+ # smallest-first by known size ordering (shard 0 = 3.3GB ... shard 2 = 6.5GB)
259
+ order = (0, 4, 5, 1, 3, 2)[:self.N_SHARDS]
260
+
261
+ def _dl(i):
262
+ return hf_hub_download(self.HF_REPO,
263
+ f"{self.HF_SPLIT_DIR}/HFCF_PublicEval_{i}.parquet",
264
+ repo_type="dataset")
265
+
266
+ # PREFETCH: download shard N+1 in the background while shard N is being read/decoded,
267
+ # overlapping the multi-minute download with processing. Peak disk = 2 shards (~12GB,
268
+ # safe) since cleanup deletes shard N before N+2 starts. One prefetch worker (serial
269
+ # downloads still saturate the HF pipe; more would just contend).
270
+ with ThreadPoolExecutor(max_workers=1) as pool:
271
+ fut = pool.submit(_dl, order[0])
272
+ for k, i in enumerate(order):
273
+ path = fut.result() # current shard (already downloading)
274
+ if k + 1 < len(order):
275
+ fut = pool.submit(_dl, order[k + 1]) # kick off the NEXT while we process
276
+ def _cleanup(p=path):
277
+ try: os.remove(p)
278
+ except OSError: pass
279
+ yield path, _cleanup
280
+
281
+ def _iter_examples(self):
282
+ for path, cleanup in self._shards():
283
+ try:
284
+ with open(path, "rb") as f:
285
+ for ex in _iter_parquet_rows(f, self._COLUMNS):
286
+ if ex.get("nsfw_flag"):
287
+ continue # NSFW: never materialize
288
+ if not self.include_reals and _read_label(ex) == 0:
289
+ continue # aggregate real license chain unverified
290
+ if not self.include_fakes and _read_label(ex) == 1:
291
+ continue # reals-only (additive CF-reals dataset)
292
+ if self._is_held_out(ex.get("model_name")):
293
+ continue # keep the OOS holdout genuinely unseen
294
+ yield ex
295
+ finally:
296
+ cleanup() # bounded disk: one shard at a time
297
+ def _to_fields(self, ex):
298
+ # Record per-image model_name (OpenRAIL-M pass-through). Respect the label field
299
+ # so any real rows in a safe subset land as label 0.
300
+ lbl = _read_label(ex)
301
+ if lbl is None: # unresolvable label -> skip (never default fake)
302
+ return None
303
+ img = ex.get("image_data") if ex.get("image_data") is not None else ex.get("image")
304
+ # image_data is the ORIGINAL encoded blob (bytes or {"bytes":...}); pass it through as
305
+ # `raw` so pull() writes it VERBATIM (no re-encode, no baked corruption, no PNG bloat).
306
+ raw = img if isinstance(img, (bytes, bytearray)) else (img.get("bytes") if isinstance(img, dict) else None)
307
+ return {"label": lbl,
308
+ "generator_id": "none" if lbl == 0 else (ex.get("model_name", "") or ""),
309
+ "real_source": (ex.get("real_source", "none") or "none") if lbl == 0 else "none",
310
+ "image": _as_pil(img), "raw": raw}
311
+
312
+ class SIDSetSource(DatasetSource):
313
+ name = "sid_set"
314
+ def __init__(self, n_real, n_fake):
315
+ self.quotas = (int(n_real), int(n_fake))
316
+
317
+ def _iter_examples(self):
318
+ from datasets import Image as HFImage, load_dataset
319
+ dataset = load_dataset("saberzl/SID_Set", split="train", streaming=True)
320
+ dataset = dataset.cast_column("image", HFImage(decode=False))
321
+ seen = [0, 0]
322
+ for ex in dataset:
323
+ lbl = int(ex["label"])
324
+ if lbl not in (0, 1) or seen[lbl] >= self.quotas[lbl]:
325
+ continue
326
+ seen[lbl] += 1
327
+ yield ex
328
+ if tuple(seen) == self.quotas:
329
+ break
330
+
331
+ def _to_fields(self, ex):
332
+ lbl = int(ex["label"])
333
+ image = ex["image"]
334
+ raw = image.get("bytes") if isinstance(image, dict) else None
335
+ return {"label": lbl, "generator_id": "none" if lbl == 0 else "sid_synth",
336
+ "real_source": ex.get("real_source", "sid"), "image": _as_pil(image),
337
+ "raw": raw}
338
+
339
+ # WildFakeSource lives in data.curator.wildfake — it fetches ONLY selected members via
340
+ # RangeZip (range requests over the ModelScope zips), never whole 25-54GB archives, and
341
+ # handles the mapped per-generator taxonomy + §5.4 held-out exclusion. Re-exported here
342
+ # so default_corpus_sources / callers keep one import site.
343
+ from data.provenance.wildfake import WildFakeSource # noqa: E402,F401
344
+
345
+ class OmniFakeSource(DatasetSource):
346
+ """OmniFake (MIT) via the OFFICIAL HF datasets loader: MoeNew/OmniFake (streaming).
347
+ 45 generators + real. It is imagefolder-style: the class/label encodes real-vs-fake
348
+ and the folder name IS the generator (e.g. FLUX_Dev, DALLE_3, StyleGAN_3, real)."""
349
+ name = "omnifake"
350
+ hf_id = "MoeNew/OmniFake"
351
+ def __init__(self, split="train"): self.split = split
352
+ def _iter_examples(self):
353
+ from datasets import load_dataset
354
+ ds = load_dataset(self.hf_id, split=self.split, streaming=True)
355
+ # imagefolder datasets expose an int `label` + a `label`->foldername mapping via
356
+ # features; surface the folder name so classify_family sees the generator string.
357
+ names = None
358
+ try:
359
+ feat = ds.features.get("label")
360
+ names = getattr(feat, "names", None)
361
+ except Exception:
362
+ names = None
363
+ for ex in ds:
364
+ if names is not None and isinstance(ex.get("label"), int):
365
+ ex = dict(ex); ex["label_name"] = names[ex["label"]]
366
+ yield ex
367
+ def _to_fields(self, ex):
368
+ # The folder name is the ground truth for BOTH real/fake and generator.
369
+ name = ex.get("label_name") or _read_generator(ex)
370
+ is_real = isinstance(name, str) and name.strip().lower() in ("real", "0_real", "authentic")
371
+ return {"label": 0 if is_real else 1,
372
+ "generator_id": "none" if is_real else (name or ""),
373
+ "real_source": "omnifake" if is_real else "none",
374
+ "image": _as_pil(_read_image(ex))}
375
+
376
+ # NOTE: DeepFace_part_v4 was RETIRED (probe finding): it is 15x HiFiMask_*.zip = face
377
+ # ANTI-SPOOFING / presentation-attack data (real faces behind masks), NOT AIGC
378
+ # fully-synthetic-vs-genuine. WildFake's Images/Real/ffhq.zip already covers real faces
379
+ # Apache-clean, so DeepFace added no fitting signal. Do not re-add without a real->fake
380
+ # face-generation source.
381
+
382
+ class CifakeSource(DatasetSource):
383
+ """CIFAKE (MIT), aux 32x32. Reads a local CIFAKE directory
384
+ (birdy654/cifake-real-and-ai-generated-synthetic-images). Label from the
385
+ FAKE/REAL folder name. No network in _iter_examples — walks the mounted dir."""
386
+ name = "cifake"
387
+ def __init__(self, root, split="train"):
388
+ self.root = root; self.split = split
389
+ def _iter_examples(self):
390
+ import glob
391
+ base = os.path.join(self.root, self.split)
392
+ # CIFAKE layout: <root>/<split>/{REAL,FAKE}/*.jpg
393
+ for cls in ("FAKE", "REAL"):
394
+ for fp in glob.glob(os.path.join(base, cls, "*")):
395
+ yield {"path": fp, "cls": cls}
396
+ def _to_fields(self, ex):
397
+ is_real = ex["cls"].strip().upper() == "REAL"
398
+ return {"label": 0 if is_real else 1,
399
+ "generator_id": "none" if is_real else "cifake_synth",
400
+ "real_source": "cifake" if is_real else "none",
401
+ "image": _as_pil(ex["path"])}
402
+
403
+ # NOTE: real images now come from the SAME materialized commercial-safe sources that
404
+ # provide fakes (SID_Set / WildFake / OmniFake carry real rows at label 0; WildFake's
405
+ # Images/Real/{coco,ffhq,celebahq,afhq,church}.zip are the primary diverse-real supply).
406
+ # There is no separate real-pool recipe source — each source's _to_fields sets label +
407
+ # real_source.
408
+
409
+ # Generators held OUT of training (the OOS holdout axes). Their fakes never enter the
410
+ # training corpus; the holdout build (WildFakeSource(holdout_only=True)) selects them +
411
+ # COCO-val2017 reals. DALLE3 is the spec §5.4 set; GigaGAN (GAN) + Imagen (research
412
+ # diffusion) add two more OOS axes (user decision, 2026-08-29).
413
+ WILDFAKE_HELD_OUT = frozenset({"DALLE3", "GigaGAN", "Imagen"})
414
+
415
+ def default_corpus_sources(per_family_cap):
416
+ """Commercial-safe training defaults: aggregate sources contribute fakes only;
417
+ SID_Set supplies independently capped real and fully-synthetic rows."""
418
+ return [
419
+ WildFakeSource(per_gen_cap=per_family_cap, held_out=WILDFAKE_HELD_OUT,
420
+ exclude_val2017=True, include_reals=False),
421
+ CommunityForensicsSource(include_reals=False),
422
+ SIDSetSource(n_real=per_family_cap, n_fake=per_family_cap),
423
+ ]
424
+
425
+ def wildfake_holdout_source(per_gen_cap=3000, spec_exact=False):
426
+ """The §5.4 OOS holdout: held-out generators' fakes (DALLE3/GigaGAN/Imagen) +
427
+ COCO-val2017 reals. Materialized separately, NEVER trained on.
428
+
429
+ per_gen_cap (default 3000) bounds each generator — without it this pulls ALL held-out
430
+ images (DALLE3 8843 + GigaGAN 27610 + Imagen 47435 = ~84k), slow and unnecessary for a
431
+ stable AUC.
432
+
433
+ spec_exact=True materializes the MANDATED §5.4 set: DALLE3 = all 8843, COCO-val2017 = all
434
+ 4998 (via per-group overrides = None → take-all), while GigaGAN/Imagen stay bounded at
435
+ per_gen_cap (they are our bonus OOS axes, not part of §5.4). Use this for the graded number."""
436
+ overrides = None
437
+ if spec_exact:
438
+ # None => take-all for the §5.4-mandated groups; GigaGAN/Imagen fall back to per_gen_cap.
439
+ overrides = {"DALLE3": None, "coco": None}
440
+ return WildFakeSource(per_gen_cap=per_gen_cap, held_out=WILDFAKE_HELD_OUT,
441
+ exclude_val2017=True, holdout_only=True, per_gen_overrides=overrides)
442
+
443
+ def official_holdout_source():
444
+ """The OFFICIAL holdout artifact (spec: sharded-corpus-pipeline): ALL WildFake
445
+ COCO-val2017 reals + ALL DALLE3 fakes. No quota or cap code path for these two
446
+ groups (None == take-all); every other held-out group is excluded (0)."""
447
+ return WildFakeSource(per_gen_cap=None, held_out=WILDFAKE_HELD_OUT,
448
+ exclude_val2017=True, holdout_only=True,
449
+ per_gen_overrides={"DALLE3": None, "coco": None,
450
+ "GigaGAN": 0, "Imagen": 0})
451
+
452
+
453
+ def cf_reals_source(real_cap=None):
454
+ """ADDITIVE dataset source: Community Forensics PublicEval REALS only (fake rows
455
+ excluded), bounded per real_source by real_cap. These are reals from the SAME
456
+ acquisition pipeline as the CF fakes already in the corpus — the fix for the source
457
+ confound (val real-side AUC 0.618: the detector partly reads SID-pipeline = real).
458
+ PublicEval is the redistribution-safe split (user-approved 2026-08-31); held-out
459
+ generators are irrelevant to reals but the filter is harmless. Published as its own
460
+ dataset (track5-cf-reals) and mounted ALONGSIDE the existing corpus — no rebuild,
461
+ no overwrite; the training box's RoutingResolver discovers it by shard prefix."""
462
+ return CommunityForensicsSource(include_reals=True, include_fakes=False,
463
+ real_cap=real_cap, families_wanted=None)
464
+
465
+
466
+ def sid_fakes_source(n_fake=2000):
467
+ """ADDITIVE dataset source: SID_Set FAKES only (n_real=0), tagged generator_id
468
+ 'sid_synth'. The other half of the source-confound fix: putting fakes into the
469
+ real-dominant SID pipeline breaks 'SID-pipeline = real' (symmetric to cf_reals_source
470
+ breaking 'CF-pipeline = fake'). This is also the SID-fake scale fix (the live corpus
471
+ has only 199 SID fakes — the family-cap bug; the per-generator cap fix lands only on a
472
+ rebuild, but this additive dataset delivers the full 2000 WITHOUT one). Published as
473
+ track5-sid-fakes, mounted alongside the corpus. SIDSetSource's own quota yields exactly
474
+ n_fake fakes (no cap_fake_families in the standalone build path)."""
475
+ return SIDSetSource(n_real=0, n_fake=int(n_fake))
476
+
477
+
478
+ def oos_validation_source(n_per_gen=3000):
479
+ """Separate OOS validation artifact: fixed samples of GigaGAN + Imagen. These are
480
+ validation data, not official holdout, and never enter training."""
481
+ return WildFakeSource(per_gen_cap=None, held_out=WILDFAKE_HELD_OUT,
482
+ exclude_val2017=True, holdout_only=True,
483
+ per_gen_overrides={"GigaGAN": int(n_per_gen),
484
+ "Imagen": int(n_per_gen),
485
+ "DALLE3": 0, "coco": 0})
486
+
487
+
488
+ def pull_all(sources, out_dir, per_family_cap=None, seed=0, max_examine=None,
489
+ selection=None, progress_cb=None) -> pd.DataFrame:
490
+ # Pass the SAME dataset root to every source; pull() namespaces by source name via
491
+ # rel_path (<name>/fakes/...), so files land at out_dir/<name>/fakes/... and round-trip.
492
+ # progress_cb is forwarded only to sources whose pull() accepts it (WildFake); others
493
+ # ignore it. Kept per-source so long network stages stream live progress to wandb.
494
+ import inspect
495
+ parts = []
496
+ for s in sources:
497
+ kw = dict(seed=seed, per_family_cap=per_family_cap,
498
+ max_examine=max_examine, selection=selection)
499
+ if progress_cb is not None and "progress_cb" in inspect.signature(s.pull).parameters:
500
+ kw["progress_cb"] = progress_cb
501
+ parts.append(s.pull(out_dir, **kw))
502
+ return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame(columns=RECORD_COLUMNS)
src/data/provenance/splitter.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/manifest/splitter.py
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ def assign_split(df, mode, val_frac, group_col, holdout_values, seed):
6
+ df = df.copy()
7
+ if mode == "holdout":
8
+ df["split"] = np.where(df["generator_id"].isin(holdout_values), "val", "train")
9
+ return df
10
+ if mode == "grouped":
11
+ rng = np.random.default_rng(seed)
12
+ # each group's stratum label = most common label within the group
13
+ grp_label = df.groupby(group_col)["label"].agg(lambda s: s.mode().iloc[0])
14
+ val_groups = set()
15
+ for lbl, groups_in_stratum in grp_label.groupby(grp_label):
16
+ gvals = np.asarray(groups_in_stratum.index) # ndarray -> safe to shuffle
17
+ rng.shuffle(gvals)
18
+ n = len(gvals)
19
+ n_val = max(1, int(round(n * val_frac)))
20
+ if n >= 2:
21
+ n_val = min(n_val, n - 1) # keep >=1 in train per label
22
+ val_groups.update(gvals[:n_val])
23
+ df["split"] = np.where(df[group_col].isin(val_groups), "val", "train")
24
+ return df
25
+ raise ValueError(f"unknown split mode {mode}")
26
+
27
+
28
+ def assign_mixed_split(df, val_frac=0.15, seed=0):
29
+ """Group-hold out fake families while randomly splitting a single-source real pool.
30
+
31
+ SID reals all have ``real_source='sid'``. Treating that one source as a grouped unit
32
+ would put every real in one split. Fakes still need family-level isolation, while
33
+ genuine SID rows can be deterministically sampled because there is no second real
34
+ source whose acquisition signature could leak across the boundary.
35
+ """
36
+ if not 0 < val_frac < 1:
37
+ raise ValueError("val_frac must be between 0 and 1")
38
+
39
+ out = df.copy()
40
+ out["split"] = "train"
41
+ rng = np.random.default_rng(seed)
42
+
43
+ real_indices = out.index[out["label"] == 0].to_numpy(copy=True)
44
+ rng.shuffle(real_indices)
45
+ if len(real_indices) >= 2:
46
+ n_real_val = min(max(1, int(round(len(real_indices) * val_frac))),
47
+ len(real_indices) - 1)
48
+ out.loc[real_indices[:n_real_val], "split"] = "val"
49
+
50
+ fake = out[out["label"] == 1].copy()
51
+ if not fake.empty:
52
+ fake["_family"] = fake["family"].astype(str).str.strip().str.lower()
53
+ if fake["_family"].nunique() >= 2:
54
+ fake = assign_split(fake, mode="grouped", val_frac=val_frac,
55
+ group_col="_family", holdout_values=None, seed=seed)
56
+ out.loc[fake.index, "split"] = fake["split"]
57
+ elif len(fake) >= 2:
58
+ fake_indices = fake.index.to_numpy(copy=True)
59
+ rng.shuffle(fake_indices)
60
+ n_fake_val = min(max(1, int(round(len(fake_indices) * val_frac))),
61
+ len(fake_indices) - 1)
62
+ out.loc[fake_indices[:n_fake_val], "split"] = "val"
63
+
64
+ for label in (0, 1):
65
+ label_rows = out[out["label"] == label]
66
+ if len(label_rows) >= 2 and set(label_rows["split"]) != {"train", "val"}:
67
+ raise RuntimeError(f"mixed split failed to retain label {label} in both splits")
68
+ return out
69
+
70
+
71
+ def assign_hybrid_split(df, grouped_frac=0.1, random_frac=0.1, seed=0):
72
+ """Per-epoch val = TWO slices measuring different things (user decision):
73
+ - val_grouped: whole generators/real-sources held out of train (cross-generator
74
+ generalization signal). group = generator_id for fakes, real_source for reals.
75
+ - val_random: a stratified-by-label random slice of the REMAINING rows
76
+ (in-distribution recall signal).
77
+ The rest is train. Returns df with split in {train, val_grouped, val_random}.
78
+ Both val slices carry both classes (stratified); grouped holds whole groups out."""
79
+ df = df.copy().reset_index(drop=True)
80
+ rng = np.random.default_rng(seed)
81
+ # CASE-CANONICAL group key: the same generator under different casing (e.g. WildFake's
82
+ # "DALLE2" vs CommForensics' "Dalle2") must be ONE group, or the whole-generator holdout
83
+ # leaks — training on one casing while "holding out" the other is not a genuine unseen
84
+ # generator. Lowercase the grouping key so such variants collapse to one held-out group.
85
+ group = df.apply(lambda r: (r["generator_id"] if r["label"] == 1
86
+ else r["real_source"]).strip().lower(), axis=1)
87
+ df["_grp"] = group
88
+
89
+ # 1) val_grouped: hold out whole groups, ~grouped_frac of rows, stratified by the
90
+ # group's modal label so BOTH classes are represented in the grouped-val slice.
91
+ grp_label = df.groupby("_grp")["label"].agg(lambda s: s.mode().iloc[0])
92
+ val_groups = set()
93
+ for lbl, groups_in_stratum in grp_label.groupby(grp_label):
94
+ gvals = np.asarray(groups_in_stratum.index)
95
+ rng.shuffle(gvals)
96
+ n = len(gvals)
97
+ n_val = max(1, int(round(n * grouped_frac)))
98
+ if n >= 2:
99
+ n_val = min(n_val, n - 1) # keep >=1 group in train per label
100
+ val_groups.update(gvals[:n_val])
101
+ split = np.where(df["_grp"].isin(val_groups), "val_grouped", "train")
102
+
103
+ # 2) val_random: from the remaining TRAIN rows, take ~random_frac (of the whole df)
104
+ # at random, stratified by label so both classes appear.
105
+ n_random_target = int(round(len(df) * random_frac))
106
+ train_idx = np.where(split == "train")[0]
107
+ if n_random_target > 0 and len(train_idx) > 0:
108
+ labels = df["label"].to_numpy()
109
+ picked = []
110
+ for lbl in (0, 1):
111
+ pool = train_idx[labels[train_idx] == lbl]
112
+ k = min(len(pool), max(1, n_random_target // 2))
113
+ if len(pool):
114
+ picked.extend(rng.choice(pool, size=k, replace=False))
115
+ split[np.array(picked, dtype=int)] = "val_random"
116
+
117
+ df["split"] = split
118
+ return df.drop(columns=["_grp"])
src/data/provenance/subsample.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+
4
+ def cap_fake_families(df, cap, seed):
5
+ """Cap synthetic rows per GENERATOR while retaining every real row.
6
+
7
+ The cap exists to stop any single prolific generator from dominating the fake
8
+ class. It is keyed on ``generator_id`` — one quota per distinct generator — NOT on
9
+ the coarse ``family`` label.
10
+
11
+ Keying on ``family`` silently conflated every generator that classifies to
12
+ ``family="unknown"`` (SID's ``sid_synth`` plus the ~20k WildFake/CommForensics rows
13
+ whose model name matches no family rule) into ONE bucket, so a cap of N kept only N
14
+ rows *across all of them*. That is exactly how the 2,000 requested SID fakes
15
+ collapsed to 199 (2000 * 2000 / 22000 ≈ 182). Per-generator keying gives each
16
+ generator its own quota, preserving generator diversity (the unseen-generator axis
17
+ the hidden test grades) instead of pooling distinct models.
18
+
19
+ Real supply (label 0) is controlled by each licensed source's explicit quota and is
20
+ never capped here — reals all carry ``generator_id="none"``, so capping them would
21
+ collapse every genuine image into one bucket and recreate the scarcity this curator
22
+ is intended to avoid.
23
+ """
24
+ if cap is None:
25
+ return df.copy().reset_index(drop=True)
26
+ if cap <= 0:
27
+ raise ValueError("cap must be positive or None")
28
+
29
+ parts = [df[df["label"] == 0]]
30
+ for _gen, group in df[df["label"] == 1].groupby("generator_id", sort=False):
31
+ if len(group) > cap:
32
+ group = group.sample(n=cap, random_state=seed)
33
+ parts.append(group)
34
+ return pd.concat(parts).sort_index().reset_index(drop=True)
src/data/provenance/val_holdout.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Track5 §5.4 Validation/Demo Holdout — a QUARANTINED benchmark.
2
+
3
+ Spec (techjam spec, §5.4): a fixed subset of WildFake for participants to
4
+ *demonstrate* performance — "Do not use the following data during training."
5
+ Non-AIGC COCO val2017 = 4998 reals, AIGC DALL-E Advanced = 8843 fakes.
6
+
7
+ Design:
8
+ - The holdout is MATERIALIZED into its own dataset (`wildfake_valset`), separate
9
+ from the training corpus, and every row is stamped `split="val_holdout"` — a
10
+ distinct quarantine tag, NOT train/val.
11
+ - The load-bearing deliverable is `assert_no_holdout_in_training`: a hard guard
12
+ that raises if any holdout row (by split OR by source_dataset) reaches a
13
+ training manifest, so the demo data can never leak into training.
14
+
15
+ Same manifest schema as the training corpus (MANIFEST_COLUMNS) so the resolver
16
+ and eval harness operate on it unchanged. Import-safe: the ModelScope SDK import
17
+ is deferred inside WildFakeValSource._iter_examples.
18
+ """
19
+ import pandas as pd
20
+ from data.provenance.families import classify_family
21
+ from data.provenance.manifest_build import MANIFEST_COLUMNS
22
+ from data.provenance.sources import DatasetSource, RECORD_COLUMNS
23
+
24
+ # --- Quarantine constants (the whole point: this data never trains) ---------
25
+ VAL_HOLDOUT_SPLIT = "val_holdout"
26
+ WILDFAKE_VALSET_SOURCE = "wildfake_valset"
27
+ DALLE_ADVANCED_GENERATOR = "dalle_advanced" # classify_family(...) -> "dalle"
28
+ COCO_VAL2017_REAL_SOURCE = "coco_val2017"
29
+
30
+ # WildFake ModelScope dataset + the two partitions this holdout draws from.
31
+ # RUNTIME-VERIFY: the exact internal partition/subset identifiers below are the
32
+ # spec's human labels, NOT confirmed against WildFake's on-disk parquet schema
33
+ # (needs network). Confirm before the real pull; see WildFakeValSource.
34
+ WILDFAKE_MODELSCOPE_ID = "hy2628982280/WildFake"
35
+ COCO_VAL2017_PARTITION = "COCO_val2017" # RUNTIME-VERIFY exact name
36
+ DALLE_ADVANCED_PARTITION = "DALLE_Advanced" # RUNTIME-VERIFY exact name
37
+
38
+
39
+ def build_val_holdout_manifest(records: pd.DataFrame) -> pd.DataFrame:
40
+ """Pure logic: raw holdout records -> a MANIFEST_COLUMNS manifest.
41
+
42
+ Normalizes identity columns purely from `label` (0=COCO real, 1=DALL-E fake)
43
+ so the manifest is correct regardless of what the raw records carried, and
44
+ stamps every row with the quarantine split.
45
+ """
46
+ df = records.copy()
47
+ is_fake = df["label"].astype(int) == 1
48
+ df["source_dataset"] = WILDFAKE_VALSET_SOURCE
49
+ df["generator_id"] = is_fake.map(
50
+ lambda f: DALLE_ADVANCED_GENERATOR if f else "none")
51
+ df["real_source"] = is_fake.map(
52
+ lambda f: "none" if f else COCO_VAL2017_REAL_SOURCE)
53
+ df["family"] = df["generator_id"].map(classify_family)
54
+ df["split"] = VAL_HOLDOUT_SPLIT
55
+ # Backfill any resolution columns a raw record might lack, mirroring
56
+ # assemble_manifest's defensiveness.
57
+ if "ref_type" not in df.columns:
58
+ df["ref_type"] = "materialized"
59
+ if "mount_slug" not in df.columns:
60
+ df["mount_slug"] = ""
61
+ if "rel_path" not in df.columns:
62
+ df["rel_path"] = df["path"]
63
+ return df[MANIFEST_COLUMNS]
64
+
65
+
66
+ def _holdout_mask(df: pd.DataFrame) -> pd.Series:
67
+ """Row-wise True where a row is holdout — by split OR by source_dataset.
68
+ Both signals are checked so a row that lost one tag is still caught."""
69
+ mask = pd.Series(False, index=df.index)
70
+ if "split" in df.columns:
71
+ mask = mask | (df["split"] == VAL_HOLDOUT_SPLIT)
72
+ if "source_dataset" in df.columns:
73
+ mask = mask | (df["source_dataset"] == WILDFAKE_VALSET_SOURCE)
74
+ return mask
75
+
76
+
77
+ def assert_no_holdout_in_training(train_manifest_df: pd.DataFrame) -> None:
78
+ """HARD GUARD — raise ValueError if any quarantined holdout row is present.
79
+
80
+ Call this on any manifest destined for training. Detects the holdout by
81
+ `split == "val_holdout"` OR `source_dataset == "wildfake_valset"`."""
82
+ mask = _holdout_mask(train_manifest_df)
83
+ n = int(mask.sum())
84
+ if n:
85
+ raise ValueError(
86
+ f"Train-exclusion guard tripped: {n} quarantined val_holdout row(s) "
87
+ f"found in a training manifest (split=='{VAL_HOLDOUT_SPLIT}' or "
88
+ f"source_dataset=='{WILDFAKE_VALSET_SOURCE}'). §5.4 forbids training "
89
+ f"on the demo holdout.")
90
+
91
+
92
+ def filter_out_holdout(df: pd.DataFrame) -> pd.DataFrame:
93
+ """Defensive: drop any holdout rows, returning a guard-clean training df."""
94
+ return df[~_holdout_mask(df)].reset_index(drop=True)
95
+
96
+
97
+ def drop_holdout_image_leaks(train_df: pd.DataFrame, holdout_df: pd.DataFrame,
98
+ threshold: int = 6) -> pd.DataFrame:
99
+ """IMAGE-level leakage guard (complements the TAG-level assert_no_holdout_in_training).
100
+
101
+ TWO passes, cheap-and-exact first:
102
+ 1. EXACT identity: drop any train row whose stable id (img_id, else rel_path) matches a
103
+ holdout row's. This catches the case both sets draw the SAME source image (e.g. both pull
104
+ COCO reals from WildFake) — no file I/O, 100% reliable. This is the leak the pHash pass
105
+ under-caught in practice (95 shared COCO reals survived a threshold-6 pHash sweep because
106
+ phash requires reading every file, which is slow/fragile at 12k holdout images).
107
+ 2. RE-ENCODE identity: drop any remaining train row whose DCT pHash is within `threshold`
108
+ (Hamming) of ANY holdout image — catches a re-encoded copy that arrives with clean tags
109
+ and a different id. Reuses dedup_audit's pHash.
110
+ """
111
+ if len(train_df) == 0 or len(holdout_df) == 0:
112
+ return train_df.reset_index(drop=True)
113
+
114
+ def _idcol(df):
115
+ for c in ("img_id", "rel_path"):
116
+ if c in df.columns and df[c].notna().any():
117
+ return df[c].astype(str)
118
+ return df["path"].astype(str)
119
+
120
+ # --- pass 1: exact id match (no file reads) ---
121
+ hold_ids = set(_idcol(holdout_df))
122
+ tr_ids = _idcol(train_df)
123
+ keep_exact = ~tr_ids.isin(hold_ids)
124
+ df = train_df[keep_exact].reset_index(drop=True)
125
+ if len(df) == 0:
126
+ return df
127
+
128
+ # --- pass 2: pHash re-encode match on what remains ---
129
+ from data.provenance.dedup_audit import phash, hamming
130
+ holdout_hashes = [phash(p) for p in holdout_df["path"]]
131
+ def _leaks(p):
132
+ h = phash(p)
133
+ return any(hamming(h, hh) <= threshold for hh in holdout_hashes)
134
+ keep = ~df["path"].map(_leaks)
135
+ return df[keep].reset_index(drop=True)
136
+
137
+
138
+ class WildFakeValSource(DatasetSource):
139
+ """Materializes the §5.4 holdout from WildFake (ModelScope parquet) into its
140
+ own `wildfake_valset` dataset: COCO val2017 reals (label 0) + DALL-E Advanced
141
+ fakes (label 1). Heavy ModelScope import is DEFERRED inside _iter_examples
142
+ for import-safety (this module imports with no network / no SDK present)."""
143
+ name = WILDFAKE_VALSET_SOURCE
144
+
145
+ def __init__(self, coco_partition=COCO_VAL2017_PARTITION,
146
+ dalle_partition=DALLE_ADVANCED_PARTITION,
147
+ modelscope_id=WILDFAKE_MODELSCOPE_ID):
148
+ self.coco_partition = coco_partition
149
+ self.dalle_partition = dalle_partition
150
+ self.modelscope_id = modelscope_id
151
+
152
+ def _iter_examples(self):
153
+ # DEFERRED heavy import — keeps `import data.provenance.val_holdout` cheap
154
+ # and network-free (pytest-collection-safe).
155
+ from modelscope.msdatasets import MsDataset
156
+ # RUNTIME-VERIFY: WildFake's exact partition/subset names are not
157
+ # confirmed offline. We tag each example with which partition it came
158
+ # from so _to_fields can assign the label deterministically. If the
159
+ # subset_name identifiers below are wrong, this is the single place to
160
+ # fix — do NOT silently guess elsewhere.
161
+ for partition, tag in ((self.coco_partition, "real"),
162
+ (self.dalle_partition, "fake")):
163
+ ds = MsDataset.load(self.modelscope_id, subset_name=partition,
164
+ split="train")
165
+ for ex in ds:
166
+ ex = dict(ex)
167
+ ex["_holdout_tag"] = tag
168
+ yield ex
169
+
170
+ def _to_fields(self, ex) -> dict:
171
+ is_fake = ex.get("_holdout_tag") == "fake"
172
+ return {
173
+ "label": 1 if is_fake else 0,
174
+ "generator_id": DALLE_ADVANCED_GENERATOR if is_fake else "none",
175
+ "real_source": "none" if is_fake else COCO_VAL2017_REAL_SOURCE,
176
+ "image": ex["image"],
177
+ }
178
+
179
+
180
+ def evaluate_on_holdout(predict_fn, holdout_df, cfg, logger=None) -> dict:
181
+ """Thin wrapper: run the existing eval harness on the quarantined holdout.
182
+ No harness logic is duplicated — this only exists to name the intent."""
183
+ from eval.harness import evaluate
184
+ return evaluate(predict_fn, holdout_df, cfg, logger=logger)
185
+
186
+
187
+ __all__ = [
188
+ "VAL_HOLDOUT_SPLIT", "WILDFAKE_VALSET_SOURCE", "DALLE_ADVANCED_GENERATOR",
189
+ "COCO_VAL2017_REAL_SOURCE", "build_val_holdout_manifest",
190
+ "assert_no_holdout_in_training", "filter_out_holdout", "drop_holdout_image_leaks",
191
+ "WildFakeValSource", "evaluate_on_holdout", "RECORD_COLUMNS",
192
+ ]
src/data/provenance/wildfake.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/curator/wildfake.py
2
+ """WildFake member classification, derived from the mapped taxonomy
3
+ (docs/specs/wildfake_membermap.log). Given a zip's source-relative path and an image
4
+ member's in-zip path, return (label, generator, timing, real_source).
5
+
6
+ Two member-path shapes exist in WildFake:
7
+ STRUCTURED: <Family>/<Typical|Advanced>/<Generator>/... (DALLE, GAN_based, Other_based,
8
+ personalizedSD, SDwithAdaptor) -> generator = segment after the timing dir.
9
+ FLAT: <hash>.png or <ZipStem>/imgs/<hash>.png (ADM, VQDM, Imagen, Midjourney/SD
10
+ part zips) -> no per-generator subfolder, so generator = the ZIP's stem and
11
+ timing comes from the zip PATH (…/Typical|Advanced/… when present).
12
+ Reals live under Images/Real/<src>.zip -> label 0, real_source = zip stem.
13
+ """
14
+ import os
15
+
16
+ _TIMINGS = ("Typical", "Advanced")
17
+
18
+
19
+ def _zip_stem(zip_rel):
20
+ """'Images/Diffusion_based/DALLE.zip' -> 'DALLE'; multi-part
21
+ 'Images/Diffusion_based/Midjourney/Advanced/part_1.zip' -> 'Midjourney'."""
22
+ parts = zip_rel.split("/")
23
+ # For part-zips the generator is the dir two-or-more levels up (…/Midjourney/Advanced/part_1.zip
24
+ # -> Midjourney); for single zips it's the filename stem.
25
+ stem = os.path.splitext(parts[-1])[0]
26
+ if stem.startswith("part_") or stem.startswith("part"):
27
+ # walk up past the timing dir to the generator dir
28
+ for i in range(len(parts) - 2, 0, -1):
29
+ if parts[i] not in _TIMINGS:
30
+ return parts[i]
31
+ return stem
32
+
33
+
34
+ def _zip_timing(zip_rel):
35
+ """Timing from the zip PATH for flat part-zips (…/Advanced/part_1.zip -> 'Advanced')."""
36
+ for seg in zip_rel.split("/"):
37
+ if seg in _TIMINGS:
38
+ return seg
39
+ return None
40
+
41
+
42
+ def classify_member(zip_rel, member):
43
+ """-> {label, generator, timing, real_source}. Pure string logic (no I/O)."""
44
+ is_real = "/Real/" in zip_rel or zip_rel.startswith("Images/Real")
45
+ if is_real:
46
+ return {"label": 0, "generator": "none", "timing": None,
47
+ "real_source": _zip_stem(zip_rel)}
48
+ segs = member.split("/")
49
+ # STRUCTURED: find a timing segment; generator is the NEXT segment.
50
+ for i, s in enumerate(segs[:-1]):
51
+ if s in _TIMINGS:
52
+ gen = segs[i + 1] if i + 1 < len(segs) else _zip_stem(zip_rel)
53
+ return {"label": 1, "generator": gen, "timing": s, "real_source": "none"}
54
+ # FLAT: generator = zip stem; timing from the zip path (part-zips) else None.
55
+ return {"label": 1, "generator": _zip_stem(zip_rel),
56
+ "timing": _zip_timing(zip_rel), "real_source": "none"}
57
+
58
+
59
+ # --- §5.4 holdout member predicates (for quarantine + dalle3 exclusion) -----------
60
+ def is_val2017_member(member):
61
+ """COCO val2017 reals (the §5.4 demo reals, 4998)."""
62
+ m = member.replace("\\", "/")
63
+ return "/val2017/" in m or m.startswith("coco/coco2017/val2017")
64
+
65
+
66
+ def is_dalle3_member(member):
67
+ """DALLE3 = §5.4 DALL-E-Advanced fakes (8843), the held-out OOS generator."""
68
+ return "DALLE3" in member or "/Advanced/DALLE3" in member
69
+
70
+
71
+ def plan_wildfake_members(zip_to_members, per_gen_cap, held_out=frozenset(),
72
+ exclude_val2017=True, seed=0, holdout_only=False, stratify=False,
73
+ oversample=0, per_gen_overrides=None, include_reals=True):
74
+ """PASS-1.5 selection for WildFake: from {zip_rel: [member,...]} choose which members
75
+ to fetch, PER GENERATOR, honoring held-out exclusion. Pure/offline (no network).
76
+
77
+ held_out: generator names kept OUT of training (their members are dropped unless
78
+ holdout_only, which selects ONLY them + the val2017 reals = the OOS set).
79
+ exclude_val2017: drop COCO val2017 reals from a training pull (they're §5.4 holdout).
80
+ stratify: sample the per-generator cap PROPORTIONALLY across the Typical/Advanced
81
+ timing strata (WildFake distinguishes older vs newer generations), so a
82
+ generator's quota isn't accidentally drawn all from one era. False = flat
83
+ uniform sample per generator (original behavior).
84
+ Returns a list of (zip_rel, member, fields), capped at per_gen_cap per generator.
85
+ """
86
+ import random
87
+ rng = random.Random(seed)
88
+ # bucket members by generator (fakes)/real_source (reals) AND timing stratum
89
+ buckets = {}
90
+ for zip_rel, members in zip_to_members.items():
91
+ for m in members:
92
+ c = classify_member(zip_rel, m)
93
+ if c["label"] == 0 and not include_reals:
94
+ continue
95
+ gen = c["generator"] if c["label"] == 1 else c["real_source"]
96
+ is_ho = (gen in held_out) or is_dalle3_member(m)
97
+ is_v2017 = is_val2017_member(m)
98
+ if holdout_only:
99
+ if not (is_ho or is_v2017):
100
+ continue
101
+ else:
102
+ if is_ho:
103
+ continue
104
+ if is_v2017 and exclude_val2017:
105
+ continue
106
+ if c["label"] == 0 and not is_v2017 and "val2017" in m:
107
+ continue
108
+ key = (zip_rel, gen, c["label"])
109
+ strat = c.get("timing") or "none"
110
+ buckets.setdefault(key, {}).setdefault(strat, []).append((m, c))
111
+
112
+ def _emit(zip_rel, gen, label, chosen, plan):
113
+ for m, c in chosen:
114
+ plan.append((zip_rel, m, {"label": c["label"],
115
+ "generator_id": "none" if c["label"] == 0 else gen,
116
+ "real_source": gen if c["label"] == 0 else "none"}))
117
+
118
+ # OVERSAMPLE: fetch a few EXTRA candidates per generator beyond per_gen_cap so the
119
+ # save loop can replace undecodable images and still land EXACTLY at the cap (count
120
+ # stability). oversample=0 preserves original behavior.
121
+ # per_gen_overrides: {gen_or_real_source: cap} overrides per_gen_cap for named groups —
122
+ # used for the spec-exact §5.4 holdout (DALLE3 all 8843, coco all 4998, GigaGAN/Imagen
123
+ # bounded). A None override means "take all" for that group.
124
+ overrides = per_gen_overrides or {}
125
+ plan = []
126
+ for (zip_rel, gen, label), strata in buckets.items():
127
+ all_items = [it for lst in strata.values() for it in lst]
128
+ cap = overrides[gen] if gen in overrides else per_gen_cap
129
+ if cap == 0:
130
+ continue # explicit 0 = group EXCLUDED (oversample must not resurrect it)
131
+ eff_cap = None if cap is None else cap + max(0, oversample)
132
+ if eff_cap is None or len(all_items) <= eff_cap:
133
+ _emit(zip_rel, gen, label, all_items, plan)
134
+ continue
135
+ if not stratify or len(strata) <= 1:
136
+ _emit(zip_rel, gen, label, rng.sample(all_items, eff_cap), plan)
137
+ continue
138
+ # STRATIFIED: split the cap across timing strata proportionally to their size,
139
+ # largest-remainder for the leftover, so both eras are represented.
140
+ total = len(all_items)
141
+ alloc, rema = {}, []
142
+ for s, lst in strata.items():
143
+ exact = eff_cap * len(lst) / total
144
+ alloc[s] = min(len(lst), int(exact))
145
+ rema.append((exact - int(exact), s))
146
+ short = eff_cap - sum(alloc.values())
147
+ for _frac, s in sorted(rema, reverse=True):
148
+ if short <= 0:
149
+ break
150
+ if alloc[s] < len(strata[s]):
151
+ alloc[s] += 1; short -= 1
152
+ for s, k in alloc.items():
153
+ _emit(zip_rel, gen, label, rng.sample(strata[s], k), plan)
154
+ return plan
155
+
156
+
157
+ class WildFakeSource:
158
+ """WildFake source over the ModelScope raw zips. BULK-downloads each generator zip
159
+ whole (one sustained connection — far faster than per-member range requests, which
160
+ pay a full TLS handshake to ModelScope/China per image), extracts only the SAMPLED
161
+ members (per-generator cap + held-out exclusion), then DELETES the zip. `download_workers`
162
+ whole zips run concurrently. scan()/pull() build on plan_wildfake_members; zip_opener/
163
+ zip_list are injectable for unit tests."""
164
+ name = "wildfake"
165
+ modelscope_id = "hy2628982280/WildFake"
166
+
167
+ def __init__(self, per_gen_cap=2000, held_out=frozenset(), exclude_val2017=True,
168
+ seed=0, holdout_only=False, zip_opener=None, zip_list=None,
169
+ download_workers=2, mode="segmented", stratify=True,
170
+ seg_workers=16, max_gap=256 * 1024, per_gen_overrides=None,
171
+ include_reals=True):
172
+ self.per_gen_cap = per_gen_cap
173
+ self.per_gen_overrides = per_gen_overrides or {}
174
+ self.held_out = frozenset(held_out)
175
+ self.exclude_val2017 = exclude_val2017
176
+ self.seed = seed
177
+ self.holdout_only = holdout_only
178
+ self.include_reals = bool(include_reals)
179
+ self._zip_opener = zip_opener
180
+ self._zip_list = zip_list
181
+ # download_workers: number of ZIPS processed concurrently.
182
+ self.download_workers = max(int(download_workers), 1)
183
+ # seg_workers/max_gap: WITHIN a zip, read_many fetches coalesced segments across
184
+ # seg_workers threads. Bandwidth probe measured the ModelScope CDN throttles
185
+ # ~8MB/s PER CONNECTION, scaling to ~97MB/s at 16 conns -> seg_workers=16. max_gap
186
+ # 256KB (not 1MB) keeps segments smaller/more-numerous so there's enough of them to
187
+ # actually use 16 workers (over-coalescing into 3 huge segments would cap it at 3).
188
+ self.seg_workers = max(int(seg_workers), 1)
189
+ self.max_gap = int(max_gap)
190
+ # mode: "segmented" (default, FAST) = RangeZip + coalesced parallel range reads.
191
+ # "bulk" = download whole zip then extract. stratify: proportional Typical/Advanced.
192
+ self.mode = mode
193
+ self.stratify = stratify
194
+
195
+ # --- ModelScope wiring (deferred; overridden in tests) ------------------
196
+ def _list_zips(self):
197
+ if self._zip_list is not None:
198
+ return self._zip_list
199
+ from modelscope.hub.api import HubApi
200
+ api = HubApi()
201
+ all_zips = [getattr(f, "path", None) or str(f)
202
+ for f in api.list_repo_files(self.modelscope_id, repo_type="dataset")
203
+ if (str(getattr(f, "path", f)).startswith("Images/")
204
+ and str(getattr(f, "path", f)).lower().endswith(".zip"))]
205
+ return self._dedupe_parts(all_zips)
206
+
207
+ @staticmethod
208
+ def _dedupe_parts(zips):
209
+ """Multi-part generator zips (…/Midjourney/Advanced/part_1.zip … part_7.zip) are
210
+ REDUNDANT splits of ONE generator — part_1 alone holds 30k+ images, far more than
211
+ any per-gen cap needs. Opening all 14 SD/Midjourney 52GB parts just to parse their
212
+ central directories is THE build bottleneck (dir-parse is 30s/zip). Keep only the
213
+ FIRST part per (generator, timing) group; non-part zips pass through untouched."""
214
+ import os
215
+ seen_groups, kept = set(), []
216
+ for z in zips:
217
+ base = os.path.basename(z)
218
+ if base.startswith("part_") or base.startswith("part"):
219
+ group = os.path.dirname(z) # …/Midjourney/Advanced (gen+timing)
220
+ if group in seen_groups:
221
+ continue # already have a part for this generator
222
+ seen_groups.add(group)
223
+ kept.append(z)
224
+ return kept
225
+
226
+ def _open_zip(self, zip_rel):
227
+ """Return a zip handle exposing .image_members(), .read(name), optionally
228
+ .read_many(names), .close(). Test opener injected verbatim. Otherwise:
229
+ segmented -> RangeZip (reads central dir only; read_many coalesces sampled
230
+ members into a few contiguous range GETs — no over-download, no
231
+ per-image handshake). THE fast path.
232
+ bulk -> download the whole zip, extract from disk, delete on close."""
233
+ if self._zip_opener is not None:
234
+ return self._zip_opener(zip_rel)
235
+ from modelscope.hub.api import HubApi
236
+ api = HubApi()
237
+ if self.mode == "segmented":
238
+ from data.provenance.rangezip import RangeZip
239
+ url = api.get_dataset_file_url(zip_rel, "WildFake", "hy2628982280", "master")
240
+ rz = RangeZip(url)
241
+ seg_workers, max_gap = self.seg_workers, self.max_gap
242
+
243
+ class _Seg:
244
+ def image_members(self): return rz.image_members()
245
+ def read(self, name): return rz.read(name)
246
+ def read_many(self, names):
247
+ # MEASURED (bandwidth probe): ModelScope CDN throttles ~8MB/s PER
248
+ # CONNECTION but scales near-linearly to ~16 conns (97MB/s). So we want
249
+ # many concurrent range GETs. Small max_gap => more, smaller segments
250
+ # => higher effective concurrency (up to seg_workers).
251
+ return rz.read_many(names, max_gap=max_gap, workers=seg_workers)
252
+ def close(self): pass # nothing downloaded to delete
253
+ return _Seg()
254
+ # bulk fallback
255
+ import os, zipfile
256
+ from modelscope import dataset_file_download
257
+ local = dataset_file_download(dataset_id=self.modelscope_id, file_path=zip_rel)
258
+ z = zipfile.ZipFile(local)
259
+
260
+ class _Handle:
261
+ def image_members(self):
262
+ return [n for n in z.namelist()
263
+ if n.lower().endswith((".jpg", ".jpeg", ".png", ".webp", ".bmp"))]
264
+ def read(self, name): return z.read(name)
265
+ def close(self):
266
+ z.close()
267
+ try: os.remove(local)
268
+ except OSError: pass
269
+ return _Handle()
270
+
271
+ def _members_of(self, handle):
272
+ return handle.image_members()
273
+
274
+ def scan(self, max_examine=None):
275
+ """Inventory per generator. Downloads each zip to read its member list, then
276
+ releases it (bounded disk). For a pure-metadata scan without downloads, use the
277
+ label CSVs (kernel-side); this scan is the source-agnostic fallback."""
278
+ import pandas as pd
279
+ counts = {}
280
+ for zr in self._list_zips():
281
+ h = self._open_zip(zr)
282
+ try:
283
+ for m in h.image_members():
284
+ c = classify_member(zr, m)
285
+ if c["label"] == 0 and not self.include_reals:
286
+ continue
287
+ gkey = c["generator"] if c["label"] == 1 else c["real_source"]
288
+ counts[(gkey, c["label"])] = counts.get((gkey, c["label"]), 0) + 1
289
+ finally:
290
+ h.close()
291
+ rows = [{"source_dataset": self.name, "group_key": g, "label": l, "count": c}
292
+ for (g, l), c in counts.items()]
293
+ return pd.DataFrame(rows, columns=["source_dataset", "group_key", "label", "count"])
294
+
295
+ def pull(self, out_dir, seed=0, per_family_cap=None, max_examine=None, selection=None,
296
+ progress_cb=None, progress_every=200):
297
+ """BULK per-zip materialization: for each zip, download it whole (one fast
298
+ connection), plan+extract only the members we sampled (held-out excluded), write
299
+ JPEGs, then DELETE the zip before the next — peak disk = one zip. INTERLEAVED:
300
+ the next zip downloads (in a background thread) while the current one extracts,
301
+ so download and extract overlap and fetching starts within seconds (no upfront
302
+ all-directories barrier).
303
+
304
+ progress_cb: fn(dict) called after each zip with {done,total,imgs_per_s,elapsed_s,
305
+ zips_done,n_zips,eta_s} so a long build streams live to wandb."""
306
+ import os, time, pandas as pd
307
+ from concurrent.futures import ThreadPoolExecutor, as_completed
308
+ from PIL import Image
309
+ from data.provenance.sources import RECORD_COLUMNS, _as_pil
310
+ from data.provenance.manifest_build import jpeg_bytes_per_pixel, save_source_image
311
+ os.makedirs(out_dir, exist_ok=True)
312
+
313
+ zips = self._list_zips()
314
+ n_zips = len(zips)
315
+ t0 = time.perf_counter()
316
+
317
+ def _process_zip(zr):
318
+ """Download one whole zip, extract its planned members, delete it. Fully
319
+ independent -> safe to run many in parallel. Each (generator lives in exactly
320
+ ONE zip after part-dedup) so per-zip filename counters never collide."""
321
+ handle = self._open_zip(zr)
322
+ zip_rows, local_counts = [], {}
323
+ try:
324
+ plan = plan_wildfake_members({zr: handle.image_members()},
325
+ per_gen_cap=self.per_gen_cap,
326
+ held_out=self.held_out,
327
+ exclude_val2017=self.exclude_val2017,
328
+ seed=self.seed, holdout_only=self.holdout_only,
329
+ stratify=self.stratify,
330
+ per_gen_overrides=self.per_gen_overrides,
331
+ include_reals=self.include_reals,
332
+ # +5% reserves (min 8) to replace undecodable blobs
333
+ oversample=max(8, (self.per_gen_cap or 0) // 20))
334
+ # Batch-fetch all sampled members in a FEW coalesced range GETs when the
335
+ # handle supports it (segmented mode); else read one at a time (bulk/test).
336
+ want = [m for _zr, m, _f in plan]
337
+ if hasattr(handle, "read_many") and want:
338
+ blobs = handle.read_many(want)
339
+ else:
340
+ blobs = {m: handle.read(m) for m in want}
341
+ for _zr, m, f in plan:
342
+ gkey = f["generator_id"] if f["label"] == 1 else f["real_source"]
343
+ # COUNT-STABLE: the plan is OVERSAMPLED (cap + buffer). Stop saving a
344
+ # generator once it reaches the true cap — extra candidates are reserves
345
+ # that only get used to replace undecodable images below.
346
+ if self.per_gen_cap is not None and local_counts.get(gkey, 0) >= self.per_gen_cap:
347
+ continue
348
+ zt = _zr.replace("/", "_").replace(".zip", "")
349
+ # VERBATIM source bytes (no re-encode): JPEG re-encode bakes a scored
350
+ # corruption into the 'clean' ground truth; PNG re-encode ~4x's disk (blows
351
+ # working-disk limits) for zero fidelity gain. save_source_image writes the
352
+ # source blob unchanged with the sniffed extension.
353
+ base = f"{self.name}/fakes/{self.name}_{gkey}_{zt}_{local_counts.get(gkey,0)}"
354
+ p_noext = os.path.join(out_dir, base)
355
+ try:
356
+ # A corrupt/undecodable blob must NOT abort the whole run — skip it; a
357
+ # reserve candidate takes its slot so the per-generator count stays exact.
358
+ p, w, h = save_source_image(blobs[m], p_noext)
359
+ rel = os.path.relpath(p, out_dir).replace(os.sep, "/")
360
+ except Exception as e:
361
+ print(f"[wildfake] skip undecodable {_zr}:{m} ({type(e).__name__}: {e})", flush=True)
362
+ import glob as _g
363
+ for _f in _g.glob(p_noext + ".*"): # remove any partial write
364
+ try: os.remove(_f)
365
+ except OSError: pass
366
+ continue
367
+ zip_rows.append({"path": p, "label": f["label"], "source_dataset": self.name,
368
+ "generator_id": f["generator_id"], "real_source": f["real_source"],
369
+ "native_w": w, "native_h": h,
370
+ "jpeg_bytes_per_pixel": jpeg_bytes_per_pixel(p),
371
+ "ref_type": "materialized", "mount_slug": "", "rel_path": rel})
372
+ local_counts[gkey] = local_counts.get(gkey, 0) + 1
373
+ finally:
374
+ handle.close()
375
+ return zip_rows
376
+
377
+ # N whole zips downloaded+extracted CONCURRENTLY (each a sustained connection;
378
+ # parallel streams saturate total bandwidth). Peak disk ~= download_workers zips.
379
+ rows, zips_done = [], 0
380
+ with ThreadPoolExecutor(max_workers=self.download_workers) as ex:
381
+ futs = {ex.submit(_process_zip, zr): zr for zr in zips}
382
+ for fut in as_completed(futs):
383
+ rows.extend(fut.result())
384
+ zips_done += 1
385
+ if progress_cb:
386
+ dt = time.perf_counter() - t0
387
+ progress_cb({"phase": "fetch", "done": len(rows), "zips_done": zips_done,
388
+ "n_zips": n_zips, "imgs_per_s": len(rows) / dt if dt > 0 else 0.0,
389
+ "elapsed_s": dt,
390
+ "eta_s": (n_zips - zips_done) * (dt / zips_done) if zips_done else None})
391
+ print(f"[wildfake] zip {zips_done}/{n_zips} {futs[fut]} -> {len(rows)} total "
392
+ f"imgs ({time.perf_counter()-t0:.0f}s)", flush=True)
393
+ return pd.DataFrame(rows, columns=RECORD_COLUMNS)
src/data/provenance/writer.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/shards/writer.py
2
+ """Deterministic ZIP64 shard planning + writing (spec: sharded-corpus-pipeline).
3
+
4
+ Members are ZIP_STORED (images are already compressed), ordered by a stable sort,
5
+ with fixed timestamps/attrs so identical inputs reproduce identical SHA-256 digests.
6
+ write_shard is transactional up to the atomic rename: it writes `<path>.partial`,
7
+ re-opens and verifies (names, order, count, sizes, per-member CRC by reading every
8
+ member), computes the digest, then renames. On any failure only the .partial is
9
+ removed; raw inputs are never touched here (deletion is the orchestrator's job,
10
+ and only AFTER the journal records the verified shard)."""
11
+ import hashlib
12
+ import os
13
+ import zipfile
14
+
15
+ _FIXED_DT = (1980, 1, 1, 0, 0, 0)
16
+
17
+
18
+ def validate_member_name(name: str) -> str:
19
+ if not name or "\\" in name or name.startswith("/"):
20
+ raise ValueError(f"unsafe member name: {name!r}")
21
+ parts = name.split("/")
22
+ if any(p in ("", ".", "..") for p in parts):
23
+ raise ValueError(f"unsafe member name: {name!r}")
24
+ return name
25
+
26
+
27
+ def plan_shards(df, target_bytes=1 << 30, prefix="corpus"):
28
+ """Assign every manifest row to a deterministic shard. Returns a sorted copy with
29
+ shard_name, member_name, byte_size columns. Close-before-exceed packing; a single
30
+ oversized member occupies its own shard."""
31
+ out = df.copy()
32
+ out["member_name"] = [validate_member_name(r) for r in out["rel_path"]]
33
+ if out["member_name"].duplicated().any():
34
+ dupes = out.loc[out["member_name"].duplicated(), "member_name"].head(3).tolist()
35
+ raise ValueError(f"duplicate member names: {dupes}")
36
+ ident = out["img_id"] if "img_id" in out.columns else out["rel_path"]
37
+ out["_grp"] = out["family"].where(out["label"] == 1, out["real_source"])
38
+ out["_ident"] = ident.astype(str)
39
+ out = out.sort_values(["source_dataset", "label", "_grp", "_ident", "rel_path"],
40
+ kind="mergesort").reset_index(drop=True)
41
+ # honor a pre-supplied byte_size column (resume path: raws of completed shards are
42
+ # deleted, their sizes recovered from the shard central directories by the caller)
43
+ if "byte_size" not in out.columns:
44
+ out["byte_size"] = [os.path.getsize(p) for p in out["path"]]
45
+ names, cur_bytes, cur_idx = [], 0, 0
46
+ for size in out["byte_size"]:
47
+ if cur_bytes > 0 and cur_bytes + size > target_bytes:
48
+ cur_idx += 1
49
+ cur_bytes = 0
50
+ names.append(f"{prefix}-{cur_idx:05d}.shard") # .shard NOT .zip: keeps generic file-sync tools from auto-extracting the archives
51
+ cur_bytes += size
52
+ out["shard_name"] = names
53
+ return out.drop(columns=["_grp", "_ident"])
54
+
55
+
56
+ def write_shard(zip_path: str, members) -> dict:
57
+ """Write members [(member_name, src_path)...] to zip_path via .partial + verify +
58
+ digest + atomic rename. Returns shard record with per-member sizes and CRCs."""
59
+ partial = zip_path + ".partial"
60
+ try:
61
+ with open(partial, "wb") as fh:
62
+ with zipfile.ZipFile(fh, "w", compression=zipfile.ZIP_STORED,
63
+ allowZip64=True) as zf:
64
+ for name, src in members:
65
+ validate_member_name(name)
66
+ zi = zipfile.ZipInfo(name, date_time=_FIXED_DT)
67
+ zi.compress_type = zipfile.ZIP_STORED
68
+ zi.create_system = 3
69
+ zi.external_attr = 0o644 << 16
70
+ with open(src, "rb") as f:
71
+ zf.writestr(zi, f.read())
72
+ fh.flush()
73
+ os.fsync(fh.fileno())
74
+ # --- verify by re-reading the closed archive, never trusting write state ---
75
+ rec_members, payload = [], 0
76
+ with zipfile.ZipFile(partial) as zf:
77
+ if zf.namelist() != [n for n, _ in members]:
78
+ raise AssertionError(f"shard member order mismatch in {zip_path}")
79
+ for name, src in members:
80
+ info = zf.getinfo(name)
81
+ data = zf.read(name) # zipfile CRC-checks on read
82
+ if len(data) != os.path.getsize(src):
83
+ raise AssertionError(f"size mismatch for {name}")
84
+ rec_members.append({"member_name": name, "byte_size": len(data),
85
+ "crc32": info.CRC})
86
+ payload += len(data)
87
+ h = hashlib.sha256()
88
+ with open(partial, "rb") as f:
89
+ for chunk in iter(lambda: f.read(1 << 20), b""):
90
+ h.update(chunk)
91
+ os.replace(partial, zip_path) # atomic on same filesystem
92
+ return {"sha256": h.hexdigest(), "zip_bytes": os.path.getsize(zip_path),
93
+ "payload_bytes": payload, "n_members": len(rec_members),
94
+ "members": rec_members}
95
+ except BaseException:
96
+ if os.path.exists(partial):
97
+ os.remove(partial)
98
+ raise
src/data/shard_reader.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/data/shard_reader.py
2
+ """Read training images directly from mounted ZIP shards — no extraction.
3
+
4
+ Handles are opened lazily per worker process and kept in a small LRU (default 8).
5
+ The pid check recreates handles after DataLoader fork/spawn: a zipfile handle
6
+ shared across processes corrupts reads. Rows resolve via shard_name + member_name
7
+ from the candidate manifest."""
8
+ import io
9
+ import os
10
+ import zipfile
11
+ from collections import OrderedDict
12
+
13
+ from PIL import Image
14
+
15
+
16
+ class ShardResolver:
17
+ def __init__(self, shard_dir, max_handles=8):
18
+ self.shard_dir = shard_dir
19
+ self.max_handles = int(max_handles)
20
+ self._handles = OrderedDict()
21
+ self._pid = os.getpid()
22
+
23
+ def _get_zip(self, shard_name):
24
+ if os.getpid() != self._pid: # forked/spawned worker: stale handles
25
+ self._handles = OrderedDict()
26
+ self._pid = os.getpid()
27
+ zf = self._handles.get(shard_name)
28
+ if zf is None:
29
+ zf = zipfile.ZipFile(os.path.join(self.shard_dir, shard_name))
30
+ self._handles[shard_name] = zf
31
+ while len(self._handles) > self.max_handles:
32
+ _, old = self._handles.popitem(last=False)
33
+ old.close()
34
+ else:
35
+ self._handles.move_to_end(shard_name)
36
+ return zf
37
+
38
+ def resolve(self, row):
39
+ zf = self._get_zip(row["shard_name"])
40
+ data = zf.read(row["member_name"])
41
+ img = Image.open(io.BytesIO(data))
42
+ img.load()
43
+ return img.convert("RGB")
44
+
45
+ def __getstate__(self): # picklable into DataLoader workers
46
+ return {"shard_dir": self.shard_dir, "max_handles": self.max_handles}
47
+
48
+ def __setstate__(self, state):
49
+ self.__init__(state["shard_dir"], state["max_handles"])
src/eval/__init__.py ADDED
File without changes
src/eval/error_gallery.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/eval/error_gallery.py
2
+ """Offline error-gallery construction from the predictions parquet.
3
+
4
+ The predictions table (one row per image x condition, with provenance) is the ONLY
5
+ input the model produces — galleries are built here, offline, AFTER a run, and can be
6
+ rebuilt at any bands without re-running training. Images are pulled on demand from the
7
+ mounted source via SourceResolver (rel_path is the stable address), so nothing is
8
+ saved during the run.
9
+
10
+ Banding is by PROBABILITY, not a 0.5 threshold (ROC AUC is rank-only, so 0.5 is
11
+ meaningless). Two bands the user asked for, stratified per dataset:
12
+ - confident_wrong : LABEL-RELATIVE — a fake (label 1) with prob < conf, or a real
13
+ (label 0) with prob > 1-conf. The embarrassing, high-confidence misses.
14
+ - borderline : prob in [border_lo, border_hi]. The model is genuinely unsure.
15
+ """
16
+ import pandas as pd
17
+
18
+
19
+ def select_gallery_rows(pred_df, n_per=30, conf=0.15, border=(0.4, 0.6),
20
+ condition="clean", seed=0):
21
+ """Return a DataFrame of selected rows with an added `band` column.
22
+
23
+ n_per caps rows per (band x source_dataset). Selection is on `condition` (default
24
+ "clean"). confident_wrong sorted by how wrong (most confident-wrong first);
25
+ borderline sampled. Deterministic given seed.
26
+ """
27
+ df = pred_df[pred_df["condition"] == condition].copy()
28
+ lo, hi = border
29
+ is_fake = df["label"] == 1
30
+ # LABEL-RELATIVE confident-wrong: fake&low OR real&high.
31
+ cw = df[(is_fake & (df["prob"] < conf)) | (~is_fake & (df["prob"] > 1 - conf))].copy()
32
+ cw["band"] = "confident_wrong"
33
+ # "wrongness" = distance of prob from the correct end (1 for fakes, 0 for reals).
34
+ cw["_wrong"] = (cw["label"] - cw["prob"]).abs()
35
+ bl = df[(df["prob"] >= lo) & (df["prob"] <= hi)].copy()
36
+ bl["band"] = "borderline"
37
+ bl["_wrong"] = (bl["prob"] - 0.5).abs() # closeness to 0.5 (most-unsure first)
38
+
39
+ out = []
40
+ for band, g, ascending in (("confident_wrong", cw, False), ("borderline", bl, True)):
41
+ for _src, grp in g.groupby("source_dataset"):
42
+ # confident_wrong: most-wrong first (desc). borderline: most-unsure first (asc dist).
43
+ grp = grp.sort_values("_wrong", ascending=ascending).head(n_per)
44
+ out.append(grp)
45
+ if not out:
46
+ return pd.DataFrame(columns=list(pred_df.columns) + ["band"])
47
+ res = pd.concat(out, ignore_index=True).drop(columns=["_wrong"])
48
+ return res
49
+
50
+
51
+ def render_gallery(selected_df, resolver, out_dir, thumb=256):
52
+ """Pull each selected image via `resolver` (offline, from the mounted source) and
53
+ write a 256px thumbnail into out_dir/<band>/<source>/. Filename encodes prob+label so
54
+ the miss is legible at a glance. Returns {(band,source): count}. Never crashes on a
55
+ single bad image (logs + skips) — the selection table is the source of truth."""
56
+ import os
57
+ counts = {}
58
+ for _, r in selected_df.iterrows():
59
+ try:
60
+ img = resolver.resolve(r) # rel_path/source_dataset -> PIL
61
+ img = img.convert("RGB"); img.thumbnail((thumb, thumb))
62
+ d = os.path.join(out_dir, r["band"], str(r["source_dataset"]))
63
+ os.makedirs(d, exist_ok=True)
64
+ tag = f"L{int(r['label'])}_p{float(r['prob']):.2f}_{os.path.basename(str(r['img_id']))}.jpg"
65
+ img.save(os.path.join(d, tag), quality=88)
66
+ k = (r["band"], r["source_dataset"]); counts[k] = counts.get(k, 0) + 1
67
+ except Exception as e: # noqa: BLE001 — one bad image must not kill the gallery
68
+ print(f"[gallery] skip {r.get('img_id')}: {e}")
69
+ return counts
src/eval/explain.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/eval/explain.py
2
+ """Explainability for the detector — the business-facing "why was this flagged" layer.
3
+
4
+ The student's AttentionPoolPatchHead already emits a per-patch attention map (the MIL
5
+ pooling weights). We turn it into (a) a spatial heatmap overlay a human moderator can read,
6
+ (b) a compact per-image rationale (verdict + confidence + where the model looked), and
7
+ (c) an aggregate reliance summary (how real/fake separation holds up per corruption). No
8
+ extra forward pass — the attention is a free byproduct of the pooling head.
9
+
10
+ Why this matters for the business case: a content-moderation / provenance system cannot act
11
+ on a black-box score. A localized "this region looks synthetic" plus a robustness profile is
12
+ what makes the flag actionable and auditable.
13
+ """
14
+ import numpy as np
15
+
16
+
17
+ def attention_heatmap(attn, img_size):
18
+ """Upsample a per-patch attention vector (length = n_side², a square grid) to a
19
+ (H, W) heatmap normalized to [0,1]. Nearest-neighbour upsample keeps patch structure."""
20
+ n = len(attn)
21
+ side = int(round(np.sqrt(n)))
22
+ if side * side != n:
23
+ raise ValueError(f"attention length {n} is not a square patch grid")
24
+ grid = np.asarray(attn, dtype=float).reshape(side, side)
25
+ rng = grid.max() - grid.min()
26
+ grid = (grid - grid.min()) / rng if rng > 0 else np.zeros_like(grid)
27
+ W, H = img_size
28
+ ys = (np.arange(H) * side // H).clip(0, side - 1)
29
+ xs = (np.arange(W) * side // W).clip(0, side - 1)
30
+ return grid[ys][:, xs]
31
+
32
+
33
+ def explain_prediction(prob, attn, img_size, threshold=0.5):
34
+ """Per-image explanation: verdict, calibrated-ish confidence, attention concentration
35
+ (focus_fraction = share of attention mass in the top 10% of patches — high means the
36
+ decision hinges on a localized region), and a short human rationale."""
37
+ attn = np.asarray(attn, dtype=float)
38
+ k = max(1, int(round(0.1 * len(attn))))
39
+ focus_fraction = float(np.sort(attn)[::-1][:k].sum() / (attn.sum() + 1e-9))
40
+ verdict = "ai-generated" if prob >= threshold else "authentic"
41
+ confidence = float(abs(prob - 0.5) * 2) # 0 at the boundary, 1 at the extremes
42
+ where = ("concentrated in a localized region" if focus_fraction > 0.4
43
+ else "spread broadly across the image")
44
+ rationale = (f"Flagged as {verdict} (p={prob:.2f}); the detector's evidence is "
45
+ f"{where} (focus={focus_fraction:.0%}).")
46
+ return {"verdict": verdict, "confidence": confidence,
47
+ "focus_fraction": focus_fraction, "heatmap": attention_heatmap(attn, img_size),
48
+ "rationale": rationale}
49
+
50
+
51
+ def aggregate_reliance(pred_df):
52
+ """Per-condition real/fake separation = mean prob(fake rows) − mean prob(real rows).
53
+ A large separation on clean that DEGRADES under a corruption tells the business exactly
54
+ which real-world transformation weakens the detector — the actionable robustness story."""
55
+ summ = {}
56
+ for cond, g in pred_df.groupby("condition"):
57
+ fake = g[g["label"] == 1]["prob"]
58
+ real = g[g["label"] == 0]["prob"]
59
+ sep = float(fake.mean() - real.mean()) if len(fake) and len(real) else float("nan")
60
+ summ[cond] = {"separation": sep, "n": int(len(g)),
61
+ "fake_mean": float(fake.mean()) if len(fake) else float("nan"),
62
+ "real_mean": float(real.mean()) if len(real) else float("nan")}
63
+ return summ
64
+
65
+
66
+ def deletion_faithfulness(predict_fn, img, attn, n_regions=8, seed=0):
67
+ """Deletion-test faithfulness of an attention explanation.
68
+
69
+ An attention map is only a real explanation if the regions it highlights are the ones
70
+ the model actually USES. We measure this by DELETING (graying out) the top-attention
71
+ patches and seeing how much the prediction drops, versus deleting random patches. Score
72
+ = (prob_full − prob_delete_top) − (prob_full − prob_delete_random) = the extra prediction
73
+ change attributable to removing the ATTENDED region. Higher = more faithful attention.
74
+ Returns a scalar; positive means attention aligns with the model's causal evidence."""
75
+ import numpy as np
76
+ from PIL import Image
77
+ rng = np.random.default_rng(seed)
78
+ a = np.asarray(img.convert("RGB")).copy()
79
+ H, W = a.shape[:2]
80
+ n = len(attn)
81
+ side = int(round(np.sqrt(n)))
82
+ attn = np.asarray(attn, dtype=float)
83
+
84
+ def _gray_patches(idxs):
85
+ out = a.copy()
86
+ for idx in idxs:
87
+ r, c = idx // side, idx % side
88
+ y0, y1 = r * H // side, (r + 1) * H // side
89
+ x0, x1 = c * W // side, (c + 1) * W // side
90
+ out[y0:y1, x0:x1] = 128 # neutral gray = "delete"
91
+ return Image.fromarray(out)
92
+
93
+ k = max(1, min(n_regions, n))
94
+ top_idx = np.argsort(attn)[::-1][:k]
95
+ rand_idx = rng.choice(n, size=k, replace=False)
96
+ p_full = float(predict_fn([img])[0])
97
+ p_top = float(predict_fn([_gray_patches(top_idx)])[0])
98
+ p_rand = float(predict_fn([_gray_patches(rand_idx)])[0])
99
+ return (p_full - p_top) - (p_full - p_rand) # = p_rand - p_top
src/eval/harness.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/eval/harness.py
2
+ import numpy as np
3
+ from PIL import Image
4
+ from data.aug.augment import CORRUPTIONS, EVAL_GRID, apply_corruption
5
+ from eval.metrics import roc_auc, aggregate_scores
6
+ from eval.predictions import build_predictions_df
7
+ from eval.stream import plan_eval_batches
8
+ from analysis.format_probe import format_of as _format_of
9
+
10
+ def evaluate(predict_fn, val_df, cfg, logger=None, corruption_fn=None, resolver=None,
11
+ watchdog=None) -> dict:
12
+ """Streaming bounded-memory evaluation over clean + the 6-corruption grid.
13
+
14
+ corruption_fn(img, name, severity)->PIL lets the caller SWAP the corruption
15
+ implementation (default: the canonical single-impl `apply_corruption`). Passing an
16
+ implementation-holdout function (see evaluate_impl_holdout) measures AUC under
17
+ operators NOT seen during training — the corruption-implementation-invariance result."""
18
+ corrupt = corruption_fn or apply_corruption
19
+ labels = val_df["label"].to_numpy()
20
+ # sharded rows (ref_type='shard') carry no usable path: resolve via the resolver
21
+ shard_rows = None
22
+ if resolver is not None and "shard_name" in val_df.columns:
23
+ shard_rows = val_df.reset_index(drop=True).to_dict("records")
24
+ paths = val_df["path"].tolist() if "path" in val_df.columns else [""] * len(val_df)
25
+ n = len(paths)
26
+ # img_id column if present, else fall back to path (aligned to `paths` order).
27
+ if "img_id" in val_df:
28
+ img_ids = val_df["img_id"].tolist()
29
+ else:
30
+ img_ids = list(paths)
31
+ # Provenance for offline error-gallery stratification + reconstruction. rel_path is
32
+ # the STABLE source address (source_dataset + rel_path -> SourceResolver re-fetches
33
+ # the image); image_path is an ephemeral /tmp path. Absent columns default to "".
34
+ def _col(name):
35
+ return val_df[name].tolist() if name in val_df else [""] * n
36
+ source_datasets = _col("source_dataset")
37
+ generator_ids = _col("generator_id")
38
+ rel_paths = _col("rel_path")
39
+ # native_w + format carry the codec/size axes so the format-shortcut probe (the decisive
40
+ # holdout test: JPEG-only, size-matched AUC) is derivable OFFLINE from the predictions table.
41
+ # native_w defaults to NaN; format is derived from the path extension when the column is absent.
42
+ native_ws = val_df["native_w"].tolist() if "native_w" in val_df else [float("nan")] * n
43
+
44
+ # The 19 eval conditions, index 0 = clean. condition_idx -> (name, severity, key).
45
+ conditions = [("clean", None, "clean")]
46
+ for name in CORRUPTIONS:
47
+ for sev in EVAL_GRID[name]:
48
+ conditions.append((name, sev, f"{name}@{sev}"))
49
+ n_cond = len(conditions)
50
+
51
+ # BOUNDED-MEMORY STREAMING (fixes host-RAM OOM): the old harness decoded EVERY val
52
+ # image into a list held across all conditions (+a full copy per condition) — tens
53
+ # of GB on a large val set. Instead we stream (image x condition) work through
54
+ # plan_eval_batches under a hard cap of `pool_max` decoded images resident at once;
55
+ # each image is decoded on first use and evicted the moment its last condition is
56
+ # scored. Memory is O(pool_max), independent of val-set size.
57
+ pool_max = max(getattr(cfg, "eval_pool_max", 64), 1)
58
+ batch = max(getattr(cfg, "eval_batch_size", 32), 1)
59
+ prob_mat = np.full((n_cond, n), np.nan, dtype=float) # [condition][image]
60
+ cache = {} # item_idx -> decoded PIL.Image (at most pool_max live)
61
+ done = np.zeros(n, dtype=int) # conditions scored per item -> evict at n_cond
62
+
63
+ # FAIR EVAL: normalize real+fake through an identical resize+recompress so absolute
64
+ # resolution/format can't be the discriminator (the holdout's reals were 200px JPEG, fakes
65
+ # large PNG -> resolution-only AUC 1.0). Opt-in; any AUC after this is real detection signal.
66
+ fair = getattr(cfg, "fair_eval", False)
67
+ fair_res = getattr(cfg, "resolution", 518)
68
+ # PARALLEL CPU PREP (cfg.eval_workers>0): decode + corruption are PIL C calls that
69
+ # release the GIL, so a THREAD pool parallelizes them for real — the serial loop left
70
+ # 29 of 30 cores idle through every eval phase (GPU util 0%, the v12 4h stall's true
71
+ # anatomy). Decode stays serialized per item (cache coherence); corruption fans out.
72
+ eval_workers = int(getattr(cfg, "eval_workers", 0))
73
+ pool = None
74
+ if eval_workers > 0:
75
+ from concurrent.futures import ThreadPoolExecutor
76
+ pool = ThreadPoolExecutor(max_workers=eval_workers)
77
+
78
+ def _decode(item):
79
+ if item not in cache:
80
+ if resolver is not None and shard_rows is not None:
81
+ im = resolver.resolve(shard_rows[item]).convert("RGB")
82
+ else:
83
+ with Image.open(paths[item]) as im2:
84
+ im = im2.convert("RGB")
85
+ if fair:
86
+ from data.preprocess import fair_eval_normalize
87
+ im = fair_eval_normalize(im, fair_res)
88
+ cache[item] = im
89
+ return cache[item]
90
+
91
+ def _make(item_cond):
92
+ item, cond = item_cond
93
+ name, sev, _ = conditions[cond]
94
+ base_im = cache[item]
95
+ return base_im if name == "clean" else corrupt(base_im, name, sev)
96
+
97
+ # MID-EVAL PROGRESS: eval phases used to be silent for up to hours (GPU idle, no
98
+ # output — indistinguishable from a hang). Log every ~30s: cells done/total, rate,
99
+ # ETA — to stdout (run.log) and, when a logger is given, to wandb as eval/progress.
100
+ import time as _time
101
+ total_cells = n * n_cond
102
+ cells_done = 0
103
+ _t0 = _time.time()
104
+ _t_last = _t0
105
+ for bat in plan_eval_batches(n, n_cond, pool_max, batch):
106
+ for item, _cond in bat:
107
+ _decode(item) # serial: fills cache once per item
108
+ coords = list(bat)
109
+ if pool is not None:
110
+ imgs = list(pool.map(_make, coords))
111
+ else:
112
+ imgs = [_make(ic) for ic in coords]
113
+ probs = predict_fn(imgs)
114
+ cells_done += len(coords)
115
+ if watchdog is not None:
116
+ watchdog.update(cells_done=cells_done, cells_total=total_cells)
117
+ if _time.time() - _t_last >= 30:
118
+ _t_last = _time.time()
119
+ rate = cells_done / max(_time.time() - _t0, 1e-9)
120
+ eta = (total_cells - cells_done) / max(rate, 1e-9)
121
+ print(f"[eval] {cells_done}/{total_cells} cells "
122
+ f"({100*cells_done/total_cells:.0f}%) | {rate:.0f} cells/s | "
123
+ f"eta {eta/60:.1f} min", flush=True)
124
+ if logger is not None:
125
+ try:
126
+ logger.log({"eval/progress_frac": cells_done / total_cells,
127
+ "eval/cells_per_s": rate})
128
+ except Exception: # noqa: BLE001 - progress logging never breaks eval
129
+ pass
130
+ for (item, cond), p in zip(coords, probs):
131
+ prob_mat[cond, item] = float(p)
132
+ done[item] += 1
133
+ if done[item] >= n_cond:
134
+ cache.pop(item, None) # last condition scored -> free the image
135
+ if pool is not None:
136
+ pool.shutdown(wait=False)
137
+
138
+ # Assemble per-condition AUCs + the long-format predictions table from prob_mat.
139
+ pred_records = []
140
+ per = {}
141
+ for cond, (name, sev, key) in enumerate(conditions):
142
+ col = prob_mat[cond]
143
+ per[key] = roc_auc(labels, col)
144
+ for i in range(n):
145
+ pred_records.append({
146
+ "img_id": img_ids[i], "image_path": paths[i],
147
+ "source_dataset": source_datasets[i], "generator_id": generator_ids[i],
148
+ "rel_path": rel_paths[i], "native_w": native_ws[i],
149
+ "format": _format_of(paths[i]), "label": int(labels[i]),
150
+ "condition": name, "severity": sev, "prob": float(col[i]),
151
+ })
152
+
153
+ agg = aggregate_scores(per)
154
+ predictions = build_predictions_df(pred_records)
155
+ result = {"per_condition": per, **agg, "predictions": predictions}
156
+
157
+ # Attention-map diagnostics are intentionally skipped: predict_fn only returns
158
+ # sigmoid probs (attn is discarded), so measuring attention peakedness would
159
+ # require a second forward pass / harness refactor. Not worth it — see report.
160
+ if logger is not None:
161
+ metrics = {f"eval/{k}": v for k, v in per.items()} # e.g. eval/jpeg@30
162
+ metrics.update({
163
+ "eval/auc_clean": agg["auc_clean"],
164
+ "eval/auc_robust_mean": agg["auc_robust_mean"],
165
+ "eval/auc_robust_worst": agg["auc_robust_worst"],
166
+ "eval/final": agg["final"],
167
+ })
168
+ logger.log(metrics)
169
+
170
+ return result
171
+
172
+
173
+ def _impl_corruption_fn(impl_choice, seed=0):
174
+ """Build a corruption_fn(img,name,sev) that applies a SPECIFIC implementation per
175
+ family. impl_choice maps family -> list of allowed impl names; one is chosen
176
+ deterministically per (family, severity) so the eval is reproducible."""
177
+ import random
178
+ from data.aug.augment_impls import apply_impl, list_impls
179
+ def fn(img, name, sev):
180
+ allowed = impl_choice.get(name) or list_impls(name)
181
+ # deterministic pick within the allowed set, varied by severity
182
+ r = random.Random(f"{name}:{sev}:{seed}")
183
+ impl = allowed[r.randrange(len(allowed))]
184
+ return apply_impl(img, name, impl, sev)
185
+ return fn
186
+
187
+
188
+ def evaluate_impl_holdout(predict_fn, val_df, cfg, train_impls, test_impls,
189
+ logger=None, seed=0, resolver=None) -> dict:
190
+ """Corruption-IMPLEMENTATION-invariance measurement: evaluate the SAME model under
191
+ (a) the training implementations and (b) the held-out implementations, and report the
192
+ AUC gap. A small gap = the detector generalizes across operators (did NOT overfit the
193
+ grader's exact JPEG/resize/blur); a large gap = operator overfit.
194
+
195
+ Returns {train_impl: <evaluate result>, test_impl: <evaluate result>, gap: {...}}.
196
+ train_impls / test_impls are {family: [impl_name,...]} from split_impls_train_test."""
197
+ seen = evaluate(predict_fn, val_df, cfg, logger=None,
198
+ corruption_fn=_impl_corruption_fn(train_impls, seed), resolver=resolver)
199
+ held = evaluate(predict_fn, val_df, cfg, logger=None,
200
+ corruption_fn=_impl_corruption_fn(test_impls, seed), resolver=resolver)
201
+ gap = {
202
+ "auc_robust_mean_seen": seen["auc_robust_mean"],
203
+ "auc_robust_mean_held": held["auc_robust_mean"],
204
+ "robust_mean_gap": seen["auc_robust_mean"] - held["auc_robust_mean"],
205
+ "final_seen": seen["final"], "final_held": held["final"],
206
+ "final_gap": seen["final"] - held["final"],
207
+ }
208
+ if logger is not None:
209
+ logger.log({f"implholdout/{k}": v for k, v in gap.items()})
210
+ return {"train_impl": seen, "test_impl": held, "gap": gap}
src/eval/metrics.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/eval/metrics.py
2
+ import numpy as np
3
+ from sklearn.metrics import roc_auc_score
4
+
5
+ def roc_auc(labels: np.ndarray, probs: np.ndarray) -> float:
6
+ labels = np.asarray(labels); probs = np.asarray(probs)
7
+ if len(np.unique(labels)) < 2:
8
+ return float("nan")
9
+ return float(roc_auc_score(labels, probs))
10
+
11
+ def stratified_auc(df, by="generator_id", prob_col="prob", label_col="label"):
12
+ """Per-group AUC: each FAKE group (e.g. one generator) scored vs the SHARED real pool.
13
+ Reveals which generators the model separates well — logged live to wandb per epoch.
14
+
15
+ df must carry label/prob/`by`. Reals (label 0) form one pool reused for every group.
16
+ Groups with <2 classes (no reals, or the group has no fakes) are skipped/NaN."""
17
+ import numpy as np
18
+ reals = df[df[label_col] == 0]
19
+ if len(reals) == 0:
20
+ return {}
21
+ real_labels = reals[label_col].to_numpy()
22
+ real_probs = reals[prob_col].to_numpy()
23
+ out = {}
24
+ for gval, grp in df[df[label_col] == 1].groupby(by):
25
+ labels = np.concatenate([real_labels, grp[label_col].to_numpy()])
26
+ probs = np.concatenate([real_probs, grp[prob_col].to_numpy()])
27
+ out[str(gval)] = roc_auc(labels, probs)
28
+ return out
29
+
30
+
31
+ def aggregate_scores(per_condition: dict[str, float]) -> dict:
32
+ """Aggregate per-condition AUCs into the competition score.
33
+
34
+ The organizer's metric is mean over the SIX corruption FAMILIES (jpeg/blur/resize/noise/
35
+ jitter/crop), each family weighted EQUALLY. Our eval grid probes several severities per
36
+ family (jpeg@30/50/70/90, ...), so a naive mean over all severity cells over-weights
37
+ families with more severities (jpeg had 4 votes, jitter/crop 1). We therefore average the
38
+ severities WITHIN each family first (family AUC), then take the mean over families — so the
39
+ number matches the score that will actually be computed. `robust_worst` is the worst FAMILY.
40
+ Condition keys are 'family@severity' (or a bare 'family' with no '@')."""
41
+ clean = per_condition["clean"]
42
+ families: dict[str, list] = {}
43
+ for k, v in per_condition.items():
44
+ if k == "clean":
45
+ continue
46
+ fam = k.split("@", 1)[0] # 'jpeg@30' -> 'jpeg'; 'blur' -> 'blur'
47
+ families.setdefault(fam, []).append(v)
48
+ family_auc = {fam: float(np.nanmean(vs)) for fam, vs in families.items()}
49
+ fam_vals = list(family_auc.values())
50
+ robust_mean = float(np.mean(fam_vals)) if fam_vals else float("nan")
51
+ robust_worst = float(np.min(fam_vals)) if fam_vals else float("nan")
52
+ return {
53
+ "auc_clean": clean,
54
+ "auc_robust_mean": robust_mean,
55
+ "auc_robust_worst": robust_worst,
56
+ "per_family_auc": family_auc,
57
+ "final": 0.5 * clean + 0.5 * robust_mean,
58
+ }
src/eval/predictions.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/eval/predictions.py
2
+ """Per-image prediction table for offline error analysis.
3
+
4
+ The eval harness already computes per-image sigmoid probabilities under every
5
+ (clean + corruption) condition and, historically, discarded them after folding
6
+ into roc_auc(). This module captures those probs into a tidy long-format table
7
+ so precision/recall and TP/FP/TN/FN sampling can be derived OFFLINE at ANY
8
+ threshold — nothing here recomputes a forward pass.
9
+
10
+ Schema (one row per image x condition), columns in this EXACT order:
11
+ img_id, image_path, source_dataset, generator_id, rel_path,
12
+ label, condition, severity, prob
13
+ - source_dataset/generator_id: provenance, so galleries stratify by dataset/generator
14
+ - rel_path : the STABLE source-relative address (source_dataset + rel_path re-addresses
15
+ the image via SourceResolver offline; image_path is an ephemeral /tmp path)
16
+ - condition: "clean" or a corruption name (jpeg/blur/resize/noise/jitter/crop)
17
+ - severity : None for clean, else the eval-grid severity value
18
+ - prob : sigmoid prob in [0,1] for that image under that condition
19
+ """
20
+ import pandas as pd
21
+
22
+ COLUMNS = ["img_id", "image_path", "source_dataset", "generator_id", "rel_path",
23
+ "native_w", "format", "label", "condition", "severity", "prob"]
24
+
25
+
26
+ def build_predictions_df(records) -> pd.DataFrame:
27
+ """Build the predictions DataFrame from an iterable of row dicts.
28
+
29
+ Each record must carry the COLUMNS keys. Column order is enforced so the
30
+ downstream (label, prob)-at-any-threshold contract is stable.
31
+ """
32
+ df = pd.DataFrame(list(records), columns=COLUMNS)
33
+ return df
34
+
35
+
36
+ def save_predictions(df: pd.DataFrame, out_dir, prefix="") -> dict[str, str]:
37
+ """Write {prefix}predictions.parquet AND {prefix}predictions.csv into out_dir.
38
+
39
+ Returns {"parquet": <path>, "csv": <path>}. Both formats are written: parquet
40
+ for typed/compact offline analysis, csv for eyeballing / spreadsheet use.
41
+ `prefix` (e.g. 'base_' / 'fuse4scalar_') keeps per-arm artifacts SEPARATE — the
42
+ v10 sequential A/B overwrote the base arm's files, destroying the paired oracle.
43
+ """
44
+ import os
45
+
46
+ os.makedirs(out_dir, exist_ok=True)
47
+ parquet_path = os.path.join(out_dir, f"{prefix}predictions.parquet")
48
+ csv_path = os.path.join(out_dir, f"{prefix}predictions.csv")
49
+ df.to_parquet(parquet_path, index=False)
50
+ df.to_csv(csv_path, index=False)
51
+ return {"parquet": parquet_path, "csv": csv_path}