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 @@
|
|
|
1
|
+
rosetta_squint_hash
|