Repository navigation
Add cover_hash_near filter to find issues by perceptual hash similarity - #683
Merged
Merged
Conversation
Computes the Hamming distance between two 64-bit hex phash strings in PostgreSQL using bit_count() over a bit(64) XOR, as groundwork for a near-match cover hash search in the issue API.
Lets API clients find issues whose main cover phash is within a Hamming distance of a supplied hash, sorted closest first: ?cover_hash_near=<16 hex chars>&cover_hash_max_distance=<0-16> The maximum distance defaults to 10. Invalid hashes or distances return 400, and the filter combines with the existing issue filters.
Describe the cover_hash_near and cover_hash_max_distance issue filters in the API README and IssueViewSet docstring, including how to compute a compatible hash with imagehash.phash().
Scan data from discussion #680 showed correct covers typically 6-16 bits from the scanned hash (only 57% within 10), while the closest wrong cover in a 40k-hash corpus was 12-18 bits away. Raise the default maximum distance to 16 and the cap to 24, relying on closest-first ordering rather than a threshold to surface the match. Scanned hashes are practically unique, so near-match responses would almost never be reused. Add an uncached_list_params hook to CachedListModelMixin and use it to bypass the list cache whenever cover_hash_near is given.
The main users of cover_hash_near are people tagging digital comic files, and the default of 16 was too loose for them. Testing 595 MetronInfo-tagged Captain America CBZs against the full ~184k-hash corpus showed: - 98.8% of file covers were within 8 bits of the correct issue. - The closest wrong cover was 8-16 bits away, mostly 12-14. - At 16, every query also returned unrelated covers (median 36, max 200 results). - At 10, recall was the same as at 8, and the correct issue was the only result 94.5% of the time. Camera scans are noisier, so the maximum stays at 24 for callers who need to raise it. Update the constant's comment, the API docs, the view docstring and the default-distance test to match.
Some digital editions drop the price and UPC barcode box found on the print covers Metron stores, which can put the correct issue 12-18 bits away. Suggest retrying with a larger cover_hash_max_distance when the default finds nothing, and note that collected editions often reuse an issue's cover exactly.
Some digital comics use a double-page spread as the cover, with the back cover on the left and the front on the right. Metron stores only the front cover, so the whole spread hashes far from it. Describe splitting a landscape cover at the midpoint and searching with each half's hash, as Metron-Tagger does, and add a code example.
bpepple
marked this pull request as ready for review
November 14, 2026 17:11
Only compare stored cover hashes that are exactly 16 hex characters. Before, one malformed value made the ::bit(64) cast fail and broke every near search with a 500, and a too-short hash was zero-padded into a wrong distance. Strip cover_hash_near before the list cache bypass check, matching the filter's cleaned value. A whitespace-only value used to skip the cache while returning the full, unfiltered issue list.
Add Issue.cover_hash_bits, a stored generated bigint parsed from cover_hash (NULL when blank or malformed), with a partial covering index. cover_hash_near now finds matching ids with an index-only scan of it, then filters issues by those ids. At the default distance this cuts DB time per request from ~160ms to ~22ms on production data, and the page count no longer rescans the issue table. The distance is now a plain bigint XOR rather than casting hex text to bit(64) on every row. Run VACUUM ANALYZE comicsdb_issue after migrating so the planner can use the index-only scan.
Cover combining cover_hash_near with upc, creator_id and role_id, which join to-many relations and add DISTINCT, and check that matches come back once and closest first. Document that cover_hash_max_distance is still validated when sent without cover_hash_near, and add a test for it.
Convert the query hash with HexToBigint, the same SQL expression that computes cover_hash_bits, instead of a separate Python conversion that had to stay in sync with it. PostgreSQL folds it to a constant, so the index-only scan and timing are unchanged. Define PHASH_REGEX once, for both the generated column and the filter's input validation.
Camera testing in discussion #680 never produced a top-8 match more than 16 bits off, so 20 leaves headroom. At 24 a query matched ~11k covers and took ~130ms. At 20 it is ~1k covers and ~36ms.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds near-match cover hash search to the issue API, so clients can identify an issue from a cover image even when the hash isn't an exact match:
cover_hash_bitscolumn: a stored generatedbigintthat PostgreSQL computes fromcover_hashwith aHexToBigintexpression (NULL when the hash is blank or malformed), with a partial covering index (issue_cover_hash_bits_idx). Migration0062adds both.CoverHashDistanceDB expression: computes the Hamming distance between two of those bigints in PostgreSQL asbit_count((a # b)::bit(64))(PostgreSQL 14+). The query hash is converted with the sameHexToBigintexpression as the stored column, so the two can't disagree. PostgreSQL folds it to a constant once per query. OnePHASH_REGEXconstant defines a valid hash for both the generated column and the filter's input validation.cover_hash_nearfilter: returns issues whose main cover hash is withincover_hash_max_distancebits (default 10, max 20), sorted closest first. It finds matching ids with an index-only scan ofissue_cover_hash_bits_idx, then fetches those issues by primary key. Issues without a valid cover hash are excluded, so one malformed stored value can't break the search. Invalid hashes or distances return400 Bad Request. It can be combined with the existing issue filters.uncached_list_paramshook onCachedListModelMixinbypasses the list cache whenevercover_hash_nearis given. A whitespace-only value counts as not given, matching how the filter treats it.IssueViewSetdocstring describe both parameters, how to compute a compatible hash withimagehash.phash(), and how to retry with a wider distance when the default finds nothing.Choosing the default distance
The default is set for the main users: people tagging digital comic files. I tested 1,271 MetronInfo-tagged CBZs from three series (Captain America, Batman, and Something Is Killing the Children) against all ~184k hashed issues:
Each series on its own gave similar numbers, and 10 was the best balance for all three. Going to 12 gains less than 1% recall while the share of queries that return a wrong match jumps from 7% to 38%.
What the misses turned out to be:
cover_hash_max_distancewhen the default finds nothing, and treating the results as candidates.Something Is Killing the Children #31is 18 bits off because Metron's image has a different-coloured figure from the published cover, probably the solicitation image. That cover was replaced.Captain America (2018) #1's file uses the double-page spread (back cover on the left, front on the right) as its cover, which is 24 bits off, beyond the maximum distance. Splitting it at the midpoint, as Metron-Tagger does (Match double-page spread covers against single Metron covers metron-tagger#288), puts the right half at 0 bits and makes it the only result. The README now describes this for client authors.Two limits that no threshold can fix:
Something Is Killing the Children #17matched*TPB* #4at 0 bits, ahead of the issue itself at 2.Narrowing wider searches with other filters
cover_hash_nearcan be combined with the existing issue filters, which makes a wider retry much more usable. If the client knows the cover year (from the filename or existing metadata), addingcover_yearcuts the candidates sharply. Results across the same 1,271 files:cover_year(median / 95th pct / max)cover_yearFor example:
/api/issue/?cover_hash_near=<hash>&cover_hash_max_distance=16&cover_year=2018. Filters such asseries_nameorpublisher_idnarrow results the same way. Cover years can differ from the year in a filename (which is often the release year), so a client may want to try the adjacent year too.So results should always be checked against the scanned cover. Camera scans are noisier (typically 6–16 bits off, per discussion #680), so callers matching photos can raise
cover_hash_max_distanceup to 20.Only main covers are searched. Variants have no
cover_hashyet.Performance
Metron-Tagger users tagging whole collections will send one uncached query per file, so the query has to be cheap. Measured on a copy of production data (~184k hashed issues), DB time per request:
Distances 16–24 are 40 random stored hashes, each searched against the full corpus.
Before this change, the default-distance query took about 160 ms. The old query scanned the whole 84 MB issue table, and pagination's
COUNT(*)ran that scan a second time. The new one reads about 8 MB of index, and the count only rechecks the matched ids. At large distances most of the time goes to sorting and paging the loose matches, not finding them.Choosing the maximum distance
The maximum is 20. In @erickolb's camera testing (discussion #680), no top-8 match was ever more than 16 bits off, and he suggested 20 as an upper bound. At 24 a query matched about 11k covers, which no one would page through, at about 4× the cost of 20. 20 leaves 4 bits of headroom over the worst camera match seen. Raising the maximum later won't break clients, but lowering it would.
Deploying
comicsdb_issue, so the table is locked while the migration runs (about 4 seconds locally, including the index build).VACUUM ANALYZE comicsdb_issue;right after migrating. The rewritten table starts without the visibility data the index-only scan relies on. Searches still work until it's vacuumed, just more slowly.Testing
CoverHashDistance, and that the generatedcover_hash_bitsholds the expected signed 64-bit values (including hashes with the high bit set, stored as negative bigints, and uppercase hex) and is NULL for blank or malformed hashes.cover_hash_near/cover_hash_max_distancefilters: ordering, default and max distance, validation (includingcover_hash_max_distancesent on its own), skipping malformed stored hashes, and combining with other filters. That includesupc,creator_idandrole_id, which join to-many relations and add DISTINCT.pytest tests/comicsdb/test_issue_cover_hash_filters.py tests/comicsdb/test_cover_hash_distance.py tests/comicsdb/test_api_response_caching.py: 84 passed. Full suite: 2,270 passed.