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.
- rosetta_squint_hash-1.0.0/PKG-INFO +133 -0
- rosetta_squint_hash-1.0.0/README.md +111 -0
- rosetta_squint_hash-1.0.0/pyproject.toml +81 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash/__init__.py +97 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash/_impl.py +394 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash.egg-info/PKG-INFO +133 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash.egg-info/SOURCES.txt +11 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash.egg-info/dependency_links.txt +1 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash.egg-info/requires.txt +11 -0
- rosetta_squint_hash-1.0.0/rosetta_squint_hash.egg-info/top_level.txt +1 -0
- rosetta_squint_hash-1.0.0/setup.cfg +4 -0
- rosetta_squint_hash-1.0.0/tests/test_reexports.py +218 -0
- rosetta_squint_hash-1.0.0/tests/test_whash_db4_robust.py +104 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rosetta_squint_hash
|
|
@@ -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
|