hilbertplot 0.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.
- hilbertplot-0.0.0/.gitignore +41 -0
- hilbertplot-0.0.0/LICENSE +21 -0
- hilbertplot-0.0.0/PKG-INFO +285 -0
- hilbertplot-0.0.0/README.md +257 -0
- hilbertplot-0.0.0/examples/groups.py +29 -0
- hilbertplot-0.0.0/examples/plot_catalog.py +43 -0
- hilbertplot-0.0.0/examples/plot_data.py +119 -0
- hilbertplot-0.0.0/examples/show_curve.py +48 -0
- hilbertplot-0.0.0/pyproject.toml +107 -0
- hilbertplot-0.0.0/src/hilbertplot/__init__.py +69 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/__init__.py +1 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/arbitrary.py +114 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/catalog.py +451 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/curve.py +604 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/generation.py +102 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/geometry.py +142 -0
- hilbertplot-0.0.0/src/hilbertplot/_core/symmetry.py +186 -0
- hilbertplot-0.0.0/src/hilbertplot/_data/__init__.py +1 -0
- hilbertplot-0.0.0/src/hilbertplot/_data/mapping.py +255 -0
- hilbertplot-0.0.0/src/hilbertplot/_viz/__init__.py +1 -0
- hilbertplot-0.0.0/src/hilbertplot/_viz/hilbert_plot.py +413 -0
- hilbertplot-0.0.0/src/hilbertplot/_viz/render.py +193 -0
- hilbertplot-0.0.0/src/hilbertplot/py.typed +0 -0
- hilbertplot-0.0.0/tests/test_arbitrary.py +95 -0
- hilbertplot-0.0.0/tests/test_catalog.py +192 -0
- hilbertplot-0.0.0/tests/test_data.py +217 -0
- hilbertplot-0.0.0/tests/test_geometry.py +82 -0
- hilbertplot-0.0.0/tests/test_plotting.py +161 -0
- hilbertplot-0.0.0/tests/test_symmetry.py +83 -0
- hilbertplot-0.0.0/tests/test_words.py +65 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Third-party source papers (copyrighted — reference only, never committed)
|
|
2
|
+
*.pdf
|
|
3
|
+
|
|
4
|
+
# Scratch / debris
|
|
5
|
+
scratch_*
|
|
6
|
+
research/scratch_*
|
|
7
|
+
.coverage
|
|
8
|
+
.coverage.*
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*.egg-info/
|
|
14
|
+
.eggs/
|
|
15
|
+
build/
|
|
16
|
+
dist/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Virtual environments
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
env/
|
|
25
|
+
|
|
26
|
+
# Example output
|
|
27
|
+
examples/output/
|
|
28
|
+
|
|
29
|
+
# OS / editor
|
|
30
|
+
.DS_Store
|
|
31
|
+
*.swp
|
|
32
|
+
.idea/
|
|
33
|
+
.vscode/
|
|
34
|
+
|
|
35
|
+
# LaTeX build artifacts
|
|
36
|
+
*.aux
|
|
37
|
+
*.log
|
|
38
|
+
*.toc
|
|
39
|
+
*.fls
|
|
40
|
+
*.fdb_latexmk
|
|
41
|
+
*.synctex.gz
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Estevez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hilbertplot
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: The complete set of 40 two-dimensional Hilbert curves, and data plots on them.
|
|
5
|
+
Project-URL: Homepage, https://github.com/El3ssar/hilbertplot
|
|
6
|
+
Project-URL: Source, https://github.com/El3ssar/hilbertplot
|
|
7
|
+
Project-URL: Issues, https://github.com/El3ssar/hilbertplot/issues
|
|
8
|
+
Author-email: Daniel Estevez <kemossabee@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dimensionality-reduction,hilbert-curve,locality,moore-curve,space-filling-curve,visualization
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: numpy>=1.21
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: matplotlib>=3.5; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff==0.15.13; extra == 'dev'
|
|
25
|
+
Provides-Extra: plot
|
|
26
|
+
Requires-Dist: matplotlib>=3.5; extra == 'plot'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# hilbertplot
|
|
30
|
+
|
|
31
|
+
The **complete set of forty two-dimensional Hilbert curves** — and a fast way to plot
|
|
32
|
+
long 1-D data on any of them.
|
|
33
|
+
|
|
34
|
+
Most software knows exactly one Hilbert curve. In fact there are **forty** distinct
|
|
35
|
+
space-filling curves of Hilbert type in two dimensions (up to rotation, reflection and
|
|
36
|
+
reversion), as proved by
|
|
37
|
+
|
|
38
|
+
> E. Estevez-Rams, D. Estevez-Moya, Y. Martínez-Camejo, D. Gómez-Gómez and
|
|
39
|
+
> B. Aragón-Fernández, *"Hilbert curves in two dimensions"*,
|
|
40
|
+
> Revista Cubana de Física **34**, 9 (2017).
|
|
41
|
+
|
|
42
|
+
| group | count | kernel(s) | members |
|
|
43
|
+
|-------|-------|-----------|---------|
|
|
44
|
+
| **homogeneous · proper** | 6 | `Hilbert` | Hilbert, Moore, Liu1–4 |
|
|
45
|
+
| **homogeneous · improper** | 6 | `Liu4` | Improper1–6 |
|
|
46
|
+
| **inhomogeneous** | 28 | mixed | e.g. `Liu2+Liu4 #1`, `Hilbert+Liu3 #1` |
|
|
47
|
+
|
|
48
|
+
*Homogeneous* curves build every quadrant from a **single** kernel curve type;
|
|
49
|
+
*inhomogeneous* curves mix **two or more** — and each inhomogeneous curve is named for
|
|
50
|
+
the recipe it uses.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install hilbertplot # core (numpy only)
|
|
56
|
+
pip install "hilbertplot[plot]" # + matplotlib for the plotting / visualisation features
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
or, with [uv](https://docs.astral.sh/uv/):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv add "hilbertplot[plot]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Development (editable, with tests):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/El3ssar/hilbertplot
|
|
69
|
+
cd hilbertplot
|
|
70
|
+
uv venv && uv pip install -e ".[dev]"
|
|
71
|
+
uv run pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Curves — you think "curve *i* of order *j*"
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import hilbertplot
|
|
78
|
+
|
|
79
|
+
c = hilbertplot.curve(0) # by index 0–39, name ("Moore"), or symbol ("12H")
|
|
80
|
+
c.show(6) # draw order 6 in a window
|
|
81
|
+
c.save(6, "hilbert.png") # ...or to a file
|
|
82
|
+
c.points(6) # (4096, 2) integer lattice points, in visit order
|
|
83
|
+
c.word(6) # the u/d/l/r stroke string
|
|
84
|
+
c # repr is just the name: Hilbert
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Rendering adapts to the order so it stays fast at every scale (`mode="auto"`): low orders
|
|
88
|
+
draw the colour-graded polyline; from order 8 up, where strokes are sub-pixel, it paints a
|
|
89
|
+
visit-order **fill heatmap** (a million points in a fraction of a second). Force it with
|
|
90
|
+
`mode="line"` / `mode="fill"`, or pass `color="steelblue"` for a solid line.
|
|
91
|
+
|
|
92
|
+
## Grouping
|
|
93
|
+
|
|
94
|
+
The group functions return a list that **prints as a table**:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
>>> print(hilbertplot.proper())
|
|
98
|
+
# name kind closed
|
|
99
|
+
- ------- ------ ------
|
|
100
|
+
0 Hilbert proper
|
|
101
|
+
1 Moore proper yes
|
|
102
|
+
2 Liu1 proper yes
|
|
103
|
+
3 Liu2 proper
|
|
104
|
+
4 Liu3 proper
|
|
105
|
+
5 Liu4 proper
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
hilbertplot.homogeneous() hilbertplot.inhomogeneous() # 12 + 28
|
|
110
|
+
hilbertplot.proper() hilbertplot.improper() # 6 + 6
|
|
111
|
+
hilbertplot.by_kernels(3, 5) # the six Liu2+Liu4 curves
|
|
112
|
+
hilbertplot.closed() # Moore, Liu1, Improper1, Improper4
|
|
113
|
+
hilbertplot.generalizing() # the 8 that tile any n×n square
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Any single curve tells you where it sits:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
c = hilbertplot.curve("Liu2+Liu4 #1")
|
|
120
|
+
c.kind # 'inhomogeneous'
|
|
121
|
+
c.is_homogeneous # False
|
|
122
|
+
c.kernels # frozenset({3, 5})
|
|
123
|
+
c.is_closed # False
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Visualising data — Hilbert plots
|
|
127
|
+
|
|
128
|
+
Lay a long 1-D vector onto a 2-D image by walking a curve and dropping `data[i]` on the
|
|
129
|
+
`i`-th cell it visits (Estevez-Rams et al., *Comput. Phys. Commun.* **197** (2015) 118).
|
|
130
|
+
`plot_data` (or the `hilbert_plot` shortcut) returns a **`HilbertPlot`** that holds the
|
|
131
|
+
data and draws *nothing* until you ask:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
import numpy as np, hilbertplot
|
|
135
|
+
|
|
136
|
+
plot = hilbertplot.hilbert_plot(0, values) # or: hilbertplot.curve(0).plot_data(values)
|
|
137
|
+
plot.show("viridis") # a matplotlib colormap by name...
|
|
138
|
+
plot.show(["red", "green", "tab:blue"]) # ...or colours to interpolate across
|
|
139
|
+
plot.save("out.png", "gray") # straight to a file
|
|
140
|
+
img = plot.image() # just the 2-D array (for a Fourier map, etc.)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Pass a **list of colours** and the library builds a smooth colormap through them and
|
|
144
|
+
spreads it over the data's min→max — so `1..100` with `["red","green","tab:blue"]` sweeps
|
|
145
|
+
red → green → blue along the curve. A single string is a built-in matplotlib colormap.
|
|
146
|
+
|
|
147
|
+
**Granularity** (block-mean coarsening that makes periodic structure pop) and the **fit**
|
|
148
|
+
(how the grid is sized) are chosen per render, so one `HilbertPlot` gives every view:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
plot.show(granularity=4) # coarsen: blocks of 4 -> their mean
|
|
152
|
+
plot.show() # fit="auto": tight ceil(√N) square for the 8
|
|
153
|
+
# generalizing curves, else pad to 2^k
|
|
154
|
+
plot.show(fit="square") # force the tight square (generalizing curves only)
|
|
155
|
+
plot.show(fit="pad") # smallest enclosing 2^k grid, extra cells blank
|
|
156
|
+
plot.show(fit="pad", fill=0.0) # ...pad with a value that won't skew the colours
|
|
157
|
+
plot.show(fit="truncate") # largest 2^k grid that fits inside; drop the tail
|
|
158
|
+
plot.show(order=6) # force a 2^order grid
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`fit` answers "what if my data length isn't a power of four?" For the eight
|
|
162
|
+
[generalizing curves](#arbitrary-square-sizes) the default is a **tight `⌈√N⌉` square**
|
|
163
|
+
(so 10 000 values → `100×100`, not `128×128`); for the other 32 it falls back to **pad**
|
|
164
|
+
(smallest enclosing `2^k`) or **truncate**. See `examples/plot_data.py`.
|
|
165
|
+
|
|
166
|
+
### Arbitrary square sizes
|
|
167
|
+
|
|
168
|
+
Eight of the forty curves — `hilbertplot.generalizing()` — can be laid on **any** `n×n` square,
|
|
169
|
+
not just `2^k` (proved in `research/quasisquares/`; the other 32 are provably obstructed):
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
hilbertplot.curve(0).grid(100) # Hilbert on a 100×100 grid -> (10000, 2)
|
|
173
|
+
hilbertplot.curve(0).generalizes # True
|
|
174
|
+
hilbertplot.curve(9).grid(100) # ValueError: Improper4 can't tile arbitrary squares
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`grid(2**k)` equals `points(k)` exactly, so the two are interchangeable.
|
|
178
|
+
|
|
179
|
+
### Where locality breaks — the difference map
|
|
180
|
+
|
|
181
|
+
Toggle a **difference-map** overlay to mark, in colour, where the curve breaks locality —
|
|
182
|
+
cells that are neighbours on the plane but far apart along the curve (Pérez-Demydenko et
|
|
183
|
+
al., *Appl. Math. Comput.* **234** (2014) 531). The threshold is tunable: **higher marks
|
|
184
|
+
fewer, worse sites**, lower marks more.
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
plot.show() # data only
|
|
188
|
+
plot.show(difference=True) # + strong-locality barrier
|
|
189
|
+
plot.show(difference=True, difference_threshold=0.5) # mark more of it
|
|
190
|
+
plot.show(difference=True, difference_color="red")
|
|
191
|
+
|
|
192
|
+
hilbertplot.curve(0).difference_map(6) # the raw field, independent of any data
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The barrier is the locus of cells above `mean + threshold·std` of the difference field;
|
|
196
|
+
there is always one between quadrants 1 and 4.
|
|
197
|
+
|
|
198
|
+
### Fourier map — hidden patterns
|
|
199
|
+
|
|
200
|
+
Toggle `fourier=True` to render the **2-D Fourier map** (centred power spectrum) of the
|
|
201
|
+
Hilbert plot instead of the data. Periodic and self-similar structure that's invisible in
|
|
202
|
+
the raw plot shows up as bright peaks and symmetric patterns (Estevez-Rams et al., 2015):
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
plot.show(fourier=True) # the 2-D Fourier map
|
|
206
|
+
plot.show(fourier=True, window=True) # Hann window first (kills edge artifacts)
|
|
207
|
+
spec = plot.fourier() # the raw spectrum array
|
|
208
|
+
|
|
209
|
+
# paper-style side by side
|
|
210
|
+
fig, (a, b) = plt.subplots(1, 2)
|
|
211
|
+
plot.draw("gray", ax=a); plot.draw("magma", ax=b, fourier=True)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
A Thue–Morse sequence gives a self-similar 4-fold-symmetric map; a periodic signal buried
|
|
215
|
+
in noise (which looks like static as a Hilbert plot) reveals a clean bright cluster.
|
|
216
|
+
|
|
217
|
+
## Round trip — 2-D back to 1-D
|
|
218
|
+
|
|
219
|
+
`unroll` reads a square array back into a 1-D vector in curve order — the inverse of the
|
|
220
|
+
data mapping, and a locality-preserving way to flatten an image. The side may be any `n`
|
|
221
|
+
for a generalizing curve, else a power of two:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
img = hilbertplot.hilbert_plot(3, data).image() # 1-D -> 2-D
|
|
225
|
+
back = hilbertplot.curve(3).unroll(img) # 2-D -> 1-D (back == data)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Performance
|
|
229
|
+
|
|
230
|
+
Producing the **whole** curve at order `n` is `O(4ⁿ)` — the curve *is* `4ⁿ` points. The
|
|
231
|
+
generator runs on numpy stroke-code arrays (a morphism is a 4-entry lookup, reversion an
|
|
232
|
+
array reversal, quadrant assembly a `concatenate`) with no Python loop over strokes:
|
|
233
|
+
|
|
234
|
+
| order | points | `points()` | `save()` |
|
|
235
|
+
|------:|-------:|-----------:|---------:|
|
|
236
|
+
| 8 | 65 536 | ~4 ms | ~80 ms |
|
|
237
|
+
| 10 | 1 048 576 | ~40 ms | ~180 ms |
|
|
238
|
+
| 12 | 16 777 216 | ~0.7 s | ~2.4 s |
|
|
239
|
+
|
|
240
|
+
## Design
|
|
241
|
+
|
|
242
|
+
The public API is small — `curve`, `catalog`, the group filters (`homogeneous`,
|
|
243
|
+
`inhomogeneous`, `proper`, `improper`, `closed`, `generalizing`, `by_kernels`), `gallery`
|
|
244
|
+
and `hilbert_plot`. `curve(i)` hands you a `Curve`; `plot_data` / `hilbert_plot` hand you a
|
|
245
|
+
`HilbertPlot`. Everything else is internal, organised into three subpackages:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
hilbertplot/
|
|
249
|
+
├── _core/ the curve maths (numpy + nothing else)
|
|
250
|
+
│ ├── symmetry.py the square's symmetry group D4, morphisms, reversion
|
|
251
|
+
│ ├── catalog.py the declarative table of all 40 curves (single source of truth)
|
|
252
|
+
│ ├── generation.py the tag-system generator (numpy stroke codes, memoised)
|
|
253
|
+
│ ├── geometry.py codes → points, space-filling validation, canonicalisation
|
|
254
|
+
│ ├── arbitrary.py arbitrary n×n squares (AEP) for the 8 generalizing curves
|
|
255
|
+
│ └── curve.py the Curve object, CurveList, and catalogue lookups
|
|
256
|
+
├── _data/ 1-D ↔ 2-D mapping, granularity, pad/truncate fitting, difference map
|
|
257
|
+
│ └── mapping.py
|
|
258
|
+
└── _viz/ matplotlib rendering (imported lazily)
|
|
259
|
+
├── render.py curve rendering + gallery
|
|
260
|
+
└── hilbert_plot.py the HilbertPlot object
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Correctness is enforced by tests that check **all forty** curves are space-filling at
|
|
264
|
+
several orders and are **pairwise distinct** under the sixteen-element
|
|
265
|
+
symmetry-plus-reversion group — which, by the paper's completeness theorem, means the set
|
|
266
|
+
is exactly right. Curve 0 is additionally cross-checked against the standard `d2xy`
|
|
267
|
+
algorithm, and the homogeneous curves against Table 3 of the paper.
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
uv run pytest # 217 tests
|
|
271
|
+
uv run pytest --doctest-modules src/hilbertplot # + the docstring examples
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Roadmap
|
|
275
|
+
|
|
276
|
+
* **done:** 1-D → 2-D on any curve, granularity, `unroll` back to 1-D, difference-map
|
|
277
|
+
overlays, the **2-D Fourier map**, and **arbitrary `n×n` squares for the eight
|
|
278
|
+
generalizing curves** (full classification + impossibility proofs in
|
|
279
|
+
`research/quasisquares/`);
|
|
280
|
+
* arbitrary **rectangles** `w×h` (the classification for these is open — next phase);
|
|
281
|
+
* faster single-point mapping — `index → (x, y)` in `O(n)` without building the whole curve.
|
|
282
|
+
|
|
283
|
+
## License
|
|
284
|
+
|
|
285
|
+
MIT.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# hilbertplot
|
|
2
|
+
|
|
3
|
+
The **complete set of forty two-dimensional Hilbert curves** — and a fast way to plot
|
|
4
|
+
long 1-D data on any of them.
|
|
5
|
+
|
|
6
|
+
Most software knows exactly one Hilbert curve. In fact there are **forty** distinct
|
|
7
|
+
space-filling curves of Hilbert type in two dimensions (up to rotation, reflection and
|
|
8
|
+
reversion), as proved by
|
|
9
|
+
|
|
10
|
+
> E. Estevez-Rams, D. Estevez-Moya, Y. Martínez-Camejo, D. Gómez-Gómez and
|
|
11
|
+
> B. Aragón-Fernández, *"Hilbert curves in two dimensions"*,
|
|
12
|
+
> Revista Cubana de Física **34**, 9 (2017).
|
|
13
|
+
|
|
14
|
+
| group | count | kernel(s) | members |
|
|
15
|
+
|-------|-------|-----------|---------|
|
|
16
|
+
| **homogeneous · proper** | 6 | `Hilbert` | Hilbert, Moore, Liu1–4 |
|
|
17
|
+
| **homogeneous · improper** | 6 | `Liu4` | Improper1–6 |
|
|
18
|
+
| **inhomogeneous** | 28 | mixed | e.g. `Liu2+Liu4 #1`, `Hilbert+Liu3 #1` |
|
|
19
|
+
|
|
20
|
+
*Homogeneous* curves build every quadrant from a **single** kernel curve type;
|
|
21
|
+
*inhomogeneous* curves mix **two or more** — and each inhomogeneous curve is named for
|
|
22
|
+
the recipe it uses.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install hilbertplot # core (numpy only)
|
|
28
|
+
pip install "hilbertplot[plot]" # + matplotlib for the plotting / visualisation features
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or, with [uv](https://docs.astral.sh/uv/):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add "hilbertplot[plot]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Development (editable, with tests):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/El3ssar/hilbertplot
|
|
41
|
+
cd hilbertplot
|
|
42
|
+
uv venv && uv pip install -e ".[dev]"
|
|
43
|
+
uv run pytest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Curves — you think "curve *i* of order *j*"
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import hilbertplot
|
|
50
|
+
|
|
51
|
+
c = hilbertplot.curve(0) # by index 0–39, name ("Moore"), or symbol ("12H")
|
|
52
|
+
c.show(6) # draw order 6 in a window
|
|
53
|
+
c.save(6, "hilbert.png") # ...or to a file
|
|
54
|
+
c.points(6) # (4096, 2) integer lattice points, in visit order
|
|
55
|
+
c.word(6) # the u/d/l/r stroke string
|
|
56
|
+
c # repr is just the name: Hilbert
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Rendering adapts to the order so it stays fast at every scale (`mode="auto"`): low orders
|
|
60
|
+
draw the colour-graded polyline; from order 8 up, where strokes are sub-pixel, it paints a
|
|
61
|
+
visit-order **fill heatmap** (a million points in a fraction of a second). Force it with
|
|
62
|
+
`mode="line"` / `mode="fill"`, or pass `color="steelblue"` for a solid line.
|
|
63
|
+
|
|
64
|
+
## Grouping
|
|
65
|
+
|
|
66
|
+
The group functions return a list that **prints as a table**:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
>>> print(hilbertplot.proper())
|
|
70
|
+
# name kind closed
|
|
71
|
+
- ------- ------ ------
|
|
72
|
+
0 Hilbert proper
|
|
73
|
+
1 Moore proper yes
|
|
74
|
+
2 Liu1 proper yes
|
|
75
|
+
3 Liu2 proper
|
|
76
|
+
4 Liu3 proper
|
|
77
|
+
5 Liu4 proper
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
hilbertplot.homogeneous() hilbertplot.inhomogeneous() # 12 + 28
|
|
82
|
+
hilbertplot.proper() hilbertplot.improper() # 6 + 6
|
|
83
|
+
hilbertplot.by_kernels(3, 5) # the six Liu2+Liu4 curves
|
|
84
|
+
hilbertplot.closed() # Moore, Liu1, Improper1, Improper4
|
|
85
|
+
hilbertplot.generalizing() # the 8 that tile any n×n square
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Any single curve tells you where it sits:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
c = hilbertplot.curve("Liu2+Liu4 #1")
|
|
92
|
+
c.kind # 'inhomogeneous'
|
|
93
|
+
c.is_homogeneous # False
|
|
94
|
+
c.kernels # frozenset({3, 5})
|
|
95
|
+
c.is_closed # False
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Visualising data — Hilbert plots
|
|
99
|
+
|
|
100
|
+
Lay a long 1-D vector onto a 2-D image by walking a curve and dropping `data[i]` on the
|
|
101
|
+
`i`-th cell it visits (Estevez-Rams et al., *Comput. Phys. Commun.* **197** (2015) 118).
|
|
102
|
+
`plot_data` (or the `hilbert_plot` shortcut) returns a **`HilbertPlot`** that holds the
|
|
103
|
+
data and draws *nothing* until you ask:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import numpy as np, hilbertplot
|
|
107
|
+
|
|
108
|
+
plot = hilbertplot.hilbert_plot(0, values) # or: hilbertplot.curve(0).plot_data(values)
|
|
109
|
+
plot.show("viridis") # a matplotlib colormap by name...
|
|
110
|
+
plot.show(["red", "green", "tab:blue"]) # ...or colours to interpolate across
|
|
111
|
+
plot.save("out.png", "gray") # straight to a file
|
|
112
|
+
img = plot.image() # just the 2-D array (for a Fourier map, etc.)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pass a **list of colours** and the library builds a smooth colormap through them and
|
|
116
|
+
spreads it over the data's min→max — so `1..100` with `["red","green","tab:blue"]` sweeps
|
|
117
|
+
red → green → blue along the curve. A single string is a built-in matplotlib colormap.
|
|
118
|
+
|
|
119
|
+
**Granularity** (block-mean coarsening that makes periodic structure pop) and the **fit**
|
|
120
|
+
(how the grid is sized) are chosen per render, so one `HilbertPlot` gives every view:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
plot.show(granularity=4) # coarsen: blocks of 4 -> their mean
|
|
124
|
+
plot.show() # fit="auto": tight ceil(√N) square for the 8
|
|
125
|
+
# generalizing curves, else pad to 2^k
|
|
126
|
+
plot.show(fit="square") # force the tight square (generalizing curves only)
|
|
127
|
+
plot.show(fit="pad") # smallest enclosing 2^k grid, extra cells blank
|
|
128
|
+
plot.show(fit="pad", fill=0.0) # ...pad with a value that won't skew the colours
|
|
129
|
+
plot.show(fit="truncate") # largest 2^k grid that fits inside; drop the tail
|
|
130
|
+
plot.show(order=6) # force a 2^order grid
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`fit` answers "what if my data length isn't a power of four?" For the eight
|
|
134
|
+
[generalizing curves](#arbitrary-square-sizes) the default is a **tight `⌈√N⌉` square**
|
|
135
|
+
(so 10 000 values → `100×100`, not `128×128`); for the other 32 it falls back to **pad**
|
|
136
|
+
(smallest enclosing `2^k`) or **truncate**. See `examples/plot_data.py`.
|
|
137
|
+
|
|
138
|
+
### Arbitrary square sizes
|
|
139
|
+
|
|
140
|
+
Eight of the forty curves — `hilbertplot.generalizing()` — can be laid on **any** `n×n` square,
|
|
141
|
+
not just `2^k` (proved in `research/quasisquares/`; the other 32 are provably obstructed):
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
hilbertplot.curve(0).grid(100) # Hilbert on a 100×100 grid -> (10000, 2)
|
|
145
|
+
hilbertplot.curve(0).generalizes # True
|
|
146
|
+
hilbertplot.curve(9).grid(100) # ValueError: Improper4 can't tile arbitrary squares
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`grid(2**k)` equals `points(k)` exactly, so the two are interchangeable.
|
|
150
|
+
|
|
151
|
+
### Where locality breaks — the difference map
|
|
152
|
+
|
|
153
|
+
Toggle a **difference-map** overlay to mark, in colour, where the curve breaks locality —
|
|
154
|
+
cells that are neighbours on the plane but far apart along the curve (Pérez-Demydenko et
|
|
155
|
+
al., *Appl. Math. Comput.* **234** (2014) 531). The threshold is tunable: **higher marks
|
|
156
|
+
fewer, worse sites**, lower marks more.
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
plot.show() # data only
|
|
160
|
+
plot.show(difference=True) # + strong-locality barrier
|
|
161
|
+
plot.show(difference=True, difference_threshold=0.5) # mark more of it
|
|
162
|
+
plot.show(difference=True, difference_color="red")
|
|
163
|
+
|
|
164
|
+
hilbertplot.curve(0).difference_map(6) # the raw field, independent of any data
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The barrier is the locus of cells above `mean + threshold·std` of the difference field;
|
|
168
|
+
there is always one between quadrants 1 and 4.
|
|
169
|
+
|
|
170
|
+
### Fourier map — hidden patterns
|
|
171
|
+
|
|
172
|
+
Toggle `fourier=True` to render the **2-D Fourier map** (centred power spectrum) of the
|
|
173
|
+
Hilbert plot instead of the data. Periodic and self-similar structure that's invisible in
|
|
174
|
+
the raw plot shows up as bright peaks and symmetric patterns (Estevez-Rams et al., 2015):
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
plot.show(fourier=True) # the 2-D Fourier map
|
|
178
|
+
plot.show(fourier=True, window=True) # Hann window first (kills edge artifacts)
|
|
179
|
+
spec = plot.fourier() # the raw spectrum array
|
|
180
|
+
|
|
181
|
+
# paper-style side by side
|
|
182
|
+
fig, (a, b) = plt.subplots(1, 2)
|
|
183
|
+
plot.draw("gray", ax=a); plot.draw("magma", ax=b, fourier=True)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A Thue–Morse sequence gives a self-similar 4-fold-symmetric map; a periodic signal buried
|
|
187
|
+
in noise (which looks like static as a Hilbert plot) reveals a clean bright cluster.
|
|
188
|
+
|
|
189
|
+
## Round trip — 2-D back to 1-D
|
|
190
|
+
|
|
191
|
+
`unroll` reads a square array back into a 1-D vector in curve order — the inverse of the
|
|
192
|
+
data mapping, and a locality-preserving way to flatten an image. The side may be any `n`
|
|
193
|
+
for a generalizing curve, else a power of two:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
img = hilbertplot.hilbert_plot(3, data).image() # 1-D -> 2-D
|
|
197
|
+
back = hilbertplot.curve(3).unroll(img) # 2-D -> 1-D (back == data)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Performance
|
|
201
|
+
|
|
202
|
+
Producing the **whole** curve at order `n` is `O(4ⁿ)` — the curve *is* `4ⁿ` points. The
|
|
203
|
+
generator runs on numpy stroke-code arrays (a morphism is a 4-entry lookup, reversion an
|
|
204
|
+
array reversal, quadrant assembly a `concatenate`) with no Python loop over strokes:
|
|
205
|
+
|
|
206
|
+
| order | points | `points()` | `save()` |
|
|
207
|
+
|------:|-------:|-----------:|---------:|
|
|
208
|
+
| 8 | 65 536 | ~4 ms | ~80 ms |
|
|
209
|
+
| 10 | 1 048 576 | ~40 ms | ~180 ms |
|
|
210
|
+
| 12 | 16 777 216 | ~0.7 s | ~2.4 s |
|
|
211
|
+
|
|
212
|
+
## Design
|
|
213
|
+
|
|
214
|
+
The public API is small — `curve`, `catalog`, the group filters (`homogeneous`,
|
|
215
|
+
`inhomogeneous`, `proper`, `improper`, `closed`, `generalizing`, `by_kernels`), `gallery`
|
|
216
|
+
and `hilbert_plot`. `curve(i)` hands you a `Curve`; `plot_data` / `hilbert_plot` hand you a
|
|
217
|
+
`HilbertPlot`. Everything else is internal, organised into three subpackages:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
hilbertplot/
|
|
221
|
+
├── _core/ the curve maths (numpy + nothing else)
|
|
222
|
+
│ ├── symmetry.py the square's symmetry group D4, morphisms, reversion
|
|
223
|
+
│ ├── catalog.py the declarative table of all 40 curves (single source of truth)
|
|
224
|
+
│ ├── generation.py the tag-system generator (numpy stroke codes, memoised)
|
|
225
|
+
│ ├── geometry.py codes → points, space-filling validation, canonicalisation
|
|
226
|
+
│ ├── arbitrary.py arbitrary n×n squares (AEP) for the 8 generalizing curves
|
|
227
|
+
│ └── curve.py the Curve object, CurveList, and catalogue lookups
|
|
228
|
+
├── _data/ 1-D ↔ 2-D mapping, granularity, pad/truncate fitting, difference map
|
|
229
|
+
│ └── mapping.py
|
|
230
|
+
└── _viz/ matplotlib rendering (imported lazily)
|
|
231
|
+
├── render.py curve rendering + gallery
|
|
232
|
+
└── hilbert_plot.py the HilbertPlot object
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Correctness is enforced by tests that check **all forty** curves are space-filling at
|
|
236
|
+
several orders and are **pairwise distinct** under the sixteen-element
|
|
237
|
+
symmetry-plus-reversion group — which, by the paper's completeness theorem, means the set
|
|
238
|
+
is exactly right. Curve 0 is additionally cross-checked against the standard `d2xy`
|
|
239
|
+
algorithm, and the homogeneous curves against Table 3 of the paper.
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
uv run pytest # 217 tests
|
|
243
|
+
uv run pytest --doctest-modules src/hilbertplot # + the docstring examples
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Roadmap
|
|
247
|
+
|
|
248
|
+
* **done:** 1-D → 2-D on any curve, granularity, `unroll` back to 1-D, difference-map
|
|
249
|
+
overlays, the **2-D Fourier map**, and **arbitrary `n×n` squares for the eight
|
|
250
|
+
generalizing curves** (full classification + impossibility proofs in
|
|
251
|
+
`research/quasisquares/`);
|
|
252
|
+
* arbitrary **rectangles** `w×h` (the classification for these is open — next phase);
|
|
253
|
+
* faster single-point mapping — `index → (x, y)` in `O(n)` without building the whole curve.
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Print the 40 curves grouped by family -- the lists print themselves as tables.
|
|
2
|
+
|
|
3
|
+
python examples/groups.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hilbertplot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
print("Proper (kernel Hilbert)\n")
|
|
11
|
+
print(hilbertplot.proper())
|
|
12
|
+
|
|
13
|
+
print("\n\nImproper (kernel Liu4)\n")
|
|
14
|
+
print(hilbertplot.improper())
|
|
15
|
+
|
|
16
|
+
print("\n\nInhomogeneous by kernel mix\n")
|
|
17
|
+
for kernels in ((3, 5), (0, 4), (1, 5), (1, 3, 5)):
|
|
18
|
+
print(hilbertplot.by_kernels(*kernels))
|
|
19
|
+
print()
|
|
20
|
+
|
|
21
|
+
print(
|
|
22
|
+
f"Totals: {len(hilbertplot.homogeneous())} homogeneous "
|
|
23
|
+
f"({len(hilbertplot.proper())} proper + {len(hilbertplot.improper())} improper) "
|
|
24
|
+
f"+ {len(hilbertplot.inhomogeneous())} inhomogeneous = {len(hilbertplot.catalog())}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Render the catalogue as PNG grids.
|
|
2
|
+
|
|
3
|
+
python examples/plot_catalog.py
|
|
4
|
+
|
|
5
|
+
Writes to examples/output/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import hilbertplot as hp
|
|
11
|
+
|
|
12
|
+
OUT = os.path.join(os.path.dirname(__file__), "output")
|
|
13
|
+
os.makedirs(OUT, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
hp.gallery(
|
|
18
|
+
hp.homogeneous(),
|
|
19
|
+
order=4,
|
|
20
|
+
ncols=6,
|
|
21
|
+
title="The 12 homogeneous Hilbert curves (order 4)",
|
|
22
|
+
save=os.path.join(OUT, "homogeneous.png"),
|
|
23
|
+
)
|
|
24
|
+
hp.gallery(
|
|
25
|
+
hp.inhomogeneous(),
|
|
26
|
+
order=3,
|
|
27
|
+
ncols=7,
|
|
28
|
+
title="The 28 inhomogeneous Hilbert curves (order 3)",
|
|
29
|
+
save=os.path.join(OUT, "inhomogeneous.png"),
|
|
30
|
+
)
|
|
31
|
+
hp.gallery(
|
|
32
|
+
hp.catalog(),
|
|
33
|
+
order=3,
|
|
34
|
+
ncols=8,
|
|
35
|
+
title="All 40 two-dimensional Hilbert curves (order 3)",
|
|
36
|
+
save=os.path.join(OUT, "all40.png"),
|
|
37
|
+
)
|
|
38
|
+
hp.curve(0).save(6, os.path.join(OUT, "hilbert_order6.png"))
|
|
39
|
+
print(f"wrote plots to {OUT}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|