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()