hilbertplot 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hilbertplot/__init__.py +69 -0
- hilbertplot/_core/__init__.py +1 -0
- hilbertplot/_core/arbitrary.py +114 -0
- hilbertplot/_core/catalog.py +451 -0
- hilbertplot/_core/curve.py +604 -0
- hilbertplot/_core/generation.py +102 -0
- hilbertplot/_core/geometry.py +142 -0
- hilbertplot/_core/symmetry.py +186 -0
- hilbertplot/_data/__init__.py +1 -0
- hilbertplot/_data/mapping.py +255 -0
- hilbertplot/_viz/__init__.py +1 -0
- hilbertplot/_viz/hilbert_plot.py +413 -0
- hilbertplot/_viz/render.py +193 -0
- hilbertplot/py.typed +0 -0
- hilbertplot-0.0.0.dist-info/METADATA +285 -0
- hilbertplot-0.0.0.dist-info/RECORD +18 -0
- hilbertplot-0.0.0.dist-info/WHEEL +4 -0
- hilbertplot-0.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""The :class:`HilbertPlot` object -- data laid on a curve, rendered on demand.
|
|
2
|
+
|
|
3
|
+
A :class:`HilbertPlot` just holds a curve and a data vector; it draws nothing until
|
|
4
|
+
you ask. Get one from :meth:`hilbertplot.curve.Curve.plot_data` or the
|
|
5
|
+
:func:`hilbert_plot` shortcut, then render with :meth:`HilbertPlot.show` /
|
|
6
|
+
:meth:`~HilbertPlot.save` / :meth:`~HilbertPlot.draw`, choosing the colormap,
|
|
7
|
+
granularity and power-of-four fit at that point (not at construction).
|
|
8
|
+
|
|
9
|
+
Colormaps
|
|
10
|
+
---------
|
|
11
|
+
``cmap`` accepts either a matplotlib colormap **name** (``"viridis"``, ``"gray"`` ...)
|
|
12
|
+
or a **list of colours** to interpolate smoothly between and spread across the data's
|
|
13
|
+
min-max range, e.g. ``["red", "green", "tab:blue"]`` sweeps red -> green -> blue.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .._core.curve import Curve
|
|
21
|
+
from .._core.curve import curve as _lookup_curve
|
|
22
|
+
from .._data.mapping import granulate, resolve_order, scatter, square_side
|
|
23
|
+
|
|
24
|
+
__all__ = ["HilbertPlot", "hilbert_plot"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_cmap(cmap):
|
|
28
|
+
"""Turn ``cmap`` (a name or a list of colours) into a matplotlib Colormap.
|
|
29
|
+
|
|
30
|
+
NaN-filled (unmapped) cells are made transparent so padding never shows.
|
|
31
|
+
"""
|
|
32
|
+
from matplotlib import colormaps
|
|
33
|
+
from matplotlib.colors import Colormap, LinearSegmentedColormap
|
|
34
|
+
|
|
35
|
+
if isinstance(cmap, Colormap):
|
|
36
|
+
resolved = cmap
|
|
37
|
+
elif isinstance(cmap, str):
|
|
38
|
+
resolved = colormaps[cmap]
|
|
39
|
+
else: # a sequence of colour specs to interpolate through
|
|
40
|
+
colors = list(cmap)
|
|
41
|
+
if not colors:
|
|
42
|
+
raise ValueError("colormap colour list is empty")
|
|
43
|
+
if len(colors) == 1:
|
|
44
|
+
colors = colors * 2
|
|
45
|
+
resolved = LinearSegmentedColormap.from_list("hilbertplot", colors)
|
|
46
|
+
# Make NaN-filled (unmapped) cells transparent so padding never shows.
|
|
47
|
+
return resolved.with_extremes(bad=(0.0, 0.0, 0.0, 0.0))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _overlay_difference(ax, dmap, side, threshold, color):
|
|
51
|
+
"""Paint the strong-locality barrier of a difference map onto ``ax``.
|
|
52
|
+
|
|
53
|
+
The barrier is the set of cells whose value exceeds ``mean + threshold * std``;
|
|
54
|
+
all other cells stay transparent.
|
|
55
|
+
"""
|
|
56
|
+
from matplotlib.colors import to_rgba
|
|
57
|
+
|
|
58
|
+
barrier = dmap > dmap.mean() + threshold * dmap.std()
|
|
59
|
+
overlay = np.zeros((side, side, 4))
|
|
60
|
+
overlay[barrier] = to_rgba(color)
|
|
61
|
+
ax.imshow(
|
|
62
|
+
overlay,
|
|
63
|
+
origin="lower",
|
|
64
|
+
interpolation="nearest",
|
|
65
|
+
extent=(-0.5, side - 0.5, -0.5, side - 0.5),
|
|
66
|
+
zorder=2,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HilbertPlot:
|
|
71
|
+
"""A 1-D data vector bound to a curve, ready to be rendered as a Hilbert plot.
|
|
72
|
+
|
|
73
|
+
Holds only the curve and the data and draws nothing on construction; the colormap,
|
|
74
|
+
granularity and power-of-four fit are all chosen at render time, so one object gives
|
|
75
|
+
you every variant. Construct via :meth:`hilbertplot.curve.Curve.plot_data` or
|
|
76
|
+
:func:`hilbert_plot`.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
curve : Curve
|
|
81
|
+
The curve whose path lays out the data.
|
|
82
|
+
data : array_like
|
|
83
|
+
The 1-D sequence to visualise.
|
|
84
|
+
|
|
85
|
+
Examples
|
|
86
|
+
--------
|
|
87
|
+
>>> import numpy as np, hilbertplot
|
|
88
|
+
>>> plot = hilbertplot.hilbert_plot(0, np.arange(1, 101))
|
|
89
|
+
>>> plot.show(["red", "green", "tab:blue"]) # doctest: +SKIP
|
|
90
|
+
>>> plot.image().shape # 100 values -> tight 10x10 square (Hilbert generalizes)
|
|
91
|
+
(10, 10)
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("_curve", "_data")
|
|
95
|
+
|
|
96
|
+
def __init__(self, curve: Curve, data) -> None:
|
|
97
|
+
if not isinstance(curve, Curve):
|
|
98
|
+
raise TypeError(f"curve must be a Curve, got {type(curve).__name__}")
|
|
99
|
+
self._curve = curve
|
|
100
|
+
# own a private, read-only copy so the plot never aliases the caller's array
|
|
101
|
+
self._data = np.array(data, dtype=float).ravel()
|
|
102
|
+
self._data.flags.writeable = False
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def curve(self) -> Curve:
|
|
106
|
+
"""The curve the data is mapped through."""
|
|
107
|
+
return self._curve
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def data(self) -> np.ndarray:
|
|
111
|
+
"""The 1-D data vector, as a read-only array (a private copy of the input)."""
|
|
112
|
+
return self._data
|
|
113
|
+
|
|
114
|
+
def __len__(self) -> int:
|
|
115
|
+
return self._data.size
|
|
116
|
+
|
|
117
|
+
# -- the mapping ---------------------------------------------------------
|
|
118
|
+
def image(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
granularity: int = 1,
|
|
122
|
+
order: int | None = None,
|
|
123
|
+
fit: str = "auto",
|
|
124
|
+
fill: float = np.nan,
|
|
125
|
+
) -> np.ndarray:
|
|
126
|
+
"""Return the 2-D image of the data mapped through the curve.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
granularity : int, default 1
|
|
131
|
+
Coarsen the data with the ``l``-granularity transform first (``1`` = none).
|
|
132
|
+
order : int, optional
|
|
133
|
+
Force a ``2**order`` grid (implies a power-of-two fit).
|
|
134
|
+
fit : {"auto", "square", "pad", "truncate"}, default "auto"
|
|
135
|
+
How to size the grid. ``"auto"`` uses ``"square"`` when the curve
|
|
136
|
+
:attr:`~hilbertplot.Curve.generalizes` (a tight ``ceil(sqrt(N))`` square, no
|
|
137
|
+
power-of-two waste) and ``"pad"`` otherwise. ``"square"`` forces the tight
|
|
138
|
+
square (only for generalizing curves). ``"pad"`` uses the smallest enclosing
|
|
139
|
+
``2**k`` grid; ``"truncate"`` the largest ``2**k`` grid that fits inside the
|
|
140
|
+
data.
|
|
141
|
+
fill : float, default numpy.nan
|
|
142
|
+
Value for unmapped cells (``NaN`` renders transparent).
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
numpy.ndarray
|
|
147
|
+
A ``side`` by ``side`` float image, indexed ``[y, x]``.
|
|
148
|
+
|
|
149
|
+
Examples
|
|
150
|
+
--------
|
|
151
|
+
>>> import numpy as np, hilbertplot
|
|
152
|
+
>>> hilbertplot.hilbert_plot(0, np.arange(10000)).image().shape # tight square
|
|
153
|
+
(100, 100)
|
|
154
|
+
>>> hilbertplot.hilbert_plot(0, np.arange(64)).image(order=3).shape
|
|
155
|
+
(8, 8)
|
|
156
|
+
"""
|
|
157
|
+
values, points, side = self._layout(granularity, order, fit)
|
|
158
|
+
return scatter(points, values, side, fill)
|
|
159
|
+
|
|
160
|
+
def fourier(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
granularity: int = 1,
|
|
164
|
+
order: int | None = None,
|
|
165
|
+
fit: str = "auto",
|
|
166
|
+
log: bool = True,
|
|
167
|
+
window: bool = False,
|
|
168
|
+
) -> np.ndarray:
|
|
169
|
+
"""Return the 2-D Fourier map of this plot -- the centred power spectrum of the image.
|
|
170
|
+
|
|
171
|
+
Periodic and self-similar structure in the data appears as bright peaks and
|
|
172
|
+
symmetric patterns (the "Fourier map" of Estevez-Rams et al., 2015).
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
granularity, order, fit
|
|
177
|
+
Passed to :meth:`image` to build the data image that is transformed.
|
|
178
|
+
log : bool, default True
|
|
179
|
+
Return ``log(1 + |F|)`` so faint structure is visible.
|
|
180
|
+
window : bool, default False
|
|
181
|
+
Apply a Hann window first to suppress edge artifacts.
|
|
182
|
+
|
|
183
|
+
Returns
|
|
184
|
+
-------
|
|
185
|
+
numpy.ndarray
|
|
186
|
+
The centred 2-D spectrum, same shape as :meth:`image`.
|
|
187
|
+
|
|
188
|
+
Examples
|
|
189
|
+
--------
|
|
190
|
+
>>> import numpy as np, hilbertplot
|
|
191
|
+
>>> spec = hilbertplot.hilbert_plot(0, np.tile([1, 0], 512)).fourier() # ·10· pattern
|
|
192
|
+
>>> spec.shape
|
|
193
|
+
(32, 32)
|
|
194
|
+
"""
|
|
195
|
+
from .._data.mapping import fourier_spectrum
|
|
196
|
+
|
|
197
|
+
img = self.image(granularity=granularity, order=order, fit=fit, fill=np.nan)
|
|
198
|
+
return fourier_spectrum(img, log=log, window=window)
|
|
199
|
+
|
|
200
|
+
def _layout(self, granularity, order, fit):
|
|
201
|
+
"""Resolve (values, curve points, grid side) for a given granularity/order/fit."""
|
|
202
|
+
values = self._data
|
|
203
|
+
if granularity != 1:
|
|
204
|
+
values = granulate(values, granularity)
|
|
205
|
+
n = values.size
|
|
206
|
+
effective = fit
|
|
207
|
+
if order is None and fit == "auto":
|
|
208
|
+
effective = "square" if self._curve.generalizes else "pad"
|
|
209
|
+
if order is None and effective == "square":
|
|
210
|
+
if not self._curve.generalizes:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"fit='square' needs a generalizing curve; {self._curve.name} is not "
|
|
213
|
+
f"one (see hilbertplot.generalizing()). Use fit='pad' or 'truncate'."
|
|
214
|
+
)
|
|
215
|
+
side = square_side(n)
|
|
216
|
+
return values, self._curve.grid(side), side
|
|
217
|
+
pot_fit = "pad" if effective in ("auto", "square") else effective
|
|
218
|
+
order = resolve_order(n, order, pot_fit)
|
|
219
|
+
return values, self._curve.points(order), 2**order
|
|
220
|
+
|
|
221
|
+
# -- rendering -----------------------------------------------------------
|
|
222
|
+
def draw(
|
|
223
|
+
self,
|
|
224
|
+
cmap="viridis",
|
|
225
|
+
*,
|
|
226
|
+
granularity: int = 1,
|
|
227
|
+
order: int | None = None,
|
|
228
|
+
fit: str = "auto",
|
|
229
|
+
fill: float = np.nan,
|
|
230
|
+
ax=None,
|
|
231
|
+
vmin=None,
|
|
232
|
+
vmax=None,
|
|
233
|
+
title: str | None = None,
|
|
234
|
+
difference: bool = False,
|
|
235
|
+
difference_threshold: float = 1.0,
|
|
236
|
+
difference_color="#2b6cff",
|
|
237
|
+
fourier: bool = False,
|
|
238
|
+
window: bool = False,
|
|
239
|
+
**imshow_kw,
|
|
240
|
+
):
|
|
241
|
+
"""Render onto ``ax`` (created if ``None``) and return the axes -- no window.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
cmap : str or list of color, default "viridis"
|
|
246
|
+
A matplotlib colormap **name**, or a **list of colours** to interpolate
|
|
247
|
+
smoothly between (e.g. ``["red", "green", "tab:blue"]`` sweeps red to blue).
|
|
248
|
+
granularity, order, fit, fill
|
|
249
|
+
Passed to :meth:`image`.
|
|
250
|
+
ax : matplotlib.axes.Axes, optional
|
|
251
|
+
Axes to draw on; a new figure is created when ``None``.
|
|
252
|
+
vmin, vmax : float, optional
|
|
253
|
+
Colour limits; default to the data's min/max so the colours span the full
|
|
254
|
+
value range.
|
|
255
|
+
title : str, optional
|
|
256
|
+
Axes title; a default naming the curve and grid size is used when ``None``.
|
|
257
|
+
difference : bool, default False
|
|
258
|
+
Overlay the curve's strong-locality barrier -- the cells of the difference
|
|
259
|
+
map above ``mean + difference_threshold * std`` -- marking where the curve
|
|
260
|
+
breaks locality (see :meth:`hilbertplot.curve.Curve.difference_map`).
|
|
261
|
+
difference_threshold : float, default 1.0
|
|
262
|
+
Std multiplier for the barrier: **higher marks fewer, worse sites**; lower
|
|
263
|
+
marks more.
|
|
264
|
+
difference_color : color, default "#2b6cff"
|
|
265
|
+
Colour of the barrier overlay.
|
|
266
|
+
fourier : bool, default False
|
|
267
|
+
Render the **2-D Fourier map** (centred power spectrum) instead of the data
|
|
268
|
+
image, revealing periodic/self-similar structure as bright peaks. The
|
|
269
|
+
``difference`` overlay is ignored in this mode.
|
|
270
|
+
window : bool, default False
|
|
271
|
+
(Fourier mode) apply a Hann window first to suppress edge artifacts.
|
|
272
|
+
**imshow_kw
|
|
273
|
+
Extra keyword arguments forwarded to ``matplotlib``'s ``imshow``.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
matplotlib.axes.Axes
|
|
278
|
+
|
|
279
|
+
Examples
|
|
280
|
+
--------
|
|
281
|
+
>>> import numpy as np, hilbertplot
|
|
282
|
+
>>> plot = hilbertplot.hilbert_plot(0, np.arange(4096))
|
|
283
|
+
>>> ax = plot.draw("gray", difference=True) # doctest: +SKIP
|
|
284
|
+
>>> ax = plot.draw("magma", fourier=True) # doctest: +SKIP
|
|
285
|
+
"""
|
|
286
|
+
from .._data.mapping import difference_map, fourier_spectrum
|
|
287
|
+
from .render import pyplot
|
|
288
|
+
|
|
289
|
+
plt = pyplot()
|
|
290
|
+
values, points, side = self._layout(granularity, order, fit)
|
|
291
|
+
image = scatter(points, values, side, fill)
|
|
292
|
+
if fourier:
|
|
293
|
+
image = fourier_spectrum(image, log=True, window=window)
|
|
294
|
+
difference = False # not meaningful on a spectrum
|
|
295
|
+
if ax is None:
|
|
296
|
+
_, ax = plt.subplots(figsize=(5, 5))
|
|
297
|
+
if fourier and (vmin is None or vmax is None):
|
|
298
|
+
# percentile contrast so faint spectral structure is visible by default
|
|
299
|
+
lo, hi = np.percentile(image, [50.0, 99.8])
|
|
300
|
+
vmin = lo if vmin is None else vmin
|
|
301
|
+
vmax = (hi if hi > lo else image.max()) if vmax is None else vmax
|
|
302
|
+
if vmin is None:
|
|
303
|
+
vmin = np.nanmin(image)
|
|
304
|
+
if vmax is None:
|
|
305
|
+
vmax = np.nanmax(image)
|
|
306
|
+
ax.imshow(
|
|
307
|
+
image,
|
|
308
|
+
origin="lower",
|
|
309
|
+
interpolation="nearest",
|
|
310
|
+
cmap=_resolve_cmap(cmap),
|
|
311
|
+
vmin=vmin,
|
|
312
|
+
vmax=vmax,
|
|
313
|
+
extent=(-0.5, side - 0.5, -0.5, side - 0.5),
|
|
314
|
+
**imshow_kw,
|
|
315
|
+
)
|
|
316
|
+
if difference:
|
|
317
|
+
_overlay_difference(
|
|
318
|
+
ax, difference_map(points, side), side, difference_threshold, difference_color
|
|
319
|
+
)
|
|
320
|
+
ax.set_aspect("equal")
|
|
321
|
+
ax.set_xticks([])
|
|
322
|
+
ax.set_yticks([])
|
|
323
|
+
for spine in ax.spines.values():
|
|
324
|
+
spine.set_visible(False)
|
|
325
|
+
if title is None:
|
|
326
|
+
gran = "" if granularity == 1 else f", {granularity}-granularity"
|
|
327
|
+
kind = "Fourier map" if fourier else "Hilbert plot"
|
|
328
|
+
title = f"{self._curve.name} {kind} ({side}x{side}{gran})"
|
|
329
|
+
ax.set_title(title, fontsize=9)
|
|
330
|
+
return ax
|
|
331
|
+
|
|
332
|
+
def show(self, cmap="viridis", **kwargs):
|
|
333
|
+
"""Draw the plot and open a window.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
cmap : str or list of color, default "viridis"
|
|
338
|
+
See :meth:`draw`.
|
|
339
|
+
**kwargs
|
|
340
|
+
Every :meth:`draw` option (``granularity``, ``fit``, ``vmin``, ...).
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
matplotlib.axes.Axes
|
|
345
|
+
"""
|
|
346
|
+
from .render import pyplot
|
|
347
|
+
|
|
348
|
+
ax = self.draw(cmap, **kwargs)
|
|
349
|
+
pyplot().show()
|
|
350
|
+
return ax
|
|
351
|
+
|
|
352
|
+
def save(self, path: str, cmap="viridis", *, dpi: int = 140, **kwargs) -> str:
|
|
353
|
+
"""Draw the plot, save it to ``path`` and return ``path``.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
path : str
|
|
358
|
+
Output file path (format inferred from the extension).
|
|
359
|
+
cmap : str or list of color, default "viridis"
|
|
360
|
+
See :meth:`draw`.
|
|
361
|
+
dpi : int, default 140
|
|
362
|
+
Output resolution.
|
|
363
|
+
**kwargs
|
|
364
|
+
Every :meth:`draw` option.
|
|
365
|
+
|
|
366
|
+
Returns
|
|
367
|
+
-------
|
|
368
|
+
str
|
|
369
|
+
``path``.
|
|
370
|
+
|
|
371
|
+
Examples
|
|
372
|
+
--------
|
|
373
|
+
>>> import numpy as np, hilbertplot
|
|
374
|
+
>>> hilbertplot.hilbert_plot(0, np.arange(1, 101)).save(
|
|
375
|
+
... "out.png", ["red", "green", "tab:blue"]) # doctest: +SKIP
|
|
376
|
+
'out.png'
|
|
377
|
+
"""
|
|
378
|
+
from .render import pyplot
|
|
379
|
+
|
|
380
|
+
ax = self.draw(cmap, **kwargs)
|
|
381
|
+
ax.figure.savefig(path, dpi=dpi, bbox_inches="tight")
|
|
382
|
+
pyplot().close(ax.figure)
|
|
383
|
+
return path
|
|
384
|
+
|
|
385
|
+
def __repr__(self) -> str:
|
|
386
|
+
return f"<HilbertPlot: {len(self)} values on {self._curve.name}>"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def hilbert_plot(curve: Curve | int | str, data) -> HilbertPlot:
|
|
390
|
+
"""Make a :class:`HilbertPlot` straight from a curve and data.
|
|
391
|
+
|
|
392
|
+
A shortcut for ``hilbertplot.curve(which).plot_data(data)`` that skips the intermediate
|
|
393
|
+
curve object.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
curve : Curve or int or str
|
|
398
|
+
A :class:`Curve`, or anything :func:`hilbertplot.curve` accepts (index, name or symbol).
|
|
399
|
+
data : array_like
|
|
400
|
+
The 1-D sequence to visualise.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
HilbertPlot
|
|
405
|
+
|
|
406
|
+
Examples
|
|
407
|
+
--------
|
|
408
|
+
>>> import hilbertplot
|
|
409
|
+
>>> plot = hilbertplot.hilbert_plot(0, range(1024)) # curve 0 (Hilbert)
|
|
410
|
+
>>> plot.show(["red", "green", "tab:blue"]) # doctest: +SKIP
|
|
411
|
+
"""
|
|
412
|
+
c = curve if isinstance(curve, Curve) else _lookup_curve(curve)
|
|
413
|
+
return HilbertPlot(c, data)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Matplotlib rendering, kept behind the :class:`~hilbertplot._core.curve.Curve` methods.
|
|
2
|
+
|
|
3
|
+
Nothing else in the package imports matplotlib, and it is imported lazily only when a
|
|
4
|
+
plot is actually drawn, so the maths runs with numpy alone. Users never call into
|
|
5
|
+
this module directly -- they use ``curve.show(order)`` / ``curve.save(order, path)`` /
|
|
6
|
+
``curve.plot(order)`` and :func:`gallery`.
|
|
7
|
+
|
|
8
|
+
Two render modes, chosen automatically by order:
|
|
9
|
+
|
|
10
|
+
* ``"line"`` draws the actual polyline. At low orders this is colour-graded along the
|
|
11
|
+
curve (via a ``LineCollection``) so the traversal order is visible; that technique
|
|
12
|
+
is one path per stroke, so it is only used up to order 7.
|
|
13
|
+
* ``"fill"`` paints a ``2**order`` by ``2**order`` image whose pixels are coloured by
|
|
14
|
+
visit order and shows it with ``imshow``. This is O(cells) and stays fast for
|
|
15
|
+
millions of points -- and at high orders, where individual strokes are sub-pixel,
|
|
16
|
+
it is also the *only* legible view.
|
|
17
|
+
|
|
18
|
+
``mode="auto"`` (the default) uses ``line`` below order 8 and ``fill`` at or above it.
|
|
19
|
+
Passing an explicit ``color=`` always draws a single fast solid polyline.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
# At/above this order individual strokes are sub-pixel: switch to the fill image.
|
|
27
|
+
_AUTO_FILL_ORDER = 8
|
|
28
|
+
# Per-stroke gradient colouring (LineCollection) is only practical up to this order.
|
|
29
|
+
_GRADIENT_MAX_ORDER = 7
|
|
30
|
+
_DEFAULT_COLOR = "#1f4e79"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pyplot():
|
|
34
|
+
"""Return ``matplotlib.pyplot`` with a friendly error if matplotlib is missing."""
|
|
35
|
+
try:
|
|
36
|
+
import matplotlib.pyplot as plt
|
|
37
|
+
except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
|
|
38
|
+
raise ModuleNotFoundError(
|
|
39
|
+
"Plotting needs matplotlib. Install it with "
|
|
40
|
+
"'uv pip install hilbertplot[plot]' (or 'pip install matplotlib')."
|
|
41
|
+
) from exc
|
|
42
|
+
return plt
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _draw_fill(ax, pts, side, cmap):
|
|
46
|
+
img = np.empty((side, side), dtype=np.float32)
|
|
47
|
+
img[pts[:, 1], pts[:, 0]] = np.linspace(0.0, 1.0, len(pts), dtype=np.float32)
|
|
48
|
+
ax.imshow(
|
|
49
|
+
img,
|
|
50
|
+
cmap=cmap,
|
|
51
|
+
origin="lower",
|
|
52
|
+
interpolation="nearest",
|
|
53
|
+
extent=(-0.5, side - 0.5, -0.5, side - 0.5),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _draw_line(ax, pts, order, color, cmap, linewidth):
|
|
58
|
+
if linewidth is None:
|
|
59
|
+
linewidth = max(0.15, 3.0 - 0.35 * order)
|
|
60
|
+
if color is None and order <= _GRADIENT_MAX_ORDER:
|
|
61
|
+
from matplotlib.collections import LineCollection
|
|
62
|
+
|
|
63
|
+
segs = np.stack([pts[:-1], pts[1:]], axis=1)
|
|
64
|
+
lc = LineCollection(
|
|
65
|
+
segs, cmap=cmap, linewidth=linewidth, capstyle="round", joinstyle="round"
|
|
66
|
+
)
|
|
67
|
+
lc.set_array(np.linspace(0.0, 1.0, len(segs)))
|
|
68
|
+
ax.add_collection(lc)
|
|
69
|
+
ax.autoscale_view()
|
|
70
|
+
else:
|
|
71
|
+
ax.plot(
|
|
72
|
+
pts[:, 0],
|
|
73
|
+
pts[:, 1],
|
|
74
|
+
color=color or _DEFAULT_COLOR,
|
|
75
|
+
linewidth=linewidth,
|
|
76
|
+
solid_capstyle="round",
|
|
77
|
+
solid_joinstyle="round",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def draw(
|
|
82
|
+
curve,
|
|
83
|
+
order: int,
|
|
84
|
+
ax=None,
|
|
85
|
+
*,
|
|
86
|
+
mode: str = "auto",
|
|
87
|
+
color: str | None = None,
|
|
88
|
+
cmap: str = "viridis",
|
|
89
|
+
linewidth: float | None = None,
|
|
90
|
+
endpoints: bool = True,
|
|
91
|
+
title: str | None = None,
|
|
92
|
+
):
|
|
93
|
+
"""Draw one curve at ``order`` onto ``ax`` (created if ``None``); return the axes."""
|
|
94
|
+
plt = pyplot()
|
|
95
|
+
pts = curve.points(order)
|
|
96
|
+
side = 2**order
|
|
97
|
+
if ax is None:
|
|
98
|
+
_, ax = plt.subplots(figsize=(5, 5))
|
|
99
|
+
if mode == "auto":
|
|
100
|
+
mode = "fill" if order >= _AUTO_FILL_ORDER else "line"
|
|
101
|
+
|
|
102
|
+
if mode == "fill":
|
|
103
|
+
_draw_fill(ax, pts, side, cmap)
|
|
104
|
+
elif mode == "line":
|
|
105
|
+
_draw_line(ax, pts, order, color, cmap, linewidth)
|
|
106
|
+
if endpoints:
|
|
107
|
+
ms = max(2.0, 6.0 - 0.5 * order)
|
|
108
|
+
ax.plot(*pts[0], "o", color="black", markersize=ms, zorder=3) # entry
|
|
109
|
+
ax.plot(*pts[-1], "s", color="black", markersize=ms, zorder=3) # exit
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError(f"mode must be 'auto', 'line' or 'fill', got {mode!r}")
|
|
112
|
+
|
|
113
|
+
ax.set_xlim(-0.75, side - 0.25)
|
|
114
|
+
ax.set_ylim(-0.75, side - 0.25)
|
|
115
|
+
ax.set_aspect("equal")
|
|
116
|
+
ax.set_xticks([])
|
|
117
|
+
ax.set_yticks([])
|
|
118
|
+
for spine in ax.spines.values():
|
|
119
|
+
spine.set_visible(False)
|
|
120
|
+
if title is None:
|
|
121
|
+
title = f"{curve.symbol} ({curve.name}), order {order}"
|
|
122
|
+
ax.set_title(title, fontsize=9)
|
|
123
|
+
return ax
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def gallery(
|
|
127
|
+
curves,
|
|
128
|
+
order=3,
|
|
129
|
+
*,
|
|
130
|
+
ncols=None,
|
|
131
|
+
save=None,
|
|
132
|
+
mode="auto",
|
|
133
|
+
cmap="viridis",
|
|
134
|
+
color=None,
|
|
135
|
+
title=None,
|
|
136
|
+
dpi=140,
|
|
137
|
+
):
|
|
138
|
+
"""Draw a grid of curves; show it, or save it if ``save=`` is given.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
curves : iterable of Curve
|
|
143
|
+
The curves to draw, e.g. ``hilbertplot.homogeneous()`` or ``hilbertplot.by_kernels(3, 5)``.
|
|
144
|
+
order : int, default 3
|
|
145
|
+
Order at which to draw every curve.
|
|
146
|
+
ncols : int, optional
|
|
147
|
+
Number of columns; chosen automatically when ``None``.
|
|
148
|
+
save : str, optional
|
|
149
|
+
If given, save the figure to this path and close it; otherwise open a window.
|
|
150
|
+
mode : {"auto", "line", "fill"}, default "auto"
|
|
151
|
+
Render mode passed to each panel (see :meth:`hilbertplot.curve.Curve.plot`).
|
|
152
|
+
cmap : str, default "viridis"
|
|
153
|
+
Colormap for the gradient/fill rendering.
|
|
154
|
+
color : str, optional
|
|
155
|
+
Solid line colour (overrides the gradient) for every panel.
|
|
156
|
+
title : str, optional
|
|
157
|
+
Overall figure title.
|
|
158
|
+
dpi : int, default 140
|
|
159
|
+
Resolution used when ``save`` is given.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
matplotlib.figure.Figure
|
|
164
|
+
|
|
165
|
+
Examples
|
|
166
|
+
--------
|
|
167
|
+
>>> import hilbertplot
|
|
168
|
+
>>> hilbertplot.gallery(hilbertplot.improper(), order=3, save="improper.png") # doctest: +SKIP
|
|
169
|
+
"""
|
|
170
|
+
plt = pyplot()
|
|
171
|
+
curves = list(curves)
|
|
172
|
+
if not curves:
|
|
173
|
+
raise ValueError("no curves to plot")
|
|
174
|
+
if ncols is None:
|
|
175
|
+
ncols = min(6, len(curves))
|
|
176
|
+
nrows = -(-len(curves) // ncols)
|
|
177
|
+
|
|
178
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(2.2 * ncols, 2.2 * nrows), squeeze=False)
|
|
179
|
+
flat = axes.ravel()
|
|
180
|
+
for ax, c in zip(flat, curves, strict=False):
|
|
181
|
+
draw(c, order, ax=ax, mode=mode, cmap=cmap, color=color, endpoints=False)
|
|
182
|
+
for ax in flat[len(curves) :]:
|
|
183
|
+
ax.set_visible(False)
|
|
184
|
+
if title:
|
|
185
|
+
fig.suptitle(title, fontsize=12)
|
|
186
|
+
fig.tight_layout()
|
|
187
|
+
|
|
188
|
+
if save is not None:
|
|
189
|
+
fig.savefig(save, dpi=dpi, bbox_inches="tight")
|
|
190
|
+
plt.close(fig)
|
|
191
|
+
else:
|
|
192
|
+
plt.show()
|
|
193
|
+
return fig
|
hilbertplot/py.typed
ADDED
|
File without changes
|