"""Detect near-duplicate images shared across train/valid/test splits. The PPE dataset is built partly from video frames, and consecutive frames that land in different splits leak training data into evaluation — inflating every reported metric. This script perceptual-hashes every image and reports cross-split pairs whose hashes are within a Hamming-distance threshold. Needs a local dataset copy (scripts/download_data.py) and the `audit` group: uv run --group audit python scripts/check_split_leakage.py uv run --group audit python scripts/check_split_leakage.py --threshold 8 --csv leakage.csv Threshold guide (64-bit pHash): 0 = pixel-identical or re-encoded copies, <=4 = near-certain duplicates (crops / adjacent video frames), <=8 = likely same scene. Start at the default 4; raise to 8 to gauge how bad it could be. """ from __future__ import annotations import argparse import csv from itertools import combinations from pathlib import Path import imagehash import numpy as np from PIL import Image SPLITS = ["train", "valid", "test"] IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} def hash_split(split_dir: Path) -> tuple[list[str], np.ndarray]: """pHash every image under split_dir/images -> (filenames, (n, 64) bool array).""" files = sorted(f for f in (split_dir / "images").iterdir() if f.suffix.lower() in IMG_EXTS) hashes = np.empty((len(files), 64), dtype=bool) for i, f in enumerate(files): with Image.open(f) as im: hashes[i] = imagehash.phash(im).hash.reshape(-1) return [f.name for f in files], hashes def cross_split_pairs( ha: np.ndarray, hb: np.ndarray, threshold: int, chunk: int = 512 ) -> list[tuple[int, int, int]]: """Index pairs (i, j, distance) with Hamming distance <= threshold, chunked to bound memory.""" pairs = [] for start in range(0, len(ha), chunk): block = ha[start : start + chunk] # (chunk, n_b) Hamming distances via broadcast XOR-count. dists = (block[:, None, :] != hb[None, :, :]).sum(axis=2) for bi, j in zip(*np.nonzero(dists <= threshold), strict=True): pairs.append((start + int(bi), int(j), int(dists[bi, j]))) return pairs def main() -> None: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--data-dir", default="work", help="directory containing train/ valid/ test/") p.add_argument("--threshold", type=int, default=4, help="max Hamming distance to flag (of 64 bits)") p.add_argument("--csv", default=None, help="optional path to write the flagged pairs as CSV") args = p.parse_args() data_dir = Path(args.data_dir) splits: dict[str, tuple[list[str], np.ndarray]] = {} for split in SPLITS: if not (data_dir / split / "images").is_dir(): print(f"skip {split}: {data_dir / split / 'images'} not found") continue names, hashes = hash_split(data_dir / split) splits[split] = (names, hashes) print(f"{split}: hashed {len(names)} images") if len(splits) < 2: raise SystemExit("Need at least two splits present — fetch the dataset with scripts/download_data.py") rows: list[tuple[str, str, str, str, int]] = [] for sa, sb in combinations(splits, 2): names_a, ha = splits[sa] names_b, hb = splits[sb] pairs = cross_split_pairs(ha, hb, args.threshold) print(f"\n{sa} × {sb}: {len(pairs)} pairs within Hamming distance {args.threshold}") for i, j, d in sorted(pairs, key=lambda t: t[2])[:20]: print(f" d={d:2d} {sa}/{names_a[i]} ~ {sb}/{names_b[j]}") if len(pairs) > 20: print(f" ... {len(pairs) - 20} more (use --csv for the full list)") rows += [(sa, names_a[i], sb, names_b[j], d) for i, j, d in pairs] # The verdict that matters: what fraction of each eval split is contaminated by train? print("\n--- Summary ---") for eval_split in ("valid", "test"): if eval_split not in splits or "train" not in splits: continue contaminated = {img_b for sa, _img_a, sb, img_b, _d in rows if sa == "train" and sb == eval_split} n = len(splits[eval_split][0]) pct = 100 * len(contaminated) / n if n else 0.0 print(f"{eval_split}: {len(contaminated)}/{n} images ({pct:.1f}%) have a near-duplicate in train") if rows: print("Metrics computed on contaminated splits overstate real-world performance.") print("Fix: move each duplicate group entirely into one split, then re-run eval.") else: print("No cross-split near-duplicates at this threshold. 🎉") if args.csv: with open(args.csv, "w", newline="") as f: w = csv.writer(f) w.writerow(["split_a", "image_a", "split_b", "image_b", "hamming"]) w.writerows(rows) print(f"\nWrote {len(rows)} pairs to {args.csv}") if __name__ == "__main__": main()