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

Deploy from GitHub 39b3777315c11d9c8bcd39ad7bf034f2a88a7379 (filtered: code + Dockerfile + README + NOTICES only)

Browse files
Files changed (40) hide show
  1. Dockerfile +78 -0
  2. NOTICES.md +99 -0
  3. README.md +150 -0
  4. pyproject.toml +17 -0
  5. requirements.txt +61 -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/fetch_open_images.py +138 -0
  10. scripts/dataset/generate_auraflow_synthetic.py +217 -0
  11. scripts/dataset/generate_flux_synthetic.py +168 -0
  12. scripts/dataset/generate_sd35_synthetic.py +205 -0
  13. scripts/dataset/generate_sdxl_synthetic.py +202 -0
  14. scripts/dataset/generation_utils.py +205 -0
  15. scripts/dataset/prompts.txt +1711 -0
  16. scripts/dataset/pull_stage3a_data.sh +147 -0
  17. scripts/dataset/run_stage3a_smoke.py +472 -0
  18. scripts/dataset/split.py +149 -0
  19. scripts/download_weights.py +27 -0
  20. scripts/evaluate_head.py +291 -0
  21. scripts/precompute_embeddings.py +488 -0
  22. scripts/run_stage3a_pipeline.py +627 -0
  23. scripts/train_head.py +367 -0
  24. src/deepfake_scanner/__init__.py +3 -0
  25. src/deepfake_scanner/api/__init__.py +0 -0
  26. src/deepfake_scanner/api/schemas.py +86 -0
  27. src/deepfake_scanner/api/v1.py +181 -0
  28. src/deepfake_scanner/config.py +113 -0
  29. src/deepfake_scanner/detectors/__init__.py +5 -0
  30. src/deepfake_scanner/detectors/base.py +57 -0
  31. src/deepfake_scanner/detectors/clip_classifier.py +152 -0
  32. src/deepfake_scanner/detectors/ensemble.py +121 -0
  33. src/deepfake_scanner/detectors/face_swap.py +26 -0
  34. src/deepfake_scanner/detectors/frequency.py +26 -0
  35. src/deepfake_scanner/preprocess.py +38 -0
  36. src/deepfake_scanner/provenance/__init__.py +3 -0
  37. src/deepfake_scanner/provenance/c2pa.py +100 -0
  38. src/deepfake_scanner/storage/__init__.py +3 -0
  39. src/deepfake_scanner/storage/blob.py +35 -0
  40. src/deepfake_scanner/storage/db.py +76 -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,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ c2pa-python>=0.5.0
31
+
32
+ # API server
33
+ fastapi>=0.111.0
34
+ uvicorn[standard]>=0.29.0
35
+ python-multipart>=0.0.9 # required for FastAPI file uploads
36
+ pydantic>=2.7.0
37
+
38
+ # Testing
39
+ pytest>=8.2.0
40
+ httpx>=0.27.0 # required by FastAPI TestClient
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Optional dataset-curation deps (NOT installed by this file).
44
+ #
45
+ # This file is the consolidated Python dependency ledger, but the production
46
+ # Dockerfile installs it directly. Keep GPU/dataset-only packages commented so
47
+ # the inference image stays lean. On a rented GPU box, install the CUDA torch
48
+ # wheel first, then install the dataset packages listed below explicitly:
49
+ #
50
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
51
+ # pip install 'fiftyone>=0.24.0' 'diffusers>=0.30.0' 'sentencepiece>=0.2.0' 'protobuf>=4.25.0'
52
+ #
53
+ # Dataset-only packages, licenses, and purpose:
54
+ # fiftyone>=0.24.0 # Apache-2.0 — Open Images V7 sampling
55
+ # diffusers>=0.30.0 # Apache-2.0 — Flux/SDXL/SD3/AuraFlow pipelines
56
+ # sentencepiece>=0.2.0 # Apache-2.0 — required by Flux's T5 tokenizer
57
+ # protobuf>=4.25.0 # BSD-3 — required by sentencepiece
58
+ #
59
+ # Stage 2/3 head training itself runs on cached embeddings and uses only the
60
+ # installed runtime/test dependencies above.
61
+ # ---------------------------------------------------------------------------
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/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/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/split.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import csv
36
+ import random
37
+ from collections import defaultdict
38
+ from pathlib import Path
39
+
40
+
41
+ def _generator_label(row: dict[str, str]) -> str:
42
+ """Return the best available generator label for an AI row."""
43
+ return row.get("generator") or row.get("source") or "unknown"
44
+
45
+
46
+ def _split_key(row: dict[str, str], stratify_by: str) -> str:
47
+ """Return the grouping key used for stratified splitting."""
48
+ cls = row["class"]
49
+ if stratify_by == "class-generator" and cls == "ai_generated":
50
+ return f"{cls}:{_generator_label(row)}"
51
+ return cls
52
+
53
+
54
+ def _write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
55
+ with path.open("w", newline="") as fh:
56
+ writer = csv.DictWriter(fh, fieldnames=fieldnames)
57
+ writer.writeheader()
58
+ writer.writerows(rows)
59
+
60
+
61
+ def main() -> None:
62
+ parser = argparse.ArgumentParser(description=__doc__)
63
+ parser.add_argument("--manifest", type=Path, required=True)
64
+ parser.add_argument("--out-dir", type=Path, required=True)
65
+ parser.add_argument("--val", type=float, default=0.1)
66
+ parser.add_argument("--test", type=float, default=0.1)
67
+ parser.add_argument("--seed", type=int, default=0)
68
+ parser.add_argument(
69
+ "--stratify-by",
70
+ choices=["class", "class-generator"],
71
+ default="class",
72
+ help=(
73
+ "Stratification mode. 'class' preserves legacy behavior; "
74
+ "'class-generator' additionally balances AI generators."
75
+ ),
76
+ )
77
+ parser.add_argument(
78
+ "--holdout-generator",
79
+ action="append",
80
+ default=[],
81
+ help=(
82
+ "AI generator to remove from train/val/test and write to "
83
+ "heldout.csv. Can be passed multiple times."
84
+ ),
85
+ )
86
+ args = parser.parse_args()
87
+
88
+ if args.val + args.test >= 1.0:
89
+ raise ValueError("val + test must be < 1.0")
90
+
91
+ with args.manifest.open() as fh:
92
+ reader = csv.DictReader(fh)
93
+ rows = list(reader)
94
+ fieldnames = reader.fieldnames or []
95
+
96
+ holdout_generators = set(args.holdout_generator)
97
+ split_candidates: list[dict[str, str]] = []
98
+ holdout_rows: list[dict[str, str]] = []
99
+ for r in rows:
100
+ if r["class"] == "ai_generated" and _generator_label(r) in holdout_generators:
101
+ holdout_rows.append(r)
102
+ else:
103
+ split_candidates.append(r)
104
+
105
+ if holdout_generators and not holdout_rows:
106
+ raise ValueError(
107
+ "No rows matched --holdout-generator values: "
108
+ f"{sorted(holdout_generators)}"
109
+ )
110
+
111
+ by_group: dict[str, list[dict[str, str]]] = defaultdict(list)
112
+ for r in split_candidates:
113
+ by_group[_split_key(r, args.stratify_by)].append(r)
114
+
115
+ rng = random.Random(args.seed)
116
+ train_rows, val_rows, test_rows = [], [], []
117
+ for group, items in sorted(by_group.items()):
118
+ # Sort by sha256 for determinism, then shuffle with seeded RNG.
119
+ items.sort(key=lambda r: r["sha256"])
120
+ rng.shuffle(items)
121
+ n = len(items)
122
+ n_test = int(round(n * args.test))
123
+ n_val = int(round(n * args.val))
124
+ test_rows.extend(items[:n_test])
125
+ val_rows.extend(items[n_test : n_test + n_val])
126
+ train_rows.extend(items[n_test + n_val :])
127
+ print(
128
+ f" {group}: total={n} "
129
+ f"train={n - n_test - n_val} val={n_val} test={n_test}"
130
+ )
131
+
132
+ args.out_dir.mkdir(parents=True, exist_ok=True)
133
+ for name, rs in [("train", train_rows), ("val", val_rows), ("test", test_rows)]:
134
+ path = args.out_dir / f"{name}.csv"
135
+ _write_csv(path, fieldnames, rs)
136
+ print(f" wrote {path} ({len(rs)} rows)")
137
+
138
+ if holdout_generators:
139
+ path = args.out_dir / "heldout.csv"
140
+ holdout_rows.sort(key=lambda r: (r["sha256"], r["path"]))
141
+ _write_csv(path, fieldnames, holdout_rows)
142
+ print(
143
+ f" wrote {path} ({len(holdout_rows)} rows) "
144
+ f"for held-out generators: {sorted(holdout_generators)}"
145
+ )
146
+
147
+
148
+ if __name__ == "__main__":
149
+ 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/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/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_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,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
68
+
69
+ class ScanResponse(BaseModel):
70
+ """The /v1/scan/image response. This is the public contract."""
71
+
72
+ verdict: Verdict
73
+ confidence: float = Field(ge=0.0, le=1.0)
74
+ probabilities: Probabilities
75
+ signals: list[DetectorSignal]
76
+ provenance: Provenance
77
+ model_version: str
78
+ scan_id: str
79
+ latency_ms: float
80
+
81
+
82
+ class ErrorResponse(BaseModel):
83
+ """Uniform error shape for non-2xx responses."""
84
+
85
+ error: str
86
+ detail: str | None = None
src/deepfake_scanner/api/v1.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 run_ensemble
28
+ from ..preprocess import InvalidImageError, decode
29
+ from ..provenance import check_c2pa
30
+ from ..storage import record_scan
31
+ from ..storage.db import ScanRecord
32
+ from .schemas import ErrorResponse, Probabilities, Provenance, ScanResponse
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+
37
+ _ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
38
+
39
+
40
+ def create_app() -> FastAPI:
41
+ app = FastAPI(
42
+ title=settings.api_title,
43
+ version=settings.api_version,
44
+ description=(
45
+ "Detect AI-generated and manipulated images. "
46
+ "Visitor uploads are processed in-memory and never stored."
47
+ ),
48
+ )
49
+
50
+ # Browser preflight allowlist for the static-HTML frontend. Credentialed
51
+ # requests are off (no cookies); the upload is the only state we accept.
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origin_regex=settings.cors_allow_origin_regex,
55
+ allow_methods=["GET", "POST", "OPTIONS"],
56
+ allow_headers=["*"],
57
+ allow_credentials=False,
58
+ )
59
+
60
+ # HEAD is accepted on read-only endpoints so uptime monitors and CDN
61
+ # validators can ping cheaply without a body. Per RFC 7231, any URL
62
+ # that accepts GET should also accept HEAD.
63
+ @app.api_route("/health", methods=["GET", "HEAD"])
64
+ def health() -> dict:
65
+ return {"status": "ok"}
66
+
67
+ @app.api_route("/v1/info", methods=["GET", "HEAD"])
68
+ def info() -> dict:
69
+ return {
70
+ "api_version": settings.api_version,
71
+ "model_version": settings.model_version,
72
+ "detectors": {
73
+ "clip_classifier": settings.enable_clip_detector,
74
+ "frequency_artifacts": settings.enable_frequency_detector,
75
+ "face_swap": settings.enable_face_swap_detector,
76
+ },
77
+ "c2pa_check_enabled": settings.enable_c2pa_check,
78
+ "max_upload_bytes": settings.max_upload_bytes,
79
+ "store_uploads": settings.store_uploads,
80
+ }
81
+
82
+ @app.post(
83
+ "/v1/scan/image",
84
+ response_model=ScanResponse,
85
+ responses={
86
+ 413: {"model": ErrorResponse},
87
+ 415: {"model": ErrorResponse},
88
+ 422: {"model": ErrorResponse},
89
+ },
90
+ )
91
+ async def scan_image(file: UploadFile = File(...)) -> ScanResponse:
92
+ if file.content_type not in _ALLOWED_TYPES:
93
+ raise HTTPException(
94
+ status_code=415,
95
+ detail=(
96
+ f"Unsupported media type '{file.content_type}'. "
97
+ "Use JPEG, PNG, or WebP."
98
+ ),
99
+ )
100
+
101
+ image_bytes = await file.read()
102
+
103
+ if len(image_bytes) > settings.max_upload_bytes:
104
+ raise HTTPException(
105
+ status_code=413,
106
+ detail=(
107
+ f"File exceeds the {settings.max_upload_bytes // (1024*1024)} MB limit."
108
+ ),
109
+ )
110
+
111
+ t0 = time.perf_counter()
112
+
113
+ # 1. Provenance check (advisory — does not yet override the model).
114
+ provenance_result = check_c2pa(image_bytes)
115
+
116
+ # 2. Decode image.
117
+ try:
118
+ image = decode(image_bytes)
119
+ except InvalidImageError as exc:
120
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
121
+
122
+ # 3. Run detector ensemble.
123
+ ensemble_out = run_ensemble(image)
124
+
125
+ latency_ms = (time.perf_counter() - t0) * 1000.0
126
+ scan_id = f"scn_{uuid.uuid4().hex[:24]}"
127
+
128
+ response = ScanResponse(
129
+ verdict=ensemble_out.verdict,
130
+ confidence=round(ensemble_out.confidence, 4),
131
+ probabilities=Probabilities(
132
+ authentic=round(ensemble_out.probabilities.authentic, 4),
133
+ ai_generated=round(ensemble_out.probabilities.ai_generated, 4),
134
+ deepfake=round(ensemble_out.probabilities.deepfake, 4),
135
+ edited=round(ensemble_out.probabilities.edited, 4),
136
+ ),
137
+ signals=ensemble_out.signals,
138
+ provenance=Provenance(
139
+ c2pa_present=provenance_result.present,
140
+ c2pa_valid=provenance_result.valid,
141
+ issuer=provenance_result.issuer,
142
+ claim_generator=provenance_result.claim_generator,
143
+ ),
144
+ model_version=settings.model_version,
145
+ scan_id=scan_id,
146
+ latency_ms=round(latency_ms, 1),
147
+ )
148
+
149
+ # 4. Persist METADATA only — never the image.
150
+ record_scan(
151
+ ScanRecord(
152
+ scan_id=scan_id,
153
+ verdict=response.verdict,
154
+ confidence=response.confidence,
155
+ model_version=response.model_version,
156
+ latency_ms=response.latency_ms,
157
+ c2pa_present=provenance_result.present,
158
+ )
159
+ )
160
+
161
+ # 5. Drop the image bytes ASAP. (Local var goes out of scope when the
162
+ # function returns; the explicit del documents the privacy intent.)
163
+ del image_bytes
164
+
165
+ return response
166
+
167
+ @app.exception_handler(HTTPException)
168
+ async def http_exception_handler(_, exc: HTTPException) -> JSONResponse:
169
+ return JSONResponse(
170
+ status_code=exc.status_code,
171
+ content=ErrorResponse(
172
+ error=f"HTTP {exc.status_code}",
173
+ detail=str(exc.detail),
174
+ ).model_dump(),
175
+ )
176
+
177
+ return app
178
+
179
+
180
+ # Default app instance for `uvicorn deepfake_scanner.api.v1:app`.
181
+ app = create_app()
src/deepfake_scanner/config.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ max_upload_bytes: int = _env_int("MAX_UPLOAD_BYTES", 10 * 1024 * 1024) # 10 MB
38
+
39
+ # --------------------------------------------------------- Image policy
40
+ # Privacy-by-default: visitor uploads are NEVER persisted.
41
+ # Logged-in paid users will be able to opt in later via a separate flag.
42
+ store_uploads: bool = _env_bool("STORE_UPLOADS", False)
43
+
44
+ # ---------------------------------------------------------- Detectors
45
+ # Stage 1: only the CLIP-based classifier is wired up.
46
+ # Stage 2 will flip these on as the detectors are trained / verified.
47
+ enable_clip_detector: bool = _env_bool("ENABLE_CLIP_DETECTOR", True)
48
+ enable_frequency_detector: bool = _env_bool("ENABLE_FREQUENCY_DETECTOR", False)
49
+ enable_face_swap_detector: bool = _env_bool("ENABLE_FACE_SWAP_DETECTOR", False)
50
+
51
+ # The CLIP backbone we use for feature extraction.
52
+ # OpenAI CLIP ViT-B/32 weights are MIT-licensed (commercially safe).
53
+ clip_model_name: str = os.getenv("CLIP_MODEL_NAME", "openai/clip-vit-base-patch32")
54
+
55
+ # Path to the trained classifier-head checkpoint. ClipClassifier auto-
56
+ # loads this in __init__ if the file exists. The same env var works
57
+ # locally (dev machine) and inside the inference Docker image.
58
+ # Stage 3A: head_v3a.pt (multi-generator: Flux+SDXL+SD3.5+AuraFlow).
59
+ head_checkpoint_path: str = os.getenv(
60
+ "HEAD_CHECKPOINT_PATH", "data/checkpoints/head_v3a.pt"
61
+ )
62
+
63
+ # Fallback for production: if the local checkpoint file isn't present,
64
+ # ClipClassifier downloads the weights from this HF Hub model repo at
65
+ # startup. The repo is PRIVATE — the container authenticates with the
66
+ # HF_TOKEN secret set in the HF Space settings (huggingface_hub reads
67
+ # HF_TOKEN from the environment automatically). Setting this to an
68
+ # empty string disables the fallback (used in tests).
69
+ # Stage 3A: head_v3a.pt. The Stage 2 head_v1.pt remains in the same
70
+ # private repo for rollback — set HEAD_CHECKPOINT_HF_FILENAME=head_v1.pt
71
+ # (and MODEL_VERSION=v0.3.0-stage2) to revert without a redeploy.
72
+ head_checkpoint_hf_repo: str = os.getenv(
73
+ "HEAD_CHECKPOINT_HF_REPO", "Veridicate/scanner-head-v1"
74
+ )
75
+ head_checkpoint_hf_filename: str = os.getenv(
76
+ "HEAD_CHECKPOINT_HF_FILENAME", "head_v3a.pt"
77
+ )
78
+
79
+ # ---------------------------------------------------------- Provenance
80
+ enable_c2pa_check: bool = _env_bool("ENABLE_C2PA_CHECK", True)
81
+
82
+ # ------------------------------------------------------------ Storage
83
+ # Postgres URL for scan records (None → in-memory fallback for local dev).
84
+ database_url: str | None = os.getenv("DATABASE_URL")
85
+
86
+ # S3-compatible blob storage (e.g. Cloudflare R2). Only used when
87
+ # store_uploads is True AND a paid user opts in.
88
+ blob_endpoint: str | None = os.getenv("BLOB_ENDPOINT")
89
+ blob_bucket: str | None = os.getenv("BLOB_BUCKET")
90
+
91
+ # ---------------------------------------------------- Model identifier
92
+ # Surfaced in API responses so clients can pin behaviour to a version.
93
+ # v0.4.0-stage3a = CLIP head trained on a multi-generator dataset
94
+ # (50k Flux + 20k SDXL + 20k SD 3.5 + 10k AuraFlow + matched authentic).
95
+ # In-distribution test accuracy 98.43%; +5.55 pp vs the Stage 2 head on
96
+ # the SDXL hold-out (a generator never seen in training). See
97
+ # docs/stage3a-implementation.md.
98
+ # Previous: v0.3.0-stage2 (Flux-only, head_v1.pt) — still available in the
99
+ # HF Hub repo for rollback (see head_checkpoint_hf_filename above).
100
+ model_version: str = os.getenv("MODEL_VERSION", "v0.4.0-stage3a")
101
+
102
+ # --------------------------------------------------------------- CORS
103
+ # Browser preflight allowlist for the static-HTML frontend. The default
104
+ # covers the production domain, any Cloudflare Pages preview, and
105
+ # localhost (any port) for local dev. Override via env var if a new
106
+ # origin needs access.
107
+ cors_allow_origin_regex: str = os.getenv(
108
+ "CORS_ALLOW_ORIGIN_REGEX",
109
+ 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+)?)$",
110
+ )
111
+
112
+
113
+ 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,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ @torch.no_grad()
114
+ def run(self, image: Image.Image) -> DetectorResult:
115
+ model, processor = _clip_singleton.get()
116
+
117
+ # Processor handles resize, center-crop, normalize — same as CLIP's
118
+ # original training preprocessing.
119
+ inputs = processor(images=image, return_tensors="pt")
120
+ features = model.get_image_features(**inputs) # [1, 512]
121
+ features = features / features.norm(p=2, dim=-1, keepdim=True)
122
+
123
+ logits = self._head(features) # [1, 2]
124
+ probs = torch.softmax(logits, dim=-1)
125
+ p_ai = float(probs[0, 1].item())
126
+
127
+ return DetectorResult(
128
+ name=self.name,
129
+ score=p_ai,
130
+ # Stage 2: trained on Open Images V7 (real) + Flux.1-schnell (AI).
131
+ # Only authentic vs. ai_generated. Other classes left empty —
132
+ # the ensemble will not assign mass to them.
133
+ contributions={
134
+ "authentic": 1.0 - p_ai,
135
+ "ai_generated": p_ai,
136
+ },
137
+ notes=(
138
+ None
139
+ if self._trained
140
+ else (
141
+ "Stage 1 scaffold — classification head is randomly initialised. "
142
+ "Output is not meaningful until Stage 2 fine-tuning."
143
+ )
144
+ ),
145
+ )
146
+
147
+ def load_head_weights(self, checkpoint_path: str) -> None:
148
+ """Load fine-tuned head weights produced by Stage 2 training."""
149
+ state = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
150
+ self._head.load_state_dict(state)
151
+ self._head.eval()
152
+ 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/preprocess.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, 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
+ if image.mode != "RGB":
36
+ image = image.convert("RGB")
37
+
38
+ 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,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
25
+ service still works — provenance is just always reported as "not present".
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from dataclasses import dataclass
31
+
32
+ from ..config import settings
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ # Try to import c2pa lazily; degrade gracefully if unavailable.
37
+ try:
38
+ import c2pa # type: ignore
39
+ _C2PA_AVAILABLE = True
40
+ except Exception as exc: # pragma: no cover - environment-dependent
41
+ log.info("c2pa-python not available (%s); provenance check disabled.", exc)
42
+ c2pa = None # type: ignore
43
+ _C2PA_AVAILABLE = False
44
+
45
+
46
+ @dataclass
47
+ class C2paResult:
48
+ present: bool
49
+ valid: bool | None
50
+ issuer: str | None
51
+ claim_generator: str | None
52
+
53
+
54
+ _NOT_PRESENT = C2paResult(present=False, valid=None, issuer=None, claim_generator=None)
55
+
56
+
57
+ def check_c2pa(image_bytes: bytes) -> C2paResult:
58
+ """Return whatever C2PA evidence we can extract from the raw image bytes."""
59
+ if not settings.enable_c2pa_check or not _C2PA_AVAILABLE:
60
+ return _NOT_PRESENT
61
+
62
+ try:
63
+ # Newer c2pa-python exposes Reader.from_stream / Reader.from_bytes;
64
+ # API has been in flux. We use the most stable interface available.
65
+ from io import BytesIO
66
+
67
+ reader = c2pa.Reader.from_stream("image/jpeg", BytesIO(image_bytes)) # type: ignore[attr-defined]
68
+ manifest_json = reader.json()
69
+ except Exception as exc:
70
+ # Either no manifest at all, or unsupported format. Both → "not present".
71
+ log.debug("C2PA read failed: %s", exc)
72
+ return _NOT_PRESENT
73
+
74
+ import json
75
+
76
+ try:
77
+ data = json.loads(manifest_json)
78
+ except json.JSONDecodeError:
79
+ return _NOT_PRESENT
80
+
81
+ active_id = data.get("active_manifest")
82
+ manifests = data.get("manifests", {})
83
+ active = manifests.get(active_id, {}) if active_id else {}
84
+
85
+ claim_generator = active.get("claim_generator")
86
+ issuer = None
87
+ sig_info = active.get("signature_info") or {}
88
+ if isinstance(sig_info, dict):
89
+ issuer = sig_info.get("issuer")
90
+
91
+ # `validation_status` is empty / absent when verification passes.
92
+ validation_status = data.get("validation_status") or active.get("validation_status")
93
+ valid = not bool(validation_status) if validation_status is not None else True
94
+
95
+ return C2paResult(
96
+ present=True,
97
+ valid=valid,
98
+ issuer=issuer,
99
+ claim_generator=claim_generator,
100
+ )
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)