rosetta-squint-hash 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: rosetta-squint-hash
3
+ Version: 1.0.0
4
+ Summary: Cross-port-stable extensions to Python imagehash, mirroring the 5 ports of rosetta-squint-hash
5
+ Author-email: Will Metcalf <william.metcalf@gmail.com>
6
+ License: BSD-2-Clause
7
+ Project-URL: Homepage, https://github.com/wmetcalf/rosetta-squint
8
+ Project-URL: Repository, https://github.com/wmetcalf/rosetta-squint
9
+ Project-URL: Issues, https://github.com/wmetcalf/rosetta-squint/issues
10
+ Project-URL: Changelog, https://github.com/wmetcalf/rosetta-squint/blob/main/CHANGELOG.md
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: imagehash==4.3.2
14
+ Requires-Dist: numpy<2.0,>=1.26
15
+ Requires-Dist: Pillow==12.2.*
16
+ Requires-Dist: PyWavelets<2.0,>=1.5
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == "test"
19
+ Provides-Extra: unpinned
20
+ Requires-Dist: imagehash<5.0,>=4.3.2; extra == "unpinned"
21
+ Requires-Dist: Pillow<14,>=12.2; extra == "unpinned"
22
+
23
+ # rosetta_squint_hash — Python port (extensions over `imagehash`)
24
+
25
+ A thin wrapper around the Python [`imagehash`](https://pypi.org/project/ImageHash/) package (4.3.2+) that adds the cross-port-stable extensions implemented across the 5 ports of `rosetta-squint-hash` (Rust, Go, Java, JS/TS, Swift).
26
+
27
+ The v1 surface that's actually different from upstream `imagehash` is **one function**: `whash_db4_robust`. Everything else (`phash`, `dhash`, `whash(mode='haar')`, `whash(mode='db4')`, `phash_simple`, `dhash_vertical`, `colorhash`, `crop_resistant_hash`, hex round-trip) is re-exported from upstream unchanged.
28
+
29
+ ## Why this package exists
30
+
31
+ Python `imagehash.whash(mode='db4')` and our 5 ports' `whashDb4` produce **the same hash** for ~93% of test fixtures. The other ~7% are pathological synthetic inputs (checkerboards, high-contrast geometric icons) where the algorithm computes a wavelet LL band whose values are mathematically exactly 0 — but float64 rounding produces tiny noise (~1e-17). The "is `coef > median`" comparison then depends on which side of zero the noise lands, which depends on the order of float additions, which depends on SIMD/FMA and compiler.
32
+
33
+ PyWavelets' C+SIMD inner loop, Rust's f64 accumulation, Java's double loops, Go's column-first traversal, JS's Number arithmetic, and Swift's Double accumulation can disagree by ~10 bits out of 256 on those inputs. **Every implementation is "right" per IEEE 754 — the input is at a tie point.**
34
+
35
+ `whash_db4_robust` resolves this by snapping `|coef| < 1e-12` to exactly 0 *before* the median + threshold step. All implementations now agree (cross-port stable) at the cost of those specific inputs producing different hex than `imagehash.whash(mode='db4')`.
36
+
37
+ For real photos this never triggers; both functions return identical output.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install -e . # from python/
43
+ ```
44
+
45
+ Not yet on PyPI.
46
+
47
+ ### Version policy
48
+
49
+ The wrapper pins upstream dependencies to the exact versions our goldens were validated against:
50
+
51
+ | Dep | Pinned to | Why |
52
+ |---|---|---|
53
+ | `imagehash` | `==4.3.2` | Algorithm output is what generates `spec/goldens.json`. New upstream release → potential drift → release of `rosetta-squint-hash` after re-validation. |
54
+ | `Pillow` | `==10.4.*` | PIL's Lanczos resize, `Image.crop` rounding, `ImageFilter.GaussianBlur` box-radius formula, and grayscale conversion are all involved in our hash pipeline. A Pillow major-version bump can change any of these and silently break parity with the 5 other ports. |
55
+ | `PyWavelets` | `>=1.5,<2.0` | db4 filter coefficients are mathematical constants; less drift risk, but bound the major version. |
56
+ | `numpy` | `>=1.26,<2.0` | NumPy 2.0 changed some default dtypes and behaviors; pin to 1.x. |
57
+
58
+ This is intentional. The upstream-tracker workflow (`.github/workflows/upstream-tracker.yml`) catches new upstream releases weekly and surfaces the goldens diff so a human can decide whether to bump and re-release.
59
+
60
+ If you need to share a Python environment with packages requiring different Pillow / imagehash versions, install with the unpinned extra:
61
+
62
+ ```bash
63
+ pip install rosetta-squint-hash[unpinned]
64
+ ```
65
+
66
+ **Caveat:** with `[unpinned]` the cross-port byte-exact guarantee is no longer enforced by the wrapper — verify your own output matches `spec/goldens.json` before relying on it. The 5 non-Python ports are unaffected (they don't link to Pillow at all).
67
+
68
+ ## Usage
69
+
70
+ ```python
71
+ import rosetta_squint_hash as rih
72
+ from PIL import Image
73
+
74
+ img = Image.open("photo.jpg")
75
+
76
+ # Drop-in upstream imagehash:
77
+ h_strict = rih.whash(img, mode="db4") # matches imagehash exactly
78
+
79
+ # Our cross-port-stable variant:
80
+ h_robust = rih.whash_db4_robust(img, hash_size=8)
81
+
82
+ print(str(h_strict)) # hex, identical to imagehash.whash output
83
+ print(str(h_robust)) # hex, identical to Rust/Go/Java/JS/Swift output
84
+
85
+ # On a real photo, the two agree:
86
+ assert str(h_strict) == str(h_robust)
87
+
88
+ # On a checkerboard, they will differ:
89
+ checker = Image.open("spec/fixtures/checker-256.png")
90
+ print(rih.whash(checker, mode="db4")) # SIMD-dependent
91
+ print(rih.whash_db4_robust(checker, hash_size=8)) # same on every platform
92
+ ```
93
+
94
+ ## When to pick which
95
+
96
+ | Use case | Function |
97
+ |---|---|
98
+ | Matching against existing hashes stored from Python `imagehash` | `rih.whash(img, mode="db4")` |
99
+ | Cross-language storage/lookup where every port (Python, Rust, Go, Java, JS, Swift) must produce the same hex for the same input | `rih.whash_db4_robust(img)` |
100
+ | Hashing photographs (untrusted or trusted) | Either; output is identical for non-pathological inputs |
101
+ | Hashing synthetic test inputs or geometric patterns | `rih.whash_db4_robust` if cross-port matters |
102
+
103
+ ## API
104
+
105
+ ```python
106
+ def whash_db4_robust(
107
+ image: PIL.Image.Image,
108
+ hash_size: int = 8,
109
+ image_scale: int | None = None,
110
+ ) -> imagehash.ImageHash:
111
+ """Cross-port-stable variant of imagehash.whash(mode='db4').
112
+
113
+ See docstring in rosetta_squint_hash/_impl.py for the full algorithm.
114
+ """
115
+
116
+ WHASH_DB4_ROBUST_EPS: float = 1e-12 # the ε threshold; fixed across ports
117
+ ```
118
+
119
+ All other names re-exported from `imagehash` are accessible as `rosetta_squint_hash.<name>`.
120
+
121
+ ## Testing
122
+
123
+ ```bash
124
+ cd python
125
+ pip install -e ".[test]"
126
+ pytest
127
+ ```
128
+
129
+ Tests verify `whash_db4_robust` output against `spec/goldens.json` — the same goldens the 5 non-Python ports test against. The Python reference produces the goldens, so this is a self-consistency check; the value comes from confirming the per-port implementations match.
130
+
131
+ ## License
132
+
133
+ BSD-2-Clause.
@@ -0,0 +1,111 @@
1
+ # rosetta_squint_hash — Python port (extensions over `imagehash`)
2
+
3
+ A thin wrapper around the Python [`imagehash`](https://pypi.org/project/ImageHash/) package (4.3.2+) that adds the cross-port-stable extensions implemented across the 5 ports of `rosetta-squint-hash` (Rust, Go, Java, JS/TS, Swift).
4
+
5
+ The v1 surface that's actually different from upstream `imagehash` is **one function**: `whash_db4_robust`. Everything else (`phash`, `dhash`, `whash(mode='haar')`, `whash(mode='db4')`, `phash_simple`, `dhash_vertical`, `colorhash`, `crop_resistant_hash`, hex round-trip) is re-exported from upstream unchanged.
6
+
7
+ ## Why this package exists
8
+
9
+ Python `imagehash.whash(mode='db4')` and our 5 ports' `whashDb4` produce **the same hash** for ~93% of test fixtures. The other ~7% are pathological synthetic inputs (checkerboards, high-contrast geometric icons) where the algorithm computes a wavelet LL band whose values are mathematically exactly 0 — but float64 rounding produces tiny noise (~1e-17). The "is `coef > median`" comparison then depends on which side of zero the noise lands, which depends on the order of float additions, which depends on SIMD/FMA and compiler.
10
+
11
+ PyWavelets' C+SIMD inner loop, Rust's f64 accumulation, Java's double loops, Go's column-first traversal, JS's Number arithmetic, and Swift's Double accumulation can disagree by ~10 bits out of 256 on those inputs. **Every implementation is "right" per IEEE 754 — the input is at a tie point.**
12
+
13
+ `whash_db4_robust` resolves this by snapping `|coef| < 1e-12` to exactly 0 *before* the median + threshold step. All implementations now agree (cross-port stable) at the cost of those specific inputs producing different hex than `imagehash.whash(mode='db4')`.
14
+
15
+ For real photos this never triggers; both functions return identical output.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install -e . # from python/
21
+ ```
22
+
23
+ Not yet on PyPI.
24
+
25
+ ### Version policy
26
+
27
+ The wrapper pins upstream dependencies to the exact versions our goldens were validated against:
28
+
29
+ | Dep | Pinned to | Why |
30
+ |---|---|---|
31
+ | `imagehash` | `==4.3.2` | Algorithm output is what generates `spec/goldens.json`. New upstream release → potential drift → release of `rosetta-squint-hash` after re-validation. |
32
+ | `Pillow` | `==10.4.*` | PIL's Lanczos resize, `Image.crop` rounding, `ImageFilter.GaussianBlur` box-radius formula, and grayscale conversion are all involved in our hash pipeline. A Pillow major-version bump can change any of these and silently break parity with the 5 other ports. |
33
+ | `PyWavelets` | `>=1.5,<2.0` | db4 filter coefficients are mathematical constants; less drift risk, but bound the major version. |
34
+ | `numpy` | `>=1.26,<2.0` | NumPy 2.0 changed some default dtypes and behaviors; pin to 1.x. |
35
+
36
+ This is intentional. The upstream-tracker workflow (`.github/workflows/upstream-tracker.yml`) catches new upstream releases weekly and surfaces the goldens diff so a human can decide whether to bump and re-release.
37
+
38
+ If you need to share a Python environment with packages requiring different Pillow / imagehash versions, install with the unpinned extra:
39
+
40
+ ```bash
41
+ pip install rosetta-squint-hash[unpinned]
42
+ ```
43
+
44
+ **Caveat:** with `[unpinned]` the cross-port byte-exact guarantee is no longer enforced by the wrapper — verify your own output matches `spec/goldens.json` before relying on it. The 5 non-Python ports are unaffected (they don't link to Pillow at all).
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ import rosetta_squint_hash as rih
50
+ from PIL import Image
51
+
52
+ img = Image.open("photo.jpg")
53
+
54
+ # Drop-in upstream imagehash:
55
+ h_strict = rih.whash(img, mode="db4") # matches imagehash exactly
56
+
57
+ # Our cross-port-stable variant:
58
+ h_robust = rih.whash_db4_robust(img, hash_size=8)
59
+
60
+ print(str(h_strict)) # hex, identical to imagehash.whash output
61
+ print(str(h_robust)) # hex, identical to Rust/Go/Java/JS/Swift output
62
+
63
+ # On a real photo, the two agree:
64
+ assert str(h_strict) == str(h_robust)
65
+
66
+ # On a checkerboard, they will differ:
67
+ checker = Image.open("spec/fixtures/checker-256.png")
68
+ print(rih.whash(checker, mode="db4")) # SIMD-dependent
69
+ print(rih.whash_db4_robust(checker, hash_size=8)) # same on every platform
70
+ ```
71
+
72
+ ## When to pick which
73
+
74
+ | Use case | Function |
75
+ |---|---|
76
+ | Matching against existing hashes stored from Python `imagehash` | `rih.whash(img, mode="db4")` |
77
+ | Cross-language storage/lookup where every port (Python, Rust, Go, Java, JS, Swift) must produce the same hex for the same input | `rih.whash_db4_robust(img)` |
78
+ | Hashing photographs (untrusted or trusted) | Either; output is identical for non-pathological inputs |
79
+ | Hashing synthetic test inputs or geometric patterns | `rih.whash_db4_robust` if cross-port matters |
80
+
81
+ ## API
82
+
83
+ ```python
84
+ def whash_db4_robust(
85
+ image: PIL.Image.Image,
86
+ hash_size: int = 8,
87
+ image_scale: int | None = None,
88
+ ) -> imagehash.ImageHash:
89
+ """Cross-port-stable variant of imagehash.whash(mode='db4').
90
+
91
+ See docstring in rosetta_squint_hash/_impl.py for the full algorithm.
92
+ """
93
+
94
+ WHASH_DB4_ROBUST_EPS: float = 1e-12 # the ε threshold; fixed across ports
95
+ ```
96
+
97
+ All other names re-exported from `imagehash` are accessible as `rosetta_squint_hash.<name>`.
98
+
99
+ ## Testing
100
+
101
+ ```bash
102
+ cd python
103
+ pip install -e ".[test]"
104
+ pytest
105
+ ```
106
+
107
+ Tests verify `whash_db4_robust` output against `spec/goldens.json` — the same goldens the 5 non-Python ports test against. The Python reference produces the goldens, so this is a self-consistency check; the value comes from confirming the per-port implementations match.
108
+
109
+ ## License
110
+
111
+ BSD-2-Clause.
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rosetta-squint-hash"
7
+ version = "1.0.0"
8
+ description = "Cross-port-stable extensions to Python imagehash, mirroring the 5 ports of rosetta-squint-hash"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "BSD-2-Clause" }
12
+ authors = [
13
+ { name = "Will Metcalf", email = "william.metcalf@gmail.com" },
14
+ ]
15
+ # Tight pinning policy: the cross-port byte-exact guarantee depends on the
16
+ # specific upstream versions our spec/goldens.json was generated against.
17
+ # A Pillow/imagehash/PyWavelets/numpy/scipy upgrade *may* change algorithm
18
+ # output in ways that diverge from our 5 non-Python ports. We only relax
19
+ # these pins when we cut a new rosetta-squint-hash release after re-running
20
+ # all goldens against the new versions. See "Version policy" in README.
21
+ #
22
+ # To intentionally use looser pins (e.g. for compatibility with another
23
+ # dependency in your environment), install with:
24
+ # pip install rosetta-squint-hash[unpinned]
25
+ # and verify your output matches against spec/goldens.json yourself.
26
+ dependencies = [
27
+ "imagehash==4.3.2",
28
+ "numpy>=1.26,<2.0",
29
+ "Pillow==12.2.*", # tested against 12.2.0; allow patch-level only
30
+ "PyWavelets>=1.5,<2.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ test = [
35
+ "pytest>=7",
36
+ ]
37
+ # Opt-in escape hatch for users who need to share a Python environment with
38
+ # packages requiring a different Pillow or imagehash version. Caveat
39
+ # emptor — cross-port byte-exact parity is no longer guaranteed.
40
+ unpinned = [
41
+ "imagehash>=4.3.2,<5.0",
42
+ "Pillow>=12.2,<14",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/wmetcalf/rosetta-squint"
47
+ Repository = "https://github.com/wmetcalf/rosetta-squint"
48
+ Issues = "https://github.com/wmetcalf/rosetta-squint/issues"
49
+ Changelog = "https://github.com/wmetcalf/rosetta-squint/blob/main/CHANGELOG.md"
50
+
51
+ [tool.setuptools.packages.find]
52
+ include = ["rosetta_squint_hash*"]
53
+ exclude = ["tests*"]
54
+
55
+ # ─── Static analysis config (consumed by the CI lint job) ─────────────────
56
+ [tool.ruff]
57
+ line-length = 120
58
+ target-version = "py39"
59
+
60
+ [tool.ruff.lint]
61
+ # Default rule set is conservative (pyflakes + pycodestyle E). The wrapper
62
+ # is small so we don't need the full pyupgrade/bugbear pack; promote any of
63
+ # these once the baseline is clean.
64
+ select = ["E", "F", "W", "B"]
65
+ ignore = [
66
+ # E501 line-too-long is enforced by line-length above; keep the rule on
67
+ # but ignore long URLs in comments via noqa per-line.
68
+ "E203",
69
+ # Allow `from X import *` for the re-export module (it's the whole point).
70
+ "F403",
71
+ "F401",
72
+ ]
73
+ exclude = ["tests"]
74
+
75
+ [tool.mypy]
76
+ python_version = "3.9"
77
+ ignore_missing_imports = true
78
+ follow_imports = "silent"
79
+ # strict=true is enforced via the CLI flag in CI (continue-on-error there so
80
+ # baseline issues don't block) — keep this file lenient so the dev-side
81
+ # `mypy rosetta_squint_hash` invocation works out of the box.
@@ -0,0 +1,97 @@
1
+ """rosetta_squint_hash — cross-port-stable variants of Python `imagehash`.
2
+
3
+ This package re-exports the public surface of `imagehash` (PyPI 4.3.2+) and
4
+ overrides every hash algorithm with a port-local wrapper. Two classes of
5
+ overrides:
6
+
7
+ 1. **RGBA composite overrides** — every hash function (``average_hash``,
8
+ ``dhash``, ``dhash_vertical``, ``whash``, ``colorhash``,
9
+ ``crop_resistant_hash``, plus the four below) first composites RGBA / LA
10
+ input against opaque black using the truncated 8-bit formula
11
+ ``out_c = (src_c * alpha) // 255``. PIL's default ``convert('L')`` /
12
+ ``convert('HSV')`` on RGBA inputs would simply *drop* the alpha channel
13
+ without compositing, which diverges from every native port (Rust, Go,
14
+ Java, JS, Swift) on any partial-alpha input.
15
+
16
+ 2. **Snap-applying overrides** — ``phash``, ``phash_simple``, ``whash_db4``,
17
+ ``whash_db4_robust`` apply both the RGBA composite *and* a
18
+ snap-to-threshold tie-break (``bit = v > threshold + SNAP_EPS``). The
19
+ snap eliminates ULP-level FP-noise divergence between this Python
20
+ reference and the matching Rust / Go / Java / JS / Swift ports,
21
+ especially at large hash sizes (32, 64) where many DCT / wavelet
22
+ coefficients cluster within ULP of the threshold.
23
+
24
+ The behaviour at sizes 4, 8, and 16 is *intentionally* allowed to drift
25
+ from upstream `imagehash` for certain pathological inputs — see
26
+ ``spec/SPEC.md`` §"Threshold tie-break" for the rationale.
27
+
28
+ Also provides:
29
+
30
+ - ``whash_db4_robust`` — the original snap-to-zero extension for
31
+ pathological symmetric inputs. Now additionally applies the snap-to-
32
+ threshold tie-break and the RGBA composite preamble.
33
+
34
+ Callers who need byte-exact upstream parity on any input (and are willing
35
+ to give up cross-port stability for partial-alpha or near-threshold
36
+ inputs) should ``import imagehash`` directly.
37
+
38
+ See ``spec/SPEC.md`` for the full pipeline-by-pipeline specification.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ # Re-export the upstream public surface that we DON'T override (constants,
44
+ # hash classes, hex helpers). Every algorithm name is imported from
45
+ # ._impl below as a port-local override.
46
+ from imagehash import ( # noqa: F401
47
+ ANTIALIAS,
48
+ ImageHash,
49
+ ImageMultiHash,
50
+ hex_to_flathash,
51
+ hex_to_hash,
52
+ hex_to_multihash,
53
+ old_hex_to_hash,
54
+ )
55
+
56
+ from ._impl import ( # noqa: F401
57
+ SNAP_EPS,
58
+ WHASH_DB4_ROBUST_EPS,
59
+ average_hash,
60
+ colorhash,
61
+ crop_resistant_hash,
62
+ dhash,
63
+ dhash_vertical,
64
+ phash,
65
+ phash_simple,
66
+ whash,
67
+ whash_db4,
68
+ whash_db4_robust,
69
+ )
70
+
71
+ __version__ = "0.1.0"
72
+
73
+ __all__ = [
74
+ "ANTIALIAS",
75
+ "ImageHash",
76
+ "ImageMultiHash",
77
+ "hex_to_flathash",
78
+ "hex_to_hash",
79
+ "hex_to_multihash",
80
+ "old_hex_to_hash",
81
+ # RGBA-compositing overrides
82
+ "average_hash",
83
+ "colorhash",
84
+ "crop_resistant_hash",
85
+ "dhash",
86
+ "dhash_vertical",
87
+ "whash",
88
+ # Snap + composite overrides
89
+ "phash",
90
+ "phash_simple",
91
+ "whash_db4",
92
+ "whash_db4_robust",
93
+ # Constants
94
+ "WHASH_DB4_ROBUST_EPS",
95
+ "SNAP_EPS",
96
+ "__version__",
97
+ ]
@@ -0,0 +1,394 @@
1
+ """Implementation of rosetta_squint_hash extensions and port-local algorithm
2
+ overrides.
3
+
4
+ This module provides:
5
+
6
+ - ``WHASH_DB4_ROBUST_EPS`` / ``whash_db4_robust``: the original snap-to-zero
7
+ extension on top of ``imagehash.whash(mode='db4')`` for pathological
8
+ symmetric inputs.
9
+ - ``SNAP_EPS``: the cross-port snap-to-threshold tie-break constant.
10
+ - ``phash`` / ``phash_simple`` / ``whash_db4``: port-local replacements for
11
+ the matching upstream ``imagehash`` functions that apply the snap-to-
12
+ threshold tie-break. ``rosetta_squint_hash`` exports THESE (not the upstream
13
+ ones) so callers get cross-port stable hashes at every supported size.
14
+ - ``average_hash`` / ``dhash`` / ``dhash_vertical`` / ``whash`` /
15
+ ``colorhash`` / ``crop_resistant_hash``: port-local wrappers around the
16
+ matching upstream functions that first composite RGBA → RGB on opaque
17
+ black using the same truncated 8-bit formula
18
+ (``out_c = floor((src_c * alpha) / 255)``) that the 5 native ports use.
19
+ PIL's ``image.convert('L')`` / ``convert('HSV')`` on an RGBA input would
20
+ simply *drop* the alpha channel, leaving the source RGB unchanged — which
21
+ diverges from every native port on any partially-transparent input.
22
+
23
+ See ``__init__.py`` docstring and ``spec/SPEC.md`` §"Threshold tie-break"
24
+ for the rationale.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import numpy as np
30
+ import pywt
31
+ import scipy.fftpack
32
+ import imagehash
33
+ from PIL import Image
34
+
35
+
36
+ def _composite_over_black_truncated(image: Image.Image) -> Image.Image:
37
+ """Composite an RGBA / LA image onto opaque black using truncated 8-bit
38
+ alpha-premultiplication (``out_c = (src_c * alpha) // 255``).
39
+
40
+ This matches the formula every native port uses (Rust ``img_rgb::to_rgb``,
41
+ JS ``toRgb`` with ``Math.trunc((r * a) / 255)``, Swift ``ImgRGB.toRGB``,
42
+ and — after the matching fix — the Go ``imgrgb.ToRGB`` and Java
43
+ ``BufferedImageRgb.convertToIntRgb``).
44
+
45
+ Why not PIL's own composite operations? Both ``Image.paste(mask=alpha)``
46
+ and ``Image.alpha_composite`` use round-half-up rounding (i.e.
47
+ ``(src_c * alpha + 127) // 255``). The 1-bit difference on partial-alpha
48
+ pixels cascades through the downstream Lanczos / DCT / wavelet pipelines
49
+ into ±1 LSB hash differences, which the differential fuzzer surfaces as
50
+ cross-port disagreements. The truncated form is therefore the normative
51
+ cross-port formula for this project.
52
+
53
+ Non-RGBA / non-LA inputs are returned in ``RGB`` mode via the standard
54
+ PIL ``convert('RGB')`` (which is a no-op fast path for already-RGB
55
+ images), so callers can immediately apply ``convert('L')`` /
56
+ ``convert('HSV')`` / etc. without further mode checks.
57
+
58
+ Args:
59
+ image: PIL Image in any mode.
60
+
61
+ Returns:
62
+ PIL Image in mode ``RGB``, with any partial alpha already
63
+ composited against opaque black. Caller is free to mutate (a new
64
+ image is returned).
65
+ """
66
+ mode = image.mode
67
+ if mode == "RGBA":
68
+ arr = np.asarray(image, dtype=np.uint16)
69
+ alpha = arr[..., 3:4]
70
+ rgb = (arr[..., :3] * alpha) // 255
71
+ return Image.fromarray(rgb.astype(np.uint8), "RGB")
72
+ if mode == "LA":
73
+ # Grayscale-with-alpha — same truncated multiply, then duplicate L
74
+ # into all three RGB channels so downstream ``convert('L')`` /
75
+ # ``convert('HSV')`` behave identically to native ports (which only
76
+ # ever see RGB inputs).
77
+ arr = np.asarray(image, dtype=np.uint16)
78
+ alpha = arr[..., 1:2]
79
+ luma = (arr[..., 0:1] * alpha) // 255
80
+ rgb = np.repeat(luma, 3, axis=-1)
81
+ return Image.fromarray(rgb.astype(np.uint8), "RGB")
82
+ if mode == "RGB":
83
+ return image
84
+ # Palette ("P") with transparency, "1", "L", "I", "F", "CMYK", "YCbCr",
85
+ # etc. — let PIL handle the convert; alpha (if any embedded in a "P"
86
+ # palette index) is not composited because non-RGBA / non-LA inputs don't
87
+ # carry a separate alpha plane this composite step is responsible for.
88
+ # Native ports' decoders also expand "P" without alpha here.
89
+ return image.convert("RGB")
90
+
91
+ WHASH_DB4_ROBUST_EPS: float = 1e-12
92
+ """ε threshold for snap-to-zero. Coefficients with ``|c| < EPS`` are snapped
93
+ to exactly 0 before median + threshold, eliminating ULP-level tie-point
94
+ ambiguity on pathological symmetric inputs.
95
+
96
+ Fixed across all 6 ports — do not vary per-call without coordinating a spec
97
+ change."""
98
+
99
+ SNAP_EPS: float = 1e-10
100
+ """ε threshold for the snap-to-threshold tie-break. Used by ``phash``,
101
+ ``phash_simple``, ``whash_db4``, and ``whash_db4_robust`` to deterministically
102
+ map near-threshold coefficients to bit 0 (``bit = v > threshold + SNAP_EPS``).
103
+
104
+ Sized to comfortably exceed cross-port FP accumulation noise (~1e-12 for DCT
105
+ on uint8 inputs, ~1e-15 for db4 wavedec2 on uint8/255 inputs) while staying
106
+ far below any meaningful signal. See ``spec/SPEC.md`` §"Threshold tie-break"."""
107
+
108
+
109
+ def phash(image: Image.Image, hash_size: int = 8, highfreq_factor: int = 4) -> imagehash.ImageHash:
110
+ """Port-local ``phash`` with snap-to-threshold tie-break and cross-port
111
+ alpha compositing.
112
+
113
+ Identical to ``imagehash.phash`` except:
114
+ - any RGBA / LA input is first composited against opaque black via the
115
+ truncated 8-bit formula (see :func:`_composite_over_black_truncated`),
116
+ matching all 5 native ports rather than PIL's default alpha-drop;
117
+ - the per-bit comparison is ``v > median + SNAP_EPS`` (deterministic
118
+ bit 0 on ties) instead of strict ``v > median``. This eliminates
119
+ cross-port FP-noise divergence at large hash sizes (32, 64) where
120
+ many DCT coefficients land within one ULP of the median.
121
+ """
122
+ if hash_size < 2:
123
+ raise ValueError("Hash size must be greater than or equal to 2")
124
+ image = _composite_over_black_truncated(image)
125
+ img_size = hash_size * highfreq_factor
126
+ L = image.convert("L").resize((img_size, img_size), imagehash.ANTIALIAS)
127
+ pixels = np.asarray(L, dtype=np.float64)
128
+ dct = scipy.fftpack.dct(scipy.fftpack.dct(pixels, axis=0), axis=1)
129
+ block = dct[:hash_size, :hash_size]
130
+ med = np.median(block)
131
+ diff = block > med + SNAP_EPS
132
+ return imagehash.ImageHash(diff)
133
+
134
+
135
+ def phash_simple(image: Image.Image, hash_size: int = 8, highfreq_factor: int = 4) -> imagehash.ImageHash:
136
+ """Port-local ``phash_simple`` with snap-to-threshold tie-break and
137
+ cross-port alpha compositing. See :func:`phash` for the rationale of
138
+ both deviations from upstream.
139
+ """
140
+ if hash_size < 2:
141
+ raise ValueError("Hash size must be greater than or equal to 2")
142
+ image = _composite_over_black_truncated(image)
143
+ img_size = hash_size * highfreq_factor
144
+ L = image.convert("L").resize((img_size, img_size), imagehash.ANTIALIAS)
145
+ pixels = np.asarray(L, dtype=np.float64)
146
+ dct = scipy.fftpack.dct(pixels)
147
+ dctlowfreq = dct[:hash_size, 1:hash_size + 1]
148
+ mean = dctlowfreq.mean()
149
+ diff = dctlowfreq > mean + SNAP_EPS
150
+ return imagehash.ImageHash(diff)
151
+
152
+
153
+ def whash_db4(
154
+ image: Image.Image,
155
+ hash_size: int = 8,
156
+ image_scale: int | None = None,
157
+ ) -> imagehash.ImageHash:
158
+ """Port-local ``whash(mode='db4')`` with snap-to-threshold tie-break and
159
+ cross-port alpha compositing.
160
+
161
+ Pipeline matches ``imagehash.whash(image, hash_size, mode='db4',
162
+ remove_max_haar_ll=True)`` exactly, except (a) any RGBA / LA input is
163
+ first composited against opaque black via the truncated 8-bit formula
164
+ (see :func:`_composite_over_black_truncated`) and (b) the per-bit
165
+ comparison is ``v > median + SNAP_EPS`` instead of strict
166
+ ``v > median``. See :func:`phash` for the rationale.
167
+ """
168
+ if image_scale is not None:
169
+ assert image_scale & (image_scale - 1) == 0, "image_scale must be power of 2"
170
+ else:
171
+ image_natural_scale = 2 ** int(np.log2(min(image.size)))
172
+ image_scale = max(image_natural_scale, hash_size)
173
+
174
+ ll_max_level = int(np.log2(image_scale))
175
+ level = int(np.log2(hash_size))
176
+ assert hash_size & (hash_size - 1) == 0, "hash_size must be power of 2"
177
+ assert level <= ll_max_level, "hash_size in a wrong range"
178
+ dwt_level = ll_max_level - level
179
+
180
+ image = _composite_over_black_truncated(image)
181
+ L = image.convert("L").resize((image_scale, image_scale), imagehash.ANTIALIAS)
182
+ pixels = np.asarray(L) / 255.0
183
+
184
+ coeffs = pywt.wavedec2(pixels, "haar", level=ll_max_level)
185
+ coeffs = list(coeffs)
186
+ coeffs[0] *= 0
187
+ pixels = pywt.waverec2(coeffs, "haar")
188
+
189
+ coeffs = pywt.wavedec2(pixels, "db4", level=dwt_level)
190
+ dwt_low = coeffs[0]
191
+
192
+ med = np.median(dwt_low)
193
+ diff = dwt_low > med + SNAP_EPS
194
+ return imagehash.ImageHash(diff)
195
+
196
+
197
+ def whash_db4_robust(
198
+ image: Image.Image,
199
+ hash_size: int = 8,
200
+ image_scale: int | None = None,
201
+ ) -> imagehash.ImageHash:
202
+ """Cross-port-stable variant of ``imagehash.whash(mode='db4')``.
203
+
204
+ Identical pipeline to ``imagehash.whash(image, hash_size, mode='db4',
205
+ remove_max_haar_ll=True)`` up to producing the LL band; then applies
206
+ BOTH stabilization steps::
207
+
208
+ dwt_low = np.where(np.abs(dwt_low) < WHASH_DB4_ROBUST_EPS, 0.0, dwt_low)
209
+ ...
210
+ diff = dwt_low > median + SNAP_EPS
211
+
212
+ The first step (snap-to-zero) handles pathological symmetric inputs whose
213
+ LL band is mathematically 0 + ULP-level noise; the second (snap-to-
214
+ threshold) handles natural images whose LL band has many coefficients
215
+ clustered near the median.
216
+
217
+ Effect:
218
+
219
+ - Real-world photographs: LL coefficients are O(0.1)–O(10) so neither snap
220
+ kicks in for non-tie values; output equals :func:`whash_db4` (modulo
221
+ bit assignments on ties).
222
+ - Pathological symmetric inputs: both snaps collapse near-zero / near-
223
+ median coefficients to a deterministic bit 0, so all 6 ports produce
224
+ identical hashes.
225
+
226
+ **Caveat:** for pathological inputs the output differs from upstream
227
+ ``imagehash.whash(mode='db4')``. Trade-off: cross-port stability vs.
228
+ upstream parity. Use this when stable hashing across language
229
+ implementations matters more than matching pre-existing imagehash output.
230
+
231
+ Args:
232
+ image: PIL Image.
233
+ hash_size: power-of-2 hash output side. Default 8.
234
+ image_scale: pre-resize scale (power of 2). Default = max image
235
+ natural scale, ``hash_size``.
236
+
237
+ Returns:
238
+ ``imagehash.ImageHash`` of shape ``(hash_size, hash_size)``.
239
+
240
+ Raises:
241
+ AssertionError: if ``hash_size`` is not a power of 2, or if
242
+ ``image_scale`` is provided and not a power of 2, or if
243
+ ``hash_size`` exceeds the allowed range for the input.
244
+ """
245
+ if image_scale is not None:
246
+ assert image_scale & (image_scale - 1) == 0, "image_scale must be power of 2"
247
+ else:
248
+ image_natural_scale = 2 ** int(np.log2(min(image.size)))
249
+ image_scale = max(image_natural_scale, hash_size)
250
+
251
+ ll_max_level = int(np.log2(image_scale))
252
+ level = int(np.log2(hash_size))
253
+ assert hash_size & (hash_size - 1) == 0, "hash_size must be power of 2"
254
+ assert level <= ll_max_level, "hash_size in a wrong range"
255
+ dwt_level = ll_max_level - level
256
+
257
+ image = _composite_over_black_truncated(image)
258
+ L = image.convert("L").resize((image_scale, image_scale), imagehash.ANTIALIAS)
259
+ pixels = np.asarray(L) / 255.0
260
+
261
+ coeffs = pywt.wavedec2(pixels, "haar", level=ll_max_level)
262
+ coeffs = list(coeffs)
263
+ coeffs[0] *= 0
264
+ pixels = pywt.waverec2(coeffs, "haar")
265
+
266
+ coeffs = pywt.wavedec2(pixels, "db4", level=dwt_level)
267
+ dwt_low = coeffs[0]
268
+
269
+ # First stabilization: snap |c| < WHASH_DB4_ROBUST_EPS to exactly 0.
270
+ dwt_low = np.where(np.abs(dwt_low) < WHASH_DB4_ROBUST_EPS, 0.0, dwt_low)
271
+
272
+ med = np.median(dwt_low)
273
+ # Second stabilization: snap coefficients within SNAP_EPS of the median
274
+ # to bit 0 via `v > median + SNAP_EPS`.
275
+ diff = dwt_low > med + SNAP_EPS
276
+ return imagehash.ImageHash(diff)
277
+
278
+
279
+ # ─── RGBA-composite overrides for the remaining 6 upstream algorithms ────────
280
+ #
281
+ # These wrappers exist solely to apply :func:`_composite_over_black_truncated`
282
+ # before forwarding into the upstream ``imagehash`` implementation. The
283
+ # upstream functions otherwise call ``image.convert('L')`` (or
284
+ # ``convert('HSV')``) directly on RGBA inputs, which would strip the alpha
285
+ # channel without compositing. The 5 native ports all composite — see the
286
+ # spec / triage report for the cross-port disagreement that motivated this.
287
+ #
288
+ # We DO forward to the upstream function (rather than re-implementing each
289
+ # pipeline) so any future upstream bugfix in the body of e.g. ``dhash``
290
+ # remains live. Only the alpha-handling preamble is overridden.
291
+
292
+
293
+ def average_hash(image: Image.Image, hash_size: int = 8, mean=np.mean) -> imagehash.ImageHash:
294
+ """Port-local ``average_hash`` with cross-port alpha compositing.
295
+
296
+ Identical to ``imagehash.average_hash`` except RGBA / LA inputs are
297
+ composited against opaque black using the truncated 8-bit formula
298
+ before the upstream algorithm runs. See
299
+ :func:`_composite_over_black_truncated`.
300
+ """
301
+ return imagehash.average_hash(
302
+ _composite_over_black_truncated(image), hash_size=hash_size, mean=mean
303
+ )
304
+
305
+
306
+ def dhash(image: Image.Image, hash_size: int = 8) -> imagehash.ImageHash:
307
+ """Port-local ``dhash`` with cross-port alpha compositing.
308
+
309
+ See :func:`average_hash` for the rationale.
310
+ """
311
+ return imagehash.dhash(
312
+ _composite_over_black_truncated(image), hash_size=hash_size
313
+ )
314
+
315
+
316
+ def dhash_vertical(image: Image.Image, hash_size: int = 8) -> imagehash.ImageHash:
317
+ """Port-local ``dhash_vertical`` with cross-port alpha compositing.
318
+
319
+ See :func:`average_hash` for the rationale.
320
+ """
321
+ return imagehash.dhash_vertical(
322
+ _composite_over_black_truncated(image), hash_size=hash_size
323
+ )
324
+
325
+
326
+ def whash(
327
+ image: Image.Image,
328
+ hash_size: int = 8,
329
+ image_scale: int | None = None,
330
+ mode: str = "haar",
331
+ remove_max_haar_ll: bool = True,
332
+ ) -> imagehash.ImageHash:
333
+ """Port-local ``whash`` with cross-port alpha compositing.
334
+
335
+ Identical to ``imagehash.whash`` except RGBA / LA inputs are composited
336
+ against opaque black using the truncated 8-bit formula before the
337
+ upstream algorithm runs. Callers using ``mode='db4'`` should prefer
338
+ :func:`whash_db4` (which additionally applies the snap-to-threshold
339
+ tie-break) for cross-port stable hashes; this :func:`whash` wrapper is
340
+ here so ``whash(mode='haar')`` callers also get correct RGBA semantics
341
+ without needing to know the underlying machinery.
342
+ """
343
+ return imagehash.whash(
344
+ _composite_over_black_truncated(image),
345
+ hash_size=hash_size,
346
+ image_scale=image_scale,
347
+ mode=mode,
348
+ remove_max_haar_ll=remove_max_haar_ll,
349
+ )
350
+
351
+
352
+ def colorhash(image: Image.Image, binbits: int = 3) -> imagehash.ImageHash:
353
+ """Port-local ``colorhash`` with cross-port alpha compositing.
354
+
355
+ ``imagehash.colorhash`` internally calls both ``image.convert('L')`` and
356
+ ``image.convert('HSV')`` on the input; PIL's RGBA→HSV conversion likewise
357
+ strips alpha rather than compositing. We composite first so the HSV
358
+ histogram is computed from the same RGB pixels the 5 native ports use.
359
+ """
360
+ return imagehash.colorhash(
361
+ _composite_over_black_truncated(image), binbits=binbits
362
+ )
363
+
364
+
365
+ def crop_resistant_hash(
366
+ image: Image.Image,
367
+ hash_func=None,
368
+ limit_segments: int | None = None,
369
+ segment_threshold: int = 128,
370
+ min_segment_size: int = 500,
371
+ segmentation_image_size: int = 300,
372
+ ) -> imagehash.ImageMultiHash:
373
+ """Port-local ``crop_resistant_hash`` with cross-port alpha compositing.
374
+
375
+ Composites the input once at the top of the pipeline so the
376
+ segmentation pass *and* each per-segment hash both see RGB pixels with
377
+ alpha already premultiplied against black. The default ``hash_func`` is
378
+ :func:`dhash` (the port-local snap/composite-applying wrapper, not
379
+ upstream's ``imagehash.dhash``) so per-segment hashes also see the
380
+ cross-port semantics — but on the composited RGB input that's already a
381
+ no-op for the alpha path, so the wrapper is only providing the
382
+ composite preamble belt-and-braces.
383
+ """
384
+ composited = _composite_over_black_truncated(image)
385
+ if hash_func is None:
386
+ hash_func = dhash
387
+ return imagehash.crop_resistant_hash(
388
+ composited,
389
+ hash_func=hash_func,
390
+ limit_segments=limit_segments,
391
+ segment_threshold=segment_threshold,
392
+ min_segment_size=min_segment_size,
393
+ segmentation_image_size=segmentation_image_size,
394
+ )
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: rosetta-squint-hash
3
+ Version: 1.0.0
4
+ Summary: Cross-port-stable extensions to Python imagehash, mirroring the 5 ports of rosetta-squint-hash
5
+ Author-email: Will Metcalf <william.metcalf@gmail.com>
6
+ License: BSD-2-Clause
7
+ Project-URL: Homepage, https://github.com/wmetcalf/rosetta-squint
8
+ Project-URL: Repository, https://github.com/wmetcalf/rosetta-squint
9
+ Project-URL: Issues, https://github.com/wmetcalf/rosetta-squint/issues
10
+ Project-URL: Changelog, https://github.com/wmetcalf/rosetta-squint/blob/main/CHANGELOG.md
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: imagehash==4.3.2
14
+ Requires-Dist: numpy<2.0,>=1.26
15
+ Requires-Dist: Pillow==12.2.*
16
+ Requires-Dist: PyWavelets<2.0,>=1.5
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == "test"
19
+ Provides-Extra: unpinned
20
+ Requires-Dist: imagehash<5.0,>=4.3.2; extra == "unpinned"
21
+ Requires-Dist: Pillow<14,>=12.2; extra == "unpinned"
22
+
23
+ # rosetta_squint_hash — Python port (extensions over `imagehash`)
24
+
25
+ A thin wrapper around the Python [`imagehash`](https://pypi.org/project/ImageHash/) package (4.3.2+) that adds the cross-port-stable extensions implemented across the 5 ports of `rosetta-squint-hash` (Rust, Go, Java, JS/TS, Swift).
26
+
27
+ The v1 surface that's actually different from upstream `imagehash` is **one function**: `whash_db4_robust`. Everything else (`phash`, `dhash`, `whash(mode='haar')`, `whash(mode='db4')`, `phash_simple`, `dhash_vertical`, `colorhash`, `crop_resistant_hash`, hex round-trip) is re-exported from upstream unchanged.
28
+
29
+ ## Why this package exists
30
+
31
+ Python `imagehash.whash(mode='db4')` and our 5 ports' `whashDb4` produce **the same hash** for ~93% of test fixtures. The other ~7% are pathological synthetic inputs (checkerboards, high-contrast geometric icons) where the algorithm computes a wavelet LL band whose values are mathematically exactly 0 — but float64 rounding produces tiny noise (~1e-17). The "is `coef > median`" comparison then depends on which side of zero the noise lands, which depends on the order of float additions, which depends on SIMD/FMA and compiler.
32
+
33
+ PyWavelets' C+SIMD inner loop, Rust's f64 accumulation, Java's double loops, Go's column-first traversal, JS's Number arithmetic, and Swift's Double accumulation can disagree by ~10 bits out of 256 on those inputs. **Every implementation is "right" per IEEE 754 — the input is at a tie point.**
34
+
35
+ `whash_db4_robust` resolves this by snapping `|coef| < 1e-12` to exactly 0 *before* the median + threshold step. All implementations now agree (cross-port stable) at the cost of those specific inputs producing different hex than `imagehash.whash(mode='db4')`.
36
+
37
+ For real photos this never triggers; both functions return identical output.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install -e . # from python/
43
+ ```
44
+
45
+ Not yet on PyPI.
46
+
47
+ ### Version policy
48
+
49
+ The wrapper pins upstream dependencies to the exact versions our goldens were validated against:
50
+
51
+ | Dep | Pinned to | Why |
52
+ |---|---|---|
53
+ | `imagehash` | `==4.3.2` | Algorithm output is what generates `spec/goldens.json`. New upstream release → potential drift → release of `rosetta-squint-hash` after re-validation. |
54
+ | `Pillow` | `==10.4.*` | PIL's Lanczos resize, `Image.crop` rounding, `ImageFilter.GaussianBlur` box-radius formula, and grayscale conversion are all involved in our hash pipeline. A Pillow major-version bump can change any of these and silently break parity with the 5 other ports. |
55
+ | `PyWavelets` | `>=1.5,<2.0` | db4 filter coefficients are mathematical constants; less drift risk, but bound the major version. |
56
+ | `numpy` | `>=1.26,<2.0` | NumPy 2.0 changed some default dtypes and behaviors; pin to 1.x. |
57
+
58
+ This is intentional. The upstream-tracker workflow (`.github/workflows/upstream-tracker.yml`) catches new upstream releases weekly and surfaces the goldens diff so a human can decide whether to bump and re-release.
59
+
60
+ If you need to share a Python environment with packages requiring different Pillow / imagehash versions, install with the unpinned extra:
61
+
62
+ ```bash
63
+ pip install rosetta-squint-hash[unpinned]
64
+ ```
65
+
66
+ **Caveat:** with `[unpinned]` the cross-port byte-exact guarantee is no longer enforced by the wrapper — verify your own output matches `spec/goldens.json` before relying on it. The 5 non-Python ports are unaffected (they don't link to Pillow at all).
67
+
68
+ ## Usage
69
+
70
+ ```python
71
+ import rosetta_squint_hash as rih
72
+ from PIL import Image
73
+
74
+ img = Image.open("photo.jpg")
75
+
76
+ # Drop-in upstream imagehash:
77
+ h_strict = rih.whash(img, mode="db4") # matches imagehash exactly
78
+
79
+ # Our cross-port-stable variant:
80
+ h_robust = rih.whash_db4_robust(img, hash_size=8)
81
+
82
+ print(str(h_strict)) # hex, identical to imagehash.whash output
83
+ print(str(h_robust)) # hex, identical to Rust/Go/Java/JS/Swift output
84
+
85
+ # On a real photo, the two agree:
86
+ assert str(h_strict) == str(h_robust)
87
+
88
+ # On a checkerboard, they will differ:
89
+ checker = Image.open("spec/fixtures/checker-256.png")
90
+ print(rih.whash(checker, mode="db4")) # SIMD-dependent
91
+ print(rih.whash_db4_robust(checker, hash_size=8)) # same on every platform
92
+ ```
93
+
94
+ ## When to pick which
95
+
96
+ | Use case | Function |
97
+ |---|---|
98
+ | Matching against existing hashes stored from Python `imagehash` | `rih.whash(img, mode="db4")` |
99
+ | Cross-language storage/lookup where every port (Python, Rust, Go, Java, JS, Swift) must produce the same hex for the same input | `rih.whash_db4_robust(img)` |
100
+ | Hashing photographs (untrusted or trusted) | Either; output is identical for non-pathological inputs |
101
+ | Hashing synthetic test inputs or geometric patterns | `rih.whash_db4_robust` if cross-port matters |
102
+
103
+ ## API
104
+
105
+ ```python
106
+ def whash_db4_robust(
107
+ image: PIL.Image.Image,
108
+ hash_size: int = 8,
109
+ image_scale: int | None = None,
110
+ ) -> imagehash.ImageHash:
111
+ """Cross-port-stable variant of imagehash.whash(mode='db4').
112
+
113
+ See docstring in rosetta_squint_hash/_impl.py for the full algorithm.
114
+ """
115
+
116
+ WHASH_DB4_ROBUST_EPS: float = 1e-12 # the ε threshold; fixed across ports
117
+ ```
118
+
119
+ All other names re-exported from `imagehash` are accessible as `rosetta_squint_hash.<name>`.
120
+
121
+ ## Testing
122
+
123
+ ```bash
124
+ cd python
125
+ pip install -e ".[test]"
126
+ pytest
127
+ ```
128
+
129
+ Tests verify `whash_db4_robust` output against `spec/goldens.json` — the same goldens the 5 non-Python ports test against. The Python reference produces the goldens, so this is a self-consistency check; the value comes from confirming the per-port implementations match.
130
+
131
+ ## License
132
+
133
+ BSD-2-Clause.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ rosetta_squint_hash/__init__.py
4
+ rosetta_squint_hash/_impl.py
5
+ rosetta_squint_hash.egg-info/PKG-INFO
6
+ rosetta_squint_hash.egg-info/SOURCES.txt
7
+ rosetta_squint_hash.egg-info/dependency_links.txt
8
+ rosetta_squint_hash.egg-info/requires.txt
9
+ rosetta_squint_hash.egg-info/top_level.txt
10
+ tests/test_reexports.py
11
+ tests/test_whash_db4_robust.py
@@ -0,0 +1,11 @@
1
+ imagehash==4.3.2
2
+ numpy<2.0,>=1.26
3
+ Pillow==12.2.*
4
+ PyWavelets<2.0,>=1.5
5
+
6
+ [test]
7
+ pytest>=7
8
+
9
+ [unpinned]
10
+ imagehash<5.0,>=4.3.2
11
+ Pillow<14,>=12.2
@@ -0,0 +1 @@
1
+ rosetta_squint_hash
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,218 @@
1
+ """Verify that rosetta_squint_hash re-exports the upstream `imagehash` surface
2
+ correctly: each public function/class is importable, callable with the same
3
+ signature, and produces byte-exact output identical to calling `imagehash`
4
+ directly (since it IS the same function — these tests guard against
5
+ accidental wrapping or shadowing).
6
+
7
+ These also serve as a self-consistency check: running them against
8
+ spec/goldens.json proves the goldens were generated by the same upstream
9
+ version we're now re-exporting.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import struct
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import pytest
20
+ from PIL import Image
21
+
22
+ import imagehash
23
+ import rosetta_squint_hash as rih
24
+
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
27
+ SPEC = REPO_ROOT / "spec"
28
+ GOLDENS_PATH = SPEC / "goldens.json"
29
+ DECODED_DIR = SPEC / "decoded"
30
+ FIXTURES_DIR = SPEC / "fixtures"
31
+
32
+
33
+ def _load_goldens() -> dict:
34
+ return json.loads(GOLDENS_PATH.read_text())
35
+
36
+
37
+ def _load_predecoded(fixture_name: str) -> Image.Image:
38
+ path = DECODED_DIR / f"{fixture_name}.rgb.bin"
39
+ blob = path.read_bytes()
40
+ w, h = struct.unpack("<II", blob[:8])
41
+ rgb = np.frombuffer(blob[8:], dtype=np.uint8).reshape((h, w, 3))
42
+ return Image.fromarray(rgb, mode="RGB")
43
+
44
+
45
+ def _sized_cases(algo: str) -> list[tuple[str, int, str]]:
46
+ g = _load_goldens()
47
+ algo_entry = g["algorithms"][algo]
48
+ out: list[tuple[str, int, str]] = []
49
+ for fix, sizes in sorted(algo_entry["fixtures"].items()):
50
+ for size_str, hex_str in sorted(sizes.items(), key=lambda kv: int(kv[0])):
51
+ if hex_str is None:
52
+ continue
53
+ out.append((fix, int(size_str), hex_str))
54
+ return out
55
+
56
+
57
+ # --- Re-export identity checks ---
58
+
59
+ def test_imagehash_version_matches_pin():
60
+ """Detect upstream version drift early."""
61
+ # Spec pins 4.3.2; allow any 4.3.x.
62
+ assert imagehash.__version__.startswith("4.3."), imagehash.__version__
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ "name",
67
+ [
68
+ "ImageHash",
69
+ "ImageMultiHash",
70
+ "hex_to_flathash",
71
+ "hex_to_hash",
72
+ "hex_to_multihash",
73
+ "old_hex_to_hash",
74
+ ],
75
+ )
76
+ def test_reexport_identity(name: str):
77
+ """Each name on rih MUST be the same object as the one on imagehash —
78
+ not a wrapper or alias. This list now only covers non-algorithm
79
+ re-exports (hash classes, hex helpers). Every algorithm name (phash,
80
+ phash_simple, whash_db4, whash_db4_robust, average_hash, dhash,
81
+ dhash_vertical, whash, colorhash, crop_resistant_hash) is a port-local
82
+ wrapper — see :func:`test_algorithm_override_is_not_upstream`."""
83
+ assert getattr(rih, name) is getattr(imagehash, name), (
84
+ f"{name}: rih.{name} is not imagehash.{name}"
85
+ )
86
+
87
+
88
+ @pytest.mark.parametrize(
89
+ "name",
90
+ [
91
+ # Snap-applying overrides (also composite RGBA)
92
+ "phash",
93
+ "phash_simple",
94
+ "whash_db4",
95
+ "whash_db4_robust",
96
+ # RGBA-compositing overrides (forward to upstream after composite)
97
+ "average_hash",
98
+ "dhash",
99
+ "dhash_vertical",
100
+ "whash",
101
+ "colorhash",
102
+ "crop_resistant_hash",
103
+ ],
104
+ )
105
+ def test_algorithm_override_is_not_upstream(name: str):
106
+ """Every algorithm exposed on rih must be a port-local wrapper, NOT the
107
+ upstream ``imagehash`` function. The snap-applying overrides apply
108
+ SNAP_EPS / EPS; the rest apply the RGBA → RGB composite preamble. Both
109
+ classes are necessary for cross-port byte-exact parity."""
110
+ if hasattr(imagehash, name):
111
+ assert getattr(rih, name) is not getattr(imagehash, name), (
112
+ f"{name}: rih.{name} must be a port-local override, not the upstream function"
113
+ )
114
+ assert callable(getattr(rih, name))
115
+
116
+
117
+ def test_extension_constant_exposed():
118
+ assert isinstance(rih.WHASH_DB4_ROBUST_EPS, float)
119
+ assert rih.WHASH_DB4_ROBUST_EPS == 1e-12
120
+ assert isinstance(rih.SNAP_EPS, float)
121
+ assert rih.SNAP_EPS == 1e-10
122
+
123
+
124
+ def test_extension_function_exposed():
125
+ assert callable(rih.whash_db4_robust)
126
+
127
+
128
+ # --- End-to-end byte-exact checks via re-exported functions ---
129
+
130
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("average_hash"),
131
+ ids=lambda v: str(v))
132
+ def test_reexport_average_hash_matches_goldens(fixture: str, size: int, expected_hex: str):
133
+ """`rih.average_hash` must produce the same hex as the frozen golden."""
134
+ img = _load_predecoded(fixture)
135
+ h = rih.average_hash(img, hash_size=size)
136
+ assert str(h) == expected_hex, (
137
+ f"{fixture} size={size}: rih.average_hash diverged from imagehash 4.3.2 — "
138
+ f"either upstream changed or the re-export is wrapped"
139
+ )
140
+
141
+
142
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("phash"),
143
+ ids=lambda v: str(v))
144
+ def test_reexport_phash_matches_goldens(fixture: str, size: int, expected_hex: str):
145
+ img = _load_predecoded(fixture)
146
+ h = rih.phash(img, hash_size=size)
147
+ assert str(h) == expected_hex
148
+
149
+
150
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("dhash"),
151
+ ids=lambda v: str(v))
152
+ def test_reexport_dhash_matches_goldens(fixture: str, size: int, expected_hex: str):
153
+ img = _load_predecoded(fixture)
154
+ h = rih.dhash(img, hash_size=size)
155
+ assert str(h) == expected_hex
156
+
157
+
158
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("phash_simple"),
159
+ ids=lambda v: str(v))
160
+ def test_reexport_phash_simple_matches_goldens(fixture: str, size: int, expected_hex: str):
161
+ img = _load_predecoded(fixture)
162
+ h = rih.phash_simple(img, hash_size=size)
163
+ assert str(h) == expected_hex
164
+
165
+
166
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("dhash_vertical"),
167
+ ids=lambda v: str(v))
168
+ def test_reexport_dhash_vertical_matches_goldens(fixture: str, size: int, expected_hex: str):
169
+ img = _load_predecoded(fixture)
170
+ h = rih.dhash_vertical(img, hash_size=size)
171
+ assert str(h) == expected_hex
172
+
173
+
174
+ @pytest.mark.parametrize("fixture,size,expected_hex", _sized_cases("whash_haar"),
175
+ ids=lambda v: str(v))
176
+ def test_reexport_whash_haar_matches_goldens(fixture: str, size: int, expected_hex: str):
177
+ img = _load_predecoded(fixture)
178
+ h = rih.whash(img, hash_size=size, mode="haar", remove_max_haar_ll=True)
179
+ assert str(h) == expected_hex
180
+
181
+
182
+ # --- crop_resistant_hash + ImageMultiHash via re-export ---
183
+
184
+ def _multihash_cases() -> list[tuple[str, str]]:
185
+ g = _load_goldens()
186
+ algo = g["algorithms"].get("crop_resistant_hash")
187
+ if not algo:
188
+ return []
189
+ return [(fix, sizes["default"]) for fix, sizes in sorted(algo["fixtures"].items())]
190
+
191
+
192
+ @pytest.mark.parametrize("fixture,expected_hex", _multihash_cases(),
193
+ ids=lambda v: str(v))
194
+ def test_reexport_crop_resistant_hash_matches_goldens(fixture: str, expected_hex: str):
195
+ """Verifies that imagehash.crop_resistant_hash via the re-export still
196
+ produces what's in goldens.json. If this fails after an `imagehash`
197
+ upgrade, the goldens are stale and need regeneration."""
198
+ img = Image.open(FIXTURES_DIR / fixture)
199
+ multihash = rih.crop_resistant_hash(img)
200
+ assert str(multihash) == expected_hex
201
+
202
+
203
+ def test_imagemultihash_subtract_returns_float():
204
+ """Documented invariant: ImageMultiHash distance is float, not int."""
205
+ img = Image.open(FIXTURES_DIR / "peppers.png")
206
+ a = rih.crop_resistant_hash(img)
207
+ b = rih.crop_resistant_hash(img)
208
+ d = a - b
209
+ assert isinstance(d, float), f"distance is {type(d).__name__}, expected float"
210
+ assert d == 0.0, "identical images should have distance 0.0"
211
+
212
+
213
+ def test_hex_to_multihash_round_trip():
214
+ img = Image.open(FIXTURES_DIR / "peppers.png")
215
+ mh = rih.crop_resistant_hash(img)
216
+ s = str(mh)
217
+ restored = rih.hex_to_multihash(s)
218
+ assert str(restored) == s, "hex round-trip failed"
@@ -0,0 +1,104 @@
1
+ """Group-2 byte-exact tests: rosetta_squint_hash.whash_db4_robust output
2
+ matches spec/goldens.json on all 21 fixtures × 2 sizes. The goldens are
3
+ generated from this same Python implementation (via spec/regenerate.py),
4
+ so this is a self-consistency check.
5
+
6
+ The value of running this test is that the other 5 ports (Rust, Go, Java,
7
+ JS, Swift) test against the same goldens — so all 6 implementations
8
+ agreeing means cross-port stability.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import struct
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import pytest
19
+ from PIL import Image
20
+
21
+ import rosetta_squint_hash as rih
22
+
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
25
+ SPEC = REPO_ROOT / "spec"
26
+ GOLDENS_PATH = SPEC / "goldens.json"
27
+ FIXTURES_DIR = SPEC / "fixtures"
28
+ DECODED_DIR = SPEC / "decoded"
29
+
30
+
31
+ def _load_goldens() -> dict:
32
+ return json.loads(GOLDENS_PATH.read_text())
33
+
34
+
35
+ def _load_predecoded(fixture_name: str) -> Image.Image:
36
+ """Read the spec/decoded/<fixture>.rgb.bin and reconstruct a PIL Image."""
37
+ path = DECODED_DIR / f"{fixture_name}.rgb.bin"
38
+ blob = path.read_bytes()
39
+ w, h = struct.unpack("<II", blob[:8])
40
+ rgb = np.frombuffer(blob[8:], dtype=np.uint8).reshape((h, w, 3))
41
+ return Image.fromarray(rgb, mode="RGB")
42
+
43
+
44
+ def _algorithm_cases(algo_name: str) -> list[tuple[str, int, str]]:
45
+ goldens = _load_goldens()
46
+ algo = goldens["algorithms"][algo_name]
47
+ cases: list[tuple[str, int, str]] = []
48
+ for fixture, sizes in sorted(algo["fixtures"].items()):
49
+ for size_str, hex_str in sorted(sizes.items(), key=lambda kv: int(kv[0])):
50
+ if hex_str is None:
51
+ continue # fixtures too small for this size
52
+ cases.append((fixture, int(size_str), hex_str))
53
+ return cases
54
+
55
+
56
+ @pytest.mark.parametrize(
57
+ "fixture,size,expected_hex",
58
+ _algorithm_cases("whash_db4_robust"),
59
+ ids=lambda v: str(v),
60
+ )
61
+ def test_whash_db4_robust_matches_goldens(fixture: str, size: int, expected_hex: str):
62
+ """Every fixture × size produces the exact hex committed in goldens.json."""
63
+ img = _load_predecoded(fixture)
64
+ h = rih.whash_db4_robust(img, hash_size=size)
65
+ assert str(h) == expected_hex, (
66
+ f"fixture={fixture} size={size}\n"
67
+ f" got {h}\n"
68
+ f" expected {expected_hex}"
69
+ )
70
+
71
+
72
+ def test_strict_and_robust_match_on_real_photo():
73
+ """For a real photograph, whash and whash_db4_robust must agree.
74
+ Demonstrates that the snap-to-zero step does NOT affect non-pathological
75
+ inputs — only pathological synthetic patterns trigger it."""
76
+ img = _load_predecoded("peppers.png")
77
+ h_strict = rih.whash(img, mode="db4")
78
+ h_robust = rih.whash_db4_robust(img)
79
+ assert str(h_strict) == str(h_robust), (
80
+ f"real photo whash output disagreed:\n"
81
+ f" strict {h_strict}\n"
82
+ f" robust {h_robust}"
83
+ )
84
+
85
+
86
+ def test_strict_and_robust_differ_on_checker():
87
+ """For a perfectly symmetric input (checker pattern), the strict and
88
+ robust variants should produce different hashes. The strict variant's
89
+ output depends on PyWavelets' SIMD path; the robust variant's output is
90
+ cross-port stable."""
91
+ img = _load_predecoded("checker-256.png")
92
+ h_strict = rih.whash(img, hash_size=16, mode="db4")
93
+ h_robust = rih.whash_db4_robust(img, hash_size=16)
94
+ assert str(h_strict) != str(h_robust), (
95
+ "Expected strict and robust to disagree on the checker pattern; "
96
+ "if they agree, the snap-to-zero step is not actually doing anything "
97
+ "and the test is meaningless."
98
+ )
99
+
100
+
101
+ def test_eps_constant():
102
+ """Ensure the epsilon constant is the same value used by the 5 other
103
+ ports (1e-12). Hardcoded — must NOT vary per-call."""
104
+ assert rih.WHASH_DB4_ROBUST_EPS == 1e-12