"""Build the imaging datasets for the modality classifier and authenticity detector. Imaging set (modality classifier): uv run python -m ml_training.datasets.build_datasets \ --source rocov2 --per-class 5000 --size 512 --out ml_training/data/imaging Streams ``eltorio/ROCOv2-radiology`` (never downloads the full 18.6GB archive), maps each row to a modality via the SAME CUI sets validated by ``probe_rocov2`` (``MODALITY_CUIS`` is imported, not duplicated), keeps rows matching exactly ONE of ct/mri/xray, and saves grayscale JPEGs (long edge == --size) into ``out//.jpg`` until the per-class quota is met for ALL classes. Resumable: existing files are skipped, so an interrupted build continues where it stopped. ``--limit-stream N`` caps the number of streamed rows (safety valve for tests/CI). A synthetic source is available for fast CI builds with identical layout: uv run python -m ml_training.datasets.build_datasets \ --source synthetic --per-class 30 --out ml_training/data/imaging-synth Authenticity set (real/fake detector), built FROM the imaging set: uv run python -m ml_training.datasets.build_datasets \ --authenticity --real-dir ml_training/data/imaging \ --out ml_training/data/authenticity --seed 42 Both manifests use the same deterministic hash-of-id 80/10/10 split (stable across runs and machines); the authenticity split is keyed on the SOURCE image id so a source never straddles splits. """ from __future__ import annotations import argparse import csv import hashlib import re from collections.abc import Callable, Iterator from pathlib import Path import cv2 import numpy as np from PIL import Image from ml_training.datasets import tampering from ml_training.datasets.probe_rocov2 import MODALITY_CUIS MODALITIES: tuple[str, ...] = ("ct", "mri", "xray") AUTH_LABELS: tuple[str, ...] = ("fake", "real") PROGRESS_EVERY = 250 # ------------------------------------------------------------------ split + id helpers def split_for_id(image_id: str) -> str: """Deterministic 80/10/10 train/val/test split from a stable hash of the id. Stable across runs, machines, and python versions (sha256, not ``hash()``). """ bucket = int(hashlib.sha256(image_id.encode("utf-8")).hexdigest(), 16) % 100 if bucket < 80: return "train" if bucket < 90: return "val" return "test" def _stable_seed(image_id: str) -> int: return int(hashlib.sha256(image_id.encode("utf-8")).hexdigest()[:16], 16) def _safe_id(image_id: str) -> str: return re.sub(r"[^A-Za-z0-9._-]", "_", image_id) def modality_for_cuis(cuis: list[str]) -> str | None: """Map a row's CUI list to a modality, mirroring probe_rocov2 exactly. Returns the lowercase modality only when the row matches exactly ONE modality overall and that modality is one of ct/mri/xray; otherwise ``None``. """ hits = {MODALITY_CUIS[c] for c in cuis if c in MODALITY_CUIS} hits = {"XRAY" if h.startswith("XRAY") else h for h in hits} if len(hits) != 1: return None label = next(iter(hits)).lower() return label if label in MODALITIES else None # ------------------------------------------------------------------ imaging set def _save_grayscale_jpeg(img: Image.Image, dest: Path, size: int) -> None: """Convert to grayscale and resize so the long edge equals ``size``.""" img = img.convert("L") w, h = img.size scale = size / max(w, h) if max(w, h) != size: img = img.resize( (max(1, round(w * scale)), max(1, round(h * scale))), Image.Resampling.LANCZOS ) img.save(dest, format="JPEG", quality=95) def _iter_rocov2( limit_stream: int | None, ) -> Iterator[tuple[str, str, Callable[[], Image.Image]]]: from datasets import load_dataset # heavy + network-touching: import lazily ds = load_dataset("eltorio/ROCOv2-radiology", split="train", streaming=True) for i, row in enumerate(ds): if limit_stream is not None and i >= limit_stream: return cuis = row.get("cui") or [] if isinstance(cuis, str): cuis = [cuis] modality = modality_for_cuis(list(cuis)) if modality is None: continue image_id = _safe_id(str(row.get("image_id") or f"rocov2_{i:07d}")) image = row["image"] yield image_id, modality, lambda img=image: img def _iter_synthetic( per_class: int, size: int ) -> Iterator[tuple[str, str, Callable[[], Image.Image]]]: for kind in MODALITIES: for i in range(per_class): image_id = f"synthetic_{kind}_{i:04d}" def make(kind: str = kind, image_id: str = image_id) -> Image.Image: rng = np.random.default_rng(_stable_seed(image_id)) return Image.fromarray(tampering.synth_base_image(kind, rng, size=size)) # type: ignore[arg-type] yield image_id, kind, make def write_imaging_manifest(out: Path) -> Path: """(Re)generate manifest.csv by scanning the output tree (sorted, deterministic).""" manifest = out / "manifest.csv" with manifest.open("w", newline="") as f: writer = csv.writer(f) writer.writerow(["path", "modality", "split"]) for modality in MODALITIES: for p in sorted((out / modality).glob("*.jpg")): writer.writerow([f"{modality}/{p.name}", modality, split_for_id(p.stem)]) return manifest def build_imaging_set( source: str, per_class: int, size: int, out: Path, limit_stream: int | None = None, ) -> Path: """Build out//.jpg until every class hits ``per_class``. Resumable: files already on disk count toward quotas and are never re-written. Returns the manifest path. """ out.mkdir(parents=True, exist_ok=True) have: dict[str, set[str]] = {} for modality in MODALITIES: (out / modality).mkdir(parents=True, exist_ok=True) have[modality] = {p.stem for p in (out / modality).glob("*.jpg")} if source == "rocov2": rows = _iter_rocov2(limit_stream) elif source == "synthetic": rows = _iter_synthetic(per_class, size) else: raise ValueError(f"unknown source: {source!r}") saved = 0 for image_id, modality, get_image in rows: if all(len(have[m]) >= per_class for m in MODALITIES): break if len(have[modality]) >= per_class or image_id in have[modality]: continue _save_grayscale_jpeg(get_image(), out / modality / f"{image_id}.jpg", size) have[modality].add(image_id) saved += 1 if saved % PROGRESS_EVERY == 0: counts = {m: len(have[m]) for m in MODALITIES} print(f"[build] saved={saved} counts={counts}", flush=True) counts = {m: len(have[m]) for m in MODALITIES} if any(len(have[m]) < per_class for m in MODALITIES): print(f"[build] WARNING: stream ended before quotas met: {counts}", flush=True) manifest = write_imaging_manifest(out) print(f"[build] done: saved={saved} counts={counts} manifest={manifest}", flush=True) return manifest # ------------------------------------------------------------------ authenticity set def write_authenticity_manifest(out_dir: Path) -> Path: manifest = out_dir / "manifest_auth.csv" with manifest.open("w", newline="") as f: writer = csv.writer(f) writer.writerow(["path", "label", "source_id", "split"]) for label in AUTH_LABELS: for p in sorted((out_dir / label).glob("*.png")): writer.writerow([f"{label}/{p.name}", label, p.stem, split_for_id(p.stem)]) return manifest def build_authenticity_set(real_dir: Path, out_dir: Path, rng_seed: int = 42) -> Path: """Derive the real/fake detector set from the built real images. For each source image, emit exactly one REAL sample (``final_resave`` applied) and one FAKE sample (``apply_random_tampering`` then ``final_resave``) into ``out_dir/real|fake/.png`` (PNG: lossless, so the on-disk pixels are exactly the post-resave pixels, with no extra compression step that could differ between classes). IDENTICAL-FINAL-RESAVE RATIONALE (the class-shortcut killer): every tampering op perturbs compression statistics as a side effect, so without a shared final step a detector learns the trivial shortcut "fake == re-compressed/processed pixels" instead of actual manipulation evidence (clone seams, splice boundaries, inpaint smear, resampling softness, double-JPEG ghosts). Both classes therefore pass through the SAME randomized single JPEG re-save (``tampering.final_resave``) as the very last step, equalizing compression provenance across classes. SPLIT IS KEYED ON THE SOURCE IMAGE: manifest_auth.csv (path,label,source_id,split) uses ``split_for_id(source_id)``, so the real and fake derivative of one source always land in the same split and a source never straddles splits (no near-duplicate leakage between train/val/test). Resumable and deterministic: per-source rng seeded from (rng_seed, source_id), so skipping already-built pairs never shifts the random stream of other pairs. """ sources = sorted( p for pattern in ("*.jpg", "*/*.jpg", "*.png", "*/*.png") for p in real_dir.glob(pattern) ) if len(sources) < 2: raise SystemExit(f"need at least 2 real images in {real_dir} (found {len(sources)})") for label in AUTH_LABELS: (out_dir / label).mkdir(parents=True, exist_ok=True) built = 0 for idx, src in enumerate(sources): source_id = src.stem real_out = out_dir / "real" / f"{source_id}.png" fake_out = out_dir / "fake" / f"{source_id}.png" if real_out.exists() and fake_out.exists(): continue rng = np.random.default_rng([rng_seed, _stable_seed(source_id)]) img = cv2.imread(str(src), cv2.IMREAD_GRAYSCALE) if img is None: raise SystemExit(f"failed to read real image: {src}") donor_idx = int(rng.integers(0, len(sources) - 1)) if donor_idx >= idx: donor_idx += 1 donor = cv2.imread(str(sources[donor_idx]), cv2.IMREAD_GRAYSCALE) if donor is None: raise SystemExit(f"failed to read donor image: {sources[donor_idx]}") real_img = tampering.final_resave(img, rng) fake_img, _ops = tampering.apply_random_tampering(img, donor, rng) fake_img = tampering.final_resave(fake_img, rng) if not cv2.imwrite(str(real_out), real_img): # pragma: no cover raise SystemExit(f"failed to write {real_out}") if not cv2.imwrite(str(fake_out), fake_img): # pragma: no cover raise SystemExit(f"failed to write {fake_out}") built += 1 if built % PROGRESS_EVERY == 0: print(f"[auth] built={built}/{len(sources)} source pairs", flush=True) manifest = write_authenticity_manifest(out_dir) print(f"[auth] done: {built} new pairs, {len(sources)} sources, manifest={manifest}", flush=True) return manifest # ------------------------------------------------------------------ CLI def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--source", choices=("rocov2", "synthetic"), default="rocov2") parser.add_argument("--per-class", type=int, default=5000) parser.add_argument("--size", type=int, default=512) parser.add_argument("--out", type=Path, required=True) parser.add_argument( "--limit-stream", type=int, default=None, help="cap streamed rows (test safety valve)" ) parser.add_argument( "--authenticity", action="store_true", help="build the real/fake set from --real-dir into --out (ignores --source)", ) parser.add_argument( "--real-dir", type=Path, default=None, help="imaging dir with built real images" ) parser.add_argument("--seed", type=int, default=42) args = parser.parse_args(argv) if args.authenticity: if args.real_dir is None: parser.error("--authenticity requires --real-dir") build_authenticity_set(args.real_dir, args.out, args.seed) else: build_imaging_set(args.source, args.per_class, args.size, args.out, args.limit_stream) if __name__ == "__main__": main()