Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/__init__.py +0 -0
- src/aigc_detector.egg-info/PKG-INFO +20 -0
- src/aigc_detector.egg-info/SOURCES.txt +80 -0
- src/aigc_detector.egg-info/dependency_links.txt +1 -0
- src/aigc_detector.egg-info/requires.txt +18 -0
- src/aigc_detector.egg-info/top_level.txt +8 -0
- src/analysis/__init__.py +0 -0
- src/analysis/format_probe.py +115 -0
- src/analysis/subset_weakness.py +95 -0
- src/config.py +81 -0
- src/data/__init__.py +0 -0
- src/data/aug/__init__.py +0 -0
- src/data/aug/augment.py +77 -0
- src/data/aug/augment_impls.py +146 -0
- src/data/aug/deshortcut.py +85 -0
- src/data/aug/random_resolution.py +61 -0
- src/data/aug/symmetry_audit.py +53 -0
- src/data/aug/warp.py +120 -0
- src/data/dataset.py +108 -0
- src/data/preprocess.py +76 -0
- src/data/provenance/__init__.py +0 -0
- src/data/provenance/audit.py +17 -0
- src/data/provenance/cleanup.py +60 -0
- src/data/provenance/dda_audit.py +16 -0
- src/data/provenance/dedup_audit.py +184 -0
- src/data/provenance/diskcheck.py +38 -0
- src/data/provenance/families.py +32 -0
- src/data/provenance/feature_cache.py +36 -0
- src/data/provenance/finalize.py +38 -0
- src/data/provenance/journal.py +48 -0
- src/data/provenance/manifest_build.py +54 -0
- src/data/provenance/orchestrate.py +122 -0
- src/data/provenance/push.py +11 -0
- src/data/provenance/rangezip.py +266 -0
- src/data/provenance/resolver.py +53 -0
- src/data/provenance/run_curator.py +110 -0
- src/data/provenance/selection.py +52 -0
- src/data/provenance/sources.py +502 -0
- src/data/provenance/splitter.py +118 -0
- src/data/provenance/subsample.py +34 -0
- src/data/provenance/val_holdout.py +192 -0
- src/data/provenance/wildfake.py +393 -0
- src/data/provenance/writer.py +98 -0
- src/data/shard_reader.py +49 -0
- src/eval/__init__.py +0 -0
- src/eval/error_gallery.py +69 -0
- src/eval/explain.py +99 -0
- src/eval/harness.py +210 -0
- src/eval/metrics.py +58 -0
- 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}
|