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 +10 -0
- griddemo/quick_demo.py +115 -0
- gridpoints/__init__.py +11 -0
- gridpoints/array_libs.py +165 -0
- gridpoints/diagonal_tricks.py +96 -0
- gridpoints/kdtree.py +203 -0
- gridpoints/mlg_sort.py +267 -0
- gridpoints-1.0.0.dist-info/METADATA +90 -0
- gridpoints-1.0.0.dist-info/RECORD +12 -0
- gridpoints-1.0.0.dist-info/WHEEL +5 -0
- gridpoints-1.0.0.dist-info/licenses/LICENSE +21 -0
- gridpoints-1.0.0.dist-info/top_level.txt +2 -0
griddemo/__init__.py
ADDED
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
gridpoints/array_libs.py
ADDED
|
@@ -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,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.
|