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:
+47
-20
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user