SpiceNet / code /audit_dedup.py
Noushad999's picture
Upload folder using huggingface_hub
1ea7ba6 verified
Raw
History Blame Contribute Delete
10.4 kB
"""
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] # (size, size) bool
packed = np.packbits(diff.flatten()) # 8 bytes
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 # within one set: avoid self/double-count
out.append((int(ia[k]), int(ib[jb]), int(d[jb])))
return out
# dHash near-collision is necessary but NOT sufficient for "same image": on uniform
# studio backgrounds many DISTINCT photos share gross gradient structure. We confirm
# every candidate at the pixel level (32x32 grayscale, normalized RMSE) so the reported
# duplicate counts are defensible.
_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
# ── candidate pairs (loose dHash gate ≀ near), pixel-verified afterwards ─────
x_cand, l_cand = [], []
for c in overlap: # (X) cross-source, overlap classes
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: # (L) train/val -> test, same source+class
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 # (D) within-source neighbours (informational)
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) # exact dHash collisions
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] # normalized-RMSE; 0.02 ~ re-encoded identical
ham_sweep = [0, 1, 2, 3, args.near]
DUP = 0.05 # pixel-confirmed duplicate threshold
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()