Spaces:
Sleeping
Sleeping
File size: 4,942 Bytes
cabc6bd | 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 | """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()
|