github-actions[bot] commited on
Commit
a2f55b2
·
0 Parent(s):

Deploy from GitHub 1af737237f5d10dfa9cf12849d98f47c3b30ce37 (filtered: code + Dockerfile + README + NOTICES only)

Browse files
Files changed (49) hide show
  1. Dockerfile +78 -0
  2. NOTICES.md +99 -0
  3. README.md +150 -0
  4. pyproject.toml +17 -0
  5. requirements.txt +67 -0
  6. scripts/dataset/README.md +159 -0
  7. scripts/dataset/augment_images.py +269 -0
  8. scripts/dataset/build_manifest.py +193 -0
  9. scripts/dataset/closed_eval_prompts.txt +104 -0
  10. scripts/dataset/fetch_open_images.py +138 -0
  11. scripts/dataset/generate_auraflow_synthetic.py +217 -0
  12. scripts/dataset/generate_flux_synthetic.py +168 -0
  13. scripts/dataset/generate_sd35_synthetic.py +205 -0
  14. scripts/dataset/generate_sdxl_synthetic.py +202 -0
  15. scripts/dataset/generation_utils.py +205 -0
  16. scripts/dataset/prompts.txt +1711 -0
  17. scripts/dataset/pull_stage3a_data.sh +147 -0
  18. scripts/dataset/rebuild_auraflow_manifest.py +226 -0
  19. scripts/dataset/run_stage3a_smoke.py +472 -0
  20. scripts/dataset/simulate_shared_copy.py +107 -0
  21. scripts/dataset/split.py +304 -0
  22. scripts/download_weights.py +27 -0
  23. scripts/eval_closed_generators.py +421 -0
  24. scripts/evaluate_head.py +291 -0
  25. scripts/precompute_embeddings.py +488 -0
  26. scripts/precompute_frequency_features.py +263 -0
  27. scripts/report_frequency_features.py +133 -0
  28. scripts/run_stage3a_pipeline.py +627 -0
  29. scripts/train_frequency_head.py +243 -0
  30. scripts/train_head.py +367 -0
  31. src/deepfake_scanner/__init__.py +3 -0
  32. src/deepfake_scanner/api/__init__.py +0 -0
  33. src/deepfake_scanner/api/schemas.py +97 -0
  34. src/deepfake_scanner/api/v1.py +219 -0
  35. src/deepfake_scanner/config.py +142 -0
  36. src/deepfake_scanner/detectors/__init__.py +5 -0
  37. src/deepfake_scanner/detectors/base.py +57 -0
  38. src/deepfake_scanner/detectors/clip_classifier.py +174 -0
  39. src/deepfake_scanner/detectors/ensemble.py +121 -0
  40. src/deepfake_scanner/detectors/face_swap.py +26 -0
  41. src/deepfake_scanner/detectors/frequency.py +26 -0
  42. src/deepfake_scanner/detectors/frequency_features.py +253 -0
  43. src/deepfake_scanner/preprocess.py +48 -0
  44. src/deepfake_scanner/provenance/__init__.py +3 -0
  45. src/deepfake_scanner/provenance/c2pa.py +346 -0
  46. src/deepfake_scanner/storage/__init__.py +3 -0
  47. src/deepfake_scanner/storage/blob.py +35 -0
  48. src/deepfake_scanner/storage/db.py +76 -0
  49. src/deepfake_scanner/verdict.py +87 -0
Dockerfile ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepFakeScanner — single-image deployment for HF Spaces, Cloud Run, Modal, etc.
2
+ #
3
+ # Build:
4
+ # docker build -t deepfake-scanner .
5
+ # Run:
6
+ # docker run -p 7860:7860 deepfake-scanner
7
+ #
8
+ # Lives at the repo root because HF Spaces' Docker SDK builds from ./Dockerfile.
9
+ # Port 7860 is the HF Spaces default. Override with the PORT env var on
10
+ # other platforms (Cloud Run injects PORT automatically).
11
+
12
+ FROM python:3.11-slim AS builder
13
+
14
+ ENV PYTHONDONTWRITEBYTECODE=1 \
15
+ PYTHONUNBUFFERED=1 \
16
+ PIP_NO_CACHE_DIR=1 \
17
+ PIP_DISABLE_PIP_VERSION_CHECK=1
18
+
19
+ # Build deps (some Python wheels still compile on slim).
20
+ RUN apt-get update && apt-get install -y --no-install-recommends \
21
+ build-essential \
22
+ libgl1 \
23
+ libglib2.0-0 \
24
+ && rm -rf /var/lib/apt/lists/*
25
+
26
+ WORKDIR /app
27
+
28
+ # Install CPU-only PyTorch first to keep the image lean (~700 MB instead of ~3 GB).
29
+ COPY requirements.txt .
30
+ RUN pip install --index-url https://download.pytorch.org/whl/cpu torch torchvision \
31
+ && pip install -r requirements.txt
32
+
33
+ # Copy source.
34
+ COPY src/ ./src/
35
+ COPY scripts/ ./scripts/
36
+ COPY pyproject.toml .
37
+
38
+ # Install the package.
39
+ RUN pip install -e .
40
+
41
+ # Pre-download CLIP backbone weights so first request isn't slow. This is a
42
+ # public download — no auth needed.
43
+ RUN python scripts/download_weights.py
44
+
45
+ # NOTE: the Stage 2 head weights (Veridicate/scanner-head-v1) are NOT
46
+ # pre-downloaded here. That repo is private, and HF Spaces does not expose
47
+ # secrets to a plain `RUN` at build time (it would need a BuildKit
48
+ # --mount=type=secret). Instead, ClipClassifier downloads the head at
49
+ # container startup using the HF_TOKEN secret, which IS available at
50
+ # runtime. The head is only ~530 KB so the startup cost is negligible.
51
+ # See config.py:head_checkpoint_hf_repo and clip_classifier.py.
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Final image
55
+ # ---------------------------------------------------------------------------
56
+ FROM python:3.11-slim
57
+
58
+ ENV PYTHONDONTWRITEBYTECODE=1 \
59
+ PYTHONUNBUFFERED=1 \
60
+ PORT=7860
61
+
62
+ RUN apt-get update && apt-get install -y --no-install-recommends \
63
+ libgl1 \
64
+ libglib2.0-0 \
65
+ && rm -rf /var/lib/apt/lists/*
66
+
67
+ # Copy installed packages and HF cache from builder.
68
+ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
69
+ COPY --from=builder /usr/local/bin /usr/local/bin
70
+ COPY --from=builder /root/.cache/huggingface /root/.cache/huggingface
71
+ COPY --from=builder /app /app
72
+
73
+ WORKDIR /app
74
+
75
+ EXPOSE 7860
76
+
77
+ # Use a wrapper so the PORT env var (Cloud Run) is honoured.
78
+ CMD ["sh", "-c", "uvicorn deepfake_scanner.api.v1:app --host 0.0.0.0 --port ${PORT:-7860}"]
NOTICES.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-Party Notices
2
+
3
+ This project is built on commercially-licensed open-source components only.
4
+ Every dependency below has been verified against its license terms.
5
+
6
+ ## Runtime dependencies
7
+
8
+ | Component | Version (≥) | License | Use |
9
+ |----------------------------|-------------|----------------|--------------------------------------------|
10
+ | Python | 3.11 | PSF | Runtime |
11
+ | PyTorch (CPU build) | 2.3 | BSD-3-Clause | Tensor / NN runtime |
12
+ | torchvision | 0.18 | BSD-3-Clause | Image transforms |
13
+ | HuggingFace `transformers` | 4.41 | Apache-2.0 | CLIP loading |
14
+ | HuggingFace `accelerate` | 0.30 | Apache-2.0 | Inference helpers |
15
+ | OpenAI CLIP ViT-B/32 | n/a | MIT | Frozen image-encoder weights |
16
+ | Pillow (PIL fork) | 10.3 | HPND | Image decoding |
17
+ | NumPy | 1.26 | BSD-3-Clause | Tensor ops |
18
+ | FastAPI | 0.111 | MIT | Web framework |
19
+ | Starlette | (transitive)| BSD-3-Clause | ASGI core |
20
+ | Uvicorn | 0.29 | BSD-3-Clause | ASGI server |
21
+ | python-multipart | 0.0.9 | Apache-2.0 | File-upload parsing |
22
+ | Pydantic | 2.7 | MIT | Schema validation |
23
+ | `c2pa-python` | 0.5 | Apache-2.0 | C2PA / Content-Credentials verification |
24
+
25
+ ## Development / test dependencies
26
+
27
+ | Component | License | Use |
28
+ |-----------|------------|----------------|
29
+ | pytest | MIT | Test runner |
30
+ | httpx | BSD-3-Clause | Test client |
31
+
32
+ ## Stage 2 dataset-curation dependencies (NOT shipped with the inference image)
33
+
34
+ These are only installed on the GPU machine that builds the dataset.
35
+
36
+ | Component | License | Use |
37
+ |-------------------------------------|--------------|-----------------------------------------|
38
+ | FiftyOne | Apache-2.0 | Open Images V7 download / sampling |
39
+ | `diffusers` (HuggingFace) | Apache-2.0 | Flux.1-schnell pipeline |
40
+ | `black-forest-labs/FLUX.1-schnell` | Apache-2.0 | Synthetic-image generator weights |
41
+
42
+ ## Stage 3A candidate generator license review
43
+
44
+ Review date: 2026-05-15. No Stage 3A images have been generated yet; this table
45
+ records which generators are approved for the initial multi-generator dataset.
46
+
47
+ | Component | License / terms | Status | Use |
48
+ |---|---|---|---|
49
+ | `black-forest-labs/FLUX.1-schnell` | Apache-2.0 ([HF model card](https://huggingface.co/black-forest-labs/FLUX.1-schnell)) | Approved | Already-used rectified-flow baseline. |
50
+ | `stabilityai/stable-diffusion-xl-base-1.0` | CreativeML Open RAIL++-M ([HF license](https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/LICENSE.md)) | Approved with use-policy restrictions | Stage 3A diffusion U-Net training source. Outputs may be used for detector training, subject to OpenRAIL use restrictions; do not use to train a competing generative model. |
51
+ | `stabilityai/stable-diffusion-3.5-medium` | Stability AI Community License ([HF model card](https://huggingface.co/stabilityai/stable-diffusion-3.5-medium), [Stability license](https://stability.ai/license)) | Conditionally approved | Stage 3A MMDiT / diffusion-transformer training source while Veridicate remains under the Community License revenue threshold and registers as required. Enterprise license required before use if annual revenue exceeds USD $1M. Outputs must not be used to create or improve a foundational generative AI model. |
52
+ | `fal/AuraFlow-v0.3` | Apache-2.0 ([HF model card](https://huggingface.co/fal/AuraFlow-v0.3)) | Approved | Stage 3A independent rectified-flow training source. |
53
+ | `PixArt-alpha/PixArt-Sigma-XL-2-1024-MS` | HF model card labels weights as CreativeML Open RAIL++-M; project code is Apache-2.0, but the model card's direct-use section says research purposes only ([HF model card](https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-1024-MS), [GitHub repo](https://github.com/PixArt-alpha/PixArt-sigma)) | Deferred / blocked pending clarification | Do not use for Stage 3A training until the model-weight license and model-card intended-use language are clarified. |
54
+
55
+ ## Datasets used at training time
56
+
57
+ | Dataset | License | Notes |
58
+ |----------------------------|--------------|-----------------------------------------|
59
+ | Open Images V7 | CC BY 2.0 | Real-image class. 50,000 images sampled. Attribution preserved in `data/manifest.csv`. |
60
+ | Self-generated (Flux schnell) | Apache-2.0 (model) | AI-image class. 50,000 images generated. We own the outputs; recorded in manifest with prompt + seed. |
61
+
62
+ ## Distributed artifacts (downstream of training)
63
+
64
+ | Artifact | License | Notes |
65
+ |---|---|---|
66
+ | `Veridicate/scanner-head-v1` (private HF Hub model repo) | Apache-2.0 | Trained CLIP classifier head weights (~530 KB). Downloaded at container startup by `ClipClassifier` using the `HF_TOKEN` Space secret. Trained on the Open Images + Flux-schnell dataset above, so the output weights are unencumbered. Repo is private (commercial-IP reasons) but the license on the weights themselves is Apache-2.0. |
67
+
68
+ ## Explicitly NOT used (research-only / non-commercial / unclear license)
69
+
70
+ These are deliberately excluded to preserve commercial usability:
71
+
72
+ - **FaceForensics++** — research/educational use only.
73
+ - **DFDC (Deepfake Detection Challenge)** — research only.
74
+ - **Celeb-DF** — research only.
75
+ - **`flux.1-dev`** — non-commercial license. Use `flux.1-schnell` instead.
76
+ - **Outputs from closed-API image generators** — including Google
77
+ Gemini / Imagen 3 / "Nano Banana", OpenAI DALL-E, Midjourney, xAI
78
+ Grok Imagine / Aurora, Adobe Firefly. Their terms of service either
79
+ restrict using outputs as training data for downstream models or
80
+ leave it ambiguous enough that, for a commercial detector, the safe
81
+ reading is "not allowed without an explicit license." Stage 3 of the
82
+ roadmap (see [`docs/plan.md`](docs/plan.md)) explicitly *catches*
83
+ these generators by training on the *open-source* equivalents of
84
+ their underlying architectures (diffusion U-Net, DiT, rectified flow,
85
+ GAN) and learning the family fingerprint — without ever using their
86
+ outputs as training data. Stage 5 may revisit the closed-API path
87
+ with negotiated enterprise license agreements, but only once
88
+ revenue justifies the legal and per-image API spend.
89
+ - Any HuggingFace model whose license tag is `cc-by-nc-*`,
90
+ `non-commercial`, or unspecified.
91
+
92
+ ## Updating this document
93
+
94
+ Add a row whenever a new dependency is introduced. If a license is unclear,
95
+ flag it and resolve before merging — never assume.
96
+
97
+ For **forward-looking** license concerns (revenue thresholds, deferred
98
+ items, in-flight investigations), see [`docs/licensing.md`](docs/licensing.md).
99
+ That file is the live tracker; this file is the approved-and-shipped record.
README.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DeepFakeScanner
3
+ sdk: docker
4
+ app_port: 7860
5
+ pinned: false
6
+ license: apache-2.0
7
+ short_description: AI-generated & manipulated image detection (v0.4.0)
8
+ ---
9
+
10
+ # DeepFakeScanner
11
+
12
+ A commercial deepfake / AI-generated image detection service.
13
+
14
+ ## What it is
15
+
16
+ A FastAPI web service that scans an uploaded image and returns a structured
17
+ verdict: `authentic`, `ai_generated`, `deepfake`, `edited`, or `uncertain` —
18
+ with per-class probabilities, per-detector signals, and a C2PA provenance
19
+ check.
20
+
21
+ ## Live
22
+
23
+ | | URL |
24
+ |---|---|
25
+ | Frontend | https://veridicate.com |
26
+ | API | https://api.veridicate.com |
27
+ | Source (HF mirror) | https://huggingface.co/spaces/veridicate/scanner |
28
+
29
+ ## Status
30
+
31
+ **Stage 2 deployed (`v0.3.0-stage2`).** The CLIP classifier head was
32
+ fine-tuned on a 100k commercially-licensed dataset (50k Open Images +
33
+ 50k Flux.1-schnell) and is currently serving real predictions in
34
+ production.
35
+
36
+ In-distribution test-set metrics (10k held-out images):
37
+ - Accuracy: **98.44%**
38
+ - Precision (AI class): 98.25%, recall: 98.64%, F1: 98.44%
39
+
40
+ **Stage 3A wired + verified 2026-05-30 (`v0.4.0-stage3a`); ships to
41
+ production on merge of `feat/akila-20260515 → main`.** Multi-generator
42
+ dataset built: 50k Flux + 20k SDXL + 20k SD 3.5 Medium + 10k AuraFlow
43
+ on the AI side, matched authentic from Open Images V7. SDXL held out of
44
+ training so the heldout split is a true generalisation test. The trained
45
+ head (`head_v3a.pt`) is published to the private HF Hub repo, the runtime
46
+ config now defaults to it (`config.py`), and a filtered, test-gated
47
+ GitHub Action deploys the inference service on merge. Headline verified
48
+ numbers vs the Stage 2 baseline:
49
+
50
+ | Split | Baseline | Candidate | Δ |
51
+ |---|---|---|---|
52
+ | heldout SDXL (UNSEEN in training) | 83.93% | **89.48%** | **+5.55 pp** |
53
+ | test (in-distribution) | 96.47% | 98.43% | +1.96 pp |
54
+ | test_augmented (robustness) | 93.96% | 98.41% | +4.46 pp |
55
+
56
+ The +5.55 pp on the SDXL holdout is the load-bearing number — SDXL was
57
+ held entirely out of training, so it's the closest available proxy for
58
+ how the model will behave on generators it never saw. The
59
+ family-fingerprint approach (train on a diverse mix of open
60
+ generators, inherit coverage of closed generators) is validated.
61
+ Detailed audit trail in
62
+ [`docs/stage3a-implementation.md`](docs/stage3a-implementation.md).
63
+
64
+ **Known limitation (until the Stage 3A merge deploys):** the live
65
+ model is still the Stage 2 head, trained on Flux.1-schnell only. Other
66
+ generators (Gemini/Imagen 3, DALL-E 3, Midjourney, Grok, Stable
67
+ Diffusion) are out-of-distribution for the *currently-live* model and
68
+ it often returns `uncertain` verdicts. The Stage 3A head (queued to
69
+ ship) closes most of this gap. Rollback is a one-line env override
70
+ (`MODEL_VERSION` + `HEAD_CHECKPOINT_HF_FILENAME`) — both heads live in
71
+ the same private HF Hub repo.
72
+
73
+ ## Roadmap at a glance
74
+
75
+ | Stage | What it delivers | Status |
76
+ |---|---|---|
77
+ | **1** | Working website, API, deploy pipeline. Detector returns random guesses. | ✅ Done |
78
+ | **2** | A trained classifier — 98% accurate on Flux-family AI images. | ✅ Done, live (`v0.3.0-stage2`) |
79
+ | **3A** | Broad coverage across the AI image-generation landscape — CLIP head retrained on Flux + SDXL + SD 3.5 + AuraFlow. | ✅ Wired + verified 2026-05-30 (`v0.4.0-stage3a`); ships on merge to `main` |
80
+ | **3B** | Frequency-artifact detector (FFT/DCT, generator-agnostic) brought online. | 🔭 Queued after Stage 3A ships |
81
+ | **4** | Production scale: faster hosting, paid tier, user accounts. | ⏸️ After Stage 3 |
82
+ | **5** | Enterprise capability: licensed paid-API training data, face-swap detection, adversarial robustness. | 🔭 Future |
83
+
84
+ Full roadmap with per-stage strengths, weaknesses, and how each
85
+ weakness gets fixed: [`docs/plan.md`](docs/plan.md). Plain-English
86
+ summary up front; technical detail below; glossary at the end for
87
+ non-technical readers.
88
+
89
+ ## Quick start
90
+
91
+ ```bash
92
+ # CPU PyTorch first (lean install)
93
+ pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
94
+ pip install -r requirements.txt
95
+ pip install -e .
96
+
97
+ # Pre-download CLIP weights
98
+ python scripts/download_weights.py
99
+
100
+ # Run the server
101
+ uvicorn deepfake_scanner.api.v1:app --reload --port 7860
102
+ ```
103
+
104
+ Then:
105
+
106
+ ```bash
107
+ curl -F "file=@some_image.jpg" http://localhost:7860/v1/scan/image | jq
108
+ ```
109
+
110
+ Or against the live API:
111
+
112
+ ```bash
113
+ curl -F "file=@some_image.jpg" https://api.veridicate.com/v1/scan/image | jq
114
+ ```
115
+
116
+ Dataset-generation dependencies are documented in the optional GPU section of
117
+ [`requirements.txt`](requirements.txt). Keep those packages out of the
118
+ production inference image.
119
+
120
+ ## API
121
+
122
+ - `GET /health` — liveness probe
123
+ - `GET /v1/info` — model + config metadata
124
+ - `POST /v1/scan/image` — scan an image (multipart/form-data, max 10 MB,
125
+ JPEG/PNG/WebP)
126
+
127
+ See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the full response schema.
128
+
129
+ ## Privacy
130
+
131
+ Visitor uploads are processed in-memory and **never persisted**. Only scan
132
+ metadata (verdict, confidence, latency, model version) is recorded.
133
+
134
+ ## Documentation
135
+
136
+ - [`docs/plan.md`](docs/plan.md) — **product roadmap** with per-stage
137
+ strengths, weaknesses, and fix paths. Written so a non-technical
138
+ reader can follow the strategy, with deeper technical detail and a
139
+ glossary inline.
140
+ - [`ARCHITECTURE.md`](ARCHITECTURE.md) — technical design of the
141
+ detection pipeline + API contract
142
+ - [`docs/decisions.md`](docs/decisions.md) — running decision log
143
+ (good context if picking up this project later)
144
+ - [`NOTICES.md`](NOTICES.md) — third-party licensing record
145
+ - [`CLAUDE.md`](CLAUDE.md) — project context (auto-loaded by Claude Code)
146
+ - [`scripts/dataset/README.md`](scripts/dataset/README.md) — dataset curation pipeline
147
+
148
+ ## License
149
+
150
+ Apache 2.0.
pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "deepfake-scanner"
7
+ version = "0.2.0"
8
+ description = "Detect AI-generated and manipulated images via a versioned FastAPI inference service."
9
+ requires-python = ">=3.11"
10
+ license = { text = "Apache-2.0" }
11
+
12
+ [tool.setuptools.packages.find]
13
+ where = ["src"]
14
+
15
+ [tool.pytest.ini_options]
16
+ testpaths = ["tests"]
17
+ addopts = "-q"
requirements.txt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------------------------------------------------------------------------
2
+ # IMPORTANT — PyTorch CPU-only install (Stage 1 deployment target is CPU)
3
+ #
4
+ # Do NOT run plain `pip install torch` — that pulls the CUDA build (~2.5 GB).
5
+ # Use the CPU wheel index instead:
6
+ #
7
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
8
+ #
9
+ # Then install the rest:
10
+ # pip install -r requirements.txt
11
+ #
12
+ # All packages below are commercially licensed (MIT / Apache-2.0 / BSD-3 / HPND).
13
+ # ---------------------------------------------------------------------------
14
+
15
+ # Deep-learning runtime
16
+ torch>=2.3.0
17
+ torchvision>=0.18.0
18
+
19
+ # CLIP backbone & general HF model loading
20
+ transformers>=4.41.0,<5 # Apache-2.0 — pinned to 4.x: 5.x changed CLIPModel.get_image_features to return a wrapper object
21
+ accelerate>=0.30.0 # Apache-2.0 — recommended companion for transformers
22
+
23
+ # Image I/O & preprocessing
24
+ Pillow>=10.3.0
25
+ numpy>=1.26.4
26
+
27
+ # C2PA / Content Credentials verification
28
+ # Apache-2.0; native deps may not build on every platform — provenance/c2pa.py
29
+ # degrades gracefully if the import fails.
30
+ # Upper bound is deliberate: the Reader API changed between 0.2x and 0.3x
31
+ # (`Reader.from_stream` was removed) and the blanket except in
32
+ # provenance/c2pa.py turned that into a SILENT three-month outage —
33
+ # see docs/decisions.md 2026-08-09. Verify provenance still works before
34
+ # widening this, and run tests/test_provenance_c2pa.py on a machine that
35
+ # has a C2PA-signed sample.
36
+ c2pa-python>=0.32.0,<0.33
37
+
38
+ # API server
39
+ fastapi>=0.111.0
40
+ uvicorn[standard]>=0.29.0
41
+ python-multipart>=0.0.9 # required for FastAPI file uploads
42
+ pydantic>=2.7.0
43
+
44
+ # Testing
45
+ pytest>=8.2.0
46
+ httpx>=0.27.0 # required by FastAPI TestClient
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Optional dataset-curation deps (NOT installed by this file).
50
+ #
51
+ # This file is the consolidated Python dependency ledger, but the production
52
+ # Dockerfile installs it directly. Keep GPU/dataset-only packages commented so
53
+ # the inference image stays lean. On a rented GPU box, install the CUDA torch
54
+ # wheel first, then install the dataset packages listed below explicitly:
55
+ #
56
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
57
+ # pip install 'fiftyone>=0.24.0' 'diffusers>=0.30.0' 'sentencepiece>=0.2.0' 'protobuf>=4.25.0'
58
+ #
59
+ # Dataset-only packages, licenses, and purpose:
60
+ # fiftyone>=0.24.0 # Apache-2.0 — Open Images V7 sampling
61
+ # diffusers>=0.30.0 # Apache-2.0 — Flux/SDXL/SD3/AuraFlow pipelines
62
+ # sentencepiece>=0.2.0 # Apache-2.0 — required by Flux's T5 tokenizer
63
+ # protobuf>=4.25.0 # BSD-3 — required by sentencepiece
64
+ #
65
+ # Stage 2/3 head training itself runs on cached embeddings and uses only the
66
+ # installed runtime/test dependencies above.
67
+ # ---------------------------------------------------------------------------
scripts/dataset/README.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dataset curation
2
+
3
+ This folder contains the scripts that produced the **commercially-licensed
4
+ training dataset** for Stage 2 fine-tuning (DONE, 2026-05).
5
+
6
+ Stage 3A extends this folder with one new generation script per
7
+ approved open-source generator (initially AuraFlow, SDXL, and SD 3.5
8
+ Medium; PixArt-Σ is deferred pending license/intended-use clarification).
9
+ The rest of the pipeline
10
+ (`build_manifest.py`, `split.py`, `../precompute_embeddings.py`,
11
+ `../train_head.py`) is generator-agnostic and will be reused as-is —
12
+ the manifest format already supports multiple AI source labels.
13
+ See [`docs/plan.md`](../../docs/plan.md) Stage 3 for the full plan.
14
+
15
+ ## What we're building
16
+
17
+ A dataset of roughly **100k images** total:
18
+
19
+ | Split | Source | Class | License |
20
+ |------------|---------------------------------------|-----------------|--------------------|
21
+ | ~50k real | Open Images V7 (sampled) | `authentic` | CC BY 2.0 |
22
+ | ~50k AI | Self-generated with Flux.1-schnell | `ai_generated` | Apache 2.0 weights → outputs are unrestricted |
23
+
24
+ Stage 2 trains the CLIP classifier head on this 2-class data. The `deepfake`
25
+ and `edited` classes will be added later via separate scripts.
26
+
27
+ ## Workflow
28
+
29
+ 1. **`fetch_open_images.py`** — downloads a stratified sample of Open Images
30
+ into `data/raw/real/`. Run once. Idempotent.
31
+
32
+ 2. **`generate_flux_synthetic.py`** — generates AI images with Flux.1-schnell
33
+ into `data/raw/ai_generated/`. Run on a rented cloud GPU
34
+ (~$30 total at RunPod / Lambda Labs spot rates).
35
+
36
+ **`generate_sdxl_synthetic.py`** — generates AI images with SDXL into
37
+ `data/raw/ai_generated/sdxl/`. Use `--dry-run` locally before running on a
38
+ GPU box.
39
+
40
+ **`generate_sd35_synthetic.py`** — generates AI images with SD 3.5 Medium
41
+ into `data/raw/ai_generated/sd35-medium/`. This source is conditionally
42
+ approved under the Stability AI Community License; review `NOTICES.md`
43
+ before running it.
44
+
45
+ **`generate_auraflow_synthetic.py`** — generates AI images with AuraFlow
46
+ v0.3 into `data/raw/ai_generated/auraflow-v0.3/`. Use `--dry-run` locally
47
+ before running on a GPU box.
48
+
49
+ Stage 3A generator scripts should reuse `generation_utils.py` for prompt
50
+ loading, deterministic seeds, stable output keys, file hashes, image
51
+ dimensions, approved generator metadata, and manifest-row construction.
52
+
53
+ 3. **`build_manifest.py`** — produces `data/manifest.csv` with one row per
54
+ image. Required columns are
55
+ `path,class,source,license,license_url,sha256`. Stage 3A adds optional
56
+ generator metadata columns:
57
+ `generator,model_family,model_id,prompt,seed,width,height,generation_params_json`.
58
+ Missing optional values are written as empty strings, and approved
59
+ AI-generator metadata is backfilled from known `source` values when older
60
+ fragments do not include the optional columns. You can pass fragments
61
+ explicitly with `--inputs`, or point `--input-dir` at `data/raw` to discover
62
+ all `*_manifest.csv` fragments recursively. This is the
63
+ **legal record** that protects you when you commercialise and the
64
+ source-of-truth for per-generator evaluation.
65
+
66
+ 4. **`split.py`** — produces train/val/test splits stratified by class. Stage
67
+ 3A adds optional generator-aware splitting with
68
+ `--stratify-by class-generator`, plus `--holdout-generator <name>` to write
69
+ a `heldout.csv` evaluation split while excluding that generator from
70
+ train/val/test.
71
+
72
+ After splitting, training itself lives outside this directory:
73
+
74
+ 5. **`augment_images.py`** — creates deterministic Stage 3A robustness
75
+ augmentations from any manifest or split CSV. It writes new JPEG images plus
76
+ a manifest-compatible CSV with `original_path`, `augmentation`,
77
+ `augmentation_seed`, and `augmentation_params_json`. Run it on `train.csv`
78
+ for augmented training rows, or on `val.csv` / `test.csv` into separate
79
+ manifests such as `test_augmented.csv` for robustness evaluation while
80
+ keeping the clean splits unchanged.
81
+
82
+ 6. **`../precompute_embeddings.py`** — encodes every image through frozen
83
+ CLIP once and caches the 512-d feature vectors. Runs on CPU (1-4 hours
84
+ on the dev laptop). One-shot per dataset version. By default it encodes
85
+ clean `train.csv`, `val.csv`, and `test.csv`. Stage 3A adds
86
+ `--train-augment-manifest train_augmented.csv` to append augmented rows to
87
+ `train.npz`, and `--extra-split test_augmented=test_augmented.csv` for
88
+ explicit augmented robustness-eval embeddings.
89
+
90
+ 7. **`../train_head.py`** — trains the small classifier head on the cached
91
+ features. Seconds per epoch on CPU; iterate hyperparameters freely. Stage
92
+ 3A reports overall metrics, per-source/per-generator/per-family metrics when
93
+ embedding metadata is available, and augmentation robustness metrics for
94
+ explicitly encoded augmented eval splits. Use `--report-out` to save the
95
+ JSON report.
96
+
97
+ 8. **`../evaluate_head.py`** — evaluates a candidate checkpoint, optionally
98
+ against a Stage 2 baseline checkpoint, on any cached embedding split
99
+ (`test`, `heldout`, `test_augmented`, etc.). It writes a reproducible JSON
100
+ report with overall, per-generator, held-out-generator, uncertainty, and
101
+ augmentation robustness metrics.
102
+
103
+ 9. **`run_stage3a_smoke.py`** — runs a tiny local fixture pipeline before
104
+ expensive GPU work. It builds fixture images/manifests, splits with an SDXL
105
+ holdout, creates train/test augmentations, writes deterministic mocked
106
+ 512-d embeddings, trains a smoke head, and evaluates it against a constant
107
+ baseline:
108
+
109
+ ```bash
110
+ python scripts/dataset/run_stage3a_smoke.py \
111
+ --work-dir /tmp/deepfakescanner-stage3a-smoke
112
+ ```
113
+
114
+ This intentionally bypasses real CLIP precompute so the smoke test stays
115
+ fast and offline; use `../precompute_embeddings.py` for real dataset runs.
116
+
117
+ 10. **`../run_stage3a_pipeline.py`** — orchestrates the full Stage 3A sequence
118
+ after the smoke test passes. It can plan the run, execute GPU generation,
119
+ build the manifest, split, augment, precompute embeddings, train the
120
+ candidate head, evaluate against the Stage 2 baseline, write a JSON
121
+ ship/no-ship recommendation, and optionally upload an accepted checkpoint
122
+ to the private HF Hub repo.
123
+
124
+ ```bash
125
+ python scripts/run_stage3a_pipeline.py --dry-run --stop-after evaluate
126
+ python scripts/run_stage3a_pipeline.py --stop-after generate
127
+ python scripts/run_stage3a_pipeline.py \
128
+ --skip-generation \
129
+ --baseline data/checkpoints/head_v1.pt
130
+ ```
131
+
132
+ Publishing is intentionally opt-in via `--publish-if-accepted` and requires
133
+ `HF_TOKEN`.
134
+
135
+ For a collaborator-facing GPU handoff, use
136
+ [`docs/stage3a-gpu-collaborator-guide.md`](../../docs/stage3a-gpu-collaborator-guide.md).
137
+
138
+ ## Why each piece is licensed for commercial use
139
+
140
+ - **Open Images V7** — Google's dataset, all images are CC BY 2.0 (commercial
141
+ use allowed with attribution). Attribution lives in `manifest.csv`.
142
+ - **Flux.1-schnell** — released by Black Forest Labs under Apache 2.0. Outputs
143
+ are not restricted; you own them. (Note: `flux.1-dev` is non-commercial —
144
+ do NOT use it.)
145
+ - **Stage 3A approved additions** — SDXL, SD 3.5 Medium, and AuraFlow were
146
+ reviewed on 2026-05-15. See [`NOTICES.md`](../../NOTICES.md) for the exact
147
+ license status and constraints before generating any images.
148
+ - **NOT used here**: FaceForensics++, DFDC, Celeb-DF (research-only),
149
+ outputs from closed-API generators (Gemini, Midjourney, DALL-E, Grok,
150
+ Firefly — ToS restrictions or ambiguous terms), `flux.1-dev`
151
+ (non-commercial), and PixArt-Σ until its model-card intended-use ambiguity
152
+ is clarified. See [`NOTICES.md`](../../NOTICES.md) for the full list and
153
+ [`docs/decisions.md`](../../docs/decisions.md) 2026-05-14 and 2026-05-15
154
+ entries for the reasoning.
155
+
156
+ ## Running it
157
+
158
+ The scripts are designed to be run independently. See each script's docstring
159
+ for prerequisites and command-line flags.
scripts/dataset/augment_images.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Create deterministic Stage 3A image augmentations from a manifest or split CSV.
3
+
4
+ The script writes new image files plus a manifest-compatible CSV fragment. It
5
+ does not modify the source images or source CSV, so validation/test splits stay
6
+ clean unless you explicitly run this script for a separate augmented eval set.
7
+
8
+ Augmentations
9
+ -------------
10
+ - JPEG recompression
11
+ - Downscale then upscale
12
+ - Deterministic crop
13
+ - Mild blur or sharpen
14
+ - Mild color/contrast/brightness jitter
15
+
16
+ Usage
17
+ -----
18
+ python scripts/dataset/augment_images.py \\
19
+ --manifest data/train.csv \\
20
+ --data-root data \\
21
+ --out-dir data/augmented/train \\
22
+ --out-manifest data/train_augmented.csv \\
23
+ --copies 1 \\
24
+ --seed 0
25
+
26
+ For a separate robustness eval set, point --manifest at val.csv or test.csv and
27
+ write to a distinct output manifest such as data/test_augmented.csv.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import csv
33
+ import hashlib
34
+ import json
35
+ import random
36
+ from io import BytesIO
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from PIL import Image, ImageEnhance, ImageFilter
41
+
42
+
43
+ AUGMENTATION_FIELDS = [
44
+ "width",
45
+ "height",
46
+ "original_path",
47
+ "augmentation",
48
+ "augmentation_seed",
49
+ "augmentation_params_json",
50
+ ]
51
+
52
+
53
+ def _stable_seed(global_seed: int, path: str, copy_index: int) -> int:
54
+ payload = f"{global_seed}|{path}|{copy_index}".encode("utf-8")
55
+ return int(hashlib.sha256(payload).hexdigest()[:16], 16) % (2**31)
56
+
57
+
58
+ def _sha256_file(path: Path) -> str:
59
+ h = hashlib.sha256()
60
+ with path.open("rb") as fh:
61
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
62
+ h.update(chunk)
63
+ return h.hexdigest()
64
+
65
+
66
+ def _params_json(params: dict[str, Any]) -> str:
67
+ return json.dumps(params, sort_keys=True, separators=(",", ":"))
68
+
69
+
70
+ def _relative_to_data_root(path: Path, data_root: Path) -> str:
71
+ try:
72
+ return path.relative_to(data_root).as_posix()
73
+ except ValueError as exc:
74
+ raise ValueError(
75
+ f"Output path {path} is not under data root {data_root}; "
76
+ "choose an --out-dir inside --data-root"
77
+ ) from exc
78
+
79
+
80
+ def _resize_roundtrip(
81
+ image: Image.Image,
82
+ rng: random.Random,
83
+ ) -> tuple[Image.Image, dict]:
84
+ width, height = image.size
85
+ scale = rng.uniform(0.65, 0.95)
86
+ small_size = (
87
+ max(1, int(round(width * scale))),
88
+ max(1, int(round(height * scale))),
89
+ )
90
+ resample_down = Image.Resampling.BICUBIC
91
+ resample_up = Image.Resampling.BILINEAR
92
+ resized = image.resize(small_size, resample_down).resize(
93
+ (width, height),
94
+ resample_up,
95
+ )
96
+ return resized, {"resize_scale": round(scale, 4)}
97
+
98
+
99
+ def _crop_and_restore(
100
+ image: Image.Image,
101
+ rng: random.Random,
102
+ ) -> tuple[Image.Image, dict]:
103
+ width, height = image.size
104
+ crop_scale = rng.uniform(0.9, 1.0)
105
+ crop_w = max(1, int(round(width * crop_scale)))
106
+ crop_h = max(1, int(round(height * crop_scale)))
107
+ max_left = max(0, width - crop_w)
108
+ max_top = max(0, height - crop_h)
109
+ left = rng.randint(0, max_left) if max_left else 0
110
+ top = rng.randint(0, max_top) if max_top else 0
111
+ cropped = image.crop((left, top, left + crop_w, top + crop_h))
112
+ restored = cropped.resize((width, height), Image.Resampling.BICUBIC)
113
+ return restored, {
114
+ "crop_scale": round(crop_scale, 4),
115
+ "crop_left": left,
116
+ "crop_top": top,
117
+ }
118
+
119
+
120
+ def _filter(image: Image.Image, rng: random.Random) -> tuple[Image.Image, dict]:
121
+ mode = rng.choice(["none", "blur", "sharpen"])
122
+ if mode == "blur":
123
+ radius = rng.uniform(0.15, 0.45)
124
+ return image.filter(ImageFilter.GaussianBlur(radius=radius)), {
125
+ "filter": mode,
126
+ "blur_radius": round(radius, 4),
127
+ }
128
+ if mode == "sharpen":
129
+ return image.filter(ImageFilter.SHARPEN), {"filter": mode}
130
+ return image, {"filter": mode}
131
+
132
+
133
+ def _enhance(image: Image.Image, rng: random.Random) -> tuple[Image.Image, dict]:
134
+ brightness = rng.uniform(0.92, 1.08)
135
+ contrast = rng.uniform(0.9, 1.1)
136
+ color = rng.uniform(0.9, 1.1)
137
+ image = ImageEnhance.Brightness(image).enhance(brightness)
138
+ image = ImageEnhance.Contrast(image).enhance(contrast)
139
+ image = ImageEnhance.Color(image).enhance(color)
140
+ return image, {
141
+ "brightness": round(brightness, 4),
142
+ "contrast": round(contrast, 4),
143
+ "color": round(color, 4),
144
+ }
145
+
146
+
147
+ def _jpeg_roundtrip(image: Image.Image, rng: random.Random) -> tuple[Image.Image, dict]:
148
+ quality = rng.randint(55, 95)
149
+ buffer = BytesIO()
150
+ image.save(buffer, format="JPEG", quality=quality, optimize=False)
151
+ buffer.seek(0)
152
+ with Image.open(buffer) as jpeg:
153
+ jpeg.load()
154
+ result = jpeg.convert("RGB")
155
+ return result, {"jpeg_quality": quality}
156
+
157
+
158
+ def augment_image(image: Image.Image, seed: int) -> tuple[Image.Image, dict[str, Any]]:
159
+ """Return a deterministic augmented image and its parameter record."""
160
+ rng = random.Random(seed)
161
+ augmented = image.convert("RGB")
162
+ params: dict[str, Any] = {"version": "stage3a-v1"}
163
+
164
+ augmented, resize_params = _resize_roundtrip(augmented, rng)
165
+ params.update(resize_params)
166
+ augmented, crop_params = _crop_and_restore(augmented, rng)
167
+ params.update(crop_params)
168
+ augmented, filter_params = _filter(augmented, rng)
169
+ params.update(filter_params)
170
+ augmented, enhance_params = _enhance(augmented, rng)
171
+ params.update(enhance_params)
172
+ augmented, jpeg_params = _jpeg_roundtrip(augmented, rng)
173
+ params.update(jpeg_params)
174
+
175
+ return augmented, params
176
+
177
+
178
+ def _output_path(out_dir: Path, seed: int, original_path: str, copy_index: int) -> Path:
179
+ key = hashlib.sha256(f"{original_path}|{seed}|{copy_index}".encode()).hexdigest()
180
+ return out_dir / f"{key[:24]}.jpg"
181
+
182
+
183
+ def _augmented_row(
184
+ row: dict[str, str],
185
+ *,
186
+ original_path: str,
187
+ output_path: Path,
188
+ data_root: Path,
189
+ seed: int,
190
+ params: dict[str, Any],
191
+ ) -> dict[str, str]:
192
+ updated = dict(row)
193
+ updated["path"] = _relative_to_data_root(output_path, data_root)
194
+ updated["sha256"] = _sha256_file(output_path)
195
+ updated["width"] = str(params["width"])
196
+ updated["height"] = str(params["height"])
197
+ updated["original_path"] = original_path
198
+ updated["augmentation"] = params["version"]
199
+ updated["augmentation_seed"] = str(seed)
200
+ updated["augmentation_params_json"] = _params_json(params)
201
+ return updated
202
+
203
+
204
+ def main() -> None:
205
+ parser = argparse.ArgumentParser(
206
+ description=__doc__,
207
+ formatter_class=argparse.RawDescriptionHelpFormatter,
208
+ )
209
+ parser.add_argument("--manifest", type=Path, required=True)
210
+ parser.add_argument("--data-root", type=Path, required=True)
211
+ parser.add_argument("--out-dir", type=Path, required=True)
212
+ parser.add_argument("--out-manifest", type=Path, required=True)
213
+ parser.add_argument("--copies", type=int, default=1)
214
+ parser.add_argument("--seed", type=int, default=0)
215
+ args = parser.parse_args()
216
+
217
+ if args.copies < 1:
218
+ raise ValueError("--copies must be >= 1")
219
+
220
+ data_root = args.data_root.resolve()
221
+ out_dir = args.out_dir.resolve()
222
+ out_manifest = args.out_manifest.resolve()
223
+ out_dir.mkdir(parents=True, exist_ok=True)
224
+ out_manifest.parent.mkdir(parents=True, exist_ok=True)
225
+
226
+ with args.manifest.open() as fh:
227
+ reader = csv.DictReader(fh)
228
+ source_rows = list(reader)
229
+ source_fieldnames = reader.fieldnames or []
230
+
231
+ rows: list[dict[str, str]] = []
232
+ for row in source_rows:
233
+ original_path = row["path"]
234
+ src = data_root / original_path
235
+ for copy_index in range(args.copies):
236
+ seed = _stable_seed(args.seed, original_path, copy_index)
237
+ dst = _output_path(out_dir, seed, original_path, copy_index)
238
+ with Image.open(src) as image:
239
+ augmented, params = augment_image(image, seed)
240
+ params["width"], params["height"] = augmented.size
241
+ augmented.save(dst, format="JPEG", quality=params["jpeg_quality"])
242
+
243
+ rows.append(
244
+ _augmented_row(
245
+ row,
246
+ original_path=original_path,
247
+ output_path=dst,
248
+ data_root=data_root,
249
+ seed=seed,
250
+ params=params,
251
+ )
252
+ )
253
+
254
+ fieldnames = [
255
+ *source_fieldnames,
256
+ *[field for field in AUGMENTATION_FIELDS if field not in source_fieldnames],
257
+ ]
258
+ with out_manifest.open("w", newline="") as fh:
259
+ writer = csv.DictWriter(fh, fieldnames=fieldnames)
260
+ writer.writeheader()
261
+ writer.writerows(rows)
262
+
263
+ print(f"Augmented rows: {len(rows)}")
264
+ print(f"Images: {out_dir}")
265
+ print(f"Manifest: {out_manifest}")
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
scripts/dataset/build_manifest.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Combine per-source manifests into a single master manifest.
3
+
4
+ The master manifest is the authoritative legal record for the dataset. It is:
5
+ • Committed alongside the trained model (so the lineage is auditable).
6
+ • Used by the training script (no image is loaded that isn't in the manifest).
7
+ • The first thing a lawyer or auditor will ask to see.
8
+
9
+ Stage 3A manifest schema
10
+ ------------------------
11
+ Required legal/training fields:
12
+ path, class, source, license, license_url, sha256
13
+
14
+ Optional generator metadata fields:
15
+ generator, model_family, model_id, prompt, seed, width, height,
16
+ generation_params_json
17
+
18
+ Missing optional values are written as empty strings. This keeps Stage 2
19
+ manifest fragments readable while giving Stage 3A scripts stable columns for
20
+ per-generator evaluation.
21
+
22
+ Usage
23
+ -----
24
+ python scripts/dataset/build_manifest.py \
25
+ --inputs data/raw/real_manifest.csv data/raw/ai_generated_manifest.csv \
26
+ --out data/manifest.csv
27
+
28
+ python scripts/dataset/build_manifest.py \
29
+ --input-dir data/raw \
30
+ --out data/manifest.csv
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import csv
36
+ from pathlib import Path
37
+
38
+ from generation_utils import APPROVED_GENERATORS
39
+
40
+ REQUIRED_FIELDS = ["path", "class", "source", "license", "license_url", "sha256"]
41
+ OPTIONAL_STAGE3A_FIELDS = [
42
+ "generator",
43
+ "model_family",
44
+ "model_id",
45
+ "prompt",
46
+ "seed",
47
+ "width",
48
+ "height",
49
+ "generation_params_json",
50
+ ]
51
+ MANIFEST_FIELDS = REQUIRED_FIELDS + OPTIONAL_STAGE3A_FIELDS
52
+ MANIFEST_FRAGMENT_GLOB = "*_manifest.csv"
53
+ GENERATOR_SPECS_BY_SOURCE = {
54
+ spec.source: spec for spec in APPROVED_GENERATORS.values()
55
+ }
56
+
57
+
58
+ def _normalise_row(row: dict[str, str]) -> dict[str, str]:
59
+ """Return a manifest row with all Stage 3A optional fields present."""
60
+ normalised = dict(row)
61
+ source = normalised.get("source", "")
62
+ spec = GENERATOR_SPECS_BY_SOURCE.get(source)
63
+ if normalised.get("class") == "ai_generated" and spec is not None:
64
+ if not normalised.get("generator"):
65
+ normalised["generator"] = spec.generator
66
+ if not normalised.get("model_family"):
67
+ normalised["model_family"] = spec.model_family
68
+ if not normalised.get("model_id"):
69
+ normalised["model_id"] = spec.model_id
70
+
71
+ for field in OPTIONAL_STAGE3A_FIELDS:
72
+ if normalised.get(field) is None:
73
+ normalised[field] = ""
74
+ else:
75
+ normalised[field] = str(normalised.get(field, ""))
76
+ return normalised
77
+
78
+
79
+ def _discover_manifest_fragments(input_dirs: list[Path], out: Path) -> list[Path]:
80
+ """Find manifest fragments below each input directory in stable order."""
81
+ discovered: list[Path] = []
82
+ out_resolved = out.resolve()
83
+ for input_dir in input_dirs:
84
+ if not input_dir.exists():
85
+ raise FileNotFoundError(f"Input directory does not exist: {input_dir}")
86
+ if not input_dir.is_dir():
87
+ raise NotADirectoryError(f"Input path is not a directory: {input_dir}")
88
+ for path in sorted(input_dir.rglob(MANIFEST_FRAGMENT_GLOB)):
89
+ if path.resolve() == out_resolved:
90
+ continue
91
+ discovered.append(path)
92
+ return discovered
93
+
94
+
95
+ def _unique_paths(paths: list[Path]) -> list[Path]:
96
+ """Deduplicate paths while preserving caller/discovery order."""
97
+ unique: list[Path] = []
98
+ seen: set[Path] = set()
99
+ for path in paths:
100
+ resolved = path.resolve()
101
+ if resolved in seen:
102
+ continue
103
+ seen.add(resolved)
104
+ unique.append(path)
105
+ return unique
106
+
107
+
108
+ def main() -> None:
109
+ parser = argparse.ArgumentParser(description=__doc__)
110
+ parser.add_argument(
111
+ "--inputs",
112
+ type=Path,
113
+ nargs="+",
114
+ default=[],
115
+ help="Explicit manifest fragments to merge",
116
+ )
117
+ parser.add_argument(
118
+ "--input-dir",
119
+ type=Path,
120
+ action="append",
121
+ default=[],
122
+ help=(
123
+ f"Directory to scan recursively for {MANIFEST_FRAGMENT_GLOB}; "
124
+ "can be passed multiple times"
125
+ ),
126
+ )
127
+ parser.add_argument("--out", type=Path, required=True)
128
+ args = parser.parse_args()
129
+
130
+ input_paths = _unique_paths(
131
+ [*args.inputs, *_discover_manifest_fragments(args.input_dir, args.out)]
132
+ )
133
+ if not input_paths:
134
+ raise ValueError("Provide at least one --inputs file or --input-dir")
135
+
136
+ rows: list[dict] = []
137
+ for src in input_paths:
138
+ if not src.exists():
139
+ raise FileNotFoundError(f"Input manifest does not exist: {src}")
140
+ with src.open() as fh:
141
+ reader = csv.DictReader(fh)
142
+ for r in reader:
143
+ missing = [f for f in REQUIRED_FIELDS if not r.get(f)]
144
+ if missing:
145
+ raise ValueError(
146
+ f"{src}: row missing required fields {missing}: {r}"
147
+ )
148
+ rows.append(_normalise_row(r))
149
+
150
+ # Detect duplicates by sha256 — important for license cleanliness AND
151
+ # to avoid train/test leakage.
152
+ seen: dict[str, str] = {}
153
+ deduped: list[dict] = []
154
+ for r in rows:
155
+ sha = r["sha256"]
156
+ if sha in seen:
157
+ print(f" dropping duplicate {r['path']} (matches {seen[sha]})")
158
+ continue
159
+ seen[sha] = r["path"]
160
+ deduped.append(r)
161
+
162
+ args.out.parent.mkdir(parents=True, exist_ok=True)
163
+ # Write a stable schema first, then any extra legacy/source-specific fields.
164
+ fieldnames = sorted({k for r in deduped for k in r.keys()})
165
+ ordered = MANIFEST_FIELDS + [f for f in fieldnames if f not in MANIFEST_FIELDS]
166
+
167
+ with args.out.open("w", newline="") as fh:
168
+ writer = csv.DictWriter(fh, fieldnames=ordered)
169
+ writer.writeheader()
170
+ writer.writerows(deduped)
171
+
172
+ classes: dict[str, int] = {}
173
+ for r in deduped:
174
+ classes[r["class"]] = classes.get(r["class"], 0) + 1
175
+
176
+ print(f"\nMaster manifest: {args.out}")
177
+ print(f" fragments: {len(input_paths)}")
178
+ print(f" total rows: {len(deduped)}")
179
+ for cls, n in sorted(classes.items()):
180
+ print(f" {cls}: {n}")
181
+
182
+ generators: dict[str, int] = {}
183
+ for r in deduped:
184
+ if r["class"] != "ai_generated":
185
+ continue
186
+ generator = r.get("generator") or r["source"]
187
+ generators[generator] = generators.get(generator, 0) + 1
188
+ for generator, n in sorted(generators.items()):
189
+ print(f" generator {generator}: {n}")
190
+
191
+
192
+ if __name__ == "__main__":
193
+ main()
scripts/dataset/closed_eval_prompts.txt ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Closed-generator evaluation prompt set (P0 benchmark)
2
+ #
3
+ # PURPOSE: produce a like-for-like AI-image eval set from closed consumer
4
+ # tools (ChatGPT/DALL-E, Gemini, Midjourney, Firefly). EVALUATION ONLY —
5
+ # these images are never added to a training manifest or data/splits*/.
6
+ #
7
+ # WHY THESE PROMPTS: our detector's weakness is photorealism, not art.
8
+ # Stylised output is already easy to catch. So every prompt below asks for
9
+ # a photograph, and ~40% deliberately ask for MEDIOCRE photographs —
10
+ # harsh flash, motion blur, clutter, bad white balance. Polished "AI
11
+ # beauty shots" flatter a detector; ordinary snapshots are what people
12
+ # actually share and what defeats detectors in the wild. A benchmark built
13
+ # only from beautiful images would overstate our accuracy.
14
+ #
15
+ # REUSE THE SAME LIST FOR EVERY TOOL. Identical prompts across ChatGPT,
16
+ # Gemini and Midjourney make tool-to-tool comparison apples-to-apples.
17
+ # (Shared prompts would be leakage in a *training* set — see the Stage 3A
18
+ # content-leak finding — but this set is never trained on, so it is safe
19
+ # and desirable here.)
20
+ #
21
+ # AR = aspect ratio to request. Mixing them matters: a detector can learn
22
+ # "1024x1024 square = AI" instead of learning anything about pixels.
23
+ #
24
+ # ---------------------------------------------------------------------
25
+ # PEOPLE / PORTRAITS (12) - highest-stakes category for a deepfake product
26
+ # ---------------------------------------------------------------------
27
+ 1 | AR 4:5 | A candid photo of an elderly man with deep wrinkles laughing in a bright kitchen, shot on a phone, slight overexposure near the window.
28
+ 2 | AR 4:5 | A woman in her thirties in a navy raincoat waiting at a bus stop in light drizzle, overcast grey light, unposed, looking off-camera.
29
+ 3 | AR 1:1 | A tired nurse in blue scrubs sitting on a plastic chair in a hospital corridor at the end of a shift, harsh fluorescent overhead lighting.
30
+ 4 | AR 3:2 | Two teenage boys on a skate ramp mid-conversation, late afternoon sun, one blurred by motion, chain-link fence behind.
31
+ 5 | AR 4:5 | A bearded man in his forties taking a mirror selfie in a cluttered bathroom, phone visible, direct flash blowing out the mirror.
32
+ 6 | AR 1:1 | A grandmother and a toddler on a sofa looking at a picture book, warm lamp light, slightly out of focus, ordinary living room.
33
+ 7 | AR 3:2 | A construction worker in a hi-vis vest drinking from a thermos, sitting on a kerb, dusty site behind, midday sun overhead.
34
+ 8 | AR 4:5 | A young woman with curly hair reading on a train, window reflections across her face, natural light, shot from the seat opposite.
35
+ 9 | AR 1:1 | A chef with a burn scar on his forearm plating food in a cramped restaurant kitchen, steam, motion blur on his hands.
36
+ 10 | AR 3:2 | A crowd of commuters on an escalator seen from below, mixed ages, nobody looking at the camera, station lighting.
37
+ 11 | AR 4:5 | A man in his sixties asleep in an armchair with a newspaper on his lap, television glow, dim room, grainy low-light phone photo.
38
+ 12 | AR 3:2 | A woman jogging on a coastal path at dawn, seen from behind at distance, slight camera shake, thin morning fog.
39
+ #
40
+ # ---------------------------------------------------------------------
41
+ # ANIMALS (6)
42
+ # ---------------------------------------------------------------------
43
+ 13 | AR 1:1 | A wet golden retriever shaking off water in a back garden, droplets frozen mid-air, bright overcast light.
44
+ 14 | AR 3:2 | A tabby cat asleep on a radiator shelf beside a dying houseplant, ordinary flat, afternoon light through net curtains.
45
+ 15 | AR 3:2 | Three cows standing in a muddy field beside a wire fence, flat grey sky, unremarkable rural scene.
46
+ 16 | AR 4:5 | A pigeon with a deformed foot standing on wet pavement outside a shop, litter nearby, dull daylight.
47
+ 17 | AR 3:2 | A horse mid-canter in a paddock, slight motion blur on the legs, dust kicked up, low evening sun behind.
48
+ 18 | AR 1:1 | A goldfish in a small glass bowl on a kitchen counter, refraction distorting its body, kitchen clutter behind.
49
+ #
50
+ # ---------------------------------------------------------------------
51
+ # FOOD (5) - AI food is often too perfect; ask for the opposite
52
+ # ---------------------------------------------------------------------
53
+ 19 | AR 1:1 | A half-eaten plate of curry and rice with a bent fork, on a scratched dining table, direct phone flash.
54
+ 20 | AR 4:5 | A cappuccino with slightly broken foam art on a cafe table, phone and keys beside it, window light.
55
+ 21 | AR 1:1 | A greasy takeaway burger in its wrapper on a car dashboard, at night, lit by streetlight through the windscreen.
56
+ 22 | AR 3:2 | A birthday cake with uneven icing and candles blown out, smoke still rising, dim kitchen, people's arms in frame.
57
+ 23 | AR 1:1 | A supermarket cheese counter with condensation on the glass, price labels visible but unreadable, fluorescent light.
58
+ #
59
+ # ---------------------------------------------------------------------
60
+ # LANDSCAPE / NATURE (6)
61
+ # ---------------------------------------------------------------------
62
+ 24 | AR 3:2 | A flat farmland horizon under heavy grey cloud, a single pylon, no dramatic light, ordinary and dull.
63
+ 25 | AR 3:2 | A rocky beach at low tide with seaweed and plastic debris, overcast, taken standing at eye level.
64
+ 26 | AR 4:5 | A narrow forest path after rain, mud and exposed roots, flat diffuse light under the canopy.
65
+ 27 | AR 3:2 | A mountain valley in clear afternoon light with a road switchbacking through it, haze in the distance.
66
+ 28 | AR 3:2 | A frozen pond edge with reeds and thin cracked ice, close to the ground, winter, weak sun.
67
+ 29 | AR 4:5 | A eucalyptus trunk in close detail, peeling bark, harsh dappled midday sunlight, shallow depth of field.
68
+ #
69
+ # ---------------------------------------------------------------------
70
+ # STREET / URBAN (6)
71
+ # ---------------------------------------------------------------------
72
+ 30 | AR 3:2 | A wet city street at night, taxi headlights, reflections on tarmac, handheld, slight motion blur.
73
+ 31 | AR 4:5 | A row of terraced houses with wheelie bins out, parked cars, grey daylight, nothing happening.
74
+ 32 | AR 3:2 | Scaffolding on a building facade with a torn safety net, seen from across the road, flat light.
75
+ 33 | AR 1:1 | A launderette interior at night seen through the window from outside, one person inside, fluorescent glare.
76
+ 34 | AR 3:2 | A pedestrian crossing seen from a car windscreen in rain, wipers mid-sweep, blurred figures crossing.
77
+ 35 | AR 4:5 | Graffiti on a shutter beside a closed shop, cigarette ends on the pavement, early morning, empty street.
78
+ #
79
+ # ---------------------------------------------------------------------
80
+ # INTERIORS / DOMESTIC (5)
81
+ # ---------------------------------------------------------------------
82
+ 36 | AR 3:2 | An unmade bed with creased sheets in morning light, phone charger cable trailing off the side table.
83
+ 37 | AR 4:5 | A cluttered home office desk with two mugs, tangled cables and sticky notes, mixed daylight and lamp light.
84
+ 38 | AR 3:2 | A kitchen sink piled with washing-up, window above it, dull afternoon light, ordinary and unstyled.
85
+ 39 | AR 1:1 | A child's bedroom floor covered in toys, ceiling light on, slightly overexposed, taken from the doorway.
86
+ 40 | AR 3:2 | A stairwell in an apartment block with scuffed walls and a bicycle chained to the railing, motion-sensor lighting.
87
+ #
88
+ # ---------------------------------------------------------------------
89
+ # OBJECTS / PRODUCT (4)
90
+ # ---------------------------------------------------------------------
91
+ 41 | AR 1:1 | A worn leather wallet with cards half out, on a wooden table, single window light, visible scratches.
92
+ 42 | AR 1:1 | A cracked phone screen still displaying a home screen, held in a hand, reflections across the glass.
93
+ 43 | AR 4:5 | A pair of muddy walking boots on a doorstep, drying, late afternoon shadow across them.
94
+ 44 | AR 1:1 | A houseplant with several browning leaves in a chipped ceramic pot on a windowsill, backlit.
95
+ #
96
+ # ---------------------------------------------------------------------
97
+ # EVENTS / GROUPS (6)
98
+ # ---------------------------------------------------------------------
99
+ 45 | AR 3:2 | A school sports day, parents watching from the sideline, children mid-race, bright hazy sun, handheld.
100
+ 46 | AR 3:2 | A crowded pub interior during a match, faces lit by screens, motion blur, noisy low-light photo.
101
+ 47 | AR 4:5 | A market stall holder handing change to a customer, produce in the foreground, busy background.
102
+ 48 | AR 3:2 | A small wedding party posing awkwardly outside a registry office, overcast, one person blinking.
103
+ 49 | AR 3:2 | A queue outside a bakery on a cold morning, breath visible, people on phones, low winter sun.
104
+ 50 | AR 1:1 | A family barbecue in a small garden, smoke from the grill, folding chairs, harsh midday light.
scripts/dataset/fetch_open_images.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download a stratified sample of Open Images V7.
3
+
4
+ Open Images is published by Google under CC BY 2.0 (commercial use OK).
5
+ Each downloaded image is recorded in the manifest with its source URL and
6
+ license string for audit trail.
7
+
8
+ Usage
9
+ -----
10
+ python scripts/dataset/fetch_open_images.py \
11
+ --out data/raw/real \
12
+ --count 50000
13
+
14
+ Notes
15
+ -----
16
+ We use the FiftyOne library because it has first-class Open Images support
17
+ and handles the (large, non-trivial) metadata files for us. FiftyOne is
18
+ Apache 2.0 licensed.
19
+
20
+ This script is idempotent: re-running with the same --out resumes where it
21
+ left off and skips already-downloaded files.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import csv
27
+ import hashlib
28
+ import time
29
+ from pathlib import Path
30
+
31
+
32
+ def _load_with_retry(foz, split: str, count: int, max_attempts: int = 20):
33
+ """Call foz.load_zoo_dataset with retry on transient network errors.
34
+
35
+ Open Images is hosted on S3; a single dropped TCP connection bubbles up
36
+ as botocore.EndpointConnectionError and kills the whole download even
37
+ when 99% of the work is done. Each successful image is cached locally,
38
+ so retrying the call resumes from where it left off — we just need to
39
+ keep retrying until the network cooperates for one full pass.
40
+
41
+ Caught broadly to also handle ConnectionError, OSError, and the
42
+ botocore wrappers without a hard import on botocore.
43
+ """
44
+ for attempt in range(1, max_attempts + 1):
45
+ try:
46
+ return foz.load_zoo_dataset(
47
+ "open-images-v7",
48
+ split=split,
49
+ max_samples=count,
50
+ shuffle=True,
51
+ # We only need image data, not labels — bbox/segmentation/etc.
52
+ # add tens of GB of metadata we don't use.
53
+ label_types=[],
54
+ )
55
+ except Exception as exc: # noqa: BLE001 — intentional broad catch
56
+ cls = type(exc).__name__
57
+ transient = any(
58
+ token in cls
59
+ for token in (
60
+ "Connection", "Endpoint", "Timeout", "SSL", "Proxy",
61
+ )
62
+ ) or isinstance(exc, OSError)
63
+ if not transient or attempt >= max_attempts:
64
+ raise
65
+ wait = min(60, 5 * attempt)
66
+ print(
67
+ f"\n[retry {attempt}/{max_attempts}] {cls}: {exc}\n"
68
+ f" Sleeping {wait}s before retrying — already-downloaded "
69
+ "images are cached, so the next attempt resumes."
70
+ )
71
+ time.sleep(wait)
72
+
73
+
74
+ def main() -> None:
75
+ parser = argparse.ArgumentParser(description=__doc__)
76
+ parser.add_argument("--out", type=Path, required=True, help="Output directory")
77
+ parser.add_argument("--count", type=int, default=50_000, help="Number of images")
78
+ parser.add_argument(
79
+ "--split",
80
+ choices=["train", "validation", "test"],
81
+ default="train",
82
+ help="Open Images split to draw from",
83
+ )
84
+ parser.add_argument(
85
+ "--max-attempts",
86
+ type=int,
87
+ default=20,
88
+ help="Max retry attempts on network errors before giving up",
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ args.out.mkdir(parents=True, exist_ok=True)
93
+ manifest_path = args.out.parent / "real_manifest.csv"
94
+
95
+ print(f"Downloading {args.count} images from Open Images {args.split} split...")
96
+ print("Importing FiftyOne (this is slow the first time)...")
97
+
98
+ # Lazy import — fiftyone has heavy native deps.
99
+ import fiftyone.zoo as foz
100
+
101
+ dataset = _load_with_retry(foz, args.split, args.count, args.max_attempts)
102
+
103
+ rows: list[dict] = []
104
+ for sample in dataset:
105
+ src = Path(sample.filepath)
106
+ dst = args.out / src.name
107
+ if not dst.exists():
108
+ dst.write_bytes(src.read_bytes())
109
+
110
+ with dst.open("rb") as fh:
111
+ sha = hashlib.sha256(fh.read()).hexdigest()
112
+
113
+ rows.append({
114
+ # as_posix() so the manifest is portable to Linux GPU boxes —
115
+ # str() on Windows produces backslashes that break Path parsing
116
+ # on POSIX.
117
+ "path": dst.relative_to(args.out.parent.parent).as_posix(),
118
+ "class": "authentic",
119
+ "source": "open_images_v7",
120
+ "license": "CC-BY-2.0",
121
+ "license_url": "https://creativecommons.org/licenses/by/2.0/",
122
+ "sha256": sha,
123
+ })
124
+
125
+ # Write manifest fragment for the real half.
126
+ with manifest_path.open("w", newline="") as fh:
127
+ writer = csv.DictWriter(
128
+ fh,
129
+ fieldnames=["path", "class", "source", "license", "license_url", "sha256"],
130
+ )
131
+ writer.writeheader()
132
+ writer.writerows(rows)
133
+
134
+ print(f"Done. {len(rows)} images. Manifest fragment: {manifest_path}")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
scripts/dataset/generate_auraflow_synthetic.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic images with AuraFlow v0.3.
3
+
4
+ License
5
+ -------
6
+ `fal/AuraFlow-v0.3` is released under Apache-2.0. Stage 3A approved it as an
7
+ independent rectified-flow training source. See `NOTICES.md` before running a
8
+ real generation job.
9
+
10
+ Usage
11
+ -----
12
+ python scripts/dataset/generate_auraflow_synthetic.py \\
13
+ --out data/raw/ai_generated/auraflow-v0.3 \\
14
+ --count 20000 \\
15
+ --prompts scripts/dataset/prompts.txt
16
+
17
+ Dry run, no model import/download:
18
+
19
+ python scripts/dataset/generate_auraflow_synthetic.py \\
20
+ --out data/raw/ai_generated/auraflow-v0.3 \\
21
+ --count 3 \\
22
+ --dry-run
23
+
24
+ Hardware
25
+ --------
26
+ AuraFlow 1024x1024 generation should be run on a CUDA GPU with enough VRAM for
27
+ half-precision inference. Start with a small smoke run on the target GPU before
28
+ renting a long job.
29
+
30
+ Idempotency
31
+ -----------
32
+ Each generated file is named by a hash of (prompt, seed), so re-runs skip
33
+ already-generated images. Crash-resume works automatically.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import csv
39
+ import random
40
+ from pathlib import Path
41
+
42
+ from generation_utils import (
43
+ APPROVED_GENERATORS,
44
+ STAGE3A_MANIFEST_FIELDS,
45
+ choose_prompt,
46
+ image_dimensions,
47
+ infer_data_root,
48
+ load_prompts,
49
+ manifest_row,
50
+ next_seed,
51
+ sha256_file,
52
+ stable_image_key,
53
+ )
54
+
55
+
56
+ FALLBACK_PROMPTS = [
57
+ "a realistic phone photo of a ceramic mug beside a laptop",
58
+ "a casual snapshot of a parking lot after a summer storm",
59
+ "a natural light photo of houseplants on a crowded windowsill",
60
+ "a slightly blurry photo of a folded jacket on a cafe chair",
61
+ ]
62
+
63
+
64
+ AURAFLOW_SPEC = APPROVED_GENERATORS["auraflow-v0.3"]
65
+
66
+
67
+ def _manifest_path(out_dir: Path) -> Path:
68
+ return out_dir.parent / "auraflow_manifest.csv"
69
+
70
+
71
+ def main() -> None:
72
+ parser = argparse.ArgumentParser(
73
+ description=__doc__,
74
+ formatter_class=argparse.RawDescriptionHelpFormatter,
75
+ )
76
+ parser.add_argument("--out", type=Path, required=True, help="Output directory")
77
+ parser.add_argument(
78
+ "--data-root",
79
+ type=Path,
80
+ default=None,
81
+ help=(
82
+ "Dataset root for manifest paths. Defaults to the parent of the "
83
+ "'raw' path segment in --out."
84
+ ),
85
+ )
86
+ parser.add_argument("--count", type=int, default=20_000)
87
+ parser.add_argument(
88
+ "--prompts",
89
+ type=Path,
90
+ default=None,
91
+ help="Optional file with one prompt per line",
92
+ )
93
+ parser.add_argument("--seed", type=int, default=0)
94
+ parser.add_argument("--steps", type=int, default=30)
95
+ parser.add_argument("--guidance-scale", type=float, default=3.5)
96
+ parser.add_argument("--width", type=int, default=1024)
97
+ parser.add_argument("--height", type=int, default=1024)
98
+ parser.add_argument(
99
+ "--dry-run",
100
+ action="store_true",
101
+ help="Validate prompt/seed/output planning without loading AuraFlow or writing files",
102
+ )
103
+ args = parser.parse_args()
104
+
105
+ out_dir = args.out.resolve()
106
+ out_dir.mkdir(parents=True, exist_ok=True)
107
+ data_root = (
108
+ args.data_root.resolve()
109
+ if args.data_root is not None
110
+ else infer_data_root(out_dir)
111
+ )
112
+ manifest_path = _manifest_path(out_dir)
113
+
114
+ prompts = load_prompts(args.prompts, fallback_prompts=FALLBACK_PROMPTS)
115
+ if args.prompts is not None:
116
+ print(f"Loaded {len(prompts)} prompts from {args.prompts}")
117
+ else:
118
+ print(
119
+ f"WARNING: --prompts not given; using {len(prompts)} built-in "
120
+ "fallback prompts (smoke-test only, not enough diversity for "
121
+ "a real training run)"
122
+ )
123
+
124
+ rng = random.Random(args.seed)
125
+
126
+ if args.dry_run:
127
+ print(
128
+ "Dry run: AuraFlow pipeline will not be loaded and no images "
129
+ "will be written."
130
+ )
131
+ for i in range(args.count):
132
+ prompt = choose_prompt(rng, prompts)
133
+ seed = next_seed(rng)
134
+ key = stable_image_key(prompt, seed)
135
+ dst = out_dir / f"{key}.png"
136
+ print(f" {i + 1:04d}: seed={seed} path={dst} prompt={prompt!r}")
137
+ print(f"Dry run complete. Planned manifest fragment: {manifest_path}")
138
+ return
139
+
140
+ print("Loading AuraFlow pipeline (large download on first run)...")
141
+ import torch
142
+ from diffusers import AuraFlowPipeline
143
+
144
+ pipe = AuraFlowPipeline.from_pretrained(
145
+ AURAFLOW_SPEC.model_id,
146
+ torch_dtype=torch.float16,
147
+ )
148
+ pipe.to("cuda")
149
+ # AuraFlow VAE dtype-mismatch fix.
150
+ # Why: AuraFlow's VAE has biases that don't survive `torch_dtype=torch.float16`
151
+ # cleanly. Diffusers' internal `upcast_vae()` path (now deprecated for AuraFlow)
152
+ # only partially upcasts, leaving conv biases stranded in fp32 while inputs are
153
+ # fp16 -> RuntimeError "Input type (c10::Half) and bias type (float) should be
154
+ # the same" during `vae.decode`.
155
+ # Fix: cast the whole VAE to fp32, AND monkey-patch `vae.decode` to cast the
156
+ # latent input to match. fp32 VAE adds ~500 MB memory and ~10-20% time to the
157
+ # decode step (negligible on 48 GB cards).
158
+ pipe.vae = pipe.vae.to(dtype=torch.float32)
159
+ _orig_vae_decode = pipe.vae.decode
160
+ def _decode_with_dtype_cast(z, *args, **kwargs):
161
+ z = z.to(pipe.vae.dtype)
162
+ return _orig_vae_decode(z, *args, **kwargs)
163
+ pipe.vae.decode = _decode_with_dtype_cast
164
+
165
+ rows: list[dict] = []
166
+ for i in range(args.count):
167
+ prompt = choose_prompt(rng, prompts)
168
+ seed = next_seed(rng)
169
+ key = stable_image_key(prompt, seed)
170
+ dst = out_dir / f"{key}.png"
171
+
172
+ if not dst.exists():
173
+ generator = torch.Generator("cuda").manual_seed(seed)
174
+ image = pipe(
175
+ prompt=prompt,
176
+ num_inference_steps=args.steps,
177
+ guidance_scale=args.guidance_scale,
178
+ width=args.width,
179
+ height=args.height,
180
+ generator=generator,
181
+ ).images[0]
182
+ image.save(dst, format="PNG")
183
+
184
+ width, height = image_dimensions(dst)
185
+ rows.append(
186
+ manifest_row(
187
+ path=dst.relative_to(data_root).as_posix(),
188
+ cls="ai_generated",
189
+ spec=AURAFLOW_SPEC,
190
+ sha256=sha256_file(dst),
191
+ prompt=prompt,
192
+ seed=seed,
193
+ width=width,
194
+ height=height,
195
+ generation_params={
196
+ "steps": args.steps,
197
+ "guidance_scale": args.guidance_scale,
198
+ "width": args.width,
199
+ "height": args.height,
200
+ "pipeline": AURAFLOW_SPEC.pipeline,
201
+ },
202
+ )
203
+ )
204
+
205
+ if (i + 1) % 100 == 0:
206
+ print(f" generated {i + 1}/{args.count}")
207
+
208
+ with manifest_path.open("w", newline="") as fh:
209
+ writer = csv.DictWriter(fh, fieldnames=STAGE3A_MANIFEST_FIELDS)
210
+ writer.writeheader()
211
+ writer.writerows(rows)
212
+
213
+ print(f"Done. {len(rows)} images. Manifest fragment: {manifest_path}")
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
scripts/dataset/generate_flux_synthetic.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic images with Flux.1-schnell.
3
+
4
+ Flux.1-schnell is released by Black Forest Labs under Apache 2.0. Outputs are
5
+ not restricted, so we can use them as commercial training data.
6
+
7
+ Usage
8
+ -----
9
+ python scripts/dataset/generate_flux_synthetic.py \
10
+ --out data/raw/ai_generated \
11
+ --count 50000 \
12
+ --prompts scripts/dataset/prompts.txt
13
+
14
+ Prompts file format
15
+ -------------------
16
+ One complete prompt per line. Blank lines and lines starting with `#` are
17
+ ignored. Diversity is critical: a model trained on monotonous prompts learns
18
+ to detect prompt style, not AI-generation artefacts. Aim for the prompts
19
+ file to span the full distribution of subjects, settings, lighting, and
20
+ camera styles a regular person might upload — including mundane and
21
+ imperfect ones (snapshots, blurry shots, boring objects), not just
22
+ gallery-worthy compositions.
23
+
24
+ If `--prompts` is omitted, a tiny built-in fallback set is used. That set
25
+ is only sufficient for smoke tests, not for a real training run.
26
+
27
+ Hardware
28
+ --------
29
+ Flux.1-schnell needs a GPU with ~12-16 GB VRAM. Runs comfortably on:
30
+ • RunPod / Lambda Labs / Vast.ai spot instances (RTX 3090 / 4090 / A10G)
31
+ • ~3-5 seconds per image at 4 inference steps on an A10G
32
+ • 50k images ≈ 60-80 GPU-hours ≈ $25-50 at typical spot rates
33
+
34
+ Idempotency
35
+ -----------
36
+ Each generated file is named by a hash of (prompt, seed) so re-runs skip
37
+ already-generated images. Crash-resume works automatically.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import argparse
42
+ import csv
43
+ import random
44
+ from pathlib import Path
45
+
46
+ from generation_utils import (
47
+ APPROVED_GENERATORS,
48
+ choose_prompt,
49
+ image_dimensions,
50
+ load_prompts,
51
+ manifest_row,
52
+ next_seed,
53
+ sha256_file,
54
+ stable_image_key,
55
+ )
56
+
57
+
58
+ # Fallback used only when --prompts is not provided. Intentionally tiny —
59
+ # enough for a smoke test, not for a real training run.
60
+ FALLBACK_PROMPTS = [
61
+ "a photo of a dog in a park, golden hour",
62
+ "candid photograph of a person walking on a busy street",
63
+ "studio lighting portrait of two friends talking",
64
+ "a wide-angle shot of a kitchen with a child playing",
65
+ ]
66
+
67
+
68
+ FLUX_SPEC = APPROVED_GENERATORS["flux.1-schnell"]
69
+
70
+
71
+ def main() -> None:
72
+ parser = argparse.ArgumentParser(description=__doc__)
73
+ parser.add_argument("--out", type=Path, required=True, help="Output directory")
74
+ parser.add_argument("--count", type=int, default=50_000)
75
+ parser.add_argument(
76
+ "--prompts",
77
+ type=Path,
78
+ default=None,
79
+ help="Optional file with one prompt template per line",
80
+ )
81
+ parser.add_argument("--seed", type=int, default=0)
82
+ parser.add_argument("--steps", type=int, default=4, help="Flux schnell uses 4 steps")
83
+ args = parser.parse_args()
84
+
85
+ args.out.mkdir(parents=True, exist_ok=True)
86
+ manifest_path = args.out.parent / "ai_generated_manifest.csv"
87
+
88
+ if args.prompts is not None:
89
+ prompts = load_prompts(args.prompts)
90
+ print(f"Loaded {len(prompts)} prompts from {args.prompts}")
91
+ else:
92
+ prompts = load_prompts(None, fallback_prompts=FALLBACK_PROMPTS)
93
+ print(
94
+ f"WARNING: --prompts not given; using {len(prompts)} built-in "
95
+ "fallback prompts (smoke-test only, not enough diversity for "
96
+ "a real training run)"
97
+ )
98
+
99
+ rng = random.Random(args.seed)
100
+
101
+ # Lazy import — diffusers + torch + accelerate are heavy.
102
+ print("Loading Flux.1-schnell pipeline (~24 GB download on first run)...")
103
+ import torch
104
+ from diffusers import FluxPipeline
105
+
106
+ pipe = FluxPipeline.from_pretrained(
107
+ "black-forest-labs/FLUX.1-schnell",
108
+ torch_dtype=torch.bfloat16,
109
+ )
110
+ pipe.to("cuda")
111
+
112
+ rows: list[dict] = []
113
+ for i in range(args.count):
114
+ prompt = choose_prompt(rng, prompts)
115
+ seed = next_seed(rng)
116
+ # Filename is content-addressable so re-runs are idempotent.
117
+ key = stable_image_key(prompt, seed)
118
+ dst = args.out / f"{key}.png"
119
+
120
+ if not dst.exists():
121
+ generator = torch.Generator("cuda").manual_seed(seed)
122
+ image = pipe(
123
+ prompt,
124
+ guidance_scale=0.0, # Flux schnell ignores guidance
125
+ num_inference_steps=args.steps,
126
+ generator=generator,
127
+ ).images[0]
128
+ image.save(dst, format="PNG")
129
+
130
+ width, height = image_dimensions(dst)
131
+
132
+ rows.append(manifest_row(
133
+ # as_posix() — keep manifests portable across OS boundaries.
134
+ path=dst.relative_to(args.out.parent.parent).as_posix(),
135
+ cls="ai_generated",
136
+ spec=FLUX_SPEC,
137
+ sha256=sha256_file(dst),
138
+ prompt=prompt,
139
+ seed=seed,
140
+ width=width,
141
+ height=height,
142
+ generation_params={
143
+ "steps": args.steps,
144
+ "guidance_scale": 0.0,
145
+ "pipeline": FLUX_SPEC.pipeline,
146
+ },
147
+ ))
148
+
149
+ if (i + 1) % 100 == 0:
150
+ print(f" generated {i + 1}/{args.count}")
151
+
152
+ with manifest_path.open("w", newline="") as fh:
153
+ writer = csv.DictWriter(
154
+ fh,
155
+ fieldnames=[
156
+ "path", "class", "source", "license", "license_url",
157
+ "sha256", "generator", "model_family", "model_id", "prompt",
158
+ "seed", "width", "height", "generation_params_json",
159
+ ],
160
+ )
161
+ writer.writeheader()
162
+ writer.writerows(rows)
163
+
164
+ print(f"Done. {len(rows)} images. Manifest fragment: {manifest_path}")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
scripts/dataset/generate_sd35_synthetic.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic images with Stable Diffusion 3.5 Medium.
3
+
4
+ License
5
+ -------
6
+ `stabilityai/stable-diffusion-3.5-medium` is available under the Stability AI
7
+ Community License. Stage 3A conditionally approved it while Veridicate remains
8
+ under the license revenue threshold and satisfies registration requirements.
9
+ Enterprise licensing is required before use above that threshold. Do not use
10
+ outputs to create or improve a foundational generative AI model. See
11
+ `NOTICES.md` before running a real generation job.
12
+
13
+ Usage
14
+ -----
15
+ python scripts/dataset/generate_sd35_synthetic.py \\
16
+ --out data/raw/ai_generated/sd35-medium \\
17
+ --count 20000 \\
18
+ --prompts scripts/dataset/prompts.txt
19
+
20
+ Dry run, no model import/download:
21
+
22
+ python scripts/dataset/generate_sd35_synthetic.py \\
23
+ --out data/raw/ai_generated/sd35-medium \\
24
+ --count 3 \\
25
+ --dry-run
26
+
27
+ Hardware
28
+ --------
29
+ SD 3.5 Medium generation should be run on a CUDA GPU with enough VRAM for
30
+ 1024x1024 half-precision inference. Start with a small smoke run on the target
31
+ GPU before renting a long job.
32
+
33
+ Idempotency
34
+ -----------
35
+ Each generated file is named by a hash of (prompt, seed), so re-runs skip
36
+ already-generated images. Crash-resume works automatically.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import csv
42
+ import random
43
+ from pathlib import Path
44
+
45
+ from generation_utils import (
46
+ APPROVED_GENERATORS,
47
+ STAGE3A_MANIFEST_FIELDS,
48
+ choose_prompt,
49
+ image_dimensions,
50
+ infer_data_root,
51
+ load_prompts,
52
+ manifest_row,
53
+ next_seed,
54
+ sha256_file,
55
+ stable_image_key,
56
+ )
57
+
58
+
59
+ FALLBACK_PROMPTS = [
60
+ "a realistic phone photo of a grocery receipt on a kitchen table",
61
+ "a handheld snapshot of a small dog looking out a car window",
62
+ "a natural photo of a rainy suburban street at dusk",
63
+ "a casual indoor photo of laundry folded on a couch",
64
+ ]
65
+
66
+
67
+ SD35_SPEC = APPROVED_GENERATORS["sd3.5-medium"]
68
+
69
+
70
+ def _manifest_path(out_dir: Path) -> Path:
71
+ return out_dir.parent / "sd35_medium_manifest.csv"
72
+
73
+
74
+ def main() -> None:
75
+ parser = argparse.ArgumentParser(
76
+ description=__doc__,
77
+ formatter_class=argparse.RawDescriptionHelpFormatter,
78
+ )
79
+ parser.add_argument("--out", type=Path, required=True, help="Output directory")
80
+ parser.add_argument(
81
+ "--data-root",
82
+ type=Path,
83
+ default=None,
84
+ help=(
85
+ "Dataset root for manifest paths. Defaults to the parent of the "
86
+ "'raw' path segment in --out."
87
+ ),
88
+ )
89
+ parser.add_argument("--count", type=int, default=20_000)
90
+ parser.add_argument(
91
+ "--prompts",
92
+ type=Path,
93
+ default=None,
94
+ help="Optional file with one prompt per line",
95
+ )
96
+ parser.add_argument("--seed", type=int, default=0)
97
+ parser.add_argument("--steps", type=int, default=28)
98
+ parser.add_argument("--guidance-scale", type=float, default=4.5)
99
+ parser.add_argument("--width", type=int, default=1024)
100
+ parser.add_argument("--height", type=int, default=1024)
101
+ parser.add_argument(
102
+ "--dry-run",
103
+ action="store_true",
104
+ help="Validate prompt/seed/output planning without loading SD 3.5 or writing files",
105
+ )
106
+ args = parser.parse_args()
107
+
108
+ out_dir = args.out.resolve()
109
+ out_dir.mkdir(parents=True, exist_ok=True)
110
+ data_root = (
111
+ args.data_root.resolve()
112
+ if args.data_root is not None
113
+ else infer_data_root(out_dir)
114
+ )
115
+ manifest_path = _manifest_path(out_dir)
116
+
117
+ prompts = load_prompts(args.prompts, fallback_prompts=FALLBACK_PROMPTS)
118
+ if args.prompts is not None:
119
+ print(f"Loaded {len(prompts)} prompts from {args.prompts}")
120
+ else:
121
+ print(
122
+ f"WARNING: --prompts not given; using {len(prompts)} built-in "
123
+ "fallback prompts (smoke-test only, not enough diversity for "
124
+ "a real training run)"
125
+ )
126
+
127
+ rng = random.Random(args.seed)
128
+
129
+ if args.dry_run:
130
+ print(
131
+ "Dry run: SD 3.5 Medium pipeline will not be loaded and no images "
132
+ "will be written."
133
+ )
134
+ for i in range(args.count):
135
+ prompt = choose_prompt(rng, prompts)
136
+ seed = next_seed(rng)
137
+ key = stable_image_key(prompt, seed)
138
+ dst = out_dir / f"{key}.png"
139
+ print(f" {i + 1:04d}: seed={seed} path={dst} prompt={prompt!r}")
140
+ print(f"Dry run complete. Planned manifest fragment: {manifest_path}")
141
+ return
142
+
143
+ print("Loading SD 3.5 Medium pipeline (large download on first run)...")
144
+ import torch
145
+ from diffusers import StableDiffusion3Pipeline
146
+
147
+ pipe = StableDiffusion3Pipeline.from_pretrained(
148
+ SD35_SPEC.model_id,
149
+ torch_dtype=torch.float16,
150
+ )
151
+ pipe.to("cuda")
152
+
153
+ rows: list[dict] = []
154
+ for i in range(args.count):
155
+ prompt = choose_prompt(rng, prompts)
156
+ seed = next_seed(rng)
157
+ key = stable_image_key(prompt, seed)
158
+ dst = out_dir / f"{key}.png"
159
+
160
+ if not dst.exists():
161
+ generator = torch.Generator("cuda").manual_seed(seed)
162
+ image = pipe(
163
+ prompt=prompt,
164
+ num_inference_steps=args.steps,
165
+ guidance_scale=args.guidance_scale,
166
+ width=args.width,
167
+ height=args.height,
168
+ generator=generator,
169
+ ).images[0]
170
+ image.save(dst, format="PNG")
171
+
172
+ width, height = image_dimensions(dst)
173
+ rows.append(
174
+ manifest_row(
175
+ path=dst.relative_to(data_root).as_posix(),
176
+ cls="ai_generated",
177
+ spec=SD35_SPEC,
178
+ sha256=sha256_file(dst),
179
+ prompt=prompt,
180
+ seed=seed,
181
+ width=width,
182
+ height=height,
183
+ generation_params={
184
+ "steps": args.steps,
185
+ "guidance_scale": args.guidance_scale,
186
+ "width": args.width,
187
+ "height": args.height,
188
+ "pipeline": SD35_SPEC.pipeline,
189
+ },
190
+ )
191
+ )
192
+
193
+ if (i + 1) % 100 == 0:
194
+ print(f" generated {i + 1}/{args.count}")
195
+
196
+ with manifest_path.open("w", newline="") as fh:
197
+ writer = csv.DictWriter(fh, fieldnames=STAGE3A_MANIFEST_FIELDS)
198
+ writer.writeheader()
199
+ writer.writerows(rows)
200
+
201
+ print(f"Done. {len(rows)} images. Manifest fragment: {manifest_path}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
scripts/dataset/generate_sdxl_synthetic.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generate synthetic images with Stable Diffusion XL base 1.0.
3
+
4
+ License
5
+ -------
6
+ `stabilityai/stable-diffusion-xl-base-1.0` is released under CreativeML Open
7
+ RAIL++-M. Stage 3A approved it for detector-training data with use-policy
8
+ restrictions. Do not use SDXL outputs to train or improve a competing
9
+ generative model. See `NOTICES.md` before running a real generation job.
10
+
11
+ Usage
12
+ -----
13
+ python scripts/dataset/generate_sdxl_synthetic.py \\
14
+ --out data/raw/ai_generated/sdxl \\
15
+ --count 20000 \\
16
+ --prompts scripts/dataset/prompts.txt
17
+
18
+ Dry run, no model import/download:
19
+
20
+ python scripts/dataset/generate_sdxl_synthetic.py \\
21
+ --out data/raw/ai_generated/sdxl \\
22
+ --count 3 \\
23
+ --dry-run
24
+
25
+ Hardware
26
+ --------
27
+ SDXL 1024x1024 generation generally needs a CUDA GPU with roughly 12-16 GB VRAM
28
+ when using half precision and attention optimizations. Runtime varies strongly
29
+ by GPU and step count; start with a small smoke run before renting a long job.
30
+
31
+ Idempotency
32
+ -----------
33
+ Each generated file is named by a hash of (prompt, seed), so re-runs skip
34
+ already-generated images. Crash-resume works automatically.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import csv
40
+ import random
41
+ from pathlib import Path
42
+
43
+ from generation_utils import (
44
+ APPROVED_GENERATORS,
45
+ STAGE3A_MANIFEST_FIELDS,
46
+ choose_prompt,
47
+ image_dimensions,
48
+ infer_data_root,
49
+ load_prompts,
50
+ manifest_row,
51
+ next_seed,
52
+ sha256_file,
53
+ stable_image_key,
54
+ )
55
+
56
+
57
+ FALLBACK_PROMPTS = [
58
+ "a casual phone photo of a bicycle leaning against a brick wall",
59
+ "a realistic photo of a bowl of soup on a kitchen counter",
60
+ "a slightly blurry snapshot of commuters waiting at a train platform",
61
+ "a natural light photo of a cluttered home office desk",
62
+ ]
63
+
64
+
65
+ SDXL_SPEC = APPROVED_GENERATORS["sdxl"]
66
+
67
+
68
+ def _manifest_path(out_dir: Path) -> Path:
69
+ return out_dir.parent / "sdxl_manifest.csv"
70
+
71
+
72
+ def main() -> None:
73
+ parser = argparse.ArgumentParser(
74
+ description=__doc__,
75
+ formatter_class=argparse.RawDescriptionHelpFormatter,
76
+ )
77
+ parser.add_argument("--out", type=Path, required=True, help="Output directory")
78
+ parser.add_argument(
79
+ "--data-root",
80
+ type=Path,
81
+ default=None,
82
+ help=(
83
+ "Dataset root for manifest paths. Defaults to the parent of the "
84
+ "'raw' path segment in --out."
85
+ ),
86
+ )
87
+ parser.add_argument("--count", type=int, default=20_000)
88
+ parser.add_argument(
89
+ "--prompts",
90
+ type=Path,
91
+ default=None,
92
+ help="Optional file with one prompt per line",
93
+ )
94
+ parser.add_argument("--seed", type=int, default=0)
95
+ parser.add_argument("--steps", type=int, default=30)
96
+ parser.add_argument("--guidance-scale", type=float, default=7.0)
97
+ parser.add_argument("--width", type=int, default=1024)
98
+ parser.add_argument("--height", type=int, default=1024)
99
+ parser.add_argument(
100
+ "--dry-run",
101
+ action="store_true",
102
+ help="Validate prompt/seed/output planning without loading SDXL or writing files",
103
+ )
104
+ args = parser.parse_args()
105
+
106
+ out_dir = args.out.resolve()
107
+ out_dir.mkdir(parents=True, exist_ok=True)
108
+ data_root = (
109
+ args.data_root.resolve()
110
+ if args.data_root is not None
111
+ else infer_data_root(out_dir)
112
+ )
113
+ manifest_path = _manifest_path(out_dir)
114
+
115
+ prompts = load_prompts(args.prompts, fallback_prompts=FALLBACK_PROMPTS)
116
+ if args.prompts is not None:
117
+ print(f"Loaded {len(prompts)} prompts from {args.prompts}")
118
+ else:
119
+ print(
120
+ f"WARNING: --prompts not given; using {len(prompts)} built-in "
121
+ "fallback prompts (smoke-test only, not enough diversity for "
122
+ "a real training run)"
123
+ )
124
+
125
+ rng = random.Random(args.seed)
126
+
127
+ if args.dry_run:
128
+ print("Dry run: SDXL pipeline will not be loaded and no images will be written.")
129
+ for i in range(args.count):
130
+ prompt = choose_prompt(rng, prompts)
131
+ seed = next_seed(rng)
132
+ key = stable_image_key(prompt, seed)
133
+ dst = out_dir / f"{key}.png"
134
+ print(f" {i + 1:04d}: seed={seed} path={dst} prompt={prompt!r}")
135
+ print(f"Dry run complete. Planned manifest fragment: {manifest_path}")
136
+ return
137
+
138
+ print("Loading SDXL pipeline (~7 GB download on first run)...")
139
+ import torch
140
+ from diffusers import StableDiffusionXLPipeline
141
+
142
+ pipe = StableDiffusionXLPipeline.from_pretrained(
143
+ SDXL_SPEC.model_id,
144
+ torch_dtype=torch.float16,
145
+ variant="fp16",
146
+ use_safetensors=True,
147
+ )
148
+ pipe.to("cuda")
149
+
150
+ rows: list[dict] = []
151
+ for i in range(args.count):
152
+ prompt = choose_prompt(rng, prompts)
153
+ seed = next_seed(rng)
154
+ key = stable_image_key(prompt, seed)
155
+ dst = out_dir / f"{key}.png"
156
+
157
+ if not dst.exists():
158
+ generator = torch.Generator("cuda").manual_seed(seed)
159
+ image = pipe(
160
+ prompt=prompt,
161
+ num_inference_steps=args.steps,
162
+ guidance_scale=args.guidance_scale,
163
+ width=args.width,
164
+ height=args.height,
165
+ generator=generator,
166
+ ).images[0]
167
+ image.save(dst, format="PNG")
168
+
169
+ width, height = image_dimensions(dst)
170
+ rows.append(
171
+ manifest_row(
172
+ path=dst.relative_to(data_root).as_posix(),
173
+ cls="ai_generated",
174
+ spec=SDXL_SPEC,
175
+ sha256=sha256_file(dst),
176
+ prompt=prompt,
177
+ seed=seed,
178
+ width=width,
179
+ height=height,
180
+ generation_params={
181
+ "steps": args.steps,
182
+ "guidance_scale": args.guidance_scale,
183
+ "width": args.width,
184
+ "height": args.height,
185
+ "pipeline": SDXL_SPEC.pipeline,
186
+ },
187
+ )
188
+ )
189
+
190
+ if (i + 1) % 100 == 0:
191
+ print(f" generated {i + 1}/{args.count}")
192
+
193
+ with manifest_path.open("w", newline="") as fh:
194
+ writer = csv.DictWriter(fh, fieldnames=STAGE3A_MANIFEST_FIELDS)
195
+ writer.writeheader()
196
+ writer.writerows(rows)
197
+
198
+ print(f"Done. {len(rows)} images. Manifest fragment: {manifest_path}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
scripts/dataset/generation_utils.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared helpers for Stage 3A dataset generation scripts.
3
+
4
+ Keep this module dependency-light: it is used on GPU dataset boxes, not in the
5
+ inference container. Generator-specific scripts should own their pipeline setup;
6
+ this module only centralizes the boring parts that must stay consistent across
7
+ sources.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import random
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Sequence
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class GeneratorSpec:
21
+ """License and metadata constants for one approved generator source."""
22
+
23
+ source: str
24
+ generator: str
25
+ model_family: str
26
+ model_id: str
27
+ license: str
28
+ license_url: str
29
+ pipeline: str
30
+
31
+
32
+ APPROVED_GENERATORS: dict[str, GeneratorSpec] = {
33
+ "flux.1-schnell": GeneratorSpec(
34
+ source="flux.1-schnell",
35
+ generator="flux.1-schnell",
36
+ model_family="rectified_flow",
37
+ model_id="black-forest-labs/FLUX.1-schnell",
38
+ license="Apache-2.0",
39
+ license_url="https://www.apache.org/licenses/LICENSE-2.0",
40
+ pipeline="FluxPipeline",
41
+ ),
42
+ "sdxl": GeneratorSpec(
43
+ source="sdxl",
44
+ generator="sdxl",
45
+ model_family="diffusion_unet",
46
+ model_id="stabilityai/stable-diffusion-xl-base-1.0",
47
+ license="CreativeML Open RAIL++-M",
48
+ license_url=(
49
+ "https://huggingface.co/stabilityai/"
50
+ "stable-diffusion-xl-base-1.0/blob/main/LICENSE.md"
51
+ ),
52
+ pipeline="StableDiffusionXLPipeline",
53
+ ),
54
+ "sd3.5-medium": GeneratorSpec(
55
+ source="sd3.5-medium",
56
+ generator="sd3.5-medium",
57
+ model_family="diffusion_transformer",
58
+ model_id="stabilityai/stable-diffusion-3.5-medium",
59
+ license="Stability AI Community License",
60
+ license_url="https://stability.ai/license",
61
+ pipeline="StableDiffusion3Pipeline",
62
+ ),
63
+ "auraflow-v0.3": GeneratorSpec(
64
+ source="auraflow-v0.3",
65
+ generator="auraflow-v0.3",
66
+ model_family="rectified_flow",
67
+ model_id="fal/AuraFlow-v0.3",
68
+ license="Apache-2.0",
69
+ license_url="https://www.apache.org/licenses/LICENSE-2.0",
70
+ pipeline="AuraFlowPipeline",
71
+ ),
72
+ }
73
+
74
+ STAGE3A_MANIFEST_FIELDS = [
75
+ "path",
76
+ "class",
77
+ "source",
78
+ "license",
79
+ "license_url",
80
+ "sha256",
81
+ "generator",
82
+ "model_family",
83
+ "model_id",
84
+ "prompt",
85
+ "seed",
86
+ "width",
87
+ "height",
88
+ "generation_params_json",
89
+ ]
90
+
91
+
92
+ def load_prompts(
93
+ path: Path | None,
94
+ *,
95
+ fallback_prompts: Sequence[str] | None = None,
96
+ ) -> list[str]:
97
+ """Read non-blank, non-comment prompts or return a fallback prompt list."""
98
+ if path is None:
99
+ if fallback_prompts is None:
100
+ raise ValueError("No prompts path provided and no fallback prompts given")
101
+ prompts = [p.strip() for p in fallback_prompts if p.strip()]
102
+ if not prompts:
103
+ raise ValueError("Fallback prompts list is empty")
104
+ return prompts
105
+
106
+ prompts: list[str] = []
107
+ with path.open(encoding="utf-8") as fh:
108
+ for line in fh:
109
+ stripped = line.strip()
110
+ if not stripped or stripped.startswith("#"):
111
+ continue
112
+ prompts.append(stripped)
113
+ if not prompts:
114
+ raise ValueError(f"No prompts found in {path}")
115
+ return prompts
116
+
117
+
118
+ def choose_prompt(rng: random.Random, prompts: Sequence[str]) -> str:
119
+ """Pick one prompt with the caller's deterministic RNG."""
120
+ if not prompts:
121
+ raise ValueError("prompts must not be empty")
122
+ return rng.choice(list(prompts))
123
+
124
+
125
+ def next_seed(rng: random.Random) -> int:
126
+ """Return a deterministic positive seed compatible with torch generators."""
127
+ return rng.randint(0, 2**31 - 1)
128
+
129
+
130
+ def stable_image_key(prompt: str, seed: int) -> str:
131
+ """Return the legacy content-addressed key for generated image files."""
132
+ return hashlib.sha256(f"{prompt}|{seed}".encode()).hexdigest()[:24]
133
+
134
+
135
+ def infer_data_root(out_dir: Path) -> Path:
136
+ """Infer the dataset root from an output path containing a `raw` segment."""
137
+ resolved = out_dir.resolve()
138
+ parts = resolved.parts
139
+ if "raw" not in parts:
140
+ raise ValueError(
141
+ f"Cannot infer data root from {out_dir}; pass --data-root explicitly"
142
+ )
143
+ raw_index = parts.index("raw")
144
+ if raw_index == 0:
145
+ raise ValueError(
146
+ f"Cannot infer data root from {out_dir}; pass --data-root explicitly"
147
+ )
148
+ return Path(*parts[:raw_index])
149
+
150
+
151
+ def sha256_file(path: Path) -> str:
152
+ """Return the SHA-256 digest for a file."""
153
+ h = hashlib.sha256()
154
+ with path.open("rb") as fh:
155
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
156
+ h.update(chunk)
157
+ return h.hexdigest()
158
+
159
+
160
+ def image_dimensions(path: Path) -> tuple[int, int]:
161
+ """Return image width and height without keeping the image open."""
162
+ from PIL import Image
163
+
164
+ with Image.open(path) as image:
165
+ return image.size
166
+
167
+
168
+ def params_json(params: dict[str, Any]) -> str:
169
+ """Serialize generation parameters consistently for manifest rows."""
170
+ return json.dumps(params, sort_keys=True, separators=(",", ":"))
171
+
172
+
173
+ def manifest_row(
174
+ *,
175
+ path: str,
176
+ cls: str,
177
+ spec: GeneratorSpec,
178
+ sha256: str,
179
+ prompt: str = "",
180
+ seed: int | str = "",
181
+ width: int | str = "",
182
+ height: int | str = "",
183
+ generation_params: dict[str, Any] | None = None,
184
+ extra: dict[str, Any] | None = None,
185
+ ) -> dict[str, Any]:
186
+ """Build a Stage 3A-compatible manifest row for one generated image."""
187
+ row: dict[str, Any] = {
188
+ "path": path,
189
+ "class": cls,
190
+ "source": spec.source,
191
+ "license": spec.license,
192
+ "license_url": spec.license_url,
193
+ "sha256": sha256,
194
+ "generator": spec.generator,
195
+ "model_family": spec.model_family,
196
+ "model_id": spec.model_id,
197
+ "prompt": prompt,
198
+ "seed": seed,
199
+ "width": width,
200
+ "height": height,
201
+ "generation_params_json": params_json(generation_params or {}),
202
+ }
203
+ if extra:
204
+ row.update(extra)
205
+ return row
scripts/dataset/prompts.txt ADDED
@@ -0,0 +1,1711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prompts for Flux.1-schnell synthetic generation (Stage 2 dataset).
2
+ #
3
+ # One complete prompt per line. Blank lines and lines starting with `#`
4
+ # are ignored. Lines should describe what a regular person might upload —
5
+ # include mundane subjects, casual snapshots, varied camera styles and
6
+ # imperfect compositions, not just gallery-worthy shots.
7
+ #
8
+ # Avoided on purpose: "cinematic", "8k", "highly detailed", "trending on
9
+ # artstation", "masterpiece", "award winning". These bias Flux toward the
10
+ # polished AI-art aesthetic that is trivially easy to detect, leaving the
11
+ # model blind to AI images that *try* to look casual.
12
+ #
13
+ # Structure: roughly grouped by category. Order does not matter at runtime
14
+ # (lines are sampled uniformly at random).
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # People — portraits, candid, group, activities (~250)
18
+ # ---------------------------------------------------------------------------
19
+ a portrait of an elderly woman with grey hair smiling at the camera
20
+ a teenage boy in a blue hoodie skateboarding down a quiet street
21
+ a chef in a white apron tasting soup from a wooden spoon
22
+ two children laughing on a swing set in a backyard
23
+ an office worker on her phone walking through a glass lobby
24
+ a man with a long beard reading a newspaper on a park bench
25
+ a dancer mid-leap on a wooden stage during rehearsal
26
+ a barista pulling a shot of espresso behind a marble counter
27
+ a construction worker in a hard hat eating lunch on a steel beam
28
+ a nurse in scrubs adjusting an IV drip beside a hospital bed
29
+ a young woman in a yellow raincoat waiting at a bus stop
30
+ a grandfather teaching his grandson how to tie a fishing knot
31
+ a cyclist taking a water break beside a country road
32
+ a librarian shelving books in a tall wooden bookcase
33
+ a mechanic in greasy overalls leaning under an open car bonnet
34
+ a guitar player busking on a cobblestone street corner
35
+ a bride and groom posing in front of a chapel
36
+ two coworkers laughing over coffee at an open-plan desk
37
+ a man in a suit running to catch a train at a station platform
38
+ a child blowing out candles on a birthday cake
39
+ an elderly couple holding hands on a park bench in autumn
40
+ a young father carrying his toddler on his shoulders
41
+ a woman jogging along a foggy waterfront at dawn
42
+ a teenager playing video games on a couch in a dim living room
43
+ a baker dusting flour onto a kneaded dough on a wooden table
44
+ a stylist trimming a customer's hair in a small salon
45
+ a yoga teacher demonstrating a pose to a small class
46
+ a security guard standing at the entrance to a museum
47
+ a postal worker delivering parcels from a small van
48
+ a fisherman repairing a net on a wooden dock
49
+ a farmer feeding chickens in a wire-mesh coop
50
+ a tattoo artist concentrating on a half-finished sleeve
51
+ a soldier reading a letter in a barracks
52
+ a dental hygienist explaining x-rays to a patient
53
+ a group of friends taking a selfie in front of a brick wall
54
+ a pregnant woman touching her stomach in front of a window
55
+ a child holding a balloon at a fairground
56
+ a man in a wheelchair stretching before a marathon
57
+ a girl playing the violin at a school concert
58
+ a businesswoman pacing while on a phone call near a glass window
59
+ a homeless man sharing his sandwich with a stray dog
60
+ a teacher writing on a whiteboard in a classroom
61
+ a man pumping gas at a small rural petrol station
62
+ a woman watering plants on a small apartment balcony
63
+ two old men playing chess in a sunny park
64
+ a girl reading a book under a tree on a summer afternoon
65
+ a chef plating a dish in a busy restaurant kitchen
66
+ a cleaner mopping a hospital corridor at night
67
+ a delivery cyclist with a thermal bag waiting at a traffic light
68
+ a surfer waxing his board on the sand
69
+ a beekeeper inspecting a frame from a hive
70
+ a vet examining a small dog on a metal table
71
+ a couple sharing an umbrella in a heavy downpour
72
+ a runner stretching against a wall in a stairwell
73
+ a man fixing a leaking pipe under a bathroom sink
74
+ a child painting a watercolor at a small wooden desk
75
+ a butcher arranging cuts of meat behind a glass display
76
+ a plumber writing an invoice on a clipboard
77
+ a delivery driver scanning a parcel in a hallway
78
+ a mother feeding a baby in a high chair
79
+ a hairdresser blowing out a customer's hair
80
+ two friends carrying boxes into a new apartment
81
+ an artist working on a canvas in a cluttered studio
82
+ a beekeeper holding a smoker beside hives in a field
83
+ a knitter working on a half-finished scarf in lamplight
84
+ a man washing his car in a suburban driveway
85
+ a couple cooking pasta together in a small kitchen
86
+ a violinist tuning her instrument backstage
87
+ a young man trying on glasses in an optician's mirror
88
+ a ranger pointing at a map on a forest trail
89
+ a woman doing pull-ups at an outdoor calisthenics park
90
+ a child eating an ice cream that is dripping down her hand
91
+ a man in a parka shoveling snow from his front path
92
+ a librarian helping a child find a book on a low shelf
93
+ a barber giving a fade haircut with electric clippers
94
+ two friends roasting marshmallows over a campfire
95
+ a forensic technician dusting a doorframe for fingerprints
96
+ a chef sharpening a knife on a steel rod
97
+ a bicycle courier stopped at a kerb checking his phone
98
+ a surfer paddling out through breaking waves at sunrise
99
+ a ballerina lacing her pointe shoes in a dressing room
100
+ a janitor emptying a bin in a quiet office
101
+ a model walking a runway under harsh stage lights
102
+ a piano teacher correcting a student's hand position
103
+ a window cleaner suspended from a high-rise on ropes
104
+ a courier signing for a package in a building lobby
105
+ a businessman tying his tie in a hotel mirror
106
+ a child throwing breadcrumbs to ducks at a pond
107
+ a man stretching his back in a small home gym
108
+ a woman applying lipstick in a car rear-view mirror
109
+ a homeless man asleep on a cardboard mat under an overpass
110
+ a child blowing bubbles in a sunlit garden
111
+ a jeweler examining a ring under a loupe
112
+ a father teaching his daughter to ride a bike on a quiet street
113
+ a paramedic loading a stretcher into an ambulance
114
+ a nun walking down a stone corridor of a convent
115
+ a grandmother braiding her granddaughter's hair on a porch
116
+ a woman sneezing into a tissue at a desk
117
+ a craftsman shaping a clay pot on a spinning wheel
118
+ a beekeeper extracting honey from a frame in a workshop
119
+ two girls jumping rope on a chalk-marked sidewalk
120
+ a sushi chef rolling a maki behind a counter
121
+ a man tying his running shoes on a park bench
122
+ a couple cuddling under a blanket on a sofa
123
+ a child crying in a supermarket aisle
124
+ a soldier polishing boots on a wooden chair
125
+ a violin maker varnishing an unfinished instrument
126
+ a chef weighing flour on a small kitchen scale
127
+ a teenager sitting on a curb eating fries from a paper bag
128
+ a man trying on a leather jacket in a thrift shop
129
+ a barista latte-arting a heart on a flat white
130
+ a runner crossing a finish line with arms raised
131
+ a man asleep on a long-haul train with a book on his lap
132
+ a graduation photo of a young woman in a black gown
133
+ a girl reading a comic book in a treehouse
134
+ a chef checking a roast in a steaming oven
135
+ a man patching a bicycle tube on a kitchen table
136
+ a paramedic taking notes on a clipboard beside a patient
137
+ a woman braiding garlic into a long string
138
+ a baker pulling sourdough loaves from a stone oven
139
+ a teenager eating cereal in pyjamas in a bright kitchen
140
+ a couple unloading groceries from a car boot
141
+ a child catching a butterfly in a glass jar
142
+ a runner stopping to drink water at a wooden trail marker
143
+ a drummer setting up a kit on a small stage
144
+ a craftsman carving wood with a chisel in a sunlit workshop
145
+ a woman applying mascara in a small bathroom mirror
146
+ a man writing in a notebook at a noisy diner
147
+ a fisherman gutting a fish on a wooden dock
148
+ a barista counting out change at a small bakery
149
+ a janitor mopping a school cafeteria after lunch
150
+ a man selling fruit from the back of a pickup truck
151
+ a woman scrolling on her phone in a packed subway car
152
+ a child building a sandcastle at low tide
153
+ a young man playing a harmonica on a porch
154
+ a baker piping cream onto a tray of eclairs
155
+ a warehouse worker scanning a barcode on a tall shelf
156
+ a schoolteacher lining up students in a hallway
157
+ a mother and daughter trying on hats in a department store
158
+ a barista wiping down the counter at closing time
159
+ a girl carrying a stack of books taller than her face
160
+ a man trying to fold a fitted sheet in a laundry room
161
+ a couple arguing in front of a parked car at night
162
+ a chef scoring a baguette with a razor blade
163
+ a teenager learning to drive with her father in the passenger seat
164
+ a man watching a football match alone in a small pub
165
+ a child napping with a stuffed rabbit on a sofa
166
+ a doctor washing his hands at a stainless-steel sink
167
+ a ranger pointing out a bird through binoculars to tourists
168
+ a busker drawing a small crowd in a metro station
169
+ a couple bringing flowers to a grave
170
+ a runner tying a shoelace at a starting line
171
+ a child trying to ride a unicycle on a driveway
172
+ a barista training a new hire on the espresso machine
173
+ a writer typing on a laptop at a window seat in a cafe
174
+ a ferryman pushing a small boat away from a wooden jetty
175
+ a chef yelling orders across a hot kitchen pass
176
+ a girl learning to use chopsticks at a dinner table
177
+ a couple painting the walls of a half-empty apartment
178
+ a maintenance worker changing a fluorescent tube on a tall ladder
179
+ a surveyor looking through a tripod-mounted instrument on a roadside
180
+ a man practicing tai chi alone in a misty park
181
+ a boy showing his grandfather a frog he caught in a jar
182
+ a paramedic checking a patient's pulse in the back of an ambulance
183
+ a janitor sweeping leaves from the steps of a courthouse
184
+ a mother spoon-feeding mashed banana to a baby
185
+ a tour guide pointing up at a cathedral ceiling
186
+ a couple looking at a map on a phone at a train platform
187
+ a barber sweeping hair clippings from a tiled floor
188
+ a woman drinking soup from a thermos on a park bench
189
+ a child trying to skate on a backyard pond in winter
190
+ a worker pouring fresh concrete into a wooden form
191
+ a security guard watching cctv monitors in a cramped booth
192
+ a teacher pinning student artwork to a corkboard
193
+ a baker proofing dough in cloth-lined baskets
194
+ a young woman crying at a bus station
195
+ a couple kissing under streetlights in a foggy alley
196
+ a man feeding pigeons from a paper bag on a stone bench
197
+ a woman braiding wet hair in a steamy bathroom mirror
198
+ a child running through a sprinkler in a backyard
199
+ a fishmonger arranging ice and fish in a market display
200
+ a runner pacing back and forth before a starting gun
201
+ a girl trying on her mother's high heels in a hallway
202
+ a mechanic showing a worn brake pad to a customer
203
+ a young woman lighting a cigarette in a doorway at night
204
+ a child climbing on a metal jungle gym at recess
205
+ a man stacking logs neatly against a shed wall
206
+ a couple posing awkwardly for a passport photo
207
+ a teenager listening to headphones on a school bus
208
+ a paramedic comforting a frightened child in the back of an ambulance
209
+ a janitor changing the bin liner in an office kitchen
210
+ a chef tasting a sauce from a wooden spoon and frowning
211
+ a mother chasing a toddler around a coffee table
212
+ a delivery driver looking lost on a residential street
213
+ a fisherman casting a line off a stone breakwater at dusk
214
+ a runner glancing at a smartwatch mid-stride
215
+ a baker brushing egg wash onto a tray of pastries
216
+ a woman trying to start a car on a cold morning
217
+ a man smoking a cigar on a wraparound porch
218
+ a couple flipping through a photo album on a couch
219
+ a barber lathering a customer for a straight-razor shave
220
+ a child holding a sparkler at a backyard party
221
+ a writer crossing out lines in a notebook with a red pen
222
+ a roofer hammering shingles under a hot midday sun
223
+ a child learning to tie shoelaces with their tongue out
224
+ a man hanging laundry on a line between two flats
225
+ a couple cooking eggs in a small studio apartment
226
+ a librarian stamping a date into a returned book
227
+ a baker mixing dough by hand in a stainless steel bowl
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Animals — pets, wildlife, farm, insects (~150)
231
+ # ---------------------------------------------------------------------------
232
+ a tabby cat sleeping on a sunlit windowsill
233
+ a golden retriever shaking water off after a bath
234
+ a small terrier digging a hole in a flower bed
235
+ a black cat staring intently at something offscreen
236
+ a parrot perched on a curtain rod inside a living room
237
+ a goldfish swimming in a small round bowl on a desk
238
+ a hamster running on a wheel in a glass cage
239
+ a beagle sniffing the base of a lamppost
240
+ a horse grazing in a green pasture under a cloudy sky
241
+ a Holstein cow chewing cud in a muddy field
242
+ a flock of sheep crossing a single-lane country road
243
+ a piglet nosing around in a straw-lined pen
244
+ a goat standing on a fallen log staring at the camera
245
+ a flock of chickens pecking around a wooden coop
246
+ a duck leading ducklings across a path beside a pond
247
+ a swan gliding across a glassy lake at sunrise
248
+ a fox cautiously emerging from underbrush at twilight
249
+ a deer grazing at the edge of a forest at dawn
250
+ a squirrel holding an acorn on a low branch
251
+ a raccoon raiding an overturned trash can
252
+ a hedgehog curled up among autumn leaves
253
+ a rabbit nibbling clover in a sunlit meadow
254
+ a bald eagle perched on a dead tree against a grey sky
255
+ a barn owl staring straight at the camera in dim light
256
+ a hummingbird hovering at a red feeder
257
+ a robin tugging a worm from wet grass
258
+ a flock of starlings swirling across a winter sky
259
+ a heron standing motionless in shallow water
260
+ a kingfisher diving toward the surface of a stream
261
+ a butterfly resting on a purple flower
262
+ a bumblebee crawling across the inside of a foxglove
263
+ a praying mantis on a green leaf at close range
264
+ a dragonfly perched on the tip of a reed
265
+ a snail leaving a trail across a rain-slicked stone
266
+ a spider in the centre of a dew-covered web at dawn
267
+ a tortoise eating a strawberry on a patio
268
+ a leopard gecko on a warm rock under a heat lamp
269
+ a python coiled on a branch inside a glass terrarium
270
+ a tropical fish swimming through coral in an aquarium
271
+ a pod of dolphins surfacing alongside a small boat
272
+ a humpback whale's tail flicking above the ocean surface
273
+ a seal lounging on a rocky outcrop in the sun
274
+ a sea otter floating on its back holding a clam
275
+ a starfish clinging to a tide-pool rock at low tide
276
+ a crab scuttling sideways across wet sand
277
+ a flock of penguins waddling across an icy beach
278
+ a polar bear walking across pack ice
279
+ a brown bear catching a leaping salmon at a waterfall
280
+ a moose wading across a shallow river at sunset
281
+ a herd of zebras drinking at a watering hole
282
+ a giraffe stretching its neck to reach high acacia leaves
283
+ a lion yawning under the shade of a tree
284
+ a cheetah resting on a low termite mound
285
+ a baby elephant nudging its mother's leg
286
+ a meerkat standing on its hind legs scanning the horizon
287
+ a wolf pack moving through deep snow at dusk
288
+ a bald eagle's nest with two chicks high in a pine tree
289
+ a hen roosting on a clutch of eggs in a wooden nest box
290
+ a flock of geese flying in a v formation against a sunset
291
+ a tabby kitten batting at a dangling string
292
+ a black labrador catching a tennis ball mid-air
293
+ a border collie herding sheep down a green hillside
294
+ a great dane lying across a small couch with legs hanging off
295
+ a poodle wearing a colorful scarf at a dog park
296
+ a bulldog asleep with its tongue hanging out
297
+ a husky howling on a snowy ridge
298
+ a chihuahua peeking out of a tote bag on a bus
299
+ a kitten asleep inside a coffee mug
300
+ a one-eyed cat lounging on a sunny porch
301
+ a stray dog drinking from a puddle in an alley
302
+ a parakeet preening another in a wire cage
303
+ a stick insect almost invisible against a green leaf
304
+ a chameleon clinging to a branch with curled tail
305
+ a baby giraffe peeking out from behind its mother
306
+ a calf nuzzling its mother in a barn
307
+ a rooster crowing on a wooden fence at sunrise
308
+ a turkey strutting across a farmyard
309
+ a llama spitting at a curious tourist
310
+ a donkey resting its head over a paddock fence
311
+ a peacock fanning its tail in a stately garden
312
+ a swan hissing at a dog approaching its nest
313
+ a magpie carrying a shiny piece of foil in its beak
314
+ a flock of pigeons rising from a city square
315
+ a sparrow bathing in a puddle on a patio
316
+ a koala asleep in the fork of a eucalyptus tree
317
+ a kangaroo standing alert in tall dry grass
318
+ a wombat shuffling across a dirt track
319
+ a possum staring from a tree branch at night
320
+ an octopus changing color in a shallow tide pool
321
+ a jellyfish drifting through deep blue water
322
+ a flock of flamingos wading in a pink-tinted lake
323
+ a school of small silver fish darting around coral
324
+ a hermit crab carrying a whelk shell across a beach
325
+ a butterfly chrysalis hanging from a leaf
326
+ a cocoon hatching with a damp moth emerging
327
+ a worm half-buried in dark garden soil
328
+ a colony of ants carrying a leaf fragment
329
+ a beetle climbing a long blade of grass
330
+ a moth pressed against a lit window at night
331
+ a guinea pig eating a slice of cucumber
332
+ a ferret peeking out of a sweatshirt sleeve
333
+ a rat sniffing a piece of cheese on a kitchen counter
334
+ a chipmunk with cheeks full of seeds on a stone wall
335
+ a baby seal alone on an icy beach looking around
336
+ an arctic fox curled up in a snowdrift
337
+ a herd of bison crossing a yellow plain
338
+ a wild turkey foraging at the edge of a forest
339
+ a black bear cub climbing a tree trunk
340
+ a bobcat slinking through tall grass at dusk
341
+ a porcupine ambling along a dirt road
342
+ a skunk lifting its tail at a curious dog
343
+ a cat napping curled around a houseplant
344
+ a dog standing in the bed of a pickup truck with the wind in its fur
345
+ a goldfish staring through the glass of an aquarium
346
+ a parakeet sitting on a child's shoulder
347
+ a tortoise eating dandelions in a backyard
348
+ a dog asleep on a doormat with muddy paws
349
+ a cat watching birds from inside a window
350
+ a horse drinking from a metal trough at a stable
351
+ a lamb wobbling on new legs in a barn
352
+ a piglet sleeping on a pile of others
353
+ a mouse crouched at the back of a kitchen cupboard
354
+ a swan family swimming across a misty lake
355
+ a pelican with a fish in its pouch standing on a dock
356
+ a flock of seagulls fighting over chips on a pier
357
+ a dog covered in mud after running through a field
358
+ a cat with one ear flicked back staring at a vacuum cleaner
359
+ a goat standing on the roof of a parked car
360
+ a ferret tumbling out of a cardboard box
361
+ a flock of starlings filling a winter tree like leaves
362
+ a herd of cattle blocking a country road
363
+ a black swan among white swans on a city pond
364
+ a parrot tilting its head at a mirror
365
+ a dog wearing a knitted sweater on a snowy walk
366
+ a cat refusing to come out from under a bed
367
+ a goldfish leaping briefly out of a pond
368
+ a hedgehog drinking from a shallow saucer in a garden
369
+ a stray dog asleep under a parked car at noon
370
+ a dragonfly skimming the surface of a pond
371
+ a pair of swallows building a nest under an eave
372
+ a fox crossing a snowy front lawn at dusk
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Landscapes — nature, weather, seasons, time of day (~200)
376
+ # ---------------------------------------------------------------------------
377
+ a misty forest path at sunrise
378
+ a snow-covered mountain pass under a clear blue sky
379
+ a calm lake reflecting autumn trees on a still morning
380
+ a rocky coastline with waves crashing against cliffs
381
+ a desert landscape with red dunes under a low sun
382
+ a wheat field rippling in the wind under a bright sky
383
+ a vineyard on a rolling hillside in late summer
384
+ a single oak tree standing alone in an empty field
385
+ a forest of birch trees with white trunks and yellow leaves
386
+ a path through a bamboo grove with sunlight filtering through
387
+ a meadow full of wildflowers in early summer
388
+ a dry riverbed cracked by the sun in late summer
389
+ a lighthouse on a rocky headland during a storm
390
+ a thunderstorm rolling across an open prairie
391
+ a tornado funnel touching the ground in distant farmland
392
+ a tropical beach at midday with white sand and turquoise water
393
+ a black-sand beach in iceland with crashing waves
394
+ a frozen lake with cracks visible beneath a thin layer of snow
395
+ a pine forest after a fresh snowfall
396
+ a glacier flowing between two rocky peaks
397
+ a salt flat reflecting clouds like a mirror at sunset
398
+ an active volcano with a glowing red lava flow at night
399
+ a rainbow over a green valley after a rainstorm
400
+ a foggy bay with boats barely visible at dawn
401
+ a marsh with tall reeds at sunset
402
+ a single cottonwood tree on a vast plain
403
+ a winding mountain road climbing through pine forest
404
+ a country road with stone walls on both sides under a grey sky
405
+ a gravel path through a heather moor
406
+ a single canoe pulled up on the shore of a quiet lake
407
+ a footbridge crossing a stream in a fern-filled forest
408
+ a wooden boardwalk through a coastal dune
409
+ a cliff edge with a railing overlooking distant mountains
410
+ a single farmhouse surrounded by autumn corn
411
+ a herd of cows grazing in a green field at golden hour
412
+ a freshly plowed field with tractor tracks under a low sky
413
+ a pumpkin patch ready for harvest in october
414
+ a sunflower field at peak bloom
415
+ a lavender field in summer with rows stretching to the horizon
416
+ a tea plantation on stepped hillsides at dawn
417
+ a rice paddy reflecting clouds in a tropical valley
418
+ a coffee plantation under a high canopy in misty mountains
419
+ a banana plantation with broad green leaves
420
+ an olive grove with twisted trunks under a hot sky
421
+ a citrus orchard heavy with oranges
422
+ a cherry orchard in full pink bloom
423
+ an apple orchard heavy with red fruit before harvest
424
+ a mossy stream running through a temperate rainforest
425
+ a waterfall plunging into a clear pool
426
+ a sea cave with light coming through a small opening
427
+ a tidal flat at low tide with seabirds feeding
428
+ a coral reef visible just below clear shallow water
429
+ a kelp forest swaying in green underwater light
430
+ a deep canyon with switchbacks descending to a river
431
+ a sandstone arch glowing red in evening light
432
+ a slot canyon with narrow walls of striped sandstone
433
+ a hot spring steaming against snow-covered ground
434
+ a geyser erupting in front of a wooded ridge
435
+ a frozen waterfall in a rocky canyon
436
+ a winter trail with footprints leading away into pines
437
+ a deer trail through tall grass at dawn
438
+ a hiking trail with switchbacks visible on a mountain face
439
+ a dirt road disappearing into the desert horizon
440
+ a country lane lined with stone walls under autumn trees
441
+ a cobblestone path through a ruined castle garden
442
+ a bridge of weathered planks over a small stream
443
+ a lookout point with a chain-link safety fence over a deep valley
444
+ a remote cabin in a clearing with smoke rising from the chimney
445
+ a yurt at dusk on a windswept steppe
446
+ a tent pitched beside a small alpine lake
447
+ a campsite with a smoldering fire ring at first light
448
+ a hammock strung between two trees overlooking a valley
449
+ a kayak on the shore of a fjord at low tide
450
+ a sailboat anchored in a quiet bay at sunset
451
+ an old wooden rowboat tied to a weathered jetty
452
+ a narrow stone bridge over a moss-banked stream
453
+ an autumn forest carpeted in fallen leaves
454
+ a winter morning with frost outlining every blade of grass
455
+ a spring meadow with bluebells and sunlight through fresh leaves
456
+ a summer afternoon with cumulus clouds piled high
457
+ an evening sky with horizontal pink and orange clouds
458
+ a starry night with the milky way over a desert horizon
459
+ a moonlit field with a single tree casting a long shadow
460
+ the aurora borealis over a snowy spruce forest
461
+ a rare double rainbow over a wet country road
462
+ a heavy fog rolling over a reservoir at dawn
463
+ heavy rain blurring the windows of a country cottage
464
+ a lightning strike forking against dark thunderclouds
465
+ a hailstorm pelting a parked car
466
+ a windswept dune with sand grains visible in motion
467
+ a salt marsh at low tide with channels of water
468
+ a beach with bleached driftwood scattered on the sand
469
+ a tide pool with anemones and small fish at low tide
470
+ a shipwreck partly buried in a beach
471
+ a riverbank with willows leaning toward the water
472
+ a meadow stream meandering past wildflowers
473
+ a freshwater spring bubbling up through clear sand
474
+ an estuary at sunset with reflections of an old jetty
475
+ a ridge line at sunrise with mist filling the valleys below
476
+ a basalt cliff with hexagonal columns
477
+ a pebble beach at low tide with a stone breakwater behind
478
+ a moor with heather in late summer
479
+ a reindeer crossing a snowy plateau in winter
480
+ a herd of bison crossing a yellowstone field
481
+ a herd of wild horses kicking up dust on a high plain
482
+ a mountain peak emerging above a sea of clouds
483
+ a forest of giant redwoods with sun shafts through the canopy
484
+ a ancient yew tree in the corner of a country churchyard
485
+ a willow tree leaning over a slow river
486
+ a baobab tree silhouetted at sunset on a savanna
487
+ a frozen stream with bubbles trapped in clear ice
488
+ a desert valley dotted with saguaro cacti at dusk
489
+ a narrow alpine lake reflecting a snowy peak
490
+ a mossy boulder beside a forest stream
491
+ a forest floor covered in ferns and dappled light
492
+ a marsh boardwalk leading to a wooden observation hide
493
+ a hiker's view from a high pass on a clear afternoon
494
+ a mountain hut perched on the edge of a steep ridge
495
+ a windmill on a hillside in northern europe
496
+ an old stone barn at the edge of a snowy field
497
+ a reservoir at low water with mud cracks
498
+ a wooden fishing pier extending into a calm sea
499
+ a row of beach huts in pastel colors on a british beach
500
+ a mangrove forest at low tide with exposed roots
501
+ a rice field at sunset with farmers walking home
502
+ a mountain village clinging to a steep slope
503
+ a desert oasis with palm trees and a small pool
504
+ a windswept island with a single white lighthouse
505
+ a cliff with seabirds nesting on every ledge
506
+ a coastline at low tide with a long line of seaweed on the sand
507
+ an inland salt lake with a pink hue from algae
508
+ a swamp with cypress trees and hanging spanish moss
509
+ a frozen waterfall illuminated by morning light
510
+ a small footbridge crossing a creek in a botanical garden
511
+ a hayfield freshly cut into long parallel rows
512
+ a grain silo standing alone on the edge of a field
513
+ a wind farm on a green hillside with slowly turning blades
514
+ a solar farm in a desert under a clear sky
515
+ a hydroelectric dam with water spilling over the top
516
+ a prairie under a heavy storm cloud
517
+ a grove of aspens with white bark and yellow leaves
518
+ a quiet beach at dusk with footprints leading to the water
519
+ a frozen pond with skate marks etched into the surface
520
+ a country fence with barbed wire and morning frost
521
+ a country road with a covered bridge in the distance
522
+ a single red barn against a green hillside
523
+ a row of haystacks at sunset
524
+ a field of fireflies at twilight in a wooded area
525
+ a hiking trail descending through wildflowers in midsummer
526
+ a forest after rain with droplets on every leaf
527
+ a beach at first light with a low pink sun on the horizon
528
+ a desert at midnight with a clear sky full of stars
529
+ a tropical jungle from above with a meandering river
530
+ a snowy clearing with deer tracks crossing it
531
+ a remote chapel on a cliff with the sea behind
532
+ an oak avenue leading to a country house
533
+ a country garden in summer with hollyhocks and bees
534
+ a frozen reservoir with ice fishing huts dotted across it
535
+ a salt-glazed coastal field at sunset
536
+ a pebble cove at low tide with sea cliffs behind
537
+ a lake at dawn with mist rising off the surface
538
+ a forest stream with a small wooden footbridge
539
+ a meadow at twilight with fireflies just starting to glow
540
+ a marsh at sunrise with mist between the reeds
541
+ a frozen river snaking through a snowy forest
542
+ a desert canyon at midday with sharp shadows
543
+ a tropical lagoon with a single sailboat anchored
544
+ a tundra in summer with low wildflowers
545
+ a bog with cotton grass swaying in the breeze
546
+ a high desert at dawn with sage and distant peaks
547
+ a coastal cliff at sunset with a path along the edge
548
+ a forest of dead trees standing in a flooded valley
549
+ a windswept moorland with stone cairns marking a path
550
+ a temperate rainforest with ferns and dripping moss
551
+ a bay at low tide with a wooden wreck visible
552
+ a quiet bay with seabirds wheeling over fishing boats
553
+ a steppe at dusk with a single rider on horseback
554
+ a lakeshore in autumn with red and yellow trees reflected
555
+ a tundra in winter with the sun barely above the horizon
556
+ a mountain ridge in fog with a single hiker silhouetted
557
+ a savanna with a watering hole at golden hour
558
+ a freshly fallen snow on a bench in a city park
559
+ a country lane after rain with puddles reflecting the sky
560
+ a grass airfield with a small white plane parked at the side
561
+ a sand bar exposed at low tide with seabirds gathering
562
+ a marsh on a calm morning with mist in the reeds
563
+ a frozen lake reflecting alpenglow on a high peak
564
+ a mossy log over a mountain stream
565
+ a beach at dawn with sand still cool and untouched
566
+ a grassy hillside in summer with grazing sheep
567
+ a coastline with a string of small islands offshore
568
+
569
+ # ---------------------------------------------------------------------------
570
+ # Urban — streets, architecture, vehicles, signage (~200)
571
+ # ---------------------------------------------------------------------------
572
+ a quiet residential street at dawn
573
+ a busy crosswalk in a city center at noon
574
+ a narrow alley with bins and a fire escape
575
+ a downtown intersection at night with light trails from cars
576
+ an old brick building with ivy growing up the side
577
+ a glass office tower reflecting a sunset sky
578
+ a row of victorian terraces in a london street
579
+ a small bakery with fogged windows on a winter morning
580
+ a corner cafe with patrons at an outdoor table
581
+ a hardware store with sandwich-board signs on the sidewalk
582
+ a butcher shop with cuts of meat in the window
583
+ a flower stand on a busy street corner
584
+ a kiosk selling magazines and lottery tickets
585
+ an old wooden phone booth on a quiet corner
586
+ a mailbox at the edge of a leafy suburban street
587
+ a fire hydrant leaking into a curbside puddle
588
+ a stop sign half-covered in stickers
589
+ a yellow taxi waiting at a red light
590
+ a city bus pulling up to a covered stop
591
+ a bicycle locked to a metal rack
592
+ a row of parked scooters on a paris street
593
+ a delivery van blocking half a one-way street
594
+ a tow truck loading an illegally parked car
595
+ a garbage truck working its way down a residential block
596
+ a milk truck making early-morning deliveries
597
+ a postal van with the back door open
598
+ a food truck with a queue at a lunch hour
599
+ an ice cream van with kids gathered at the window
600
+ a hot dog cart with steam rising from the kettle
601
+ a row of bicycles for hire chained to a docking station
602
+ a tram crossing a square with cobblestone tracks
603
+ a metro entrance with a green art-nouveau sign
604
+ a subway platform with passengers waiting at a yellow line
605
+ an empty subway car at midnight
606
+ a busy commuter train pulling into a suburban station
607
+ an overpass with traffic streaming below
608
+ a highway interchange seen from above
609
+ a toll booth on a country highway
610
+ a roundabout with a fountain in the center
611
+ a pedestrian crossing with worn paint
612
+ a one-lane bridge with a give-way sign
613
+ a multistory parking garage at dusk
614
+ a downtown crosswalk at rush hour
615
+ a hipster coffee shop with exposed brick walls
616
+ a vintage cinema with neon marquee letters
617
+ a corner bookstore with stacks visible through the window
618
+ a record shop with crates of vinyl in the doorway
619
+ a barbershop with a striped pole outside
620
+ a tattoo parlor with neon signage in the front window
621
+ a tailor shop with a sewing machine visible through the window
622
+ a thrift store with hanging racks visible from the street
623
+ a corner pub with hanging baskets at the entrance
624
+ a small synagogue between two larger buildings
625
+ a domed church at the end of a narrow street
626
+ a mosque with a minaret over a rooftop view
627
+ a hindu temple with painted figures over the doorway
628
+ a rural chapel with a small graveyard
629
+ a glass-and-steel art museum on a city square
630
+ a brutalist concrete building with weather-stained walls
631
+ an industrial warehouse converted into apartments
632
+ an old mill on a river with a working waterwheel
633
+ a row of victorian factory chimneys against a grey sky
634
+ a boarded-up storefront with peeling paint
635
+ a mural covering the side of an old brick building
636
+ a graffiti tag on a metal roller shutter
637
+ a stencil street-art piece on a concrete wall
638
+ a public sculpture in a small park
639
+ a bronze statue of a politician with pigeons on the head
640
+ an art-deco theater facade at night
641
+ a victorian train station with iron-and-glass roof
642
+ an underground passage with tiled walls
643
+ an underpass with a busker playing acoustic guitar
644
+ a cobblestone street in a rainy european old town
645
+ a street market with fruit and vegetable stalls
646
+ a fish market with crushed ice and styrofoam crates
647
+ a flea market with old radios and tools laid out on a blanket
648
+ a christmas market with wooden stalls and string lights
649
+ a night market with food stalls in southeast asia
650
+ a souk with hanging lamps and spice piles
651
+ a bazaar with woven baskets stacked high
652
+ an outdoor flea market in the rain with vendors covered in tarps
653
+ a high street with closed shops on a sunday afternoon
654
+ a residential street the morning after a snowfall
655
+ a city street flooded after a thunderstorm
656
+ a city park with cherry blossoms and benches
657
+ a small playground with a single child on the swings
658
+ a basketball court with chain nets in a public park
659
+ a tennis court at dusk with floodlights coming on
660
+ a city pool with empty deck chairs and a lifeguard reading
661
+ a community garden behind a chain-link fence
662
+ a small farmers market in a parking lot on a saturday morning
663
+ a row of allotments with sheds and beanpoles
664
+ an old gas station converted into a coffee shop
665
+ a drive-through bank with a single car at the window
666
+ a 24-hour laundromat with one customer reading
667
+ a phone repair shop with handsets in a glass case
668
+ a watch repair shop with clocks visible through the window
669
+ a key cutter's stall in a covered market
670
+ an alley behind a restaurant with stacked crates and a chef on a smoke break
671
+ a fire escape with potted plants on every landing
672
+ a rooftop with laundry hanging on a line
673
+ a rooftop terrace with strung lights and a small grill
674
+ a high-rise window cleaner mid-task on a glass facade
675
+ a construction site with cranes against a city skyline
676
+ a half-built skyscraper with exposed steel framing
677
+ a new apartment block with scaffolding still up
678
+ a freshly paved road with workers spreading hot tar
679
+ roadworks with cones and a flagger directing traffic
680
+ a power line technician at the top of a wooden pole
681
+ a streetlight just turning on at twilight
682
+ a row of bus stop benches with peeling paint
683
+ a public phone with a cracked screen and graffiti
684
+ a public bench with a coffee cup left behind
685
+ a city bench with a newspaper folded on the seat
686
+ a wooden boardwalk along a city waterfront
687
+ a footbridge across a busy highway
688
+ a highway shoulder with a hitchhiker holding a sign
689
+ a long-haul trucker filling up at a midnight gas station
690
+ a gas station at sunset with a single car parked
691
+ a warehouse loading dock with a truck reversing in
692
+ an industrial yard with stacked shipping containers
693
+ a ferry terminal at dawn with foot passengers waiting
694
+ an airport terminal at night with cleaners pushing carts
695
+ a small airfield with a single propeller plane
696
+ a bus depot at dawn with rows of parked buses
697
+ a tram depot with engineers at work on a tram
698
+ a railway crossing with the barrier coming down
699
+ a level crossing in a small town with a freight train passing
700
+ a metro station entrance covered in posters
701
+ a sleek high-speed train pulling into a modern station
702
+ a steam train at a heritage railway station
703
+ a single old wooden caboose abandoned on a siding
704
+ a train carriage being scrapped in a rail yard
705
+ a city skyline at night with a full moon rising
706
+ a city skyline at sunrise from a far-off ridge
707
+ a port with cranes loading a container ship
708
+ a marina with sailboats moored in neat rows
709
+ a small fishing harbor with crab pots stacked on the quayside
710
+ a beachfront promenade with a victorian pier
711
+ a seaside boardwalk with arcade games and a ferris wheel
712
+ a riverside walk with joggers and dog walkers
713
+ a canal with houseboats moored along one side
714
+ a lock-keeper's cottage beside a working canal lock
715
+ a quiet canal with reflections of buildings on the water
716
+ a city square with a fountain and pigeons
717
+ a town square with a clock tower and a war memorial
718
+ a piazza in italy with cafes and a renaissance fountain
719
+ a plaza in mexico with a bandstand and string lights
720
+ a courtyard between three apartment buildings with a single tree
721
+ a hidden garden behind an iron gate in a busy city
722
+ a residential street in summer with kids running in a sprinkler
723
+ a snowy residential street with cars half-buried in drifts
724
+ a rainy bus stop with people sharing umbrellas
725
+ a winter street with shops still lit at dusk
726
+ a busy market street in a southeast asian capital
727
+ a quiet residential street in tokyo at night
728
+ a wide boulevard in paris during morning rush hour
729
+ a narrow street in venice with washing strung overhead
730
+ a steep street in lisbon with a yellow tram
731
+ a stepped alley in a moroccan medina
732
+ a colorful street in havana with classic cars
733
+ a tree-lined avenue in autumn with leaves on the ground
734
+ a riverside walk with weeping willows
735
+ a city park in winter with a frozen pond and skaters
736
+ a rooftop bar at sunset with a city skyline behind
737
+ a hotel lobby with a doorman and a brass luggage cart
738
+ a corner store at night with the lights still on
739
+ a 24-hour diner with a single customer at the counter
740
+ a roadside motel with a flickering vacancy sign
741
+ a quiet train carriage at dawn
742
+ a busy train carriage at rush hour
743
+ a tram passenger looking out at a city street
744
+ a bus passenger leaning against a window with headphones on
745
+ a passenger plane interior with the cabin lights dimmed
746
+ a domestic airport gate area with people sleeping on chairs
747
+ a regional airport at sunrise with one plane on the tarmac
748
+ a small ferry terminal in the rain
749
+ a tug boat pushing a barge upstream
750
+ a freight train crossing a long steel bridge
751
+ a single car driving down a long straight road through cornfields
752
+ a rural intersection with a stop sign and a single farmhouse
753
+ a rural mailbox row at the end of a long driveway
754
+ a rural church with a graveyard at the edge of a field
755
+ a small town main street on a quiet sunday morning
756
+ a small town hardware store with old signage
757
+ a small town diner with a row of stools at the counter
758
+ a small town gas station with a hand-painted sign
759
+ a small town barber shop with a striped pole
760
+ a small town post office with a flag pole
761
+ a small town fire station with a vintage truck visible
762
+ a small town library in an old victorian house
763
+ a county fair with a ferris wheel and food stalls
764
+ a state fair with a livestock barn and tractor pulls
765
+ a parade with marching bands moving down a closed street
766
+ a fourth of july parade with kids on decorated bikes
767
+ a halloween street with carved pumpkins on every step
768
+ a christmas street with wreaths on every door
769
+ a chinese new year parade with a long dragon
770
+ a diwali festival with lit lamps lining a street
771
+ a new years celebration with fireworks over a river
772
+ a wedding procession leaving a small church
773
+ a funeral procession crossing a quiet square
774
+ a protest march with handmade signs filling a wide avenue
775
+ a cyclists' rally crossing a downtown intersection
776
+ a film crew shooting a scene on a closed city street
777
+
778
+ # ---------------------------------------------------------------------------
779
+ # Food & drink (~150)
780
+ # ---------------------------------------------------------------------------
781
+ a stack of pancakes with maple syrup running down the side
782
+ a bowl of ramen with a soft-boiled egg on top
783
+ a plate of spaghetti carbonara with cracked black pepper
784
+ a margherita pizza fresh from a wood oven
785
+ a hand-formed burger with melted cheese on a sesame bun
786
+ a bowl of pho with herbs and lime on the side
787
+ a sushi platter with assorted nigiri and rolls
788
+ a single croissant on a small white plate
789
+ a baguette torn open beside a wedge of brie
790
+ a wheel of camembert with a knife stuck into it
791
+ a charcuterie board with cured meats and olives
792
+ a wooden board with sliced sourdough and butter
793
+ a slice of new york cheesecake with strawberry sauce
794
+ a chocolate lava cake with vanilla ice cream
795
+ a tiramisu in a glass with cocoa powder on top
796
+ a crème brûlée with the sugar top cracked
797
+ a slice of carrot cake with cream cheese frosting
798
+ a single donut with rainbow sprinkles
799
+ a row of macarons in pastel colors
800
+ a glass jar of peanut butter on a kitchen counter
801
+ a wooden spoon stirring tomato sauce in a pan
802
+ a frying pan with bacon mid-cook
803
+ an omelette folded over with chives on top
804
+ scrambled eggs on toast with avocado
805
+ a poached egg on top of an english muffin
806
+ a bowl of cereal with sliced banana
807
+ a bowl of porridge with berries and honey
808
+ a smoothie bowl topped with granola and chia
809
+ an iced coffee with condensation on the glass
810
+ a flat white with a leaf-shape latte art
811
+ a cappuccino with cocoa dusted on the foam
812
+ a tea pot with a curl of steam rising from the spout
813
+ a glass of fresh-squeezed orange juice
814
+ a pint of beer with a thick foam head
815
+ a glass of red wine on a candlelit table
816
+ a martini with a single olive on a toothpick
817
+ a cocktail with a sprig of mint and a paper straw
818
+ a hot chocolate with whipped cream and marshmallows
819
+ a bowl of chili topped with sour cream and chives
820
+ a steaming bowl of clam chowder with crackers on the side
821
+ a tray of cinnamon buns fresh from the oven
822
+ a slab of brisket on butcher paper with pickles
823
+ a paper cone of fish and chips with vinegar
824
+ a pulled pork sandwich on a brioche bun
825
+ a mexican taco al pastor with pineapple
826
+ a burrito wrapped in foil with one bite taken
827
+ a quesadilla cut into wedges with salsa and guacamole
828
+ a plate of nachos loaded with cheese and jalapeños
829
+ a bowl of guacamole with tortilla chips
830
+ a tray of sushi rolls beside soy sauce and wasabi
831
+ a bento box with rice, fish, and pickled vegetables
832
+ a steaming bowl of dumplings with a small dish of vinegar
833
+ a plate of dim sum on a bamboo steamer
834
+ a wok of stir-fried vegetables with steam rising
835
+ a bowl of laksa with shrimp and bean sprouts
836
+ a plate of pad thai with lime wedges and crushed peanuts
837
+ a bowl of bibimbap with a raw egg yolk in the center
838
+ a grilled cheese sandwich cut diagonally
839
+ a club sandwich held together with toothpicks
840
+ a lobster roll on a buttered split-top bun
841
+ a hot dog with mustard and chopped onion
842
+ a plate of fries with ketchup on the side
843
+ a bag of popcorn at a movie theatre
844
+ a roasted turkey on a platter with rosemary
845
+ a glazed christmas ham with cloves
846
+ a roast chicken with crisp skin and lemon
847
+ a pan of lasagna fresh from the oven
848
+ a casserole dish with the lid just lifted off
849
+ a cast-iron skillet with cornbread inside
850
+ a wooden cutting board with chopped onions and garlic
851
+ a kitchen counter with a half-cut onion and tear stains
852
+ a bowl of fresh basil leaves on a counter
853
+ a cluster of cherry tomatoes on the vine
854
+ a basket of fresh strawberries from a farm stand
855
+ a bunch of carrots with the green tops still on
856
+ a head of romaine lettuce being washed in a sink
857
+ a wedge of watermelon on a hot summer day
858
+ a pile of mangoes at a fruit stall
859
+ a pyramid of oranges in a market display
860
+ a bunch of bananas hanging from a hook
861
+ a cluster of grapes still wet from washing
862
+ a pomegranate cut open with seeds spilling out
863
+ a halved avocado on a wooden board
864
+ a single ripe peach on a windowsill
865
+ a bowl of nuts on a coffee table
866
+ a wedge of dark chocolate broken into squares
867
+ a bar of chocolate with one bite taken
868
+ a glass jar of jam on a sunlit table
869
+ a glass of milk beside a plate of cookies
870
+ a plate of warm chocolate-chip cookies
871
+ a tray of brownies cut into squares
872
+ a slice of apple pie with vanilla ice cream
873
+ a slice of pumpkin pie with whipped cream
874
+ a homemade pie with a lattice top fresh from the oven
875
+ a tray of muffins cooling on a rack
876
+ a tray of cupcakes with buttercream frosting
877
+ a wedding cake with three tiers and fresh flowers
878
+ a birthday cake with lit candles
879
+ a sheet cake with a child's name in icing
880
+ a fresh batch of bagels on a wooden board
881
+ a glass jar of pickles on a kitchen shelf
882
+ a tin of sardines opened on a piece of toast
883
+ a bowl of olives with a small empty dish for pits
884
+ a wedge of feta on a salad with cucumbers and tomatoes
885
+ a bowl of hummus with olive oil pooled on top
886
+ a plate of falafel with tahini and pickled turnips
887
+ a plate of curry with basmati rice and naan
888
+ a plate of biryani with raita
889
+ a thali with several small bowls on a metal tray
890
+ a bowl of dal makhani with butter melting on top
891
+ a banana leaf with rice and curries served family-style
892
+ a bowl of beef rendang with sticky rice
893
+ a plate of sashimi with daikon shreds
894
+ a bowl of miso soup with tofu and seaweed
895
+ a plate of tempura with dipping sauce
896
+ a tray of yakitori skewers with a wedge of lemon
897
+ a bowl of pho with thinly sliced beef
898
+ a plate of dumplings with chili oil on the side
899
+ a steaming bao bun split open with pork inside
900
+ a coconut split open with a straw inside
901
+ a roadside stall selling fresh sugarcane juice
902
+ a market stall stacked with dried chillies
903
+ a stall selling spices in open burlap sacks
904
+ a butcher counter with neat rows of meat
905
+ a fishmonger's display with whole fish on ice
906
+ a cheese counter with wheels and wedges labeled
907
+ a wine cellar with bottles racked floor to ceiling
908
+ a rustic farmhouse kitchen with a pot simmering
909
+ a modern kitchen with a chef plating a dish
910
+ a kitchen island covered with prep ingredients
911
+ a cutting board with neatly chopped vegetables
912
+ a meat thermometer reading the inside of a roast
913
+ a stand mixer working a batch of dough
914
+ a piping bag finishing a row of meringues
915
+ a knife slicing a tomato into perfect rounds
916
+ a kettle on a gas stove with steam pouring out
917
+ a frying pan with butter foaming and just turning brown
918
+ a pasta machine with a sheet of dough being rolled
919
+ a crab boil dumped on a newspaper-covered table
920
+ a crawfish boil with corn and potatoes
921
+ a clam bake at the beach with a dug pit
922
+ a backyard barbecue with steaks on the grill
923
+ a backyard pizza oven with a fire glowing inside
924
+ a smoker with brisket inside and smoke curling out
925
+ a campfire with a dutch oven hanging above
926
+ a hiker eating a granola bar on a rock
927
+ a kid licking an ice-cream cone melting in the heat
928
+ a popsicle dripping down a child's hand
929
+ a slice of birthday cake on a paper plate at a party
930
+ a half-eaten sandwich beside an open laptop at a desk
931
+ a coffee mug with a heart drawn in the foam
932
+ a glass of sparkling water with a slice of lemon
933
+ a glass of iced tea on a wicker table
934
+ a thermos of soup at a tailgate party
935
+
936
+ # ---------------------------------------------------------------------------
937
+ # Indoor — homes, offices, public spaces (~150)
938
+ # ---------------------------------------------------------------------------
939
+ a cozy living room with a fireplace and a sleeping cat
940
+ a small studio apartment with a futon and a tiny kitchen
941
+ a kitchen with morning light spilling onto the counter
942
+ a kitchen island with hanging copper pots above
943
+ a dining room set for a family dinner
944
+ a bedroom with sunlight through sheer curtains
945
+ an unmade bed with morning light through a window
946
+ a child's bedroom with toys scattered across the floor
947
+ a teenager's bedroom with posters and a desk in the corner
948
+ a home office with two monitors and a coffee mug
949
+ a study with bookshelves to the ceiling and a leather chair
950
+ an attic with sloped ceilings and a single skylight
951
+ a basement workshop with tools hung neatly on a pegboard
952
+ a garage with a half-restored motorcycle
953
+ a garage with a rake, snow shovel, and bicycles
954
+ a laundry room with detergent shelves and a clothes line
955
+ a bathroom with subway tiles and a clawfoot tub
956
+ a small powder room with a vintage mirror
957
+ a hallway with framed family photos along one wall
958
+ a foyer with a wooden bench and rain boots underneath
959
+ a pantry with mason jars labeled and stacked
960
+ a walk-in closet with neatly hanging clothes
961
+ a nursery with a wooden crib and a mobile of stars
962
+ a playroom with a small table and crayons spilled across it
963
+ a sunroom full of houseplants and wicker furniture
964
+ a library inside an old country house
965
+ a music room with a grand piano and sheet music on the stand
966
+ a recording studio with foam walls and a microphone
967
+ a photographer's studio with seamless paper backdrop
968
+ a small art studio with paints and canvases stacked
969
+ a potter's studio with clay-streaked aprons hanging on hooks
970
+ a sewing room with bolts of fabric and a sewing machine
971
+ a meditation room with cushions and a low table
972
+ a yoga studio with hardwood floors and rolled mats
973
+ a dance studio with mirrored walls and a barre
974
+ a small home gym with weights and a treadmill
975
+ a basement bar with a pool table and neon signs
976
+ a man cave with a leather sofa and a big screen
977
+ a finished basement set up as a theater room
978
+ a gaming room with rgb lights and a dual-monitor setup
979
+ a room mid-renovation with paint cans and drop cloths
980
+ a freshly painted empty room with the smell of paint almost visible
981
+ an empty apartment after a move-out with marks on the walls
982
+ an open-plan office with rows of desks
983
+ a small startup office with sit-stand desks and exposed beams
984
+ a coworking space with a coffee bar and a few people on laptops
985
+ a corporate boardroom with a long table and twelve chairs
986
+ a doctor's waiting room with magazines on a side table
987
+ a hospital corridor with fluorescent lighting at night
988
+ an emergency room reception desk with people waiting
989
+ a dentist's chair with the overhead light on
990
+ a vet's exam room with a metal table and a scale
991
+ a hospital room with a single occupied bed and a window
992
+ an x-ray room with a film viewer wall lit up
993
+ a chemistry lab with rows of glassware on shelves
994
+ a biology lab with microscopes and a centrifuge
995
+ a clean room with workers in full bunny suits
996
+ a server room with rows of blinking racks
997
+ a control room with screens covering one wall
998
+ a courtroom with empty benches and a wooden bar
999
+ a police station's front desk on a quiet night
1000
+ a fire station bay with a fire truck inside
1001
+ a post office sorting room with parcels everywhere
1002
+ a bank's main hall with marble floors and tellers
1003
+ an old-fashioned bank vault door open
1004
+ a museum hallway with art on the walls and benches
1005
+ an art gallery with track lighting on a single painting
1006
+ a natural history museum with a dinosaur skeleton
1007
+ a children's museum with a hands-on exhibit
1008
+ a planetarium with a domed ceiling lit up
1009
+ a music hall with red velvet seats empty before a show
1010
+ a concert hall with the stage lit and the audience seated
1011
+ a small theater stage with set still being built
1012
+ a black-box theater with chairs in a circle around the actors
1013
+ a movie theater with the screen lit and seats half full
1014
+ a multiplex lobby with a popcorn counter
1015
+ a bowling alley with neon and pinspotters at the far end
1016
+ an arcade with rows of cabinets glowing
1017
+ a roller rink with kids skating in a circle
1018
+ a swimming pool indoor with lap lanes
1019
+ a hotel lobby with marble floors and a chandelier
1020
+ a hotel room with the bed turned down and a chocolate on the pillow
1021
+ a hostel dormitory with bunk beds and shared cubbies
1022
+ a bed and breakfast living room with a fire and tea tray
1023
+ a small inn with timber beams and a low ceiling
1024
+ a coffee shop interior with mismatched chairs and a bookshelf
1025
+ a bakery interior with breads displayed in baskets
1026
+ a butcher shop interior with cuts of meat in a display case
1027
+ an old-fashioned ice cream parlor with red booths
1028
+ a diner interior with chrome stools at a counter
1029
+ a fast-food restaurant at midnight with a single customer
1030
+ a fine-dining restaurant with white tablecloths and candles
1031
+ a sushi bar with chefs working behind a glass case
1032
+ a dim sum restaurant with carts being pushed between tables
1033
+ a noodle shop with steam rising from open kitchen
1034
+ a tapas bar with small plates and bottles of wine on shelves
1035
+ a pub interior with a dart board and a fire
1036
+ a small wine bar with bottles racked behind the counter
1037
+ a hipster cocktail bar with brass fixtures and a jazz singer
1038
+ a karaoke room with neon and a leather couch
1039
+ a billiards hall with green felt tables and overhead lights
1040
+ a small bookstore with a reading nook and a sleeping cat
1041
+ a record shop with crates of vinyl and posters on the walls
1042
+ a vintage clothing store with mannequins and racks
1043
+ a department store with displays under bright lighting
1044
+ a supermarket aisle with shelves stocked floor to ceiling
1045
+ a corner store with a clerk reading behind the counter
1046
+ a hardware store interior with rows of paint
1047
+ a pharmacy interior with a queue at the prescription counter
1048
+ a post office with a wall of small boxes
1049
+ a gym interior with weights and rubber flooring
1050
+ a martial arts dojo with mats and a kanji on the wall
1051
+ a climbing gym interior with a tall bouldering wall
1052
+ a swimming pool changing room with lockers and benches
1053
+ a sauna interior with wooden benches and a heater
1054
+ a hammam interior with steam and stone benches
1055
+ a spa treatment room with a massage table and dim lighting
1056
+ a salon interior with chairs and mirrored walls
1057
+ a barbershop interior with a striped pole and big mirrors
1058
+ a tattoo parlor interior with portfolios on a coffee table
1059
+ a piercing studio with a clean steel chair
1060
+ an ice rink with the surface freshly resurfaced
1061
+ a roller derby practice on an outdoor banked track
1062
+ a children's classroom with low tables and bright walls
1063
+ a high school classroom with desks in rows and a projector
1064
+ a university lecture hall with sloped seating
1065
+ a school library with study carrels and a globe
1066
+ a school cafeteria with trays on a steel rail
1067
+ a school gym with a basketball hoop and bleachers
1068
+ a school auditorium with a grand piano on stage
1069
+ a kindergarten with painted handprints on the wall
1070
+ a nursery with cribs and a rocking chair
1071
+ an after-school program with kids playing chess
1072
+ a preschool art table with finger paints and aprons
1073
+ a co-op kindergarten with parents helping out
1074
+ a children's reading area at a public library
1075
+ a community center hall set up for a wedding
1076
+ a town hall meeting with chairs in rows
1077
+ a polling station with privacy booths in a school gym
1078
+ a senior center with bingo cards on tables
1079
+ a homeless shelter with cots in rows
1080
+ a soup kitchen with volunteers serving from a long table
1081
+ a food bank with shelves of canned goods and pasta
1082
+ a community garden's tool shed with neatly hung tools
1083
+ a children's hospital play area with toys and a small slide
1084
+ a hospice room with a hand-knit blanket on the bed
1085
+ a small dental office reception with a fish tank
1086
+ a busy emergency department triage area
1087
+ a rural medical clinic with a wood-paneled waiting room
1088
+ an emergency operations center with maps and screens
1089
+
1090
+ # ---------------------------------------------------------------------------
1091
+ # Objects & still life (~150)
1092
+ # ---------------------------------------------------------------------------
1093
+ a single red apple on a wooden table
1094
+ a worn leather wallet on a kitchen counter
1095
+ a set of keys on a small dish in an entryway
1096
+ a smartphone face down on a desk
1097
+ a cracked iphone screen on a bed
1098
+ a laptop with a coffee ring on the lid
1099
+ a wireless headphone case open on a desk
1100
+ a pair of running shoes by a front door
1101
+ a pair of leather boots beside a hearth
1102
+ a pair of high heels at the bottom of a staircase
1103
+ a stack of books on a bedside table
1104
+ a book open with reading glasses on top
1105
+ a fountain pen and a half-written letter
1106
+ a set of artist's brushes in a glass jar
1107
+ a tube of paint with the cap off
1108
+ a sketchbook open to a half-finished drawing
1109
+ a wristwatch with the second hand visible
1110
+ a vintage pocket watch on a velvet cloth
1111
+ a ring in a small velvet box
1112
+ a simple silver chain on a dressing table
1113
+ a pair of glasses folded on an open book
1114
+ a stack of mail on a hallway table
1115
+ a crumpled receipt on a kitchen counter
1116
+ a coffee mug with a chipped rim
1117
+ a tea cup with a small crack along the side
1118
+ a stainless steel water bottle on a desk
1119
+ a battered thermos beside a hiking pack
1120
+ a cast-iron skillet hanging on a hook
1121
+ a wooden cutting board with chef's knife
1122
+ a bunch of keys with a dozen keychains
1123
+ a cluttered desk with notebooks and pens
1124
+ an empty water glass on a windowsill
1125
+ a vase with three drooping tulips
1126
+ a vase with fresh peonies just placed
1127
+ an old typewriter on a writing desk
1128
+ a dial telephone in a green hallway
1129
+ a transistor radio on a kitchen shelf
1130
+ an old polaroid camera on a dresser
1131
+ a film camera with the lens cap off
1132
+ a digital slr on a tripod in a studio
1133
+ a pair of binoculars hanging on a hook
1134
+ a magnifying glass on top of a stamp album
1135
+ an open jewelry box with mismatched earrings
1136
+ a small metal tin with old buttons inside
1137
+ an iron with the cord wrapped around it
1138
+ a sewing kit open on a coffee table
1139
+ a knitting basket with half-finished sock
1140
+ a spool of thread and a sewing needle
1141
+ a pair of scissors on a paper-strewn desk
1142
+ a stapler open with a strip of staples
1143
+ a roll of tape and a half-cut sheet of wrapping paper
1144
+ a bunch of crayons spilled across a table
1145
+ a child's drawing taped to a fridge
1146
+ a fridge magnet collection of vacation souvenirs
1147
+ a coffee maker drip-brewing into a glass pot
1148
+ a kettle whistling on a gas stove
1149
+ a toaster with two slices of bread popped up
1150
+ a microwave with a covered bowl spinning inside
1151
+ a blender mid-pulse with smoothie inside
1152
+ a stand mixer mid-knead with dough wrapping the hook
1153
+ a cast iron pan with a fried egg
1154
+ a pressure cooker with steam venting from the top
1155
+ a slow cooker with a stew bubbling
1156
+ a sushi rolling mat with rice and seaweed
1157
+ a chef's knife on a wooden block
1158
+ a bread knife mid-slice on a sourdough loaf
1159
+ a meat thermometer stuck into a roast
1160
+ a wooden rolling pin dusted with flour
1161
+ a measuring jug with milk
1162
+ a measuring cup overflowing with sugar
1163
+ a kitchen scale with a pat of butter on it
1164
+ a glass jar of dry pasta on a shelf
1165
+ a row of mason jars with grains and beans
1166
+ a basket of fresh eggs on a counter
1167
+ a wooden bowl of nuts on a coffee table
1168
+ a bowl of fresh fruit on a kitchen island
1169
+ a paper bag of groceries on a doorstep
1170
+ a brown bag lunch with a sandwich peeking out
1171
+ a metal lunch box with stickers
1172
+ a child's backpack with a stuffed animal sticking out
1173
+ a school backpack on a hallway bench
1174
+ a satchel hanging from a coat rack
1175
+ a leather briefcase on an office chair
1176
+ a backpack with hiking pole strapped to the side
1177
+ a duffel bag on a hotel bed
1178
+ a suitcase with a luggage tag in an airport
1179
+ a vintage trunk at the foot of a bed
1180
+ a stack of vintage suitcases as side tables
1181
+ a guitar case beside a leather couch
1182
+ a violin case open with the violin inside
1183
+ a saxophone on a stand in a corner
1184
+ a harmonica on a wooden table
1185
+ a kazoo and a recorder on a child's bed
1186
+ a vinyl record on a turntable mid-spin
1187
+ a stack of vinyl records beside a turntable
1188
+ a cassette tape with the tape pulled out
1189
+ an old cd jewel case open on a desk
1190
+ a boom box from the eighties on a shelf
1191
+ a tube tv with rabbit ears on top
1192
+ a flat-screen tv mounted above a fireplace
1193
+ a video game controller on a couch
1194
+ a chess board mid-game with a king tipped over
1195
+ a rubik's cube nearly solved
1196
+ a deck of cards spread on a table
1197
+ a backgammon board with dice on the side
1198
+ a monopoly board mid-game with houses everywhere
1199
+ a jigsaw puzzle half-completed on a coffee table
1200
+ a single lego brick on a hardwood floor
1201
+ a box of crayons with several broken
1202
+ a child's wooden train set on a rug
1203
+ a doll house with a half-finished living room
1204
+ a stuffed bear sitting on a child's bed
1205
+ a wooden rocking horse in a sunlit nursery
1206
+ a toolbox open with tools spread out on a workbench
1207
+ a hammer and a row of nails on a wooden bench
1208
+ a level and a tape measure on a kitchen counter
1209
+ a paint roller in a tray of paint
1210
+ a stepladder under a half-painted wall
1211
+ a power drill with a few bits beside it
1212
+ a chainsaw on a tarp beside a stack of wood
1213
+ a wheelbarrow full of garden mulch
1214
+ a garden hose coiled on a hook
1215
+ a watering can beside a row of seedlings
1216
+ a pair of garden gloves on a dirt-stained workbench
1217
+ a pair of pruners on a kitchen counter
1218
+ a stack of garden trays with seedlings
1219
+ a basket of fresh eggs from the chicken coop
1220
+ a wooden honeycomb frame dripping with honey
1221
+ a glass jar of homemade jam with a checked cloth top
1222
+ a hand-knit scarf in progress with needles and yarn
1223
+ a cross-stitched piece in a hoop
1224
+ a half-finished crochet blanket on a couch
1225
+ a wooden whittling project with shavings on the floor
1226
+ a mason jar of pickles fermenting with a glass weight
1227
+ an open envelope with a handwritten letter inside
1228
+ a stack of postcards from various cities
1229
+ a leather journal with a fountain pen on top
1230
+ a passport open to a stamp page
1231
+ a wedding ring on a velvet cushion
1232
+ a baby's first shoes on a dresser
1233
+ a wedding bouquet pressed under a glass dome
1234
+ a christening gown in tissue paper
1235
+ a graduation cap on a kitchen counter
1236
+ a single trophy on a mantel
1237
+ a sports medal hanging from a doorknob
1238
+ a pair of skates with the laces still tied
1239
+ a hockey stick leaning by a back door
1240
+ a baseball mitt and ball on a porch
1241
+ a basketball on a driveway
1242
+ a soccer ball on wet grass
1243
+ a tennis racket against a court fence
1244
+
1245
+ # ---------------------------------------------------------------------------
1246
+ # Activities, sports, hobbies (~120)
1247
+ # ---------------------------------------------------------------------------
1248
+ two friends hiking up a forest trail with backpacks
1249
+ a couple paddling a tandem kayak across a lake
1250
+ a lone backpacker setting up a tent at dusk
1251
+ a climber on a sheer rock face with a rope
1252
+ a group of skiers on a chairlift in a snowstorm
1253
+ a snowboarder mid-jump in a halfpipe
1254
+ a cross-country skier on a wooded trail
1255
+ a snowshoer crossing a clearing in deep snow
1256
+ a fly fisherman casting in a rocky stream
1257
+ a boy fishing off a wooden dock
1258
+ a kayaker paddling down a slow river
1259
+ a rafter mid-rapid with water spray
1260
+ a sailboat heeling over in a strong breeze
1261
+ a windsurfer on a choppy bay
1262
+ a kitesurfer mid-jump above the waves
1263
+ a surfer carving the face of a wave
1264
+ a paddleboarder on a glassy lake at dawn
1265
+ a swimmer doing laps in an outdoor pool
1266
+ a runner pacing on a wooded trail at sunrise
1267
+ a marathoner crossing a finish line in pouring rain
1268
+ a cyclist standing on the pedals climbing a hill
1269
+ a mountain biker mid-jump on a forest trail
1270
+ a bmx rider doing a trick at a skate park
1271
+ a skateboarder grinding a rail in a downtown plaza
1272
+ a roller skater dancing in a city park
1273
+ a parkour runner leaping between rooftops
1274
+ a basketball player shooting a free throw
1275
+ a soccer player about to take a corner kick
1276
+ a tennis player serving from the baseline
1277
+ a golfer driving from a tee on a sunny course
1278
+ a baseball pitcher mid-windup in a stadium
1279
+ a football quarterback handing off to a running back
1280
+ a rugby player breaking a tackle
1281
+ a hockey player checking another into the boards
1282
+ a lacrosse player scoring a goal
1283
+ a fencer in mid-lunge in a sport hall
1284
+ a boxer hitting a heavy bag in a gym
1285
+ a karate student breaking a board with a kick
1286
+ a judo throw being executed on a mat
1287
+ a kickboxer training with pads
1288
+ a powerlifter mid-deadlift in a competition
1289
+ a weightlifter performing a clean and jerk
1290
+ a gymnast on a balance beam mid-routine
1291
+ a diver mid-flip from a high platform
1292
+ a synchronized swimming team in formation
1293
+ a ballet dancer rehearsing in front of a mirror
1294
+ a tap dancer practicing on a wooden floor
1295
+ a salsa couple dancing on a city sidewalk
1296
+ a hip-hop crew rehearsing in a studio
1297
+ a folk dancer in traditional costume at a festival
1298
+ a knitter at a craft fair with a basket of yarn
1299
+ a quilter at her sewing machine
1300
+ a wood carver mid-project on a porch
1301
+ a jeweler at a workbench setting a stone
1302
+ a calligrapher mid-stroke on a long piece of paper
1303
+ a watercolor painter at an easel outdoors
1304
+ a plein-air oil painter on a cliff
1305
+ a sculptor chipping at a marble block
1306
+ a printmaker pulling a print from an etching press
1307
+ a glassblower shaping a vase at a furnace
1308
+ a blacksmith hammering hot metal on an anvil
1309
+ a leatherworker stamping a wallet on a workbench
1310
+ a beekeeper inspecting a frame in protective gear
1311
+ a beekeeper extracting honey from a comb
1312
+ a vintner pruning vines in winter
1313
+ a vintner tasting wine from a barrel in a cellar
1314
+ a brewer stirring a mash tun with a long paddle
1315
+ a brewer pulling a sample from a fermenter
1316
+ a baker shaping a sourdough loaf
1317
+ a baker scoring a loaf with a razor blade
1318
+ a chef breaking down a whole fish
1319
+ a chef plating a tasting menu course
1320
+ a barista pulling latte art on a flat white
1321
+ a barista weighing beans for a brew
1322
+ a bartender shaking a cocktail at a polished bar
1323
+ a bartender lighting a flame on top of a cocktail
1324
+ a sommelier pouring wine into a tasting glass
1325
+ a butcher breaking down a side of beef
1326
+ a fisherman pulling a crab pot up from the dock
1327
+ a clammer working at low tide with a rake
1328
+ an oysterman shucking oysters at a market stall
1329
+ a hunter glassing a ridge with binoculars
1330
+ a falconer with a bird of prey on a gloved hand
1331
+ a dog trainer working with a malinois on a long line
1332
+ a horse trainer working a young horse on a lunge line
1333
+ a rancher branding a calf at a roundup
1334
+ a sheep shearer mid-shear with wool piling up
1335
+ a farmer driving a tractor through a wheat field
1336
+ a farmer harvesting potatoes by hand
1337
+ a flower farmer cutting blooms for market
1338
+ a beekeeper preparing a smoker
1339
+ a forager gathering mushrooms on a forest floor
1340
+ a gardener pruning roses in summer
1341
+ a gardener mulching a vegetable bed
1342
+ a kid learning to ride a bike with training wheels
1343
+ a kid learning to swim with arm floaties
1344
+ a kid learning to read with a parent
1345
+ a kid practicing piano with sheet music
1346
+ a kid drawing with chalk on a driveway
1347
+ a kid building a fort out of couch cushions
1348
+ a kid jumping into a pile of leaves
1349
+ a kid running through sprinklers in a backyard
1350
+ a kid riding a sled down a snowy hill
1351
+ a kid eating watermelon with juice running down the chin
1352
+ a teenager learning to drive in a parking lot
1353
+ a teenager studying late at night with a single lamp
1354
+ a teenager moving boxes into a college dorm
1355
+ a couple painting their first apartment together
1356
+ a couple assembling furniture from instructions
1357
+ a couple cooking together in a small kitchen
1358
+ a couple making sushi at home with a rolling mat
1359
+ a couple sitting on a porch swing on a summer evening
1360
+ a couple walking on a beach at low tide
1361
+ a couple laughing at a joke at a kitchen table
1362
+ a group of friends playing board games at a coffee table
1363
+ a group of friends playing poker around a kitchen table
1364
+ a group of friends watching a sports game at a bar
1365
+ a group of friends going camping in a forest
1366
+ a group of friends paddling canoes on a lake
1367
+ a group of friends having a backyard barbecue
1368
+ a group of friends at a karaoke bar
1369
+ a group of friends taking a holiday photo
1370
+ a family eating dinner together at a long table
1371
+ a family decorating a christmas tree together
1372
+ a family carving pumpkins on the porch
1373
+ a family visiting a pumpkin patch
1374
+ a family hiking a national park trail
1375
+ a family at the beach with sandcastles and umbrellas
1376
+ a family on a camping trip with a fire and marshmallows
1377
+ a family on a road trip with snacks in the back seat
1378
+ a family on a ski trip getting lift tickets
1379
+ a family doing chores together on a saturday morning
1380
+
1381
+ # ---------------------------------------------------------------------------
1382
+ # Travel, transport, landmarks (~130)
1383
+ # ---------------------------------------------------------------------------
1384
+ a backpacker waiting at a quiet train station at dawn
1385
+ a tourist looking up at a cathedral ceiling
1386
+ a couple posing in front of the eiffel tower
1387
+ a lone traveler watching the sun set over angkor wat
1388
+ a tour group photographing the colosseum
1389
+ a tourist holding a map in front of the brandenburg gate
1390
+ a hiker on a switchback trail in patagonia
1391
+ a monk lighting candles inside a tibetan monastery
1392
+ a farmer ploughing a field in tuscany
1393
+ a fisherman mending nets in a portuguese harbor
1394
+ a couple walking through a flower market in amsterdam
1395
+ a tram crossing a square in lisbon
1396
+ a vespa parked outside a roman cafe
1397
+ a paris cafe table with two espressos and a newspaper
1398
+ a london bus crossing tower bridge
1399
+ a black cab waiting at a piccadilly intersection
1400
+ a new york yellow cab pulling up to a curb
1401
+ a chicago el train passing between buildings
1402
+ a san francisco cable car climbing a steep hill
1403
+ a las vegas strip at night with neon
1404
+ a route 66 motel sign at sunset
1405
+ a roadside diner along a desert highway
1406
+ a long highway stretching toward distant mountains
1407
+ a pickup truck stopped at an overlook on a mountain pass
1408
+ a campervan parked at a forest clearing
1409
+ a vintage volkswagen bus on a coastal road
1410
+ an old wood-paneled station wagon on a country road
1411
+ a rented car parked at a national park visitor center
1412
+ a mototaxi waiting at a thai night market
1413
+ a tuk-tuk weaving through bangkok traffic
1414
+ a rickshaw on a quiet calcutta street
1415
+ a horse-drawn carriage on a cobblestone street
1416
+ a steam train pulling out of a small heritage station
1417
+ a sleeper train carriage at night with curtains drawn
1418
+ a high-speed train passing a rural station too fast to see
1419
+ a freight train crossing a long iron bridge over a river
1420
+ a mountain railway with a single carriage on a steep grade
1421
+ a cable car ascending a snowy peak
1422
+ a gondola crossing a venetian canal at dusk
1423
+ a riverboat docked beside an old town
1424
+ a houseboat on a quiet canal
1425
+ a yacht anchored in a turquoise bay
1426
+ a fishing trawler returning to port at dawn
1427
+ a container ship steaming past a coastline
1428
+ a ferry crossing a misty fjord
1429
+ a lighthouse keeper's cottage on a rocky promontory
1430
+ a coastal village with white houses and red roofs
1431
+ a swiss village with timber chalets and cow bells
1432
+ a moroccan medina with narrow alleys and lanterns
1433
+ a kyoto street with wooden tea houses
1434
+ a kyoto bamboo grove with sunlight filtering through
1435
+ a tokyo crossing at rush hour from above
1436
+ a tokyo neon alley with a small ramen shop
1437
+ a hong kong skyline at night from victoria peak
1438
+ a singapore marina at dusk with the cityscape behind
1439
+ a sydney opera house at sunrise from the harbor
1440
+ a sydney harbor bridge with ferries crossing below
1441
+ a melbourne laneway with cafes and street art
1442
+ a perth beach at sunset
1443
+ a queenstown lake at dawn with mountains
1444
+ a maori meeting house at the edge of a forest
1445
+ a buenos aires tango couple in a plaza
1446
+ a brazilian carnival float covered in feathers
1447
+ a colombian coffee farm on a steep hillside
1448
+ a peruvian terraced field high in the andes
1449
+ a mexican market with bright textiles and pottery
1450
+ a guatemalan village square on market day
1451
+ a cuban classic car on a havana street
1452
+ a jamaican beach with reggae playing softly
1453
+ a kenyan safari jeep at a watering hole
1454
+ a tanzanian sunrise behind kilimanjaro
1455
+ a moroccan sahara camp at sunset
1456
+ an egyptian temple wall with hieroglyphs
1457
+ a bedouin tent in a saharan dune field
1458
+ a syrian souk with copper pots stacked
1459
+ a turkish carpet shop with rolls stacked to the ceiling
1460
+ a greek island village with white houses and blue domes
1461
+ a santorini sunset from a cliffside terrace
1462
+ an italian trattoria with checkered tablecloths
1463
+ a venetian gondola on a quiet canal at dawn
1464
+ a tuscan farmhouse with cypress trees lining the drive
1465
+ a provence lavender field at full bloom
1466
+ a scottish highlands sheep crossing a single-track road
1467
+ an irish coastal cliff with a stone tower
1468
+ a welsh stone bridge over a rocky stream
1469
+ a prague rooftop view at sunrise
1470
+ a budapest bath house at dusk
1471
+ a vienna coffeehouse with marble tables
1472
+ a swiss train passing a snow-covered village
1473
+ a norwegian fjord with a small village at the head
1474
+ a icelandic geothermal pool at sunset
1475
+ a finnish sauna by a lake at dusk
1476
+ a danish hygge living room with candles
1477
+ a dutch canal house with a bicycle in front
1478
+ a belgian beer cafe with a dozen taps
1479
+ a polish town square with christmas market stalls
1480
+ a russian orthodox church with onion domes
1481
+ a transsiberian train carriage at night
1482
+ a mongolian ger in a wide grassland
1483
+ a japanese fish auction at dawn
1484
+ a south korean palace courtyard with autumn leaves
1485
+ a thai beach with longtail boats
1486
+ a vietnamese rice terrace at sunrise
1487
+ a cambodian river village with stilt houses
1488
+ a balinese temple with offerings at the gate
1489
+ a malaysian street food stall at night
1490
+ a singaporean hawker center at lunchtime
1491
+ an australian outback road with red dirt
1492
+ an alaskan fishing village with snowy peaks
1493
+ a canadian rockies hike with a glacier in view
1494
+ a new england lighthouse on a stormy day
1495
+ a southern usa porch with rocking chairs and sweet tea
1496
+ a midwest small town water tower with the town name
1497
+ a route 1 viewpoint along the california coast
1498
+ a great smoky mountain trail in autumn
1499
+ a yellowstone hot spring with steam rising
1500
+ a yosemite valley view at golden hour
1501
+ a grand canyon viewpoint with tourists at the rim
1502
+ a monument valley road stretching toward the buttes
1503
+ a death valley salt flat at noon
1504
+ a niagara falls boat in mist
1505
+ a maine lobster shack with red picnic tables
1506
+ a florida beach pier at sunset
1507
+ a new orleans balcony with iron railing and flowers
1508
+ a key west sunset crowd at a pier
1509
+ a chicago lakefront beach with the skyline behind
1510
+ a seattle pike place market with vendors
1511
+ a portland food cart pod
1512
+ a banff lake with mountains reflected
1513
+ a quebec city old town in winter
1514
+ a montreal jazz club exterior with a small marquee
1515
+ a halifax harbor with a tall ship docked
1516
+ a hawaiian luau at sunset with hula dancers
1517
+ a fiji overwater bungalow at dusk
1518
+ a tahitian lagoon with a small outrigger canoe
1519
+ a maldives white-sand beach at high noon
1520
+ a galapagos beach with sea lions on the sand
1521
+ a patagonian glacier face calving into a bay
1522
+
1523
+ # ---------------------------------------------------------------------------
1524
+ # Macro / close-up / textures (~80)
1525
+ # ---------------------------------------------------------------------------
1526
+ close-up of dew on a single blade of grass
1527
+ close-up of a honeybee on a sunflower
1528
+ close-up of an ant carrying a leaf fragment
1529
+ close-up of a snail's eye stalks
1530
+ close-up of a butterfly wing showing scales
1531
+ close-up of frost crystals on a window
1532
+ close-up of a single snowflake on a black wool glove
1533
+ close-up of raindrops on a spider web
1534
+ close-up of the texture of weathered wood
1535
+ close-up of peeling paint on an old door
1536
+ close-up of rust on a metal gate
1537
+ close-up of moss growing on a stone wall
1538
+ close-up of lichen on a tree branch
1539
+ close-up of mushrooms growing from a fallen log
1540
+ close-up of cracked earth in a dry riverbed
1541
+ close-up of pebbles on a beach with seawater receding
1542
+ close-up of beach sand showing tiny shells
1543
+ close-up of fingerprints on a wine glass
1544
+ close-up of a thumbprint in soft clay
1545
+ close-up of stitching on a pair of jeans
1546
+ close-up of the weave of a wool sweater
1547
+ close-up of a single feather on a forest floor
1548
+ close-up of an eye showing the iris
1549
+ close-up of a baby's hand grasping an adult finger
1550
+ close-up of an old hand holding a teacup
1551
+ close-up of fingertips covered in flour
1552
+ close-up of a paint brush dipped in paint
1553
+ close-up of pencil shavings on a desk
1554
+ close-up of a typewriter key mid-strike
1555
+ close-up of a sewing needle and thread
1556
+ close-up of a piano keyboard with one key pressed
1557
+ close-up of guitar strings being plucked
1558
+ close-up of a violin's f-hole
1559
+ close-up of a vinyl record's grooves
1560
+ close-up of a clock's mechanism with gears visible
1561
+ close-up of a watch's movement under a loupe
1562
+ close-up of a magnifying glass over text
1563
+ close-up of a fountain pen nib touching paper
1564
+ close-up of a candle flame with the wick visible
1565
+ close-up of a match igniting against the strike strip
1566
+ close-up of bubbles in a glass of carbonated water
1567
+ close-up of espresso pouring from a portafilter
1568
+ close-up of milk being steamed in a pitcher
1569
+ close-up of latte art being poured
1570
+ close-up of a wine glass with light through the rim
1571
+ close-up of a tea bag tag in a hot cup
1572
+ close-up of bread crust with deep cracks
1573
+ close-up of a freshly cut sourdough showing crumb
1574
+ close-up of melting butter on warm toast
1575
+ close-up of a runny egg yolk being broken
1576
+ close-up of citrus zest being grated
1577
+ close-up of garlic cloves being crushed
1578
+ close-up of fresh basil being torn
1579
+ close-up of a steaming bowl of soup with herbs floating
1580
+ close-up of melted cheese stretching off a slice of pizza
1581
+ close-up of soy sauce being poured into sushi rolls
1582
+ close-up of caramel being drizzled
1583
+ close-up of chocolate being broken from a bar
1584
+ close-up of fresh strawberries with droplets of water
1585
+ close-up of a peeled lychee
1586
+ close-up of a halved fig showing the flesh
1587
+ close-up of a halved pomegranate
1588
+ close-up of a wedge of brie at room temperature
1589
+ close-up of a cluster of barley in a field
1590
+ close-up of a corn cob with kernels visible
1591
+ close-up of a coffee cherry on a branch
1592
+ close-up of a tea leaf being plucked
1593
+ close-up of a cocoa pod cut open
1594
+ close-up of a vanilla pod sliced lengthwise
1595
+ close-up of a cinnamon stick with curling layers
1596
+ close-up of saffron threads on a white plate
1597
+ close-up of black peppercorns in a small mortar
1598
+ close-up of fresh ginger root being grated
1599
+ close-up of sesame seeds being toasted
1600
+ close-up of bubbling kombucha in a glass jar
1601
+ close-up of a sourdough starter with bubbles
1602
+ close-up of yarn winding off a spinning wheel
1603
+ close-up of a knitting needle pulling a stitch
1604
+ close-up of a bicycle chain with a few drops of oil
1605
+ close-up of a brass instrument's valve being pressed
1606
+
1607
+ # ---------------------------------------------------------------------------
1608
+ # Casual snapshots — phone photos, mundane scenes (~100)
1609
+ # ---------------------------------------------------------------------------
1610
+ a slightly blurry phone snapshot of a coffee cup on a desk
1611
+ a slightly out-of-focus photo of a friend laughing across a table
1612
+ a casual snapshot of feet in flip-flops on a beach
1613
+ a casual snapshot of a dog asleep with one paw twitching
1614
+ a phone photo of the contents of a fridge
1615
+ a phone photo of a strangely formed potato
1616
+ a phone photo of a parking ticket on a windshield
1617
+ a phone photo of a long supermarket queue
1618
+ a phone photo of a leaning street sign after a storm
1619
+ a phone photo of a dropped ice cream cone on a sidewalk
1620
+ a phone photo of a half-eaten sandwich at a desk
1621
+ a phone photo of a flat tire on a country road
1622
+ a phone photo of a beautiful sunset taken through a windshield
1623
+ a phone photo of a flower bouquet held up against a brick wall
1624
+ a phone photo of a dog in a stroller
1625
+ a phone photo of a cat that just woke up looking annoyed
1626
+ a phone photo of a backyard chicken caught mid-step
1627
+ a phone photo of a ridiculous traffic jam
1628
+ a phone photo of a small boy showing his missing tooth
1629
+ a phone photo of a child's drawing held up to a fridge
1630
+ a casual photo of a friend asleep on a long flight
1631
+ a casual photo of a sunburned arm on a beach towel
1632
+ a casual photo of a half-built ikea bookshelf with manual
1633
+ a casual photo of a neighbor's cat sitting on a fence
1634
+ a casual photo of muddy boots after a hike
1635
+ a casual photo of an unmade bed with a curled-up dog
1636
+ a phone photo of a sock missing its match in a laundry basket
1637
+ a phone photo of a plate of leftovers in a fridge
1638
+ a phone photo of a bag of potato chips popped open
1639
+ a phone photo of a coffee spilled on a notebook
1640
+ a phone photo of a friend asleep in a hammock
1641
+ a phone photo of the inside of an oven mid-roast
1642
+ a phone photo of a fresh haircut taken in a barbershop mirror
1643
+ a phone photo of a new tattoo wrapped in cling film
1644
+ a phone photo of a bandaged finger
1645
+ a phone photo of a sunburn line at the edge of a sock
1646
+ a phone photo of an old sweatshirt with paint stains
1647
+ a casual photo of a backyard tomato plant with the first ripe fruit
1648
+ a casual photo of a half-eaten birthday cake
1649
+ a casual photo of a child's lego creation
1650
+ a casual photo of a garage with a half-built shelving unit
1651
+ a casual photo of a dog wearing a toddler's hat
1652
+ a casual photo of a cat sitting in an empty cardboard box
1653
+ a phone photo of a plant slowly recovering after being almost dead
1654
+ a phone photo of a herb garden on a kitchen window
1655
+ a phone photo of a freshly baked loaf cooling on a rack
1656
+ a phone photo of a soup pot half-emptied
1657
+ a phone photo of a takeout bag from a favorite restaurant
1658
+ a phone photo of a strange sky just before a storm
1659
+ a phone photo of a moon barely visible through clouds
1660
+ a phone photo of a friend's hand holding up a beer at a bar
1661
+ a phone photo of a karaoke screen mid-song
1662
+ a phone photo of a wrong turn on a winding country road
1663
+ a phone photo of a parking lot full of identical white cars
1664
+ a casual photo of three friends squeezed into a photobooth
1665
+ a casual photo of a wedding ring on a kitchen counter
1666
+ a casual photo of an empty wine bottle and two glasses
1667
+ a casual photo of a half-eaten pizza in the box
1668
+ a casual photo of a fridge covered in magnets and notes
1669
+ a casual photo of a child sleeping in a car seat
1670
+ a casual photo of two siblings arguing at a kitchen table
1671
+ a casual photo of a dog with food crumbs on its nose
1672
+ a casual photo of a cat crashing into a glass door
1673
+ a phone photo of a friend mid-blink looking goofy
1674
+ a phone photo of three people awkwardly mid-cheers
1675
+ a phone photo of a bad parking job in a tight spot
1676
+ a phone photo of a road sign reading something funny
1677
+ a phone photo of a car odometer reading a milestone number
1678
+ a casual photo of a grandmother kissing a baby
1679
+ a casual photo of a grandfather teaching a child to fish
1680
+ a phone photo of a child's first lost tooth in a tissue
1681
+ a phone photo of a runner's bib pinned to a shirt
1682
+ a phone photo of a marathon medal on a kitchen counter
1683
+ a phone photo of a sunburn line on a back
1684
+ a phone photo of a melted candle on a birthday cake
1685
+ a phone photo of a tipped over wine glass with a stain
1686
+ a phone photo of a popped balloon on a kitchen floor
1687
+ a phone photo of a fresh layer of snow on a parked car
1688
+ a phone photo of a leaf stuck to the windshield
1689
+ a phone photo of a deer crossing a suburban street
1690
+ a phone photo of a raccoon caught in the porch light
1691
+ a phone photo of a bird's nest with eggs in a hedge
1692
+ a phone photo of a stranger's cute dog on a walk
1693
+ a phone photo of someone's elaborate latte art
1694
+ a phone photo of a half-empty plate at a fancy restaurant
1695
+ a phone photo of an unidentifiable food at a buffet
1696
+ a phone photo of a bag of groceries spilling in a parking lot
1697
+ a phone photo of an unfortunate haircut taken in a mirror
1698
+ a phone photo of a sock with a hole in the toe
1699
+ a phone photo of a hand-drawn sign at a roadside stand
1700
+ a phone photo of a freshly washed car in a driveway
1701
+ a phone photo of an old photo found in an attic box
1702
+ a phone photo of a half-completed jigsaw puzzle
1703
+ a phone photo of a jar with the lid stuck shut
1704
+ a phone photo of a pile of unopened mail on a doormat
1705
+ a phone photo of a child's first attempt at a haircut
1706
+ a phone photo of a homemade cake that didn't quite work
1707
+ a phone photo of a tent set up in the backyard
1708
+ a phone photo of a campfire with marshmallows on sticks
1709
+ a phone photo of a wave that just splashed the camera
1710
+ a phone photo of a bug on a windshield
1711
+ a phone photo of a flat soda fizz in a glass
scripts/dataset/pull_stage3a_data.sh ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Pull Stage 3A AI-generated images and manifests from the RunPod
3
+ # generation box to the local training laptop.
4
+ #
5
+ # Run from Git Bash on Windows (not PowerShell — rsync line continuations
6
+ # and POSIX paths are messy in PowerShell).
7
+ #
8
+ # Usage:
9
+ # bash scripts/dataset/pull_stage3a_data.sh <phase>
10
+ #
11
+ # Phases:
12
+ # stable Pull SDXL + SD 3.5 Medium. Safe to run anytime; those
13
+ # dirs are stable and not being written to. Run NOW
14
+ # while AuraFlow is still generating to save time.
15
+ # auraflow Pull AuraFlow. Verifies generation has stopped first
16
+ # by polling the file count twice; refuses if still
17
+ # growing.
18
+ # await_auraflow Wait (polling pod every 60s) until the auto_stop
19
+ # watcher logs "STOP signal sent", then automatically
20
+ # run the auraflow phase. Use this to walk away and
21
+ # have the AuraFlow pull happen unattended overnight.
22
+ # verify Compare counts + sizes on pod and laptop. Read-only.
23
+ # help Show this message.
24
+ #
25
+ # All rsync transfers are resumable: if the network drops or you Ctrl-C,
26
+ # re-run the same command and it picks up from the last fully-written
27
+ # file.
28
+ #
29
+ # Override defaults with env vars: POD_HOST, POD_PORT, POD, SSH_KEY,
30
+ # POD_SRC, DEST.
31
+
32
+ set -euo pipefail
33
+
34
+ POD_HOST=${POD_HOST:-195.26.233.56}
35
+ POD_PORT=${POD_PORT:-57837}
36
+ SSH_KEY=${SSH_KEY:-/c/Users/Dulip/.ssh/id_ed25519}
37
+ POD=${POD:-root@$POD_HOST}
38
+ POD_SRC=${POD_SRC:-/workspace/deepfakescanner/data/raw/ai_generated}
39
+ DEST=${DEST:-/c/Data/Personal/work/Projects/deepfakescanner/data/raw/ai_generated}
40
+
41
+ ssh_cmd="ssh -o ConnectTimeout=10 -p $POD_PORT -i $SSH_KEY"
42
+ phase=${1:-help}
43
+
44
+ pull_dir() {
45
+ local name=$1
46
+ echo
47
+ echo "=== Pulling $name ==="
48
+ rsync -avh --partial --info=progress2 -e "$ssh_cmd" \
49
+ "$POD:$POD_SRC/$name/" "$DEST/$name/"
50
+ }
51
+
52
+ pull_files() {
53
+ echo
54
+ echo "=== Pulling manifests: $* ==="
55
+ for f in "$@"; do
56
+ rsync -avh -e "$ssh_cmd" "$POD:$POD_SRC/$f" "$DEST/"
57
+ done
58
+ }
59
+
60
+ await_watcher_fired() {
61
+ # Polls the pod's auto_stop.log every 60s for the "STOP signal sent"
62
+ # line written by auto_stop.sh when the AuraFlow output dir reaches
63
+ # 10,000 files. Prints a periodic heartbeat with the current AuraFlow
64
+ # count so the operator has evidence the watcher loop is alive.
65
+ #
66
+ # If the SSH check fails transiently (network blip, pod restart, etc.)
67
+ # the if-block treats it as "not yet" and the loop simply retries.
68
+ echo "=== Watching auto_stop.log for STOP signal (polling pod every 60s) ==="
69
+ local n=0 cur
70
+ while true; do
71
+ if $ssh_cmd "$POD" "grep -q 'STOP signal sent' /workspace/deepfakescanner/auto_stop.log 2>/dev/null"; then
72
+ echo
73
+ echo "[$(date +%H:%M:%S)] Watcher fired."
74
+ return 0
75
+ fi
76
+ n=$((n + 1))
77
+ if [ $((n % 5)) -eq 0 ]; then
78
+ cur=$($ssh_cmd "$POD" "ls $POD_SRC/auraflow-v0.3 2>/dev/null | wc -l" 2>/dev/null || echo '?')
79
+ echo "[$(date +%H:%M:%S)] still waiting — AuraFlow count: $cur / 10000"
80
+ fi
81
+ sleep 60
82
+ done
83
+ }
84
+
85
+ verify_auraflow_stopped() {
86
+ echo "=== Confirming AuraFlow generation has stopped ==="
87
+ local c1 c2
88
+ c1=$($ssh_cmd "$POD" "ls $POD_SRC/auraflow-v0.3 2>/dev/null | wc -l")
89
+ sleep 10
90
+ c2=$($ssh_cmd "$POD" "ls $POD_SRC/auraflow-v0.3 2>/dev/null | wc -l")
91
+ if [ "$c1" != "$c2" ]; then
92
+ echo "ERROR: AuraFlow still generating ($c1 -> $c2 in 10s)."
93
+ echo "Wait for the auto_stop watcher to fire — check the log:"
94
+ echo " $ssh_cmd $POD 'tail /workspace/deepfakescanner/auto_stop.log'"
95
+ echo "Then re-run: $0 auraflow"
96
+ exit 2
97
+ fi
98
+ echo "Stable at $c1 images."
99
+ }
100
+
101
+ verify_counts() {
102
+ echo
103
+ echo "=== Verification: pod vs laptop ==="
104
+ echo "--- POD ---"
105
+ $ssh_cmd "$POD" \
106
+ "cd $POD_SRC && for d in */; do printf '%-22s %6d files %s\n' \"\$d\" \"\$(ls \"\$d\" | wc -l)\" \"\$(du -sh \"\$d\" | cut -f1)\"; done"
107
+ echo "--- LAPTOP ---"
108
+ for d in "$DEST"/*/; do
109
+ [ -d "$d" ] || continue
110
+ printf '%-22s %6d files %s\n' "$(basename "$d")/" "$(ls "$d" | wc -l)" "$(du -sh "$d" | cut -f1)"
111
+ done
112
+ }
113
+
114
+ mkdir -p "$DEST"
115
+
116
+ case "$phase" in
117
+ stable)
118
+ pull_dir sdxl
119
+ pull_dir sd35-medium
120
+ pull_files sdxl_manifest.csv sd35_medium_manifest.csv
121
+ verify_counts
122
+ ;;
123
+ auraflow)
124
+ verify_auraflow_stopped
125
+ pull_dir auraflow-v0.3
126
+ pull_files auraflow_manifest.csv
127
+ verify_counts
128
+ ;;
129
+ await_auraflow)
130
+ await_watcher_fired
131
+ verify_auraflow_stopped
132
+ pull_dir auraflow-v0.3
133
+ pull_files auraflow_manifest.csv
134
+ verify_counts
135
+ ;;
136
+ verify)
137
+ verify_counts
138
+ ;;
139
+ help|--help|-h)
140
+ sed -n '2,30p' "$0"
141
+ ;;
142
+ *)
143
+ echo "Unknown phase: $phase" >&2
144
+ sed -n '2,30p' "$0" >&2
145
+ exit 1
146
+ ;;
147
+ esac
scripts/dataset/rebuild_auraflow_manifest.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reconstruct the full AuraFlow manifest from the images on disk.
3
+
4
+ Why this exists
5
+ ---------------
6
+ During Stage 3A generation, `generate_auraflow_synthetic.py` flushed manifest
7
+ rows to CSV in batches. A SIGINT stopped the run before the next flush, so
8
+ `data/raw/ai_generated/auraflow_manifest.csv` was left as a 982-byte stub
9
+ holding only the first 2 of 10,000 rows. This was noticed at the time and
10
+ judged "not blocking training" on the basis that per-image generator labels
11
+ come from the directory structure.
12
+
13
+ That judgement was wrong. `split.py` builds splits from the *manifests*, not
14
+ from the directory tree, so 9,998 AuraFlow images were silently excluded from
15
+ every split — and therefore from training. See
16
+ `docs/stage3a-implementation.md` (2026-08-02 Handoff Log entry).
17
+
18
+ How the reconstruction works (and why it is exact, not a guess)
19
+ --------------------------------------------------------------
20
+ Output filenames are content-addressed from the (prompt, seed) pair by
21
+ `generation_utils.py`, and every generator in Stage 3A consumed the same
22
+ `prompts.txt` with the same deterministic seed derivation. Consequence: the
23
+ set of AuraFlow filenames is an exact subset of the SDXL and SD 3.5 filename
24
+ sets, and a given filename denotes the same (prompt, seed) in all three.
25
+
26
+ So for each AuraFlow image on disk we recover prompt/seed/width/height by
27
+ joining on basename against a sibling manifest, and take the AuraFlow-specific
28
+ columns (source, license, model_id, generation params) from the surviving stub
29
+ rows. `sha256` is computed from the actual file, never copied.
30
+
31
+ **Self-check:** the two genuine stub rows are re-derived by the same code path
32
+ and compared field-by-field against the originals. If the reconstruction
33
+ method were wrong, this check fails and the script refuses to write.
34
+
35
+ Usage
36
+ -----
37
+ python scripts/dataset/rebuild_auraflow_manifest.py # dry run
38
+ python scripts/dataset/rebuild_auraflow_manifest.py --write
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import csv
44
+ import hashlib
45
+ import sys
46
+ from pathlib import Path
47
+
48
+ MANIFEST_FIELDS = [
49
+ "path",
50
+ "class",
51
+ "source",
52
+ "license",
53
+ "license_url",
54
+ "sha256",
55
+ "generator",
56
+ "model_family",
57
+ "model_id",
58
+ "prompt",
59
+ "seed",
60
+ "width",
61
+ "height",
62
+ "generation_params_json",
63
+ ]
64
+
65
+ # Columns that are identical for every row of a single generator's run.
66
+ _CONSTANT_FIELDS = [
67
+ "class",
68
+ "source",
69
+ "license",
70
+ "license_url",
71
+ "generator",
72
+ "model_family",
73
+ "model_id",
74
+ "generation_params_json",
75
+ ]
76
+ # Columns recovered from a sibling generator's manifest via the (prompt, seed)
77
+ # identity encoded in the content-addressed filename.
78
+ _JOINED_FIELDS = ["prompt", "seed", "width", "height"]
79
+
80
+
81
+ def _sha256(path: Path) -> str:
82
+ h = hashlib.sha256()
83
+ with path.open("rb") as fh:
84
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
85
+ h.update(chunk)
86
+ return h.hexdigest()
87
+
88
+
89
+ def _read_rows(path: Path) -> list[dict[str, str]]:
90
+ with path.open(newline="", encoding="utf-8") as fh:
91
+ return list(csv.DictReader(fh))
92
+
93
+
94
+ def _build_row(
95
+ image: Path,
96
+ repo_root: Path,
97
+ constants: dict[str, str],
98
+ joined: dict[str, str],
99
+ ) -> dict[str, str]:
100
+ rel = image.relative_to(repo_root / "data").as_posix()
101
+ row = {"path": rel, "sha256": _sha256(image)}
102
+ row.update(constants)
103
+ row.update(joined)
104
+ return {f: row.get(f, "") for f in MANIFEST_FIELDS}
105
+
106
+
107
+ def main() -> int:
108
+ parser = argparse.ArgumentParser(description=__doc__)
109
+ parser.add_argument("--write", action="store_true", help="write the manifest")
110
+ parser.add_argument(
111
+ "--repo-root", type=Path, default=Path(__file__).resolve().parents[2]
112
+ )
113
+ parser.add_argument("--generator-dir", default="auraflow-v0.3")
114
+ parser.add_argument("--stub", default="auraflow_manifest.csv")
115
+ parser.add_argument(
116
+ "--reference",
117
+ default="sdxl_manifest.csv",
118
+ help="sibling manifest to recover prompt/seed from",
119
+ )
120
+ args = parser.parse_args()
121
+
122
+ ai_dir = args.repo_root / "data" / "raw" / "ai_generated"
123
+ img_dir = ai_dir / args.generator_dir
124
+ stub_path = ai_dir / args.stub
125
+ ref_path = ai_dir / args.reference
126
+
127
+ for p in (img_dir, stub_path, ref_path):
128
+ if not p.exists():
129
+ print(f"ERROR: missing {p}", file=sys.stderr)
130
+ return 1
131
+
132
+ stub_rows = _read_rows(stub_path)
133
+ if not stub_rows:
134
+ print(f"ERROR: {stub_path} has no rows to learn constants from", file=sys.stderr)
135
+ return 1
136
+
137
+ # Constant columns must agree across every surviving stub row, else the
138
+ # "one generator run = one set of constants" assumption is false.
139
+ constants: dict[str, str] = {}
140
+ for field in _CONSTANT_FIELDS:
141
+ values = {r.get(field, "") for r in stub_rows}
142
+ if len(values) != 1:
143
+ print(
144
+ f"ERROR: stub rows disagree on constant field {field!r}: {values}",
145
+ file=sys.stderr,
146
+ )
147
+ return 1
148
+ constants[field] = values.pop()
149
+
150
+ ref_by_name = {
151
+ Path(r["path"]).name: {f: r.get(f, "") for f in _JOINED_FIELDS}
152
+ for r in _read_rows(ref_path)
153
+ }
154
+
155
+ images = sorted(img_dir.glob("*.png"))
156
+ if not images:
157
+ print(f"ERROR: no PNGs under {img_dir}", file=sys.stderr)
158
+ return 1
159
+
160
+ missing = [p.name for p in images if p.name not in ref_by_name]
161
+ if missing:
162
+ print(
163
+ f"ERROR: {len(missing)} images have no match in {args.reference} "
164
+ f"(first: {missing[:3]}). Reconstruction would be a guess — refusing.",
165
+ file=sys.stderr,
166
+ )
167
+ return 1
168
+
169
+ # ---- Self-check against the genuine stub rows -----------------------
170
+ print(f"Self-check: re-deriving {len(stub_rows)} known-good stub row(s)...")
171
+ for original in stub_rows:
172
+ name = Path(original["path"]).name
173
+ rebuilt = _build_row(
174
+ img_dir / name, args.repo_root, constants, ref_by_name[name]
175
+ )
176
+ for field in MANIFEST_FIELDS:
177
+ if rebuilt[field] != original.get(field, ""):
178
+ print(
179
+ f"ERROR: self-check FAILED on {name} field {field!r}\n"
180
+ f" original : {original.get(field, '')!r}\n"
181
+ f" rebuilt : {rebuilt[field]!r}\n"
182
+ "The reconstruction method does not reproduce known-good "
183
+ "rows. Refusing to write.",
184
+ file=sys.stderr,
185
+ )
186
+ return 1
187
+ print(f" OK {name}")
188
+
189
+ print(f"\nRebuilding {len(images)} rows (hashing {len(images)} files)...")
190
+ rows = []
191
+ for i, image in enumerate(images, 1):
192
+ rows.append(_build_row(image, args.repo_root, constants, ref_by_name[image.name]))
193
+ if i % 1000 == 0:
194
+ print(f" {i}/{len(images)}")
195
+
196
+ shas = {r["sha256"] for r in rows}
197
+ if len(shas) != len(rows):
198
+ print(
199
+ f"WARNING: {len(rows) - len(shas)} duplicate sha256 values — "
200
+ "build_manifest.py will drop these as duplicates.",
201
+ file=sys.stderr,
202
+ )
203
+
204
+ print(f"\n rows : {len(rows)}")
205
+ print(f" unique sha : {len(shas)}")
206
+ print(f" stub had : {len(stub_rows)}")
207
+
208
+ if not args.write:
209
+ print("\nDry run - pass --write to overwrite the stub manifest.")
210
+ return 0
211
+
212
+ backup = stub_path.with_suffix(".csv.stub-backup")
213
+ if not backup.exists():
214
+ backup.write_bytes(stub_path.read_bytes())
215
+ print(f"\n backed up original stub -> {backup}")
216
+
217
+ with stub_path.open("w", newline="", encoding="utf-8") as fh:
218
+ writer = csv.DictWriter(fh, fieldnames=MANIFEST_FIELDS)
219
+ writer.writeheader()
220
+ writer.writerows(rows)
221
+ print(f" wrote {stub_path} ({len(rows)} rows)")
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())
scripts/dataset/run_stage3a_smoke.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run a tiny local Stage 3A smoke pipeline.
3
+
4
+ This creates fixture images and manifest fragments, then runs the real
5
+ manifest builder, generator-aware splitter, augmentation script, CLIP-head
6
+ trainer, and checkpoint evaluator. Embeddings are deterministic mocked
7
+ 512-dimensional vectors so this smoke test proves script compatibility without
8
+ downloading or running CLIP.
9
+
10
+ Usage
11
+ -----
12
+ python scripts/dataset/run_stage3a_smoke.py \
13
+ --work-dir /tmp/deepfakescanner-stage3a-smoke
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import csv
19
+ import hashlib
20
+ import json
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+ from PIL import Image
29
+
30
+
31
+ CLASS_TO_LABEL = {
32
+ "authentic": 0,
33
+ "ai_generated": 1,
34
+ }
35
+
36
+ FIELDNAMES = [
37
+ "path",
38
+ "class",
39
+ "source",
40
+ "license",
41
+ "license_url",
42
+ "sha256",
43
+ "generator",
44
+ "model_family",
45
+ "model_id",
46
+ "prompt",
47
+ "seed",
48
+ "width",
49
+ "height",
50
+ "generation_params_json",
51
+ ]
52
+
53
+
54
+ def _repo_root() -> Path:
55
+ return Path(__file__).resolve().parents[2]
56
+
57
+
58
+ def _reset_work_dir(work_dir: Path) -> None:
59
+ if not work_dir.exists():
60
+ return
61
+ marker = work_dir.name.lower()
62
+ if "smoke" not in marker:
63
+ raise ValueError(
64
+ f"Refusing to clear existing non-smoke work dir: {work_dir}. "
65
+ "Choose a scratch path with 'smoke' in the final directory name "
66
+ "or pass --keep-existing."
67
+ )
68
+ shutil.rmtree(work_dir)
69
+
70
+
71
+ def _sha256_file(path: Path) -> str:
72
+ h = hashlib.sha256()
73
+ with path.open("rb") as fh:
74
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
75
+ h.update(chunk)
76
+ return h.hexdigest()
77
+
78
+
79
+ def _write_image(path: Path, color: tuple[int, int, int]) -> None:
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ image = Image.new("RGB", (32, 24), color=color)
82
+ image.save(path, format="PNG")
83
+
84
+
85
+ def _write_csv(path: Path, rows: list[dict[str, str]]) -> None:
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ with path.open("w", newline="") as fh:
88
+ writer = csv.DictWriter(fh, fieldnames=FIELDNAMES)
89
+ writer.writeheader()
90
+ writer.writerows(rows)
91
+
92
+
93
+ def _fixture_row(
94
+ data_root: Path,
95
+ relative_path: str,
96
+ *,
97
+ cls: str,
98
+ source: str,
99
+ license_name: str,
100
+ license_url: str,
101
+ color: tuple[int, int, int],
102
+ generator: str = "",
103
+ model_family: str = "",
104
+ model_id: str = "",
105
+ prompt: str = "",
106
+ seed: int | None = None,
107
+ ) -> dict[str, str]:
108
+ image_path = data_root / relative_path
109
+ _write_image(image_path, color)
110
+ return {
111
+ "path": relative_path,
112
+ "class": cls,
113
+ "source": source,
114
+ "license": license_name,
115
+ "license_url": license_url,
116
+ "sha256": _sha256_file(image_path),
117
+ "generator": generator,
118
+ "model_family": model_family,
119
+ "model_id": model_id,
120
+ "prompt": prompt,
121
+ "seed": "" if seed is None else str(seed),
122
+ "width": "32",
123
+ "height": "24",
124
+ "generation_params_json": (
125
+ ""
126
+ if seed is None
127
+ else json.dumps({"smoke": True, "seed": seed}, sort_keys=True)
128
+ ),
129
+ }
130
+
131
+
132
+ def _create_fixtures(data_root: Path) -> None:
133
+ raw = data_root / "raw"
134
+ real_rows: list[dict[str, str]] = []
135
+ flux_rows: list[dict[str, str]] = []
136
+ sdxl_rows: list[dict[str, str]] = []
137
+
138
+ for i in range(6):
139
+ real_rows.append(
140
+ _fixture_row(
141
+ data_root,
142
+ f"raw/real/real_{i}.png",
143
+ cls="authentic",
144
+ source="open_images_v7",
145
+ license_name="CC-BY-2.0",
146
+ license_url="https://creativecommons.org/licenses/by/2.0/",
147
+ color=(30 + i * 10, 80 + i * 5, 120 + i * 3),
148
+ )
149
+ )
150
+ flux_rows.append(
151
+ _fixture_row(
152
+ data_root,
153
+ f"raw/ai_generated/flux/flux_{i}.png",
154
+ cls="ai_generated",
155
+ source="flux.1-schnell",
156
+ license_name="Apache-2.0",
157
+ license_url="https://www.apache.org/licenses/LICENSE-2.0",
158
+ color=(120 + i * 8, 40 + i * 6, 70 + i * 9),
159
+ prompt=f"stage3a smoke flux prompt {i}",
160
+ seed=1000 + i,
161
+ )
162
+ )
163
+
164
+ for i in range(4):
165
+ sdxl_rows.append(
166
+ _fixture_row(
167
+ data_root,
168
+ f"raw/ai_generated/sdxl/sdxl_{i}.png",
169
+ cls="ai_generated",
170
+ source="sdxl",
171
+ license_name="CreativeML Open RAIL++-M",
172
+ license_url=(
173
+ "https://huggingface.co/stabilityai/"
174
+ "stable-diffusion-xl-base-1.0/blob/main/LICENSE.md"
175
+ ),
176
+ color=(60 + i * 13, 110 + i * 4, 30 + i * 15),
177
+ generator="sdxl",
178
+ model_family="diffusion_unet",
179
+ model_id="stabilityai/stable-diffusion-xl-base-1.0",
180
+ prompt=f"stage3a smoke sdxl prompt {i}",
181
+ seed=2000 + i,
182
+ )
183
+ )
184
+
185
+ _write_csv(raw / "real_manifest.csv", real_rows)
186
+ _write_csv(raw / "ai_generated" / "flux_manifest.csv", flux_rows)
187
+ _write_csv(raw / "ai_generated" / "sdxl_manifest.csv", sdxl_rows)
188
+
189
+
190
+ def _run(repo_root: Path, command: list[str]) -> subprocess.CompletedProcess[str]:
191
+ printable = " ".join(command)
192
+ print(f"\n$ {printable}")
193
+ result = subprocess.run(
194
+ command,
195
+ cwd=repo_root,
196
+ check=True,
197
+ capture_output=True,
198
+ text=True,
199
+ )
200
+ if result.stdout.strip():
201
+ print(result.stdout.rstrip())
202
+ if result.stderr.strip():
203
+ print(result.stderr.rstrip(), file=sys.stderr)
204
+ return result
205
+
206
+
207
+ def _read_csv(path: Path) -> list[dict[str, str]]:
208
+ with path.open() as fh:
209
+ return list(csv.DictReader(fh))
210
+
211
+
212
+ def _mock_embedding(row: dict[str, str], index: int) -> np.ndarray:
213
+ label = CLASS_TO_LABEL[row["class"]]
214
+ vector = np.zeros(512, dtype=np.float32)
215
+ vector[label] = 1.0
216
+ vector[10 + (index % 32)] = 0.05
217
+ if row.get("augmentation"):
218
+ vector[80 + (index % 32)] = 0.02
219
+ norm = np.linalg.norm(vector)
220
+ return vector / norm
221
+
222
+
223
+ def _write_mock_embeddings(csv_path: Path, out_path: Path) -> None:
224
+ rows = _read_csv(csv_path)
225
+ embeddings = np.stack(
226
+ [_mock_embedding(row, index) for index, row in enumerate(rows)],
227
+ axis=0,
228
+ ).astype(np.float32)
229
+ labels = np.asarray([CLASS_TO_LABEL[row["class"]] for row in rows], dtype=np.int8)
230
+
231
+ out_path.parent.mkdir(parents=True, exist_ok=True)
232
+ np.savez_compressed(
233
+ out_path,
234
+ embeddings=embeddings,
235
+ labels=labels,
236
+ paths=np.asarray([row["path"] for row in rows]),
237
+ sources=np.asarray([row.get("source", "") for row in rows]),
238
+ generators=np.asarray([row.get("generator", "") for row in rows]),
239
+ model_families=np.asarray([row.get("model_family", "") for row in rows]),
240
+ augmentations=np.asarray([row.get("augmentation", "") for row in rows]),
241
+ original_paths=np.asarray([row.get("original_path", "") for row in rows]),
242
+ )
243
+
244
+
245
+ def _write_eval_mock_embeddings(splits_dir: Path, emb_dir: Path) -> None:
246
+ split_files = {
247
+ "val": splits_dir / "val.csv",
248
+ "test": splits_dir / "test.csv",
249
+ "heldout": splits_dir / "heldout.csv",
250
+ "test_augmented": splits_dir / "test_augmented.csv",
251
+ }
252
+ for split, csv_path in split_files.items():
253
+ _write_mock_embeddings(csv_path, emb_dir / f"{split}.npz")
254
+ print(f" wrote mocked embeddings: {emb_dir / f'{split}.npz'}")
255
+
256
+
257
+ def _load_train_head(repo_root: Path) -> Any:
258
+ import importlib.util
259
+
260
+ module_path = repo_root / "scripts" / "train_head.py"
261
+ spec = importlib.util.spec_from_file_location("train_head", module_path)
262
+ if spec is None or spec.loader is None:
263
+ raise RuntimeError(f"Could not load {module_path}")
264
+ module = importlib.util.module_from_spec(spec)
265
+ sys.modules[spec.name] = module
266
+ spec.loader.exec_module(module)
267
+ return module
268
+
269
+
270
+ def _write_constant_baseline(repo_root: Path, out_path: Path) -> None:
271
+ import torch
272
+
273
+ train_head = _load_train_head(repo_root)
274
+ head = train_head._build_head()
275
+ for param in head.parameters():
276
+ param.data.zero_()
277
+ head[-1].bias.data[0] = 1.0
278
+ out_path.parent.mkdir(parents=True, exist_ok=True)
279
+ torch.save(head.state_dict(), out_path)
280
+ print(f" wrote constant-authentic baseline: {out_path}")
281
+
282
+
283
+ def main() -> None:
284
+ parser = argparse.ArgumentParser(
285
+ description=__doc__,
286
+ formatter_class=argparse.RawDescriptionHelpFormatter,
287
+ )
288
+ parser.add_argument(
289
+ "--work-dir",
290
+ type=Path,
291
+ required=True,
292
+ help="Scratch directory for generated fixtures and reports.",
293
+ )
294
+ parser.add_argument(
295
+ "--keep-existing",
296
+ action="store_true",
297
+ help="Do not clear --work-dir before running.",
298
+ )
299
+ args = parser.parse_args()
300
+
301
+ repo_root = _repo_root()
302
+ work_dir = args.work_dir.resolve()
303
+ data_root = work_dir / "data"
304
+ splits_dir = data_root / "splits"
305
+ emb_dir = data_root / "embeddings"
306
+ checkpoints_dir = data_root / "checkpoints"
307
+ reports_dir = data_root / "reports"
308
+
309
+ if not args.keep_existing:
310
+ _reset_work_dir(work_dir)
311
+ work_dir.mkdir(parents=True, exist_ok=True)
312
+
313
+ print(f"Stage 3A smoke work dir: {work_dir}")
314
+ _create_fixtures(data_root)
315
+
316
+ _run(
317
+ repo_root,
318
+ [
319
+ sys.executable,
320
+ "scripts/dataset/build_manifest.py",
321
+ "--input-dir",
322
+ str(data_root / "raw"),
323
+ "--out",
324
+ str(data_root / "manifest.csv"),
325
+ ],
326
+ )
327
+ _run(
328
+ repo_root,
329
+ [
330
+ sys.executable,
331
+ "scripts/dataset/split.py",
332
+ "--manifest",
333
+ str(data_root / "manifest.csv"),
334
+ "--out-dir",
335
+ str(splits_dir),
336
+ "--val",
337
+ "0.25",
338
+ "--test",
339
+ "0.25",
340
+ "--seed",
341
+ "17",
342
+ "--stratify-by",
343
+ "class-generator",
344
+ "--holdout-generator",
345
+ "sdxl",
346
+ ],
347
+ )
348
+ _run(
349
+ repo_root,
350
+ [
351
+ sys.executable,
352
+ "scripts/dataset/augment_images.py",
353
+ "--manifest",
354
+ str(splits_dir / "train.csv"),
355
+ "--data-root",
356
+ str(data_root),
357
+ "--out-dir",
358
+ str(data_root / "augmented" / "train"),
359
+ "--out-manifest",
360
+ str(splits_dir / "train_augmented.csv"),
361
+ "--copies",
362
+ "1",
363
+ "--seed",
364
+ "17",
365
+ ],
366
+ )
367
+ _run(
368
+ repo_root,
369
+ [
370
+ sys.executable,
371
+ "scripts/dataset/augment_images.py",
372
+ "--manifest",
373
+ str(splits_dir / "test.csv"),
374
+ "--data-root",
375
+ str(data_root),
376
+ "--out-dir",
377
+ str(data_root / "augmented" / "test"),
378
+ "--out-manifest",
379
+ str(splits_dir / "test_augmented.csv"),
380
+ "--copies",
381
+ "1",
382
+ "--seed",
383
+ "23",
384
+ ],
385
+ )
386
+
387
+ print("\nWriting deterministic mocked embeddings...")
388
+ train_rows = _read_csv(splits_dir / "train.csv") + _read_csv(
389
+ splits_dir / "train_augmented.csv"
390
+ )
391
+ combined_train = splits_dir / "train_with_augmented.csv"
392
+ with combined_train.open("w", newline="") as fh:
393
+ writer = csv.DictWriter(
394
+ fh,
395
+ fieldnames=FIELDNAMES
396
+ + [
397
+ "original_path",
398
+ "augmentation",
399
+ "augmentation_seed",
400
+ "augmentation_params_json",
401
+ ],
402
+ )
403
+ writer.writeheader()
404
+ writer.writerows(train_rows)
405
+ _write_eval_mock_embeddings(splits_dir, emb_dir)
406
+ _write_mock_embeddings(combined_train, emb_dir / "train.npz")
407
+ print(f" wrote mocked embeddings: {emb_dir / 'train.npz'}")
408
+
409
+ _run(
410
+ repo_root,
411
+ [
412
+ sys.executable,
413
+ "scripts/train_head.py",
414
+ "--emb-dir",
415
+ str(emb_dir),
416
+ "--out",
417
+ str(checkpoints_dir / "head_smoke.pt"),
418
+ "--epochs",
419
+ "20",
420
+ "--batch-size",
421
+ "4",
422
+ "--patience",
423
+ "5",
424
+ "--lr",
425
+ "0.05",
426
+ "--seed",
427
+ "17",
428
+ "--eval-split",
429
+ "heldout",
430
+ "--eval-split",
431
+ "test_augmented",
432
+ "--report-out",
433
+ str(reports_dir / "head_smoke_train.json"),
434
+ ],
435
+ )
436
+
437
+ baseline_path = checkpoints_dir / "head_constant_authentic.pt"
438
+ _write_constant_baseline(repo_root, baseline_path)
439
+
440
+ _run(
441
+ repo_root,
442
+ [
443
+ sys.executable,
444
+ "scripts/evaluate_head.py",
445
+ "--emb-dir",
446
+ str(emb_dir),
447
+ "--candidate",
448
+ str(checkpoints_dir / "head_smoke.pt"),
449
+ "--baseline",
450
+ str(baseline_path),
451
+ "--split",
452
+ "test",
453
+ "--split",
454
+ "heldout",
455
+ "--split",
456
+ "test_augmented",
457
+ "--report-out",
458
+ str(reports_dir / "head_smoke_eval.json"),
459
+ "--uncertainty-threshold",
460
+ "0.6",
461
+ ],
462
+ )
463
+
464
+ print("\nStage 3A smoke pipeline passed.")
465
+ print(f"Manifest: {data_root / 'manifest.csv'}")
466
+ print(f"Splits: {splits_dir}")
467
+ print(f"Train report: {reports_dir / 'head_smoke_train.json'}")
468
+ print(f"Evaluation report: {reports_dir / 'head_smoke_eval.json'}")
469
+
470
+
471
+ if __name__ == "__main__":
472
+ main()
scripts/dataset/simulate_shared_copy.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Produce "shared copy" variants of eval images: what survives being passed around.
3
+
4
+ Why
5
+ ---
6
+ Every P0 measurement so far is on *untouched downloads*, where a C2PA manifest
7
+ decides the verdict and detection is 100%. That is not how images reach a
8
+ visitor. A messaging app, a social upload or a re-save downscales the image,
9
+ recompresses it as JPEG, and discards the metadata — including the signature.
10
+ At that point the model is alone.
11
+
12
+ This script isolates exactly that: same pixels, minus the manifest, plus the
13
+ recompression an ordinary sharing path applies. Comparing the two conditions
14
+ gives the cost of losing provenance, measured rather than assumed.
15
+
16
+ What this is NOT
17
+ ----------------
18
+ **Not the screenshot arm.** A screenshot adds display rescaling, a second
19
+ round of resampling, possible UI chrome and colour-profile conversion. This is
20
+ the milder, cleaner case: metadata stripped and one recompression. Treat the
21
+ numbers as an OPTIMISTIC bound on the sharing path — real screenshots should be
22
+ no better and are likely worse. Owner-collected screenshots remain the thing
23
+ that settles it; see the P0 spec.
24
+
25
+ Output is written to `ai/<tool>/reencoded_sim/`, deliberately suffixed `_sim`
26
+ so no reader mistakes a generated condition for collected evidence.
27
+
28
+ Usage
29
+ -----
30
+ python scripts/dataset/simulate_shared_copy.py --root data/closed_eval
31
+ python scripts/dataset/simulate_shared_copy.py --root data/closed_eval --force
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ from pathlib import Path
37
+
38
+ from PIL import Image
39
+
40
+ # Chosen to mimic a mainstream messaging/social pipeline rather than a worst
41
+ # case: most resize the long edge to roughly this and recompress at high-ish
42
+ # quality. A harsher setting would flatter the argument for P1 by making the
43
+ # baseline look worse than reality.
44
+ LONG_EDGE = 1080
45
+ JPEG_QUALITY = 85
46
+
47
+
48
+ def shared_copy(src: Path, dst: Path) -> None:
49
+ """Downscale, recompress as JPEG, and drop every metadata block."""
50
+ with Image.open(src) as im:
51
+ im = im.convert("RGB")
52
+ w, h = im.size
53
+ scale = LONG_EDGE / max(w, h)
54
+ if scale < 1.0:
55
+ im = im.resize((round(w * scale), round(h * scale)), Image.LANCZOS)
56
+ # A fresh image object carries no info dict, so the C2PA chunk, EXIF and
57
+ # ICC profile are all left behind — the same way a share pipeline does it.
58
+ out = Image.new("RGB", im.size)
59
+ out.putdata(list(im.getdata()))
60
+ out.save(dst, "JPEG", quality=JPEG_QUALITY, optimize=True)
61
+
62
+
63
+ def main() -> None:
64
+ ap = argparse.ArgumentParser(description=__doc__,
65
+ formatter_class=argparse.RawDescriptionHelpFormatter)
66
+ ap.add_argument("--root", type=Path, default=Path("data/closed_eval"))
67
+ ap.add_argument("--force", action="store_true",
68
+ help="Regenerate even if the output folder already has files.")
69
+ args = ap.parse_args()
70
+
71
+ ai_root = args.root / "ai"
72
+ if not ai_root.is_dir():
73
+ raise SystemExit(f"{ai_root} does not exist.")
74
+
75
+ markers = (b"caBX", b"jumbf", b"c2pa")
76
+ total = 0
77
+ for tool_dir in sorted(p for p in ai_root.iterdir() if p.is_dir()):
78
+ src_dir = tool_dir / "original"
79
+ if not src_dir.is_dir():
80
+ continue
81
+ dst_dir = tool_dir / "reencoded_sim"
82
+ dst_dir.mkdir(parents=True, exist_ok=True)
83
+ existing = list(dst_dir.glob("*.jpg"))
84
+ if existing and not args.force:
85
+ print(f"{tool_dir.name}: {len(existing)} already present, skipping "
86
+ f"(use --force to regenerate)")
87
+ continue
88
+
89
+ made = leaked = 0
90
+ for src in sorted(src_dir.glob("*.png"), key=lambda p: p.stem):
91
+ dst = dst_dir / f"{src.stem}.jpg"
92
+ shared_copy(src, dst)
93
+ # Verify the point of the exercise actually happened. If a manifest
94
+ # survived, the condition is not what it claims to be.
95
+ if any(m in dst.read_bytes() for m in markers):
96
+ leaked += 1
97
+ made += 1
98
+ total += made
99
+ status = "all manifests stripped" if not leaked else f"{leaked} STILL CARRY C2PA"
100
+ print(f"{tool_dir.name}: wrote {made} shared copies -> {dst_dir} ({status})")
101
+
102
+ print(f"\n{total} images written. Re-run the eval to compare conditions:")
103
+ print(" python scripts/eval_closed_generators.py --dir data/closed_eval")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
scripts/dataset/split.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Produce stratified train/val/test splits from the master manifest.
3
+
4
+ Stratification preserves the per-class ratio across splits — important when
5
+ the classes are imbalanced (and they always are, eventually).
6
+
7
+ Determinism: a fixed seed plus sha256 sorting means re-running on the same
8
+ manifest produces identical splits. This matters for reproducibility.
9
+
10
+ Usage
11
+ -----
12
+ python scripts/dataset/split.py \
13
+ --manifest data/manifest.csv \
14
+ --out-dir data \
15
+ --val 0.1 --test 0.1
16
+
17
+ Generator-aware Stage 3A split:
18
+
19
+ python scripts/dataset/split.py \
20
+ --manifest data/manifest.csv \
21
+ --out-dir data \
22
+ --stratify-by class-generator
23
+
24
+ Held-out generator evaluation split:
25
+
26
+ python scripts/dataset/split.py \
27
+ --manifest data/manifest.csv \
28
+ --out-dir data \
29
+ --stratify-by class-generator \
30
+ --holdout-generator sdxl
31
+
32
+ Content-disjoint held-out split (recommended for generalisation claims):
33
+
34
+ python scripts/dataset/split.py \
35
+ --manifest data/manifest.csv \
36
+ --out-dir data/splits_v2 \
37
+ --stratify-by class-generator \
38
+ --holdout-generator sdxl \
39
+ --group-by content-id
40
+
41
+ Why --group-by content-id exists
42
+ --------------------------------
43
+ Output filenames are content-addressed from the (prompt, seed) pair, and every
44
+ Stage 3A generator consumed the same `prompts.txt` with the same deterministic
45
+ seed derivation. So `<stem>.png` denotes the same prompt and seed in the SDXL,
46
+ SD 3.5 and AuraFlow sets.
47
+
48
+ `--holdout-generator` alone takes *every* row of that generator, which means
49
+ the held-out set can depict prompts that a sibling generator already
50
+ contributed to training. That is exactly what happened in Stage 3A: all 20,000
51
+ held-out SDXL images shared their (prompt, seed) with an SD 3.5 image in
52
+ train/val/test, so the split measured "unseen generator, **seen** content"
53
+ while being described as a true generalisation test.
54
+
55
+ With `--group-by content-id`, a (prompt, seed) identity is assigned to exactly
56
+ one partition, and two held-out files are written:
57
+
58
+ heldout.csv unseen generator AND unseen content (honest test)
59
+ heldout_seen_content.csv unseen generator, seen content (legacy-comparable)
60
+
61
+ Rows of non-held-out generators that fall in the reserved held-out content
62
+ partition are dropped from train/val/test and reported, since including them
63
+ would reintroduce the leak.
64
+
65
+ Default remains `--group-by none`, which reproduces the legacy behaviour
66
+ byte-for-byte.
67
+ """
68
+ from __future__ import annotations
69
+
70
+ import argparse
71
+ import csv
72
+ import random
73
+ from collections import defaultdict
74
+ from pathlib import Path
75
+
76
+
77
+ def _content_id(row: dict[str, str]) -> str:
78
+ """Identity of the (prompt, seed) pair behind a content-addressed filename.
79
+
80
+ The same stem denotes the same prompt and seed across every Stage 3A
81
+ generator, so this groups sibling renderings of identical content.
82
+ """
83
+ return Path(row["path"]).stem
84
+
85
+
86
+ def _generator_label(row: dict[str, str]) -> str:
87
+ """Return the best available generator label for an AI row."""
88
+ return row.get("generator") or row.get("source") or "unknown"
89
+
90
+
91
+ def _split_key(row: dict[str, str], stratify_by: str) -> str:
92
+ """Return the grouping key used for stratified splitting."""
93
+ cls = row["class"]
94
+ if stratify_by == "class-generator" and cls == "ai_generated":
95
+ return f"{cls}:{_generator_label(row)}"
96
+ return cls
97
+
98
+
99
+ def _write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
100
+ with path.open("w", newline="") as fh:
101
+ writer = csv.DictWriter(fh, fieldnames=fieldnames)
102
+ writer.writeheader()
103
+ writer.writerows(rows)
104
+
105
+
106
+ def _split_content_disjoint(
107
+ args: argparse.Namespace,
108
+ rows: list[dict[str, str]],
109
+ fieldnames: list[str],
110
+ holdout_generators: set[str],
111
+ ) -> None:
112
+ """Partition by (prompt, seed) identity so held-out content is truly unseen."""
113
+ ai_rows = [r for r in rows if r["class"] == "ai_generated"]
114
+ real_rows = [r for r in rows if r["class"] != "ai_generated"]
115
+
116
+ groups: dict[str, list[dict[str, str]]] = defaultdict(list)
117
+ for r in ai_rows:
118
+ groups[_content_id(r)].append(r)
119
+
120
+ group_ids = sorted(groups)
121
+ rng = random.Random(args.seed)
122
+ rng.shuffle(group_ids)
123
+
124
+ n_groups = len(group_ids)
125
+ n_holdout = int(round(n_groups * args.holdout_frac)) if holdout_generators else 0
126
+ holdout_ids = set(group_ids[:n_holdout])
127
+ remaining = group_ids[n_holdout:]
128
+
129
+ n_test = int(round(len(remaining) * args.test))
130
+ n_val = int(round(len(remaining) * args.val))
131
+ partition = {gid: "test" for gid in remaining[:n_test]}
132
+ partition.update({gid: "val" for gid in remaining[n_test : n_test + n_val]})
133
+ partition.update({gid: "train" for gid in remaining[n_test + n_val :]})
134
+
135
+ buckets: dict[str, list[dict[str, str]]] = defaultdict(list)
136
+ dropped: dict[str, int] = defaultdict(int)
137
+ for gid, items in groups.items():
138
+ in_holdout_content = gid in holdout_ids
139
+ for r in items:
140
+ is_holdout_gen = _generator_label(r) in holdout_generators
141
+ if is_holdout_gen:
142
+ buckets["heldout" if in_holdout_content else "heldout_seen_content"].append(r)
143
+ elif in_holdout_content:
144
+ # Including this would put the held-out prompt back into
145
+ # training via a sibling generator — the exact Stage 3A leak.
146
+ dropped[_generator_label(r)] += 1
147
+ else:
148
+ buckets[partition[gid]].append(r)
149
+
150
+ # Real photos share no content identity with generated images, so they are
151
+ # split by the ordinary stratified fractions.
152
+ real_sorted = sorted(real_rows, key=lambda r: r["sha256"])
153
+ rng_real = random.Random(args.seed)
154
+ rng_real.shuffle(real_sorted)
155
+ n = len(real_sorted)
156
+ n_test_r = int(round(n * args.test))
157
+ n_val_r = int(round(n * args.val))
158
+ buckets["test"].extend(real_sorted[:n_test_r])
159
+ buckets["val"].extend(real_sorted[n_test_r : n_test_r + n_val_r])
160
+ buckets["train"].extend(real_sorted[n_test_r + n_val_r :])
161
+
162
+ print(f"content-id groups: {n_groups}")
163
+ print(f" reserved for held-out content: {len(holdout_ids)}")
164
+ print(f" train/val/test groups: {len(remaining) - n_test - n_val}/{n_val}/{n_test}")
165
+ if dropped:
166
+ total_dropped = sum(dropped.values())
167
+ print(
168
+ f" dropped {total_dropped} non-held-out rows whose content is "
169
+ "reserved (prevents leakage):"
170
+ )
171
+ for gen, count in sorted(dropped.items()):
172
+ print(f" {gen}: {count}")
173
+
174
+ args.out_dir.mkdir(parents=True, exist_ok=True)
175
+ for name in ("train", "val", "test", "heldout", "heldout_seen_content"):
176
+ rs = buckets.get(name, [])
177
+ if not rs:
178
+ continue
179
+ rs.sort(key=lambda r: (r["sha256"], r["path"]))
180
+ path = args.out_dir / f"{name}.csv"
181
+ _write_csv(path, fieldnames, rs)
182
+ counts: dict[str, int] = defaultdict(int)
183
+ for r in rs:
184
+ key = (
185
+ _generator_label(r) if r["class"] == "ai_generated" else "authentic"
186
+ )
187
+ counts[key] += 1
188
+ breakdown = " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
189
+ print(f" wrote {path} ({len(rs)} rows) {breakdown}")
190
+
191
+
192
+ def main() -> None:
193
+ parser = argparse.ArgumentParser(description=__doc__)
194
+ parser.add_argument("--manifest", type=Path, required=True)
195
+ parser.add_argument("--out-dir", type=Path, required=True)
196
+ parser.add_argument("--val", type=float, default=0.1)
197
+ parser.add_argument("--test", type=float, default=0.1)
198
+ parser.add_argument("--seed", type=int, default=0)
199
+ parser.add_argument(
200
+ "--stratify-by",
201
+ choices=["class", "class-generator"],
202
+ default="class",
203
+ help=(
204
+ "Stratification mode. 'class' preserves legacy behavior; "
205
+ "'class-generator' additionally balances AI generators."
206
+ ),
207
+ )
208
+ parser.add_argument(
209
+ "--holdout-generator",
210
+ action="append",
211
+ default=[],
212
+ help=(
213
+ "AI generator to remove from train/val/test and write to "
214
+ "heldout.csv. Can be passed multiple times."
215
+ ),
216
+ )
217
+ parser.add_argument(
218
+ "--group-by",
219
+ choices=["none", "content-id"],
220
+ default="none",
221
+ help=(
222
+ "'none' (default) reproduces legacy behaviour. 'content-id' keeps "
223
+ "every generator's rendering of the same (prompt, seed) in one "
224
+ "partition, so the held-out split is content-disjoint as well as "
225
+ "generator-disjoint."
226
+ ),
227
+ )
228
+ parser.add_argument(
229
+ "--holdout-frac",
230
+ type=float,
231
+ default=0.1,
232
+ help=(
233
+ "Fraction of content-id groups reserved for the held-out content "
234
+ "partition. Only used with --group-by content-id."
235
+ ),
236
+ )
237
+ args = parser.parse_args()
238
+
239
+ if args.val + args.test >= 1.0:
240
+ raise ValueError("val + test must be < 1.0")
241
+
242
+ with args.manifest.open() as fh:
243
+ reader = csv.DictReader(fh)
244
+ rows = list(reader)
245
+ fieldnames = reader.fieldnames or []
246
+
247
+ holdout_generators = set(args.holdout_generator)
248
+
249
+ if args.group_by == "content-id":
250
+ return _split_content_disjoint(args, rows, fieldnames, holdout_generators)
251
+
252
+ split_candidates: list[dict[str, str]] = []
253
+ holdout_rows: list[dict[str, str]] = []
254
+ for r in rows:
255
+ if r["class"] == "ai_generated" and _generator_label(r) in holdout_generators:
256
+ holdout_rows.append(r)
257
+ else:
258
+ split_candidates.append(r)
259
+
260
+ if holdout_generators and not holdout_rows:
261
+ raise ValueError(
262
+ "No rows matched --holdout-generator values: "
263
+ f"{sorted(holdout_generators)}"
264
+ )
265
+
266
+ by_group: dict[str, list[dict[str, str]]] = defaultdict(list)
267
+ for r in split_candidates:
268
+ by_group[_split_key(r, args.stratify_by)].append(r)
269
+
270
+ rng = random.Random(args.seed)
271
+ train_rows, val_rows, test_rows = [], [], []
272
+ for group, items in sorted(by_group.items()):
273
+ # Sort by sha256 for determinism, then shuffle with seeded RNG.
274
+ items.sort(key=lambda r: r["sha256"])
275
+ rng.shuffle(items)
276
+ n = len(items)
277
+ n_test = int(round(n * args.test))
278
+ n_val = int(round(n * args.val))
279
+ test_rows.extend(items[:n_test])
280
+ val_rows.extend(items[n_test : n_test + n_val])
281
+ train_rows.extend(items[n_test + n_val :])
282
+ print(
283
+ f" {group}: total={n} "
284
+ f"train={n - n_test - n_val} val={n_val} test={n_test}"
285
+ )
286
+
287
+ args.out_dir.mkdir(parents=True, exist_ok=True)
288
+ for name, rs in [("train", train_rows), ("val", val_rows), ("test", test_rows)]:
289
+ path = args.out_dir / f"{name}.csv"
290
+ _write_csv(path, fieldnames, rs)
291
+ print(f" wrote {path} ({len(rs)} rows)")
292
+
293
+ if holdout_generators:
294
+ path = args.out_dir / "heldout.csv"
295
+ holdout_rows.sort(key=lambda r: (r["sha256"], r["path"]))
296
+ _write_csv(path, fieldnames, holdout_rows)
297
+ print(
298
+ f" wrote {path} ({len(holdout_rows)} rows) "
299
+ f"for held-out generators: {sorted(holdout_generators)}"
300
+ )
301
+
302
+
303
+ if __name__ == "__main__":
304
+ main()
scripts/download_weights.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pre-download CLIP weights to the HuggingFace cache.
3
+
4
+ Run during Docker image build or first deploy so the first user-facing
5
+ request isn't blocked by a multi-hundred-MB download.
6
+
7
+ Usage
8
+ -----
9
+ python scripts/download_weights.py
10
+ """
11
+ from __future__ import annotations
12
+
13
+
14
+ def main() -> None:
15
+ from transformers import CLIPModel, CLIPProcessor
16
+
17
+ from deepfake_scanner.config import settings
18
+
19
+ name = settings.clip_model_name
20
+ print(f"Downloading {name} (Apache-2.0 transformers, MIT-licensed weights)...")
21
+ CLIPProcessor.from_pretrained(name)
22
+ CLIPModel.from_pretrained(name)
23
+ print("Done. Weights cached.")
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
scripts/eval_closed_generators.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluate the live detector on closed-generator images AND real photos (P0 benchmark).
3
+
4
+ Why this exists
5
+ ---------------
6
+ We deliberately never *train* on closed-API generator outputs (ToS rule,
7
+ docs/decisions.md 2026-05-14), so we detect them only by family resemblance to
8
+ the open generators we can train on. That leaves us partly blind to how well we
9
+ do on the consumer tools people actually use. This script closes that gap on
10
+ the MEASUREMENT side.
11
+
12
+ The May 2026 run was 11 screenshotted AI images with **no real-photo arm**, so
13
+ it could report a detection rate but not a false-positive rate — the exact
14
+ number that separates a useful detector from one that just says "AI" a lot.
15
+ This version reports both, per tool and per condition, plus a threshold sweep
16
+ so the production 0.55 cutoff can be re-checked against a set large enough to
17
+ justify it. Spec: docs/closed-generator-position-review-2026-07-30.md section 6.
18
+
19
+ IMPORTANT — licensing / privacy guardrails
20
+ -------------------------------------------
21
+ - These images are for EVALUATION ONLY. They must never be added to a training
22
+ manifest or `data/splits*/`. Training reads only the split CSVs, so images
23
+ placed under the eval folder cannot leak into training by accident.
24
+ - The default eval folder lives under `data/` which is fully .gitignored, so
25
+ ToS-encumbered images are never committed to the repo.
26
+ - Nothing is uploaded anywhere; this runs the detector locally.
27
+
28
+ Folder layout
29
+ -------------
30
+ data/closed_eval/
31
+ ai/<tool>/original/ untouched downloads (C2PA path testable)
32
+ ai/<tool>/screenshot/ screenshots of those (the real sharing path)
33
+ ai/<tool>/logo_intact/ generator logo left in frame
34
+ real/phone_original/ own camera photos
35
+ real/phone_screenshot/ the SAME photos, screenshotted <- key control
36
+ real/edited/ filtered / edited real photos
37
+ real/stock/ web / stock real photos
38
+
39
+ Everything under `ai/` is scored as known-synthetic; everything under `real/`
40
+ as known-authentic. Folders starting with `_` (e.g. `_legacy_may2026/`) are
41
+ skipped unless --include-legacy is passed.
42
+
43
+ If neither `ai/` nor `real/` exists, the target is treated as a flat set of AI
44
+ images (the pre-P0 behaviour) and a warning is printed — no false-positive
45
+ rate can be computed in that mode.
46
+
47
+ Usage
48
+ -----
49
+ # whole benchmark
50
+ python scripts/eval_closed_generators.py --dir data/closed_eval
51
+
52
+ # one condition only
53
+ python scripts/eval_closed_generators.py --dir data/closed_eval/ai/chatgpt/original
54
+
55
+ # freeze a report to diff against after a model change
56
+ python scripts/eval_closed_generators.py --dir data/closed_eval \
57
+ --report-out data/reports/closed_eval_v0.4.0.json
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import json
63
+ from dataclasses import dataclass, field
64
+ from pathlib import Path
65
+
66
+ _IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
67
+ _PROD_THRESHOLD = 0.55
68
+ _SWEEP = [0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95]
69
+
70
+ # Byte markers for an embedded C2PA manifest. Used only to sanity-check the
71
+ # provenance reader against the files themselves — see the self-check below.
72
+ _C2PA_MARKERS = (b"caBX", b"jumbf", b"c2pa")
73
+
74
+ _NAN = float("nan")
75
+
76
+
77
+ @dataclass
78
+ class Group:
79
+ """One reporting bucket: an (arm, tool, condition) triple of images."""
80
+
81
+ name: str
82
+ arm: str # "ai" (known synthetic) | "real" (known authentic)
83
+ paths: list[Path]
84
+ tool: str | None = None
85
+ condition: str | None = None
86
+ records: list[dict] = field(default_factory=list)
87
+ errors: int = 0
88
+
89
+
90
+ def _images(d: Path) -> list[Path]:
91
+ return [p for p in sorted(d.rglob("*")) if p.suffix.lower() in _IMG_EXTS]
92
+
93
+
94
+ def _skipped(d: Path) -> list[Path]:
95
+ """Image-like files the scanner cannot read (e.g. iPhone .HEIC).
96
+
97
+ Reported loudly rather than dropped: a folder that looks full but is half
98
+ unreadable produces a rate computed on a silently different sample. This is
99
+ the same failure shape as the 2026-08 C2PA outage - see docs/decisions.md.
100
+ """
101
+ return [
102
+ p for p in sorted(d.rglob("*"))
103
+ if p.is_file()
104
+ and p.suffix.lower() not in _IMG_EXTS
105
+ and p.suffix.lower() in {".heic", ".heif", ".avif", ".tif", ".tiff", ".gif", ".dng", ".raw"}
106
+ ]
107
+
108
+
109
+ def _loose(d: Path) -> list[Path]:
110
+ return [
111
+ p for p in sorted(d.iterdir())
112
+ if p.is_file() and p.suffix.lower() in _IMG_EXTS
113
+ ]
114
+
115
+
116
+ def _subdirs(d: Path, include_legacy: bool) -> list[Path]:
117
+ return [
118
+ p for p in sorted(d.iterdir())
119
+ if p.is_dir() and (include_legacy or not p.name.startswith("_"))
120
+ ]
121
+
122
+
123
+ def _discover(root: Path, include_legacy: bool) -> tuple[list[Group], list[str]]:
124
+ """Map the folder tree onto labelled groups. Returns (groups, warnings)."""
125
+ groups: list[Group] = []
126
+ warnings: list[str] = []
127
+ ai_root, real_root = root / "ai", root / "real"
128
+
129
+ if not ai_root.is_dir() and not real_root.is_dir():
130
+ # Pre-P0 flat layout, or a single condition folder passed directly.
131
+ imgs = _images(root)
132
+ if imgs:
133
+ groups.append(Group(name=root.name or str(root), arm="ai", paths=imgs))
134
+ warnings.append(
135
+ "No ai/ or real/ subfolder found - treating every image as known-AI. "
136
+ "No false-positive rate can be computed in this mode."
137
+ )
138
+ return groups, warnings
139
+
140
+ if ai_root.is_dir():
141
+ for tool_dir in _subdirs(ai_root, include_legacy):
142
+ for cond_dir in _subdirs(tool_dir, include_legacy):
143
+ imgs = _images(cond_dir)
144
+ if imgs:
145
+ groups.append(Group(
146
+ name=f"ai/{tool_dir.name}/{cond_dir.name}", arm="ai",
147
+ paths=imgs, tool=tool_dir.name, condition=cond_dir.name,
148
+ ))
149
+ stray = _loose(tool_dir)
150
+ if stray:
151
+ groups.append(Group(
152
+ name=f"ai/{tool_dir.name}/(loose)", arm="ai", paths=stray,
153
+ tool=tool_dir.name, condition="(loose)",
154
+ ))
155
+ warnings.append(
156
+ f"ai/{tool_dir.name}/ has {len(stray)} loose image(s) outside a "
157
+ f"condition subfolder - they cannot be paired original<->screenshot."
158
+ )
159
+
160
+ if real_root.is_dir():
161
+ for bucket_dir in _subdirs(real_root, include_legacy):
162
+ imgs = _images(bucket_dir)
163
+ if imgs:
164
+ groups.append(Group(
165
+ name=f"real/{bucket_dir.name}", arm="real", paths=imgs,
166
+ condition=bucket_dir.name,
167
+ ))
168
+
169
+ if include_legacy:
170
+ for d in sorted(root.iterdir()):
171
+ if d.is_dir() and d.name.startswith("_") and _images(d):
172
+ groups.append(Group(name=d.name, arm="ai", paths=_images(d)))
173
+
174
+ for d in (ai_root, real_root):
175
+ if not d.is_dir():
176
+ continue
177
+ skipped = _skipped(d)
178
+ if skipped:
179
+ kinds = sorted({s.suffix.lower() for s in skipped})
180
+ warnings.append(
181
+ f"{len(skipped)} file(s) under {d.name}/ are NOT readable by the "
182
+ f"scanner and were EXCLUDED from every number below "
183
+ f"({', '.join(kinds)}). They are not counted as collected - the "
184
+ f"rates come from the readable files only."
185
+ )
186
+
187
+ if not any(g.arm == "real" for g in groups):
188
+ warnings.append(
189
+ "Real arm is empty - a detection rate alone cannot tell you whether the "
190
+ "detector is accurate or merely biased toward 'AI'. Populate "
191
+ "real/phone_original/ and real/phone_screenshot/ before drawing "
192
+ "conclusions."
193
+ )
194
+ return groups, warnings
195
+
196
+
197
+ def _classify(p_ai: float, threshold: float) -> str:
198
+ """Reproduce the production rule: the top class wins only if it clears the bar."""
199
+ if p_ai >= threshold:
200
+ return "ai_generated"
201
+ if (1.0 - p_ai) >= threshold:
202
+ return "authentic"
203
+ return "uncertain"
204
+
205
+
206
+ def _rates(records: list[dict], arm: str, threshold: float) -> dict:
207
+ n = len(records)
208
+ if n == 0:
209
+ return {"n": 0}
210
+ calls = [_classify(r["p_ai"], threshold) for r in records]
211
+ said_ai = sum(c == "ai_generated" for c in calls)
212
+ said_real = sum(c == "authentic" for c in calls)
213
+ unsure = sum(c == "uncertain" for c in calls)
214
+ out = {
215
+ "n": n,
216
+ "uncertain_rate": unsure / n,
217
+ "mean_p_ai": sum(r["p_ai"] for r in records) / n,
218
+ }
219
+ if arm == "ai":
220
+ out["detect_rate"] = said_ai / n
221
+ out["miss_rate"] = said_real / n # known-AI image called real
222
+ else:
223
+ out["false_positive_rate"] = said_ai / n # real photo called AI
224
+ out["correct_rate"] = said_real / n
225
+ return out
226
+
227
+
228
+ def _headline(r: dict, arm: str) -> str:
229
+ if arm == "ai":
230
+ return (f"detected_AI={r['detect_rate']:.1%} "
231
+ f"missed_as_real={r['miss_rate']:.1%}")
232
+ return (f"FALSE_POSITIVE={r['false_positive_rate']:.1%} "
233
+ f"correct={r['correct_rate']:.1%}")
234
+
235
+
236
+ def main() -> None:
237
+ parser = argparse.ArgumentParser(
238
+ description=__doc__,
239
+ formatter_class=argparse.RawDescriptionHelpFormatter,
240
+ )
241
+ parser.add_argument(
242
+ "--dir", type=Path, default=Path("data/closed_eval"),
243
+ help="Benchmark root (expects ai/ and real/ subfolders).",
244
+ )
245
+ parser.add_argument("--report-out", type=Path, default=None)
246
+ parser.add_argument(
247
+ "--include-legacy", action="store_true",
248
+ help="Include _-prefixed folders such as _legacy_may2026/. Off by default: "
249
+ "that sample is 11 screenshot-only images with no real arm and must "
250
+ "not be merged into P0 counts.",
251
+ )
252
+ parser.add_argument(
253
+ "--threshold", type=float, default=_PROD_THRESHOLD,
254
+ help="Decision threshold for the headline numbers (default: production 0.55).",
255
+ )
256
+ args = parser.parse_args()
257
+
258
+ if not args.dir.exists():
259
+ raise SystemExit(
260
+ f"Eval folder {args.dir} does not exist. See this script's header."
261
+ )
262
+
263
+ # Import here so --help works without loading torch/CLIP.
264
+ from deepfake_scanner.config import settings
265
+ from deepfake_scanner.detectors.ensemble import run_ensemble
266
+ # The SAME decoder the API uses. Calling Image.open().convert("RGB") here
267
+ # instead measured a preprocessing path production does not run: 31 of the
268
+ # 100 real benchmark photographs carry an EXIF rotation tag, so the
269
+ # benchmark scored them sideways and reported 21% where production-
270
+ # equivalent decoding gives 24%. Found in review 2026-09-14. Never decode
271
+ # independently of the service in this script.
272
+ from deepfake_scanner.preprocess import decode
273
+ from deepfake_scanner.provenance.c2pa import check_c2pa
274
+
275
+ groups, warnings = _discover(args.dir, args.include_legacy)
276
+ if not groups:
277
+ raise SystemExit(f"No images found under {args.dir}.")
278
+
279
+ print(f"Detector model_version: {settings.model_version}")
280
+ print(f"Decision threshold: {args.threshold}\n")
281
+ for w in warnings:
282
+ print(f" ! {w}")
283
+ if warnings:
284
+ print()
285
+
286
+ # --- inference ---------------------------------------------------------
287
+ c2pa_bytes_seen = c2pa_read_ok = 0
288
+ for g in groups:
289
+ for path in g.paths:
290
+ try:
291
+ raw = path.read_bytes()
292
+ out = run_ensemble(decode(raw))
293
+ except Exception as e: # corrupt/unsupported file - count, don't crash
294
+ g.errors += 1
295
+ print(f" [skip] {path.name}: {e}")
296
+ continue
297
+ prov = check_c2pa(raw)
298
+ has_marker = any(m in raw for m in _C2PA_MARKERS)
299
+ c2pa_bytes_seen += int(has_marker)
300
+ c2pa_read_ok += int(prov.present)
301
+ g.records.append({
302
+ "file": path.name,
303
+ "p_ai": out.probabilities.ai_generated,
304
+ "verdict": out.verdict,
305
+ "c2pa_present": prov.present,
306
+ "c2pa_issuer": prov.issuer,
307
+ "c2pa_marker_in_bytes": has_marker,
308
+ # A validated chain declaring AI content decides the verdict in
309
+ # production (see verdict.py), so the benchmark measures the
310
+ # same combination the service actually ships.
311
+ "provenance_says_ai": bool(prov.present and prov.valid
312
+ and prov.ai_declared),
313
+ })
314
+
315
+ report: dict = {
316
+ "model_version": settings.model_version,
317
+ "threshold": args.threshold,
318
+ "groups": {},
319
+ "warnings": warnings,
320
+ }
321
+
322
+ # --- per-group report --------------------------------------------------
323
+ for arm, title in (("ai", "AI ARM (known synthetic)"),
324
+ ("real", "REAL ARM (known authentic)")):
325
+ members = [g for g in groups if g.arm == arm and g.records]
326
+ if not members:
327
+ continue
328
+ print(title)
329
+ print("-" * len(title))
330
+ for g in members:
331
+ r = _rates(g.records, arm, args.threshold)
332
+ c2pa_n = sum(rec["c2pa_present"] for rec in g.records)
333
+ report["groups"][g.name] = {
334
+ **r, "arm": arm, "tool": g.tool, "condition": g.condition,
335
+ "errors": g.errors, "c2pa_present_n": c2pa_n,
336
+ }
337
+ print(f" {g.name:36s} n={r['n']:<4d} {_headline(r, arm)} "
338
+ f"uncertain={r['uncertain_rate']:.1%} "
339
+ f"mean_p_ai={r['mean_p_ai']:.3f} c2pa={c2pa_n}/{r['n']}")
340
+ pooled = [rec for g in members for rec in g.records]
341
+ r = _rates(pooled, arm, args.threshold)
342
+ report[f"overall_{arm}"] = r
343
+ print(f" {'ALL ' + arm.upper():36s} n={r['n']:<4d} {_headline(r, arm)} "
344
+ f"uncertain={r['uncertain_rate']:.1%}\n")
345
+
346
+ # --- threshold sweep ---------------------------------------------------
347
+ ai_recs = [rec for g in groups if g.arm == "ai" for rec in g.records]
348
+ real_recs = [rec for g in groups if g.arm == "real" for rec in g.records]
349
+ both = ai_recs + real_recs
350
+
351
+ print("THRESHOLD SWEEP")
352
+ print("-" * 15)
353
+ print(f" {'thresh':>6s} {'AI detected':>11s} {'AI missed':>9s} "
354
+ f"{'real FPR':>8s} {'real correct':>12s} {'uncertain(all)':>14s}")
355
+ sweep = {}
356
+ for t in _SWEEP:
357
+ a = _rates(ai_recs, "ai", t) if ai_recs else {}
358
+ b = _rates(real_recs, "real", t) if real_recs else {}
359
+ unc = sum(_classify(r["p_ai"], t) == "uncertain" for r in both) / max(len(both), 1)
360
+ sweep[f"{t:.2f}"] = {"ai": a, "real": b, "uncertain_rate_all": unc}
361
+ print(f" {t:>6.2f} {a.get('detect_rate', _NAN):>10.1%} "
362
+ f"{a.get('miss_rate', _NAN):>8.1%} "
363
+ f"{b.get('false_positive_rate', _NAN):>7.1%} "
364
+ f"{b.get('correct_rate', _NAN):>11.1%} {unc:>13.1%}")
365
+ report["threshold_sweep"] = sweep
366
+ if not real_recs:
367
+ print("\n (real FPR columns are nan because the real arm is empty)")
368
+
369
+ # --- production routing: provenance overriding the model ----------------
370
+ # verdict.py lets a validated C2PA chain that declares AI content override
371
+ # the classifier, one-directionally. Report what the shipped combination
372
+ # scores, not just the model in isolation.
373
+ print()
374
+ print("WITH PROVENANCE ROUTING (what production actually returns)")
375
+ print("-" * 57)
376
+ routed = {}
377
+ for arm, members in (("ai", [g for g in groups if g.arm == "ai" and g.records]),
378
+ ("real", [g for g in groups if g.arm == "real" and g.records])):
379
+ for g in members:
380
+ n = len(g.records)
381
+ model_ai = sum(_classify(r["p_ai"], args.threshold) == "ai_generated"
382
+ for r in g.records)
383
+ both = sum(_classify(r["p_ai"], args.threshold) == "ai_generated"
384
+ or r["provenance_says_ai"] for r in g.records)
385
+ rescued = sum(r["provenance_says_ai"]
386
+ and _classify(r["p_ai"], args.threshold) != "ai_generated"
387
+ for r in g.records)
388
+ label = "detected" if arm == "ai" else "FALSE POSITIVE"
389
+ routed[g.name] = {"n": n, "model_only": model_ai / n, "routed": both / n,
390
+ "rescued_by_provenance": rescued}
391
+ print(f" {g.name:36s} n={n:<4d} {label}: model {model_ai/n:5.1%} "
392
+ f"-> routed {both/n:5.1%} ({rescued} decided by signature)")
393
+ report["provenance_routing"] = routed
394
+ if any(g.arm == "real" for g in groups):
395
+ print(" NOTE: on the real arm the routed column is the false-positive rate;")
396
+ print(" provenance can only push toward ai_generated, so it can only rise.")
397
+
398
+ # --- provenance self-check ---------------------------------------------
399
+ # A silently broken C2PA reader is indistinguishable from "no image carried a
400
+ # manifest", so compare what the reader found against what is in the bytes.
401
+ print(f"\nC2PA: reader found {c2pa_read_ok} manifest(s); "
402
+ f"{c2pa_bytes_seen} file(s) contain an embedded C2PA marker.")
403
+ if c2pa_bytes_seen and not c2pa_read_ok:
404
+ print(" ! The provenance reader found NOTHING in files that demonstrably "
405
+ "carry a C2PA manifest.")
406
+ print(" ! Treat every c2pa column above as UNMEASURED, not as absent.")
407
+ report["c2pa_selfcheck"] = {
408
+ "reader_found": c2pa_read_ok,
409
+ "marker_in_bytes": c2pa_bytes_seen,
410
+ }
411
+
412
+ if args.report_out is not None:
413
+ args.report_out.parent.mkdir(parents=True, exist_ok=True)
414
+ report["per_image"] = {g.name: g.records for g in groups if g.records}
415
+ with args.report_out.open("w", encoding="utf-8") as fh:
416
+ json.dump(report, fh, indent=2, sort_keys=True)
417
+ print(f"\nsaved report to {args.report_out}")
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()
scripts/evaluate_head.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluate CLIP head checkpoints on cached embedding splits.
3
+
4
+ This is the Stage 3A checkpoint-comparison script. It loads one candidate head,
5
+ optionally a Stage 2 baseline head, and reports overall, per-generator,
6
+ per-source, per-model-family, and per-augmentation metrics for any requested
7
+ embedding splits.
8
+
9
+ Usage
10
+ -----
11
+ python scripts/evaluate_head.py \\
12
+ --emb-dir data/embeddings \\
13
+ --candidate data/checkpoints/head_v3a.pt \\
14
+ --baseline data/checkpoints/head_stage2.pt \\
15
+ --split test \\
16
+ --split heldout \\
17
+ --split test_augmented \\
18
+ --report-out data/reports/head_v3a_eval.json
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+ from train_head import SplitEmbeddings, _build_head, _load_split
30
+
31
+
32
+ def _softmax(logits):
33
+ import torch
34
+
35
+ return torch.softmax(logits, dim=-1)
36
+
37
+
38
+ def _rate(num: int, den: int) -> float:
39
+ return float(num / den) if den else 0.0
40
+
41
+
42
+ def _threshold_metrics(logits, labels, uncertainty_threshold: float) -> dict[str, Any]:
43
+ probs = _softmax(logits)
44
+ max_probs, argmax = probs.max(dim=-1)
45
+ certain = max_probs >= uncertainty_threshold
46
+
47
+ authentic = labels == 0
48
+ ai = labels == 1
49
+ pred_authentic = argmax == 0
50
+ pred_ai = argmax == 1
51
+
52
+ tp = int((certain & pred_ai & ai).sum().item())
53
+ tn = int((certain & pred_authentic & authentic).sum().item())
54
+ fp = int((certain & pred_ai & authentic).sum().item())
55
+ fn = int((certain & pred_authentic & ai).sum().item())
56
+ uncertain_authentic = int((~certain & authentic).sum().item())
57
+ uncertain_ai = int((~certain & ai).sum().item())
58
+ uncertain = uncertain_authentic + uncertain_ai
59
+ n = int(len(labels))
60
+
61
+ argmax_correct = int((argmax == labels).sum().item())
62
+ certain_correct = int(((argmax == labels) & certain).sum().item())
63
+ certain_n = int(certain.sum().item())
64
+
65
+ return {
66
+ "n": n,
67
+ "argmax_accuracy": _rate(argmax_correct, n),
68
+ "coverage_accuracy": _rate(certain_correct, certain_n),
69
+ "coverage_rate": _rate(certain_n, n),
70
+ "uncertainty_rate": _rate(uncertain, n),
71
+ "false_positive_rate": _rate(fp, fp + tn + uncertain_authentic),
72
+ "false_negative_rate": _rate(fn, fn + tp + uncertain_ai),
73
+ "confusion": {
74
+ "tp": tp,
75
+ "tn": tn,
76
+ "fp": fp,
77
+ "fn": fn,
78
+ "uncertain_authentic": uncertain_authentic,
79
+ "uncertain_ai": uncertain_ai,
80
+ },
81
+ "mean_confidence": float(max_probs.mean().item()) if n else 0.0,
82
+ }
83
+
84
+
85
+ def _indices_for(values: np.ndarray, label: str) -> list[int]:
86
+ return [i for i, value in enumerate(values) if str(value) == label]
87
+
88
+
89
+ def _group_metrics(
90
+ logits,
91
+ labels,
92
+ values: np.ndarray,
93
+ uncertainty_threshold: float,
94
+ ) -> dict[str, dict[str, Any]]:
95
+ import torch
96
+
97
+ result: dict[str, dict[str, Any]] = {}
98
+ group_labels = sorted({str(v) for v in values if str(v)})
99
+ for label in group_labels:
100
+ idx = _indices_for(values, label)
101
+ if not idx:
102
+ continue
103
+ tensor_idx = torch.as_tensor(idx, dtype=torch.long, device=logits.device)
104
+ result[label] = _threshold_metrics(
105
+ logits.index_select(0, tensor_idx),
106
+ labels.index_select(0, tensor_idx),
107
+ uncertainty_threshold,
108
+ )
109
+ return result
110
+
111
+
112
+ def _generator_values(split: SplitEmbeddings) -> np.ndarray:
113
+ values: list[str] = []
114
+ labels = split.y.cpu().numpy()
115
+ for i, label in enumerate(labels):
116
+ if label != 1:
117
+ values.append("")
118
+ continue
119
+ values.append(str(split.generators[i] or split.sources[i]))
120
+ return np.asarray(values)
121
+
122
+
123
+ def _augmentation_values(split: SplitEmbeddings) -> np.ndarray:
124
+ return np.asarray(
125
+ [str(value) if str(value) else "clean" for value in split.augmentations]
126
+ )
127
+
128
+
129
+ def _split_report(
130
+ split: SplitEmbeddings,
131
+ logits,
132
+ uncertainty_threshold: float,
133
+ ) -> dict[str, Any]:
134
+ report: dict[str, Any] = {
135
+ "overall": _threshold_metrics(logits, split.y, uncertainty_threshold)
136
+ }
137
+
138
+ groups = {
139
+ "by_source": split.sources,
140
+ "by_generator": _generator_values(split),
141
+ "by_model_family": split.model_families,
142
+ }
143
+ if any(str(value) for value in split.augmentations):
144
+ groups["by_augmentation"] = _augmentation_values(split)
145
+
146
+ for name, values in groups.items():
147
+ metrics = _group_metrics(logits, split.y, values, uncertainty_threshold)
148
+ if metrics:
149
+ report[name] = metrics
150
+
151
+ return report
152
+
153
+
154
+ def _load_head(checkpoint: Path):
155
+ import torch
156
+
157
+ head = _build_head()
158
+ state = torch.load(checkpoint, map_location="cpu")
159
+ head.load_state_dict(state)
160
+ head.eval()
161
+ return head
162
+
163
+
164
+ def _evaluate_checkpoint(
165
+ checkpoint: Path,
166
+ splits: dict[str, SplitEmbeddings],
167
+ uncertainty_threshold: float,
168
+ ) -> dict[str, Any]:
169
+ import torch
170
+
171
+ head = _load_head(checkpoint)
172
+ report: dict[str, Any] = {
173
+ "checkpoint": str(checkpoint),
174
+ "splits": {},
175
+ }
176
+ with torch.no_grad():
177
+ for name, split in splits.items():
178
+ logits = head(split.x)
179
+ report["splits"][name] = _split_report(
180
+ split,
181
+ logits,
182
+ uncertainty_threshold,
183
+ )
184
+ return report
185
+
186
+
187
+ def _comparison(candidate: dict[str, Any], baseline: dict[str, Any] | None) -> dict:
188
+ if baseline is None:
189
+ return {}
190
+
191
+ result: dict[str, dict[str, float]] = {}
192
+ for split, candidate_report in candidate["splits"].items():
193
+ if split not in baseline["splits"]:
194
+ continue
195
+ candidate_overall = candidate_report["overall"]
196
+ baseline_overall = baseline["splits"][split]["overall"]
197
+ result[split] = {
198
+ "argmax_accuracy_delta": (
199
+ candidate_overall["argmax_accuracy"]
200
+ - baseline_overall["argmax_accuracy"]
201
+ ),
202
+ "uncertainty_rate_delta": (
203
+ candidate_overall["uncertainty_rate"]
204
+ - baseline_overall["uncertainty_rate"]
205
+ ),
206
+ "false_positive_rate_delta": (
207
+ candidate_overall["false_positive_rate"]
208
+ - baseline_overall["false_positive_rate"]
209
+ ),
210
+ "false_negative_rate_delta": (
211
+ candidate_overall["false_negative_rate"]
212
+ - baseline_overall["false_negative_rate"]
213
+ ),
214
+ }
215
+ return result
216
+
217
+
218
+ def _print_summary(report: dict[str, Any]) -> None:
219
+ print(f"Candidate: {report['candidate']['checkpoint']}")
220
+ if report.get("baseline"):
221
+ print(f"Baseline: {report['baseline']['checkpoint']}")
222
+ for split, metrics in report["candidate"]["splits"].items():
223
+ overall = metrics["overall"]
224
+ print(
225
+ f" {split}: n={overall['n']} "
226
+ f"acc={overall['argmax_accuracy']:.4f} "
227
+ f"uncertain={overall['uncertainty_rate']:.4f} "
228
+ f"fpr={overall['false_positive_rate']:.4f} "
229
+ f"fnr={overall['false_negative_rate']:.4f}"
230
+ )
231
+ for group_name in ["by_generator", "by_augmentation"]:
232
+ if group_name not in metrics:
233
+ continue
234
+ print(f" {group_name}:")
235
+ for label, group_metrics in metrics[group_name].items():
236
+ print(
237
+ f" {label}: n={group_metrics['n']} "
238
+ f"acc={group_metrics['argmax_accuracy']:.4f} "
239
+ f"uncertain={group_metrics['uncertainty_rate']:.4f}"
240
+ )
241
+
242
+
243
+ def main() -> None:
244
+ parser = argparse.ArgumentParser(
245
+ description=__doc__,
246
+ formatter_class=argparse.RawDescriptionHelpFormatter,
247
+ )
248
+ parser.add_argument("--emb-dir", type=Path, required=True)
249
+ parser.add_argument("--candidate", type=Path, required=True)
250
+ parser.add_argument("--baseline", type=Path, default=None)
251
+ parser.add_argument(
252
+ "--split",
253
+ action="append",
254
+ default=[],
255
+ help="Embedding split name to evaluate, without .npz. Defaults to test.",
256
+ )
257
+ parser.add_argument("--report-out", type=Path, required=True)
258
+ parser.add_argument("--uncertainty-threshold", type=float, default=0.6)
259
+ args = parser.parse_args()
260
+
261
+ split_names = args.split or ["test"]
262
+ splits = {name: _load_split(args.emb_dir, name) for name in split_names}
263
+
264
+ candidate = _evaluate_checkpoint(
265
+ args.candidate,
266
+ splits,
267
+ args.uncertainty_threshold,
268
+ )
269
+ baseline = (
270
+ _evaluate_checkpoint(args.baseline, splits, args.uncertainty_threshold)
271
+ if args.baseline is not None
272
+ else None
273
+ )
274
+
275
+ report = {
276
+ "uncertainty_threshold": args.uncertainty_threshold,
277
+ "candidate": candidate,
278
+ "baseline": baseline,
279
+ "comparison": _comparison(candidate, baseline),
280
+ }
281
+
282
+ args.report_out.parent.mkdir(parents=True, exist_ok=True)
283
+ with args.report_out.open("w", encoding="utf-8") as fh:
284
+ json.dump(report, fh, indent=2, sort_keys=True)
285
+
286
+ _print_summary(report)
287
+ print(f"\nSaved evaluation report to {args.report_out}")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ main()
scripts/precompute_embeddings.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pre-compute CLIP image embeddings for the training set.
3
+
4
+ Why a separate step?
5
+ --------------------
6
+ CLIP forward passes are the expensive part of Stage 2 training. The CLIP
7
+ backbone is FROZEN — only the small classification head defined in
8
+ `src/deepfake_scanner/detectors/clip_classifier.py` is being trained.
9
+ Encoding ~100k images on CPU takes 1-4 hours; training the head on the
10
+ cached embeddings then takes seconds per epoch and can be iterated freely
11
+ without re-encoding.
12
+
13
+ Local feasibility
14
+ -----------------
15
+ Yes — this script runs fine on the dev laptop (i7-1195G7, 32 GB).
16
+ With the default batch size of 16, peak RAM is ~1 GB. Throughput is
17
+ roughly 50-150 ms per image depending on thread count.
18
+
19
+ Same script runs ~50-100x faster on a CUDA GPU. Use `--device cuda` only
20
+ if you happen to be on a GPU box already (e.g. the Flux generation box,
21
+ before tearing it down).
22
+
23
+ Output format
24
+ -------------
25
+ One `.npz` file per split, in `--out-dir`:
26
+ - `embeddings`: float32 array, shape (N, 512), L2-normalised
27
+ - `labels`: int8 array, shape (N,) 0 = authentic, 1 = ai_generated
28
+ - `paths`: unicode array, shape (N,) source image paths
29
+ - `sources`: unicode array, shape (N,) manifest source values
30
+ - `generators`: unicode array, shape (N,) AI generator values or ""
31
+ - `model_families`: unicode array, shape (N,) generator family values or ""
32
+ - `augmentations`: unicode array, shape (N,) augmentation version or ""
33
+ - `original_paths`: unicode array, shape (N,) original path for augmented rows
34
+
35
+ The extra metadata arrays are ignored by `train_head.py` today, but they make
36
+ the cached embeddings auditable and give Stage 3A evaluation code enough
37
+ context to separate clean rows from augmented rows.
38
+
39
+ Resumability
40
+ ------------
41
+ Long runs survive crashes, lid-closes, and Ctrl-C. Each split is encoded in
42
+ small shards (`{split}_shards/shard_NNNNNNNN.npz`); on restart we count the
43
+ contiguous shards already written from index 0 and resume from there. At
44
+ the end the shards are concatenated into the final `{split}.npz` and the
45
+ shard directory is removed.
46
+
47
+ Idempotent: an existing final `.npz` is skipped unless `--force` is passed.
48
+ `--force` also wipes any leftover shard directory.
49
+
50
+ Usage
51
+ -----
52
+ python scripts/precompute_embeddings.py \\
53
+ --splits-dir data \\
54
+ --out-dir data/embeddings \\
55
+ --data-root data
56
+
57
+ Include augmented training rows:
58
+
59
+ python scripts/precompute_embeddings.py \\
60
+ --splits-dir data \\
61
+ --out-dir data/embeddings \\
62
+ --data-root data \\
63
+ --train-augment-manifest data/train_augmented.csv
64
+
65
+ Encode a separate augmented evaluation split:
66
+
67
+ python scripts/precompute_embeddings.py \\
68
+ --splits-dir data \\
69
+ --out-dir data/embeddings \\
70
+ --data-root data \\
71
+ --extra-split test_augmented=data/test_augmented.csv
72
+ """
73
+ from __future__ import annotations
74
+
75
+ import argparse
76
+ import csv
77
+ import shutil
78
+ import time
79
+ from dataclasses import dataclass
80
+ from pathlib import Path
81
+
82
+ import numpy as np
83
+
84
+
85
+ # Order MUST match the head's output ordering in
86
+ # src/deepfake_scanner/detectors/clip_classifier.py (logits[..., 0] = authentic,
87
+ # logits[..., 1] = ai_generated). Drift between train and inference here
88
+ # would silently invert predictions.
89
+ CLASS_TO_LABEL: dict[str, int] = {
90
+ "authentic": 0,
91
+ "ai_generated": 1,
92
+ }
93
+
94
+ # Images per shard. 500 × 512 × 4 bytes = 1 MB on disk; small enough that a
95
+ # crash loses at most ~25 seconds of CPU work, large enough that shard I/O
96
+ # overhead is negligible.
97
+ SHARD_SIZE = 500
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class EmbeddingItem:
102
+ image_path: Path
103
+ label: int
104
+ manifest_path: str
105
+ source: str = ""
106
+ generator: str = ""
107
+ model_family: str = ""
108
+ augmentation: str = ""
109
+ original_path: str = ""
110
+
111
+
112
+ def _read_manifest(path: Path, data_root: Path) -> list[EmbeddingItem]:
113
+ items: list[EmbeddingItem] = []
114
+ with path.open() as fh:
115
+ reader = csv.DictReader(fh)
116
+ for row in reader:
117
+ cls = row["class"]
118
+ if cls not in CLASS_TO_LABEL:
119
+ raise ValueError(f"{path}: unknown class {cls!r}")
120
+ manifest_path = row["path"]
121
+ items.append(
122
+ EmbeddingItem(
123
+ image_path=data_root / manifest_path,
124
+ label=CLASS_TO_LABEL[cls],
125
+ manifest_path=manifest_path,
126
+ source=row.get("source", ""),
127
+ generator=row.get("generator", ""),
128
+ model_family=row.get("model_family", ""),
129
+ augmentation=row.get("augmentation", ""),
130
+ original_path=row.get("original_path", ""),
131
+ )
132
+ )
133
+ return items
134
+
135
+
136
+ def _read_split(
137
+ path: Path,
138
+ data_root: Path,
139
+ extra_manifests: list[Path] | None = None,
140
+ ) -> list[EmbeddingItem]:
141
+ items = _read_manifest(path, data_root)
142
+ for extra_manifest in extra_manifests or []:
143
+ items.extend(_read_manifest(extra_manifest, data_root))
144
+
145
+ seen: set[str] = set()
146
+ duplicates: list[str] = []
147
+ for item in items:
148
+ if item.manifest_path in seen:
149
+ duplicates.append(item.manifest_path)
150
+ seen.add(item.manifest_path)
151
+ if duplicates:
152
+ raise ValueError(
153
+ f"Duplicate image paths in split inputs for {path}: {duplicates[:5]}"
154
+ )
155
+
156
+ return items
157
+
158
+
159
+ def _resume_cursor(shard_dir: Path) -> int:
160
+ """How many items have been encoded already, by counting the contiguous
161
+ shard prefix starting at index 0. Corrupt shards are deleted in passing."""
162
+ if not shard_dir.exists():
163
+ return 0
164
+ by_start: dict[int, int] = {}
165
+ for p in sorted(shard_dir.glob("shard_*.npz")):
166
+ try:
167
+ with np.load(p) as z:
168
+ start = int(z["start_idx"])
169
+ end = int(z["end_idx"])
170
+ required = [
171
+ "embeddings",
172
+ "labels",
173
+ "paths",
174
+ "sources",
175
+ "generators",
176
+ "model_families",
177
+ "augmentations",
178
+ "original_paths",
179
+ ]
180
+ for key in required:
181
+ if key not in z:
182
+ raise KeyError(key)
183
+ except Exception:
184
+ print(f" removing corrupt shard {p.name}")
185
+ p.unlink()
186
+ continue
187
+ by_start[start] = end
188
+ cursor = 0
189
+ while cursor in by_start:
190
+ cursor = by_start[cursor]
191
+ return cursor
192
+
193
+
194
+ def _save_shard_atomic(
195
+ shard_path: Path,
196
+ embeddings: np.ndarray,
197
+ labels: np.ndarray,
198
+ paths: np.ndarray,
199
+ sources: np.ndarray,
200
+ generators: np.ndarray,
201
+ model_families: np.ndarray,
202
+ augmentations: np.ndarray,
203
+ original_paths: np.ndarray,
204
+ start_idx: int,
205
+ end_idx: int,
206
+ ) -> None:
207
+ """Write to .tmp first, then atomic rename. Avoids partial-write corruption
208
+ if the process is killed mid-save."""
209
+ tmp = shard_path.with_suffix(".tmp.npz")
210
+ np.savez_compressed(
211
+ tmp,
212
+ embeddings=embeddings,
213
+ labels=labels,
214
+ paths=paths,
215
+ sources=sources,
216
+ generators=generators,
217
+ model_families=model_families,
218
+ augmentations=augmentations,
219
+ original_paths=original_paths,
220
+ start_idx=np.asarray(start_idx),
221
+ end_idx=np.asarray(end_idx),
222
+ )
223
+ tmp.replace(shard_path)
224
+
225
+
226
+ def _concat_shards(shard_dir: Path, out_path: Path, expected_total: int) -> None:
227
+ paths = sorted(shard_dir.glob("shard_*.npz"))
228
+ embeds_list: list[np.ndarray] = []
229
+ labels_list: list[np.ndarray] = []
230
+ paths_list: list[np.ndarray] = []
231
+ sources_list: list[np.ndarray] = []
232
+ generators_list: list[np.ndarray] = []
233
+ model_families_list: list[np.ndarray] = []
234
+ augmentations_list: list[np.ndarray] = []
235
+ original_paths_list: list[np.ndarray] = []
236
+ for p in paths:
237
+ with np.load(p) as z:
238
+ embeds_list.append(z["embeddings"].copy())
239
+ labels_list.append(z["labels"].copy())
240
+ paths_list.append(z["paths"].copy())
241
+ sources_list.append(z["sources"].copy())
242
+ generators_list.append(z["generators"].copy())
243
+ model_families_list.append(z["model_families"].copy())
244
+ augmentations_list.append(z["augmentations"].copy())
245
+ original_paths_list.append(z["original_paths"].copy())
246
+ embeddings = np.concatenate(embeds_list, axis=0)
247
+ labels = np.concatenate(labels_list, axis=0)
248
+ manifest_paths = np.concatenate(paths_list, axis=0)
249
+ sources = np.concatenate(sources_list, axis=0)
250
+ generators = np.concatenate(generators_list, axis=0)
251
+ model_families = np.concatenate(model_families_list, axis=0)
252
+ augmentations = np.concatenate(augmentations_list, axis=0)
253
+ original_paths = np.concatenate(original_paths_list, axis=0)
254
+ if len(embeddings) != expected_total:
255
+ raise RuntimeError(
256
+ f"Shard concat produced {len(embeddings)} rows but split expected "
257
+ f"{expected_total}. Refusing to write {out_path}."
258
+ )
259
+ np.savez_compressed(
260
+ out_path,
261
+ embeddings=embeddings,
262
+ labels=labels,
263
+ paths=manifest_paths,
264
+ sources=sources,
265
+ generators=generators,
266
+ model_families=model_families,
267
+ augmentations=augmentations,
268
+ original_paths=original_paths,
269
+ )
270
+ shutil.rmtree(shard_dir)
271
+
272
+
273
+ def _process_split(
274
+ split: str,
275
+ split_csv: Path,
276
+ out_dir: Path,
277
+ data_root: Path,
278
+ batch_size: int,
279
+ force: bool,
280
+ extra_manifests: list[Path],
281
+ model,
282
+ processor,
283
+ device: str,
284
+ torch_module,
285
+ Image,
286
+ ) -> None:
287
+ out_path = out_dir / f"{split}.npz"
288
+ shard_dir = out_dir / f"{split}_shards"
289
+
290
+ if force:
291
+ if out_path.exists():
292
+ out_path.unlink()
293
+ if shard_dir.exists():
294
+ shutil.rmtree(shard_dir)
295
+
296
+ if out_path.exists():
297
+ print(
298
+ f"Skipping {split} — {out_path} already exists. "
299
+ "Use --force to recompute."
300
+ )
301
+ return
302
+
303
+ if not split_csv.exists():
304
+ print(f"Skipping {split} — {split_csv} not found.")
305
+ return
306
+
307
+ items = _read_split(split_csv, data_root, extra_manifests)
308
+ n = len(items)
309
+ print(f"\n{split}: {n} images")
310
+ if extra_manifests:
311
+ print(
312
+ f" includes {len(extra_manifests)} extra manifest(s): "
313
+ + ", ".join(str(p) for p in extra_manifests)
314
+ )
315
+
316
+ cursor = _resume_cursor(shard_dir)
317
+ if cursor > 0:
318
+ print(f" resuming from item {cursor}/{n} ({cursor / n:.1%} done)")
319
+ shard_dir.mkdir(parents=True, exist_ok=True)
320
+
321
+ start_t = time.time()
322
+ items_at_start = cursor
323
+
324
+ while cursor < n:
325
+ shard_end = min(cursor + SHARD_SIZE, n)
326
+ shard_n = shard_end - cursor
327
+ shard_embeds = np.empty((shard_n, 512), dtype=np.float32)
328
+ shard_items = items[cursor:shard_end]
329
+ shard_labels = np.asarray(
330
+ [item.label for item in shard_items],
331
+ dtype=np.int8,
332
+ )
333
+ shard_paths = np.asarray([item.manifest_path for item in shard_items])
334
+ shard_sources = np.asarray([item.source for item in shard_items])
335
+ shard_generators = np.asarray([item.generator for item in shard_items])
336
+ shard_model_families = np.asarray(
337
+ [item.model_family for item in shard_items]
338
+ )
339
+ shard_augmentations = np.asarray(
340
+ [item.augmentation for item in shard_items]
341
+ )
342
+ shard_original_paths = np.asarray(
343
+ [item.original_path for item in shard_items]
344
+ )
345
+
346
+ pos = 0
347
+ i = cursor
348
+ while i < shard_end:
349
+ batch_end = min(i + batch_size, shard_end)
350
+ batch_paths = [item.image_path for item in items[i:batch_end]]
351
+ batch_imgs = []
352
+ for p in batch_paths:
353
+ with Image.open(p) as img:
354
+ img_rgb = img.convert("RGB")
355
+ img_rgb.load()
356
+ batch_imgs.append(img_rgb.copy())
357
+
358
+ inputs = processor(images=batch_imgs, return_tensors="pt").to(device)
359
+ with torch_module.no_grad():
360
+ feats = model.get_image_features(**inputs)
361
+ # L2-normalise to match the inference path
362
+ # (clip_classifier.py:94). Drift here breaks predictions.
363
+ feats = feats / feats.norm(p=2, dim=-1, keepdim=True)
364
+ shard_embeds[pos : pos + len(batch_imgs)] = (
365
+ feats.cpu().numpy().astype(np.float32)
366
+ )
367
+ pos += len(batch_imgs)
368
+ i = batch_end
369
+
370
+ shard_path = shard_dir / f"shard_{cursor:08d}.npz"
371
+ _save_shard_atomic(
372
+ shard_path,
373
+ shard_embeds,
374
+ shard_labels,
375
+ shard_paths,
376
+ shard_sources,
377
+ shard_generators,
378
+ shard_model_families,
379
+ shard_augmentations,
380
+ shard_original_paths,
381
+ cursor,
382
+ shard_end,
383
+ )
384
+ cursor = shard_end
385
+
386
+ elapsed = time.time() - start_t
387
+ done_this_run = cursor - items_at_start
388
+ rate = done_this_run / max(elapsed, 1e-6)
389
+ eta = (n - cursor) / max(rate, 1e-6)
390
+ print(
391
+ f" {cursor}/{n} ({rate:.1f} img/s, "
392
+ f"eta {eta / 60:.1f} min, shard saved)"
393
+ )
394
+
395
+ print(f" concatenating shards into {out_path}...")
396
+ _concat_shards(shard_dir, out_path, expected_total=n)
397
+ print(f" wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB)")
398
+
399
+
400
+ def main() -> None:
401
+ parser = argparse.ArgumentParser(
402
+ description=__doc__,
403
+ formatter_class=argparse.RawDescriptionHelpFormatter,
404
+ )
405
+ parser.add_argument("--splits-dir", type=Path, required=True,
406
+ help="Directory containing train.csv / val.csv / test.csv")
407
+ parser.add_argument("--out-dir", type=Path, required=True,
408
+ help="Where to write {train,val,test}.npz")
409
+ parser.add_argument("--data-root", type=Path, required=True,
410
+ help="Root that the manifest 'path' column is relative to")
411
+ parser.add_argument("--batch-size", type=int, default=16)
412
+ parser.add_argument("--model", default="openai/clip-vit-base-patch32",
413
+ help="HF model name. MUST match the inference config.")
414
+ parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
415
+ parser.add_argument("--force", action="store_true",
416
+ help="Recompute even if output .npz / shards already exist")
417
+ parser.add_argument(
418
+ "--train-augment-manifest",
419
+ type=Path,
420
+ action="append",
421
+ default=[],
422
+ help=(
423
+ "Augmented manifest to append to train.csv only. Can be passed "
424
+ "multiple times."
425
+ ),
426
+ )
427
+ parser.add_argument(
428
+ "--extra-split",
429
+ action="append",
430
+ default=[],
431
+ metavar="NAME=CSV",
432
+ help=(
433
+ "Additional explicit split to encode, for example "
434
+ "test_augmented=data/test_augmented.csv. Can be passed multiple times."
435
+ ),
436
+ )
437
+ args = parser.parse_args()
438
+
439
+ args.out_dir.mkdir(parents=True, exist_ok=True)
440
+
441
+ print(f"Loading CLIP model {args.model} on {args.device}...")
442
+ import torch
443
+ from PIL import Image
444
+ from transformers import CLIPModel, CLIPProcessor
445
+
446
+ processor = CLIPProcessor.from_pretrained(args.model)
447
+ model = CLIPModel.from_pretrained(args.model).to(args.device)
448
+ model.eval()
449
+ for p in model.parameters():
450
+ p.requires_grad = False
451
+
452
+ extra_splits: list[tuple[str, Path]] = []
453
+ for spec in args.extra_split:
454
+ if "=" not in spec:
455
+ raise ValueError(f"--extra-split must be NAME=CSV, got {spec!r}")
456
+ name, csv_path = spec.split("=", 1)
457
+ if not name or "/" in name or "\\" in name:
458
+ raise ValueError(f"Invalid extra split name: {name!r}")
459
+ extra_splits.append((name, Path(csv_path)))
460
+
461
+ split_specs = [
462
+ ("train", args.splits_dir / "train.csv", args.train_augment_manifest),
463
+ ("val", args.splits_dir / "val.csv", []),
464
+ ("test", args.splits_dir / "test.csv", []),
465
+ *[(name, path, []) for name, path in extra_splits],
466
+ ]
467
+
468
+ for split, split_csv, extra_manifests in split_specs:
469
+ _process_split(
470
+ split=split,
471
+ split_csv=split_csv,
472
+ out_dir=args.out_dir,
473
+ data_root=args.data_root,
474
+ batch_size=args.batch_size,
475
+ force=args.force,
476
+ extra_manifests=extra_manifests,
477
+ model=model,
478
+ processor=processor,
479
+ device=args.device,
480
+ torch_module=torch,
481
+ Image=Image,
482
+ )
483
+
484
+ print("\nDone.")
485
+
486
+
487
+ if __name__ == "__main__":
488
+ main()
scripts/precompute_frequency_features.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pre-compute Stage 3B frequency features for the dataset splits.
3
+
4
+ Mirrors `scripts/precompute_embeddings.py` in shape (same split CSVs, same
5
+ `--data-root` resolution, same label map, resumable shards) but emits the
6
+ 15-dim `freq-v1` feature vector from
7
+ `deepfake_scanner.detectors.frequency_features` instead of CLIP embeddings.
8
+
9
+ Why a separate offline step?
10
+ ----------------------------
11
+ Feature extraction is pure-numpy and CPU-cheap (~a few ms/image), but the
12
+ full multi-generator dataset is ~100k+ images, so caching the features once
13
+ lets the classifier (S3B-07) iterate in seconds without re-reading images.
14
+
15
+ Output format
16
+ -------------
17
+ One `{split}_freq.npz` per split in `--out-dir`:
18
+ - features: float32 (N, 16) freq-v1 feature vectors
19
+ - labels: int8 (N,) 0 = authentic, 1 = ai_generated
20
+ - paths: unicode (N,) manifest-relative image paths
21
+ - sources: unicode (N,)
22
+ - generators: unicode (N,)
23
+ - model_families: unicode (N,)
24
+ - augmentations: unicode (N,)
25
+ - feature_schema: scalar str FEATURE_SCHEMA_VERSION ("freq-v1")
26
+ - feature_names: unicode (16,) FEATURE_NAMES, for validation
27
+
28
+ Resumability
29
+ ------------
30
+ Each split is encoded in shards (`{split}_freq_shards/shard_NNNNNNNN.npz`);
31
+ on restart we resume from the first missing contiguous shard. Final files
32
+ are concatenated at the end and the shard dir removed. Idempotent: an
33
+ existing final `.npz` is skipped unless `--force`.
34
+
35
+ Usage
36
+ -----
37
+ python scripts/precompute_frequency_features.py \\
38
+ --splits-dir data/splits \\
39
+ --out-dir data/freq_features \\
40
+ --data-root data
41
+
42
+ # Include the augmented splits used for robustness training/eval:
43
+ python scripts/precompute_frequency_features.py \\
44
+ --splits-dir data/splits \\
45
+ --out-dir data/freq_features \\
46
+ --data-root data \\
47
+ --splits train val test heldout test_augmented train_augmented
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import csv
53
+ import shutil
54
+ import time
55
+ from dataclasses import dataclass
56
+ from pathlib import Path
57
+
58
+ import numpy as np
59
+ from PIL import Image
60
+
61
+ from deepfake_scanner.detectors.frequency_features import (
62
+ FEATURE_NAMES,
63
+ FEATURE_SCHEMA_VERSION,
64
+ N_FEATURES,
65
+ extract_features,
66
+ )
67
+
68
+ # Keep label mapping identical to precompute_embeddings.py / train_head.py:
69
+ # logits[..., 1] = ai_generated.
70
+ _LABEL_MAP = {"authentic": 0, "ai_generated": 1}
71
+
72
+ # Images per shard. Feature extraction is cheap, so use a larger shard than
73
+ # the embeddings script; a crash loses at most a few seconds of work.
74
+ SHARD_SIZE = 2000
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class FeatureItem:
79
+ label: int
80
+ manifest_path: str
81
+ image_path: Path
82
+ source: str
83
+ generator: str
84
+ model_family: str
85
+ augmentation: str
86
+
87
+
88
+ def _read_split(csv_path: Path, data_root: Path) -> list[FeatureItem]:
89
+ items: list[FeatureItem] = []
90
+ with csv_path.open(encoding="utf-8") as fh:
91
+ reader = csv.DictReader(fh)
92
+ for row in reader:
93
+ label = _LABEL_MAP[row["class"]]
94
+ manifest_path = row["path"]
95
+ items.append(
96
+ FeatureItem(
97
+ label=label,
98
+ manifest_path=manifest_path,
99
+ image_path=data_root / manifest_path,
100
+ source=row.get("source", ""),
101
+ generator=row.get("generator", ""),
102
+ model_family=row.get("model_family", ""),
103
+ augmentation=row.get("augmentation", ""),
104
+ )
105
+ )
106
+ return items
107
+
108
+
109
+ def _resume_cursor(shard_dir: Path) -> int:
110
+ """Count the contiguous valid shard prefix; delete a trailing corrupt one."""
111
+ if not shard_dir.exists():
112
+ return 0
113
+ cursor = 0
114
+ for p in sorted(shard_dir.glob("shard_*.npz")):
115
+ try:
116
+ with np.load(p) as z:
117
+ _ = z["features"], z["labels"]
118
+ cursor += int(np.load(p)["features"].shape[0])
119
+ except Exception:
120
+ print(f" removing corrupt shard {p.name}")
121
+ p.unlink()
122
+ break
123
+ return cursor
124
+
125
+
126
+ def _save_shard_atomic(shard_path: Path, **arrays) -> None:
127
+ tmp = shard_path.with_suffix(".tmp.npz")
128
+ np.savez_compressed(tmp, **arrays)
129
+ tmp.replace(shard_path)
130
+
131
+
132
+ def _concat_shards(shard_dir: Path, out_path: Path, expected_total: int) -> None:
133
+ paths = sorted(shard_dir.glob("shard_*.npz"))
134
+ keys = [
135
+ "features",
136
+ "labels",
137
+ "paths",
138
+ "sources",
139
+ "generators",
140
+ "model_families",
141
+ "augmentations",
142
+ ]
143
+ acc: dict[str, list[np.ndarray]] = {k: [] for k in keys}
144
+ for p in paths:
145
+ with np.load(p) as z:
146
+ for k in keys:
147
+ acc[k].append(z[k].copy())
148
+ merged = {k: np.concatenate(acc[k], axis=0) for k in keys}
149
+ if len(merged["labels"]) != expected_total:
150
+ raise RuntimeError(
151
+ f"Concatenated {len(merged['labels'])} rows but expected "
152
+ f"{expected_total}. Refusing to write {out_path}."
153
+ )
154
+ np.savez_compressed(
155
+ out_path,
156
+ feature_schema=np.asarray(FEATURE_SCHEMA_VERSION),
157
+ feature_names=np.asarray(FEATURE_NAMES),
158
+ **merged,
159
+ )
160
+ shutil.rmtree(shard_dir)
161
+
162
+
163
+ def _encode_split(
164
+ split: str,
165
+ splits_dir: Path,
166
+ out_dir: Path,
167
+ data_root: Path,
168
+ force: bool,
169
+ ) -> None:
170
+ csv_path = splits_dir / f"{split}.csv"
171
+ if not csv_path.exists():
172
+ print(f"Skipping {split} — {csv_path} not found.")
173
+ return
174
+
175
+ out_path = out_dir / f"{split}_freq.npz"
176
+ shard_dir = out_dir / f"{split}_freq_shards"
177
+
178
+ if force:
179
+ if out_path.exists():
180
+ out_path.unlink()
181
+ if shard_dir.exists():
182
+ shutil.rmtree(shard_dir)
183
+
184
+ if out_path.exists():
185
+ print(f"Skipping {split} — {out_path} already exists. Use --force.")
186
+ return
187
+
188
+ items = _read_split(csv_path, data_root)
189
+ n = len(items)
190
+ print(f"{split}: {n} images")
191
+
192
+ cursor = _resume_cursor(shard_dir)
193
+ if cursor:
194
+ print(f" resuming from {cursor}/{n}")
195
+ shard_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ t0 = time.time()
198
+ while cursor < n:
199
+ shard_end = min(cursor + SHARD_SIZE, n)
200
+ chunk = items[cursor:shard_end]
201
+ feats = np.empty((len(chunk), N_FEATURES), dtype=np.float32)
202
+ for i, item in enumerate(chunk):
203
+ with Image.open(item.image_path) as img:
204
+ feats[i] = extract_features(img)
205
+ _save_shard_atomic(
206
+ shard_dir / f"shard_{cursor:08d}.npz",
207
+ features=feats,
208
+ labels=np.asarray([it.label for it in chunk], dtype=np.int8),
209
+ paths=np.asarray([it.manifest_path for it in chunk]),
210
+ sources=np.asarray([it.source for it in chunk]),
211
+ generators=np.asarray([it.generator for it in chunk]),
212
+ model_families=np.asarray([it.model_family for it in chunk]),
213
+ augmentations=np.asarray([it.augmentation for it in chunk]),
214
+ )
215
+ cursor = shard_end
216
+ elapsed = time.time() - t0
217
+ rate = cursor / elapsed if elapsed else 0.0
218
+ eta = (n - cursor) / rate if rate else 0.0
219
+ print(
220
+ f" {cursor}/{n} ({rate:.0f} img/s, eta {eta / 60:.1f} min, "
221
+ f"shard saved)"
222
+ )
223
+
224
+ print(f" concatenating shards into {out_path}...")
225
+ _concat_shards(shard_dir, out_path, expected_total=n)
226
+ print(f" wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB)")
227
+
228
+
229
+ def main() -> None:
230
+ parser = argparse.ArgumentParser(
231
+ description=__doc__,
232
+ formatter_class=argparse.RawDescriptionHelpFormatter,
233
+ )
234
+ parser.add_argument("--splits-dir", type=Path, required=True)
235
+ parser.add_argument("--out-dir", type=Path, required=True)
236
+ parser.add_argument("--data-root", type=Path, required=True)
237
+ parser.add_argument(
238
+ "--splits",
239
+ nargs="+",
240
+ default=["train", "val", "test", "heldout", "test_augmented"],
241
+ help="Split names (without .csv) to encode.",
242
+ )
243
+ parser.add_argument(
244
+ "--force",
245
+ action="store_true",
246
+ help="Recompute even if an output .npz already exists.",
247
+ )
248
+ args = parser.parse_args()
249
+
250
+ args.out_dir.mkdir(parents=True, exist_ok=True)
251
+ print(f"Feature schema: {FEATURE_SCHEMA_VERSION} ({N_FEATURES} features)")
252
+ for split in args.splits:
253
+ _encode_split(
254
+ split=split,
255
+ splits_dir=args.splits_dir,
256
+ out_dir=args.out_dir,
257
+ data_root=args.data_root,
258
+ force=args.force,
259
+ )
260
+
261
+
262
+ if __name__ == "__main__":
263
+ main()
scripts/report_frequency_features.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QA report for cached Stage 3B frequency features (S3B-06).
3
+
4
+ Reads the `{split}_freq.npz` files written by
5
+ `scripts/precompute_frequency_features.py` and produces a small JSON+text
6
+ report so we can sanity-check the features BEFORE training a classifier:
7
+
8
+ - per-feature mean/std, split by class (authentic vs ai_generated)
9
+ - per-feature mean by generator (where present)
10
+ - NaN/Inf counts (must be zero — the extractor forces finite, this verifies)
11
+ - extreme-outlier counts (|z| > 8 against the split's own mean/std)
12
+ - a crude per-feature separability score: |mean_ai - mean_real| / pooled_std,
13
+ which flags which features carry class signal at all (a fast pre-check of
14
+ whether the frequency approach has any chance before we train).
15
+
16
+ No new dependencies (numpy only). Read-only over the feature cache.
17
+
18
+ Usage
19
+ -----
20
+ python scripts/report_frequency_features.py \\
21
+ --feat-dir data/freq_features \\
22
+ --splits train val test heldout test_augmented \\
23
+ --report-out data/reports/freq_v1_feature_qa.json
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+
33
+ from deepfake_scanner.detectors.frequency_features import (
34
+ FEATURE_NAMES,
35
+ FEATURE_SCHEMA_VERSION,
36
+ )
37
+
38
+
39
+ def _load(feat_dir: Path, split: str):
40
+ path = feat_dir / f"{split}_freq.npz"
41
+ if not path.exists():
42
+ return None
43
+ with np.load(path, allow_pickle=False) as z:
44
+ return {
45
+ "features": z["features"].astype(np.float64),
46
+ "labels": z["labels"].astype(np.int64),
47
+ "generators": np.asarray([str(g) for g in z["generators"]]),
48
+ "schema": str(z["feature_schema"]),
49
+ }
50
+
51
+
52
+ def _separability(feats: np.ndarray, labels: np.ndarray) -> list[float]:
53
+ """|mean_ai - mean_real| / pooled_std per feature. Higher = more signal."""
54
+ real = feats[labels == 0]
55
+ ai = feats[labels == 1]
56
+ if len(real) == 0 or len(ai) == 0:
57
+ return [0.0] * feats.shape[1]
58
+ pooled = np.sqrt((real.var(axis=0) + ai.var(axis=0)) / 2.0) + 1e-8
59
+ return (np.abs(ai.mean(axis=0) - real.mean(axis=0)) / pooled).tolist()
60
+
61
+
62
+ def main() -> None:
63
+ parser = argparse.ArgumentParser(
64
+ description=__doc__,
65
+ formatter_class=argparse.RawDescriptionHelpFormatter,
66
+ )
67
+ parser.add_argument("--feat-dir", type=Path, required=True)
68
+ parser.add_argument(
69
+ "--splits",
70
+ nargs="+",
71
+ default=["train", "val", "test", "heldout", "test_augmented"],
72
+ )
73
+ parser.add_argument("--report-out", type=Path, default=None)
74
+ args = parser.parse_args()
75
+
76
+ report: dict = {"feature_schema": FEATURE_SCHEMA_VERSION, "splits": {}}
77
+
78
+ for split in args.splits:
79
+ data = _load(args.feat_dir, split)
80
+ if data is None:
81
+ print(f"skip {split} (no feature file)")
82
+ continue
83
+ if data["schema"] != FEATURE_SCHEMA_VERSION:
84
+ raise ValueError(
85
+ f"{split}: schema {data['schema']!r} != {FEATURE_SCHEMA_VERSION!r}"
86
+ )
87
+ x = data["features"]
88
+ y = data["labels"]
89
+ n_nan = int(np.isnan(x).sum())
90
+ n_inf = int(np.isinf(x).sum())
91
+ mean = x.mean(axis=0)
92
+ std = x.std(axis=0) + 1e-8
93
+ z = np.abs((x - mean) / std)
94
+ n_outliers = int((z > 8).sum())
95
+ sep = _separability(x, y)
96
+
97
+ split_rep = {
98
+ "n": int(len(y)),
99
+ "class_counts": {
100
+ "authentic": int((y == 0).sum()),
101
+ "ai_generated": int((y == 1).sum()),
102
+ },
103
+ "nan_count": n_nan,
104
+ "inf_count": n_inf,
105
+ "extreme_outlier_count": n_outliers,
106
+ "per_feature": {
107
+ FEATURE_NAMES[i]: {
108
+ "mean": float(mean[i]),
109
+ "std": float(std[i] - 1e-8),
110
+ "separability": float(sep[i]),
111
+ }
112
+ for i in range(len(FEATURE_NAMES))
113
+ },
114
+ }
115
+ report["splits"][split] = split_rep
116
+
117
+ # Console summary: top separating features on this split.
118
+ order = np.argsort(sep)[::-1]
119
+ top = ", ".join(f"{FEATURE_NAMES[i]}={sep[i]:.2f}" for i in order[:5])
120
+ print(
121
+ f"[{split}] n={len(y)} nan={n_nan} inf={n_inf} "
122
+ f"outliers={n_outliers} | top-sep: {top}"
123
+ )
124
+
125
+ if args.report_out is not None:
126
+ args.report_out.parent.mkdir(parents=True, exist_ok=True)
127
+ with args.report_out.open("w", encoding="utf-8") as fh:
128
+ json.dump(report, fh, indent=2, sort_keys=True)
129
+ print(f"saved report to {args.report_out}")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
scripts/run_stage3a_pipeline.py ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run the full Stage 3A dataset, training, evaluation, and optional publish flow.
3
+
4
+ This is the production counterpart to `scripts/dataset/run_stage3a_smoke.py`.
5
+ It is intentionally orchestration-only: generator scripts, manifest building,
6
+ splitting, augmentation, embedding precompute, training, and evaluation remain
7
+ owned by their focused scripts.
8
+
9
+ Typical GPU-box generation and local training flow:
10
+
11
+ python scripts/run_stage3a_pipeline.py --dry-run
12
+ python scripts/run_stage3a_pipeline.py --stop-after generate
13
+
14
+ # After copying the data directory back to the training machine:
15
+ python scripts/run_stage3a_pipeline.py \\
16
+ --skip-generation \\
17
+ --baseline data/checkpoints/head_v1.pt
18
+
19
+ Publishing is opt-in and gated by the evaluation report:
20
+
21
+ python scripts/run_stage3a_pipeline.py \\
22
+ --skip-generation \\
23
+ --baseline data/checkpoints/head_v1.pt \\
24
+ --publish-if-accepted
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+
38
+ STEPS = [
39
+ "generate",
40
+ "manifest",
41
+ "split",
42
+ "augment",
43
+ "precompute",
44
+ "train",
45
+ "evaluate",
46
+ "recommend",
47
+ "publish",
48
+ ]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CommandStep:
53
+ name: str
54
+ command: list[str]
55
+ log_name: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Thresholds:
60
+ min_test_accuracy: float
61
+ min_heldout_accuracy: float
62
+ min_augmented_accuracy: float
63
+ max_false_positive_rate: float
64
+ max_false_negative_rate: float
65
+ max_accuracy_regression: float
66
+
67
+
68
+ def _repo_root() -> Path:
69
+ return Path(__file__).resolve().parents[1]
70
+
71
+
72
+ def _step_range(start_at: str, stop_after: str) -> set[str]:
73
+ start = STEPS.index(start_at)
74
+ stop = STEPS.index(stop_after)
75
+ if stop < start:
76
+ raise ValueError("--stop-after must be the same as or after --start-at")
77
+ return set(STEPS[start : stop + 1])
78
+
79
+
80
+ def _python(args: argparse.Namespace) -> str:
81
+ return str(args.python)
82
+
83
+
84
+ def _stage3a_paths(args: argparse.Namespace) -> dict[str, Path]:
85
+ data_root = args.data_root
86
+ splits_dir = data_root / "splits"
87
+ return {
88
+ "raw": data_root / "raw",
89
+ "manifest": data_root / "manifest.csv",
90
+ "splits": splits_dir,
91
+ "train_augmented": splits_dir / "train_augmented.csv",
92
+ "test_augmented": splits_dir / "test_augmented.csv",
93
+ "embeddings": data_root / "embeddings",
94
+ "candidate": args.candidate,
95
+ "baseline": args.baseline,
96
+ "train_report": args.train_report,
97
+ "eval_report": args.eval_report,
98
+ "recommendation": args.recommendation_out,
99
+ "logs": args.logs_dir,
100
+ }
101
+
102
+
103
+ def _generation_steps(args: argparse.Namespace) -> list[CommandStep]:
104
+ specs = [
105
+ (
106
+ "flux",
107
+ args.flux_count,
108
+ "scripts/dataset/generate_flux_synthetic.py",
109
+ args.data_root / "raw" / "ai_generated",
110
+ ["--steps", str(args.flux_steps)],
111
+ ),
112
+ (
113
+ "sdxl",
114
+ args.sdxl_count,
115
+ "scripts/dataset/generate_sdxl_synthetic.py",
116
+ args.data_root / "raw" / "ai_generated" / "sdxl",
117
+ ["--steps", str(args.sdxl_steps)],
118
+ ),
119
+ (
120
+ "sd35",
121
+ args.sd35_count,
122
+ "scripts/dataset/generate_sd35_synthetic.py",
123
+ args.data_root / "raw" / "ai_generated" / "sd35-medium",
124
+ ["--steps", str(args.sd35_steps)],
125
+ ),
126
+ (
127
+ "auraflow",
128
+ args.auraflow_count,
129
+ "scripts/dataset/generate_auraflow_synthetic.py",
130
+ args.data_root / "raw" / "ai_generated" / "auraflow-v0.3",
131
+ ["--steps", str(args.auraflow_steps)],
132
+ ),
133
+ ]
134
+
135
+ steps: list[CommandStep] = []
136
+ for name, count, script, out_dir, extra in specs:
137
+ if count <= 0:
138
+ continue
139
+ command = [
140
+ _python(args),
141
+ script,
142
+ "--out",
143
+ str(out_dir),
144
+ "--count",
145
+ str(count),
146
+ "--prompts",
147
+ str(args.prompts),
148
+ "--seed",
149
+ str(args.seed),
150
+ *extra,
151
+ ]
152
+ if name != "flux":
153
+ command.extend(["--data-root", str(args.data_root)])
154
+ steps.append(CommandStep("generate", command, f"generate_{name}.log"))
155
+ return steps
156
+
157
+
158
+ def _pipeline_steps(args: argparse.Namespace) -> list[CommandStep]:
159
+ paths = _stage3a_paths(args)
160
+ holdout_args: list[str] = []
161
+ for generator in args.holdout_generator:
162
+ holdout_args.extend(["--holdout-generator", generator])
163
+
164
+ steps = [
165
+ CommandStep(
166
+ "manifest",
167
+ [
168
+ _python(args),
169
+ "scripts/dataset/build_manifest.py",
170
+ "--input-dir",
171
+ str(paths["raw"]),
172
+ "--out",
173
+ str(paths["manifest"]),
174
+ ],
175
+ "build_manifest.log",
176
+ ),
177
+ CommandStep(
178
+ "split",
179
+ [
180
+ _python(args),
181
+ "scripts/dataset/split.py",
182
+ "--manifest",
183
+ str(paths["manifest"]),
184
+ "--out-dir",
185
+ str(paths["splits"]),
186
+ "--val",
187
+ str(args.val_fraction),
188
+ "--test",
189
+ str(args.test_fraction),
190
+ "--seed",
191
+ str(args.seed),
192
+ "--stratify-by",
193
+ "class-generator",
194
+ *holdout_args,
195
+ ],
196
+ "split.log",
197
+ ),
198
+ CommandStep(
199
+ "augment",
200
+ [
201
+ _python(args),
202
+ "scripts/dataset/augment_images.py",
203
+ "--manifest",
204
+ str(paths["splits"] / "train.csv"),
205
+ "--data-root",
206
+ str(args.data_root),
207
+ "--out-dir",
208
+ str(args.data_root / "augmented" / "train"),
209
+ "--out-manifest",
210
+ str(paths["train_augmented"]),
211
+ "--copies",
212
+ str(args.train_augmentation_copies),
213
+ "--seed",
214
+ str(args.seed),
215
+ ],
216
+ "augment_train.log",
217
+ ),
218
+ CommandStep(
219
+ "augment",
220
+ [
221
+ _python(args),
222
+ "scripts/dataset/augment_images.py",
223
+ "--manifest",
224
+ str(paths["splits"] / "test.csv"),
225
+ "--data-root",
226
+ str(args.data_root),
227
+ "--out-dir",
228
+ str(args.data_root / "augmented" / "test"),
229
+ "--out-manifest",
230
+ str(paths["test_augmented"]),
231
+ "--copies",
232
+ str(args.test_augmentation_copies),
233
+ "--seed",
234
+ str(args.seed + 1),
235
+ ],
236
+ "augment_test.log",
237
+ ),
238
+ CommandStep(
239
+ "precompute",
240
+ [
241
+ _python(args),
242
+ "scripts/precompute_embeddings.py",
243
+ "--splits-dir",
244
+ str(paths["splits"]),
245
+ "--out-dir",
246
+ str(paths["embeddings"]),
247
+ "--data-root",
248
+ str(args.data_root),
249
+ "--batch-size",
250
+ str(args.embedding_batch_size),
251
+ "--device",
252
+ args.embedding_device,
253
+ "--train-augment-manifest",
254
+ str(paths["train_augmented"]),
255
+ "--extra-split",
256
+ f"heldout={paths['splits'] / 'heldout.csv'}",
257
+ "--extra-split",
258
+ f"test_augmented={paths['test_augmented']}",
259
+ # Force rebuild so stale .npz files from a previous (e.g.
260
+ # Stage 2) run don't get reused against new split CSVs.
261
+ # precompute_embeddings.py is idempotent by default —
262
+ # it skips splits whose final .npz already exists, which
263
+ # bit us on 2026-05-30 when Stage 3A train/val/test
264
+ # split CSVs were silently evaluated against stale
265
+ # Stage 2 train/val/test .npz files. Within a single
266
+ # run, shard-based resumability still works (shards are
267
+ # wiped on --force only at start, then built up
268
+ # incrementally).
269
+ "--force",
270
+ ],
271
+ "precompute_embeddings.log",
272
+ ),
273
+ CommandStep(
274
+ "train",
275
+ [
276
+ _python(args),
277
+ "scripts/train_head.py",
278
+ "--emb-dir",
279
+ str(paths["embeddings"]),
280
+ "--out",
281
+ str(paths["candidate"]),
282
+ "--epochs",
283
+ str(args.epochs),
284
+ "--batch-size",
285
+ str(args.train_batch_size),
286
+ "--lr",
287
+ str(args.learning_rate),
288
+ "--weight-decay",
289
+ str(args.weight_decay),
290
+ "--patience",
291
+ str(args.patience),
292
+ "--seed",
293
+ str(args.seed),
294
+ "--eval-split",
295
+ "heldout",
296
+ "--eval-split",
297
+ "test_augmented",
298
+ "--report-out",
299
+ str(paths["train_report"]),
300
+ ],
301
+ "train_head.log",
302
+ ),
303
+ CommandStep(
304
+ "evaluate",
305
+ [
306
+ _python(args),
307
+ "scripts/evaluate_head.py",
308
+ "--emb-dir",
309
+ str(paths["embeddings"]),
310
+ "--candidate",
311
+ str(paths["candidate"]),
312
+ "--baseline",
313
+ str(paths["baseline"]),
314
+ "--split",
315
+ "test",
316
+ "--split",
317
+ "heldout",
318
+ "--split",
319
+ "test_augmented",
320
+ "--report-out",
321
+ str(paths["eval_report"]),
322
+ "--uncertainty-threshold",
323
+ str(args.uncertainty_threshold),
324
+ ],
325
+ "evaluate_head.log",
326
+ ),
327
+ ]
328
+ return steps
329
+
330
+
331
+ def _planned_steps(args: argparse.Namespace) -> list[CommandStep]:
332
+ selected = _step_range(args.start_at, args.stop_after)
333
+ steps: list[CommandStep] = []
334
+ if "generate" in selected and not args.skip_generation:
335
+ steps.extend(_generation_steps(args))
336
+ steps.extend(step for step in _pipeline_steps(args) if step.name in selected)
337
+ return steps
338
+
339
+
340
+ def _has_cuda() -> bool:
341
+ try:
342
+ import torch
343
+ except Exception:
344
+ return False
345
+ return bool(torch.cuda.is_available())
346
+
347
+
348
+ def _validate_before_run(args: argparse.Namespace, selected: set[str]) -> None:
349
+ paths = _stage3a_paths(args)
350
+ if (
351
+ "generate" in selected
352
+ and not args.skip_generation
353
+ and not args.dry_run
354
+ and not _has_cuda()
355
+ ):
356
+ raise RuntimeError(
357
+ "CUDA is not available. Run generation on a GPU box, or pass "
358
+ "--skip-generation after copying/generated data into --data-root."
359
+ )
360
+ if "generate" in selected and not args.skip_generation and not args.prompts.exists():
361
+ raise FileNotFoundError(f"Prompts file does not exist: {args.prompts}")
362
+ if "evaluate" in selected and not args.dry_run and not paths["baseline"].exists():
363
+ raise FileNotFoundError(
364
+ f"Baseline checkpoint does not exist: {paths['baseline']}. "
365
+ "Pass --baseline pointing at the Stage 2 head."
366
+ )
367
+
368
+
369
+ def _run_step(repo_root: Path, step: CommandStep, log_dir: Path, dry_run: bool) -> None:
370
+ printable = " ".join(step.command)
371
+ print(f"\n[{step.name}] $ {printable}")
372
+ if dry_run:
373
+ return
374
+
375
+ log_dir.mkdir(parents=True, exist_ok=True)
376
+ log_path = log_dir / step.log_name
377
+ with log_path.open("w", encoding="utf-8") as log:
378
+ log.write(f"$ {printable}\n\n")
379
+ result = subprocess.run(
380
+ step.command,
381
+ cwd=repo_root,
382
+ text=True,
383
+ stdout=subprocess.PIPE,
384
+ stderr=subprocess.STDOUT,
385
+ )
386
+ log.write(result.stdout)
387
+ print(f" log: {log_path}")
388
+ if result.returncode != 0:
389
+ raise subprocess.CalledProcessError(result.returncode, step.command)
390
+
391
+
392
+ def _metric(report: dict[str, Any], split: str, key: str) -> float:
393
+ return float(report["candidate"]["splits"][split]["overall"][key])
394
+
395
+
396
+ def build_recommendation(
397
+ report: dict[str, Any],
398
+ thresholds: Thresholds,
399
+ ) -> dict[str, Any]:
400
+ comparison = report.get("comparison") or {}
401
+ checks = {
402
+ "test_accuracy": (
403
+ _metric(report, "test", "argmax_accuracy"),
404
+ ">=",
405
+ thresholds.min_test_accuracy,
406
+ ),
407
+ "heldout_accuracy": (
408
+ _metric(report, "heldout", "argmax_accuracy"),
409
+ ">=",
410
+ thresholds.min_heldout_accuracy,
411
+ ),
412
+ "augmented_accuracy": (
413
+ _metric(report, "test_augmented", "argmax_accuracy"),
414
+ ">=",
415
+ thresholds.min_augmented_accuracy,
416
+ ),
417
+ "test_false_positive_rate": (
418
+ _metric(report, "test", "false_positive_rate"),
419
+ "<=",
420
+ thresholds.max_false_positive_rate,
421
+ ),
422
+ "test_false_negative_rate": (
423
+ _metric(report, "test", "false_negative_rate"),
424
+ "<=",
425
+ thresholds.max_false_negative_rate,
426
+ ),
427
+ }
428
+ for split, deltas in comparison.items():
429
+ checks[f"{split}_accuracy_delta"] = (
430
+ float(deltas["argmax_accuracy_delta"]),
431
+ ">=",
432
+ -thresholds.max_accuracy_regression,
433
+ )
434
+
435
+ failures: list[dict[str, Any]] = []
436
+ for name, (actual, op, expected) in checks.items():
437
+ passed = actual >= expected if op == ">=" else actual <= expected
438
+ if not passed:
439
+ failures.append(
440
+ {
441
+ "check": name,
442
+ "actual": actual,
443
+ "operator": op,
444
+ "expected": expected,
445
+ }
446
+ )
447
+
448
+ return {
449
+ "accepted": not failures,
450
+ "failures": failures,
451
+ "checks": {
452
+ name: {"actual": actual, "operator": op, "expected": expected}
453
+ for name, (actual, op, expected) in checks.items()
454
+ },
455
+ }
456
+
457
+
458
+ def _write_recommendation(args: argparse.Namespace) -> dict[str, Any]:
459
+ paths = _stage3a_paths(args)
460
+ with paths["eval_report"].open(encoding="utf-8") as fh:
461
+ report = json.load(fh)
462
+
463
+ recommendation = build_recommendation(
464
+ report,
465
+ Thresholds(
466
+ min_test_accuracy=args.min_test_accuracy,
467
+ min_heldout_accuracy=args.min_heldout_accuracy,
468
+ min_augmented_accuracy=args.min_augmented_accuracy,
469
+ max_false_positive_rate=args.max_false_positive_rate,
470
+ max_false_negative_rate=args.max_false_negative_rate,
471
+ max_accuracy_regression=args.max_accuracy_regression,
472
+ ),
473
+ )
474
+ recommendation["candidate"] = str(paths["candidate"])
475
+ recommendation["baseline"] = str(paths["baseline"])
476
+ recommendation["eval_report"] = str(paths["eval_report"])
477
+ recommendation["model_version"] = args.model_version
478
+
479
+ paths["recommendation"].parent.mkdir(parents=True, exist_ok=True)
480
+ with paths["recommendation"].open("w", encoding="utf-8") as fh:
481
+ json.dump(recommendation, fh, indent=2, sort_keys=True)
482
+
483
+ verdict = "ACCEPT" if recommendation["accepted"] else "DO NOT SHIP"
484
+ print(f"\n[recommend] {verdict}: {paths['recommendation']}")
485
+ for failure in recommendation["failures"]:
486
+ print(
487
+ " failed {check}: {actual:.4f} {operator} {expected:.4f}".format(
488
+ **failure
489
+ )
490
+ )
491
+ return recommendation
492
+
493
+
494
+ def _publish(args: argparse.Namespace, recommendation: dict[str, Any] | None) -> None:
495
+ if not args.publish_if_accepted:
496
+ return
497
+ if recommendation is None:
498
+ recommendation = _write_recommendation(args)
499
+ if not recommendation["accepted"]:
500
+ raise RuntimeError("Refusing to publish because the ship gate did not pass.")
501
+ if not os.getenv("HF_TOKEN"):
502
+ raise RuntimeError("HF_TOKEN is not set; cannot publish to a private HF repo.")
503
+
504
+ from huggingface_hub import upload_file
505
+
506
+ paths = _stage3a_paths(args)
507
+ upload_file(
508
+ path_or_fileobj=str(paths["candidate"]),
509
+ path_in_repo=args.hf_filename,
510
+ repo_id=args.hf_repo,
511
+ repo_type="model",
512
+ )
513
+ print(f"\n[publish] uploaded {paths['candidate']} to {args.hf_repo}/{args.hf_filename}")
514
+ print("Runtime env after publish:")
515
+ print(f" MODEL_VERSION={args.model_version}")
516
+ print(f" HEAD_CHECKPOINT_HF_REPO={args.hf_repo}")
517
+ print(f" HEAD_CHECKPOINT_HF_FILENAME={args.hf_filename}")
518
+
519
+
520
+ def _parse_args() -> argparse.Namespace:
521
+ parser = argparse.ArgumentParser(
522
+ description=__doc__,
523
+ formatter_class=argparse.RawDescriptionHelpFormatter,
524
+ )
525
+ parser.add_argument("--data-root", type=Path, default=Path("data"))
526
+ parser.add_argument(
527
+ "--prompts",
528
+ type=Path,
529
+ default=Path("scripts/dataset/prompts.txt"),
530
+ )
531
+ parser.add_argument("--python", type=Path, default=Path(sys.executable))
532
+ parser.add_argument("--logs-dir", type=Path, default=Path("data/logs/stage3a"))
533
+ parser.add_argument("--dry-run", action="store_true")
534
+ parser.add_argument("--skip-generation", action="store_true")
535
+ parser.add_argument("--start-at", choices=STEPS, default="generate")
536
+ parser.add_argument("--stop-after", choices=STEPS, default="recommend")
537
+ parser.add_argument("--seed", type=int, default=0)
538
+
539
+ parser.add_argument("--flux-count", type=int, default=0)
540
+ parser.add_argument("--sdxl-count", type=int, default=20_000)
541
+ parser.add_argument("--sd35-count", type=int, default=20_000)
542
+ parser.add_argument("--auraflow-count", type=int, default=20_000)
543
+ parser.add_argument("--flux-steps", type=int, default=4)
544
+ parser.add_argument("--sdxl-steps", type=int, default=30)
545
+ parser.add_argument("--sd35-steps", type=int, default=28)
546
+ parser.add_argument("--auraflow-steps", type=int, default=30)
547
+
548
+ parser.add_argument("--val-fraction", type=float, default=0.1)
549
+ parser.add_argument("--test-fraction", type=float, default=0.1)
550
+ parser.add_argument("--holdout-generator", action="append", default=None)
551
+ parser.add_argument("--train-augmentation-copies", type=int, default=1)
552
+ parser.add_argument("--test-augmentation-copies", type=int, default=1)
553
+
554
+ parser.add_argument("--embedding-device", choices=["cpu", "cuda"], default="cpu")
555
+ parser.add_argument("--embedding-batch-size", type=int, default=16)
556
+ parser.add_argument(
557
+ "--candidate",
558
+ type=Path,
559
+ default=Path("data/checkpoints/head_v3a.pt"),
560
+ )
561
+ parser.add_argument(
562
+ "--baseline",
563
+ type=Path,
564
+ default=Path("data/checkpoints/head_v1.pt"),
565
+ )
566
+ parser.add_argument(
567
+ "--train-report",
568
+ type=Path,
569
+ default=Path("data/reports/head_v3a_train.json"),
570
+ )
571
+ parser.add_argument(
572
+ "--eval-report",
573
+ type=Path,
574
+ default=Path("data/reports/head_v3a_eval.json"),
575
+ )
576
+ parser.add_argument(
577
+ "--recommendation-out",
578
+ type=Path,
579
+ default=Path("data/reports/head_v3a_recommendation.json"),
580
+ )
581
+ parser.add_argument("--epochs", type=int, default=30)
582
+ parser.add_argument("--train-batch-size", type=int, default=512)
583
+ parser.add_argument("--learning-rate", type=float, default=1e-3)
584
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
585
+ parser.add_argument("--patience", type=int, default=5)
586
+ parser.add_argument("--uncertainty-threshold", type=float, default=0.6)
587
+
588
+ parser.add_argument("--min-test-accuracy", type=float, default=0.90)
589
+ parser.add_argument("--min-heldout-accuracy", type=float, default=0.80)
590
+ parser.add_argument("--min-augmented-accuracy", type=float, default=0.85)
591
+ parser.add_argument("--max-false-positive-rate", type=float, default=0.05)
592
+ parser.add_argument("--max-false-negative-rate", type=float, default=0.12)
593
+ parser.add_argument("--max-accuracy-regression", type=float, default=0.01)
594
+
595
+ parser.add_argument("--publish-if-accepted", action="store_true")
596
+ parser.add_argument("--hf-repo", default="Veridicate/scanner-head-v1")
597
+ parser.add_argument("--hf-filename", default="head_v3a.pt")
598
+ parser.add_argument("--model-version", default="v0.4.0-stage3a")
599
+ args = parser.parse_args()
600
+ if args.holdout_generator is None:
601
+ args.holdout_generator = ["sdxl"]
602
+ return args
603
+
604
+
605
+ def main() -> None:
606
+ args = _parse_args()
607
+ repo_root = _repo_root()
608
+ selected = _step_range(args.start_at, args.stop_after)
609
+ _validate_before_run(args, selected)
610
+
611
+ steps = _planned_steps(args)
612
+ print("Stage 3A pipeline plan:")
613
+ for step in steps:
614
+ print(f" - {step.name}: {' '.join(step.command)}")
615
+
616
+ for step in steps:
617
+ _run_step(repo_root, step, args.logs_dir, args.dry_run)
618
+
619
+ recommendation: dict[str, Any] | None = None
620
+ if "recommend" in selected and not args.dry_run:
621
+ recommendation = _write_recommendation(args)
622
+ if "publish" in selected and not args.dry_run:
623
+ _publish(args, recommendation)
624
+
625
+
626
+ if __name__ == "__main__":
627
+ main()
scripts/train_frequency_head.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train the Stage 3B frequency classifier on cached freq-v1 features.
3
+
4
+ Mirrors `scripts/train_head.py` in idiom (torch, seeded, early stop, JSON
5
+ report) but operates on the 16-dim frequency features from
6
+ `scripts/precompute_frequency_features.py` instead of CLIP embeddings.
7
+
8
+ Model
9
+ -----
10
+ A standardised linear logistic-regression head (the "classical first model"
11
+ required by S3B-07): feature standardisation (mean/std from train) baked into
12
+ the saved checkpoint, then `Linear(16 -> 2)`. Interpretable, tiny, no new
13
+ deps (torch only). A small MLP is available via --hidden if the linear model
14
+ underperforms, but linear is the default per the tracker's guidance.
15
+
16
+ Checkpoint format (torch.save dict)
17
+ -----------------------------------
18
+ - feature_schema: "freq-v1" (must match the features at inference)
19
+ - feature_mean: float32 (16,)
20
+ - feature_std: float32 (16,)
21
+ - state_dict: the classifier head weights
22
+ - hidden: int (0 = linear logreg)
23
+ - classes: ["authentic", "ai_generated"]
24
+
25
+ Usage
26
+ -----
27
+ python scripts/train_frequency_head.py \\
28
+ --feat-dir data/freq_features \\
29
+ --out data/checkpoints/freq_v1.pt \\
30
+ --report-out data/reports/freq_v1_train.json
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import time
37
+ from pathlib import Path
38
+
39
+ import numpy as np
40
+
41
+ FEATURE_SCHEMA_VERSION = "freq-v1"
42
+ _CLASSES = ["authentic", "ai_generated"]
43
+
44
+
45
+ def _load_feat(feat_dir: Path, split: str):
46
+ path = feat_dir / f"{split}_freq.npz"
47
+ with np.load(path, allow_pickle=False) as z:
48
+ schema = str(z["feature_schema"])
49
+ if schema != FEATURE_SCHEMA_VERSION:
50
+ raise ValueError(
51
+ f"{path}: feature_schema {schema!r} != {FEATURE_SCHEMA_VERSION!r}"
52
+ )
53
+ return {
54
+ "features": z["features"].astype(np.float32),
55
+ "labels": z["labels"].astype(np.int64),
56
+ "generators": z["generators"],
57
+ "sources": z["sources"],
58
+ }
59
+
60
+
61
+ def _rates(pred: np.ndarray, y: np.ndarray) -> dict:
62
+ tp = int(((pred == 1) & (y == 1)).sum())
63
+ tn = int(((pred == 0) & (y == 0)).sum())
64
+ fp = int(((pred == 1) & (y == 0)).sum())
65
+ fn = int(((pred == 0) & (y == 1)).sum())
66
+ return {
67
+ "n": int(len(y)),
68
+ "accuracy": float((pred == y).mean()) if len(y) else 0.0,
69
+ "confusion": {"tp": tp, "tn": tn, "fp": fp, "fn": fn},
70
+ "false_positive_rate": float(fp / (fp + tn)) if (fp + tn) else 0.0,
71
+ "false_negative_rate": float(fn / (fn + tp)) if (fn + tp) else 0.0,
72
+ }
73
+
74
+
75
+ def main() -> None:
76
+ parser = argparse.ArgumentParser(
77
+ description=__doc__,
78
+ formatter_class=argparse.RawDescriptionHelpFormatter,
79
+ )
80
+ parser.add_argument("--feat-dir", type=Path, required=True)
81
+ parser.add_argument("--out", type=Path, required=True)
82
+ parser.add_argument("--report-out", type=Path, default=None)
83
+ parser.add_argument("--epochs", type=int, default=200)
84
+ parser.add_argument("--batch-size", type=int, default=512)
85
+ parser.add_argument("--lr", type=float, default=1e-2)
86
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
87
+ parser.add_argument("--patience", type=int, default=15)
88
+ parser.add_argument("--seed", type=int, default=0)
89
+ parser.add_argument(
90
+ "--hidden",
91
+ type=int,
92
+ default=0,
93
+ help="Hidden units (0 = linear logistic regression).",
94
+ )
95
+ parser.add_argument(
96
+ "--eval-split",
97
+ action="append",
98
+ default=[],
99
+ help="Extra split(s) to evaluate, e.g. --eval-split heldout.",
100
+ )
101
+ args = parser.parse_args()
102
+
103
+ import torch
104
+ import torch.nn as nn
105
+
106
+ torch.manual_seed(args.seed)
107
+ np.random.seed(args.seed)
108
+
109
+ train = _load_feat(args.feat_dir, "train")
110
+ val = _load_feat(args.feat_dir, "val")
111
+ test = _load_feat(args.feat_dir, "test")
112
+ extra = {name: _load_feat(args.feat_dir, name) for name in args.eval_split}
113
+
114
+ # Standardisation from train only (baked into the checkpoint).
115
+ mean = train["features"].mean(axis=0)
116
+ std = train["features"].std(axis=0) + 1e-8
117
+
118
+ def prep(split):
119
+ x = (split["features"] - mean) / std
120
+ return torch.from_numpy(x.astype(np.float32)), torch.from_numpy(
121
+ split["labels"]
122
+ )
123
+
124
+ x_tr, y_tr = prep(train)
125
+ x_val, y_val = prep(val)
126
+
127
+ n_feat = x_tr.shape[1]
128
+ if args.hidden > 0:
129
+ head = nn.Sequential(
130
+ nn.Linear(n_feat, args.hidden),
131
+ nn.ReLU(),
132
+ nn.Linear(args.hidden, 2),
133
+ )
134
+ else:
135
+ head = nn.Linear(n_feat, 2)
136
+
137
+ # Class weights to counter the authentic/ai imbalance in the splits.
138
+ counts = np.bincount(train["labels"], minlength=2).astype(np.float64)
139
+ w = counts.sum() / (2.0 * np.maximum(counts, 1.0))
140
+ class_weight = torch.tensor(w, dtype=torch.float32)
141
+ loss_fn = nn.CrossEntropyLoss(weight=class_weight)
142
+ opt = torch.optim.Adam(
143
+ head.parameters(), lr=args.lr, weight_decay=args.weight_decay
144
+ )
145
+
146
+ print(
147
+ f"train={tuple(x_tr.shape)} val={tuple(x_val.shape)} "
148
+ f"class_counts={counts.tolist()} hidden={args.hidden}"
149
+ )
150
+
151
+ best_val = float("inf")
152
+ best_state = None
153
+ no_improve = 0
154
+ for epoch in range(1, args.epochs + 1):
155
+ head.train()
156
+ perm = torch.randperm(len(x_tr))
157
+ losses = []
158
+ for i in range(0, len(x_tr), args.batch_size):
159
+ idx = perm[i : i + args.batch_size]
160
+ opt.zero_grad()
161
+ loss = loss_fn(head(x_tr[idx]), y_tr[idx])
162
+ loss.backward()
163
+ opt.step()
164
+ losses.append(loss.item())
165
+ head.eval()
166
+ with torch.no_grad():
167
+ vloss = loss_fn(head(x_val), y_val).item()
168
+ vacc = float((head(x_val).argmax(-1) == y_val).float().mean())
169
+ if epoch % 20 == 0 or epoch == 1:
170
+ print(
171
+ f" epoch {epoch:3d} train_loss={np.mean(losses):.4f} "
172
+ f"val_loss={vloss:.4f} val_acc={vacc:.4f}"
173
+ )
174
+ if vloss < best_val - 1e-4:
175
+ best_val = vloss
176
+ best_state = {k: v.clone() for k, v in head.state_dict().items()}
177
+ no_improve = 0
178
+ else:
179
+ no_improve += 1
180
+ if no_improve >= args.patience:
181
+ print(f" early stop at epoch {epoch}")
182
+ break
183
+
184
+ if best_state is not None:
185
+ head.load_state_dict(best_state)
186
+ head.eval()
187
+
188
+ def evaluate(split) -> dict:
189
+ x = (split["features"] - mean) / std
190
+ with torch.no_grad():
191
+ logits = head(torch.from_numpy(x.astype(np.float32)))
192
+ pred = logits.argmax(-1).numpy()
193
+ y = split["labels"]
194
+ rep = _rates(pred, y)
195
+ # Per-generator breakdown over AI rows.
196
+ gens = np.asarray([str(g) for g in split["generators"]])
197
+ by_gen = {}
198
+ for g in sorted(set(gens) - {""}):
199
+ m = gens == g
200
+ by_gen[g] = _rates(pred[m], y[m])
201
+ if by_gen:
202
+ rep["by_generator"] = by_gen
203
+ return rep
204
+
205
+ report = {
206
+ "feature_schema": FEATURE_SCHEMA_VERSION,
207
+ "hidden": args.hidden,
208
+ "splits": {
209
+ "val": evaluate(val),
210
+ "test": evaluate(test),
211
+ **{name: evaluate(s) for name, s in extra.items()},
212
+ },
213
+ }
214
+ for split_name in ("test", *args.eval_split):
215
+ r = report["splits"][split_name]
216
+ print(
217
+ f" [{split_name}] acc={r['accuracy']:.4f} "
218
+ f"fpr={r['false_positive_rate']:.4f} fnr={r['false_negative_rate']:.4f}"
219
+ )
220
+
221
+ args.out.parent.mkdir(parents=True, exist_ok=True)
222
+ torch.save(
223
+ {
224
+ "feature_schema": FEATURE_SCHEMA_VERSION,
225
+ "feature_mean": mean.astype(np.float32),
226
+ "feature_std": std.astype(np.float32),
227
+ "state_dict": head.state_dict(),
228
+ "hidden": args.hidden,
229
+ "classes": _CLASSES,
230
+ },
231
+ args.out,
232
+ )
233
+ print(f"\nsaved frequency head to {args.out}")
234
+
235
+ if args.report_out is not None:
236
+ args.report_out.parent.mkdir(parents=True, exist_ok=True)
237
+ with args.report_out.open("w", encoding="utf-8") as fh:
238
+ json.dump(report, fh, indent=2, sort_keys=True)
239
+ print(f"saved report to {args.report_out}")
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
scripts/train_head.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train the CLIP classifier head on cached embeddings.
3
+
4
+ Loads precomputed embeddings (from `scripts/precompute_embeddings.py`),
5
+ trains the head architecture from `clip_classifier.py`, and saves a
6
+ state_dict that can be loaded by `ClipClassifier.load_head_weights()`.
7
+
8
+ The head is intentionally tiny (~130k params), so training is laptop-fast
9
+ even on CPU — each epoch over 100k cached embeddings takes a few seconds.
10
+ That makes it practical to iterate on hyperparameters without re-encoding
11
+ the dataset.
12
+
13
+ Usage
14
+ -----
15
+ python scripts/train_head.py \\
16
+ --emb-dir data/embeddings \\
17
+ --out data/checkpoints/head_v1.pt \\
18
+ --epochs 30
19
+
20
+ Save a Stage 3A metrics report:
21
+
22
+ python scripts/train_head.py \\
23
+ --emb-dir data/embeddings \\
24
+ --out data/checkpoints/head_v3a.pt \\
25
+ --report-out data/reports/head_v3a_metrics.json \\
26
+ --eval-split test_augmented
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import time
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import numpy as np
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SplitEmbeddings:
42
+ x: Any
43
+ y: Any
44
+ paths: np.ndarray
45
+ sources: np.ndarray
46
+ generators: np.ndarray
47
+ model_families: np.ndarray
48
+ augmentations: np.ndarray
49
+ original_paths: np.ndarray
50
+
51
+
52
+ # Mirror src/deepfake_scanner/detectors/clip_classifier.py:78-83 exactly.
53
+ # If that architecture changes, this MUST change too — checkpoints won't
54
+ # load otherwise.
55
+ def _build_head():
56
+ import torch.nn as nn
57
+
58
+ return nn.Sequential(
59
+ nn.Linear(512, 256),
60
+ nn.ReLU(),
61
+ nn.Dropout(0.2),
62
+ nn.Linear(256, 2),
63
+ )
64
+
65
+
66
+ def _load_split(emb_dir: Path, name: str):
67
+ import torch
68
+
69
+ with np.load(emb_dir / f"{name}.npz") as z:
70
+ embeddings = z["embeddings"].copy()
71
+ labels = z["labels"].copy()
72
+ n = len(labels)
73
+ x = torch.from_numpy(embeddings).float()
74
+ y = torch.from_numpy(labels).long()
75
+ return SplitEmbeddings(
76
+ x=x,
77
+ y=y,
78
+ paths=_optional_str_array(z, "paths", n),
79
+ sources=_optional_str_array(z, "sources", n),
80
+ generators=_optional_str_array(z, "generators", n),
81
+ model_families=_optional_str_array(z, "model_families", n),
82
+ augmentations=_optional_str_array(z, "augmentations", n),
83
+ original_paths=_optional_str_array(z, "original_paths", n),
84
+ )
85
+
86
+
87
+ def _optional_str_array(z, key: str, n: int) -> np.ndarray:
88
+ if key not in z:
89
+ return np.asarray([""] * n)
90
+ values = np.asarray(z[key]).astype(str)
91
+ if len(values) != n:
92
+ raise ValueError(f"{key} has {len(values)} rows but labels has {n}")
93
+ return values
94
+
95
+
96
+ def _accuracy(logits, y) -> float:
97
+ return float((logits.argmax(dim=-1) == y).float().mean().item())
98
+
99
+
100
+ def _confusion(logits, y) -> dict:
101
+ pred = logits.argmax(dim=-1)
102
+ tp = int(((pred == 1) & (y == 1)).sum().item())
103
+ tn = int(((pred == 0) & (y == 0)).sum().item())
104
+ fp = int(((pred == 1) & (y == 0)).sum().item())
105
+ fn = int(((pred == 0) & (y == 1)).sum().item())
106
+ return {"tp": tp, "tn": tn, "fp": fp, "fn": fn}
107
+
108
+
109
+ def _rate(num: int, den: int) -> float:
110
+ return float(num / den) if den else 0.0
111
+
112
+
113
+ def _metrics(logits, y, loss_fn=None) -> dict[str, Any]:
114
+ cm = _confusion(logits, y)
115
+ result: dict[str, Any] = {
116
+ "n": int(len(y)),
117
+ "accuracy": _accuracy(logits, y),
118
+ "confusion": cm,
119
+ "false_positive_rate": _rate(cm["fp"], cm["fp"] + cm["tn"]),
120
+ "false_negative_rate": _rate(cm["fn"], cm["fn"] + cm["tp"]),
121
+ }
122
+ if loss_fn is not None:
123
+ result["loss"] = float(loss_fn(logits, y).item())
124
+ return result
125
+
126
+
127
+ def _group_metrics(logits, y, values: np.ndarray, loss_fn=None) -> dict[str, dict]:
128
+ import torch
129
+
130
+ result: dict[str, dict] = {}
131
+ labels = sorted({str(v) for v in values if str(v)})
132
+ for label in labels:
133
+ idx = [i for i, value in enumerate(values) if str(value) == label]
134
+ if not idx:
135
+ continue
136
+ tensor_idx = torch.as_tensor(idx, dtype=torch.long, device=logits.device)
137
+ result[label] = _metrics(
138
+ logits.index_select(0, tensor_idx),
139
+ y.index_select(0, tensor_idx),
140
+ loss_fn,
141
+ )
142
+ return result
143
+
144
+
145
+ def _augmentation_values(split: SplitEmbeddings) -> np.ndarray:
146
+ values = np.asarray(split.augmentations).astype(str)
147
+ return np.asarray([value if value else "clean" for value in values])
148
+
149
+
150
+ def _generator_values(split: SplitEmbeddings) -> np.ndarray:
151
+ values: list[str] = []
152
+ labels = split.y.cpu().numpy()
153
+ for i, label in enumerate(labels):
154
+ if label != 1:
155
+ values.append("")
156
+ continue
157
+ generator = split.generators[i] or split.sources[i]
158
+ values.append(str(generator))
159
+ return np.asarray(values)
160
+
161
+
162
+ def _split_report(name: str, split: SplitEmbeddings, logits, loss_fn) -> dict[str, Any]:
163
+ report: dict[str, Any] = {"overall": _metrics(logits, split.y, loss_fn)}
164
+
165
+ by_source = _group_metrics(logits, split.y, split.sources, loss_fn)
166
+ if by_source:
167
+ report["by_source"] = by_source
168
+
169
+ by_generator = _group_metrics(logits, split.y, _generator_values(split), loss_fn)
170
+ if by_generator:
171
+ report["by_generator"] = by_generator
172
+
173
+ by_model_family = _group_metrics(logits, split.y, split.model_families, loss_fn)
174
+ if by_model_family:
175
+ report["by_model_family"] = by_model_family
176
+
177
+ if any(str(value) for value in split.augmentations):
178
+ report["by_augmentation"] = _group_metrics(
179
+ logits,
180
+ split.y,
181
+ _augmentation_values(split),
182
+ loss_fn,
183
+ )
184
+
185
+ print(
186
+ f"\n{name}: loss={report['overall']['loss']:.4f} "
187
+ f"acc={report['overall']['accuracy']:.4f}"
188
+ )
189
+ cm = report["overall"]["confusion"]
190
+ print(
191
+ f" confusion matrix: tp={cm['tp']} tn={cm['tn']} "
192
+ f"fp={cm['fp']} fn={cm['fn']}"
193
+ )
194
+ for group_name in [
195
+ "by_generator",
196
+ "by_source",
197
+ "by_model_family",
198
+ "by_augmentation",
199
+ ]:
200
+ if group_name not in report:
201
+ continue
202
+ print(f" {group_name}:")
203
+ for label, metrics in report[group_name].items():
204
+ print(
205
+ f" {label}: n={metrics['n']} "
206
+ f"acc={metrics['accuracy']:.4f} "
207
+ f"fpr={metrics['false_positive_rate']:.4f} "
208
+ f"fnr={metrics['false_negative_rate']:.4f}"
209
+ )
210
+
211
+ return report
212
+
213
+
214
+ def main() -> None:
215
+ parser = argparse.ArgumentParser(
216
+ description=__doc__,
217
+ formatter_class=argparse.RawDescriptionHelpFormatter,
218
+ )
219
+ parser.add_argument("--emb-dir", type=Path, required=True)
220
+ parser.add_argument("--out", type=Path, required=True)
221
+ parser.add_argument("--epochs", type=int, default=30)
222
+ parser.add_argument("--batch-size", type=int, default=512)
223
+ parser.add_argument("--lr", type=float, default=1e-3)
224
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
225
+ parser.add_argument("--patience", type=int, default=5,
226
+ help="Early-stop after N epochs without val-loss improvement")
227
+ parser.add_argument("--seed", type=int, default=0)
228
+ parser.add_argument(
229
+ "--eval-split",
230
+ action="append",
231
+ default=[],
232
+ help=(
233
+ "Additional embedding split name to evaluate, without .npz. "
234
+ "Example: --eval-split test_augmented"
235
+ ),
236
+ )
237
+ parser.add_argument(
238
+ "--report-out",
239
+ type=Path,
240
+ default=None,
241
+ help="Optional JSON path for overall and grouped metrics",
242
+ )
243
+ args = parser.parse_args()
244
+
245
+ import torch
246
+ import torch.nn as nn
247
+
248
+ torch.manual_seed(args.seed)
249
+ np.random.seed(args.seed)
250
+
251
+ print(f"Loading embeddings from {args.emb_dir}...")
252
+ train = _load_split(args.emb_dir, "train")
253
+ val = _load_split(args.emb_dir, "val")
254
+ test = _load_split(args.emb_dir, "test")
255
+ extra_evals = {
256
+ name: _load_split(args.emb_dir, name)
257
+ for name in args.eval_split
258
+ }
259
+ x_train, y_train = train.x, train.y
260
+ x_val, y_val = val.x, val.y
261
+ print(f" train: {train.x.shape}, val: {val.x.shape}, test: {test.x.shape}")
262
+ for name, split in extra_evals.items():
263
+ print(f" {name}: {split.x.shape}")
264
+
265
+ cls_counts = torch.bincount(y_train, minlength=2)
266
+ print(f" train class counts: authentic={cls_counts[0].item()} "
267
+ f"ai_generated={cls_counts[1].item()}")
268
+
269
+ head = _build_head()
270
+ optimizer = torch.optim.Adam(
271
+ head.parameters(), lr=args.lr, weight_decay=args.weight_decay,
272
+ )
273
+ loss_fn = nn.CrossEntropyLoss()
274
+
275
+ best_val_loss = float("inf")
276
+ best_state = None
277
+ epochs_no_improve = 0
278
+
279
+ for epoch in range(1, args.epochs + 1):
280
+ t0 = time.time()
281
+ head.train()
282
+ perm = torch.randperm(len(x_train))
283
+ train_losses: list[float] = []
284
+ for i in range(0, len(x_train), args.batch_size):
285
+ idx = perm[i : i + args.batch_size]
286
+ xb, yb = x_train[idx], y_train[idx]
287
+ optimizer.zero_grad()
288
+ logits = head(xb)
289
+ loss = loss_fn(logits, yb)
290
+ loss.backward()
291
+ optimizer.step()
292
+ train_losses.append(loss.item())
293
+
294
+ head.eval()
295
+ with torch.no_grad():
296
+ val_logits = head(x_val)
297
+ val_loss = loss_fn(val_logits, y_val).item()
298
+ val_acc = _accuracy(val_logits, y_val)
299
+
300
+ train_loss = float(np.mean(train_losses))
301
+ dt = time.time() - t0
302
+ print(
303
+ f" epoch {epoch:3d} train_loss={train_loss:.4f} "
304
+ f"val_loss={val_loss:.4f} val_acc={val_acc:.4f} ({dt:.1f}s)"
305
+ )
306
+
307
+ if val_loss < best_val_loss - 1e-4:
308
+ best_val_loss = val_loss
309
+ best_state = {k: v.clone() for k, v in head.state_dict().items()}
310
+ epochs_no_improve = 0
311
+ else:
312
+ epochs_no_improve += 1
313
+ if epochs_no_improve >= args.patience:
314
+ print(
315
+ f" early stop at epoch {epoch} "
316
+ f"(no val-loss improvement for {args.patience} epochs)"
317
+ )
318
+ break
319
+
320
+ if best_state is not None:
321
+ head.load_state_dict(best_state)
322
+
323
+ head.eval()
324
+ with torch.no_grad():
325
+ validation_logits = head(val.x)
326
+ eval_logits = {
327
+ "test": head(test.x),
328
+ **{name: head(split.x) for name, split in extra_evals.items()},
329
+ }
330
+
331
+ report: dict[str, Any] = {
332
+ "train": {
333
+ "n": int(len(train.y)),
334
+ "class_counts": {
335
+ "authentic": int(cls_counts[0].item()),
336
+ "ai_generated": int(cls_counts[1].item()),
337
+ },
338
+ },
339
+ "validation": _split_report("validation", val, validation_logits, loss_fn),
340
+ "evaluation": {
341
+ "test": _split_report("test", test, eval_logits["test"], loss_fn),
342
+ },
343
+ }
344
+ for name, split in extra_evals.items():
345
+ report["evaluation"][name] = _split_report(
346
+ name,
347
+ split,
348
+ eval_logits[name],
349
+ loss_fn,
350
+ )
351
+
352
+ args.out.parent.mkdir(parents=True, exist_ok=True)
353
+ torch.save(head.state_dict(), args.out)
354
+ print(f"\nsaved head to {args.out}")
355
+ if args.report_out is not None:
356
+ args.report_out.parent.mkdir(parents=True, exist_ok=True)
357
+ with args.report_out.open("w", encoding="utf-8") as fh:
358
+ json.dump(report, fh, indent=2, sort_keys=True)
359
+ print(f"saved metrics report to {args.report_out}")
360
+ print("To use this checkpoint at inference time:")
361
+ print(" from deepfake_scanner.detectors.clip_classifier import ClipClassifier")
362
+ print(" c = ClipClassifier()")
363
+ print(f" c.load_head_weights('{args.out}')")
364
+
365
+
366
+ if __name__ == "__main__":
367
+ main()
src/deepfake_scanner/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """DeepFakeScanner — commercial deepfake / AI-generated image detection service."""
2
+
3
+ __version__ = "0.2.0"
src/deepfake_scanner/api/__init__.py ADDED
File without changes
src/deepfake_scanner/api/schemas.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API schemas (v1).
3
+
4
+ These are the public contract for the /v1/scan/image endpoint. Treat any change
5
+ to a field name, type, or required-ness as a breaking change.
6
+
7
+ Forward-compatibility notes
8
+ ---------------------------
9
+ • `probabilities` is a fixed 4-class vector. Stage 1 only meaningfully populates
10
+ `authentic` and `ai_generated`; `deepfake` and `edited` start at 0.0 until
11
+ the corresponding detectors come online in Stage 2. Clients should treat 0.0
12
+ as "not assessed" for an unsigned-classifier slot — but the field is always
13
+ present so the schema itself never changes.
14
+
15
+ • `signals` is a list, currently with a single entry for the CLIP detector.
16
+ More detectors (frequency, face-swap) append to this list in later stages.
17
+
18
+ • `provenance` is always present. When C2PA is disabled or unavailable,
19
+ `c2pa_present` is False and `c2pa_valid` is None.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Literal
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ # The 4-class taxonomy. New classes must NOT be added without bumping API to v2.
28
+ Verdict = Literal["authentic", "ai_generated", "deepfake", "edited", "uncertain"]
29
+
30
+
31
+ class Probabilities(BaseModel):
32
+ """Probability mass over the 4 mutually-exclusive classes.
33
+
34
+ Sums to 1.0 (allowing for floating-point rounding within ±1e-3).
35
+ """
36
+
37
+ authentic: float = Field(ge=0.0, le=1.0)
38
+ ai_generated: float = Field(ge=0.0, le=1.0)
39
+ deepfake: float = Field(ge=0.0, le=1.0)
40
+ edited: float = Field(ge=0.0, le=1.0)
41
+
42
+
43
+ class DetectorSignal(BaseModel):
44
+ """Per-detector contribution, surfaced for transparency / debugging.
45
+
46
+ `score` is the detector's own internal "fakeness" estimate in [0, 1];
47
+ its meaning depends on the detector. Aggregation into `probabilities`
48
+ happens in the ensemble layer.
49
+ """
50
+
51
+ name: str
52
+ score: float = Field(ge=0.0, le=1.0)
53
+ notes: str | None = None
54
+
55
+
56
+ class Provenance(BaseModel):
57
+ """C2PA / Content Credentials check.
58
+
59
+ A trustworthy C2PA manifest from a known camera or generator can short-
60
+ circuit the model — those signals are currently advisory only.
61
+ """
62
+
63
+ c2pa_present: bool
64
+ c2pa_valid: bool | None = None # None → not checked / unverifiable
65
+ issuer: str | None = None # e.g. "Sony", "OpenAI", "Adobe"
66
+ claim_generator: str | None = None # raw claim_generator string from the manifest
67
+ # True when a manifest anywhere in the chain declares an AI digital source
68
+ # type (IPTC trainedAlgorithmicMedia). This is what can override the model.
69
+ ai_declared: bool = False
70
+
71
+
72
+ class ScanResponse(BaseModel):
73
+ """The /v1/scan/image response. This is the public contract."""
74
+
75
+ verdict: Verdict
76
+ confidence: float = Field(ge=0.0, le=1.0)
77
+ probabilities: Probabilities
78
+ signals: list[DetectorSignal]
79
+ provenance: Provenance
80
+ # "model" — the verdict is the classifier's own output
81
+ # "provenance" — a validated C2PA chain declared the image AI-generated and
82
+ # overrode the classifier. Read this before treating
83
+ # `probabilities` as the explanation for `verdict`: when the
84
+ # source is provenance the probabilities still show what the
85
+ # model thought, which may disagree.
86
+ verdict_source: str = "model"
87
+ model_version: str
88
+ service_version: str
89
+ scan_id: str
90
+ latency_ms: float
91
+
92
+
93
+ class ErrorResponse(BaseModel):
94
+ """Uniform error shape for non-2xx responses."""
95
+
96
+ error: str
97
+ detail: str | None = None
src/deepfake_scanner/api/v1.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ v1 API.
3
+
4
+ Endpoints
5
+ ---------
6
+ GET /health → liveness probe
7
+ GET /v1/info → model + config metadata
8
+ POST /v1/scan/image → scan an uploaded image
9
+
10
+ Privacy
11
+ -------
12
+ Image bytes live in-memory only for the duration of the request. They are
13
+ never written to disk or to blob storage in Stage 1. After the response is
14
+ returned the bytes go out of scope and are garbage-collected.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ import uuid
21
+
22
+ from fastapi import FastAPI, File, HTTPException, UploadFile
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import JSONResponse
25
+
26
+ from ..config import settings
27
+ from ..detectors.ensemble import _DETECTORS, run_ensemble
28
+ from ..preprocess import InvalidImageError, decode
29
+ from ..provenance import check_c2pa
30
+ from ..verdict import decide
31
+ from ..storage import record_scan
32
+ from ..storage.db import ScanRecord
33
+ from .schemas import (
34
+ DetectorSignal,
35
+ ErrorResponse,
36
+ Probabilities,
37
+ Provenance,
38
+ ScanResponse,
39
+ )
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+
44
+ def _detectors_for_info():
45
+ """Live detector instances, for reporting real loaded state."""
46
+ return _DETECTORS
47
+
48
+
49
+ _ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
50
+
51
+
52
+ def create_app() -> FastAPI:
53
+ app = FastAPI(
54
+ title=settings.api_title,
55
+ version=settings.api_version,
56
+ description=(
57
+ "Detect AI-generated and manipulated images. "
58
+ "Visitor uploads are processed in-memory and never stored."
59
+ ),
60
+ )
61
+
62
+ # Browser preflight allowlist for the static-HTML frontend. Credentialed
63
+ # requests are off (no cookies); the upload is the only state we accept.
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origin_regex=settings.cors_allow_origin_regex,
67
+ allow_methods=["GET", "POST", "OPTIONS"],
68
+ allow_headers=["*"],
69
+ allow_credentials=False,
70
+ )
71
+
72
+ # HEAD is accepted on read-only endpoints so uptime monitors and CDN
73
+ # validators can ping cheaply without a body. Per RFC 7231, any URL
74
+ # that accepts GET should also accept HEAD.
75
+ @app.api_route("/health", methods=["GET", "HEAD"])
76
+ def health() -> dict:
77
+ # service_version here so a single curl identifies the running build.
78
+ return {"status": "ok", "service_version": settings.service_version}
79
+
80
+ @app.api_route("/v1/info", methods=["GET", "HEAD"])
81
+ def info() -> dict:
82
+ # head_loaded reports what is ACTUALLY serving, not what is configured.
83
+ # model_version is a config string and stays the same whether or not the
84
+ # weights loaded - which is exactly how a random head can look healthy.
85
+ head_loaded = None
86
+ for det in _detectors_for_info():
87
+ if getattr(det, "name", None) == "clip_classifier":
88
+ head_loaded = bool(getattr(det, "is_trained", False))
89
+ return {
90
+ "api_version": settings.api_version,
91
+ "service_version": settings.service_version,
92
+ "model_version": settings.model_version,
93
+ "head_loaded": head_loaded,
94
+ "detectors": {
95
+ "clip_classifier": settings.enable_clip_detector,
96
+ "frequency_artifacts": settings.enable_frequency_detector,
97
+ "face_swap": settings.enable_face_swap_detector,
98
+ },
99
+ "c2pa_check_enabled": settings.enable_c2pa_check,
100
+ "max_upload_bytes": settings.max_upload_bytes,
101
+ "store_uploads": settings.store_uploads,
102
+ }
103
+
104
+ @app.post(
105
+ "/v1/scan/image",
106
+ response_model=ScanResponse,
107
+ responses={
108
+ 413: {"model": ErrorResponse},
109
+ 415: {"model": ErrorResponse},
110
+ 422: {"model": ErrorResponse},
111
+ },
112
+ )
113
+ async def scan_image(file: UploadFile = File(...)) -> ScanResponse:
114
+ if file.content_type not in _ALLOWED_TYPES:
115
+ raise HTTPException(
116
+ status_code=415,
117
+ detail=(
118
+ f"Unsupported media type '{file.content_type}'. "
119
+ "Use JPEG, PNG, or WebP."
120
+ ),
121
+ )
122
+
123
+ image_bytes = await file.read()
124
+
125
+ if len(image_bytes) > settings.max_upload_bytes:
126
+ raise HTTPException(
127
+ status_code=413,
128
+ detail=(
129
+ f"File exceeds the {settings.max_upload_bytes // (1024*1024)} MB limit."
130
+ ),
131
+ )
132
+
133
+ t0 = time.perf_counter()
134
+
135
+ # 1. Provenance check (advisory — does not yet override the model).
136
+ provenance_result = check_c2pa(image_bytes)
137
+
138
+ # 2. Decode image.
139
+ try:
140
+ image = decode(image_bytes)
141
+ except InvalidImageError as exc:
142
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
143
+
144
+ # 3. Run detector ensemble.
145
+ ensemble_out = run_ensemble(image)
146
+
147
+ # 3b. Let validated provenance override the model. One-directional:
148
+ # it can only push toward `ai_generated`, never toward `authentic`.
149
+ # See verdict.py for why a camera signature is not trusted in reverse.
150
+ decision = decide(
151
+ ensemble_out.verdict, ensemble_out.confidence, provenance_result
152
+ )
153
+
154
+ latency_ms = (time.perf_counter() - t0) * 1000.0
155
+ scan_id = f"scn_{uuid.uuid4().hex[:24]}"
156
+
157
+ response = ScanResponse(
158
+ verdict=decision.verdict,
159
+ confidence=round(decision.confidence, 4),
160
+ verdict_source=decision.source,
161
+ probabilities=Probabilities(
162
+ authentic=round(ensemble_out.probabilities.authentic, 4),
163
+ ai_generated=round(ensemble_out.probabilities.ai_generated, 4),
164
+ deepfake=round(ensemble_out.probabilities.deepfake, 4),
165
+ edited=round(ensemble_out.probabilities.edited, 4),
166
+ ),
167
+ signals=(
168
+ ensemble_out.signals
169
+ + [DetectorSignal(name="c2pa_provenance", score=1.0,
170
+ notes=decision.reason)]
171
+ if decision.source == "provenance"
172
+ else ensemble_out.signals
173
+ ),
174
+ provenance=Provenance(
175
+ c2pa_present=provenance_result.present,
176
+ c2pa_valid=provenance_result.valid,
177
+ issuer=provenance_result.issuer,
178
+ claim_generator=provenance_result.claim_generator,
179
+ ai_declared=provenance_result.ai_declared,
180
+ ),
181
+ model_version=settings.model_version,
182
+ service_version=settings.service_version,
183
+ scan_id=scan_id,
184
+ latency_ms=round(latency_ms, 1),
185
+ )
186
+
187
+ # 4. Persist METADATA only — never the image.
188
+ record_scan(
189
+ ScanRecord(
190
+ scan_id=scan_id,
191
+ verdict=response.verdict,
192
+ confidence=response.confidence,
193
+ model_version=response.model_version,
194
+ latency_ms=response.latency_ms,
195
+ c2pa_present=provenance_result.present,
196
+ )
197
+ )
198
+
199
+ # 5. Drop the image bytes ASAP. (Local var goes out of scope when the
200
+ # function returns; the explicit del documents the privacy intent.)
201
+ del image_bytes
202
+
203
+ return response
204
+
205
+ @app.exception_handler(HTTPException)
206
+ async def http_exception_handler(_, exc: HTTPException) -> JSONResponse:
207
+ return JSONResponse(
208
+ status_code=exc.status_code,
209
+ content=ErrorResponse(
210
+ error=f"HTTP {exc.status_code}",
211
+ detail=str(exc.detail),
212
+ ).model_dump(),
213
+ )
214
+
215
+ return app
216
+
217
+
218
+ # Default app instance for `uvicorn deepfake_scanner.api.v1:app`.
219
+ app = create_app()
src/deepfake_scanner/config.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Central configuration for DeepFakeScanner.
3
+
4
+ All runtime knobs are read from environment variables with sensible defaults
5
+ so the service can run unchanged from local dev → HF Spaces → Cloud Run.
6
+
7
+ Read this once at import; do NOT scatter os.getenv() calls throughout the code.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+
15
+ def _env_bool(name: str, default: bool) -> bool:
16
+ raw = os.getenv(name)
17
+ if raw is None:
18
+ return default
19
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
20
+
21
+
22
+ def _env_int(name: str, default: int) -> int:
23
+ raw = os.getenv(name)
24
+ if raw is None:
25
+ return default
26
+ try:
27
+ return int(raw)
28
+ except ValueError:
29
+ return default
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Settings:
34
+ # ------------------------------------------------------------------ API
35
+ api_title: str = "DeepFakeScanner"
36
+ api_version: str = "0.2.0"
37
+
38
+ # Build identifier for the deployed service. Three versions travel in the
39
+ # API response and they answer different questions:
40
+ # api_version — the response CONTRACT. Bump only on a breaking change.
41
+ # model_version — which trained head is serving (see below).
42
+ # service_version — which BUILD of the application code is live.
43
+ # The third exists because a deploy can change behaviour without touching
44
+ # either of the other two: the 2026-08-13 release fixed a dead C2PA reader,
45
+ # so provenance fields went from always-absent to populated, with an
46
+ # identical contract and an identical model. Bump this on every deploy that
47
+ # changes runtime behaviour, and note it in CHANGELOG.md.
48
+ # v0.5.0 — C2PA provenance reading repaired (2026-08-13)
49
+ # v0.6.0 — validated provenance now overrides the model, one-directionally
50
+ # (68% -> 100% on the 100-image closed-generator benchmark)
51
+ # v0.7.0 — EXIF orientation applied on decode; portrait phone photos were
52
+ # being scanned rotated 90 degrees
53
+ # v0.8.0 — C2PA validity fails closed (Untrusted/Unknown no longer read as
54
+ # valid); refuse to start without a trained head; /v1/info
55
+ # reports head_loaded
56
+ service_version: str = os.getenv("SERVICE_VERSION", "v0.8.0")
57
+ max_upload_bytes: int = _env_int("MAX_UPLOAD_BYTES", 10 * 1024 * 1024) # 10 MB
58
+
59
+ # --------------------------------------------------------- Image policy
60
+ # Privacy-by-default: visitor uploads are NEVER persisted.
61
+ # Logged-in paid users will be able to opt in later via a separate flag.
62
+ store_uploads: bool = _env_bool("STORE_UPLOADS", False)
63
+
64
+ # ---------------------------------------------------------- Detectors
65
+ # Stage 1: only the CLIP-based classifier is wired up.
66
+ # Stage 2 will flip these on as the detectors are trained / verified.
67
+ enable_clip_detector: bool = _env_bool("ENABLE_CLIP_DETECTOR", True)
68
+ enable_frequency_detector: bool = _env_bool("ENABLE_FREQUENCY_DETECTOR", False)
69
+ enable_face_swap_detector: bool = _env_bool("ENABLE_FACE_SWAP_DETECTOR", False)
70
+
71
+ # The CLIP backbone we use for feature extraction.
72
+ # OpenAI CLIP ViT-B/32 weights are MIT-licensed (commercially safe).
73
+ clip_model_name: str = os.getenv("CLIP_MODEL_NAME", "openai/clip-vit-base-patch32")
74
+
75
+ # Path to the trained classifier-head checkpoint. ClipClassifier auto-
76
+ # loads this in __init__ if the file exists. The same env var works
77
+ # locally (dev machine) and inside the inference Docker image.
78
+ # Stage 3A: head_v3a.pt (multi-generator: Flux+SDXL+SD3.5+AuraFlow).
79
+ head_checkpoint_path: str = os.getenv(
80
+ "HEAD_CHECKPOINT_PATH", "data/checkpoints/head_v3a.pt"
81
+ )
82
+
83
+ # Fallback for production: if the local checkpoint file isn't present,
84
+ # ClipClassifier downloads the weights from this HF Hub model repo at
85
+ # startup. The repo is PRIVATE — the container authenticates with the
86
+ # HF_TOKEN secret set in the HF Space settings (huggingface_hub reads
87
+ # HF_TOKEN from the environment automatically). Setting this to an
88
+ # empty string disables the fallback (used in tests).
89
+ # Stage 3A: head_v3a.pt. The Stage 2 head_v1.pt remains in the same
90
+ # private repo for rollback — set HEAD_CHECKPOINT_HF_FILENAME=head_v1.pt
91
+ # (and MODEL_VERSION=v0.3.0-stage2) to revert without a redeploy.
92
+ head_checkpoint_hf_repo: str = os.getenv(
93
+ "HEAD_CHECKPOINT_HF_REPO", "Veridicate/scanner-head-v1"
94
+ )
95
+ head_checkpoint_hf_filename: str = os.getenv(
96
+ "HEAD_CHECKPOINT_HF_FILENAME", "head_v3a.pt"
97
+ )
98
+
99
+ # Refuse to start if the trained head cannot be loaded. Scaffold mode runs
100
+ # a RANDOMLY INITIALISED head: it answers every request, reports the
101
+ # configured model_version, and /health returns ok - so a failed weight
102
+ # download looks exactly like a healthy service returning nonsense. That is
103
+ # the same shape as the 2026-08 C2PA outage, and it is why this defaults to
104
+ # True. Tests and local scaffold work set REQUIRE_TRAINED_HEAD=false
105
+ # explicitly (see tests/conftest.py).
106
+ require_trained_head: bool = _env_bool("REQUIRE_TRAINED_HEAD", True)
107
+
108
+ # ---------------------------------------------------------- Provenance
109
+ enable_c2pa_check: bool = _env_bool("ENABLE_C2PA_CHECK", True)
110
+
111
+ # ------------------------------------------------------------ Storage
112
+ # Postgres URL for scan records (None → in-memory fallback for local dev).
113
+ database_url: str | None = os.getenv("DATABASE_URL")
114
+
115
+ # S3-compatible blob storage (e.g. Cloudflare R2). Only used when
116
+ # store_uploads is True AND a paid user opts in.
117
+ blob_endpoint: str | None = os.getenv("BLOB_ENDPOINT")
118
+ blob_bucket: str | None = os.getenv("BLOB_BUCKET")
119
+
120
+ # ---------------------------------------------------- Model identifier
121
+ # Surfaced in API responses so clients can pin behaviour to a version.
122
+ # v0.4.0-stage3a = CLIP head trained on a multi-generator dataset
123
+ # (50k Flux + 20k SDXL + 20k SD 3.5 + 10k AuraFlow + matched authentic).
124
+ # In-distribution test accuracy 98.43%; +5.55 pp vs the Stage 2 head on
125
+ # the SDXL hold-out (a generator never seen in training). See
126
+ # docs/stage3a-implementation.md.
127
+ # Previous: v0.3.0-stage2 (Flux-only, head_v1.pt) — still available in the
128
+ # HF Hub repo for rollback (see head_checkpoint_hf_filename above).
129
+ model_version: str = os.getenv("MODEL_VERSION", "v0.4.0-stage3a")
130
+
131
+ # --------------------------------------------------------------- CORS
132
+ # Browser preflight allowlist for the static-HTML frontend. The default
133
+ # covers the production domain, any Cloudflare Pages preview, and
134
+ # localhost (any port) for local dev. Override via env var if a new
135
+ # origin needs access.
136
+ cors_allow_origin_regex: str = os.getenv(
137
+ "CORS_ALLOW_ORIGIN_REGEX",
138
+ r"^(https://(www\.)?veridicate\.com|https://[a-z0-9-]+\.pages\.dev|https://[a-z0-9-]+\.dulipcf\.workers\.dev|http://localhost(:\d+)?|http://127\.0\.0\.1(:\d+)?)$",
139
+ )
140
+
141
+
142
+ settings = Settings()
src/deepfake_scanner/detectors/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Image-forensic detectors. Each detector implements the Detector ABC."""
2
+
3
+ from .base import Detector, DetectorResult
4
+
5
+ __all__ = ["Detector", "DetectorResult"]
src/deepfake_scanner/detectors/base.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Detector interface.
3
+
4
+ Every detector is a plug-in that takes a PIL image and returns a
5
+ DetectorResult. The ensemble layer aggregates results from all enabled
6
+ detectors into a single Probabilities vector.
7
+
8
+ Adding a new detector = subclassing Detector + registering it in ensemble.py.
9
+ The /v1 API response shape never changes.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+
16
+ from PIL import Image
17
+
18
+
19
+ @dataclass
20
+ class DetectorResult:
21
+ """Output of a single detector run.
22
+
23
+ Attributes
24
+ ----------
25
+ name :
26
+ Stable identifier for this detector (used in API `signals`).
27
+ score :
28
+ The detector's own "fakeness" estimate in [0, 1].
29
+ 1.0 means "definitely synthetic"; 0.0 means "definitely authentic".
30
+ contributions :
31
+ Optional per-class hints in [0, 1]. Keys must be a subset of the
32
+ 4-class taxonomy: "authentic", "ai_generated", "deepfake", "edited".
33
+ Detectors that only know "real vs. fake" leave this empty and let
34
+ the ensemble splat their score across the relevant classes.
35
+ notes :
36
+ Free-form human-readable note (surfaced in API for debugging).
37
+ """
38
+
39
+ name: str
40
+ score: float
41
+ contributions: dict[str, float]
42
+ notes: str | None = None
43
+
44
+
45
+ class Detector(ABC):
46
+ """Base class for all forensic detectors.
47
+
48
+ Detectors must be safe to instantiate once at process start and reused
49
+ across requests — load model weights in __init__, not in run().
50
+ """
51
+
52
+ name: str = "abstract"
53
+
54
+ @abstractmethod
55
+ def run(self, image: Image.Image) -> DetectorResult:
56
+ """Score a single PIL image. Must not mutate the image."""
57
+ raise NotImplementedError
src/deepfake_scanner/detectors/clip_classifier.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CLIP-based binary classifier.
3
+
4
+ Strategy
5
+ --------
6
+ Use a frozen pretrained CLIP image encoder to produce a 512-d feature vector,
7
+ then a small linear head maps it to [P(authentic), P(ai_generated)].
8
+
9
+ Why CLIP?
10
+ ~~~~~~~~~
11
+ CLIP's pretraining on 400M+ image-text pairs has been shown (UniversalFakeDetect,
12
+ Ojha et al. 2023, and follow-up work) to generalise far better across unseen
13
+ generators than ImageNet-pretrained CNNs. This is the single biggest lever for
14
+ cross-generator robustness in a commercial product.
15
+
16
+ Stage 1 status
17
+ --------------
18
+ The classification head is RANDOMLY INITIALIZED. This file establishes the
19
+ correct architecture and pipeline; predictions are not meaningful until the
20
+ head is trained on the curated dataset (see `scripts/dataset/`).
21
+
22
+ Licensing
23
+ ---------
24
+ - HuggingFace `transformers` — Apache-2.0 (commercially safe)
25
+ - OpenAI CLIP ViT-B/32 weights — MIT (commercially safe)
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import torch
33
+ import torch.nn as nn
34
+ from PIL import Image
35
+
36
+ from ..config import settings
37
+ from .base import Detector, DetectorResult
38
+
39
+
40
+ class _LazyClipBackbone:
41
+ """Defers loading transformers + downloading CLIP weights until first use.
42
+
43
+ Keeps `import` cheap so tests that don't need the model don't pay the cost.
44
+ """
45
+
46
+ def __init__(self) -> None:
47
+ self._model: Any | None = None
48
+ self._processor: Any | None = None
49
+
50
+ def get(self) -> tuple[Any, Any]:
51
+ if self._model is None or self._processor is None:
52
+ # Local import — transformers is heavy.
53
+ from transformers import CLIPModel, CLIPProcessor
54
+
55
+ name = settings.clip_model_name
56
+ self._processor = CLIPProcessor.from_pretrained(name)
57
+ self._model = CLIPModel.from_pretrained(name)
58
+ self._model.eval()
59
+ for p in self._model.parameters():
60
+ p.requires_grad = False
61
+ return self._model, self._processor
62
+
63
+
64
+ _clip_singleton = _LazyClipBackbone()
65
+
66
+
67
+ class ClipClassifier(Detector):
68
+ """Frozen CLIP image encoder + small trainable head."""
69
+
70
+ name = "clip_classifier"
71
+
72
+ # CLIP ViT-B/32 image-embedding dim is 512.
73
+ _EMBED_DIM = 512
74
+
75
+ def __init__(self) -> None:
76
+ # The trainable head. Two outputs: logits for [authentic, ai_generated].
77
+ # Architecture mirrored exactly in scripts/train_head.py — keep them
78
+ # in sync or checkpoint loads will fail.
79
+ self._head: nn.Module = nn.Sequential(
80
+ nn.Linear(self._EMBED_DIM, 256),
81
+ nn.ReLU(),
82
+ nn.Dropout(0.2),
83
+ nn.Linear(256, 2),
84
+ )
85
+ self._head.eval()
86
+ self._trained: bool = False
87
+
88
+ # Stage 2 head loading. Priority:
89
+ # 1. Local file at settings.head_checkpoint_path (dev / pre-baked).
90
+ # 2. HF Hub model repo (settings.head_checkpoint_hf_repo).
91
+ # 3. Fall through to scaffold mode (random head) — what tests use.
92
+ # Errors here are caught and logged so the container always starts.
93
+ ckpt = Path(settings.head_checkpoint_path)
94
+ if not ckpt.is_file() and settings.head_checkpoint_hf_repo:
95
+ try:
96
+ from huggingface_hub import hf_hub_download
97
+
98
+ downloaded = hf_hub_download(
99
+ repo_id=settings.head_checkpoint_hf_repo,
100
+ filename=settings.head_checkpoint_hf_filename,
101
+ )
102
+ ckpt = Path(downloaded)
103
+ except Exception as exc: # noqa: BLE001 — never crash on startup
104
+ print(
105
+ f"WARNING: Stage 2 head download failed ({type(exc).__name__}: "
106
+ f"{exc}). Falling back to scaffold mode.",
107
+ flush=True,
108
+ )
109
+
110
+ if ckpt.is_file():
111
+ self.load_head_weights(str(ckpt))
112
+
113
+ if not self._trained:
114
+ msg = (
115
+ "Trained head could not be loaded (looked for "
116
+ f"{settings.head_checkpoint_path!r} and HF repo "
117
+ f"{settings.head_checkpoint_hf_repo!r}). The head is randomly "
118
+ "initialised and its output is meaningless."
119
+ )
120
+ if settings.require_trained_head:
121
+ # Fail closed. Serving random predictions while /health reports
122
+ # ok is worse than not serving: the failure is invisible to
123
+ # monitoring and indistinguishable from a working detector.
124
+ raise RuntimeError(
125
+ msg + " Refusing to start. Set REQUIRE_TRAINED_HEAD=false "
126
+ "to run in scaffold mode for development or tests."
127
+ )
128
+ print(f"WARNING: {msg} Running in scaffold mode.", flush=True)
129
+
130
+ @property
131
+ def is_trained(self) -> bool:
132
+ """True when real weights loaded; False in scaffold mode."""
133
+ return self._trained
134
+
135
+ @torch.no_grad()
136
+ def run(self, image: Image.Image) -> DetectorResult:
137
+ model, processor = _clip_singleton.get()
138
+
139
+ # Processor handles resize, center-crop, normalize — same as CLIP's
140
+ # original training preprocessing.
141
+ inputs = processor(images=image, return_tensors="pt")
142
+ features = model.get_image_features(**inputs) # [1, 512]
143
+ features = features / features.norm(p=2, dim=-1, keepdim=True)
144
+
145
+ logits = self._head(features) # [1, 2]
146
+ probs = torch.softmax(logits, dim=-1)
147
+ p_ai = float(probs[0, 1].item())
148
+
149
+ return DetectorResult(
150
+ name=self.name,
151
+ score=p_ai,
152
+ # Stage 2: trained on Open Images V7 (real) + Flux.1-schnell (AI).
153
+ # Only authentic vs. ai_generated. Other classes left empty —
154
+ # the ensemble will not assign mass to them.
155
+ contributions={
156
+ "authentic": 1.0 - p_ai,
157
+ "ai_generated": p_ai,
158
+ },
159
+ notes=(
160
+ None
161
+ if self._trained
162
+ else (
163
+ "Stage 1 scaffold — classification head is randomly initialised. "
164
+ "Output is not meaningful until Stage 2 fine-tuning."
165
+ )
166
+ ),
167
+ )
168
+
169
+ def load_head_weights(self, checkpoint_path: str) -> None:
170
+ """Load fine-tuned head weights produced by Stage 2 training."""
171
+ state = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
172
+ self._head.load_state_dict(state)
173
+ self._head.eval()
174
+ self._trained = True
src/deepfake_scanner/detectors/ensemble.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Detector ensemble.
3
+
4
+ Holds the registry of enabled detectors and aggregates their per-class
5
+ contributions into the final 4-class probability vector returned to the API.
6
+
7
+ Aggregation strategy (Stage 1)
8
+ ------------------------------
9
+ Simple averaging of the per-class contributions across detectors that emit
10
+ non-empty contributions, then renormalisation. When only one detector is
11
+ active (Stage 1), the output is just its contributions (with the absent
12
+ classes filled with 0.0 and a tiny epsilon to keep softmax-style downstream
13
+ math safe).
14
+
15
+ Stage 2+ may switch to:
16
+ • weighted averaging (per-detector confidence / calibration)
17
+ • a small meta-classifier that takes detector scores as input
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ from PIL import Image
24
+
25
+ from ..api.schemas import DetectorSignal, Probabilities, Verdict
26
+ from ..config import settings
27
+ from .base import Detector, DetectorResult
28
+ from .clip_classifier import ClipClassifier
29
+ from .face_swap import FaceSwapDetector
30
+ from .frequency import FrequencyDetector
31
+
32
+ # The fixed taxonomy. Order matters — used for deterministic argmax tie-break.
33
+ _CLASSES: tuple[str, ...] = ("authentic", "ai_generated", "deepfake", "edited")
34
+
35
+
36
+ @dataclass
37
+ class EnsembleOutput:
38
+ probabilities: Probabilities
39
+ signals: list[DetectorSignal]
40
+ verdict: Verdict
41
+ confidence: float
42
+
43
+
44
+ def _build_detectors() -> list[Detector]:
45
+ """Instantiate the enabled detectors at process start."""
46
+ detectors: list[Detector] = []
47
+ if settings.enable_clip_detector:
48
+ detectors.append(ClipClassifier())
49
+ if settings.enable_frequency_detector:
50
+ detectors.append(FrequencyDetector())
51
+ if settings.enable_face_swap_detector:
52
+ detectors.append(FaceSwapDetector())
53
+ if not detectors:
54
+ raise RuntimeError(
55
+ "No detectors enabled. At minimum, ENABLE_CLIP_DETECTOR must be true."
56
+ )
57
+ return detectors
58
+
59
+
60
+ # Module-level singleton — built once at import.
61
+ _DETECTORS: list[Detector] = _build_detectors()
62
+
63
+
64
+ def _aggregate(results: list[DetectorResult]) -> Probabilities:
65
+ """Average per-class contributions across detectors, renormalise."""
66
+ sums = {c: 0.0 for c in _CLASSES}
67
+ counts = {c: 0 for c in _CLASSES}
68
+
69
+ for r in results:
70
+ for cls, val in r.contributions.items():
71
+ if cls in sums:
72
+ sums[cls] += val
73
+ counts[cls] += 1
74
+
75
+ averaged = {
76
+ c: (sums[c] / counts[c]) if counts[c] > 0 else 0.0 for c in _CLASSES
77
+ }
78
+ total = sum(averaged.values())
79
+ if total <= 0.0:
80
+ # No detector contributed — default to maximum uncertainty over the
81
+ # two Stage 1 classes (graceful degradation).
82
+ averaged = {"authentic": 0.5, "ai_generated": 0.5, "deepfake": 0.0, "edited": 0.0}
83
+ else:
84
+ averaged = {c: v / total for c, v in averaged.items()}
85
+
86
+ return Probabilities(**averaged)
87
+
88
+
89
+ def _verdict_from(probs: Probabilities) -> tuple[Verdict, float]:
90
+ """Pick the top class as the verdict; return it with its probability.
91
+
92
+ If the top probability is below 0.55, return 'uncertain' to discourage
93
+ callers from over-trusting low-confidence outputs.
94
+ """
95
+ items = [
96
+ ("authentic", probs.authentic),
97
+ ("ai_generated", probs.ai_generated),
98
+ ("deepfake", probs.deepfake),
99
+ ("edited", probs.edited),
100
+ ]
101
+ items.sort(key=lambda kv: kv[1], reverse=True)
102
+ top_class, top_prob = items[0]
103
+ if top_prob < 0.55:
104
+ return "uncertain", top_prob
105
+ return top_class, top_prob # type: ignore[return-value]
106
+
107
+
108
+ def run_ensemble(image: Image.Image) -> EnsembleOutput:
109
+ """Run all enabled detectors and aggregate their outputs."""
110
+ results = [d.run(image) for d in _DETECTORS]
111
+ probs = _aggregate(results)
112
+ signals = [
113
+ DetectorSignal(name=r.name, score=r.score, notes=r.notes) for r in results
114
+ ]
115
+ verdict, confidence = _verdict_from(probs)
116
+ return EnsembleOutput(
117
+ probabilities=probs,
118
+ signals=signals,
119
+ verdict=verdict,
120
+ confidence=confidence,
121
+ )
src/deepfake_scanner/detectors/face_swap.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face-swap / deepfake detector (Stage 2).
3
+
4
+ Targets the deepfake class specifically (real photo + swapped face), as
5
+ opposed to fully synthetic AI-generated images. Typically combines:
6
+ • a face detector (commercially-licensed — e.g. MediaPipe, Apache-2.0)
7
+ • a CNN trained on face-swap artefacts (blend boundaries, eye consistency,
8
+ skin texture mismatches)
9
+
10
+ NOT IMPLEMENTED in Stage 1.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from PIL import Image
15
+
16
+ from .base import Detector, DetectorResult
17
+
18
+
19
+ class FaceSwapDetector(Detector):
20
+ name = "face_swap"
21
+
22
+ def run(self, image: Image.Image) -> DetectorResult: # noqa: ARG002
23
+ raise NotImplementedError(
24
+ "FaceSwapDetector is reserved for Stage 2. "
25
+ "Set ENABLE_FACE_SWAP_DETECTOR=false in Stage 1."
26
+ )
src/deepfake_scanner/detectors/frequency.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Frequency-domain detector (Stage 2).
3
+
4
+ The intuition: many generators leave characteristic artefacts in the frequency
5
+ spectrum (DCT/FFT) that are not visible to humans but are highly diagnostic.
6
+ This detector complements CLIP-based semantic detection — when the generator
7
+ fools CLIP, frequency artefacts often still betray it.
8
+
9
+ NOT IMPLEMENTED in Stage 1. Wire the implementation in when the dataset is
10
+ curated and we can validate the approach on held-out generators.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from PIL import Image
15
+
16
+ from .base import Detector, DetectorResult
17
+
18
+
19
+ class FrequencyDetector(Detector):
20
+ name = "frequency_artifacts"
21
+
22
+ def run(self, image: Image.Image) -> DetectorResult: # noqa: ARG002
23
+ raise NotImplementedError(
24
+ "FrequencyDetector is reserved for Stage 2. "
25
+ "Set ENABLE_FREQUENCY_DETECTOR=false in Stage 1."
26
+ )
src/deepfake_scanner/detectors/frequency_features.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Frequency-domain feature extraction for the Stage 3B detector.
3
+
4
+ Why this exists
5
+ ---------------
6
+ The CLIP classifier (Stage 2/3A) reads images the way humans do — semantic
7
+ content. It has two measured weak spots (see
8
+ docs/stage3b-implementation.md S3B-01):
9
+ - test_augmented FNR 19.2% — JPEG/resize/crop degrade the semantic signal
10
+ - heldout SDXL FNR 13.5% — unseen generators it misses as "real"
11
+ Frequency artefacts are an ORTHOGONAL, largely generator-agnostic signal:
12
+ diffusion/DiT upsampling leaves periodic spectral fingerprints that survive
13
+ metadata stripping and many transforms. This module turns an image into a
14
+ small, fixed-length, interpretable feature vector capturing those artefacts.
15
+
16
+ Design constraints
17
+ ------------------
18
+ - **No new dependencies**: pure NumPy + Pillow (both already runtime deps).
19
+ The DCT is implemented on top of `numpy.fft` (no SciPy).
20
+ - **Deterministic**: same image bytes -> identical feature vector.
21
+ - **Runtime-safe**: bounded work (image is resized to a fixed analysis
22
+ size first), guaranteed-finite output (no NaN/Inf reaches the caller).
23
+ - **Versioned**: FEATURE_SCHEMA_VERSION + a fixed FEATURE_NAMES order so
24
+ offline-precomputed feature files can be validated against the runtime
25
+ extractor.
26
+
27
+ Feature schema (freq-v1, 16 features, fixed order = FEATURE_NAMES)
28
+ -----------------------------------------------------------------
29
+ radial_bin_0 .. radial_bin_7 (8) : log radial power spectrum of the
30
+ luminance 2D FFT, averaged into 8 concentric annuli (low->high freq),
31
+ each normalised by total spectral power. Captures the overall
32
+ low-vs-high frequency energy distribution.
33
+ mid_high_ratio : energy in mid annuli / energy in high annuli.
34
+ high_energy_frac : fraction of spectral power in the top half of
35
+ frequencies (diffusion images often differ here).
36
+ spectral_slope : slope of log(power) vs log(radius) — natural
37
+ images follow a characteristic 1/f^a falloff;
38
+ generated images deviate.
39
+ dct_highfreq_mean_abs : mean |coeff| in the high-frequency quadrant of a
40
+ whole-image DCT-II (log1p-scaled).
41
+ dct_highfreq_energy_frac: high-freq DCT energy / total DCT energy.
42
+ jpeg_blockiness_8 : 8-pixel-grid blockiness (boundary vs interior
43
+ gradient ratio) — compression / generation grids.
44
+ highpass_energy : log energy of a high-pass residual (image minus
45
+ a box-blurred copy).
46
+ highpass_kurtosis : kurtosis of the high-pass residual — real-sensor
47
+ noise vs synthesised micro-texture differ.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import numpy as np
52
+ from PIL import Image
53
+
54
+ # Bump this whenever FEATURE_NAMES or the extraction math changes, so stale
55
+ # precomputed feature files are rejected rather than silently mixed.
56
+ FEATURE_SCHEMA_VERSION = "freq-v1"
57
+
58
+ _N_RADIAL_BINS = 8
59
+
60
+ FEATURE_NAMES: tuple[str, ...] = (
61
+ *[f"radial_bin_{i}" for i in range(_N_RADIAL_BINS)],
62
+ "mid_high_ratio",
63
+ "high_energy_frac",
64
+ "spectral_slope",
65
+ "dct_highfreq_mean_abs",
66
+ "dct_highfreq_energy_frac",
67
+ "jpeg_blockiness_8",
68
+ "highpass_energy",
69
+ "highpass_kurtosis",
70
+ )
71
+
72
+ N_FEATURES = len(FEATURE_NAMES)
73
+
74
+ # Fixed analysis size. Resizing first makes FFT bins comparable across images
75
+ # of any input size, and bounds the runtime cost on large uploads. Must be a
76
+ # multiple of 8 so the JPEG-blockiness grid lines up.
77
+ _ANALYSIS_SIZE = 256
78
+ _EPS = 1e-8
79
+
80
+
81
+ def _to_luminance_array(image: Image.Image) -> np.ndarray:
82
+ """RGB/any PIL image -> float64 luminance array at the fixed analysis size."""
83
+ # "L" = ITU-R 601-2 luma transform; deterministic. Resize with a fixed
84
+ # resample filter so output is reproducible.
85
+ gray = image.convert("L").resize(
86
+ (_ANALYSIS_SIZE, _ANALYSIS_SIZE), Image.BILINEAR
87
+ )
88
+ arr = np.asarray(gray, dtype=np.float64)
89
+ return arr
90
+
91
+
92
+ def _radial_power_features(lum: np.ndarray) -> tuple[np.ndarray, float, float, float]:
93
+ """Radial power-spectrum bins + mid/high ratio, high fraction, slope."""
94
+ # 2D FFT magnitude spectrum, DC shifted to centre.
95
+ f = np.fft.fftshift(np.fft.fft2(lum))
96
+ power = np.abs(f) ** 2
97
+ total = power.sum() + _EPS
98
+
99
+ h, w = power.shape
100
+ cy, cx = h / 2.0, w / 2.0
101
+ yy, xx = np.ogrid[:h, :w]
102
+ r = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
103
+ r_max = r.max() + _EPS
104
+ # Bin index 0..N-1 by normalised radius.
105
+ bin_idx = np.minimum(
106
+ (r / r_max * _N_RADIAL_BINS).astype(int), _N_RADIAL_BINS - 1
107
+ )
108
+
109
+ bins = np.zeros(_N_RADIAL_BINS, dtype=np.float64)
110
+ for i in range(_N_RADIAL_BINS):
111
+ mask = bin_idx == i
112
+ if mask.any():
113
+ bins[i] = power[mask].mean()
114
+ # Normalise by total mean power, then log1p for scale stability.
115
+ bins_norm = np.log1p(bins / (bins.sum() + _EPS))
116
+
117
+ # Mid (bins 3-4) vs high (bins 6-7) energy ratio.
118
+ mid = bins[3:5].sum()
119
+ high = bins[6:8].sum()
120
+ mid_high_ratio = float(np.log1p(mid / (high + _EPS)))
121
+
122
+ # Fraction of power in the top-half frequency bins.
123
+ high_energy_frac = float(bins[_N_RADIAL_BINS // 2 :].sum() / (bins.sum() + _EPS))
124
+
125
+ # Spectral slope: linear fit of log(power) vs log(radius) over bins.
126
+ radii = np.arange(1, _N_RADIAL_BINS + 1, dtype=np.float64)
127
+ log_r = np.log(radii)
128
+ log_p = np.log(bins + _EPS)
129
+ # least-squares slope
130
+ slope = float(np.polyfit(log_r, log_p, 1)[0])
131
+
132
+ return bins_norm, mid_high_ratio, high_energy_frac, slope
133
+
134
+
135
+ def _dct2(block: np.ndarray) -> np.ndarray:
136
+ """Separable 2D DCT-II implemented via numpy.fft (no SciPy).
137
+
138
+ Uses the standard even-symmetric-extension trick so we depend only on
139
+ numpy. Returns the 2D DCT-II of the input array.
140
+ """
141
+
142
+ def dct1(x: np.ndarray, axis: int) -> np.ndarray:
143
+ n = x.shape[axis]
144
+ # Even extension along axis: [x, reversed(x)]
145
+ x_ext = np.concatenate([x, np.flip(x, axis=axis)], axis=axis)
146
+ X = np.fft.fft(x_ext, axis=axis)
147
+ # Keep first n, apply the DCT-II phase factor.
148
+ sl = [slice(None)] * x.ndim
149
+ sl[axis] = slice(0, n)
150
+ Xn = X[tuple(sl)]
151
+ k = np.arange(n)
152
+ phase = np.exp(-1j * np.pi * k / (2 * n))
153
+ shape = [1] * x.ndim
154
+ shape[axis] = n
155
+ phase = phase.reshape(shape)
156
+ return np.real(Xn * phase)
157
+
158
+ return dct1(dct1(block, axis=0), axis=1)
159
+
160
+
161
+ def _dct_features(lum: np.ndarray) -> tuple[float, float]:
162
+ """High-frequency DCT statistics over the whole (resized) image."""
163
+ coeffs = _dct2(lum)
164
+ abs_c = np.abs(coeffs)
165
+ n = abs_c.shape[0]
166
+ half = n // 2
167
+ # High-frequency quadrant = bottom-right (both axes high).
168
+ high = abs_c[half:, half:]
169
+ total_energy = (abs_c ** 2).sum() + _EPS
170
+ high_mean_abs = float(np.log1p(high.mean()))
171
+ high_energy_frac = float((high ** 2).sum() / total_energy)
172
+ return high_mean_abs, high_energy_frac
173
+
174
+
175
+ def _blockiness_8(lum: np.ndarray) -> float:
176
+ """8-grid blockiness: mean gradient ON 8-px boundaries vs OFF them.
177
+
178
+ JPEG and many generator upsamplers leave an 8x8 grid signature: gradients
179
+ across block boundaries are systematically larger than interior gradients.
180
+ Ratio ~1.0 = no grid; >1.0 = grid present.
181
+ """
182
+ # Vertical boundaries every 8 columns (cols 8,16,...).
183
+ col_diff = np.abs(np.diff(lum, axis=1))
184
+ bound_cols = col_diff[:, 7::8]
185
+ interior_cols = np.delete(col_diff, np.s_[7::8], axis=1)
186
+ # Horizontal boundaries every 8 rows.
187
+ row_diff = np.abs(np.diff(lum, axis=0))
188
+ bound_rows = row_diff[7::8, :]
189
+ interior_rows = np.delete(row_diff, np.s_[7::8], axis=0)
190
+
191
+ bound = (bound_cols.mean() + bound_rows.mean()) / 2.0
192
+ interior = (interior_cols.mean() + interior_rows.mean()) / 2.0 + _EPS
193
+ return float(bound / interior)
194
+
195
+
196
+ def _highpass_features(lum: np.ndarray) -> tuple[float, float]:
197
+ """Energy + kurtosis of a high-pass residual (image minus box blur)."""
198
+ # 3x3 box blur via cumulative-sum trick would be overkill; use a simple
199
+ # separable mean filter through FFT-free convolution with np.
200
+ k = 3
201
+ pad = k // 2
202
+ padded = np.pad(lum, pad, mode="reflect")
203
+ # Separable box blur.
204
+ cs = np.cumsum(np.cumsum(padded, axis=0), axis=1)
205
+ cs = np.pad(cs, ((1, 0), (1, 0)), mode="constant")
206
+ h, w = lum.shape
207
+ y0, x0 = np.arange(h), np.arange(w)
208
+ Y1, X1 = np.meshgrid(y0, x0, indexing="ij")
209
+ Y2, X2 = Y1 + k, X1 + k
210
+ box = cs[Y2, X2] - cs[Y1, X2] - cs[Y2, X1] + cs[Y1, X1]
211
+ blur = box / (k * k)
212
+ resid = lum - blur
213
+
214
+ energy = float(np.log1p((resid ** 2).mean()))
215
+ std = resid.std() + _EPS
216
+ kurt = float(((resid - resid.mean()) ** 4).mean() / (std ** 4))
217
+ return energy, kurt
218
+
219
+
220
+ def extract_features(image: Image.Image) -> np.ndarray:
221
+ """Extract the fixed-length freq-v1 feature vector from a PIL image.
222
+
223
+ Returns a float32 array of shape (N_FEATURES,). Output is guaranteed
224
+ finite (NaN/Inf are mapped to 0.0) so downstream code never sees bad
225
+ values regardless of degenerate input (constant image, 1x1, etc.).
226
+ """
227
+ lum = _to_luminance_array(image)
228
+
229
+ radial, mid_high, high_frac, slope = _radial_power_features(lum)
230
+ dct_mean_abs, dct_high_frac = _dct_features(lum)
231
+ blockiness = _blockiness_8(lum)
232
+ hp_energy, hp_kurt = _highpass_features(lum)
233
+
234
+ feats = np.concatenate(
235
+ [
236
+ radial,
237
+ np.array(
238
+ [
239
+ mid_high,
240
+ high_frac,
241
+ slope,
242
+ dct_mean_abs,
243
+ dct_high_frac,
244
+ blockiness,
245
+ hp_energy,
246
+ hp_kurt,
247
+ ],
248
+ dtype=np.float64,
249
+ ),
250
+ ]
251
+ )
252
+ feats = np.nan_to_num(feats, nan=0.0, posinf=0.0, neginf=0.0)
253
+ return feats.astype(np.float32)
src/deepfake_scanner/preprocess.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image decoding.
3
+
4
+ In the new architecture, each detector does its own tensor preprocessing
5
+ (CLIP needs CLIP's specific resize/normalise; frequency detectors don't
6
+ resize at all). This module just turns raw bytes into a validated PIL Image
7
+ that all detectors can consume.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from io import BytesIO
12
+
13
+ from PIL import Image, ImageOps, UnidentifiedImageError
14
+
15
+ # Pillow's default decompression-bomb guard is ~89 MP. Tighten it: a 25 MP
16
+ # image is more than enough for any consumer photo, and rejecting larger
17
+ # inputs caps memory usage on the inference server.
18
+ Image.MAX_IMAGE_PIXELS = 25_000_000
19
+
20
+
21
+ class InvalidImageError(ValueError):
22
+ """Raised when the bytes do not decode as a usable image."""
23
+
24
+
25
+ def decode(image_bytes: bytes) -> Image.Image:
26
+ """Decode raw bytes into an RGB PIL image. Raises InvalidImageError on failure."""
27
+ try:
28
+ image = Image.open(BytesIO(image_bytes))
29
+ image.load() # Force decode now so we catch errors here, not later.
30
+ except (UnidentifiedImageError, OSError) as exc:
31
+ raise InvalidImageError(f"Could not decode image: {exc}") from exc
32
+ except Image.DecompressionBombError as exc:
33
+ raise InvalidImageError(f"Image too large: {exc}") from exc
34
+
35
+ # Phone cameras almost always store the sensor readout unrotated and record
36
+ # the intended rotation in the EXIF Orientation tag — 9 of the first 13 real
37
+ # test photos (Galaxy S23 Ultra) came through as orientation 6, i.e. stored
38
+ # sideways. Every viewer applies that tag, so a user who uploads a portrait
39
+ # photo sees it upright and assumes we scanned it upright. Without this call
40
+ # the detector was seeing it rotated 90°, which is both wrong for users and
41
+ # would have silently corrupted the P0 false-positive measurement.
42
+ # exif_transpose is a no-op when the tag is absent or already upright.
43
+ image = ImageOps.exif_transpose(image)
44
+
45
+ if image.mode != "RGB":
46
+ image = image.convert("RGB")
47
+
48
+ return image
src/deepfake_scanner/provenance/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .c2pa import check_c2pa
2
+
3
+ __all__ = ["check_c2pa"]
src/deepfake_scanner/provenance/c2pa.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ C2PA / Content Credentials verification.
3
+
4
+ C2PA is a cryptographic provenance standard adopted by:
5
+ • Cameras: Sony, Nikon, Leica, Canon (rolling out)
6
+ • AI tools: OpenAI (DALL-E, GPT image), Adobe Firefly, Microsoft, Google
7
+ • Editors: Photoshop, Lightroom
8
+
9
+ A valid C2PA manifest gives strong evidence about an image's origin. We use
10
+ this as a FIRST-PASS check before the ML model runs:
11
+
12
+ • valid manifest from a known camera → strong evidence of "authentic"
13
+ • valid manifest from a known generator → strong evidence of "ai_generated"
14
+ • no manifest / invalid manifest → fall through to the model
15
+
16
+ This module returns advisory data only — the routing logic lives in api/v1.py.
17
+
18
+ Library: `c2pa-python` is Apache-2.0 licensed and the official Python bindings
19
+ to the Content Authenticity Initiative SDK.
20
+
21
+ Graceful degradation, and its trap
22
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
23
+ The c2pa package has native dependencies and may not be installable in every
24
+ environment (e.g. Hugging Face Spaces ARM builds). If import fails, the service
25
+ still works — provenance is just always reported as "not present".
26
+
27
+ That fallback hid a real outage between 2026-05 and 2026-08-09: the module
28
+ called `Reader.from_stream(...)`, which was removed in c2pa-python 0.3x, and
29
+ `requirements.txt` pinned only `>=0.5.0`. Every call raised `AttributeError`,
30
+ the blanket `except` swallowed it, and provenance was reported absent for every
31
+ upload — including images carrying a valid OpenAI manifest. "No manifest found"
32
+ and "the reader is broken" looked identical from the outside.
33
+
34
+ Two defences against a repeat:
35
+ 1. `_build_reader()` tries each known constructor generation and records
36
+ which one worked, so an API change degrades to a warning, not silence.
37
+ 2. `check_c2pa()` looks for C2PA byte markers in the payload. If a marker is
38
+ present but the reader returned nothing, that is logged at WARNING —
39
+ the one case that must never pass quietly.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import logging
45
+ from dataclasses import dataclass
46
+ from io import BytesIO
47
+
48
+ from ..config import settings
49
+
50
+ log = logging.getLogger(__name__)
51
+
52
+ # Try to import c2pa lazily; degrade gracefully if unavailable.
53
+ try:
54
+ import c2pa # type: ignore
55
+ _C2PA_AVAILABLE = True
56
+ except Exception as exc: # pragma: no cover - environment-dependent
57
+ log.info("c2pa-python not available (%s); provenance check disabled.", exc)
58
+ c2pa = None # type: ignore
59
+ _C2PA_AVAILABLE = False
60
+
61
+ # Byte signatures of an embedded C2PA manifest. `caBX` is the JUMBF box type
62
+ # used in PNG/JPEG; `jumbf`/`c2pa` appear in the box headers of most formats.
63
+ _C2PA_MARKERS = (b"caBX", b"jumbf", b"c2pa")
64
+
65
+ # Magic-number → mime. The reader needs the real format: passing "image/jpeg"
66
+ # for a PNG makes it fail to find a manifest that is demonstrably there.
67
+ _MAGIC: tuple[tuple[bytes, str], ...] = (
68
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
69
+ (b"\xff\xd8\xff", "image/jpeg"),
70
+ (b"GIF87a", "image/gif"),
71
+ (b"GIF89a", "image/gif"),
72
+ (b"BM", "image/bmp"),
73
+ (b"II*\x00", "image/tiff"),
74
+ (b"MM\x00*", "image/tiff"),
75
+ )
76
+
77
+
78
+ # IPTC digital-source-type codes that mean "a generative model made this".
79
+ # Keyed on the IPTC vocabulary rather than on issuer names, because issuer
80
+ # alone proves nothing about how the pixels were made: Adobe signs Photoshop
81
+ # edits of ordinary photographs with the same certificate it would use for
82
+ # Firefly output. The source type is the standards-defined answer.
83
+ _AI_SOURCE_TYPES = frozenset({
84
+ "trainedAlgorithmicMedia", # created by a generative model
85
+ "compositeWithTrainedAlgorithmicMedia", # real content combined with AI content
86
+ })
87
+
88
+
89
+ @dataclass
90
+ class C2paResult:
91
+ present: bool
92
+ valid: bool | None
93
+ issuer: str | None
94
+ claim_generator: str | None
95
+ # True when some manifest in the chain declares an AI digital source type.
96
+ ai_declared: bool = False
97
+ # "active" — the active manifest itself declares it (e.g. ChatGPT)
98
+ # "chain" — only an ancestor declares it (e.g. Gemini, whose active
99
+ # manifest describes watermarking and reports `composite`,
100
+ # while every parent declares trainedAlgorithmicMedia)
101
+ ai_declared_scope: str | None = None
102
+ # Whether the signing certificate chains to an accepted trust anchor.
103
+ # SEPARATE from `valid`: a manifest can be intact and cryptographically
104
+ # sound while signed by anyone at all. None = could not establish.
105
+ signer_trusted: bool | None = None
106
+
107
+
108
+ _NOT_PRESENT = C2paResult(present=False, valid=None, issuer=None, claim_generator=None)
109
+
110
+
111
+ def _ai_source_types(manifest: dict) -> set[str]:
112
+ """Digital source types declared by a single manifest's actions."""
113
+ found: set[str] = set()
114
+ for assertion in manifest.get("assertions") or []:
115
+ if not str(assertion.get("label", "")).startswith("c2pa.actions"):
116
+ continue
117
+ for action in (assertion.get("data") or {}).get("actions") or []:
118
+ dst = action.get("digitalSourceType")
119
+ if dst and str(dst).rsplit("/", 1)[-1] in _AI_SOURCE_TYPES:
120
+ found.add(str(dst).rsplit("/", 1)[-1])
121
+ return found
122
+
123
+
124
+ def _scan_for_ai(data: dict, active_id: str | None) -> tuple[bool, str | None]:
125
+ """Walk EVERY manifest in the store looking for an AI source-type claim.
126
+
127
+ Checking only the active manifest is not enough. Gemini's active manifest
128
+ covers the final watermarking step and reports `composite`; the claim that
129
+ a model generated the pixels sits in the parent manifests. Reading only the
130
+ active one would miss all 50 Gemini images in the P0 benchmark.
131
+ """
132
+ manifests = data.get("manifests") or {}
133
+ if active_id and active_id in manifests:
134
+ if _ai_source_types(manifests[active_id]):
135
+ return True, "active"
136
+ for mid, manifest in manifests.items():
137
+ if mid != active_id and _ai_source_types(manifest):
138
+ return True, "chain"
139
+ return False, None
140
+
141
+
142
+ def sniff_mime(image_bytes: bytes) -> str:
143
+ """Best-effort mime type from magic bytes; falls back to image/jpeg."""
144
+ for magic, mime in _MAGIC:
145
+ if image_bytes.startswith(magic):
146
+ return mime
147
+ # RIFF....WEBP — the size field sits between the two markers.
148
+ if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
149
+ return "image/webp"
150
+ # ISO-BMFF family (HEIC/AVIF): "....ftyp<brand>".
151
+ if image_bytes[4:8] == b"ftyp":
152
+ brand = image_bytes[8:12]
153
+ if brand.startswith(b"avif"):
154
+ return "image/avif"
155
+ return "image/heic"
156
+ return "image/jpeg"
157
+
158
+
159
+ def _build_reader(mime: str, image_bytes: bytes):
160
+ """Construct a c2pa Reader across API generations.
161
+
162
+ 0.3x → Reader(format, stream)
163
+ 0.5-0.2x → Reader.from_stream(format, stream)
164
+ Raises the last error if no constructor works.
165
+ """
166
+ last_exc: Exception | None = None
167
+ for attempt in ("constructor", "from_stream"):
168
+ try:
169
+ if attempt == "constructor":
170
+ return c2pa.Reader(mime, BytesIO(image_bytes)) # type: ignore[misc]
171
+ factory = getattr(c2pa.Reader, "from_stream", None) # type: ignore[union-attr]
172
+ if factory is None:
173
+ continue
174
+ return factory(mime, BytesIO(image_bytes))
175
+ except AttributeError as exc:
176
+ # Wrong API generation — try the next one.
177
+ last_exc = exc
178
+ except Exception:
179
+ # The constructor exists but this payload has no readable manifest.
180
+ raise
181
+ raise last_exc or RuntimeError("no usable c2pa Reader constructor")
182
+
183
+
184
+ def _claim_generator(active: dict) -> str | None:
185
+ """Read the generator name across manifest schema versions.
186
+
187
+ Older manifests carry a `claim_generator` string. Newer ones (C2PA 1.3+,
188
+ which is what OpenAI writes today) carry `claim_generator_info`, a list of
189
+ {name, version} entries — `claim_generator` is then absent.
190
+ """
191
+ direct = active.get("claim_generator")
192
+ if direct:
193
+ return str(direct)
194
+
195
+ info = active.get("claim_generator_info")
196
+ if isinstance(info, dict):
197
+ info = [info]
198
+ if isinstance(info, list):
199
+ names = []
200
+ for entry in info:
201
+ if isinstance(entry, dict) and entry.get("name"):
202
+ version = entry.get("version")
203
+ names.append(f"{entry['name']} {version}" if version else str(entry["name"]))
204
+ elif isinstance(entry, str):
205
+ names.append(entry)
206
+ if names:
207
+ return ", ".join(names)
208
+ return None
209
+
210
+
211
+ # Exact validation states, never substring tests. `"untrusted"` CONTAINS
212
+ # `"trusted"`, and a substring check therefore read an untrusted signature as a
213
+ # trusted one — the same trap as `"Invalid".endswith("valid")`, reintroduced in
214
+ # the other branch of this function and caught by review on 2026-09-14.
215
+ _STATE_VALID = frozenset({"valid", "trusted"})
216
+ _STATE_INVALID = frozenset({"invalid", "untrusted"})
217
+
218
+
219
+ def _validity(reader, data: dict, active: dict) -> bool | None:
220
+ """Prefer the SDK's own validation state; fall back to manifest fields.
221
+
222
+ Returns True only for a state we positively recognise as good, False for one
223
+ we recognise as bad, and **None for anything else** — unknown states, an
224
+ unreadable state, and an absent fallback all mean *we could not establish
225
+ this*, which must never be spelled the same way as *this is fine*. Callers
226
+ treat None as indeterminate and leave the model in charge.
227
+ """
228
+ getter = getattr(reader, "get_validation_state", None)
229
+ if callable(getter):
230
+ try:
231
+ state = getter()
232
+ except Exception as exc: # pragma: no cover - SDK-dependent
233
+ log.debug("C2PA validation_state unavailable: %s", exc)
234
+ else:
235
+ if state is not None:
236
+ text = str(state).strip().lower()
237
+ if text in _STATE_INVALID:
238
+ return False
239
+ if text in _STATE_VALID:
240
+ return True
241
+ log.warning(
242
+ "C2PA reported an unrecognised validation state %r; treating "
243
+ "it as indeterminate rather than valid.", state,
244
+ )
245
+ return None
246
+
247
+ # `validation_status` is an EMPTY LIST when verification passes and a
248
+ # populated one when it does not. Test key presence rather than truthiness:
249
+ # `[] or active.get(...)` collapses "present and empty" (a pass) into
250
+ # "absent" (unknown), which is the opposite meaning.
251
+ for source in (data, active):
252
+ if "validation_status" in source:
253
+ return not bool(source["validation_status"])
254
+ # Neither source said anything at all — fail closed.
255
+ return None
256
+
257
+
258
+ def _signer_trusted(state: object, data: dict, active: dict) -> bool | None:
259
+ """Whether the signing certificate chains to an accepted trust anchor.
260
+
261
+ This is a SEPARATE question from `_validity`. A manifest can be perfectly
262
+ intact and cryptographically sound while being signed by anybody at all —
263
+ including an attacker. Only a `trusted` state proves the signer chains to a
264
+ trust list.
265
+
266
+ We ship no trust anchors (adding a trust list is a third-party asset and
267
+ needs a licensing-tracker entry first), so in practice this returns False or
268
+ None today: even genuine OpenAI images report `Valid` alongside a
269
+ `signingCredential.untrusted` status. Surfaced so the gap is visible in the
270
+ API response and in logs rather than silently assumed away.
271
+ """
272
+ if state is not None and str(state).strip().lower() == "trusted":
273
+ return True
274
+
275
+ status = data.get("validation_status") or active.get("validation_status") or []
276
+ if isinstance(status, list):
277
+ for entry in status:
278
+ if isinstance(entry, dict) and str(
279
+ entry.get("code", "")
280
+ ).startswith("signingCredential.untrusted"):
281
+ return False
282
+ return None
283
+
284
+
285
+ def check_c2pa(image_bytes: bytes) -> C2paResult:
286
+ """Return whatever C2PA evidence we can extract from the raw image bytes."""
287
+ if not settings.enable_c2pa_check or not _C2PA_AVAILABLE:
288
+ return _NOT_PRESENT
289
+
290
+ has_marker = any(m in image_bytes for m in _C2PA_MARKERS)
291
+ mime = sniff_mime(image_bytes)
292
+
293
+ try:
294
+ reader = _build_reader(mime, image_bytes)
295
+ manifest_json = reader.json()
296
+ except Exception as exc:
297
+ # Either no manifest at all, or the reader could not handle this
298
+ # payload. Those are indistinguishable to the caller — but if the
299
+ # bytes clearly contain a manifest, the reader is at fault and that
300
+ # must be loud. This is the exact failure that went unnoticed for
301
+ # three months.
302
+ if has_marker:
303
+ log.warning(
304
+ "C2PA manifest markers present in a %s payload but the reader "
305
+ "failed (%s: %s). Provenance is UNMEASURED, not absent — check "
306
+ "the installed c2pa-python API.",
307
+ mime, type(exc).__name__, exc,
308
+ )
309
+ else:
310
+ log.debug("C2PA read failed for %s: %s", mime, exc)
311
+ return _NOT_PRESENT
312
+
313
+ try:
314
+ data = json.loads(manifest_json)
315
+ except json.JSONDecodeError:
316
+ log.warning("C2PA reader returned non-JSON for a %s payload.", mime)
317
+ return _NOT_PRESENT
318
+
319
+ active_id = data.get("active_manifest")
320
+ manifests = data.get("manifests", {})
321
+ active = manifests.get(active_id, {}) if active_id else {}
322
+
323
+ issuer = None
324
+ sig_info = active.get("signature_info") or {}
325
+ if isinstance(sig_info, dict):
326
+ issuer = sig_info.get("issuer")
327
+
328
+ ai_declared, scope = _scan_for_ai(data, active_id)
329
+
330
+ raw_state = None
331
+ getter = getattr(reader, "get_validation_state", None)
332
+ if callable(getter):
333
+ try:
334
+ raw_state = getter()
335
+ except Exception: # pragma: no cover - SDK-dependent
336
+ raw_state = None
337
+
338
+ return C2paResult(
339
+ present=True,
340
+ valid=_validity(reader, data, active),
341
+ issuer=issuer,
342
+ claim_generator=_claim_generator(active),
343
+ ai_declared=ai_declared,
344
+ ai_declared_scope=scope,
345
+ signer_trusted=_signer_trusted(raw_state, data, active),
346
+ )
src/deepfake_scanner/storage/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .db import record_scan
2
+
3
+ __all__ = ["record_scan"]
src/deepfake_scanner/storage/blob.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Optional image storage (S3 / Cloudflare R2).
3
+
4
+ Privacy contract
5
+ ----------------
6
+ Visitor uploads are NEVER persisted by default. This module is only invoked
7
+ when ALL of the following are true:
8
+
9
+ 1. settings.store_uploads is True (env: STORE_UPLOADS=true)
10
+ 2. The request comes from an authenticated user
11
+ 3. That user has explicitly opted in to scan history / forensic reports
12
+
13
+ None of this is wired up in Stage 1. The module exists to make the
14
+ architecture visible and to lock in the privacy default at the type level.
15
+ """
16
+ from __future__ import annotations
17
+
18
+
19
+ def store_image(scan_id: str, image_bytes: bytes) -> str: # noqa: ARG001
20
+ """Upload image to blob storage; return the object key.
21
+
22
+ Raises
23
+ ------
24
+ PermissionError :
25
+ If called when settings.store_uploads is False, as a defence-in-depth
26
+ check against a future bug accidentally persisting visitor uploads.
27
+ """
28
+ from ..config import settings
29
+
30
+ if not settings.store_uploads:
31
+ raise PermissionError(
32
+ "Image storage is disabled by default. Enable explicitly via "
33
+ "STORE_UPLOADS=true AND user opt-in."
34
+ )
35
+ raise NotImplementedError("Blob storage wired up in Stage 2.")
src/deepfake_scanner/storage/db.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scan-record persistence.
3
+
4
+ Important
5
+ ---------
6
+ This module persists ONLY metadata (scan_id, verdict, model_version, latency,
7
+ timestamp). The image itself is NEVER stored here. Image storage is a separate,
8
+ opt-in path handled by `storage.blob`.
9
+
10
+ Stage 1 ships an in-memory fallback so the service runs out of the box with
11
+ no database. Set DATABASE_URL to switch to Postgres (Neon, Supabase, etc.).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import time
17
+ from dataclasses import dataclass, field
18
+
19
+ from ..config import settings
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class ScanRecord:
26
+ scan_id: str
27
+ verdict: str
28
+ confidence: float
29
+ model_version: str
30
+ latency_ms: float
31
+ c2pa_present: bool
32
+ timestamp_unix: float = field(default_factory=time.time)
33
+
34
+
35
+ # In-memory store — bounded to last N records to avoid leaking memory.
36
+ _MEMORY_CAP = 1000
37
+ _memory_store: list[ScanRecord] = []
38
+
39
+
40
+ def record_scan(record: ScanRecord) -> None:
41
+ """Persist a scan record. No-op-safe; never raises into the request path."""
42
+ try:
43
+ if settings.database_url:
44
+ _record_postgres(record)
45
+ else:
46
+ _record_memory(record)
47
+ except Exception as exc:
48
+ # Persistence failures must NEVER break the user-facing scan response.
49
+ log.warning("Failed to persist scan record: %s", exc)
50
+
51
+
52
+ def _record_memory(record: ScanRecord) -> None:
53
+ _memory_store.append(record)
54
+ if len(_memory_store) > _MEMORY_CAP:
55
+ del _memory_store[: len(_memory_store) - _MEMORY_CAP]
56
+
57
+
58
+ def _record_postgres(record: ScanRecord) -> None: # pragma: no cover - infra
59
+ """Lazy import — SQLAlchemy is heavy and not needed in dev/tests."""
60
+ # Implementation intentionally deferred until Stage 2 — schema below.
61
+ #
62
+ # CREATE TABLE scans (
63
+ # scan_id TEXT PRIMARY KEY,
64
+ # verdict TEXT NOT NULL,
65
+ # confidence REAL NOT NULL,
66
+ # model_version TEXT NOT NULL,
67
+ # latency_ms REAL NOT NULL,
68
+ # c2pa_present BOOLEAN NOT NULL,
69
+ # created_at TIMESTAMPTZ NOT NULL DEFAULT now()
70
+ # );
71
+ raise NotImplementedError("Postgres persistence wired up in Stage 2.")
72
+
73
+
74
+ def memory_store_snapshot() -> list[ScanRecord]:
75
+ """Test/debug helper — returns a copy of the in-memory store."""
76
+ return list(_memory_store)
src/deepfake_scanner/verdict.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Final verdict routing: cryptographic provenance first, model second.
3
+
4
+ Why provenance outranks the model
5
+ ---------------------------------
6
+ The CLIP head is a probabilistic guess. A validated C2PA manifest declaring
7
+ an AI digital source type is a signed statement by the generator's own vendor
8
+ that a model produced the pixels. On the P0 benchmark the model identifies
9
+ 68/100 closed-generator images; the provenance chain identifies 100/100. When
10
+ both are available and they disagree, the signature is the better evidence.
11
+
12
+ What this deliberately does NOT do
13
+ ----------------------------------
14
+ 1. **A camera manifest never forces `authentic`.** A C2PA-signing camera
15
+ pointed at a screen showing an AI image produces a perfectly valid capture
16
+ manifest for synthetic content, so "signed by a camera" cannot be trusted
17
+ to mean "real". The override is one-directional: provenance can only push a
18
+ verdict TOWARD `ai_generated`, never away from it.
19
+ 2. **Absence of a manifest means nothing.** Most real photographs carry no
20
+ C2PA data at all, and any re-encode — a screenshot, a messaging app, a
21
+ social upload — strips it. No manifest simply hands the decision to the
22
+ model, exactly as before.
23
+ 3. **An invalid manifest is ignored.** If the signature does not validate, the
24
+ claim is worthless and the model decides.
25
+
26
+ Consequence worth stating plainly: this raises accuracy only on *untouched
27
+ downloads*. The moment an image is screenshotted or re-encoded the manifest is
28
+ gone and the model is on its own again — which is why the screenshot arm of
29
+ the P0 benchmark is the one that measures the detector itself.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from typing import Literal
35
+
36
+ from .api.schemas import Verdict
37
+ from .provenance.c2pa import C2paResult
38
+
39
+ # Confidence reported when a validated signature decides the verdict. Not 1.0:
40
+ # the signature proves the declared origin, not that our reading of every edge
41
+ # case is perfect.
42
+ PROVENANCE_CONFIDENCE = 0.99
43
+
44
+ VerdictSource = Literal["model", "provenance"]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class VerdictDecision:
49
+ verdict: Verdict
50
+ confidence: float
51
+ source: VerdictSource
52
+ reason: str | None = None
53
+
54
+
55
+ def decide(
56
+ model_verdict: Verdict,
57
+ model_confidence: float,
58
+ provenance: C2paResult,
59
+ ) -> VerdictDecision:
60
+ """Combine the model's verdict with C2PA evidence.
61
+
62
+ Returns the model's own verdict unchanged unless a *validated* manifest in
63
+ the provenance chain declares the content to be model-generated.
64
+ """
65
+ trustworthy = (
66
+ provenance.present
67
+ and provenance.valid is True
68
+ and provenance.ai_declared
69
+ )
70
+ if not trustworthy:
71
+ return VerdictDecision(model_verdict, model_confidence, "model")
72
+
73
+ where = (
74
+ "the active manifest"
75
+ if provenance.ai_declared_scope == "active"
76
+ else "a parent manifest in the provenance chain"
77
+ )
78
+ issuer = provenance.issuer or "an unnamed issuer"
79
+ return VerdictDecision(
80
+ verdict="ai_generated",
81
+ confidence=PROVENANCE_CONFIDENCE,
82
+ source="provenance",
83
+ reason=(
84
+ f"Content Credentials signed by {issuer} declare this image as "
85
+ f"AI-generated in {where}."
86
+ ),
87
+ )