File size: 16,818 Bytes
2e175db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
"""
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()