Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
2e175db
0
Parent(s):
Deploy from GitHub 39b3777315c11d9c8bcd39ad7bf034f2a88a7379 (filtered: code + Dockerfile + README + NOTICES only)
Browse files- Dockerfile +78 -0
- NOTICES.md +99 -0
- README.md +150 -0
- pyproject.toml +17 -0
- requirements.txt +61 -0
- scripts/dataset/README.md +159 -0
- scripts/dataset/augment_images.py +269 -0
- scripts/dataset/build_manifest.py +193 -0
- scripts/dataset/fetch_open_images.py +138 -0
- scripts/dataset/generate_auraflow_synthetic.py +217 -0
- scripts/dataset/generate_flux_synthetic.py +168 -0
- scripts/dataset/generate_sd35_synthetic.py +205 -0
- scripts/dataset/generate_sdxl_synthetic.py +202 -0
- scripts/dataset/generation_utils.py +205 -0
- scripts/dataset/prompts.txt +1711 -0
- scripts/dataset/pull_stage3a_data.sh +147 -0
- scripts/dataset/run_stage3a_smoke.py +472 -0
- scripts/dataset/split.py +149 -0
- scripts/download_weights.py +27 -0
- scripts/evaluate_head.py +291 -0
- scripts/precompute_embeddings.py +488 -0
- scripts/run_stage3a_pipeline.py +627 -0
- scripts/train_head.py +367 -0
- src/deepfake_scanner/__init__.py +3 -0
- src/deepfake_scanner/api/__init__.py +0 -0
- src/deepfake_scanner/api/schemas.py +86 -0
- src/deepfake_scanner/api/v1.py +181 -0
- src/deepfake_scanner/config.py +113 -0
- src/deepfake_scanner/detectors/__init__.py +5 -0
- src/deepfake_scanner/detectors/base.py +57 -0
- src/deepfake_scanner/detectors/clip_classifier.py +152 -0
- src/deepfake_scanner/detectors/ensemble.py +121 -0
- src/deepfake_scanner/detectors/face_swap.py +26 -0
- src/deepfake_scanner/detectors/frequency.py +26 -0
- src/deepfake_scanner/preprocess.py +38 -0
- src/deepfake_scanner/provenance/__init__.py +3 -0
- src/deepfake_scanner/provenance/c2pa.py +100 -0
- src/deepfake_scanner/storage/__init__.py +3 -0
- src/deepfake_scanner/storage/blob.py +35 -0
- 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)
|