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.
@@ -0,0 +1,142 @@
1
+ """From curve words to grid coordinates, plus validation and canonicalisation.
2
+
3
+ The functions here turn a word of ``u/d/l/r`` strokes into the ordered array of
4
+ integer lattice points the curve visits, and provide the tools we use to *prove*
5
+ the constructions are correct:
6
+
7
+ * :func:`is_space_filling` checks that an order ``k`` curve visits every cell of the
8
+ ``2**k`` by ``2**k`` grid exactly once (a Hamiltonian path on the grid);
9
+ * :func:`canonical_form` reduces a point sequence modulo the sixteen square
10
+ symmetries and reversion, so that two curves can be compared "up to rotation,
11
+ reflection or reversion" -- the equivalence under which the paper's forty curves
12
+ are distinct.
13
+
14
+ Coordinates are produced with a vectorised cumulative sum, so building the
15
+ ``4**k`` points is linear in the output size with essentially no Python-level loop.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+
22
+ from .symmetry import (
23
+ ANTIDIAGONAL,
24
+ DIAGONAL,
25
+ FLIP_HORIZONTAL,
26
+ FLIP_VERTICAL,
27
+ IDENTITY,
28
+ ROT90,
29
+ ROT180,
30
+ ROT270,
31
+ Symmetry,
32
+ )
33
+
34
+ __all__ = [
35
+ "codes_to_points",
36
+ "word_to_codes",
37
+ "word_to_steps",
38
+ "word_to_points",
39
+ "is_space_filling",
40
+ "canonical_form",
41
+ "D4_ELEMENTS",
42
+ ]
43
+
44
+ #: The eight elements of D4, used for canonicalisation and (later) generalisation.
45
+ D4_ELEMENTS: tuple[Symmetry, ...] = (
46
+ IDENTITY,
47
+ ROT90,
48
+ ROT180,
49
+ ROT270,
50
+ FLIP_VERTICAL,
51
+ FLIP_HORIZONTAL,
52
+ DIAGONAL,
53
+ ANTIDIAGONAL,
54
+ )
55
+
56
+ # Stroke codes u=0, d=1, l=2, r=3, indexed into unit (dx, dy) steps.
57
+ _LETTER_TO_CODE = bytes.maketrans(b"udlr", bytes((0, 1, 2, 3)))
58
+ _STEP_LOOKUP = np.array([(0, 1), (0, -1), (-1, 0), (1, 0)], dtype=np.int64)
59
+
60
+
61
+ def word_to_codes(word: str) -> np.ndarray:
62
+ """Return the ``uint8`` stroke-code array for a ``u/d/l/r`` word."""
63
+ if not word:
64
+ return np.empty(0, dtype=np.uint8)
65
+ return np.frombuffer(word.encode("ascii").translate(_LETTER_TO_CODE), dtype=np.uint8)
66
+
67
+
68
+ def codes_to_points(codes: np.ndarray, *, normalize: bool = True) -> np.ndarray:
69
+ """Return the ordered ``(len(codes) + 1, 2)`` integer points for a stroke-code array.
70
+
71
+ The walk starts at the origin and accumulates unit steps with a single vectorised
72
+ ``cumsum``. With ``normalize=True`` (the default) the points are shifted so the
73
+ minimum corner is ``(0, 0)``; for a valid order ``k`` curve they then tile
74
+ ``{0, ..., 2**k - 1}`` on each axis.
75
+ """
76
+ points = np.empty((len(codes) + 1, 2), dtype=np.int64)
77
+ points[0] = 0
78
+ if len(codes):
79
+ np.cumsum(_STEP_LOOKUP[codes], axis=0, out=points[1:])
80
+ if normalize:
81
+ points -= points.min(axis=0)
82
+ return points
83
+
84
+
85
+ def word_to_steps(word: str) -> np.ndarray:
86
+ """Return the ``(len(word), 2)`` array of unit ``(dx, dy)`` steps for ``word``."""
87
+ return _STEP_LOOKUP[word_to_codes(word)]
88
+
89
+
90
+ def word_to_points(word: str, *, normalize: bool = True) -> np.ndarray:
91
+ """Return the ordered ``(len(word) + 1, 2)`` integer points a curve word visits."""
92
+ return codes_to_points(word_to_codes(word), normalize=normalize)
93
+
94
+
95
+ def is_space_filling(points: np.ndarray, order: int) -> bool:
96
+ """Check that ``points`` is a Hamiltonian path over the ``2**order`` grid.
97
+
98
+ Verifies three things at once: the right number of points, all of them distinct,
99
+ and a bounding box of exactly ``2**order`` by ``2**order``. Together these imply
100
+ the curve visits every cell of the square exactly once.
101
+ """
102
+ side = 2**order
103
+ if points.shape != (side * side, 2):
104
+ return False
105
+ lo = points.min(axis=0)
106
+ hi = points.max(axis=0)
107
+ if not (np.array_equal(lo, (0, 0)) and np.array_equal(hi, (side - 1, side - 1))):
108
+ return False
109
+ # Distinctness: pack each (x, y) into a single integer key and count uniques.
110
+ keys = points[:, 0] * side + points[:, 1]
111
+ return np.unique(keys).size == side * side
112
+
113
+
114
+ def _transform_points(points: np.ndarray, sym: Symmetry, *, reverse: bool) -> np.ndarray:
115
+ (a, b), (c, d) = sym.matrix
116
+ x = points[:, 0]
117
+ y = points[:, 1]
118
+ out = np.empty_like(points)
119
+ out[:, 0] = a * x + b * y
120
+ out[:, 1] = c * x + d * y
121
+ if reverse:
122
+ out = out[::-1]
123
+ out -= out.min(axis=0)
124
+ return np.ascontiguousarray(out)
125
+
126
+
127
+ def canonical_form(points: np.ndarray) -> bytes:
128
+ """Return a canonical byte key for ``points`` modulo symmetry and reversion.
129
+
130
+ Two point sequences share a canonical form iff one can be mapped onto the other
131
+ by a square symmetry (rotation/reflection) and/or reversion (traversal reversal).
132
+ This is exactly the equivalence under which the paper's forty curves are counted
133
+ as distinct, so equal canonical forms mean "the same curve".
134
+ """
135
+ best: bytes | None = None
136
+ for sym in D4_ELEMENTS:
137
+ for reverse in (False, True):
138
+ key = _transform_points(points, sym, reverse=reverse).tobytes()
139
+ if best is None or key < best:
140
+ best = key
141
+ assert best is not None
142
+ return best
@@ -0,0 +1,186 @@
1
+ """Symmetries of the square (the dihedral group :math:`D_4`) acting on directions.
2
+
3
+ Every construction rule in Estevez-Rams *et al.* (2017) is expressed with a small
4
+ set of *morphisms* :math:`\\delta_\\bullet` that rewrite the direction letters of a
5
+ curve word. Each such morphism is nothing more than an element of the symmetry
6
+ group of the square applied to unit direction vectors. Making that explicit here
7
+ (rather than hard-coding opaque letter tables) keeps the code principled and gives
8
+ us the group machinery we will need later when generalising the curves to squares
9
+ of arbitrary side.
10
+
11
+ Directions
12
+ ----------
13
+ A curve is encoded as a word over the alphabet ``{'u', 'd', 'l', 'r'}`` where each
14
+ letter is a unit pencil stroke:
15
+
16
+ ====== ==========
17
+ letter vector
18
+ ====== ==========
19
+ ``u`` ``(0, +1)``
20
+ ``d`` ``(0, -1)``
21
+ ``l`` ``(-1, 0)``
22
+ ``r`` ``(+1, 0)``
23
+ ====== ==========
24
+
25
+ The eight elements of :math:`D_4` are exposed both as named constants and through
26
+ :data:`MORPHISMS`, keyed by the single-letter codes used throughout the paper
27
+ (``e o a g x f m y``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Iterable, Mapping
33
+ from dataclasses import dataclass
34
+ from functools import cached_property
35
+
36
+ __all__ = [
37
+ "Direction",
38
+ "DIRECTIONS",
39
+ "DIRECTION_VECTORS",
40
+ "Symmetry",
41
+ "MORPHISMS",
42
+ "IDENTITY",
43
+ "ROT90",
44
+ "ROT180",
45
+ "ROT270",
46
+ "FLIP_VERTICAL",
47
+ "FLIP_HORIZONTAL",
48
+ "DIAGONAL",
49
+ "ANTIDIAGONAL",
50
+ "reverse_word",
51
+ "apply_morphism",
52
+ ]
53
+
54
+ #: The four direction letters, in a fixed canonical order.
55
+ DIRECTIONS: tuple[str, ...] = ("u", "d", "l", "r")
56
+
57
+ #: Mapping from each direction letter to its integer unit vector ``(dx, dy)``.
58
+ DIRECTION_VECTORS: Mapping[str, tuple[int, int]] = {
59
+ "u": (0, 1),
60
+ "d": (0, -1),
61
+ "l": (-1, 0),
62
+ "r": (1, 0),
63
+ }
64
+
65
+ # Reverse lookup: unit vector -> letter.
66
+ _VECTOR_TO_LETTER: Mapping[tuple[int, int], str] = {v: k for k, v in DIRECTION_VECTORS.items()}
67
+
68
+ # A lightweight alias; a "Direction" is just one of the letters above.
69
+ Direction = str
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Symmetry:
74
+ """An element of the dihedral group :math:`D_4` (symmetries of the square).
75
+
76
+ The element is stored as a 2x2 integer matrix ``((a, b), (c, d))`` acting on a
77
+ column vector ``(x, y)`` as ``(a*x + b*y, c*x + d*y)``. Only the eight
78
+ orthogonal integer matrices with determinant :math:`\\pm 1` are valid, but the
79
+ class does not police this -- the named constants below are the canonical set.
80
+
81
+ Instances are immutable and hashable, so they compose cleanly and can be used
82
+ as dictionary keys or catalog entries.
83
+ """
84
+
85
+ matrix: tuple[tuple[int, int], tuple[int, int]]
86
+ name: str = ""
87
+
88
+ # -- action on geometry -------------------------------------------------
89
+ def apply_vector(self, vector: tuple[int, int]) -> tuple[int, int]:
90
+ """Return ``self`` applied to an integer ``(x, y)`` vector."""
91
+ (a, b), (c, d) = self.matrix
92
+ x, y = vector
93
+ return (a * x + b * y, c * x + d * y)
94
+
95
+ def apply_letter(self, letter: Direction) -> Direction:
96
+ """Return the direction letter obtained by transforming ``letter``."""
97
+ return self._letter_map[letter]
98
+
99
+ @cached_property
100
+ def _letter_map(self) -> Mapping[str, str]:
101
+ return {
102
+ letter: _VECTOR_TO_LETTER[self.apply_vector(DIRECTION_VECTORS[letter])]
103
+ for letter in DIRECTIONS
104
+ }
105
+
106
+ def apply_word(self, word: str) -> str:
107
+ """Rewrite every letter of ``word`` under this symmetry (a morphism)."""
108
+ table = self._letter_map
109
+ return "".join(table[letter] for letter in word)
110
+
111
+ # -- group structure ----------------------------------------------------
112
+ def compose(self, other: Symmetry) -> Symmetry:
113
+ """Return the symmetry ``self`` followed by ``other`` (``other after self``).
114
+
115
+ ``s.compose(t).apply_vector(v) == t.apply_vector(s.apply_vector(v))``.
116
+ """
117
+ (a, b), (c, d) = other.matrix
118
+ (e, f), (g, h) = self.matrix
119
+ return Symmetry(
120
+ (
121
+ (a * e + b * g, a * f + b * h),
122
+ (c * e + d * g, c * f + d * h),
123
+ )
124
+ )
125
+
126
+ def __matmul__(self, other: Symmetry) -> Symmetry:
127
+ return self.compose(other)
128
+
129
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
130
+ return f"Symmetry({self.name or self.matrix})"
131
+
132
+
133
+ # The eight elements of D4. Matrices act on (x, y) column vectors.
134
+ IDENTITY = Symmetry(((1, 0), (0, 1)), "e") # identity
135
+ ROT90 = Symmetry(((0, -1), (1, 0)), "g") # +90 deg (counter-clockwise)
136
+ ROT180 = Symmetry(((-1, 0), (0, -1)), "f") # 180 deg
137
+ ROT270 = Symmetry(((0, 1), (-1, 0)), "x") # -90 deg (clockwise)
138
+ FLIP_VERTICAL = Symmetry(((1, 0), (0, -1)), "m") # reflect across x-axis (y -> -y)
139
+ FLIP_HORIZONTAL = Symmetry(((-1, 0), (0, 1)), "y") # reflect across y-axis (x -> -x)
140
+ DIAGONAL = Symmetry(((0, 1), (1, 0)), "o") # reflect across line y = x
141
+ ANTIDIAGONAL = Symmetry(((0, -1), (-1, 0)), "a") # reflect across line y = -x
142
+
143
+ #: The paper's morphisms, keyed by their single-letter codes.
144
+ #:
145
+ #: ``e`` identity, ``g`` = :math:`\\delta_g`, ``x`` = :math:`\\delta_x`,
146
+ #: ``f`` = :math:`\\delta_f`, ``m`` = :math:`\\delta_m`, ``y`` = :math:`\\delta_y`,
147
+ #: ``o`` = :math:`\\delta_o`, ``a`` = :math:`\\delta_a` (written :math:`\\delta_d`
148
+ #: in section V of the paper).
149
+ MORPHISMS: Mapping[str, Symmetry] = {
150
+ "e": IDENTITY,
151
+ "g": ROT90,
152
+ "x": ROT270,
153
+ "f": ROT180,
154
+ "m": FLIP_VERTICAL,
155
+ "y": FLIP_HORIZONTAL,
156
+ "o": DIAGONAL,
157
+ "a": ANTIDIAGONAL,
158
+ }
159
+
160
+
161
+ def apply_morphism(code: str, word: str) -> str:
162
+ """Apply the morphism named ``code`` (one of :data:`MORPHISMS`) to ``word``."""
163
+ return MORPHISMS[code].apply_word(word)
164
+
165
+
166
+ def reverse_word(word: str) -> str:
167
+ """Return the *reversion* :math:`\\bar p` of a curve word.
168
+
169
+ Reversion swaps the entry and exit point of a curve: it reverses the order of
170
+ the strokes and negates each one (``u`` <-> ``d`` and ``l`` <-> ``r``). This is
171
+ exactly walking the curve backwards, i.e. reversing the word and applying the
172
+ 180-degree rotation :data:`ROT180` to every letter.
173
+
174
+ >>> reverse_word("ruluurdrurddldr")
175
+ 'luruuldlulddrdl'
176
+ """
177
+ flip = ROT180._letter_map
178
+ return "".join(flip[letter] for letter in reversed(word))
179
+
180
+
181
+ def compose_all(symmetries: Iterable[Symmetry]) -> Symmetry:
182
+ """Compose a sequence of symmetries left to right, returning their product."""
183
+ result = IDENTITY
184
+ for sym in symmetries:
185
+ result = result.compose(sym)
186
+ return result
@@ -0,0 +1 @@
1
+ """Internal subpackage (not part of the public API)."""
@@ -0,0 +1,255 @@
1
+ """Mapping 1-D data onto 2-D through a Hilbert curve, and the granularity transform.
2
+
3
+ This implements the *Hilbert plot* of Estevez-Rams et al., "Visualizing long vectors
4
+ of measurements by use of the Hilbert curve", Comput. Phys. Commun. 197 (2015) 118:
5
+ the chosen curve is walked and value ``data[i]`` is placed at the ``i``-th cell it
6
+ visits, so every one of the forty curves induces its own data-to-image mapping. The
7
+ *l-granularity* transform (eqs. 1-3) coarsens the data by replacing each block of ``l``
8
+ values with ``l`` copies of the block mean.
9
+
10
+ Everything here is curve-agnostic (functions take a plain point array), which keeps it
11
+ reusable for the coming arbitrary-square generalisation. Fitting a sequence whose
12
+ length is not a power of four is an explicit choice, see :func:`resolve_order` /
13
+ :func:`scatter`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ __all__ = [
21
+ "order_for",
22
+ "order_floor",
23
+ "square_side",
24
+ "granulate",
25
+ "resolve_order",
26
+ "scatter",
27
+ "gather",
28
+ "label_map",
29
+ "difference_map",
30
+ "fourier_spectrum",
31
+ ]
32
+
33
+
34
+ def order_for(n: int) -> int:
35
+ """Smallest order ``k`` whose ``2**k`` by ``2**k`` grid holds ``n`` values.
36
+
37
+ ``ceil(log4(n))`` -- the order the paper uses for a length-``n`` sequence.
38
+
39
+ >>> order_for(1024), order_for(1000), order_for(1025)
40
+ (5, 5, 6)
41
+ """
42
+ if n < 1:
43
+ raise ValueError(f"need at least one value, got n={n}")
44
+ k = 0
45
+ while 4**k < n:
46
+ k += 1
47
+ return k
48
+
49
+
50
+ def order_floor(n: int) -> int:
51
+ """Largest order ``k`` whose grid fits *inside* ``n`` values (``floor(log4(n))``).
52
+
53
+ >>> order_floor(1024), order_floor(1000), order_floor(15)
54
+ (5, 4, 1)
55
+ """
56
+ if n < 1:
57
+ raise ValueError(f"need at least one value, got n={n}")
58
+ k = 0
59
+ while 4 ** (k + 1) <= n:
60
+ k += 1
61
+ return k
62
+
63
+
64
+ def resolve_order(n: int, order: int | None, fit: str) -> int:
65
+ """Pick the grid order for ``n`` values given an explicit ``order`` or a ``fit`` mode.
66
+
67
+ ``fit="pad"`` uses the smallest enclosing grid (``order_for``); ``fit="truncate"``
68
+ uses the largest grid that fits inside the data (``order_floor``). An explicit
69
+ ``order`` overrides both.
70
+ """
71
+ if order is not None:
72
+ return order
73
+ if fit == "pad":
74
+ return order_for(n)
75
+ if fit == "truncate":
76
+ return order_floor(n)
77
+ raise ValueError(f"fit must be 'pad' or 'truncate', got {fit!r}")
78
+
79
+
80
+ def granulate(data, l: int) -> np.ndarray:
81
+ """Return the *l-granularity* representation of ``data`` (same length as the input).
82
+
83
+ The sequence is cut into non-overlapping blocks of ``l`` values and each block is
84
+ replaced by ``l`` copies of its mean. ``l = 1`` returns the data unchanged (the
85
+ "faithful" representation); a trailing short block is averaged over what it has.
86
+
87
+ >>> granulate([0, 2, 10, 0], 2).tolist()
88
+ [1.0, 1.0, 5.0, 5.0]
89
+ """
90
+ if l < 1:
91
+ raise ValueError(f"granularity l must be >= 1, got {l}")
92
+ data = np.asarray(data, dtype=float).ravel()
93
+ if l == 1:
94
+ return data.copy()
95
+ n = data.size
96
+ out = np.empty(n, dtype=float)
97
+ full = (n // l) * l
98
+ if full:
99
+ out[:full] = np.repeat(data[:full].reshape(-1, l).mean(axis=1), l)
100
+ if full < n: # trailing partial block
101
+ out[full:] = data[full:].mean()
102
+ return out
103
+
104
+
105
+ def square_side(n: int) -> int:
106
+ """Smallest square side holding ``n`` values: ``ceil(sqrt(n))``.
107
+
108
+ >>> square_side(10000), square_side(1000)
109
+ (100, 32)
110
+ """
111
+ if n < 1:
112
+ raise ValueError(f"need at least one value, got n={n}")
113
+ s = int(n**0.5)
114
+ while s * s < n:
115
+ s += 1
116
+ while (s - 1) * (s - 1) >= n:
117
+ s -= 1
118
+ return s
119
+
120
+
121
+ def scatter(points: np.ndarray, values: np.ndarray, side: int, fill: float = np.nan) -> np.ndarray:
122
+ """Place ``values`` onto a ``side x side`` grid along a curve's ``points`` path.
123
+
124
+ ``values[i]`` is written to ``points[i]``. If there are more values than cells
125
+ they are truncated to fit; if fewer, the remaining cells keep ``fill``. Returned
126
+ array is indexed ``image[y, x]`` (for ``origin="lower"`` display).
127
+ """
128
+ cap = side * side
129
+ values = np.asarray(values, dtype=float).ravel()
130
+ if values.size > cap:
131
+ values = values[:cap]
132
+ n = values.size
133
+ image = np.full((side, side), fill, dtype=float)
134
+ sel = points[:n]
135
+ image[sel[:, 1], sel[:, 0]] = values
136
+ return image
137
+
138
+
139
+ def gather(points: np.ndarray, array: np.ndarray) -> np.ndarray:
140
+ """Read a 2-D ``array`` along a curve's ``points`` path -> 1-D (inverse of :func:`scatter`).
141
+
142
+ Parameters
143
+ ----------
144
+ points : numpy.ndarray
145
+ The ordered ``(N, 2)`` ``(x, y)`` cells the curve visits.
146
+ array : numpy.ndarray
147
+ A 2-D array indexed ``array[y, x]`` covering those cells.
148
+
149
+ Returns
150
+ -------
151
+ numpy.ndarray
152
+ ``array`` values in visit order, i.e. ``array[points[i, 1], points[i, 0]]``.
153
+ """
154
+ return array[points[:, 1], points[:, 0]]
155
+
156
+
157
+ def fourier_spectrum(
158
+ image: np.ndarray, *, log: bool = True, window: bool = False, remove_dc: bool = True
159
+ ) -> np.ndarray:
160
+ """Return the centred 2-D power spectrum of a Hilbert-plot image.
161
+
162
+ The magnitude of the (fft-shifted) 2-D Fourier transform: hidden periodicities and
163
+ symmetries in the data show up as bright peaks and structured patterns, exactly the
164
+ "Fourier map" companion to the Hilbert plot of Estevez-Rams et al. (2015).
165
+
166
+ Parameters
167
+ ----------
168
+ image : numpy.ndarray
169
+ A 2-D array (a data image from :meth:`hilbertplot.HilbertPlot.image`). ``NaN`` cells
170
+ (padding) are replaced by the finite mean before transforming.
171
+ log : bool, default True
172
+ Return ``log(1 + |F|)`` (compresses the huge dynamic range so faint structure is
173
+ visible); otherwise the raw magnitude.
174
+ window : bool, default False
175
+ Multiply by a 2-D Hann window first, to suppress the cross-shaped artifact from
176
+ the image's hard edges.
177
+ remove_dc : bool, default True
178
+ Subtract the mean before transforming, so the enormous DC (mean-brightness) spike
179
+ at the centre does not wash out the pattern.
180
+
181
+ Returns
182
+ -------
183
+ numpy.ndarray
184
+ A real ``image.shape`` array, the centred spectrum (indexed ``[y, x]``).
185
+ """
186
+ img = np.asarray(image, dtype=float)
187
+ if np.isnan(img).any():
188
+ img = np.where(np.isnan(img), np.nanmean(img), img)
189
+ if remove_dc:
190
+ img = img - img.mean()
191
+ if window:
192
+ img = img * np.outer(np.hanning(img.shape[0]), np.hanning(img.shape[1]))
193
+ mag = np.abs(np.fft.fftshift(np.fft.fft2(img)))
194
+ return np.log1p(mag) if log else mag
195
+
196
+
197
+ def label_map(points: np.ndarray, side: int) -> np.ndarray:
198
+ """Return the ``(side, side)`` grid of visit-order indices (labels) for a curve.
199
+
200
+ ``label_map[y, x]`` is the step ``i`` at which the curve reaches cell ``(x, y)`` --
201
+ the inverse of ``points``.
202
+
203
+ Parameters
204
+ ----------
205
+ points : numpy.ndarray
206
+ The ordered ``(side*side, 2)`` cells the curve visits.
207
+ side : int
208
+ Grid side length (``2**order``).
209
+
210
+ Returns
211
+ -------
212
+ numpy.ndarray
213
+ An ``(side, side)`` integer array of labels, indexed ``[y, x]``.
214
+ """
215
+ labels = np.empty((side, side), dtype=np.int64)
216
+ labels[points[:, 1], points[:, 0]] = np.arange(points.shape[0])
217
+ return labels
218
+
219
+
220
+ def difference_map(points: np.ndarray, side: int) -> np.ndarray:
221
+ """Return the *difference map*: mean 8-neighbour label distance at every cell.
222
+
223
+ At each cell the value is the mean of ``|label - neighbour_label|`` over its up-to-8
224
+ nearest (Moore) neighbours. Large values mark sites where cells that are close on
225
+ the plane are far apart along the curve -- i.e. where locality is broken (Estevez-Rams
226
+ et al., "Locality preserving homogeneous Hilbert curves by use of arbitrary kernels",
227
+ *Appl. Math. Comput.* **234** (2014) 531).
228
+
229
+ Parameters
230
+ ----------
231
+ points : numpy.ndarray
232
+ The ordered ``(side*side, 2)`` cells the curve visits.
233
+ side : int
234
+ Grid side length (``2**order``).
235
+
236
+ Returns
237
+ -------
238
+ numpy.ndarray
239
+ An ``(side, side)`` float array of mean neighbour label differences, ``[y, x]``.
240
+ """
241
+ labels = label_map(points, side).astype(np.float64)
242
+ padded = np.full((side + 2, side + 2), np.nan)
243
+ padded[1:-1, 1:-1] = labels
244
+ total = np.zeros((side, side))
245
+ count = np.zeros((side, side))
246
+ for dy in (-1, 0, 1):
247
+ for dx in (-1, 0, 1):
248
+ if dx == 0 and dy == 0:
249
+ continue
250
+ neighbour = padded[1 + dy : 1 + dy + side, 1 + dx : 1 + dx + side]
251
+ valid = ~np.isnan(neighbour)
252
+ diff = np.abs(labels - neighbour)
253
+ total[valid] += diff[valid]
254
+ count[valid] += 1
255
+ return total / count
@@ -0,0 +1 @@
1
+ """Internal subpackage (not part of the public API)."""