| """
|
| audit_dedup.py β Duplicate / leakage audit for SpiceNet-Bench (Path A, step 2).
|
|
|
| Pure-CPU near-duplicate audit using a 64-bit difference hash (dHash). No extra
|
| deps (PIL + numpy only). Answers the three questions a Q1 reviewer asks of a
|
| two-source benchmark whose headline is a cross-source accuracy gap:
|
|
|
| (L) TRAIN/TEST LEAKAGE β is any test image a (near-)duplicate of a training
|
| image of the SAME source/class? Leakage inflates the within-source ~99%.
|
| (X) CROSS-SOURCE LEAK β within an overlap class, is any in-the-wild image a
|
| near-duplicate of a studio image? A cross-source dup would confound the
|
| asymmetric -38 pp finding (the existential check).
|
| (D) WITHIN-SOURCE DUPS β near-duplicate clusters inside one source (reported
|
| for the datasheet; not disqualifying on its own).
|
|
|
| Outputs outputs/dedup_audit.json + a printed verdict.
|
|
|
| python audit_dedup.py
|
| python audit_dedup.py --base outputs/unified_benchmark.json --near 5
|
| """
|
| import argparse
|
| import json
|
| from collections import defaultdict
|
| from pathlib import Path
|
|
|
| import numpy as np
|
| from PIL import Image
|
|
|
|
|
| def dhash(path, size=8):
|
| """64-bit difference hash as a Python int; None on failure."""
|
| try:
|
| img = Image.open(path).convert("L").resize((size + 1, size), Image.BILINEAR)
|
| except Exception:
|
| return None
|
| a = np.asarray(img, dtype=np.int16)
|
| diff = a[:, 1:] > a[:, :-1]
|
| packed = np.packbits(diff.flatten())
|
| return int.from_bytes(packed.tobytes(), "big")
|
|
|
|
|
| def source_of(path):
|
| p = str(path).lower().replace("\\", "/")
|
| if "indian_spices" in p:
|
| return "indian"
|
| if "spice_spectrum" in p:
|
| return "spice_spectrum"
|
| return "unknown"
|
|
|
|
|
| def popcount(arr_u64):
|
| """Vectorized set-bit count for a uint64 array."""
|
| return np.unpackbits(arr_u64.view(np.uint8).reshape(-1, 8), axis=1).sum(axis=1)
|
|
|
|
|
| def near_pairs(ha, ia, hb, ib, thr, same=False):
|
| """(i, j, dist) with Hamming(a_i, b_j) <= thr. ha/hb uint64, ia/ib orig idx."""
|
| out = []
|
| if len(ha) == 0 or len(hb) == 0:
|
| return out
|
| for k in range(len(ha)):
|
| d = popcount(np.bitwise_xor(ha[k], hb))
|
| for jb in np.where(d <= thr)[0]:
|
| if same and ib[jb] <= ia[k]:
|
| continue
|
| out.append((int(ia[k]), int(ib[jb]), int(d[jb])))
|
| return out
|
|
|
|
|
|
|
|
|
|
|
|
|
| _PIXCACHE = {}
|
|
|
|
|
| def small_gray(path, s=32):
|
| if path in _PIXCACHE:
|
| return _PIXCACHE[path]
|
| try:
|
| img = Image.open(path).convert("L").resize((s, s), Image.BILINEAR)
|
| a = np.asarray(img, dtype=np.float32) / 255.0
|
| except Exception:
|
| a = None
|
| _PIXCACHE[path] = a
|
| return a
|
|
|
|
|
| def pixel_rmse(pa, pb):
|
| a, b = small_gray(pa), small_gray(pb)
|
| if a is None or b is None:
|
| return 1.0
|
| return float(np.sqrt(np.mean((a - b) ** 2)))
|
|
|
|
|
| def verify(pairs, paths, rmse_thr):
|
| """Pixel-verify candidate pairs; return list of (i,j,hamming,rmse) with rmse<=thr."""
|
| keep = []
|
| for i, j, d in pairs:
|
| r = pixel_rmse(paths[i], paths[j])
|
| if r <= rmse_thr:
|
| keep.append((i, j, d, r))
|
| return keep
|
|
|
|
|
| def main():
|
| ap = argparse.ArgumentParser()
|
| ap.add_argument("--base", default="outputs/unified_benchmark.json")
|
| ap.add_argument("--near", type=int, default=5, help="Hamming <= near = near-duplicate")
|
| ap.add_argument("--out", default="outputs/dedup_audit.json")
|
| ap.add_argument("--max-examples", type=int, default=8)
|
| args = ap.parse_args()
|
|
|
| m = json.load(open(args.base))
|
| idx2name = {c["index"]: c["name"] for c in m["classes"]}
|
| paths, cls, src, split = [], [], [], []
|
| for sp in ("train", "val", "test"):
|
| for path, label in m["samples"][sp]:
|
| paths.append(path); cls.append(idx2name[int(label)])
|
| src.append(source_of(path)); split.append(sp)
|
| n = len(paths)
|
| cls = np.array(cls); src = np.array(src); split = np.array(split)
|
|
|
| print(f"hashing {n} images (dHash, CPU) ...")
|
| hashes = np.zeros(n, dtype=np.uint64)
|
| ok = np.ones(n, dtype=bool)
|
| for i, p in enumerate(paths):
|
| h = dhash(p)
|
| if h is None:
|
| ok[i] = False
|
| else:
|
| hashes[i] = np.uint64(h)
|
| if (i + 1) % 2000 == 0:
|
| print(f" {i + 1}/{n}")
|
| print(f" hashed ok={int(ok.sum())} fail={int((~ok).sum())}")
|
|
|
| idx = np.arange(n)
|
| classes = sorted(set(cls[ok]))
|
| src_by_cls = {c: sorted(set(src[ok & (cls == c)])) for c in classes}
|
| overlap = [c for c in classes if {"indian", "spice_spectrum"} <= set(src_by_cls[c])]
|
| print(f"classes={len(classes)} overlap(both-source)={overlap}")
|
|
|
| def grp(mask):
|
| ii = idx[ok & mask]
|
| return hashes[ii], ii
|
|
|
|
|
| x_cand, l_cand = [], []
|
| for c in overlap:
|
| hi, ii = grp((cls == c) & (src == "indian"))
|
| hs, is_ = grp((cls == c) & (src == "spice_spectrum"))
|
| x_cand += near_pairs(hi, ii, hs, is_, args.near)
|
| for c in classes:
|
| for s in ("indian", "spice_spectrum"):
|
| ht, it = grp((cls == c) & (src == s) & (split == "test"))
|
| htr, itr = grp((cls == c) & (src == s) & (np.isin(split, ["train", "val"])))
|
| l_cand += near_pairs(ht, it, htr, itr, args.near)
|
| d_count = 0
|
| for c in classes:
|
| for s in ("indian", "spice_spectrum"):
|
| h, ii = grp((cls == c) & (src == s))
|
| d_count += len(near_pairs(h, ii, h, ii, args.near, same=True))
|
|
|
| buckets = defaultdict(list)
|
| for i in idx[ok]:
|
| buckets[int(hashes[i])].append(int(i))
|
| exact_groups = [v for v in buckets.values() if len(v) > 1]
|
| exact_cross = sum(1 for g in exact_groups if len({src[i] for i in g}) > 1)
|
|
|
| print(f"candidates @ dHash<= {args.near}: cross-source={len(x_cand)} "
|
| f"leakage={len(l_cand)}; pixel-verifying {len(x_cand) + len(l_cand)} pairs ...")
|
| x_rows = [(i, j, d, pixel_rmse(paths[i], paths[j])) for i, j, d in x_cand]
|
| l_rows = [(i, j, d, pixel_rmse(paths[i], paths[j])) for i, j, d in l_cand]
|
|
|
| rmse_sweep = [0.02, 0.05, 0.10]
|
| ham_sweep = [0, 1, 2, 3, args.near]
|
| DUP = 0.05
|
|
|
| def le(rows, t):
|
| return sum(1 for *_, r in rows if r <= t)
|
|
|
| def ham_le(cand, t):
|
| return sum(1 for _, _, d in cand if d <= t)
|
|
|
| def ex(rows):
|
| rows = sorted([r for r in rows if r[3] <= DUP], key=lambda z: z[3])
|
| return [{"a": paths[i], "b": paths[j], "hamming": d, "rmse": round(r, 4),
|
| "a_src": str(src[i]), "b_src": str(src[j]),
|
| "a_split": str(split[i]), "b_split": str(split[j]), "class": str(cls[i])}
|
| for i, j, d, r in rows[: args.max_examples]]
|
|
|
| report = {
|
| "base": args.base, "n_images": n, "n_hash_fail": int((~ok).sum()),
|
| "n_classes": len(classes), "overlap_classes": overlap,
|
| "method": "dHash(64) candidate gate, then 32x32 grayscale normalized-RMSE pixel verify",
|
| "dup_rmse_threshold": DUP,
|
| "cross_source": {
|
| "candidates_dhash": len(x_cand),
|
| "candidates_by_hamming": {str(t): ham_le(x_cand, t) for t in ham_sweep},
|
| "verified_by_rmse": {str(t): le(x_rows, t) for t in rmse_sweep},
|
| "verified_dups": le(x_rows, DUP), "examples": ex(x_rows)},
|
| "train_test_leakage": {
|
| "candidates_dhash": len(l_cand),
|
| "candidates_by_hamming": {str(t): ham_le(l_cand, t) for t in ham_sweep},
|
| "verified_by_rmse": {str(t): le(l_rows, t) for t in rmse_sweep},
|
| "verified_dups": le(l_rows, DUP), "examples": ex(l_rows)},
|
| "within_source_visual_neighbours": d_count,
|
| "exact_dhash": {"groups": len(exact_groups), "cross_source_groups": exact_cross},
|
| "verdict": {"cross_source_clean": le(x_rows, DUP) == 0,
|
| "no_train_test_leakage": le(l_rows, DUP) == 0},
|
| }
|
| Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
| json.dump(report, open(args.out, "w"), indent=2)
|
|
|
| print("\n" + "=" * 72)
|
| print("DEDUP AUDIT VERDICT (verified = pixel-confirmed, normalized RMSE <= %.2f)" % DUP)
|
| print("-" * 72)
|
| print(" CROSS-SOURCE (overlap classes) β would confound the -38pp finding if >0")
|
| print(f" dHash candidates {len(x_cand)} | by Hamming {ham_sweep}: {[ham_le(x_cand,t) for t in ham_sweep]}")
|
| print(f" pixel-verified by RMSE {rmse_sweep}: {[le(x_rows,t) for t in rmse_sweep]}")
|
| print(f" => VERIFIED cross-source duplicates: {le(x_rows, DUP)} "
|
| f"{'[CLEAN]' if le(x_rows,DUP)==0 else '[INVESTIGATE]'}")
|
| print(" TRAIN/VAL -> TEST LEAKAGE (same source+class) β would inflate ~99% if >0")
|
| print(f" dHash candidates {len(l_cand)} | by Hamming {ham_sweep}: {[ham_le(l_cand,t) for t in ham_sweep]}")
|
| print(f" pixel-verified by RMSE {rmse_sweep}: {[le(l_rows,t) for t in rmse_sweep]}")
|
| print(f" => VERIFIED leakage duplicates: {le(l_rows, DUP)} "
|
| f"{'[CLEAN]' if le(l_rows,DUP)==0 else '[INVESTIGATE]'}")
|
| print(f" within-source visual neighbours (expected on studio data): {d_count}")
|
| print(f" exact-dHash groups: {len(exact_groups)} (cross-source: {exact_cross})")
|
| print(f" -> {args.out}")
|
| print("=" * 72)
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|