scanner / scripts /precompute_embeddings.py
github-actions[bot]
Deploy from GitHub 39b3777315c11d9c8bcd39ad7bf034f2a88a7379 (filtered: code + Dockerfile + README + NOTICES only)
2e175db
Raw
History Blame Contribute Delete
16.8 kB
"""
Pre-compute CLIP image embeddings for the training set.
Why a separate step?
--------------------
CLIP forward passes are the expensive part of Stage 2 training. The CLIP
backbone is FROZEN — only the small classification head defined in
`src/deepfake_scanner/detectors/clip_classifier.py` is being trained.
Encoding ~100k images on CPU takes 1-4 hours; training the head on the
cached embeddings then takes seconds per epoch and can be iterated freely
without re-encoding.
Local feasibility
-----------------
Yes — this script runs fine on the dev laptop (i7-1195G7, 32 GB).
With the default batch size of 16, peak RAM is ~1 GB. Throughput is
roughly 50-150 ms per image depending on thread count.
Same script runs ~50-100x faster on a CUDA GPU. Use `--device cuda` only
if you happen to be on a GPU box already (e.g. the Flux generation box,
before tearing it down).
Output format
-------------
One `.npz` file per split, in `--out-dir`:
- `embeddings`: float32 array, shape (N, 512), L2-normalised
- `labels`: int8 array, shape (N,) 0 = authentic, 1 = ai_generated
- `paths`: unicode array, shape (N,) source image paths
- `sources`: unicode array, shape (N,) manifest source values
- `generators`: unicode array, shape (N,) AI generator values or ""
- `model_families`: unicode array, shape (N,) generator family values or ""
- `augmentations`: unicode array, shape (N,) augmentation version or ""
- `original_paths`: unicode array, shape (N,) original path for augmented rows
The extra metadata arrays are ignored by `train_head.py` today, but they make
the cached embeddings auditable and give Stage 3A evaluation code enough
context to separate clean rows from augmented rows.
Resumability
------------
Long runs survive crashes, lid-closes, and Ctrl-C. Each split is encoded in
small shards (`{split}_shards/shard_NNNNNNNN.npz`); on restart we count the
contiguous shards already written from index 0 and resume from there. At
the end the shards are concatenated into the final `{split}.npz` and the
shard directory is removed.
Idempotent: an existing final `.npz` is skipped unless `--force` is passed.
`--force` also wipes any leftover shard directory.
Usage
-----
python scripts/precompute_embeddings.py \\
--splits-dir data \\
--out-dir data/embeddings \\
--data-root data
Include augmented training rows:
python scripts/precompute_embeddings.py \\
--splits-dir data \\
--out-dir data/embeddings \\
--data-root data \\
--train-augment-manifest data/train_augmented.csv
Encode a separate augmented evaluation split:
python scripts/precompute_embeddings.py \\
--splits-dir data \\
--out-dir data/embeddings \\
--data-root data \\
--extra-split test_augmented=data/test_augmented.csv
"""
from __future__ import annotations
import argparse
import csv
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
# Order MUST match the head's output ordering in
# src/deepfake_scanner/detectors/clip_classifier.py (logits[..., 0] = authentic,
# logits[..., 1] = ai_generated). Drift between train and inference here
# would silently invert predictions.
CLASS_TO_LABEL: dict[str, int] = {
"authentic": 0,
"ai_generated": 1,
}
# Images per shard. 500 × 512 × 4 bytes = 1 MB on disk; small enough that a
# crash loses at most ~25 seconds of CPU work, large enough that shard I/O
# overhead is negligible.
SHARD_SIZE = 500
@dataclass(frozen=True)
class EmbeddingItem:
image_path: Path
label: int
manifest_path: str
source: str = ""
generator: str = ""
model_family: str = ""
augmentation: str = ""
original_path: str = ""
def _read_manifest(path: Path, data_root: Path) -> list[EmbeddingItem]:
items: list[EmbeddingItem] = []
with path.open() as fh:
reader = csv.DictReader(fh)
for row in reader:
cls = row["class"]
if cls not in CLASS_TO_LABEL:
raise ValueError(f"{path}: unknown class {cls!r}")
manifest_path = row["path"]
items.append(
EmbeddingItem(
image_path=data_root / manifest_path,
label=CLASS_TO_LABEL[cls],
manifest_path=manifest_path,
source=row.get("source", ""),
generator=row.get("generator", ""),
model_family=row.get("model_family", ""),
augmentation=row.get("augmentation", ""),
original_path=row.get("original_path", ""),
)
)
return items
def _read_split(
path: Path,
data_root: Path,
extra_manifests: list[Path] | None = None,
) -> list[EmbeddingItem]:
items = _read_manifest(path, data_root)
for extra_manifest in extra_manifests or []:
items.extend(_read_manifest(extra_manifest, data_root))
seen: set[str] = set()
duplicates: list[str] = []
for item in items:
if item.manifest_path in seen:
duplicates.append(item.manifest_path)
seen.add(item.manifest_path)
if duplicates:
raise ValueError(
f"Duplicate image paths in split inputs for {path}: {duplicates[:5]}"
)
return items
def _resume_cursor(shard_dir: Path) -> int:
"""How many items have been encoded already, by counting the contiguous
shard prefix starting at index 0. Corrupt shards are deleted in passing."""
if not shard_dir.exists():
return 0
by_start: dict[int, int] = {}
for p in sorted(shard_dir.glob("shard_*.npz")):
try:
with np.load(p) as z:
start = int(z["start_idx"])
end = int(z["end_idx"])
required = [
"embeddings",
"labels",
"paths",
"sources",
"generators",
"model_families",
"augmentations",
"original_paths",
]
for key in required:
if key not in z:
raise KeyError(key)
except Exception:
print(f" removing corrupt shard {p.name}")
p.unlink()
continue
by_start[start] = end
cursor = 0
while cursor in by_start:
cursor = by_start[cursor]
return cursor
def _save_shard_atomic(
shard_path: Path,
embeddings: np.ndarray,
labels: np.ndarray,
paths: np.ndarray,
sources: np.ndarray,
generators: np.ndarray,
model_families: np.ndarray,
augmentations: np.ndarray,
original_paths: np.ndarray,
start_idx: int,
end_idx: int,
) -> None:
"""Write to .tmp first, then atomic rename. Avoids partial-write corruption
if the process is killed mid-save."""
tmp = shard_path.with_suffix(".tmp.npz")
np.savez_compressed(
tmp,
embeddings=embeddings,
labels=labels,
paths=paths,
sources=sources,
generators=generators,
model_families=model_families,
augmentations=augmentations,
original_paths=original_paths,
start_idx=np.asarray(start_idx),
end_idx=np.asarray(end_idx),
)
tmp.replace(shard_path)
def _concat_shards(shard_dir: Path, out_path: Path, expected_total: int) -> None:
paths = sorted(shard_dir.glob("shard_*.npz"))
embeds_list: list[np.ndarray] = []
labels_list: list[np.ndarray] = []
paths_list: list[np.ndarray] = []
sources_list: list[np.ndarray] = []
generators_list: list[np.ndarray] = []
model_families_list: list[np.ndarray] = []
augmentations_list: list[np.ndarray] = []
original_paths_list: list[np.ndarray] = []
for p in paths:
with np.load(p) as z:
embeds_list.append(z["embeddings"].copy())
labels_list.append(z["labels"].copy())
paths_list.append(z["paths"].copy())
sources_list.append(z["sources"].copy())
generators_list.append(z["generators"].copy())
model_families_list.append(z["model_families"].copy())
augmentations_list.append(z["augmentations"].copy())
original_paths_list.append(z["original_paths"].copy())
embeddings = np.concatenate(embeds_list, axis=0)
labels = np.concatenate(labels_list, axis=0)
manifest_paths = np.concatenate(paths_list, axis=0)
sources = np.concatenate(sources_list, axis=0)
generators = np.concatenate(generators_list, axis=0)
model_families = np.concatenate(model_families_list, axis=0)
augmentations = np.concatenate(augmentations_list, axis=0)
original_paths = np.concatenate(original_paths_list, axis=0)
if len(embeddings) != expected_total:
raise RuntimeError(
f"Shard concat produced {len(embeddings)} rows but split expected "
f"{expected_total}. Refusing to write {out_path}."
)
np.savez_compressed(
out_path,
embeddings=embeddings,
labels=labels,
paths=manifest_paths,
sources=sources,
generators=generators,
model_families=model_families,
augmentations=augmentations,
original_paths=original_paths,
)
shutil.rmtree(shard_dir)
def _process_split(
split: str,
split_csv: Path,
out_dir: Path,
data_root: Path,
batch_size: int,
force: bool,
extra_manifests: list[Path],
model,
processor,
device: str,
torch_module,
Image,
) -> None:
out_path = out_dir / f"{split}.npz"
shard_dir = out_dir / f"{split}_shards"
if force:
if out_path.exists():
out_path.unlink()
if shard_dir.exists():
shutil.rmtree(shard_dir)
if out_path.exists():
print(
f"Skipping {split}{out_path} already exists. "
"Use --force to recompute."
)
return
if not split_csv.exists():
print(f"Skipping {split}{split_csv} not found.")
return
items = _read_split(split_csv, data_root, extra_manifests)
n = len(items)
print(f"\n{split}: {n} images")
if extra_manifests:
print(
f" includes {len(extra_manifests)} extra manifest(s): "
+ ", ".join(str(p) for p in extra_manifests)
)
cursor = _resume_cursor(shard_dir)
if cursor > 0:
print(f" resuming from item {cursor}/{n} ({cursor / n:.1%} done)")
shard_dir.mkdir(parents=True, exist_ok=True)
start_t = time.time()
items_at_start = cursor
while cursor < n:
shard_end = min(cursor + SHARD_SIZE, n)
shard_n = shard_end - cursor
shard_embeds = np.empty((shard_n, 512), dtype=np.float32)
shard_items = items[cursor:shard_end]
shard_labels = np.asarray(
[item.label for item in shard_items],
dtype=np.int8,
)
shard_paths = np.asarray([item.manifest_path for item in shard_items])
shard_sources = np.asarray([item.source for item in shard_items])
shard_generators = np.asarray([item.generator for item in shard_items])
shard_model_families = np.asarray(
[item.model_family for item in shard_items]
)
shard_augmentations = np.asarray(
[item.augmentation for item in shard_items]
)
shard_original_paths = np.asarray(
[item.original_path for item in shard_items]
)
pos = 0
i = cursor
while i < shard_end:
batch_end = min(i + batch_size, shard_end)
batch_paths = [item.image_path for item in items[i:batch_end]]
batch_imgs = []
for p in batch_paths:
with Image.open(p) as img:
img_rgb = img.convert("RGB")
img_rgb.load()
batch_imgs.append(img_rgb.copy())
inputs = processor(images=batch_imgs, return_tensors="pt").to(device)
with torch_module.no_grad():
feats = model.get_image_features(**inputs)
# L2-normalise to match the inference path
# (clip_classifier.py:94). Drift here breaks predictions.
feats = feats / feats.norm(p=2, dim=-1, keepdim=True)
shard_embeds[pos : pos + len(batch_imgs)] = (
feats.cpu().numpy().astype(np.float32)
)
pos += len(batch_imgs)
i = batch_end
shard_path = shard_dir / f"shard_{cursor:08d}.npz"
_save_shard_atomic(
shard_path,
shard_embeds,
shard_labels,
shard_paths,
shard_sources,
shard_generators,
shard_model_families,
shard_augmentations,
shard_original_paths,
cursor,
shard_end,
)
cursor = shard_end
elapsed = time.time() - start_t
done_this_run = cursor - items_at_start
rate = done_this_run / max(elapsed, 1e-6)
eta = (n - cursor) / max(rate, 1e-6)
print(
f" {cursor}/{n} ({rate:.1f} img/s, "
f"eta {eta / 60:.1f} min, shard saved)"
)
print(f" concatenating shards into {out_path}...")
_concat_shards(shard_dir, out_path, expected_total=n)
print(f" wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB)")
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--splits-dir", type=Path, required=True,
help="Directory containing train.csv / val.csv / test.csv")
parser.add_argument("--out-dir", type=Path, required=True,
help="Where to write {train,val,test}.npz")
parser.add_argument("--data-root", type=Path, required=True,
help="Root that the manifest 'path' column is relative to")
parser.add_argument("--batch-size", type=int, default=16)
parser.add_argument("--model", default="openai/clip-vit-base-patch32",
help="HF model name. MUST match the inference config.")
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
parser.add_argument("--force", action="store_true",
help="Recompute even if output .npz / shards already exist")
parser.add_argument(
"--train-augment-manifest",
type=Path,
action="append",
default=[],
help=(
"Augmented manifest to append to train.csv only. Can be passed "
"multiple times."
),
)
parser.add_argument(
"--extra-split",
action="append",
default=[],
metavar="NAME=CSV",
help=(
"Additional explicit split to encode, for example "
"test_augmented=data/test_augmented.csv. Can be passed multiple times."
),
)
args = parser.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
print(f"Loading CLIP model {args.model} on {args.device}...")
import torch
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
processor = CLIPProcessor.from_pretrained(args.model)
model = CLIPModel.from_pretrained(args.model).to(args.device)
model.eval()
for p in model.parameters():
p.requires_grad = False
extra_splits: list[tuple[str, Path]] = []
for spec in args.extra_split:
if "=" not in spec:
raise ValueError(f"--extra-split must be NAME=CSV, got {spec!r}")
name, csv_path = spec.split("=", 1)
if not name or "/" in name or "\\" in name:
raise ValueError(f"Invalid extra split name: {name!r}")
extra_splits.append((name, Path(csv_path)))
split_specs = [
("train", args.splits_dir / "train.csv", args.train_augment_manifest),
("val", args.splits_dir / "val.csv", []),
("test", args.splits_dir / "test.csv", []),
*[(name, path, []) for name, path in extra_splits],
]
for split, split_csv, extra_manifests in split_specs:
_process_split(
split=split,
split_csv=split_csv,
out_dir=args.out_dir,
data_root=args.data_root,
batch_size=args.batch_size,
force=args.force,
extra_manifests=extra_manifests,
model=model,
processor=processor,
device=args.device,
torch_module=torch,
Image=Image,
)
print("\nDone.")
if __name__ == "__main__":
main()