Skip to content

Replace imagehash with a bit-identical pure-Python pHash - #233

Merged
ajslater merged 1 commit into
developfrom
phash-no-imagehash
Oct 5, 2026
Merged

ajslater merged 1 commit into
developfrom
phash-no-imagehash

Conversation

@ajslater

@ajslater ajslater commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Summary

imagehash was used for one phash() call but pulled in numpy, scipy and PyWavelets: about 165 MB on Linux (two bundled OpenBLAS copies) and ~0.6 s of import. That stack is ~208 MB of the codex Docker image.

  • cover_hash.phash_int reproduces imagehash 4.3.2's phash bit for bit: LANCZOS to 32×32 grayscale, an unnormalized DCT-II on both axes, and the low 8×8 against its median.
  • The DCT is a radix-2 FFT with Makhoul's reordering, not a direct cosine sum. The butterflies cancel equal inputs exactly the way scipy's FFT does, so blank, flat and mirrored pages hash the same as imagehash. A direct sum lands up to 31 bits off on those.
  • parse_hash returns an int and raises ValueError unless given exactly 16 hex digits. A malformed Metron hash still trips the matcher's except instead of scoring. hamming_distance is (a ^ b).bit_count().
  • imagehash moves to the test group. test_cover_hash_parity.py (106 cases) compares live against it, so an imagehash release that changes output fails CI instead of drifting. cover_hash_golden.json pins today's hashes without imagehash.
  • doctor: the imagehash row becomes cover hash, a self-test against a known answer, on an asymmetric card that is stable across Pillow's resampling filters.

Parity evidence

A sweep of 2,591 hashes (every unique CBZ/CBR/CB7/CBT/PDF cover under ~/Code in original, JPEG-q25, 1/5-scale, 5%-crop and mirrored forms, plus every jpg/png/webp/gif, including ~169 real covers from codex's cover cache): 2,590 identical. The one miss is 1 bit on a mirror-symmetric Pillow APNG test fixture. There, a mathematically zero coefficient sits on an exactly-zero median, so rounding decides the bit. No comic cover differed.

Test plan

  • make fix lint ty test: 2516 passed, 1 skipped (pre-existing)
  • uv export --no-dev --all-extras | grep -iE 'numpy|scipy|pywavelets|imagehash' prints nothing

🤖 Generated with Claude Code

imagehash was used for one call, phash(), and dragged in numpy, scipy
and PyWavelets: about 165 MB installed, two copies of OpenBLAS on
Linux, and ~0.6 s of import time the first time a cover is hashed.

cover_hash.phash_int reproduces imagehash 4.3.2's phash bit for bit:
LANCZOS shrink to 32x32 grayscale, scipy.fftpack-style unnormalized
DCT-II on both axes, low 8x8 against their median. The DCT is a radix-2
FFT with Makhoul's reordering, not a direct cosine sum: the butterflies
cancel equal inputs exactly the way scipy's FFT does, so blank, flat
and mirrored pages land on the same side of the median as imagehash.
Metron's cover hashes come from imagehash, so the two must agree.

- compute_phash still returns 16 lowercase hex digits. parse_hash
  returns an int and raises ValueError unless given exactly 16 hex
  digits, so a malformed candidate hash still trips the matcher's
  except around cover_score instead of scoring. hamming_distance is
  (a ^ b).bit_count().
- imagehash moves to the test group. test_cover_hash_parity compares
  against it live on every real test image (CBZ/PDF first pages and the
  loose Captain Science pages), their JPEG-q25, 1/5-scale, 5%-crop and
  mirrored variants, and 59 synthetics (solids, gradients, checkerboard,
  split, noise, 50 seeded block images): 106 cases. An imagehash
  release that changes phash output fails CI instead of drifting.
- cover_hash_golden.json pins today's hashes of the real test images
  without needing imagehash.
- doctor: the imagehash row becomes "cover hash", a self-test that
  hashes a built-in card and checks the answer imagehash gives for it.
  The old 8x8 x*y*4 card was transposition-symmetric, which ties at
  the median; the new 16x12 card is asymmetric and keeps its hash under
  every smoothing filter Pillow has. A crash or wrong answer is an
  ERROR with a reinstall hint.

A broad sweep of 2,591 hashes (every unique CBZ/CBR/CB7/CBT/PDF cover
under ~/Code with the same four variants, plus every jpg/png/webp/gif)
matched imagehash on all but one: a Pillow APNG test fixture whose
mirror-symmetric pixels make a mathematically zero coefficient sit on
an exactly-zero median, so rounding noise decides one bit. No comic
cover differed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 569a48d into develop Oct 5, 2026
7 checks passed
@ajslater
ajslater deleted the phash-no-imagehash branch October 6, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant