find_dupes: triple-gate closeness (dHash<=6, aHash<=8, color-hist L1<=0.10) + strict linkage

Measured on verified same/different pairs: dHash alone has a false-
positive floor for same-style anime frames (distinct shots at distance
<=10 and even <=6 with different color content). Same-shot broadcast
replays are pixel-twins: near-zero color-hist distance and low aHash
distance too. Requires ALL three gates, no transitive chaining.
This commit is contained in:
2026-10-18 18:51:45 -04:00
parent 7e128c4c32
commit 107f48d118
+47 -20
View File
@@ -27,10 +27,22 @@ def parse_args():
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
description=(__doc__ or "").split("\n")[0]) description=(__doc__ or "").split("\n")[0])
p.add_argument("-I", "--input-dir", required=True) p.add_argument("-I", "--input-dir", required=True)
p.add_argument("--max-dist", type=int, default=10, p.add_argument("--max-dist", type=int, default=6,
help="Max differing dHash bits (of 64) to cluster. " help="Max differing dHash bits (of 64) to cluster. "
"0-6 near-identical, 8-12 replay-same-shot, " "Broadcast replays of the identical shot differ "
">14 starts eating costume/pose changes.") "by only a few bits; verified at 10 that "
"genuinely-different frames can still be "
"within threshold (dHash floor), so default 6.")
p.add_argument("--max-adist", type=int, default=None,
help="Optional second gate: max differing aHash bits. "
"Different hashes rarely agree twice; same frames "
"always do.")
p.add_argument("--max-cdist", type=float, default=0.10,
help="Max L1 distance between normalized RGB color "
"histograms (0.128-bin/bh). Broadcast replays "
"are the SAME frames (near-zero color distance); "
"different shots differ >>0.10. This is the "
"signal that dHash lacks; set high to disable.")
p.add_argument("--report", default=None, help="CSV report path.") p.add_argument("--report", default=None, help="CSV report path.")
p.add_argument("--move", action="store_true", p.add_argument("--move", action="store_true",
help="Move non-keepsakes to --dupe-dir.") help="Move non-keepsakes to --dupe-dir.")
@@ -44,7 +56,16 @@ def parse_args():
def hashes(img): def hashes(img):
import cv2 import cv2
import numpy as np
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# color histogram signature (normalized L1 distance separates
# same-shot replays (~0) from different shots (>>0.1) — the signal
# dHash lacks: same gradient structure, different content)
hist = np.zeros(3 * 64, dtype=np.float32)
for c in range(3):
h = cv2.calcHist([img], [c], None, [64], [0, 256]).flatten()
hist[c*64:(c+1)*64] = h
hist /= hist.sum()
# dHash 8x8 gradient # dHash 8x8 gradient
small = cv2.resize(g, (9, 8), interpolation=cv2.INTER_AREA) small = cv2.resize(g, (9, 8), interpolation=cv2.INTER_AREA)
diff = small[:, 1:] > small[:, :-1] diff = small[:, 1:] > small[:, :-1]
@@ -61,7 +82,7 @@ def hashes(img):
a |= 1 << bit a |= 1 << bit
# sharpness proxy for --keep-sharp # sharpness proxy for --keep-sharp
sharp = cv2.Laplacian(g, cv2.CV_64F).var() sharp = cv2.Laplacian(g, cv2.CV_64F).var()
return d, a, sharp return d, a, sharp, hist
def main(): def main():
@@ -84,8 +105,8 @@ def main():
img = cv2.imread(os.path.join(args.input_dir, f)) img = cv2.imread(os.path.join(args.input_dir, f))
if img is None: if img is None:
continue continue
d, a, sharp = hashes(img) d, a, sharp, hist = hashes(img)
data[f] = (d, a, sharp) data[f] = (d, a, sharp, hist)
if (i + 1) % 500 == 0: if (i + 1) % 500 == 0:
print(f" {i+1}/{len(files)}", flush=True) print(f" {i+1}/{len(files)}", flush=True)
@@ -106,19 +127,27 @@ def main():
for b in range(64): for b in range(64):
ham += ((x >> b) & 1).astype(np.int16) ham += ((x >> b) & 1).astype(np.int16)
# union-find clustering on distance threshold # combined close matrix: dHash AND aHash AND color-histogram gates.
parent = list(range(n)) # dHash alone has a false-positive floor (verified: distinct frames
# at distance <=10); a second, differently-computed hash plus a
# color signature is what separates 'same frame replayed' from
# 'same show, different shot'.
close = ham <= args.max_dist
def find(i): amat = np.array([data[f][1] for f in names], dtype=np.int64)
while parent[i] != i: xa = (amat[:, None] ^ amat[None, :])
parent[i] = parent[parent[i]] aham = np.zeros(xa.shape, dtype=np.int16)
i = parent[i] for b in range(64):
return i aham += ((xa >> b) & 1).astype(np.int16)
close &= aham <= (args.max_adist
if args.max_adist is not None else 8)
def union(i, j): hmat = np.stack([data[f][3] for f in names]) # N x 192 hist rows
ri, rj = find(i), find(j) cdist = np.zeros((n, n), dtype=np.float32)
if ri != rj: for i in range(n): # row-wise keeps it ~seconds and RAM-light
parent[ri] = rj cdist[i] = np.abs(hmat - hmat[i]).sum(axis=1)
close &= cdist <= args.max_cdist
np.fill_diagonal(close, False)
# strict (complete-linkage) clustering — NO transitive chaining: # strict (complete-linkage) clustering — NO transitive chaining:
# seed with the ungrouped image having most neighbours, take its # seed with the ungrouped image having most neighbours, take its
@@ -126,8 +155,6 @@ def main():
# within threshold. Union-find chaining merges unrelated frames # within threshold. Union-find chaining merges unrelated frames
# after enough hops (verified failure: 90-frame 'group' of six # after enough hops (verified failure: 90-frame 'group' of six
# different scenes). # different scenes).
close = ham <= args.max_dist
np.fill_diagonal(close, False)
order = sorted(range(n), key=lambda i: -close[i].sum()) order = sorted(range(n), key=lambda i: -close[i].sum())
used = np.zeros(n, dtype=bool) used = np.zeros(n, dtype=bool)
clusters = [] clusters = []
@@ -138,7 +165,7 @@ def main():
if close[seed][j] and not used[j]] if close[seed][j] and not used[j]]
group = [] group = []
for j in cand: # keep only pairwise-close members for j in cand: # keep only pairwise-close members
if all(bool(ham[j][k] <= args.max_dist) for k in group): if all(bool(close[j][k]) for k in group):
group.append(j) group.append(j)
if len(group) > 1: if len(group) > 1:
for j in group: for j in group: