Implement near-duplicate detection via perceptual hashing #16
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
v0.1.0 detects exact duplicates by SHA-256, but a photo scanned twice — different
scanner settings, a re-scan after a crooked first pass, or the same print appearing in
two albums — produces different bytes and slips through. The schema already anticipates
this:
DuplicateType.nearandJobType.duplicate_scanexist and are unused.Scope
Perceptual-hash computation at ingest, a backfill job for the existing collection, and
candidate near-duplicate pair detection.
Implementation notes
SHA-256, and store it on
Photo. Requires a migration.family collection. Bucket candidates first — hamming-distance-indexed prefixes or a
BK-tree — and document the chosen approach and its expected cost.
duplicate_scanjob type handles backfill over already-ingested photos so thefeature applies retroactively, not just to new scans.
positive shown to a reviewer is cheap; a false negative that silently merges two
distinct photographs is not — and crucially, nothing here may auto-merge or delete.
Detection proposes; the reviewer disposes.
of one negative) must remain independently reviewable.
Done when
References
backend/app/models/models.py(DuplicateType.near,JobType.duplicate_scan)backend/app/services/ingest.py(existing SHA-256 exact detection)docs/circa-spec.mdPhase 2Depends on: #2 (worker runtime).
Context from #144, which deliberately left this alone.
#144 wired ingest up to enqueue OCR, and stopped there.
docs/circa-spec.md§8.3 step 9 also asks ingest to enqueue near-duplicate detection — this issue — and that half is still unwired:JobType.duplicate_scanremains unused, exactly as the body says. Nothing has changed about that; recording it so the gap is not rediscovered as a surprise.Three things landed since this was written that change how it should be built.
1. Ingest is no longer one path, and "compute a perceptual hash at ingest" now has to say where. #105 gave
ingest_photofive outcomes. Two of them matter here:The lesson #105 paid for is worth reusing: derive the condition from the variable that decided where the bytes went, not from a list of outcomes.
services/ingest.pydoes this for the OCR enqueue and the comment there explains why.2. There is now one place to enqueue from.
app/services/jobs.py::enqueue_photo_jobholds the attempt numbering, the idempotency key, the in-flight fast path and the race recovery, and is called by both the rerun route and ingest. Use it rather than writing a third copy — two restatements of one rule is what caused a real bug in #105.Note its
IntegrityErrorrecovery is a savepoint, notdb.rollback(), specifically so a queue collision cannot discard an in-flight ingest's photograph.3.
sha256_backexists now, added in #105 so a re-presented back scan can recognise itself. Not a perceptual hash and no substitute for one, but worth knowing before adding another hash column: the ingest ledger (ingest_record) also records the content hash of every file ever presented, which may be a cheaper place to answer some of the backfill questions than a pass over originals.Nothing here changes the scope or the conclusions in the body — the quadratic-comparison warning and the "detection proposes, the reviewer disposes" rule are untouched and remain the important parts.