gridpoints 1.0.7__tar.gz → 1.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gridpoints-1.1.1/PKG-INFO +223 -0
- gridpoints-1.1.1/README.md +207 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/pyproject.toml +3 -3
- gridpoints-1.1.1/src/griddemo/__init__.py +10 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints/__init__.py +13 -11
- gridpoints-1.1.1/src/gridpoints/array_libs.py +183 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints/diagonal_tricks.py +106 -94
- gridpoints-1.1.1/src/gridpoints/kdtree.py +176 -0
- gridpoints-1.1.1/src/gridpoints/mlg_sort.py +289 -0
- gridpoints-1.1.1/src/gridpoints/padding.py +48 -0
- gridpoints-1.1.1/src/gridpoints.egg-info/PKG-INFO +223 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints.egg-info/SOURCES.txt +1 -0
- gridpoints-1.0.7/PKG-INFO +0 -112
- gridpoints-1.0.7/README.md +0 -96
- gridpoints-1.0.7/src/griddemo/__init__.py +0 -10
- gridpoints-1.0.7/src/gridpoints/array_libs.py +0 -175
- gridpoints-1.0.7/src/gridpoints/kdtree.py +0 -203
- gridpoints-1.0.7/src/gridpoints/mlg_sort.py +0 -280
- gridpoints-1.0.7/src/gridpoints.egg-info/PKG-INFO +0 -112
- {gridpoints-1.0.7 → gridpoints-1.1.1}/LICENSE +0 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/setup.cfg +0 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/griddemo/quick_demo.py +0 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints.egg-info/dependency_links.txt +0 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints.egg-info/requires.txt +0 -0
- {gridpoints-1.0.7 → gridpoints-1.1.1}/src/gridpoints.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridpoints
|
|
3
|
+
Version: 1.1.1
|
|
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://neighborhood-grid.github.io/gridpoints/
|
|
8
|
+
Project-URL: Repository, https://github.com/Neighborhood-Grid/gridpoints
|
|
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
|
|
18
|
+
|
|
19
|
+
[](https://pypi.org/project/gridpoints/)
|
|
20
|
+
[](https://github.com/Neighborhood-Grid/gridpoints)
|
|
21
|
+
|
|
22
|
+
<img src="https://raw.githubusercontent.com/Neighborhood-Grid/CubeNet/main/plots/table(1).gif">
|
|
23
|
+
|
|
24
|
+
**Reorder an unstructured point cloud into a regular grid, so that neighbor-based operations become simple grid processing.**
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import gridpoints as grid
|
|
28
|
+
points = your_dataset_of_points(1000000, 3) # standard (N, D) array
|
|
29
|
+
order = grid.argsort(points, gridshape=(100, 100, 100)) # set gridshape: (I, J, K, ...) = (100, 100, 100)
|
|
30
|
+
Pgrid = points[order].reshape(100, 100, 100, 3) # now an (I, J, K, ..., D) version of your dataset
|
|
31
|
+
# Ready for convolution, neighbor look-ups or geometrical analysis.
|
|
32
|
+
# Note that grid.argsort also supports a batch mode (B, N, D) -> (B, I, J, K, ..., D)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Gridpoints supports NumPy, PyTorch and CuPy arrays.
|
|
36
|
+
The `argsort()` reordering is a one-to-one assignment of your points to the cells of a grid with the given `gridshape`. This is related to Monge optimal transport, but `argsort()` trades optimality for scalability, by combining greedy axial operations which exploit the specific structure of grids. One million 2D/3D points are sorted in about 0.5 s on a GPU (PyTorch) or 10 s on a CPU (NumPy).
|
|
37
|
+
|
|
38
|
+
<img src="https://raw.githubusercontent.com/Neighborhood-Grid/CubeNet/main/plots/ballexemple.png">
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## TL;DR
|
|
43
|
+
|
|
44
|
+
- **Problem:** point clouds are stored as a flat list `[P1, ..., PN]`. Finding the neighbors of a point usually needs a tree or a graph (KD-tree, radius graph...), which is hard to use with standard tensor-based machine learning, especially on GPU.
|
|
45
|
+
- **Idea:** assign every point to a cell of a D-dimensional grid, one point per cell, such that the grid is sorted along each of its axes. Then the neighbors of a point are *approximately* the points in nearby cells, found by plain indexing.
|
|
46
|
+
- **Payoff:** local operations (convolution, smoothing, clustering, interpolation...) that are usually hard to implement on irregular point clouds can be applied on the grid: `points -> grid`. To recover the result on the points, one just applies the inverse assignment: `grid -> points`.
|
|
47
|
+
- **Catch:** a plain grid gives *approximate* neighborhoods (about 99% of true nearest neighbors within a small radius). Exact neighborhoods are recoverable with tiling (see [Neighborhood radius](#neighborhood-radius)). It works best in 2D/3D, and stays reasonable up to about 6D.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## The problem
|
|
52
|
+
|
|
53
|
+
Points are almost always stored in a one-dimensional order. You would like points that are close in space to also be close in memory, so that the nearest neighbor of `P[n]` is often `P[n±1]`, `P[n±2]`, or a bit further. This is a well-studied problem, and space-filling curves such as the Morton curve are a common solution.
|
|
54
|
+
|
|
55
|
+
Gridpoints takes a different route. Instead of a 1D order, it gives each point a **multi-index** `[i, j, ...]` in a D-dimensional grid. This matters because a grid supports stencil operations ("look at the cells within a few steps in each direction", `[i±di, j±dj, k±dk]`).
|
|
56
|
+
|
|
57
|
+
## What Gridpoints does
|
|
58
|
+
|
|
59
|
+
Input: a point cloud `P` of shape `(N, D)` and a target grid shape `(I, J, ...)`.
|
|
60
|
+
|
|
61
|
+
Output: a permutation `order` of the indices such that
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
Pgrid = P[order].reshape(I, J, ..., D)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
is a bijection between points and grid cells (one point, one cell), and is **sorted along every axis**. In 3D, with xyz notation `P = (x, y, z)`:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
x[i+1, j, k] >= x[i, j, k] # x increases along axis 0
|
|
71
|
+
y[i, j+1, k] >= y[i, j, k] # y increases along axis 1
|
|
72
|
+
z[i, j, k+1] >= z[i, j, k] # z increases along axis 2
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This property (*grid monotonicity*) is the only thing guaranteed. See [Neighborhood radius](#neighborhood-radius) for what it does and does not imply.
|
|
76
|
+
|
|
77
|
+
On `Pgrid`, a neighbor query becomes a stencil lookup:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
neighborhood(Pgrid[i, j, k]) ≈ { Pgrid[i±di, j±dj, k±dk] : (di, dj, dk) within radius R }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
where `R` is a cutoff you choose for your task. Operations built on this run in **O(N)** on the grid view.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Internal algorithm
|
|
88
|
+
|
|
89
|
+
To get the main ideas of the sorting algorithm, the core of `gridpoints` is:
|
|
90
|
+
1. initialisation: a good starting assignement is important both for quick convergence, and to converge on an assignement with good neighborhood properties. The initialiser build a kdtree on the points, and convert the tree cuts to a grid structure
|
|
91
|
+
2. iterative 1d sorts: once the initialiser built the starting assignement, gridpoints further optimise it iteratively by looking at 1d slices of the grid (axial slices, but also diagonal ones), and apply standard 1d sort on the points, projected along the direction of the slice.
|
|
92
|
+
|
|
93
|
+
For interested readers, the full sorting procedure and motivation behind is explained in more details [here](https://github.com/Neighborhood-Grid/Cartesian-Grid-Sort).
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install gridpoints # core only
|
|
99
|
+
pip install gridpoints[demo] # adds visualisation tools for the demo
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Demo 1. modelnet40:
|
|
103
|
+
To play with real life objects from the Modelnet40 dataset, first get modelnet40 from [princeton.edu](http://modelnet.cs.princeton.edu) (~2 GB), then:
|
|
104
|
+
```
|
|
105
|
+
python modelnet40_demo.py --obj table --num 6
|
|
106
|
+
python modelnet40_demo.py --obj airplane --num 1
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
Animated figures (morphing from the raw point cloud to a cubic grid) will be saved to sqrnet\plot.gif
|
|
110
|
+
|
|
111
|
+
## Demo 2. big point clouds:
|
|
112
|
+
To test gridpoints on various synthetic datasets, see the notebook: [notebook.ipynb](https://github.com/Neighborhood-Grid/CubeNet/blob/main/notebook.ipynb)
|
|
113
|
+
|
|
114
|
+
## Quickstart
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import gridpoints as grid
|
|
118
|
+
import numpy as np
|
|
119
|
+
|
|
120
|
+
N = 1_000_000
|
|
121
|
+
A = np.random.rand(N, 3) # raw point cloud (NumPy, PyTorch or CuPy)
|
|
122
|
+
|
|
123
|
+
# 1. Compute the permutation and view the points as a grid
|
|
124
|
+
order = grid.argsort(A, gridshape=(100, 100, 100))
|
|
125
|
+
Bflat = A[order] # sorted, flat (C-order)
|
|
126
|
+
Bgrid = Bflat.reshape(100, 100, 100, 3) # grid view
|
|
127
|
+
|
|
128
|
+
# 2. Work on the grid
|
|
129
|
+
Cgrid = apply_something(Bgrid) # e.g. a convolution; shape (100, 100, 100, *)
|
|
130
|
+
|
|
131
|
+
# 3. Go back to the original point order
|
|
132
|
+
Cflat = Cgrid.reshape(N, -1)
|
|
133
|
+
orderinv = grid.invert_permutation(order)
|
|
134
|
+
C = Cflat[orderinv] # back to the original point indexing
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`apply_something` stands for whatever you want to compute on the grid.
|
|
138
|
+
|
|
139
|
+
`gridpoints.sort()` is also available.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Choosing the parameters
|
|
144
|
+
|
|
145
|
+
### Grid shape
|
|
146
|
+
|
|
147
|
+
The grid shape must be roughly tuned to the *kind* of data, with the constraint that the number of points `N` has to be factorized as `I⋅J⋅K⋅…`
|
|
148
|
+
|
|
149
|
+
- The exact factorization hardly matters: `(18, 20, 16)` and `(16, 15, 24)` behave similarly.
|
|
150
|
+
- The **orders of magnitude** of each dimension matter a lot: `(18, 20, 16)` and `(36, 40, 4)` can give substantially different results.
|
|
151
|
+
- For a thin surface (e.g. an eggshell), use a flat grid such as `(128, 128, 2)`. A naive `(32, 32, 32)` grid on the same data produces a poor representation of the geometry.
|
|
152
|
+
|
|
153
|
+
**Padding:** if the number of cells `I⋅J⋅K⋅…` exceeds the number of points `N`, the point cloud can be padded with special values (see [Padding](#padding-with-nan-and-inf)). This lets you sort a prime or variable number of points, and removes the integer-factorization constraint on the grid shape.
|
|
154
|
+
|
|
155
|
+
### Neighborhood radius
|
|
156
|
+
|
|
157
|
+
There is no theoretical guarantee on the radius `R` needed for a given task. Nearest neighbors are not guaranteed to land in exactly adjacent cells.
|
|
158
|
+
|
|
159
|
+
Empirically, in 2D, `R = 5` captures about 99% of nearest neighbors. Some outliers might sit further away, mostly on complex geometries with sharp peaks, holes or other non-smooth features.
|
|
160
|
+
|
|
161
|
+
If you need stricter neighborhoods, or work in higher dimension, there are two options:
|
|
162
|
+
|
|
163
|
+
- **Ensemble of grids:** build several grids, each on a rotated or projected view of the points, and combine them (see https://arxiv.org/abs/1505.00880 for the concept of multi view pooling). Projecting the data randomly onto one or several lower-dimensional subspaces before sorting also helps reduce topology-specific effects, since random projections approximately preserve pairwise distances (Johnson–Lindenstrauss lemma).
|
|
164
|
+
- **Tiling:** if you need *exact* neighbors, tiling gives them. Split the grid into tiles, attach metadata to each tile (e.g. a bounding box), and skip pairs of tiles that are certifiably too far apart for your criterion. The grid does the bulk of the work; the tiles only certify or correct the rest.
|
|
165
|
+
|
|
166
|
+
### Padding with NaN and Inf
|
|
167
|
+
|
|
168
|
+
NaN and Inf values are supported in a consistent way by the package, so that padding is natural:
|
|
169
|
+
|
|
170
|
+
- `NaN` → placed at a random position
|
|
171
|
+
- `±Inf` → placed on the border of the grid
|
|
172
|
+
|
|
173
|
+
This lets you complete the N real points with ΔN fictitious points so that N + ΔN = I⋅J⋅K⋅… , using `gridpoints.pad`
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Performance
|
|
178
|
+
|
|
179
|
+
Sorting 1 million points (2D/3D), measured on Google Colab:
|
|
180
|
+
|
|
181
|
+
| Backend | Time |
|
|
182
|
+
|---|---|
|
|
183
|
+
| NumPy (CPU) | ~10 s |
|
|
184
|
+
| PyTorch (T4 GPU) | ~0.5 s |
|
|
185
|
+
| CuPy (T4 GPU) | ~1 s (the first, cold call takes ~10 s because of compilation) |
|
|
186
|
+
|
|
187
|
+
### Running stencil operations efficiently
|
|
188
|
+
|
|
189
|
+
A typical use looks like:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
output(i, j, k) = f( Pgrid[i±di, j±dj, k±dk] ) for (di, dj, dk) in a local window
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
To compute this kind of kernel efficiently, avoid pure Python loops, which are slow. Prefer:
|
|
196
|
+
|
|
197
|
+
- Native grid convolutions of standard libraries, whenever your operation can be expressed that way.
|
|
198
|
+
- `pystencils` or `taichi` for complex or non-linear kernels.
|
|
199
|
+
- On GPU, tiling the grid with a custom `triton` kernel can be particularly efficient.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Limits
|
|
204
|
+
|
|
205
|
+
**Good fit**
|
|
206
|
+
|
|
207
|
+
- **Low-dimensional point clouds.** 2D and 3D are the sweet spot, with enough points for O(N) grid operations to pay off.
|
|
208
|
+
- **Pipelines that benefit from regular arrays:** GPU processing, convolutions, repeated local operations.
|
|
209
|
+
- **Nice geometries.** "Nice" is deliberately informal. The question is: can you mentally spread your point distribution onto a regular box with the specified dimensions without your brain blowing up in the process? Remarkably, this requires neither convexity (a map of France, a sponge, a donut, an elephant, an eggshell are valid) nor connectedness (clusters and blobs are valid, Gridpoints just glues them together). Examples are in the [plots folder](https://github.com/Neighborhood-Grid/CubeNet/blob/main/plots).
|
|
210
|
+
|
|
211
|
+
**Poor fit**
|
|
212
|
+
|
|
213
|
+
- **High dimension.** Arbitrary dimension is supported, but the sweet spot is 2D/3D. Dimensions 4 to 6 are still reasonable. Above ~8D, Gridpoints is generally not appropriate. Note that many powerful dimension reduction technique exists beyond random projections ensemble or principal component analysis. Modern ml tools such as auto-encoders or parametric umap could maybe help to convert high dimensional data to the low dimensional framework of gridpoints.
|
|
214
|
+
- **Small point clouds.** Below a few hundred points, the overhead is not worth it: a naive quadratic implementation of your kernels will be simpler and probably faster.
|
|
215
|
+
- **Anti-grid geometries.** A spider web, a wind turbine... The sort still runs, but a single grid sort gives a poor representation of the structure. For such distributions, you currently need a projection + grid ensemble procedure, which must be set up manually. That may make Gridpoints less attractive if you were looking for a light, out-of-the-box gridification framework. I am working on making this more automatic, and I hope that at some point Gridpoints will be easily applicable to a much wider range of geometries. Updates will be posted here!
|
|
216
|
+
|
|
217
|
+
## Should you use it?
|
|
218
|
+
|
|
219
|
+
Try it if you process 2D/3D point clouds with local operations and you want regular array layouts (especially on GPU). Approximate neighborhoods work out of the box, and exact ones are available through tiling.
|
|
220
|
+
|
|
221
|
+
Stay with a KD-tree or a graph method if you work in high dimension or have only a few hundred points. If you need exact nearest neighbors, Gridpoints can still work, via tiling, but expect some extra implementation effort.
|
|
222
|
+
|
|
223
|
+
A quick way to decide is to run the [demo notebook](https://github.com/Neighborhood-Grid/CubeNet/blob/main/notebook.ipynb) on a sample of your own data and check how much of your true nearest neighbors fall within your radius `R`.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Gridpoints
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/gridpoints/)
|
|
4
|
+
[](https://github.com/Neighborhood-Grid/gridpoints)
|
|
5
|
+
|
|
6
|
+
<img src="https://raw.githubusercontent.com/Neighborhood-Grid/CubeNet/main/plots/table(1).gif">
|
|
7
|
+
|
|
8
|
+
**Reorder an unstructured point cloud into a regular grid, so that neighbor-based operations become simple grid processing.**
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import gridpoints as grid
|
|
12
|
+
points = your_dataset_of_points(1000000, 3) # standard (N, D) array
|
|
13
|
+
order = grid.argsort(points, gridshape=(100, 100, 100)) # set gridshape: (I, J, K, ...) = (100, 100, 100)
|
|
14
|
+
Pgrid = points[order].reshape(100, 100, 100, 3) # now an (I, J, K, ..., D) version of your dataset
|
|
15
|
+
# Ready for convolution, neighbor look-ups or geometrical analysis.
|
|
16
|
+
# Note that grid.argsort also supports a batch mode (B, N, D) -> (B, I, J, K, ..., D)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Gridpoints supports NumPy, PyTorch and CuPy arrays.
|
|
20
|
+
The `argsort()` reordering is a one-to-one assignment of your points to the cells of a grid with the given `gridshape`. This is related to Monge optimal transport, but `argsort()` trades optimality for scalability, by combining greedy axial operations which exploit the specific structure of grids. One million 2D/3D points are sorted in about 0.5 s on a GPU (PyTorch) or 10 s on a CPU (NumPy).
|
|
21
|
+
|
|
22
|
+
<img src="https://raw.githubusercontent.com/Neighborhood-Grid/CubeNet/main/plots/ballexemple.png">
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## TL;DR
|
|
27
|
+
|
|
28
|
+
- **Problem:** point clouds are stored as a flat list `[P1, ..., PN]`. Finding the neighbors of a point usually needs a tree or a graph (KD-tree, radius graph...), which is hard to use with standard tensor-based machine learning, especially on GPU.
|
|
29
|
+
- **Idea:** assign every point to a cell of a D-dimensional grid, one point per cell, such that the grid is sorted along each of its axes. Then the neighbors of a point are *approximately* the points in nearby cells, found by plain indexing.
|
|
30
|
+
- **Payoff:** local operations (convolution, smoothing, clustering, interpolation...) that are usually hard to implement on irregular point clouds can be applied on the grid: `points -> grid`. To recover the result on the points, one just applies the inverse assignment: `grid -> points`.
|
|
31
|
+
- **Catch:** a plain grid gives *approximate* neighborhoods (about 99% of true nearest neighbors within a small radius). Exact neighborhoods are recoverable with tiling (see [Neighborhood radius](#neighborhood-radius)). It works best in 2D/3D, and stays reasonable up to about 6D.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## The problem
|
|
36
|
+
|
|
37
|
+
Points are almost always stored in a one-dimensional order. You would like points that are close in space to also be close in memory, so that the nearest neighbor of `P[n]` is often `P[n±1]`, `P[n±2]`, or a bit further. This is a well-studied problem, and space-filling curves such as the Morton curve are a common solution.
|
|
38
|
+
|
|
39
|
+
Gridpoints takes a different route. Instead of a 1D order, it gives each point a **multi-index** `[i, j, ...]` in a D-dimensional grid. This matters because a grid supports stencil operations ("look at the cells within a few steps in each direction", `[i±di, j±dj, k±dk]`).
|
|
40
|
+
|
|
41
|
+
## What Gridpoints does
|
|
42
|
+
|
|
43
|
+
Input: a point cloud `P` of shape `(N, D)` and a target grid shape `(I, J, ...)`.
|
|
44
|
+
|
|
45
|
+
Output: a permutation `order` of the indices such that
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
Pgrid = P[order].reshape(I, J, ..., D)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
is a bijection between points and grid cells (one point, one cell), and is **sorted along every axis**. In 3D, with xyz notation `P = (x, y, z)`:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
x[i+1, j, k] >= x[i, j, k] # x increases along axis 0
|
|
55
|
+
y[i, j+1, k] >= y[i, j, k] # y increases along axis 1
|
|
56
|
+
z[i, j, k+1] >= z[i, j, k] # z increases along axis 2
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This property (*grid monotonicity*) is the only thing guaranteed. See [Neighborhood radius](#neighborhood-radius) for what it does and does not imply.
|
|
60
|
+
|
|
61
|
+
On `Pgrid`, a neighbor query becomes a stencil lookup:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
neighborhood(Pgrid[i, j, k]) ≈ { Pgrid[i±di, j±dj, k±dk] : (di, dj, dk) within radius R }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
where `R` is a cutoff you choose for your task. Operations built on this run in **O(N)** on the grid view.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Internal algorithm
|
|
72
|
+
|
|
73
|
+
To get the main ideas of the sorting algorithm, the core of `gridpoints` is:
|
|
74
|
+
1. initialisation: a good starting assignement is important both for quick convergence, and to converge on an assignement with good neighborhood properties. The initialiser build a kdtree on the points, and convert the tree cuts to a grid structure
|
|
75
|
+
2. iterative 1d sorts: once the initialiser built the starting assignement, gridpoints further optimise it iteratively by looking at 1d slices of the grid (axial slices, but also diagonal ones), and apply standard 1d sort on the points, projected along the direction of the slice.
|
|
76
|
+
|
|
77
|
+
For interested readers, the full sorting procedure and motivation behind is explained in more details [here](https://github.com/Neighborhood-Grid/Cartesian-Grid-Sort).
|
|
78
|
+
|
|
79
|
+
## Installation
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pip install gridpoints # core only
|
|
83
|
+
pip install gridpoints[demo] # adds visualisation tools for the demo
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Demo 1. modelnet40:
|
|
87
|
+
To play with real life objects from the Modelnet40 dataset, first get modelnet40 from [princeton.edu](http://modelnet.cs.princeton.edu) (~2 GB), then:
|
|
88
|
+
```
|
|
89
|
+
python modelnet40_demo.py --obj table --num 6
|
|
90
|
+
python modelnet40_demo.py --obj airplane --num 1
|
|
91
|
+
...
|
|
92
|
+
```
|
|
93
|
+
Animated figures (morphing from the raw point cloud to a cubic grid) will be saved to sqrnet\plot.gif
|
|
94
|
+
|
|
95
|
+
## Demo 2. big point clouds:
|
|
96
|
+
To test gridpoints on various synthetic datasets, see the notebook: [notebook.ipynb](https://github.com/Neighborhood-Grid/CubeNet/blob/main/notebook.ipynb)
|
|
97
|
+
|
|
98
|
+
## Quickstart
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import gridpoints as grid
|
|
102
|
+
import numpy as np
|
|
103
|
+
|
|
104
|
+
N = 1_000_000
|
|
105
|
+
A = np.random.rand(N, 3) # raw point cloud (NumPy, PyTorch or CuPy)
|
|
106
|
+
|
|
107
|
+
# 1. Compute the permutation and view the points as a grid
|
|
108
|
+
order = grid.argsort(A, gridshape=(100, 100, 100))
|
|
109
|
+
Bflat = A[order] # sorted, flat (C-order)
|
|
110
|
+
Bgrid = Bflat.reshape(100, 100, 100, 3) # grid view
|
|
111
|
+
|
|
112
|
+
# 2. Work on the grid
|
|
113
|
+
Cgrid = apply_something(Bgrid) # e.g. a convolution; shape (100, 100, 100, *)
|
|
114
|
+
|
|
115
|
+
# 3. Go back to the original point order
|
|
116
|
+
Cflat = Cgrid.reshape(N, -1)
|
|
117
|
+
orderinv = grid.invert_permutation(order)
|
|
118
|
+
C = Cflat[orderinv] # back to the original point indexing
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`apply_something` stands for whatever you want to compute on the grid.
|
|
122
|
+
|
|
123
|
+
`gridpoints.sort()` is also available.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Choosing the parameters
|
|
128
|
+
|
|
129
|
+
### Grid shape
|
|
130
|
+
|
|
131
|
+
The grid shape must be roughly tuned to the *kind* of data, with the constraint that the number of points `N` has to be factorized as `I⋅J⋅K⋅…`
|
|
132
|
+
|
|
133
|
+
- The exact factorization hardly matters: `(18, 20, 16)` and `(16, 15, 24)` behave similarly.
|
|
134
|
+
- The **orders of magnitude** of each dimension matter a lot: `(18, 20, 16)` and `(36, 40, 4)` can give substantially different results.
|
|
135
|
+
- For a thin surface (e.g. an eggshell), use a flat grid such as `(128, 128, 2)`. A naive `(32, 32, 32)` grid on the same data produces a poor representation of the geometry.
|
|
136
|
+
|
|
137
|
+
**Padding:** if the number of cells `I⋅J⋅K⋅…` exceeds the number of points `N`, the point cloud can be padded with special values (see [Padding](#padding-with-nan-and-inf)). This lets you sort a prime or variable number of points, and removes the integer-factorization constraint on the grid shape.
|
|
138
|
+
|
|
139
|
+
### Neighborhood radius
|
|
140
|
+
|
|
141
|
+
There is no theoretical guarantee on the radius `R` needed for a given task. Nearest neighbors are not guaranteed to land in exactly adjacent cells.
|
|
142
|
+
|
|
143
|
+
Empirically, in 2D, `R = 5` captures about 99% of nearest neighbors. Some outliers might sit further away, mostly on complex geometries with sharp peaks, holes or other non-smooth features.
|
|
144
|
+
|
|
145
|
+
If you need stricter neighborhoods, or work in higher dimension, there are two options:
|
|
146
|
+
|
|
147
|
+
- **Ensemble of grids:** build several grids, each on a rotated or projected view of the points, and combine them (see https://arxiv.org/abs/1505.00880 for the concept of multi view pooling). Projecting the data randomly onto one or several lower-dimensional subspaces before sorting also helps reduce topology-specific effects, since random projections approximately preserve pairwise distances (Johnson–Lindenstrauss lemma).
|
|
148
|
+
- **Tiling:** if you need *exact* neighbors, tiling gives them. Split the grid into tiles, attach metadata to each tile (e.g. a bounding box), and skip pairs of tiles that are certifiably too far apart for your criterion. The grid does the bulk of the work; the tiles only certify or correct the rest.
|
|
149
|
+
|
|
150
|
+
### Padding with NaN and Inf
|
|
151
|
+
|
|
152
|
+
NaN and Inf values are supported in a consistent way by the package, so that padding is natural:
|
|
153
|
+
|
|
154
|
+
- `NaN` → placed at a random position
|
|
155
|
+
- `±Inf` → placed on the border of the grid
|
|
156
|
+
|
|
157
|
+
This lets you complete the N real points with ΔN fictitious points so that N + ΔN = I⋅J⋅K⋅… , using `gridpoints.pad`
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Performance
|
|
162
|
+
|
|
163
|
+
Sorting 1 million points (2D/3D), measured on Google Colab:
|
|
164
|
+
|
|
165
|
+
| Backend | Time |
|
|
166
|
+
|---|---|
|
|
167
|
+
| NumPy (CPU) | ~10 s |
|
|
168
|
+
| PyTorch (T4 GPU) | ~0.5 s |
|
|
169
|
+
| CuPy (T4 GPU) | ~1 s (the first, cold call takes ~10 s because of compilation) |
|
|
170
|
+
|
|
171
|
+
### Running stencil operations efficiently
|
|
172
|
+
|
|
173
|
+
A typical use looks like:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
output(i, j, k) = f( Pgrid[i±di, j±dj, k±dk] ) for (di, dj, dk) in a local window
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
To compute this kind of kernel efficiently, avoid pure Python loops, which are slow. Prefer:
|
|
180
|
+
|
|
181
|
+
- Native grid convolutions of standard libraries, whenever your operation can be expressed that way.
|
|
182
|
+
- `pystencils` or `taichi` for complex or non-linear kernels.
|
|
183
|
+
- On GPU, tiling the grid with a custom `triton` kernel can be particularly efficient.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Limits
|
|
188
|
+
|
|
189
|
+
**Good fit**
|
|
190
|
+
|
|
191
|
+
- **Low-dimensional point clouds.** 2D and 3D are the sweet spot, with enough points for O(N) grid operations to pay off.
|
|
192
|
+
- **Pipelines that benefit from regular arrays:** GPU processing, convolutions, repeated local operations.
|
|
193
|
+
- **Nice geometries.** "Nice" is deliberately informal. The question is: can you mentally spread your point distribution onto a regular box with the specified dimensions without your brain blowing up in the process? Remarkably, this requires neither convexity (a map of France, a sponge, a donut, an elephant, an eggshell are valid) nor connectedness (clusters and blobs are valid, Gridpoints just glues them together). Examples are in the [plots folder](https://github.com/Neighborhood-Grid/CubeNet/blob/main/plots).
|
|
194
|
+
|
|
195
|
+
**Poor fit**
|
|
196
|
+
|
|
197
|
+
- **High dimension.** Arbitrary dimension is supported, but the sweet spot is 2D/3D. Dimensions 4 to 6 are still reasonable. Above ~8D, Gridpoints is generally not appropriate. Note that many powerful dimension reduction technique exists beyond random projections ensemble or principal component analysis. Modern ml tools such as auto-encoders or parametric umap could maybe help to convert high dimensional data to the low dimensional framework of gridpoints.
|
|
198
|
+
- **Small point clouds.** Below a few hundred points, the overhead is not worth it: a naive quadratic implementation of your kernels will be simpler and probably faster.
|
|
199
|
+
- **Anti-grid geometries.** A spider web, a wind turbine... The sort still runs, but a single grid sort gives a poor representation of the structure. For such distributions, you currently need a projection + grid ensemble procedure, which must be set up manually. That may make Gridpoints less attractive if you were looking for a light, out-of-the-box gridification framework. I am working on making this more automatic, and I hope that at some point Gridpoints will be easily applicable to a much wider range of geometries. Updates will be posted here!
|
|
200
|
+
|
|
201
|
+
## Should you use it?
|
|
202
|
+
|
|
203
|
+
Try it if you process 2D/3D point clouds with local operations and you want regular array layouts (especially on GPU). Approximate neighborhoods work out of the box, and exact ones are available through tiling.
|
|
204
|
+
|
|
205
|
+
Stay with a KD-tree or a graph method if you work in high dimension or have only a few hundred points. If you need exact nearest neighbors, Gridpoints can still work, via tiling, but expect some extra implementation effort.
|
|
206
|
+
|
|
207
|
+
A quick way to decide is to run the [demo notebook](https://github.com/Neighborhood-Grid/CubeNet/blob/main/notebook.ipynb) on a sample of your own data and check how much of your true nearest neighbors fall within your radius `R`.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gridpoints"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.1"
|
|
8
8
|
description = "fast multidimensional sorting tool for point clouds using the monotonic lagrangian grid "
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -26,8 +26,8 @@ demo = [
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
[project.urls]
|
|
29
|
-
Homepage = "https://github.
|
|
30
|
-
Repository = "https://github.com/Neighborhood-Grid/
|
|
29
|
+
Homepage = "https://neighborhood-grid.github.io/gridpoints/"
|
|
30
|
+
Repository = "https://github.com/Neighborhood-Grid/gridpoints"
|
|
31
31
|
|
|
32
32
|
[tool.setuptools.packages.find]
|
|
33
33
|
where = ["src"]
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
"""Monotonic Lagrangian sorting for NumPy, CuPy, and PyTorch arrays."""
|
|
2
|
-
|
|
3
|
-
from .mlg_sort import argsort, sort, invert_permutation
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
"""Monotonic Lagrangian sorting for NumPy, CuPy, and PyTorch arrays."""
|
|
2
|
+
|
|
3
|
+
from .mlg_sort import argsort, sort, invert_permutation
|
|
4
|
+
from .padding import pad
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
"argsort",
|
|
8
|
+
"sort",
|
|
9
|
+
"invert_permutation",
|
|
10
|
+
"pad",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "1.1.1"
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
|
|
10
|
+
def precision(N):
|
|
11
|
+
"""Names of the (float, int) dtypes suited to point clouds of N points.
|
|
12
|
+
|
|
13
|
+
- float32 resolves the gaps (~1/N) between neighbouring points only up to
|
|
14
|
+
a few 10**4 points, float64 is used beyond.
|
|
15
|
+
- the diagonal sorts store values up to 6N: int32 is safe up to N ~ 3.5e8.
|
|
16
|
+
"""
|
|
17
|
+
return ('float32' if N <= 2**16 else 'float64'), ('int32' if 6 * N < 2**31 else 'int64')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_dtype(dtp, xp):
|
|
21
|
+
if dtp == 'bool':
|
|
22
|
+
return getattr(xp, 'bool_', None) or xp.bool
|
|
23
|
+
return getattr(xp, dtp) if isinstance(dtp, str) else dtp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_backend(X):
|
|
27
|
+
module = type(X).__module__.split('.')[0]
|
|
28
|
+
if module == 'torch':
|
|
29
|
+
import torch
|
|
30
|
+
return torch, 'torch'
|
|
31
|
+
elif module == 'cupy':
|
|
32
|
+
import cupy
|
|
33
|
+
return cupy, 'cupy'
|
|
34
|
+
else:
|
|
35
|
+
import numpy
|
|
36
|
+
return numpy, 'numpy'
|
|
37
|
+
|
|
38
|
+
def astype(X, dtype_str, reference_array=None):
|
|
39
|
+
xp, backend_name = get_backend(X)
|
|
40
|
+
dtype = get_dtype(dtype_str, xp)
|
|
41
|
+
if backend_name == 'torch':
|
|
42
|
+
device = reference_array.device if reference_array is not None else X.device
|
|
43
|
+
return X.to(dtype=dtype, device=device)
|
|
44
|
+
return X.astype(dtype, copy=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def inplace_sort(X, axis):
|
|
48
|
+
xp, backend_name = get_backend(X)
|
|
49
|
+
if backend_name == 'torch':
|
|
50
|
+
X.copy_(X.sort(dim=axis)[0])
|
|
51
|
+
elif backend_name == "cupy":
|
|
52
|
+
X[...] = xp.sort(xp.ascontiguousarray(X), axis=axis)
|
|
53
|
+
else:
|
|
54
|
+
X.sort(axis=axis)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sort(X, axis):
|
|
58
|
+
xp, backend_name = get_backend(X)
|
|
59
|
+
return X.sort(dim=axis)[0] if backend_name == 'torch' else xp.sort(X, axis=axis)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _alloc(fn, shape, dtype_str, reference_array):
|
|
63
|
+
xp, backend_name = get_backend(reference_array)
|
|
64
|
+
dtype = get_dtype(dtype_str, xp)
|
|
65
|
+
if backend_name == 'torch':
|
|
66
|
+
return getattr(xp, fn)(shape, dtype=dtype, device=reference_array.device)
|
|
67
|
+
return getattr(xp, fn)(shape, dtype=dtype)
|
|
68
|
+
|
|
69
|
+
def empty(shape, dtype_str, reference_array):
|
|
70
|
+
return _alloc('empty', shape, dtype_str, reference_array)
|
|
71
|
+
|
|
72
|
+
def zeros(shape, dtype_str, reference_array):
|
|
73
|
+
return _alloc('zeros', shape, dtype_str, reference_array)
|
|
74
|
+
|
|
75
|
+
def ones(shape, dtype_str, reference_array):
|
|
76
|
+
return _alloc('ones', shape, dtype_str, reference_array)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def arange(N, dtype_str, reference_array):
|
|
80
|
+
xp, backend_name = get_backend(reference_array)
|
|
81
|
+
dtype = get_dtype(dtype_str, xp)
|
|
82
|
+
if backend_name == 'torch':
|
|
83
|
+
return xp.arange(N, dtype=dtype, device=reference_array.device)
|
|
84
|
+
return xp.arange(N, dtype=dtype)
|
|
85
|
+
|
|
86
|
+
def random(shape, dtype_str, reference_array):
|
|
87
|
+
"""Uniform random numbers in [0, 1)."""
|
|
88
|
+
xp, backend_name = get_backend(reference_array)
|
|
89
|
+
dtype = get_dtype(dtype_str, xp)
|
|
90
|
+
if backend_name == 'torch':
|
|
91
|
+
return xp.rand(shape, dtype=dtype, device=reference_array.device)
|
|
92
|
+
elif backend_name == 'cupy':
|
|
93
|
+
return xp.random.rand(*shape).astype(dtype)
|
|
94
|
+
return xp.random.default_rng(42).random(shape, dtype=dtype)
|
|
95
|
+
|
|
96
|
+
def copy(arr):
|
|
97
|
+
_, backend_name = get_backend(arr)
|
|
98
|
+
if backend_name == 'torch':
|
|
99
|
+
return arr.clone()
|
|
100
|
+
return arr.copy()
|
|
101
|
+
|
|
102
|
+
def amax(X, axis):
|
|
103
|
+
return X.amax(axis) if get_backend(X)[1] == 'torch' else X.max(axis)
|
|
104
|
+
|
|
105
|
+
def amin(X, axis):
|
|
106
|
+
return X.amin(axis) if get_backend(X)[1] == 'torch' else X.min(axis)
|
|
107
|
+
|
|
108
|
+
def all_true(tensor):
|
|
109
|
+
return bool(tensor.all())
|
|
110
|
+
|
|
111
|
+
def any_true(tensor):
|
|
112
|
+
return bool(tensor.any())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def nan_infs_to_num_and_tiebreaker(arr):
|
|
116
|
+
"""Make a batch of clouds (B, N, D) finite, and break ties with a tiny noise.
|
|
117
|
+
|
|
118
|
+
+inf / -inf are put just beyond the finite points of their cloud and axis
|
|
119
|
+
(they end up on the border of the grid), NaN are replaced by the value of a
|
|
120
|
+
random finite point of their cloud and axis (they end up anywhere).
|
|
121
|
+
"""
|
|
122
|
+
xp, _ = get_backend(arr)
|
|
123
|
+
out = arr
|
|
124
|
+
finite = xp.isfinite(arr)
|
|
125
|
+
if not all_true(finite):
|
|
126
|
+
hi = amax(xp.where(finite, arr, -xp.inf), 1)[:, None, :]
|
|
127
|
+
lo = amin(xp.where(finite, arr, xp.inf), 1)[:, None, :]
|
|
128
|
+
out = xp.where(xp.isposinf(arr), hi + 1, arr)
|
|
129
|
+
out = xp.where(xp.isneginf(arr), lo - 1, out)
|
|
130
|
+
nan = xp.isnan(arr)
|
|
131
|
+
if any_true(nan):
|
|
132
|
+
finite_first = sort(xp.where(finite, arr, xp.inf), 1)
|
|
133
|
+
n_finite = finite.sum(1)[:, None, :]
|
|
134
|
+
pick = astype(random(arr.shape, arr.dtype, arr) * n_finite, 'int64')
|
|
135
|
+
out = xp.where(nan, take_along_axis(finite_first, pick, 1), out)
|
|
136
|
+
eps = 1e-5 if '32' in str(arr.dtype) else 1e-10
|
|
137
|
+
return out + eps * random(out.shape, arr.dtype, out)
|
|
138
|
+
|
|
139
|
+
def flip(X, axis=None):
|
|
140
|
+
xp, _ = get_backend(X)
|
|
141
|
+
if axis is None or axis == () or axis == []:
|
|
142
|
+
return X
|
|
143
|
+
|
|
144
|
+
if isinstance(axis, int):
|
|
145
|
+
axis = (axis,)
|
|
146
|
+
else:
|
|
147
|
+
axis = tuple(axis)
|
|
148
|
+
|
|
149
|
+
if hasattr(X, "flip") and callable(X.flip):
|
|
150
|
+
return X.flip(axis)
|
|
151
|
+
|
|
152
|
+
return xp.flip(X, axis=axis)
|
|
153
|
+
|
|
154
|
+
def argsort(X):
|
|
155
|
+
xp, backend_name = get_backend(X)
|
|
156
|
+
return xp.argsort(X, dim=-1) if backend_name == 'torch' else xp.argsort(X, axis=-1)
|
|
157
|
+
|
|
158
|
+
def argpartition(X, mid):
|
|
159
|
+
"""Indices that put the `mid` smallest values of the last axis first."""
|
|
160
|
+
xp, backend_name = get_backend(X)
|
|
161
|
+
if backend_name == 'torch':
|
|
162
|
+
return xp.argsort(X, dim=-1)
|
|
163
|
+
else:
|
|
164
|
+
return xp.argpartition(X, mid - 1, axis=-1)
|
|
165
|
+
|
|
166
|
+
def take_along_axis(arr, idx, axis):
|
|
167
|
+
xp, backend_name = get_backend(arr)
|
|
168
|
+
if backend_name == 'torch':
|
|
169
|
+
return xp.take_along_dim(arr, idx.long(), dim=axis)
|
|
170
|
+
else:
|
|
171
|
+
return xp.take_along_axis(arr, idx, axis=axis)
|
|
172
|
+
|
|
173
|
+
def concatenate(arrays, axis = 0):
|
|
174
|
+
xp, name = get_backend(arrays[0])
|
|
175
|
+
if name == "torch":
|
|
176
|
+
return xp.cat(list(arrays), dim=axis)
|
|
177
|
+
return xp.concatenate(arrays, axis=axis)
|
|
178
|
+
|
|
179
|
+
def transpose(X, axes):
|
|
180
|
+
_, name = get_backend(X)
|
|
181
|
+
if name == "torch":
|
|
182
|
+
return X.permute(*axes)
|
|
183
|
+
return X.transpose(axes)
|