fix: filter near-uniform pHashes, lower threshold to 3
Near-uniform images (sand close-ups, solid-color shots, blank frames) have almost no gradient transitions so their dHash has very few set bits. These match many unrelated photos because "low contrast ≈ low contrast." - Skip photos whose pHash has <8 or >56 set bits out of 64 before comparing — they can't be reliably compared by dHash - Lower default threshold 5 → 3 (≤3 differing bits = genuinely near- identical, e.g. same photo saved at different compression levels) - Report "N compared" in scan result so skipped photos are visible Run "Scan library" to replace existing pairs with clean results. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e58e6de125
commit
7236028156
3 files changed
+24
-5
No files matched your search
@@ -106,7 +106,7 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||
{scanResult.newPairs === 0
|
||||
? 'No new pairs'
|
||||
: `${scanResult.newPairs} new pair${scanResult.newPairs !== 1 ? 's' : ''} found`}
|
||||
{' · '}{scanResult.photos.toLocaleString()} photos · {scanResult.duration}ms
|
||||
{' · '}{(scanResult.compared ?? scanResult.photos).toLocaleString()} compared · {scanResult.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+22
-3
@@ -4,18 +4,37 @@ import { hammingDistance } from '../worker/hash.js';
|
||||
// so the server stays responsive during a long scan.
|
||||
const SCAN_CHUNK = 50;
|
||||
|
||||
// dHash is 64 bits. A near-uniform image (solid color, blank shot, sand
|
||||
// close-up) has almost no gradient transitions so its hash has very few
|
||||
// or very many bits set. These images match many unrelated photos and
|
||||
// produce false positives. Require 8–56 set bits out of 64 (12–87% density).
|
||||
function popcount16hex(hex) {
|
||||
let n = (parseInt(hex.slice(0, 8), 16) >>> 0);
|
||||
let m = (parseInt(hex.slice(8), 16) >>> 0);
|
||||
const pc = x => { x -= (x >> 1) & 0x55555555; x = (x & 0x33333333) + ((x >> 2) & 0x33333333); x = (x + (x >> 4)) & 0x0f0f0f0f; return Math.imul(x, 0x01010101) >>> 24; };
|
||||
return pc(n) + pc(m);
|
||||
}
|
||||
|
||||
function hasGoodEntropy(phash) {
|
||||
const bits = popcount16hex(phash);
|
||||
return bits >= 8 && bits <= 56;
|
||||
}
|
||||
|
||||
export async function scan(db, threshold) {
|
||||
const THRESHOLD = threshold ?? parseInt(process.env.DEDUPE_THRESHOLD ?? '5', 10);
|
||||
const THRESHOLD = threshold ?? parseInt(process.env.DEDUPE_THRESHOLD ?? '3', 10);
|
||||
const start = Date.now();
|
||||
|
||||
// Clear all existing pairs so the result exactly matches the current threshold.
|
||||
// Dismissed pairs are also cleared — a fresh scan is a clean slate.
|
||||
db.raw.prepare('DELETE FROM duplicate_pairs').run();
|
||||
|
||||
const photos = db.raw.prepare(
|
||||
const allPhotos = db.raw.prepare(
|
||||
"SELECT id, phash FROM photos WHERE phash IS NOT NULL AND phash != '0000000000000000'"
|
||||
).all();
|
||||
|
||||
// Skip near-uniform images — their hashes are unreliable and cause false matches
|
||||
const photos = allPhotos.filter(p => hasGoodEntropy(p.phash));
|
||||
|
||||
const insert = db.raw.prepare(
|
||||
'INSERT OR IGNORE INTO duplicate_pairs (photo_id_a, photo_id_b, distance) VALUES (?, ?, ?)'
|
||||
);
|
||||
@@ -39,7 +58,7 @@ export async function scan(db, threshold) {
|
||||
})();
|
||||
}
|
||||
|
||||
return { photos: photos.length, newPairs, duration: Date.now() - start };
|
||||
return { photos: allPhotos.length, compared: photos.length, newPairs, duration: Date.now() - start };
|
||||
}
|
||||
|
||||
const ID_BATCH = 900;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ const SETTING_DEFAULTS = {
|
||||
thumb_quality: '80',
|
||||
preview_size: '1600',
|
||||
preview_quality: '85',
|
||||
dedupe_threshold: '5',
|
||||
dedupe_threshold: '3',
|
||||
// iCloud Photos sync
|
||||
icloud_enabled: '0',
|
||||
icloud_apple_id: '',
|
||||
|
||||
Reference in new issue
Block a user