| |
| """Extract a small batch of Guillaumin 2014 images to disk for sweep tests. |
| |
| Reads `data/gtsegs_ijcv.mat` and writes the first N images (or a sampled set) |
| as JPEGs named after their canonical ImageNet ids (`nXXXXXXXX_NNNN.JPEG`). |
| This makes the sweep able to look up GT masks by filename stem and compute |
| FER / mIoU / mAP end-to-end. |
| |
| Also generates `metadata.json` mapping each filename to its ImageNet-1k class |
| index (when the synset is in ILSVRC-2012; null otherwise). The mapping format |
| is consumed by `experiments.run_attack_sweep.load_sample_ground_truth_map`. |
| |
| Background: Guillaumin 2014 samples 445 synsets from the FULL ImageNet pool |
| (Deng et al. 2009, "IN-21k"), of which only 95 fall in ImageNet-1k. So roughly |
| 22% of the 4,276 images get a valid `ground_truth` integer; the rest have |
| `imagenet_id: null` and the sweep falls back to the model's clean prediction |
| for label-dependent metrics (ASR, confidence drop, top-k drop). See |
| `wiki/pesquisa-vit/metodologia-fer.md` for the full protocol rationale. |
| |
| Usage: |
| python scripts/export_guillaumin_samples.py --n 5 \ |
| --out data/guillaumin_samples |
| python scripts/export_guillaumin_samples.py --all \ |
| --out data/guillaumin_samples_full |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from pathlib import Path |
| from typing import Dict, Optional |
|
|
|
|
| def _find_project_root() -> Path: |
| cur = Path(__file__).resolve().parent |
| for parent in [cur, *cur.parents]: |
| if (parent / "requirements.txt").exists(): |
| return parent |
| raise RuntimeError(f"project root not found from {__file__}") |
|
|
|
|
| PROJECT_ROOT = _find_project_root() |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| import numpy as np |
| from PIL import Image |
|
|
| from utils.foreground import GTMaskLoader, _decode_id |
|
|
|
|
| def _build_in1k_synset_index() -> Dict[str, int]: |
| """Return mapping `synset (e.g. 'n01440764') → IN-1k class index 0..999`. |
| |
| Uses timm's canonical IN-1k synset list. Order matches the standard |
| `synset_to_idx` mapping used by ImageNet-1k pretrained classifiers. |
| """ |
| try: |
| from timm.data.imagenet_info import ImageNetInfo |
| except ImportError as exc: |
| raise ImportError( |
| "timm is required for ImageNet-1k synset mapping. " |
| "Install: pip install timm" |
| ) from exc |
| info = ImageNetInfo() |
| synsets = list(info.label_descriptions(as_dict=True).keys()) |
| return {syn: idx for idx, syn in enumerate(synsets)} |
|
|
|
|
| def _synset_for_image_id(image_id: str) -> str: |
| """`'n01322343_1025'` → `'n01322343'`.""" |
| return image_id.split("_", 1)[0] |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| "--n", |
| type=int, |
| default=5, |
| help="Number of images to export (ignored if --all is set).", |
| ) |
| parser.add_argument( |
| "--all", |
| action="store_true", |
| help="Export every image in the .mat file (overrides --n / --start).", |
| ) |
| parser.add_argument( |
| "--in1k-only", |
| action="store_true", |
| help="Only export images whose synset is in ImageNet-1k. Useful for " |
| "smoke tests where ground_truth coverage matters.", |
| ) |
| parser.add_argument( |
| "--diverse-synsets", |
| action="store_true", |
| help="Pick at most one image per synset (round-robin). Useful with " |
| "--in1k-only for a varied smoke set instead of multiple images " |
| "of the same class.", |
| ) |
| parser.add_argument( |
| "--out", |
| type=Path, |
| default=PROJECT_ROOT / "data" / "guillaumin_samples", |
| help="Output directory (created if missing).", |
| ) |
| parser.add_argument( |
| "--start", |
| type=int, |
| default=0, |
| help="Start index in the .mat file (default 0; ignored with --all).", |
| ) |
| args = parser.parse_args() |
|
|
| loader = GTMaskLoader() |
| if not loader.available: |
| print(f"ERROR: GT mask file not found at {loader.mat_path}") |
| return 1 |
|
|
| args.out.mkdir(parents=True, exist_ok=True) |
|
|
| loader._ensure_open() |
| val = loader._h5["value"] |
| total = int(np.asarray(val["n"]).squeeze()) |
|
|
| in1k_map = _build_in1k_synset_index() |
| print(f"IN-1k synset table loaded ({len(in1k_map)} synsets)") |
|
|
| |
| if args.all: |
| candidate_indices = range(total) |
| else: |
| if args.start >= total: |
| print(f"ERROR: --start {args.start} >= total images {total}") |
| return 1 |
| candidate_indices = range(args.start, total) |
|
|
| target_count = total if args.all else args.n |
| if args.in1k_only: |
| target_count = min(target_count, total) |
|
|
| print(f"Output dir: {args.out}") |
| print(f"Filter: in1k_only={args.in1k_only}, target_count={target_count}") |
|
|
| downloaded_files: list[str] = [] |
| suggested_classes: list[Dict[str, Optional[object]]] = [] |
| in1k_count = 0 |
| skipped_shape = 0 |
| skipped_ood = 0 |
| skipped_duplicate_synset = 0 |
| seen_synsets: set[str] = set() |
|
|
| for i in candidate_indices: |
| if len(downloaded_files) >= target_count: |
| break |
|
|
| ref = val["id"][i, 0] |
| image_id = _decode_id(loader._h5[ref][()]) |
| synset = _synset_for_image_id(image_id) |
| in1k_idx = in1k_map.get(synset) |
|
|
| if args.in1k_only and in1k_idx is None: |
| skipped_ood += 1 |
| continue |
| if args.diverse_synsets and synset in seen_synsets: |
| skipped_duplicate_synset += 1 |
| continue |
|
|
| img = loader._image_at(i) |
| if img.ndim != 3 or img.shape[2] != 3: |
| print(f" [{i}] {image_id} — unexpected image shape {img.shape}, skipping") |
| skipped_shape += 1 |
| continue |
| out_path = args.out / f"{image_id}.JPEG" |
| Image.fromarray(img).save(out_path, format="JPEG", quality=95) |
|
|
| downloaded_files.append(out_path.name) |
| seen_synsets.add(synset) |
| if in1k_idx is not None: |
| in1k_count += 1 |
| suggested_classes.append({ |
| "synset": synset, |
| "imagenet_id": in1k_idx if in1k_idx is not None else None, |
| "in_imagenet_1k": in1k_idx is not None, |
| }) |
| h, w = img.shape[:2] |
| in1k_marker = "[IN-1k]" if in1k_idx is not None else "[OOD]" |
| print(f" [{i}] {image_id} ({h}x{w}) -> {out_path.name} {in1k_marker}") |
|
|
| metadata = { |
| "description": ( |
| "Guillaumin 2014 ImageNet-Segmentation samples extracted from " |
| "data/gtsegs_ijcv.mat. Each image is named after its canonical " |
| "ImageNet id (synset + sample number). suggested_classes has the " |
| "same length and ordering as downloaded_files; " |
| "imagenet_id is the IN-1k class index (0..999) when the synset " |
| "is in ILSVRC-2012, and null otherwise. The sweep loader skips " |
| "null entries and falls back to clean prediction as reference." |
| ), |
| "source": "data/gtsegs_ijcv.mat (Guillaumin et al. 2014, IJCV)", |
| "imagenet_namespace": "ILSVRC-2012 (1000 classes); synsets outside " |
| "this set come from the broader ImageNet-21k pool.", |
| "exported": len(downloaded_files), |
| "in_imagenet_1k": in1k_count, |
| "out_of_distribution": len(downloaded_files) - in1k_count, |
| "downloaded_files": downloaded_files, |
| "suggested_classes": suggested_classes, |
| } |
| metadata_path = args.out / "metadata.json" |
| with open(metadata_path, "w", encoding="utf-8") as f: |
| json.dump(metadata, f, indent=2, ensure_ascii=False) |
|
|
| print(f"\nDone:") |
| print(f" exported: {len(downloaded_files)} files in {args.out}") |
| print(f" IN-1k coverage: {in1k_count}/{len(downloaded_files)} " |
| f"({100 * in1k_count / max(len(downloaded_files), 1):.1f}%)") |
| if skipped_shape: |
| print(f" skipped (bad shape): {skipped_shape}") |
| if skipped_ood: |
| print(f" skipped (OOD synset, --in1k-only): {skipped_ood}") |
| if skipped_duplicate_synset: |
| print(f" skipped (duplicate synset, --diverse-synsets): {skipped_duplicate_synset}") |
| print(f" metadata: {metadata_path}") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|