File size: 10,368 Bytes
1ea7ba6 | 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 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """
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()
|