blurhash-rs 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.
- blurhash_rs-1.0.0/Cargo.lock +172 -0
- blurhash_rs-1.0.0/Cargo.toml +17 -0
- blurhash_rs-1.0.0/PKG-INFO +92 -0
- blurhash_rs-1.0.0/README.md +62 -0
- blurhash_rs-1.0.0/benchmark.py +123 -0
- blurhash_rs-1.0.0/build.rs +53 -0
- blurhash_rs-1.0.0/pyproject.toml +68 -0
- blurhash_rs-1.0.0/python/blurhash_rs/__init__.py +53 -0
- blurhash_rs-1.0.0/python/blurhash_rs/py.typed +0 -0
- blurhash_rs-1.0.0/src/base83.rs +53 -0
- blurhash_rs-1.0.0/src/cos.rs +88 -0
- blurhash_rs-1.0.0/src/decode.rs +153 -0
- blurhash_rs-1.0.0/src/encode.rs +195 -0
- blurhash_rs-1.0.0/src/lib.rs +51 -0
- blurhash_rs-1.0.0/src/srgb.rs +25 -0
- blurhash_rs-1.0.0/tests/data/decode_known_hash_8x8.rgbhex +10 -0
- blurhash_rs-1.0.0/tests/data/small_pattern_4x3.rgbhex +5 -0
- blurhash_rs-1.0.0/tests/test___init__.py +112 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "autocfg"
|
|
7
|
+
version = "1.5.0"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "blurhash_rs"
|
|
13
|
+
version = "1.0.0"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pyo3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[[package]]
|
|
19
|
+
name = "heck"
|
|
20
|
+
version = "0.5.0"
|
|
21
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
22
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
23
|
+
|
|
24
|
+
[[package]]
|
|
25
|
+
name = "indoc"
|
|
26
|
+
version = "2.0.7"
|
|
27
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
28
|
+
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
|
29
|
+
dependencies = [
|
|
30
|
+
"rustversion",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[package]]
|
|
34
|
+
name = "libc"
|
|
35
|
+
version = "0.2.178"
|
|
36
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
37
|
+
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
|
38
|
+
|
|
39
|
+
[[package]]
|
|
40
|
+
name = "memoffset"
|
|
41
|
+
version = "0.9.1"
|
|
42
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
43
|
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
44
|
+
dependencies = [
|
|
45
|
+
"autocfg",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "once_cell"
|
|
50
|
+
version = "1.21.3"
|
|
51
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
52
|
+
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
|
53
|
+
|
|
54
|
+
[[package]]
|
|
55
|
+
name = "portable-atomic"
|
|
56
|
+
version = "1.11.1"
|
|
57
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
58
|
+
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
|
59
|
+
|
|
60
|
+
[[package]]
|
|
61
|
+
name = "proc-macro2"
|
|
62
|
+
version = "1.0.103"
|
|
63
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
64
|
+
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
|
65
|
+
dependencies = [
|
|
66
|
+
"unicode-ident",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[[package]]
|
|
70
|
+
name = "pyo3"
|
|
71
|
+
version = "0.27.2"
|
|
72
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
73
|
+
checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
|
|
74
|
+
dependencies = [
|
|
75
|
+
"indoc",
|
|
76
|
+
"libc",
|
|
77
|
+
"memoffset",
|
|
78
|
+
"once_cell",
|
|
79
|
+
"portable-atomic",
|
|
80
|
+
"pyo3-build-config",
|
|
81
|
+
"pyo3-ffi",
|
|
82
|
+
"pyo3-macros",
|
|
83
|
+
"unindent",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "pyo3-build-config"
|
|
88
|
+
version = "0.27.2"
|
|
89
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
90
|
+
checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
|
|
91
|
+
dependencies = [
|
|
92
|
+
"target-lexicon",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[[package]]
|
|
96
|
+
name = "pyo3-ffi"
|
|
97
|
+
version = "0.27.2"
|
|
98
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
99
|
+
checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
|
|
100
|
+
dependencies = [
|
|
101
|
+
"libc",
|
|
102
|
+
"pyo3-build-config",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
[[package]]
|
|
106
|
+
name = "pyo3-macros"
|
|
107
|
+
version = "0.27.2"
|
|
108
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
109
|
+
checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
|
|
110
|
+
dependencies = [
|
|
111
|
+
"proc-macro2",
|
|
112
|
+
"pyo3-macros-backend",
|
|
113
|
+
"quote",
|
|
114
|
+
"syn",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
[[package]]
|
|
118
|
+
name = "pyo3-macros-backend"
|
|
119
|
+
version = "0.27.2"
|
|
120
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
121
|
+
checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
|
|
122
|
+
dependencies = [
|
|
123
|
+
"heck",
|
|
124
|
+
"proc-macro2",
|
|
125
|
+
"pyo3-build-config",
|
|
126
|
+
"quote",
|
|
127
|
+
"syn",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
[[package]]
|
|
131
|
+
name = "quote"
|
|
132
|
+
version = "1.0.42"
|
|
133
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
134
|
+
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
|
135
|
+
dependencies = [
|
|
136
|
+
"proc-macro2",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
[[package]]
|
|
140
|
+
name = "rustversion"
|
|
141
|
+
version = "1.0.22"
|
|
142
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
143
|
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
|
144
|
+
|
|
145
|
+
[[package]]
|
|
146
|
+
name = "syn"
|
|
147
|
+
version = "2.0.111"
|
|
148
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
149
|
+
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
|
150
|
+
dependencies = [
|
|
151
|
+
"proc-macro2",
|
|
152
|
+
"quote",
|
|
153
|
+
"unicode-ident",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
[[package]]
|
|
157
|
+
name = "target-lexicon"
|
|
158
|
+
version = "0.13.3"
|
|
159
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
160
|
+
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
|
161
|
+
|
|
162
|
+
[[package]]
|
|
163
|
+
name = "unicode-ident"
|
|
164
|
+
version = "1.0.22"
|
|
165
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
166
|
+
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
|
167
|
+
|
|
168
|
+
[[package]]
|
|
169
|
+
name = "unindent"
|
|
170
|
+
version = "0.2.4"
|
|
171
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
172
|
+
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "blurhash_rs"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
crate-type = ["cdylib"]
|
|
9
|
+
name = "blurhash_rs"
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
pyo3 = { version = "0.27.2", features = ["extension-module"] }
|
|
13
|
+
|
|
14
|
+
[profile.release]
|
|
15
|
+
overflow-checks = true
|
|
16
|
+
lto = true
|
|
17
|
+
codegen-units = 1
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blurhash-rs
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
15
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
16
|
+
Classifier: Programming Language :: Rust
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: pillow>=9.0.0
|
|
21
|
+
Summary: High-performance BlurHash encoder/decoder
|
|
22
|
+
Keywords: blurhash,decoder,encoder,image,loading,placeholder,preview,progressive
|
|
23
|
+
Author-email: Kamil Monicz <kamil@monicz.dev>
|
|
24
|
+
License-Expression: MIT
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
27
|
+
Project-URL: Issues, https://github.com/Zaczero/pkgs/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/Zaczero/pkgs/tree/main/blurhash-rs
|
|
29
|
+
|
|
30
|
+
# blurhash-rs
|
|
31
|
+
|
|
32
|
+
High-performance [BlurHash](https://blurha.sh) encoder/decoder for Python.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install blurhash-rs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```py
|
|
43
|
+
from PIL import Image
|
|
44
|
+
from blurhash_rs import blurhash_encode, blurhash_decode
|
|
45
|
+
|
|
46
|
+
with Image.open('image.jpg') as img:
|
|
47
|
+
# Reduce resolution for faster encoding
|
|
48
|
+
img.thumbnail((100, 100))
|
|
49
|
+
|
|
50
|
+
blurhash = blurhash_encode(img, x_components=4, y_components=3)
|
|
51
|
+
preview = blurhash_decode(blurhash, 32, 32)
|
|
52
|
+
preview.save("preview.png")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Benchmarks
|
|
56
|
+
|
|
57
|
+
### Run
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
benchmark.py --impl blurhash-rs -o blurhash-rs.json --rigorous
|
|
61
|
+
benchmark.py --impl blurhash-python -o blurhash-python.json --rigorous
|
|
62
|
+
benchmark.py --impl blurhash -o blurhash.json --rigorous
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Results
|
|
66
|
+
|
|
67
|
+
Linux x86_64, CPython 3.14.0:
|
|
68
|
+
|
|
69
|
+
#### blurhash-python vs blurhash-rs
|
|
70
|
+
|
|
71
|
+
| Benchmark | blurhash-python-1.2.2 | blurhash-rs-1.0.0 |
|
|
72
|
+
|----------------------|:---------------------:|:-----------------------:|
|
|
73
|
+
| encode 32x32 (4x3) | 486 us | 5.57 us: 87.29x faster |
|
|
74
|
+
| encode 64x64 (4x3) | 1.91 ms | 10.8 us: 177.73x faster |
|
|
75
|
+
| encode 128x128 (4x3) | 7.59 ms | 30.6 us: 247.76x faster |
|
|
76
|
+
| decode 32x32 (4x3) | 160 us | 12.0 us: 13.31x faster |
|
|
77
|
+
| decode 64x64 (4x3) | 626 us | 34.6 us: 18.08x faster |
|
|
78
|
+
| decode 128x128 (4x3) | 2.45 ms | 122 us: 20.08x faster |
|
|
79
|
+
| Geometric mean | (ref) | 51.46x faster |
|
|
80
|
+
|
|
81
|
+
#### blurhash vs blurhash-rs
|
|
82
|
+
|
|
83
|
+
| Benchmark | blurhash-1.1.5 | blurhash-rs-1.0.0 |
|
|
84
|
+
|----------------------|:--------------:|:------------------------:|
|
|
85
|
+
| encode 32x32 (4x3) | 6.00 ms | 5.57 us: 1078.28x faster |
|
|
86
|
+
| encode 64x64 (4x3) | 24.2 ms | 10.8 us: 2249.49x faster |
|
|
87
|
+
| encode 128x128 (4x3) | 96.4 ms | 30.6 us: 3146.95x faster |
|
|
88
|
+
| decode 32x32 (4x3) | 6.91 ms | 12.0 us: 573.46x faster |
|
|
89
|
+
| decode 64x64 (4x3) | 27.6 ms | 34.6 us: 797.99x faster |
|
|
90
|
+
| decode 128x128 (4x3) | 112 ms | 122 us: 912.88x faster |
|
|
91
|
+
| Geometric mean | (ref) | 1213.21x faster |
|
|
92
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# blurhash-rs
|
|
2
|
+
|
|
3
|
+
High-performance [BlurHash](https://blurha.sh) encoder/decoder for Python.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install blurhash-rs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```py
|
|
14
|
+
from PIL import Image
|
|
15
|
+
from blurhash_rs import blurhash_encode, blurhash_decode
|
|
16
|
+
|
|
17
|
+
with Image.open('image.jpg') as img:
|
|
18
|
+
# Reduce resolution for faster encoding
|
|
19
|
+
img.thumbnail((100, 100))
|
|
20
|
+
|
|
21
|
+
blurhash = blurhash_encode(img, x_components=4, y_components=3)
|
|
22
|
+
preview = blurhash_decode(blurhash, 32, 32)
|
|
23
|
+
preview.save("preview.png")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Benchmarks
|
|
27
|
+
|
|
28
|
+
### Run
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
benchmark.py --impl blurhash-rs -o blurhash-rs.json --rigorous
|
|
32
|
+
benchmark.py --impl blurhash-python -o blurhash-python.json --rigorous
|
|
33
|
+
benchmark.py --impl blurhash -o blurhash.json --rigorous
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Results
|
|
37
|
+
|
|
38
|
+
Linux x86_64, CPython 3.14.0:
|
|
39
|
+
|
|
40
|
+
#### blurhash-python vs blurhash-rs
|
|
41
|
+
|
|
42
|
+
| Benchmark | blurhash-python-1.2.2 | blurhash-rs-1.0.0 |
|
|
43
|
+
|----------------------|:---------------------:|:-----------------------:|
|
|
44
|
+
| encode 32x32 (4x3) | 486 us | 5.57 us: 87.29x faster |
|
|
45
|
+
| encode 64x64 (4x3) | 1.91 ms | 10.8 us: 177.73x faster |
|
|
46
|
+
| encode 128x128 (4x3) | 7.59 ms | 30.6 us: 247.76x faster |
|
|
47
|
+
| decode 32x32 (4x3) | 160 us | 12.0 us: 13.31x faster |
|
|
48
|
+
| decode 64x64 (4x3) | 626 us | 34.6 us: 18.08x faster |
|
|
49
|
+
| decode 128x128 (4x3) | 2.45 ms | 122 us: 20.08x faster |
|
|
50
|
+
| Geometric mean | (ref) | 51.46x faster |
|
|
51
|
+
|
|
52
|
+
#### blurhash vs blurhash-rs
|
|
53
|
+
|
|
54
|
+
| Benchmark | blurhash-1.1.5 | blurhash-rs-1.0.0 |
|
|
55
|
+
|----------------------|:--------------:|:------------------------:|
|
|
56
|
+
| encode 32x32 (4x3) | 6.00 ms | 5.57 us: 1078.28x faster |
|
|
57
|
+
| encode 64x64 (4x3) | 24.2 ms | 10.8 us: 2249.49x faster |
|
|
58
|
+
| encode 128x128 (4x3) | 96.4 ms | 30.6 us: 3146.95x faster |
|
|
59
|
+
| decode 32x32 (4x3) | 6.91 ms | 12.0 us: 573.46x faster |
|
|
60
|
+
| decode 64x64 (4x3) | 27.6 ms | 34.6 us: 797.99x faster |
|
|
61
|
+
| decode 128x128 (4x3) | 112 ms | 122 us: 912.88x faster |
|
|
62
|
+
| Geometric mean | (ref) | 1213.21x faster |
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import pyperf
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _pil_rgb_image_to_3d_array(img: Image.Image) -> list[list[list[int]]]:
|
|
9
|
+
data = list(img.getdata()) # type: ignore
|
|
10
|
+
pixels: list[list[list[int]]] = []
|
|
11
|
+
p = 0
|
|
12
|
+
for _y in range(img.height):
|
|
13
|
+
row: list[list[int]] = []
|
|
14
|
+
for _x in range(img.width):
|
|
15
|
+
r, g, b = data[p]
|
|
16
|
+
row.append([r, g, b])
|
|
17
|
+
p += 1
|
|
18
|
+
pixels.append(row)
|
|
19
|
+
return pixels
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_gradient_image(width: int, height: int):
|
|
23
|
+
buf = bytearray(width * height * 3)
|
|
24
|
+
denom_x = max(1, width - 1)
|
|
25
|
+
denom_y = max(1, height - 1)
|
|
26
|
+
denom_xy = max(1, (width - 1) + (height - 1))
|
|
27
|
+
|
|
28
|
+
p = 0
|
|
29
|
+
for y in range(height):
|
|
30
|
+
gy = (255 * y) // denom_y
|
|
31
|
+
for x in range(width):
|
|
32
|
+
rx = (255 * x) // denom_x
|
|
33
|
+
bx = (255 * (x + y)) // denom_xy
|
|
34
|
+
buf[p + 0] = rx
|
|
35
|
+
buf[p + 1] = gy
|
|
36
|
+
buf[p + 2] = bx
|
|
37
|
+
p += 3
|
|
38
|
+
|
|
39
|
+
return Image.frombytes('RGB', (width, height), bytes(buf))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_suite(impl: str, pyperf_args: list[str]) -> int:
|
|
43
|
+
if impl == 'blurhash-python':
|
|
44
|
+
from blurhash import decode as blurhash_decode
|
|
45
|
+
from blurhash import encode as blurhash_encode
|
|
46
|
+
|
|
47
|
+
if blurhash_encode.__module__ != 'blurhash':
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
'Expected blurhash-python for --impl blurhash-python, but imported a different "blurhash" package.'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
elif impl == 'blurhash':
|
|
53
|
+
from blurhash import decode as blurhash_decode
|
|
54
|
+
from blurhash import encode as blurhash_encode
|
|
55
|
+
|
|
56
|
+
if blurhash_encode.__module__ != 'blurhash.blurhash':
|
|
57
|
+
raise SystemExit(
|
|
58
|
+
'Expected blurhash for --impl blurhash, but imported a different "blurhash" package.'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
from blurhash_rs import blurhash_decode, blurhash_encode
|
|
63
|
+
|
|
64
|
+
sizes = [32, 64, 128]
|
|
65
|
+
x_components = 4
|
|
66
|
+
y_components = 3
|
|
67
|
+
|
|
68
|
+
# pyperf spawns worker processes by re-running the "program". Since we
|
|
69
|
+
# parse custom CLI args ourselves, we must include them in program_args so
|
|
70
|
+
# workers can parse them too.
|
|
71
|
+
runner = pyperf.Runner(program_args=(sys.argv[0], '--impl', impl))
|
|
72
|
+
runner.parse_args(pyperf_args)
|
|
73
|
+
|
|
74
|
+
# Encode benchmarks first (so pyperf compare_to shows them grouped).
|
|
75
|
+
for size in sizes:
|
|
76
|
+
img = _make_gradient_image(size, size)
|
|
77
|
+
|
|
78
|
+
if impl == 'blurhash-python':
|
|
79
|
+
# blurhash-python closes the image it is given; avoid breaking the
|
|
80
|
+
# benchmark loop by making close() a no-op.
|
|
81
|
+
img.close = lambda: None # type: ignore[method-assign]
|
|
82
|
+
elif impl == 'blurhash':
|
|
83
|
+
img = _pil_rgb_image_to_3d_array(img)
|
|
84
|
+
|
|
85
|
+
runner.bench_func(
|
|
86
|
+
f'encode {size}x{size} ({x_components}x{y_components})',
|
|
87
|
+
blurhash_encode,
|
|
88
|
+
img,
|
|
89
|
+
x_components,
|
|
90
|
+
y_components,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Decode benchmarks second.
|
|
94
|
+
from blurhash_rs import blurhash_encode
|
|
95
|
+
|
|
96
|
+
for size in sizes:
|
|
97
|
+
img = _make_gradient_image(size, size)
|
|
98
|
+
h = blurhash_encode(img, x_components, y_components)
|
|
99
|
+
|
|
100
|
+
runner.bench_func(
|
|
101
|
+
f'decode {size}x{size} ({x_components}x{y_components})',
|
|
102
|
+
blurhash_decode,
|
|
103
|
+
h,
|
|
104
|
+
size,
|
|
105
|
+
size,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> int:
|
|
112
|
+
parser = argparse.ArgumentParser(
|
|
113
|
+
description='BlurHash benchmark suite. Writes pyperf JSON via -o/--output.'
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
'--impl', required=True, choices=['blurhash-python', 'blurhash', 'blurhash-rs']
|
|
117
|
+
)
|
|
118
|
+
args, pyperf_args = parser.parse_known_args()
|
|
119
|
+
return _run_suite(args.impl, pyperf_args)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == '__main__':
|
|
123
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
use std::env;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
|
|
5
|
+
fn srgb_u8_to_linear(v: u8) -> f32 {
|
|
6
|
+
let srgb = v as f32 / 255.0;
|
|
7
|
+
if srgb <= 0.04045 {
|
|
8
|
+
srgb / 12.92
|
|
9
|
+
} else {
|
|
10
|
+
((srgb + 0.055) / 1.055).powf(2.4)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fn linear_to_srgb_u8(v: f32) -> u8 {
|
|
15
|
+
let v = v.clamp(0.0, 1.0);
|
|
16
|
+
let srgb = if v < 0.0031308 {
|
|
17
|
+
v * 12.92
|
|
18
|
+
} else {
|
|
19
|
+
1.055 * v.powf(1.0 / 2.4) - 0.055
|
|
20
|
+
};
|
|
21
|
+
let value = (srgb * 255.0 + 0.5) as i32;
|
|
22
|
+
value.clamp(0, 255) as u8
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn main() {
|
|
26
|
+
println!("cargo:rerun-if-changed=build.rs");
|
|
27
|
+
|
|
28
|
+
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be set"));
|
|
29
|
+
let dest = out_dir.join("srgb_tables.rs");
|
|
30
|
+
|
|
31
|
+
const LINEAR_TO_SRGB_LUT_LEN: usize = 4096;
|
|
32
|
+
|
|
33
|
+
let mut out = String::new();
|
|
34
|
+
out.push_str("pub const SRGB_U8_TO_LINEAR: [f32; 256] = [\n");
|
|
35
|
+
for v in 0u16..=255 {
|
|
36
|
+
let lin = srgb_u8_to_linear(v as u8);
|
|
37
|
+
out.push_str(&format!(" f32::from_bits(0x{:08x}),\n", lin.to_bits()));
|
|
38
|
+
}
|
|
39
|
+
out.push_str("];\n\n");
|
|
40
|
+
|
|
41
|
+
out.push_str(&format!(
|
|
42
|
+
"pub const LINEAR_TO_SRGB_U8: [u8; {LINEAR_TO_SRGB_LUT_LEN}] = [\n"
|
|
43
|
+
));
|
|
44
|
+
for i in 0..LINEAR_TO_SRGB_LUT_LEN {
|
|
45
|
+
let v = i as f32 / (LINEAR_TO_SRGB_LUT_LEN - 1) as f32;
|
|
46
|
+
let srgb = linear_to_srgb_u8(v);
|
|
47
|
+
out.push_str(&format!(" {srgb},\n"));
|
|
48
|
+
}
|
|
49
|
+
out.push_str("];\n");
|
|
50
|
+
|
|
51
|
+
fs::write(dest, out).expect("Failed to write generated sRGB tables");
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "blurhash-rs"
|
|
3
|
+
description = "High-performance BlurHash encoder/decoder"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
requires-python = ">=3.9"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "Kamil Monicz", email = "kamil@monicz.dev" }]
|
|
8
|
+
keywords = [
|
|
9
|
+
"blurhash",
|
|
10
|
+
"decoder",
|
|
11
|
+
"encoder",
|
|
12
|
+
"image",
|
|
13
|
+
"loading",
|
|
14
|
+
"placeholder",
|
|
15
|
+
"preview",
|
|
16
|
+
"progressive"
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
30
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
31
|
+
"Programming Language :: Rust",
|
|
32
|
+
"Topic :: Multimedia :: Graphics",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
dependencies = ["Pillow>=9.0.0"]
|
|
37
|
+
dynamic = ["version"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Issues = "https://github.com/Zaczero/pkgs/issues"
|
|
41
|
+
Repository = "https://github.com/Zaczero/pkgs/tree/main/blurhash-rs"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = ["pytest"]
|
|
45
|
+
bench = ["blurhash-python", "pyperf"]
|
|
46
|
+
bench-2 = ["blurhash", "pyperf"]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["maturin>=1.7,<2.0"]
|
|
50
|
+
build-backend = "maturin"
|
|
51
|
+
|
|
52
|
+
[tool.maturin]
|
|
53
|
+
bindings = "pyo3"
|
|
54
|
+
features = ["pyo3/extension-module"]
|
|
55
|
+
module-name = "blurhash_rs._lib"
|
|
56
|
+
python-source = "python"
|
|
57
|
+
|
|
58
|
+
[tool.pyright]
|
|
59
|
+
include = ["python/blurhash_rs", "tests"]
|
|
60
|
+
pythonPlatform = "All"
|
|
61
|
+
pythonVersion = "3.9"
|
|
62
|
+
strict = ["python/blurhash_rs"]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
extend = "../ruff.toml"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from contextlib import nullcontext
|
|
2
|
+
|
|
3
|
+
from PIL import Image
|
|
4
|
+
|
|
5
|
+
from ._lib import decode_rgb, encode_rgb
|
|
6
|
+
|
|
7
|
+
TYPE_CHECKING = False
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from os import PathLike
|
|
10
|
+
from typing import IO
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'BlurhashDecodeError',
|
|
14
|
+
'blurhash_decode',
|
|
15
|
+
'blurhash_encode',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BlurhashDecodeError(Exception):
|
|
20
|
+
def __init__(self, blurhash: str):
|
|
21
|
+
self.blurhash = blurhash
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def blurhash_encode(
|
|
25
|
+
image: 'Image.Image | str | bytes | PathLike[str] | PathLike[bytes] | IO[bytes]',
|
|
26
|
+
x_components: int = 4,
|
|
27
|
+
y_components: int = 3,
|
|
28
|
+
) -> str:
|
|
29
|
+
with (
|
|
30
|
+
nullcontext(image)
|
|
31
|
+
if isinstance(image, Image.Image)
|
|
32
|
+
else Image.open(image) as img
|
|
33
|
+
):
|
|
34
|
+
if img.mode != 'RGB':
|
|
35
|
+
img = img.convert('RGB')
|
|
36
|
+
width, height = img.size
|
|
37
|
+
data = img.tobytes()
|
|
38
|
+
return encode_rgb(data, width, height, x_components, y_components)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def blurhash_decode(
|
|
42
|
+
blurhash: str,
|
|
43
|
+
width: int,
|
|
44
|
+
height: int,
|
|
45
|
+
*,
|
|
46
|
+
punch: float = 1,
|
|
47
|
+
) -> Image.Image:
|
|
48
|
+
try:
|
|
49
|
+
data = decode_rgb(blurhash, width, height, punch)
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
raise BlurhashDecodeError(blurhash) from e
|
|
52
|
+
else:
|
|
53
|
+
return Image.frombuffer('RGB', (width, height), data, 'raw', 'RGB', 0, 1)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const CHARSET: &[u8; 83] =
|
|
2
|
+
b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
|
3
|
+
|
|
4
|
+
const INVALID: u8 = 0xFF;
|
|
5
|
+
|
|
6
|
+
const fn build_decode_table() -> [u8; 256] {
|
|
7
|
+
let mut table = [INVALID; 256];
|
|
8
|
+
let mut i = 0usize;
|
|
9
|
+
while i < 83 {
|
|
10
|
+
table[CHARSET[i] as usize] = i as u8;
|
|
11
|
+
i += 1;
|
|
12
|
+
}
|
|
13
|
+
table
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DECODE: [u8; 256] = build_decode_table();
|
|
17
|
+
|
|
18
|
+
const fn build_pow83_table() -> [u32; 5] {
|
|
19
|
+
let mut out = [0u32; 5];
|
|
20
|
+
out[0] = 1;
|
|
21
|
+
let mut i = 1usize;
|
|
22
|
+
while i < out.len() {
|
|
23
|
+
out[i] = out[i - 1] * 83;
|
|
24
|
+
i += 1;
|
|
25
|
+
}
|
|
26
|
+
out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const POW83: [u32; 5] = build_pow83_table();
|
|
30
|
+
|
|
31
|
+
pub fn decode_byte(b: u8) -> Option<u8> {
|
|
32
|
+
let v = DECODE[b as usize];
|
|
33
|
+
if v == INVALID { None } else { Some(v) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub fn decode_u32(bytes: &[u8]) -> Option<u32> {
|
|
37
|
+
let mut value = 0u32;
|
|
38
|
+
for &b in bytes {
|
|
39
|
+
let digit = decode_byte(b)? as u32;
|
|
40
|
+
value = value * 83 + digit;
|
|
41
|
+
}
|
|
42
|
+
Some(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub fn push_base83(out: &mut Vec<u8>, value: u32, length: usize) {
|
|
46
|
+
let mut i = length;
|
|
47
|
+
while i > 0 {
|
|
48
|
+
i -= 1;
|
|
49
|
+
let divisor = POW83[i];
|
|
50
|
+
let digit = (value / divisor) % 83;
|
|
51
|
+
out.push(CHARSET[digit as usize]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
use std::cell::RefCell;
|
|
2
|
+
use std::f32::consts::PI;
|
|
3
|
+
use std::rc::Rc;
|
|
4
|
+
use std::simd::Simd;
|
|
5
|
+
|
|
6
|
+
pub fn precompute_cos_axis(len: usize, components: usize) -> Vec<f32> {
|
|
7
|
+
let len_f = len as f32;
|
|
8
|
+
let mut out = vec![0.0f32; len * components];
|
|
9
|
+
for p in 0..len {
|
|
10
|
+
let p_f = p as f32;
|
|
11
|
+
let row = &mut out[p * components..(p + 1) * components];
|
|
12
|
+
for c in 0..components {
|
|
13
|
+
row[c] = (PI * p_f * (c as f32) / len_f).cos();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
thread_local! {
|
|
20
|
+
// Small caches: typical callers only ever use a handful of image sizes and
|
|
21
|
+
// component counts. A linear scan over a tiny Vec has better locality than
|
|
22
|
+
// a HashMap and avoids hashing overhead.
|
|
23
|
+
static COS_AXIS_CACHE: RefCell<Vec<((usize, usize), Rc<[f32]>)>> =
|
|
24
|
+
RefCell::new(Vec::with_capacity(1));
|
|
25
|
+
static COS_AXIS_SIMD4_CACHE: RefCell<Vec<((usize, usize), Rc<[Simd<f32, 4>]>)>> =
|
|
26
|
+
RefCell::new(Vec::with_capacity(1));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub fn cos_axis_cached(len: usize, components: usize) -> Rc<[f32]> {
|
|
30
|
+
let key = (len, components);
|
|
31
|
+
COS_AXIS_CACHE.with(|cache| {
|
|
32
|
+
let mut cache = cache.borrow_mut();
|
|
33
|
+
for (k, v) in cache.iter() {
|
|
34
|
+
if *k == key {
|
|
35
|
+
return v.clone();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Simple eviction to avoid unbounded growth in pathological cases.
|
|
40
|
+
if cache.len() >= 32 {
|
|
41
|
+
cache.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let vec = precompute_cos_axis(len, components);
|
|
45
|
+
let rc: Rc<[f32]> = vec.into();
|
|
46
|
+
cache.push((key, rc.clone()));
|
|
47
|
+
rc
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
pub fn cos_axis_simd4_cached(len: usize, components: usize) -> Rc<[Simd<f32, 4>]> {
|
|
52
|
+
let key = (len, components);
|
|
53
|
+
COS_AXIS_SIMD4_CACHE.with(|cache| {
|
|
54
|
+
let mut cache = cache.borrow_mut();
|
|
55
|
+
for (k, v) in cache.iter() {
|
|
56
|
+
if *k == key {
|
|
57
|
+
return v.clone();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Simple eviction to avoid unbounded growth in pathological cases.
|
|
62
|
+
if cache.len() >= 32 {
|
|
63
|
+
cache.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let blocks = (components + 3) / 4;
|
|
67
|
+
let len_f = len as f32;
|
|
68
|
+
|
|
69
|
+
let mut out = Vec::with_capacity(len * blocks);
|
|
70
|
+
for p in 0..len {
|
|
71
|
+
let p_f = p as f32;
|
|
72
|
+
for block in 0..blocks {
|
|
73
|
+
let start = block * 4;
|
|
74
|
+
let lanes = (components - start).min(4);
|
|
75
|
+
let mut v = [0.0f32; 4];
|
|
76
|
+
for lane in 0..lanes {
|
|
77
|
+
let c = start + lane;
|
|
78
|
+
v[lane] = (PI * p_f * (c as f32) / len_f).cos();
|
|
79
|
+
}
|
|
80
|
+
out.push(Simd::from_array(v));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let rc: Rc<[Simd<f32, 4>]> = out.into();
|
|
85
|
+
cache.push((key, rc.clone()));
|
|
86
|
+
rc
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
use core::hint::unlikely;
|
|
2
|
+
use std::simd::Simd;
|
|
3
|
+
use std::simd::num::SimdFloat;
|
|
4
|
+
|
|
5
|
+
use crate::base83;
|
|
6
|
+
use crate::cos;
|
|
7
|
+
use crate::srgb;
|
|
8
|
+
|
|
9
|
+
type V4 = Simd<f32, 4>;
|
|
10
|
+
|
|
11
|
+
pub type DecodeResult<T> = Result<T, &'static str>;
|
|
12
|
+
|
|
13
|
+
fn sign_pow_2(v: f32) -> f32 {
|
|
14
|
+
let abs = v.abs();
|
|
15
|
+
v.signum() * abs * abs
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pub fn decode_rgb_into(
|
|
19
|
+
blurhash: &str,
|
|
20
|
+
width: usize,
|
|
21
|
+
height: usize,
|
|
22
|
+
punch: f32,
|
|
23
|
+
out_pixels: &mut [u8],
|
|
24
|
+
) -> DecodeResult<()> {
|
|
25
|
+
if unlikely(width == 0 || height == 0) {
|
|
26
|
+
return Err("Image is empty");
|
|
27
|
+
}
|
|
28
|
+
if unlikely(!punch.is_finite()) {
|
|
29
|
+
return Err("punch must be finite");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let bytes = blurhash.as_bytes();
|
|
33
|
+
if unlikely(bytes.len() < 6) {
|
|
34
|
+
return Err("blurhash is too short");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let Some(size_flag) = base83::decode_byte(bytes[0]) else {
|
|
38
|
+
return Err("blurhash is malformed");
|
|
39
|
+
};
|
|
40
|
+
let size_flag = size_flag as usize;
|
|
41
|
+
let num_y = (size_flag / 9) + 1;
|
|
42
|
+
let num_x = (size_flag % 9) + 1;
|
|
43
|
+
let num_components = num_x * num_y;
|
|
44
|
+
|
|
45
|
+
let expected_len = 4 + 2 * num_components;
|
|
46
|
+
if unlikely(bytes.len() != expected_len) {
|
|
47
|
+
return Err("blurhash length mismatch");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let Some(quantised_max_value) = base83::decode_byte(bytes[1]) else {
|
|
51
|
+
return Err("blurhash is malformed");
|
|
52
|
+
};
|
|
53
|
+
let quantised_max_value = quantised_max_value as f32;
|
|
54
|
+
let max_value = ((quantised_max_value + 1.0) / 166.0) * punch.max(1.0);
|
|
55
|
+
|
|
56
|
+
let out_len = width
|
|
57
|
+
.checked_mul(height)
|
|
58
|
+
.and_then(|v| v.checked_mul(3))
|
|
59
|
+
.ok_or("Image is too large")?;
|
|
60
|
+
|
|
61
|
+
if unlikely(out_pixels.len() != out_len) {
|
|
62
|
+
return Err("Invalid output buffer length");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let blocks = (num_x + 3) / 4;
|
|
66
|
+
|
|
67
|
+
let zero = V4::splat(0.0);
|
|
68
|
+
let mut colors_r_v = [zero; 27];
|
|
69
|
+
let mut colors_g_v = [zero; 27];
|
|
70
|
+
let mut colors_b_v = [zero; 27];
|
|
71
|
+
|
|
72
|
+
let Some(dc_value) = base83::decode_u32(&bytes[2..6]) else {
|
|
73
|
+
return Err("blurhash is malformed");
|
|
74
|
+
};
|
|
75
|
+
colors_r_v[0].as_mut_array()[0] = srgb::srgb_u8_to_linear((dc_value >> 16) as u8);
|
|
76
|
+
colors_g_v[0].as_mut_array()[0] = srgb::srgb_u8_to_linear(((dc_value >> 8) & 255) as u8);
|
|
77
|
+
colors_b_v[0].as_mut_array()[0] = srgb::srgb_u8_to_linear((dc_value & 255) as u8);
|
|
78
|
+
|
|
79
|
+
for idx in 1..num_components {
|
|
80
|
+
let start = 4 + idx * 2;
|
|
81
|
+
let Some(value) = base83::decode_u32(&bytes[start..start + 2]) else {
|
|
82
|
+
return Err("blurhash is malformed");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let quant_r = (value / (19 * 19)) as i32;
|
|
86
|
+
let quant_g = ((value / 19) % 19) as i32;
|
|
87
|
+
let quant_b = (value % 19) as i32;
|
|
88
|
+
|
|
89
|
+
let i = idx % num_x;
|
|
90
|
+
let slot = (idx / num_x) * blocks + (i / 4);
|
|
91
|
+
let lane = i % 4;
|
|
92
|
+
|
|
93
|
+
let r = sign_pow_2((quant_r as f32 - 9.0) / 9.0) * max_value;
|
|
94
|
+
let g = sign_pow_2((quant_g as f32 - 9.0) / 9.0) * max_value;
|
|
95
|
+
let b = sign_pow_2((quant_b as f32 - 9.0) / 9.0) * max_value;
|
|
96
|
+
|
|
97
|
+
colors_r_v[slot].as_mut_array()[lane] = r;
|
|
98
|
+
colors_g_v[slot].as_mut_array()[lane] = g;
|
|
99
|
+
colors_b_v[slot].as_mut_array()[lane] = b;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let cos_y = cos::cos_axis_cached(height, num_y);
|
|
103
|
+
let cos_x_simd = cos::cos_axis_simd4_cached(width, num_x);
|
|
104
|
+
|
|
105
|
+
// Separable basis:
|
|
106
|
+
//
|
|
107
|
+
// pixel[x,y] = sum_i cos_x[x,i] * (sum_j colors[i,j] * cos_y[y,j])
|
|
108
|
+
for y in 0..height {
|
|
109
|
+
let mut row_r = [zero; 3];
|
|
110
|
+
let mut row_g = [zero; 3];
|
|
111
|
+
let mut row_b = [zero; 3];
|
|
112
|
+
|
|
113
|
+
let cos_y_row = &cos_y[y * num_y..(y + 1) * num_y];
|
|
114
|
+
for j in 0..num_y {
|
|
115
|
+
let cosy = V4::splat(cos_y_row[j]);
|
|
116
|
+
let base = j * blocks;
|
|
117
|
+
|
|
118
|
+
for k in 0..blocks {
|
|
119
|
+
let idx = base + k;
|
|
120
|
+
row_r[k] += colors_r_v[idx] * cosy;
|
|
121
|
+
row_g[k] += colors_g_v[idx] * cosy;
|
|
122
|
+
row_b[k] += colors_b_v[idx] * cosy;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let mut out_idx = y * width * 3;
|
|
127
|
+
for x in 0..width {
|
|
128
|
+
let base = x * blocks;
|
|
129
|
+
let bases = &cos_x_simd[base..base + blocks];
|
|
130
|
+
|
|
131
|
+
let mut acc_r = zero;
|
|
132
|
+
let mut acc_g = zero;
|
|
133
|
+
let mut acc_b = zero;
|
|
134
|
+
for k in 0..blocks {
|
|
135
|
+
let basis_x = bases[k];
|
|
136
|
+
acc_r += row_r[k] * basis_x;
|
|
137
|
+
acc_g += row_g[k] * basis_x;
|
|
138
|
+
acc_b += row_b[k] * basis_x;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let r = acc_r.reduce_sum();
|
|
142
|
+
let g = acc_g.reduce_sum();
|
|
143
|
+
let b = acc_b.reduce_sum();
|
|
144
|
+
|
|
145
|
+
out_pixels[out_idx + 0] = srgb::linear_to_srgb_u8(r);
|
|
146
|
+
out_pixels[out_idx + 1] = srgb::linear_to_srgb_u8(g);
|
|
147
|
+
out_pixels[out_idx + 2] = srgb::linear_to_srgb_u8(b);
|
|
148
|
+
out_idx += 3;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Ok(())
|
|
153
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
use core::hint::unlikely;
|
|
2
|
+
use std::simd::Simd;
|
|
3
|
+
|
|
4
|
+
use crate::base83;
|
|
5
|
+
use crate::cos;
|
|
6
|
+
use crate::srgb;
|
|
7
|
+
|
|
8
|
+
type V4 = Simd<f32, 4>;
|
|
9
|
+
|
|
10
|
+
pub type EncodeResult<T> = Result<T, &'static str>;
|
|
11
|
+
|
|
12
|
+
fn sign_pow_05(v: f32) -> f32 {
|
|
13
|
+
v.signum() * v.abs().sqrt()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub fn encode_rgb(
|
|
17
|
+
rgb: &[u8],
|
|
18
|
+
width: usize,
|
|
19
|
+
height: usize,
|
|
20
|
+
x_components: usize,
|
|
21
|
+
y_components: usize,
|
|
22
|
+
) -> EncodeResult<String> {
|
|
23
|
+
if unlikely(width == 0 || height == 0) {
|
|
24
|
+
return Err("Image is empty");
|
|
25
|
+
}
|
|
26
|
+
if unlikely(x_components == 0 || x_components > 9 || y_components == 0 || y_components > 9) {
|
|
27
|
+
return Err("x_components and y_components must be in 1..=9");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let expected_len = width
|
|
31
|
+
.checked_mul(height)
|
|
32
|
+
.and_then(|v| v.checked_mul(3))
|
|
33
|
+
.ok_or("Image is too large")?;
|
|
34
|
+
|
|
35
|
+
if unlikely(rgb.len() != expected_len) {
|
|
36
|
+
return Err("Invalid RGB buffer length");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
encode_rgb_impl(rgb, width, height, x_components, y_components)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn encode_rgb_impl(
|
|
43
|
+
rgb: &[u8],
|
|
44
|
+
width: usize,
|
|
45
|
+
height: usize,
|
|
46
|
+
x_components: usize,
|
|
47
|
+
y_components: usize,
|
|
48
|
+
) -> EncodeResult<String> {
|
|
49
|
+
let num_components = x_components * y_components;
|
|
50
|
+
|
|
51
|
+
let blocks = (x_components + 3) / 4;
|
|
52
|
+
|
|
53
|
+
let cos_y = cos::cos_axis_cached(height, y_components);
|
|
54
|
+
let cos_x_simd = cos::cos_axis_simd4_cached(width, x_components);
|
|
55
|
+
|
|
56
|
+
let zero = V4::splat(0.0);
|
|
57
|
+
let mut factors_r = [zero; 27];
|
|
58
|
+
let mut factors_g = [zero; 27];
|
|
59
|
+
let mut factors_b = [zero; 27];
|
|
60
|
+
|
|
61
|
+
// Separable basis:
|
|
62
|
+
//
|
|
63
|
+
// C[i,j] = sum_y cos_y[y,j] * (sum_x cos_x[x,i] * pixel[x,y])
|
|
64
|
+
//
|
|
65
|
+
// This computes all coefficients in a single image scan.
|
|
66
|
+
for y in 0..height {
|
|
67
|
+
let mut row_r = [zero; 3];
|
|
68
|
+
let mut row_g = [zero; 3];
|
|
69
|
+
let mut row_b = [zero; 3];
|
|
70
|
+
|
|
71
|
+
let mut pixel = y * width * 3;
|
|
72
|
+
for x in 0..width {
|
|
73
|
+
let r = srgb::srgb_u8_to_linear(rgb[pixel + 0]);
|
|
74
|
+
let g = srgb::srgb_u8_to_linear(rgb[pixel + 1]);
|
|
75
|
+
let b = srgb::srgb_u8_to_linear(rgb[pixel + 2]);
|
|
76
|
+
pixel += 3;
|
|
77
|
+
|
|
78
|
+
let r_v = V4::splat(r);
|
|
79
|
+
let g_v = V4::splat(g);
|
|
80
|
+
let b_v = V4::splat(b);
|
|
81
|
+
|
|
82
|
+
let base = x * blocks;
|
|
83
|
+
let bases = &cos_x_simd[base..base + blocks];
|
|
84
|
+
for k in 0..blocks {
|
|
85
|
+
let basis_x = bases[k];
|
|
86
|
+
row_r[k] += basis_x * r_v;
|
|
87
|
+
row_g[k] += basis_x * g_v;
|
|
88
|
+
row_b[k] += basis_x * b_v;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let cos_y_row = &cos_y[y * y_components..(y + 1) * y_components];
|
|
93
|
+
for j in 0..y_components {
|
|
94
|
+
let cosy = V4::splat(cos_y_row[j]);
|
|
95
|
+
let base = j * blocks;
|
|
96
|
+
for k in 0..blocks {
|
|
97
|
+
let idx = base + k;
|
|
98
|
+
factors_r[idx] += row_r[k] * cosy;
|
|
99
|
+
factors_g[idx] += row_g[k] * cosy;
|
|
100
|
+
factors_b[idx] += row_b[k] * cosy;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let inv_wh = 1.0f32 / (width * height) as f32;
|
|
106
|
+
let scale_ac = inv_wh * 2.0;
|
|
107
|
+
for j in 0..y_components {
|
|
108
|
+
let base = j * blocks;
|
|
109
|
+
for block in 0..blocks {
|
|
110
|
+
let scale = if unlikely(j == 0 && block == 0) {
|
|
111
|
+
V4::from_array([inv_wh, scale_ac, scale_ac, scale_ac])
|
|
112
|
+
} else {
|
|
113
|
+
V4::splat(scale_ac)
|
|
114
|
+
};
|
|
115
|
+
let idx = base + block;
|
|
116
|
+
factors_r[idx] *= scale;
|
|
117
|
+
factors_g[idx] *= scale;
|
|
118
|
+
factors_b[idx] *= scale;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let mut maximum_value = 0.0f32;
|
|
123
|
+
for j in 0..y_components {
|
|
124
|
+
let base = j * blocks;
|
|
125
|
+
for block in 0..blocks {
|
|
126
|
+
let idx = base + block;
|
|
127
|
+
let r = factors_r[idx].to_array();
|
|
128
|
+
let g = factors_g[idx].to_array();
|
|
129
|
+
let b = factors_b[idx].to_array();
|
|
130
|
+
|
|
131
|
+
let start_lane = if j == 0 && block == 0 { 1 } else { 0 };
|
|
132
|
+
let lanes = if block + 1 == blocks {
|
|
133
|
+
x_components - block * 4
|
|
134
|
+
} else {
|
|
135
|
+
4
|
|
136
|
+
};
|
|
137
|
+
for lane in start_lane..lanes {
|
|
138
|
+
maximum_value =
|
|
139
|
+
maximum_value.max(r[lane].abs().max(g[lane].abs()).max(b[lane].abs()));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let (quantised_max_value, maximum_value) = if num_components > 1 {
|
|
145
|
+
let quantised = ((maximum_value * 166.0 - 0.5).floor() as i32).clamp(0, 82) as u32;
|
|
146
|
+
(quantised, (quantised as f32 + 1.0) / 166.0)
|
|
147
|
+
} else {
|
|
148
|
+
(0, 1.0)
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let total_len = 1 + 1 + 4 + 2 * (num_components - 1);
|
|
152
|
+
let mut out = Vec::with_capacity(total_len);
|
|
153
|
+
|
|
154
|
+
let size_flag = ((x_components - 1) + (y_components - 1) * 9) as u32;
|
|
155
|
+
base83::push_base83(&mut out, size_flag, 1);
|
|
156
|
+
base83::push_base83(&mut out, quantised_max_value, 1);
|
|
157
|
+
|
|
158
|
+
let dc_r = factors_r[0].to_array()[0];
|
|
159
|
+
let dc_g = factors_g[0].to_array()[0];
|
|
160
|
+
let dc_b = factors_b[0].to_array()[0];
|
|
161
|
+
let dc_value = ((srgb::linear_to_srgb_u8(dc_r) as u32) << 16)
|
|
162
|
+
| ((srgb::linear_to_srgb_u8(dc_g) as u32) << 8)
|
|
163
|
+
| (srgb::linear_to_srgb_u8(dc_b) as u32);
|
|
164
|
+
base83::push_base83(&mut out, dc_value, 4);
|
|
165
|
+
|
|
166
|
+
for j in 0..y_components {
|
|
167
|
+
for i in 0..x_components {
|
|
168
|
+
if j == 0 && i == 0 {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let block = i / 4;
|
|
173
|
+
let lane = i % 4;
|
|
174
|
+
let idx = j * blocks + block;
|
|
175
|
+
|
|
176
|
+
let r = factors_r[idx].to_array()[lane] / maximum_value;
|
|
177
|
+
let g = factors_g[idx].to_array()[lane] / maximum_value;
|
|
178
|
+
let b = factors_b[idx].to_array()[lane] / maximum_value;
|
|
179
|
+
|
|
180
|
+
let quant_r = (sign_pow_05(r) * 9.0 + 9.5).floor() as i32;
|
|
181
|
+
let quant_g = (sign_pow_05(g) * 9.0 + 9.5).floor() as i32;
|
|
182
|
+
let quant_b = (sign_pow_05(b) * 9.0 + 9.5).floor() as i32;
|
|
183
|
+
|
|
184
|
+
let quant_r = quant_r.clamp(0, 18) as u32;
|
|
185
|
+
let quant_g = quant_g.clamp(0, 18) as u32;
|
|
186
|
+
let quant_b = quant_b.clamp(0, 18) as u32;
|
|
187
|
+
|
|
188
|
+
let ac_value = quant_r * 19 * 19 + quant_g * 19 + quant_b;
|
|
189
|
+
base83::push_base83(&mut out, ac_value, 2);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Safety: we only ever push bytes from the base83 charset.
|
|
194
|
+
Ok(unsafe { String::from_utf8_unchecked(out) })
|
|
195
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#![feature(likely_unlikely)]
|
|
2
|
+
#![feature(portable_simd)]
|
|
3
|
+
|
|
4
|
+
mod base83;
|
|
5
|
+
mod cos;
|
|
6
|
+
mod decode;
|
|
7
|
+
mod encode;
|
|
8
|
+
mod srgb;
|
|
9
|
+
|
|
10
|
+
use pyo3::exceptions::PyValueError;
|
|
11
|
+
use pyo3::prelude::*;
|
|
12
|
+
use pyo3::types::PyByteArray;
|
|
13
|
+
|
|
14
|
+
#[pyfunction]
|
|
15
|
+
fn encode_rgb(
|
|
16
|
+
rgb: &[u8],
|
|
17
|
+
width: usize,
|
|
18
|
+
height: usize,
|
|
19
|
+
x_components: usize,
|
|
20
|
+
y_components: usize,
|
|
21
|
+
) -> PyResult<String> {
|
|
22
|
+
encode::encode_rgb(rgb, width, height, x_components, y_components)
|
|
23
|
+
.map_err(PyValueError::new_err)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[pyfunction]
|
|
27
|
+
fn decode_rgb(
|
|
28
|
+
py: Python<'_>,
|
|
29
|
+
blurhash: &str,
|
|
30
|
+
width: usize,
|
|
31
|
+
height: usize,
|
|
32
|
+
punch: f32,
|
|
33
|
+
) -> PyResult<Py<PyByteArray>> {
|
|
34
|
+
let out_len = width
|
|
35
|
+
.checked_mul(height)
|
|
36
|
+
.and_then(|v| v.checked_mul(3))
|
|
37
|
+
.ok_or_else(|| PyValueError::new_err("Image is too large"))?;
|
|
38
|
+
|
|
39
|
+
let out = PyByteArray::new_with(py, out_len, |buf| {
|
|
40
|
+
decode::decode_rgb_into(blurhash, width, height, punch, buf).map_err(PyValueError::new_err)
|
|
41
|
+
})?;
|
|
42
|
+
Ok(out.unbind())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[pymodule(gil_used = false)]
|
|
46
|
+
#[pyo3(name = "_lib")]
|
|
47
|
+
fn lib(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
48
|
+
m.add_function(wrap_pyfunction!(encode_rgb, m)?)?;
|
|
49
|
+
m.add_function(wrap_pyfunction!(decode_rgb, m)?)?;
|
|
50
|
+
Ok(())
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
use core::hint::unlikely;
|
|
2
|
+
|
|
3
|
+
include!(concat!(env!("OUT_DIR"), "/srgb_tables.rs"));
|
|
4
|
+
|
|
5
|
+
pub fn srgb_u8_to_linear(v: u8) -> f32 {
|
|
6
|
+
SRGB_U8_TO_LINEAR[v as usize]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
pub fn linear_to_srgb_u8(v: f32) -> u8 {
|
|
10
|
+
if unlikely(v <= 0.0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
if unlikely(v >= 1.0) {
|
|
14
|
+
return 255;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Approximation mode: map linear -> u8 via a dense LUT.
|
|
18
|
+
//
|
|
19
|
+
// This is much faster than calling powf() per channel per pixel and is
|
|
20
|
+
// visually indistinguishable for BlurHash previews, but not byte-identical
|
|
21
|
+
// to reference implementations.
|
|
22
|
+
let len = LINEAR_TO_SRGB_U8.len();
|
|
23
|
+
let idx = ((v * (len as f32 - 1.0)) + 0.5) as usize;
|
|
24
|
+
LINEAR_TO_SRGB_U8[idx.min(len - 1)]
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Expected decode output for blurhash: LEHV6nWB2yk8pyo0adR*.7kCMdnj
|
|
2
|
+
# RGB pixels (8x8), whitespace-separated `RRGGBB` hex tokens, row-major order.
|
|
3
|
+
87a4b1 8fa7b1 a1adb1 b0b2ae b5b4ab afb1ab a0acae 90a7b3
|
|
4
|
+
85a2b0 8da4af 9fa9ad adada9 b2aea5 acaca6 9da8ab 8ea5b1
|
|
5
|
+
809ead 889eaa 989ea3 a69e9a aa9e95 a49f98 979fa2 899fad
|
|
6
|
+
7a99a8 8296a3 919096 9e8b87 a28a7f 9c8f86 919497 8598a7
|
|
7
|
+
7894a2 7f909b 8d868b 9a7e76 9e7d6c 998476 8e8c8d 82929f
|
|
8
|
+
7b919c 818d96 8f8585 9b7e70 a17f66 9d8471 928b87 848f99
|
|
9
|
+
809097 868e92 948b85 a28a76 a98b6f a68e76 998f86 888f94
|
|
10
|
+
869094 8b9091 999288 a8957e b0987a ae987e 9f9488 8c9091
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from blurhash_rs import BlurhashDecodeError, blurhash_decode, blurhash_encode
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _assert_bytes_close(
|
|
9
|
+
actual: bytes,
|
|
10
|
+
expected: bytes,
|
|
11
|
+
*,
|
|
12
|
+
max_abs_diff: int,
|
|
13
|
+
max_mean_abs_diff: float,
|
|
14
|
+
max_different_bytes: int,
|
|
15
|
+
) -> None:
|
|
16
|
+
assert len(actual) == len(expected)
|
|
17
|
+
diffs = [abs(a - b) for a, b in zip(actual, expected)]
|
|
18
|
+
assert max(diffs) <= max_abs_diff
|
|
19
|
+
assert (sum(diffs) / len(diffs)) <= max_mean_abs_diff
|
|
20
|
+
assert sum(1 for d in diffs if d) <= max_different_bytes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_DATA_DIR = Path(__file__).resolve().parent / 'data'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_rgbhex(filename: str, *, width: int, height: int):
|
|
27
|
+
path = _DATA_DIR / filename
|
|
28
|
+
rgb: list[int] = []
|
|
29
|
+
|
|
30
|
+
for raw_line in path.read_text(encoding='utf-8').splitlines():
|
|
31
|
+
line = raw_line.split('#', 1)[0].strip()
|
|
32
|
+
if not line:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
for token in line.split():
|
|
36
|
+
token = token.strip()
|
|
37
|
+
token = token.removeprefix('0x')
|
|
38
|
+
|
|
39
|
+
if len(token) != 6:
|
|
40
|
+
raise AssertionError(f'{path}: expected RRGGBB token, got {token!r}')
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
r = int(token[0:2], 16)
|
|
44
|
+
g = int(token[2:4], 16)
|
|
45
|
+
b = int(token[4:6], 16)
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
raise AssertionError(
|
|
48
|
+
f'{path}: expected RRGGBB token, got {token!r}'
|
|
49
|
+
) from e
|
|
50
|
+
|
|
51
|
+
rgb.extend((r, g, b))
|
|
52
|
+
|
|
53
|
+
expected_len = width * height * 3
|
|
54
|
+
assert len(rgb) == expected_len, (
|
|
55
|
+
f'{path}: expected {expected_len} bytes, got {len(rgb)}'
|
|
56
|
+
)
|
|
57
|
+
return bytes(rgb)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_SMALL_PATTERN_RGB = _load_rgbhex('small_pattern_4x3.rgbhex', width=4, height=3)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_encode_1x1_red_matches_reference() -> None:
|
|
64
|
+
img = Image.new('RGB', (1, 1), (255, 0, 0))
|
|
65
|
+
assert blurhash_encode(img, 1, 1) == '00TI:j'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_encode_small_pattern_matches_reference() -> None:
|
|
69
|
+
img = Image.frombytes('RGB', (4, 3), _SMALL_PATTERN_RGB)
|
|
70
|
+
assert blurhash_encode(img, 4, 3) == 'LzJkWT?SSs?F~q$um-krE0vfmJ5t'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_encode_accepts_rgba_input_ignores_alpha_matches_rgb() -> None:
|
|
74
|
+
rgb = _SMALL_PATTERN_RGB
|
|
75
|
+
rgba = bytearray()
|
|
76
|
+
for i in range(0, len(rgb), 3):
|
|
77
|
+
rgba.extend((
|
|
78
|
+
rgb[i],
|
|
79
|
+
rgb[i + 1],
|
|
80
|
+
rgb[i + 2],
|
|
81
|
+
0 if (i // 3) % 2 == 0 else 255,
|
|
82
|
+
))
|
|
83
|
+
|
|
84
|
+
img = Image.frombytes('RGBA', (4, 3), rgba)
|
|
85
|
+
assert blurhash_encode(img, 4, 3) == 'LzJkWT?SSs?F~q$um-krE0vfmJ5t'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_decode_known_hash_matches_reference_bytes() -> None:
|
|
89
|
+
hash_ = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'
|
|
90
|
+
img = blurhash_decode(hash_, 8, 8)
|
|
91
|
+
assert img.mode == 'RGB'
|
|
92
|
+
assert img.size == (8, 8)
|
|
93
|
+
|
|
94
|
+
reference = _load_rgbhex('decode_known_hash_8x8.rgbhex', width=8, height=8)
|
|
95
|
+
_assert_bytes_close(
|
|
96
|
+
img.tobytes(),
|
|
97
|
+
reference,
|
|
98
|
+
max_abs_diff=2,
|
|
99
|
+
max_mean_abs_diff=0.05,
|
|
100
|
+
max_different_bytes=32,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_decode_invalid_blurhash_raises() -> None:
|
|
105
|
+
with pytest.raises(BlurhashDecodeError) as excinfo:
|
|
106
|
+
blurhash_decode('not a blurhash', 8, 8)
|
|
107
|
+
assert excinfo.value.blurhash == 'not a blurhash'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_decode_whitespace_suffix_raises() -> None:
|
|
111
|
+
with pytest.raises(BlurhashDecodeError):
|
|
112
|
+
blurhash_decode('LEHV6nWB2yk8pyo0adR*.7kCMdnj ', 8, 8)
|