rosetta-squint-hash 1.0.0__py3-none-any.whl

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,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,6 @@
1
+ rosetta_squint_hash/__init__.py,sha256=V6UdDm5271bBdAPtbaZTFvVqQXCNzSZxPjI_uDmURqc,3077
2
+ rosetta_squint_hash/_impl.py,sha256=gDn9JQu4jbe0pSoJqUT5mo-YSUXWw1RNESlehGqnOGg,16604
3
+ rosetta_squint_hash-1.0.0.dist-info/METADATA,sha256=4j8k7l4Lp3sGpYaqiWFcUEKAh0IvkQ_4ayNyDRuOJWs,6533
4
+ rosetta_squint_hash-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ rosetta_squint_hash-1.0.0.dist-info/top_level.txt,sha256=3b9ElFyjcWDP1qB2_jLLt-UjaNosbeuCNZNzhL0QBPo,20
6
+ rosetta_squint_hash-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rosetta_squint_hash