Skip to content

Point clouds and particles

A point cloud is a dataset whose cells are its points: one vertex cell per point, stored nowhere. LiDAR surveys, SPH particles, N-body particles and tracers from a simulation are all point clouds, with fields per point. The filters that work on points (threshold_points, probe's target, statistics, transform) work on them as on any dataset, and a point locator adds the filters that need each point's neighbours: cleaning and thinning, normals, clusters, deposition onto a grid, SPH interpolation and cosmology halos.

Task Algorithm
Make a cloud from positions and fields point_cloud
Find the points within a radius, or the k nearest PointLocator
Mark noise outliers (radius or statistical)
Thin to one point per voxel voxel_downsample
Estimate surface normals point_normals
Group points closer than a distance connected_components(radius=)
Count, sum, average, or take the extremes on a grid deposit
Density and kernel-weighted values of SPH particles sph_density, sph_interpolate
Halos of dark matter particles connected_components(radius=, periodic=), halo_catalog

The reference has every argument. Each filter is deterministic, with no atomics in its sums: the same input gives the same values every run.

Far from zero: the origin

A survey in projected coordinates sits hundreds of kilometres from zero, and single precision has about seven digits: at 4,580,000 m its spacing is half a metre. So a dataset's positions are an origin, three doubles on the host, plus values in the geometry's type. point_cloud given double-precision positions picks the lower corner of their box, in whole units, as the origin; the values are then offsets of at most the tile's size, and keep millimetres in single precision. Kernels see only the offsets. positions() gives them; world_positions() gives the world coordinates back in double precision.

Filters keep the origin, and anything given in world coordinates (a probe point, a seed, an implicit function, a viewpoint) is moved to it in double precision. A grid to deposit onto is rebased to the same origin, as below. Pieces whose positions meet share one origin.

Clean, thin and grid a LiDAR tile

import numpy as np

import tack
import tack.dataset as td

tack.set_default_device("cpu")

# A 100 m survey tile in UTM coordinates: terrain, a building, and noise.
rng = np.random.default_rng(1)
n = 40000
east, north = rng.uniform(0.0, 100.0, n), rng.uniform(0.0, 100.0, n)
height = 0.02 * east + rng.normal(0.0, 0.02, n)
roof = (np.abs(east - 50.0) < 10.0) & (np.abs(north - 50.0) < 10.0)
height[roof] += 8.0
height[:40] += rng.uniform(20.0, 40.0, 40)                 # birds and spurious returns
world = np.stack([east + 431000.0, north + 4580000.0, height + 120.0], axis=1)
tile = td.point_cloud(world, fields={"z": world[:, 2],
                                    "classification": np.where(roof, 6, 2).astype(np.uint8)})
print(tile.origin)                     # the box's lower corner in whole metres
assert np.abs(tile.world_positions() - world).max() < 1e-3  # millimetres, in f32

# Mark the outliers, keep the rest, thin to 0.5 m voxels.
marked = td.outliers(tile, radius=1.0, min_neighbors=3)
kept = td.threshold_points(marked, "outlier", 0, 0)
thin = td.voxel_downsample(kept, 0.5)
print(kept.num_points, "kept,", thin.num_points, "after thinning")
assert tile.num_points - kept.num_points >= 40

# Normals point up on the ground and the roof.
normals = td.point_normals(thin, k=12).fields["normals"].values.numpy()
assert np.median(normals[:, 2]) > 0.99

# A surface model: the highest return in each 2 m cell of a 2D image.
image = td.uniform_grid((51, 51), origin=(431000.0, 4580000.0), spacing=(2.0, 2.0))
image = image.rebased(tile.origin)                         # its corner exact, near zero
dsm = td.deposit(kept, image, "z", reduce="max").values.numpy().reshape(50, 50)
print(f"roof {dsm[25, 25]:.1f} m, ground {dsm[5, 5]:.1f} m")
assert abs(dsm[25, 25] - 129.0) < 0.2 and abs(dsm[5, 5] - 120.2) < 0.2

outliers marks points (outlier, 1 for noise) rather than removing them, as PDAL does: threshold_points keeps the rest, and the marks stay for inspection. The radius test marks points with fewer than min_neighbors others within radius, as VTK's vtkRadiusOutlierRemoval; outliers(data, k=8) is the statistical test instead, a mean distance to the k nearest more than deviations standard deviations above the mean, as VTK's vtkStatisticalOutlierRemoval and PCL.

voxel_downsample keeps one point per occupied voxel, of exactly the size asked: the voxel's centroid (floating-point fields averaged, integer ones such as a classification taken from its first point), its first point, or its point nearest the centroid (keep=). point_normals fits each point's neighbourhood, its k nearest or those within a radius, and signs the normal upward or toward a viewpoint.

deposit puts each point in the image cell holding it (method="nearest", a field on the cells), or spreads it over the cell's corners by trilinear weights ("cic", cloud in cell, on the points). Without a field it counts; with one it takes the "sum", "mean", "min" or "max": the highest return per cell is a surface model, the lowest ground return a terrain model. Cells nothing reaches hold 0 for counts and sums, NaN otherwise.

An image's own coordinates are in the geometry's type too, so one cornered at 431,000 m would be half a metre coarse in single precision at 4,580,000 m. rebased(tile.origin) keeps it an image and moves its corner next to zero in double precision, in the tile's frame.

Neighbours, directly

The filters above share one structure, which you can use yourself:

locator = td.PointLocator(tile)                      # uniform bins, sorted by bin
offsets, ids = locator.neighbors(1.0)                # within 1 m of each point (CSR)
nearest, d2 = locator.nearest(8)                     # the 8 nearest, nearest first

neighbors and nearest take other query points too, world positions or offsets. A filter given no locator uses the one the dataset's topology keeps, so several filters on one cloud bin it once; a locator is refused once the positions change. Its device class (locator.grid()) gives your own kernels the bins.

SPH particles

import numpy as np

import tack
import tack.dataset as td

tack.set_default_device("cpu")

# Particles of equal mass on a jittered lattice filling the unit cube: density 1.
rng = np.random.default_rng(2)
side = np.arange(0.0, 1.0, 0.05) + 0.025
x = np.stack(np.meshgrid(side, side, side, indexing="ij"), axis=-1).reshape(-1, 3)
x += rng.uniform(-0.005, 0.005, x.shape)
particles = td.point_cloud(x.astype(np.float32), origin=(0.0, 0.0, 0.0),
                           fields={"h": np.full(len(x), 0.06), "mass": np.full(len(x), 0.05**3),
                                   "T": 300.0 + 10.0 * x[:, 0], "one": np.ones(len(x))})
particles = td.sph_density(particles)                       # adds "density"
inner = np.all((x > 0.2) & (x < 0.8), axis=1)
rho = particles.fields["density"].values.numpy()
assert abs(np.median(rho[inner]) - 1.0) < 0.02

# Resample onto an image. The Shepard sum says how well the particles cover
# each point; normalize divides by it, which keeps a constant exact out to the
# particles' edge, where the plain sum falls off.
image = td.uniform_grid((11, 11, 11), origin=(0.0, 0.0, 0.0), spacing=(0.1, 0.1, 0.1))
out = td.sph_interpolate(particles, image, fields=["T", "one"], normalize=True)
T, one = (out.fields[name].values.numpy() for name in ("T", "one"))
shepard = out.fields["shepard"].values.numpy()
print(f"coverage {shepard.min():.2f} at a corner, {np.median(shepard):.2f} inside")
assert np.abs(one - 1.0).max() < 1e-5
inside = np.all((image.positions() > 0.15) & (image.positions() < 0.85), axis=1)
assert np.abs(T - (300.0 + 10.0 * image.positions()[:, 0]))[inside].max() < 0.05

sph_interpolate sums (m_j / rho_j) A_j W(|x - x_j|, h_j) over the particles reaching each target point (the scatter form, each particle out to its kernel's support), onto any dataset's points: an image, a slice, another cloud, or an array of world positions. kernel is "cubic" (the default), "quartic", "quintic" or "wendland", VTK's, normalized in dimension dimensions; the results agree with VTK's vtkSPHInterpolator to float rounding. Smoothing length, mass and density are point fields, by name, or one number for all. sph_density computes the density (the gather form, SPLASH's) when a code does not write it.

Halos in a periodic box

import numpy as np

import tack
import tack.dataset as td

tack.set_default_device("cpu")

# Dark matter particles in a periodic box of side 50: three halos, one of them
# across a face of the box, and a smooth background.
rng = np.random.default_rng(3)
box = 50.0
centres = np.array([[10.0, 10.0, 10.0], [30.0, 25.0, 40.0], [49.5, 20.0, 20.0]])
parts = [c + rng.normal(0.0, 0.4, (n, 3)) for c, n in zip(centres, (3000, 1500, 800))]
x = np.concatenate(parts + [rng.uniform(0.0, box, (20000, 3))]) % box
v = rng.normal(0.0, 1.0, x.shape)
particles = td.point_cloud(x.astype(np.float32), fields={"v": v}, origin=(0.0, 0.0, 0.0))

# Friends of friends: particles closer than the linking length are one halo.
linking = 0.2 * box / len(x) ** (1 / 3)
fof = td.connected_components(particles, radius=linking, min_size=100, periodic=box)
catalog = td.halo_catalog(fof, mass=1.0e10, velocity="v", periodic=box)
counts = catalog.fields["count"].values.numpy()
print(catalog.num_points, "halos of", sorted(counts.tolist()), "particles")
print(catalog.positions())                                  # their most bound particles
assert catalog.num_points == 3 and counts.min() > 700
gap = catalog.positions()[:, None, :] - centres[None, :, :]
gap -= box * np.round(gap / box)                            # across the box's faces
assert (np.linalg.norm(gap, axis=2).min(axis=1) < 0.5).all()

With periodic (a box's side, its three sides, or two corners), distances are to the nearest image, so a halo across a face is one halo. Friends of friends is connected_components with the linking length as radius; min_size drops the small groups, renumbering the rest. halo_catalog gives one point per halo, at its most bound particle (center="com" for the centre of mass), with its count, mass, centre of mass, the most bound particle's index and potential, the mass-weighted mean velocity and the velocity dispersion, and its radius. The most bound particle is exact for every halo: large halos are pruned by bounds, not estimated. On ParaView's HACC test data the halos, their counts and their most bound particles are those of ParaView's ANL halo finder, which counts the halos across a face of the box twice.

Point clouds in pieces

A cloud cut into pieces, by a simulation's ranks or a survey's tiles, needs each piece's neighbours from the others. ghost_points gives them, and clusters, catalogues, outliers, voxels and deposits take the pieces: see Point clouds in pieces.