gridpoints 1.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.
griddemo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+
2
+ from .quick_demo import sample, show_result, print_zoom
3
+
4
+ __all__ = (
5
+ sample,
6
+ show_result,
7
+ print_zoom,
8
+ )
9
+
10
+ __version__ = "1.0.0"
griddemo/quick_demo.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ Helpers:
3
+ sample various datasets,
4
+ plot grids as a mesh,
5
+ check neighbormap counts
6
+
7
+ Required:
8
+ pip install squarenet
9
+ """
10
+
11
+ def sample(method, size, backend="numpy"):
12
+ """Sample a dataset and convert it to a backend array.
13
+
14
+ Parameters
15
+ ----------
16
+ method : str
17
+ Dataset sampling method:
18
+ "ball", "square", "gaussian", "spiky", "holy"
19
+ "france", "germany" (2D only)
20
+ size : (N, D)
21
+ Number of points to sample and dimension.
22
+ backend : {"numpy", "cupy", "torch"}, default="numpy"
23
+ Array backend for the returned points.
24
+
25
+ Returns
26
+ -------
27
+ array-like (N, D)
28
+ Sampled point cloud.
29
+ """
30
+ try:
31
+ import squarenet
32
+ except:
33
+ print("Missing dependencies for the demo. pip install gridsort[demo]")
34
+ from squarenet import samplepoints
35
+ points = samplepoints(method = method, size = size, plot_points = False)
36
+ if backend == "cupy":
37
+ import cupy as cp
38
+ points = cp.asarray(points)
39
+ if backend == "torch":
40
+ import torch
41
+ points = torch.tensor(points)
42
+ if method == "everest": #just a bug fix
43
+ points = points[..., [0, 2, 1]]
44
+ return points + 1
45
+
46
+ def show_result(X, animate=False):
47
+ """Visualize a gridified point cloud as a mesh using ``squarenet``.
48
+ The point cloud must be sorted by the monotonic_lagrangian_sort
49
+ fonction first, and reshaped as a grid (X.reshape(*gridshape, D))
50
+
51
+ Parameters
52
+ ----------
53
+ X : array-like (*G, D)
54
+ Point cloud arranged on a grid.
55
+ animate : bool, default=False
56
+ Whether to request an animated visualization.
57
+ """
58
+ try:
59
+ import squarenet
60
+ except:
61
+ print("Missing dependencies for the demo. pip install gridsort[demo]")
62
+ from squarenet import SquareNet
63
+ import numpy as np
64
+ X = np.asarray(X)
65
+ sn = SquareNet(gridshape = X.shape[:-1])
66
+ sn.pointsmaped = X
67
+ sn.plot(style = "mesh", animate = animate, save = animate)
68
+ #neighbormap counts the number of points found at a given relative delta = [i'-i, j'-j, k] such that
69
+ #P[i', j', k] = argmin(i', j') dist(P[i,j,k], P[i',j',k])
70
+ sn.neighbormap(projection = (0, 1)) #can also be projection = (1, 2)/(0, 1)
71
+
72
+ def print_zoom(Ygrid, center, side=1):
73
+ """
74
+ Print a local cross-section of a 2D or 3D grid around ``center``.
75
+
76
+ Parameters
77
+ ----------
78
+ Ygrid : array-like
79
+ Grid of points, with shape ``(*grid_shape, D)`` where D is 2 or 3.
80
+ center : tuple of int
81
+ Index of the central grid point.
82
+ side : int, default=1
83
+ Number of cells shown on each side of the center.
84
+ """
85
+ import numpy as np
86
+ Ygrid = np.asarray(Ygrid)
87
+ ndim = Ygrid.ndim - 1
88
+ if ndim not in (2, 3):
89
+ raise ValueError("printzoom only for 2D/3D grids")
90
+ def _format_point(point):
91
+ return "(" + ", ".join(f"{float(x):.3f}" for x in point) + ")"
92
+ print(f"\n zooming arround the grid position {center} :\n")
93
+ if ndim == 2:
94
+ i, j = center
95
+ for point in Ygrid[i, j-side:j]:
96
+ print("." * (15*side-1), _format_point(point), "." * (15*side-1))
97
+ for point in Ygrid[i-side:i+side+1, j]:
98
+ print(_format_point(point), end=" ")
99
+ print("")
100
+ for point in Ygrid[i, j+1:j+side+1]:
101
+ print("." * (15*side-1), _format_point(point), "." * (15*side-1))
102
+ else:
103
+ i, j, k = center
104
+ print("axis 0:")
105
+ for ii in range(i-len, i+side+1):
106
+ print(_format_point(Ygrid[ii, j, k]), end=" ")
107
+ print()
108
+ print("axis 1:")
109
+ for jj in range(j-len, j+side+1):
110
+ print(_format_point(Ygrid[i, jj, k]), end=" ")
111
+ print()
112
+ print("axis 2:")
113
+ for kk in range(k-len, k+side+1):
114
+ print(_format_point(Ygrid[i, j, kk]), end=" ")
115
+ print()
gridpoints/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Monotonic Lagrangian sorting for NumPy, CuPy, and PyTorch arrays."""
2
+
3
+ from .mlg_sort import argsort, sort, invert_permutation
4
+
5
+ __all__ = (
6
+ "argsort",
7
+ "sort",
8
+ "invert_permutation",
9
+ )
10
+
11
+ __version__ = "1.0.0"
@@ -0,0 +1,165 @@
1
+ """
2
+ Helpers:
3
+ Unified array syntax to support numpy, cupy and pytorch arrays
4
+
5
+ Here we just define basic primitives that we will need later
6
+ like arange, copy, shuffle etc.
7
+ """
8
+
9
+ #This have to be promoted to float64 and int64 for very big point clouds (more than 1 billion points)
10
+ _FLOAT = 'float32'
11
+ _INT = 'int32'
12
+
13
+ EPS = 10**(-10)
14
+
15
+
16
+ def get_dtype(dtp, xp):
17
+ if dtp == 'float':
18
+ dtp = _FLOAT
19
+ elif dtp in ('int', 'integer'):
20
+ dtp = _INT
21
+ return getattr(xp, dtp) if isinstance(dtp, str) else dtp
22
+
23
+
24
+ def get_backend(X):
25
+ module = type(X).__module__.split('.')[0]
26
+ if module == 'torch':
27
+ import torch
28
+ return torch, 'torch'
29
+ elif module == 'cupy':
30
+ import cupy
31
+ return cupy, 'cupy'
32
+ else:
33
+ import numpy
34
+ return numpy, 'numpy'
35
+
36
+ def astype(X, dtype_str, reference_array=None):
37
+ xp, backend_name = get_backend(X)
38
+ dtype = get_dtype(dtype_str, xp)
39
+ if backend_name == 'torch':
40
+ device = reference_array.device if reference_array is not None else X.device
41
+ return X.to(dtype=dtype, device=device)
42
+ return X.astype(dtype)
43
+
44
+
45
+ def inplace_sort(X, axis):
46
+ _, backend_name = get_backend(X)
47
+ if backend_name == 'torch':
48
+ X.copy_(X.sort(dim=axis)[0])
49
+ else:
50
+ X.sort(axis=axis)
51
+
52
+
53
+ def empty(shape, dtype_str, reference_array):
54
+ xp, backend_name = get_backend(reference_array)
55
+ dtype = get_dtype(dtype_str, xp)
56
+ if backend_name == 'torch':
57
+ return xp.empty(shape, dtype=dtype, device=reference_array.device)
58
+ return xp.empty(shape, dtype=dtype)
59
+
60
+
61
+ def arange(N, dtype_str, reference_array):
62
+ xp, backend_name = get_backend(reference_array)
63
+ dtype = get_dtype(dtype_str, xp)
64
+ if backend_name == 'torch':
65
+ return xp.arange(N, dtype=dtype, device=reference_array.device)
66
+ return xp.arange(N, dtype=dtype)
67
+
68
+ def argmax(arr, axis):
69
+ xp, backend_name = get_backend(arr)
70
+ if backend_name == 'torch':
71
+ if arr.dtype == xp.bool:
72
+ arr = arr.to(xp.int32)
73
+ return arr.argmax(dim=axis)
74
+ return arr.argmax(axis=axis)
75
+
76
+ def random_noise(shape, reference_array):
77
+ xp, backend_name = get_backend(reference_array)
78
+ if backend_name == 'torch':
79
+ return EPS * xp.rand(*shape, device=reference_array.device)
80
+ elif backend_name == 'cupy':
81
+ return EPS * xp.random.rand(*shape)
82
+ else:
83
+ rng = xp.random.default_rng(42)
84
+ return EPS * rng.random(shape)
85
+
86
+ def copy(arr):
87
+ _, backend_name = get_backend(arr)
88
+ if backend_name == 'torch':
89
+ return arr.clone()
90
+ return arr.copy()
91
+
92
+ def nan_to_num(arr):
93
+ xp, backend = get_backend(arr)
94
+ out = copy(arr)
95
+
96
+ for d in range(arr.shape[1]):
97
+ x = arr[:, d]
98
+ finite = xp.isfinite(x)
99
+ vals = x[finite]
100
+
101
+ out[xp.isposinf(x), d] = 2 * vals.max()
102
+ out[xp.isneginf(x), d] = 2 * vals.min()
103
+
104
+ nan = xp.isnan(x)
105
+ n = nan.sum().item() if backend == 'torch' else int(nan.sum())
106
+ if n:
107
+ idx = (xp.randint(len(vals), (n,), device=arr.device)
108
+ if backend == 'torch' else xp.random.randint(len(vals), size=n))
109
+ out[nan, d] = vals[idx]
110
+
111
+ return out + random_noise(out.shape, out)
112
+
113
+ def all_true(tensor):
114
+ _, backend_name = get_backend(tensor)
115
+ if backend_name == 'torch':
116
+ return tensor.all().item()
117
+ return bool(tensor.all())
118
+
119
+ def random_permutation(arr):
120
+ xp, backend_name = get_backend(arr)
121
+ if backend_name == 'torch':
122
+ perm = xp.randperm(len(arr), device=arr.device)
123
+ return arr[perm]
124
+ return xp.random.permutation(arr)
125
+
126
+ def flip(X, axis=None):
127
+ xp, _ = get_backend(X)
128
+ if axis is None or axis == () or axis == []:
129
+ return X
130
+
131
+ if isinstance(axis, int):
132
+ axis = (axis,)
133
+ else:
134
+ axis = tuple(axis)
135
+
136
+ if hasattr(X, "flip") and callable(X.flip):
137
+ return X.flip(axis)
138
+
139
+ return xp.flip(X, axis=axis)
140
+
141
+ def argpartition(X, mid, axis):
142
+ xp, backend_name = get_backend(X)
143
+ if backend_name == 'torch':
144
+ return xp.argsort(X, dim=axis)
145
+ else:
146
+ return xp.argpartition(X, mid - 1, axis=-1)
147
+
148
+ def take_along_axis(idx, val, axis):
149
+ xp, backend_name = get_backend(val)
150
+ if backend_name == 'torch':
151
+ return xp.take_along_dim(idx, val, dim=axis)
152
+ else:
153
+ return xp.take_along_axis(idx, val, axis=axis)
154
+
155
+ def concatenate(arrays, axis = 0):
156
+ xp, name = get_backend(arrays[0])
157
+ if name == "torch":
158
+ return xp.cat(list(arrays), dim=axis)
159
+ return xp.concatenate(arrays, axis=axis)
160
+
161
+ def transpose(X, axes):
162
+ _, name = get_backend(X)
163
+ if name == "torch":
164
+ return X.permute(*axes)
165
+ return X.transpose(axes)
@@ -0,0 +1,96 @@
1
+ """
2
+ Helpers:
3
+ Diagonal trick is based on a tensor rotation that allow
4
+ to sort arrays efficiently along arbitrary diagonal fibers.
5
+ """
6
+
7
+
8
+ from .array_libs import (
9
+ get_backend,
10
+ arange,
11
+ inplace_sort,
12
+ all_true
13
+ )
14
+
15
+ def sort_diag_trick(X):
16
+ """
17
+ we rotate the tensor along the main diagonal
18
+ to place diagonals on the last axis, then
19
+ perform a standard sort(axis = -1), and
20
+ rotate back the diagonals
21
+ """
22
+ xp, _ = get_backend(X)
23
+ bigM = (X.max() - X.min()) + 1#bigM for safety, protect
24
+ #overlapping diagonals
25
+
26
+ n, m = X.shape[-2:]
27
+
28
+ if m > n: #Optional, maximal parallelisation
29
+ X = xp.moveaxis(X, [-1, -2], [-2, -1])
30
+ n, m = m, n
31
+
32
+ i = arange(n, 'int', X)[:, None]
33
+ j = arange(m, 'int', X)[None, :]
34
+
35
+ di = (i - j)
36
+ i_ = di % n
37
+ ires = di // n
38
+ groupkey = bigM * ires
39
+
40
+ X[..., i_, j] = X[..., i, j] - groupkey #Rotation
41
+ # ========================
42
+ # here is the trick: as we rotated the tensor
43
+ # sorting on the diagonal is just working on
44
+ # axis -1 of the rotated view
45
+ # ========================
46
+ was_sorted = all_true(xp.diff(X, axis=-1) >= 0)
47
+ inplace_sort(X, axis=-1)
48
+ X[..., i, j] = X[..., i_, j] + groupkey
49
+
50
+ return was_sorted
51
+
52
+ def sort_tridiag_trick(X):
53
+ """
54
+ we rotate the tensor along the tridiagonal,
55
+ then perform a standard sort(axis = -1),
56
+ and rotate back the tridiagonals
57
+ """
58
+ xp, _ = get_backend(X)
59
+ bigM = (X.max() - X.min()) + 1#bigM for safety, protect
60
+ #overlapping diagonals
61
+
62
+ a, b, c = X.shape[-3:]
63
+
64
+ #Optional, maximal parallelisation:
65
+ #move the smallest axis to last position
66
+ #before the tridiagonal rotation
67
+ if (a < b) and (a < c):
68
+ X = xp.moveaxis(X, [-3, -1], [-1, -3])
69
+ a, c = c, a
70
+ elif b < c:
71
+ X = xp.moveaxis(X, [-2, -1], [-1, -2])
72
+ b, c = c, b
73
+
74
+ i = arange(a, 'int', X)[:, None, None]
75
+ j = arange(b, 'int', X)[None, :, None]
76
+ k = arange(c, 'int', X)[None, None, :]
77
+
78
+ di = i - k
79
+ dj = j - k
80
+ di_ = di % a
81
+ dj_ = dj % b
82
+ dires = di // a
83
+ djres = dj // b
84
+ groupkey = bigM * (dires * (2 * (b + c)) + djres)
85
+
86
+ X[..., di_, dj_, k] = X[..., i, j, k] - groupkey #Trirotation
87
+ # ========================
88
+ # Again, thanks to the rotation trick
89
+ # we just need to .sort(X, axis = -1)
90
+ # to sort the tridiagonal
91
+ # ========================
92
+ was_sorted = all_true(xp.diff(X, axis=-1) >= 0)
93
+ inplace_sort(X, axis=-1)
94
+ X[..., i, j, k] = X[..., di_, dj_, k] + groupkey
95
+
96
+ return was_sorted
gridpoints/kdtree.py ADDED
@@ -0,0 +1,203 @@
1
+ """
2
+ Helpers:
3
+ anything related to kdtree
4
+ """
5
+
6
+ import math
7
+ from collections import defaultdict
8
+ from .array_libs import (
9
+ get_backend,
10
+ transpose,
11
+ arange,
12
+ empty,
13
+ argpartition,
14
+ take_along_axis,
15
+ concatenate,
16
+ )
17
+
18
+
19
+ def kdtree_order_dyadic(X):
20
+ """
21
+ Sorting version for the (easy) dyadic case, were X has a power of 2**D shape.
22
+ Mainly for pedagogical reason. It will help to understand what the general
23
+ version do, when the number of points is not necesarily a power of two and
24
+ we have to deal with potential median hyperplan.
25
+
26
+ Parameters
27
+ ----------
28
+ X : array-like, shape (N, D). Point cloud
29
+
30
+ Returns
31
+ -------
32
+ order : array, shape (N,), based on the kdtree cuts, such that
33
+ Xgrid = X[order].reshape(*gridshape, D) is monotonic along each axis:
34
+ Xgrid[i+1, j, k][0] >= Xgrid[i, j, k][0]
35
+ Xgrid[i, j+1, k][1] >= Xgrid[i, j, k][1]
36
+
37
+ """
38
+ N, D = X.shape
39
+ M = int(N**(1/D) + 0.1)
40
+ K = int(math.log2(M))
41
+
42
+ idx = arange(N, 'int', reference_array=X)
43
+
44
+ for step in range(K * D):
45
+ axis = step % D
46
+ size = idx.shape[-1]
47
+ mid = size // 2
48
+ X_curr = X[idx, axis]
49
+ p = argpartition(X_curr, mid, axis = -1)
50
+ idx = take_along_axis(idx, p, axis = -1)
51
+ idx = idx.reshape(*idx.shape[:-1], 2, mid)
52
+
53
+ idx = idx.squeeze(-1)
54
+ transpose_axes = [d + k * D for d in range(D) for k in range(K)]
55
+ idx = transpose(idx, transpose_axes)
56
+
57
+ return idx.flatten()
58
+
59
+
60
+
61
+ def kdtree_order(X, gridshape):
62
+ """
63
+ Parameters
64
+ ----------
65
+ X : array-like, shape (N, D). Point cloud
66
+ gridshape : sequence of int, length D, product == N
67
+
68
+ Returns
69
+ -------
70
+ order : array, shape (N,), based on the kdtree cuts, such that
71
+ Xgrid = X[order].reshape(*gridshape, D) is monotonic along each axis:
72
+ Xgrid[i+1, j, k][0] >= Xgrid[i, j, k][0]
73
+ Xgrid[i, j+1, k][1] >= Xgrid[i, j, k][1]
74
+
75
+ Note:
76
+ -----
77
+ It is the natural generalisation of the kdtree_order_dyadic version.
78
+ What make the general case more complex is simply to deal with
79
+ the median hyperplan when cuting a non even shape of the grid hyperrectangle.
80
+ """
81
+ xp, _ = get_backend(X)
82
+ N, D = X.shape
83
+ shape = tuple(int(s) for s in gridshape)
84
+ assert math.prod(shape) == N
85
+ assert len(shape) == D
86
+
87
+ def next_axis(sh, start):
88
+ for off in range(D):
89
+ a = (start + off) % D
90
+ if sh[a] > 1:
91
+ return a
92
+ return None
93
+
94
+ def multi_index_on_axis(pos, axis):
95
+ """Multi-index component on `axis` for C-order linear indices `pos`."""
96
+ stride = int(math.prod(shape[axis + 1 :])) if axis + 1 < D else 1
97
+ return (pos // stride) % shape[axis]
98
+
99
+ output = empty(N, 'int', reference_array=X)
100
+
101
+ if N == 1:
102
+ output[0] = 0
103
+ return output
104
+
105
+ # idx = point indices, pos = fixed destination slots (C-order of the grid)
106
+ idx_init = arange(N, 'int', reference_array=X)[None, :]
107
+ pos_init = arange(N, 'int', reference_array=X)[None, :]
108
+
109
+ ax0 = next_axis(shape, 0)
110
+ worklist = defaultdict(lambda: ([], []))
111
+ worklist[(shape, ax0)][0].append(idx_init)
112
+ worklist[(shape, ax0)][1].append(pos_init)
113
+
114
+ while worklist:
115
+ key = next(iter(worklist))
116
+ idx_list, pos_list = worklist.pop(key)
117
+ cur_shape, cur_axis = key
118
+
119
+ idx_b = concatenate(idx_list) if len(idx_list) > 1 else idx_list[0]
120
+ pos_b = concatenate(pos_list) if len(pos_list) > 1 else pos_list[0]
121
+
122
+ n = idx_b.shape[-1]
123
+ if n == 1:
124
+ output[pos_b[:, 0]] = idx_b[:, 0]
125
+ continue
126
+
127
+ axis = cur_axis
128
+ size = cur_shape[axis]
129
+ slab = n // size
130
+ lsize = size // 2
131
+ ln = lsize * slab
132
+ mid_n = slab if (size % 2) else 0
133
+
134
+ def push(idx_part, pos_part, child_shape, child_n_axis):
135
+ m = idx_part.shape[-1]
136
+ if m == 1 or child_n_axis is None:
137
+ output[pos_part[:, 0]] = idx_part[:, 0]
138
+ else:
139
+ ckey = (child_shape, child_n_axis)
140
+ worklist[ckey][0].append(idx_part)
141
+ worklist[ckey][1].append(pos_part)
142
+
143
+ # --- partition the POINTS only ---
144
+ if size % 2 == 0:
145
+ vals = X[idx_b, axis]
146
+ p = argpartition(vals, ln, axis=-1)
147
+ idx_b = take_along_axis(idx_b, p, axis=-1)
148
+ idx_l, idx_r = idx_b[:, :ln], idx_b[:, ln:]
149
+
150
+ # destinations = current pos sorted by their multi-index on `axis`
151
+ mi = multi_index_on_axis(pos_b, axis)
152
+ order_mi = xp.argsort(mi, axis=-1)
153
+ pos_sorted = take_along_axis(pos_b, order_mi, axis=-1)
154
+ pos_l, pos_r = pos_sorted[:, :ln], pos_sorted[:, ln:]
155
+
156
+ child_shape = list(cur_shape)
157
+ child_shape[axis] = lsize
158
+ child_shape = tuple(child_shape)
159
+ nax = next_axis(child_shape, (axis + 1) % D)
160
+
161
+ push(concatenate([idx_l, idx_r]),
162
+ concatenate([pos_l, pos_r]),
163
+ child_shape, nax)
164
+
165
+ else:
166
+ # odd: left + median slab + right
167
+ total_keep = ln + mid_n
168
+ vals = X[idx_b, axis]
169
+ p = argpartition(vals, total_keep, axis=-1)
170
+ idx_b = take_along_axis(idx_b, p, axis=-1)
171
+ idx_r = idx_b[:, total_keep:]
172
+ idx_lm = idx_b[:, :total_keep]
173
+
174
+ if ln > 0:
175
+ vals_lm = X[idx_lm, axis]
176
+ p2 = argpartition(vals_lm, ln, axis=-1)
177
+ idx_lm = take_along_axis(idx_lm, p2, axis=-1)
178
+ idx_l = idx_lm[:, :ln]
179
+ idx_m = idx_lm[:, ln:]
180
+ else:
181
+ idx_l = None
182
+ idx_m = idx_lm
183
+
184
+ mi = multi_index_on_axis(pos_b, axis)
185
+ order_mi = xp.argsort(mi, axis=-1)
186
+ pos_sorted = take_along_axis(pos_b, order_mi, axis=-1)
187
+ pos_l = pos_sorted[:, :ln] if ln > 0 else None
188
+ pos_m = pos_sorted[:, ln : ln + mid_n]
189
+ pos_r = pos_sorted[:, ln + mid_n :]
190
+
191
+ child_l = list(cur_shape); child_l[axis] = lsize; child_l = tuple(child_l)
192
+ child_m = list(cur_shape); child_m[axis] = 1; child_m = tuple(child_m)
193
+
194
+ nax_lr = next_axis(child_l, (axis + 1) % D)
195
+ nax_m = next_axis(child_m, (axis + 1) % D)
196
+
197
+ if idx_l is not None:
198
+ push(concatenate([idx_l, idx_r]),
199
+ concatenate([pos_l, pos_r]),
200
+ child_l, nax_lr)
201
+ push(idx_m, pos_m, child_m, nax_m)
202
+
203
+ return output
gridpoints/mlg_sort.py ADDED
@@ -0,0 +1,267 @@
1
+ """
2
+ Main fonctions of the library are defined here:
3
+ argsort and sort
4
+ """
5
+
6
+
7
+ import math
8
+ import warnings
9
+ from .array_libs import (
10
+ arange, get_backend, all_true,
11
+ inplace_sort, empty, astype, nan_to_num,
12
+ random_permutation, argmax, copy, flip
13
+ )
14
+ from .diagonal_tricks import sort_diag_trick, sort_tridiag_trick
15
+ from .kdtree import kdtree_order
16
+
17
+ # ============================================
18
+ # Sorting API
19
+ # ============================================
20
+
21
+ def argsort(X, gridshape, level=2, init="kdtree", n_iter=50, verbose = 2):
22
+ """Compute a multi-directional monotonic sorted ordering of a point cloud.
23
+
24
+ Parameters
25
+ ----------
26
+ X : ndarray of shape (N, D)
27
+ Input point cloud.
28
+ gridshape : tuple of int
29
+ Target shape of the sorting grid. The product of the dimensions must
30
+ equal N and its length must equal D. E.g., for X of shape (42, 2),
31
+ `shape` can be (6, 7); for (60, 3), `shape` can be (3, 4, 5).
32
+
33
+ Note: Point clouds can be padded with +/- infinity placeholders to reach
34
+ a valid grid size. NaNs can also be used, but will be placed randomly
35
+ rather than pushed to grid boundaries.
36
+ level : {1, 2, 3}, default=2
37
+ Maximum order of lattice directions considered. Higher values improve
38
+ grid quality at the expense of computational speed. Note that `level=3`
39
+ is only supported if D >= 3.
40
+ init : {'kdtree', 'shuffle'} or None, default="kdtree"
41
+ Initialization method for the permutation order. Try init = None
42
+ if the point cloud is already nearly sorted.
43
+ n_iter : int, default=50
44
+ Maximum number of optimization iterations.
45
+ verbose : int, default 2
46
+ 0 = silent, 1 = log progress, 2 = 1 + warn if convergence failed
47
+
48
+ Returns
49
+ -------
50
+ order : ndarray of shape (N,)
51
+ Permutation indices such that `X[order].reshape(*shape, D)` forms a
52
+ monotonically sorted grid.
53
+ converged : bool
54
+ `True` if the algorithm converged within `n_iter` iterations, `False` otherwise.
55
+ If not converged, the fonction will also raise a convergence warning
56
+ """
57
+ N, D = X.shape
58
+ assert math.prod(gridshape) == N
59
+ assert len(gridshape) == D
60
+
61
+ level = min(level, X.shape[-1])
62
+ if init == "kdtree":
63
+ if verbose >=1:
64
+ print("performing kdtree initialisation")
65
+ order = kdtree_order(X, gridshape)
66
+ if verbose >=1:
67
+ print("done")
68
+ converged = True
69
+ if level == 1:
70
+ return order, converged
71
+ else:
72
+ order = arange(len(X), 'int', reference_array=X)
73
+ if init == "shuffle":
74
+ order = random_permutation(order)
75
+
76
+ ordernew, converged = _mlg_step(X[order], gridshape, level=level, n_iter=n_iter, verbose = verbose)
77
+ order = order[ordernew]
78
+
79
+ if not converged:
80
+ while level >= 2:
81
+ level = level - 1
82
+ ordernew, converged = _mlg_step(
83
+ X[order], gridshape, level=level, n_iter=n_iter, verbose = verbose
84
+ )
85
+ order = order[ordernew]
86
+ if not converged and verbose >=2:
87
+ warnings.warn(
88
+ f"Algorithm failed to converge after {n_iter} iterations. " \
89
+ "Consider checking if the grid structure is already suitable" \
90
+ "for the given task, which is higly probable, or increase the" \
91
+ "`n_iter` parameter if strict convergence is required.",
92
+ category=ConvergenceWarning,
93
+ stacklevel=2,
94
+ )
95
+ if verbose:
96
+ print("done")
97
+ return order, converged
98
+
99
+
100
+ def sort(X, gridshape, level=2, init="kdtree", inplace=False, n_iter=50, verbose = 2):
101
+ """Sort a point cloud `X` using the monotonic Lagrangian algorithm.
102
+
103
+ Parameters
104
+ ----------
105
+ X : ndarray of shape (N, D)
106
+ Input point cloud.
107
+ gridshape : tuple of int
108
+ Target shape of the sorting grid. The product of the dimensions must
109
+ equal N and its length must equal D. E.g., for X of shape (42, 2),
110
+ `shape` can be (6, 7); for (60, 3), `shape` can be (3, 4, 5).
111
+
112
+ Note: Point clouds can be padded with +/- infinity placeholders to reach
113
+ a valid grid size. NaNs can also be used, but will be placed randomly
114
+ rather than pushed to grid boundaries.
115
+ level : {1, 2, 3}, default=1
116
+ Maximum order of lattice directions considered. Higher values improve
117
+ grid quality at the expense of computational speed. Note that `level=3`
118
+ is only supported if D >= 3.
119
+ init : {'kdtree', 'shuffle'} or None, default="kdtree"
120
+ Initialization method for the permutation order. Try init = None
121
+ if the point cloud is already nearly sorted.
122
+ inplace : bool, default=False
123
+ If True, modify the point cloud `X` in place.
124
+ n_iter : int, default=50
125
+ Maximum number of optimization iterations.
126
+ verbose : int, default 2
127
+ 0 = silent, 1 = log progress, 2 = 1 + warn if convergence failed
128
+
129
+ Returns
130
+ -------
131
+ X_sorted : ndarray of shape (N, D) or None
132
+ The sorted point cloud such that `X_sorted.reshape(*shape, D)` forms a
133
+ monotonically sorted grid. Returns `None` if `inplace=True`.
134
+ """
135
+ order, _ = argsort(X, gridshape, level=level, init = init, n_iter=n_iter,verbose = verbose)
136
+
137
+ if inplace:
138
+ X[:] = X[order]
139
+ return None
140
+
141
+ return X[order]
142
+
143
+ def invert_permutation(sigma):
144
+ """
145
+ invert permutation helper, sigma_inv[sigma] = identity
146
+ """
147
+ xp, _ = get_backend(sigma)
148
+ return xp.argsort(sigma)
149
+
150
+ # ============================================
151
+ # Helpers
152
+ # ============================================
153
+
154
+ class ConvergenceWarning(UserWarning):
155
+ """Warning for convergence issues."""
156
+ pass
157
+
158
+ def _canonical_directions(directions):
159
+ first_nz = argmax(directions != 0, axis=1)
160
+ idx = arange(len(directions), 'int', reference_array=directions)
161
+ first_nz_sign = directions[idx, first_nz]
162
+ return directions[first_nz_sign > 0]
163
+
164
+
165
+ def _direction_blocks(level, xp, dim=3):
166
+ offsets = xp.arange(-1, 2)
167
+ lattice = xp.stack(xp.meshgrid(*(offsets,) * dim, indexing="ij"), axis=-1).reshape(-1, dim)
168
+ sq_norms = xp.sum(lattice ** 2, axis=-1)
169
+ dirs = _canonical_directions(lattice[(sq_norms > 0) & (sq_norms <= level ** 2)])
170
+ return [dirs[xp.sum(dirs ** 2, axis=-1) == k] for k in range(1, level + 1)]
171
+
172
+
173
+ def _sort_along_direction(X, direction):
174
+ xp, _ = get_backend(X)
175
+ level = int(xp.abs(direction).sum())
176
+
177
+ if level == 1:
178
+ axis = int(xp.argmax(xp.abs(direction)))
179
+ if all_true(xp.diff(X, axis=axis) >= 0):
180
+ return True
181
+ inplace_sort(X, axis=axis)
182
+ return False
183
+
184
+ elif level == 2:
185
+ ax0, ax1 = xp.where(direction != 0)[0]
186
+ Y = xp.moveaxis(X, [ax0, ax1], [-2, -1])
187
+ flipaxes = []
188
+ for ax, _ax in zip([ax0, ax1], [-2, -1]):
189
+ if direction[ax] == -1:
190
+ flipaxes.append(_ax)
191
+ Y[...] = flip(Y, flipaxes)
192
+ was_sorted = sort_diag_trick(Y)
193
+ Y[...] = flip(Y, flipaxes)
194
+ return was_sorted
195
+
196
+ elif level == 3:
197
+ ax0, ax1, ax2 = xp.where(direction != 0)[0]
198
+ Y = xp.moveaxis(X, [ax0, ax1, ax2], [-3, -2, -1])
199
+ flipaxes = []
200
+ for ax, _ax in zip([ax0, ax1, ax2], [-3, -2, -1]):
201
+ if direction[ax] == -1:
202
+ flipaxes.append(_ax)
203
+ Y[...] = flip(Y, flipaxes)
204
+ was_sorted = sort_tridiag_trick(Y)
205
+ Y[...] = flip(Y, flipaxes)
206
+ return was_sorted
207
+
208
+ raise ValueError(f"Unsupported direction with L1-norm {level}")
209
+
210
+
211
+ def _build_monotonic_lagrangian_loop(X, directions):
212
+ xp, _ = get_backend(X)
213
+ N, K = len(X), len(directions)
214
+ projections = X @ directions.T
215
+
216
+ rank = empty((K, N), 'int', reference_array=X)
217
+ for k in range(K):
218
+ order = xp.argsort(projections[:, k])
219
+ rank[k, order] = arange(N, 'int', reference_array=X)
220
+
221
+ identity = arange(N, 'int', reference_array=X)
222
+ h = [identity] + [rank[k] for k in range(K)]
223
+ h_next = [rank[k] for k in range(K)] + [identity]
224
+
225
+ perms = []
226
+ for hk, hk_next in zip(h, h_next):
227
+ sigma = empty((N,), 'int', reference_array=X)
228
+ sigma[hk] = hk_next
229
+ perms.append(sigma)
230
+
231
+ init_perm, end_perm = copy(perms[0]), copy(perms[-1])
232
+ cycle_perms = perms[:-1]
233
+ cycle_perms[0] = init_perm[end_perm]
234
+
235
+ return init_perm, cycle_perms, end_perm
236
+
237
+
238
+ def _sort_loop(loop, directions, shape, n_iter, verbose):
239
+ idx_to_cycle, directions_cycle, cycle_to_idx = loop
240
+ grid = idx_to_cycle.reshape(shape)
241
+ first_step = True
242
+
243
+ for it in range(n_iter):
244
+ if verbose:
245
+ print("-", end="", flush=True)
246
+ fully_sorted = True
247
+ for direction, along_direction in zip(directions, directions_cycle):
248
+ if not first_step:
249
+ grid = along_direction[grid]
250
+ first_step = False
251
+ fully_sorted = fully_sorted & _sort_along_direction(grid, direction)
252
+ if fully_sorted:
253
+ break
254
+
255
+ print("it:", it)
256
+ return (cycle_to_idx[grid]).ravel(), fully_sorted
257
+
258
+ def _mlg_step(X, shape, level, n_iter, verbose):
259
+ xp, _ = get_backend(X)
260
+ directions_raw = xp.vstack(_direction_blocks(level=level, xp=xp, dim=X.shape[-1]))
261
+ directions = astype(directions_raw, 'float', reference_array=X)
262
+ X_float = nan_to_num(astype(X, 'float'))
263
+
264
+ loop = _build_monotonic_lagrangian_loop(X_float, directions)
265
+ if verbose >=1:
266
+ print(f"level [{level}]. sorting")
267
+ return _sort_loop(loop, directions, shape, n_iter, verbose)
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: gridpoints
3
+ Version: 1.0.0
4
+ Summary: fast multidimensional sorting tool for point clouds using the monotonic lagrangian grid
5
+ Author: Armand de Cacqueray
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Neighborhood-Grid/CubeNet
8
+ Project-URL: Repository, https://github.com/Neighborhood-Grid/CubeNet
9
+ Keywords: sorting,point-clouds,multi-indexing,grid,neighborhood-grid,monotonic-lagrangian-grid,squarenet,nearest-neighbor
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: demo
14
+ Requires-Dist: squarenet; extra == "demo"
15
+ Dynamic: license-file
16
+
17
+ ## ❒ Gridpoints — Multi dimensional sort for point clouds
18
+
19
+ `Gridpoints` maps unstructured **point clouds** to structured grids through a **bijective transformation**: one point, one cell, no overlap, fully invertible. It replaces and enhances the squarenet project with a faster sorting algorithm.
20
+
21
+ Take raw point cloud `X(N, D)`
22
+ → find a grid shape and a permutation `order` such that
23
+ Xgrid = X[order].reshape(*gridshape, D) is sorted along every axis of the grid.
24
+ → On the `Xgrid` view of `X`, neighbor queries become a simple stencil look-up
25
+ neighborhood[i, j, k] = {Xgrid[i±di, j±dj, k±dk] | (di, dj, dk) ≤ R}, where `R` is a radius cutoff to determine,
26
+ allowing local operations in linear time.
27
+ → Standard operations (grid convolution, clustering, …) can then be applied
28
+ directly on the `Xgrid` view instead of relying on complex graph convolutions
29
+ or other point-cloud techniques.
30
+
31
+ `X` can be a NumPy, PyTorch or CuPy array of any dimension (N, D).
32
+ To allow natural padding when the grid has more cells than points,
33
+ NaNs and Infs are supported in a consistent manner:
34
+ - nans → random position
35
+ - (+-) infs → border of the grid
36
+ This allow to deal with prime or variable N, as long as one is ready to
37
+ deal with void/special grid cells.
38
+
39
+ Expected runtime for sorting 1 million points: CPU → < 10s, GPU → < 100 ms
40
+
41
+ ---
42
+
43
+ ### Installation
44
+
45
+ ```bash
46
+ pip install gridpoints # core only
47
+ pip install gridpoints[demo] # for the demonstration notebook, see `notebook.ipynb`
48
+ ```
49
+
50
+ ### Quickstart
51
+
52
+ ```python
53
+ import gridpoints as grid
54
+ import numpy as np
55
+
56
+ # Raw point cloud (numpy, pytorch or cupy)
57
+ X = np.random.rand(1_000_000, 3)
58
+
59
+ # Sorted view: place the points inside the grid
60
+ order = grid.argsort(X, gridshape=(100, 100, 100))
61
+ Yflat = X[order]
62
+ Ygrid = Yflat.reshape(100, 100, 100, 3)
63
+
64
+ # Rest of your pipeline, working with grids
65
+ Zgrid = apply_something(Ygrid)
66
+
67
+ # Back to the original points indexing
68
+ Zflat = Zgrid.reshape(-1, 2)
69
+ orderinv = grid.invert_permutation(order)
70
+ Z = Zflat[orderinv] # matches the initial points order
71
+ ```
72
+
73
+ <img src="https://raw.githubusercontent.com/Neighborhood-Grid/CubeNet/main/ballexemple.png">
74
+
75
+ ---
76
+
77
+ ### Note on the cutoff radius R
78
+
79
+ There is no strict theoretical guarantee about what the cutof radius R should be for a given task. E.g the relative grid position between a point and its nearest neighbors can't be garanted to be in the exact adjacent grid cells. What is guaranteed from the sorted ordering is only **grid monotonicity**: *x* coordinates increase along rows, *y* coordinates along columns, and so on.
80
+
81
+ As an example, empirical results in 2-D show that `R = 5` is enough for ~99 % of the nearest neighbors; some outlier neighbors will sit further apart for complex geometries with pronounced peaks, holes or any non-smoothness. When a stricter neighborhood is required, or in high dimensional setting, the best practice is to build an assembly of grid experts, each working on a rotated / projected view of the points, as discussed in [this topic](https://github.com/glotzerlab/freud/discussions/1417).
82
+
83
+ ### Note on efficient stencil operations
84
+
85
+ The typical use-case of `Gridpoints` is to allow fast local operations on arbitrary point clouds using stencil kernels:
86
+
87
+ ```text
88
+ output(i, j, k) = f( Xgrid[i±di, j±dj, k±dk] | di, dj, dk in local window )
89
+ ```
90
+ To go beyond standard (slow) python loops, this can be accelerated with native grid convolution operations of standard libraries whenever possible, or with `pystencils` or `taichi` compilers for complex/non linear grid kernels.
@@ -0,0 +1,12 @@
1
+ griddemo/__init__.py,sha256=6V0GaQ-14Nt31OK30w5AVHgO1RH03CscWIcT3oGYX_o,148
2
+ griddemo/quick_demo.py,sha256=nHkTSOsLBwy2pMa4envFlTrvGKO1hLvd__nET1AF1ag,3913
3
+ gridpoints/__init__.py,sha256=vUdnYE5bemyOrkrQfggllWmIwmDi7wb7_gEFXc8mKEI,229
4
+ gridpoints/array_libs.py,sha256=K8JTF3Ul8aMz6zX7o6H5mgOu-tgdtgrQliIgcXLXY_E,4831
5
+ gridpoints/diagonal_tricks.py,sha256=JpKb8lOeXYb8tg37fdlalhDrsz0FgK69Xa_HcawguWQ,2705
6
+ gridpoints/kdtree.py,sha256=pO7EBBpjxGW0f1Z1FywRYmWnjBfdBdLPIfuZbKlB7-k,6816
7
+ gridpoints/mlg_sort.py,sha256=NJpLme2gQxQwurUVZMcjinPjtjbKZaTd7ZvrgrxqB94,9931
8
+ gridpoints-1.0.0.dist-info/licenses/LICENSE,sha256=BFLP4IAOAWhrs3Al_5RUZem9LTTud3BOTcD0XAEEd3g,1095
9
+ gridpoints-1.0.0.dist-info/METADATA,sha256=_2bNQR6mPInFr90wZZ-r7QPQycLmp7SSt_0YYXv1UIM,4298
10
+ gridpoints-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ gridpoints-1.0.0.dist-info/top_level.txt,sha256=IOiFob4pQdS5foU3vSvqjzxVSWqaIhSxH4ed6nY5SzM,20
12
+ gridpoints-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neighborhood-grid
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,2 @@
1
+ griddemo
2
+ gridpoints