Important
Consolidated into SurveySuite
This engine has been consolidated into the unified SurveySuite monorepo. Active development, bug fixes, releases, and unified testing now live in survey-suite. This repository is preserved as an archive.
Point-cloud processing engine for surveying: LiDAR-style point modelling, voxel thinning, grid-minimum ground classification, DEM gridding (minimum/maximum/IDW), and grid-based cut/fill volumes.
Pure Python, zero dependencies, no UI imports, no file I/O. Part of the survey-suite engine collection.
pip install -e .Requires Python ≥ 3.9. No third-party dependencies.
from pointcloud import (
PointCloud, classify_ground_grid, grid_minimum, volume_grid, GROUND,
)
# Load points however you like -- tuples, lists, numpy rows, laspy records.
pts = [(x, y, z) for x, y, z in read_my_points()]
cloud = PointCloud(pts, crs="EPSG:32618")
print(cloud.bounds()) # (min_x, min_y, min_z, max_x, max_y, max_z)
print(cloud.describe()) # count, z stats, classification histogram
# Classify ground (ASPRS class 2), grid a DTM, compute stockpile volume.
ground = PointCloud(classify_ground_grid(pts, cell_size=2.0, threshold=0.15))
dtm = grid_minimum(ground.points, origin_x=0, origin_y=0,
pixel_size=1.0, width=200, height=200)
cut, fill, net = volume_grid(dtm, base_elevation=100.0, cell_area=1.0)
print(f"cut={cut:.1f} m3 fill={fill:.1f} m3 net={net:.1f} m3")Point(x, y, z, intensity=0.0, classification=1, return_number=1, number_of_returns=1)— immutable-by-convention dataclass for a single LiDAR-style point.xyz(p)— extract(x, y, z)from any point-like:Point, tuple/list, object with.x/.y/.zattributes, or numpy row.as_point(p)— coerce a point-like to a canonicalPoint.- ASPRS LAS classification constants:
CREATED(0),UNCLASSIFIED(1),GROUND(2),LOW_VEGETATION(3),MEDIUM_VEGETATION(4),HIGH_VEGETATION(5),BUILDING(6),NOISE(7),WATER(9),BRIDGE_DECK(17), plusCLASSIFICATION_NAMESmapping codes to labels.
PointCloud(points, crs="")— coerces inputs toPointon construction..bounds()→(min_x, min_y, min_z, max_x, max_y, max_z).describe()→CloudStats(count, z min/max/mean, per-class counts).crop(min_x, min_y, max_x, max_y)→ new cloud in a bounding box.filter_classification([GROUND])→ new cloud of matching classes.thin(voxel_size, keep="lowest"|"first")→ voxel-decimated copy.chunks(n)→ iterate in n-sized chunks (bounded memory)voxel_thin(points, voxel_size, keep="lowest")— standalone, single-pass O(n) decimation;"lowest"keeps the lowest-z point per cubic voxel.iter_chunks(points, n)— yield successive n-sized chunks.
classify_ground_grid(points, cell_size, threshold, ground_class=GROUND)— two-pass O(n) morphological filter: per-cell minimum z becomes the local ground estimate; points withinthresholdabove it are labelled ground. Returns a new list (inputs are never mutated); non-ground points keep their original classification. Best on gentle terrain; see Limitations.
All functions return plain row-major list[list[float]] grids; empty or
out-of-bounds cells are nan nodata.
grid_minimum(points, origin_x, origin_y, pixel_size, width, height)— lowest z per cell → DTM-style bare-earth surface.grid_maximum(...)— highest z per cell → DSM-style surface.interpolate_idw(points, origin_x, origin_y, pixel_size, width, height, power=2.0, radius=None)— inverse-distance-weighted interpolation. A point at (or ~at) a cell centre pins that cell. Withradius=Noneevery point influences every cell; otherwise cells with no neighbours insideradiusare nodata.
volume_grid(grid, base_elevation, cell_area)→(cut, fill, net)of a surface against a flat base (borrow-pit / stockpile method). Nodata cells skipped.volume_between(upper, lower, cell_area)→(cut, fill, net)between two co-registered grids, e.g. pre- vs. post-construction DEMs.
This engine is designed to sit between data capture and analysis:
- Point inputs are duck-typed. Pass plain
(x, y, z)tuples, lists, numpy rows, or laspy point records directly — no conversion layer needed.as_point()exists only when you want canonicalPointobjects. - Classification codes follow the ASPRS LAS standard, so classified output round-trips with real LiDAR tooling (laspy, PDAL, CloudCompare).
- DEM outputs feed
survey-rasterdirectly — a grid fromgrid_minimum/interpolate_idwis already the row-major 2D structure aRasterwraps, so the standard pipeline is: classify → grid →Raster(grid, transform, crs)→ indices, stats, hillshade. - Coordinates flow to
survey-geodesyfor datum shifts, projections, and UTM conversion before or after processing; keep one CRS string on thePointCloudso provenance travels with the data. - No file I/O by design. LAS/LAZ readers (laspy) and writers live in a thin adapter outside the engine; the engine only ever sees point sequences and grids.
- Single-pass algorithms where it counts.
voxel_thin,classify_ground_grid, and min/max gridding are all O(n) with no spatial index — a 10 M-point cloud thins and classifies in one streaming pass. - Thin before you interpolate. IDW is O(cells × points) brute force in
0.1.0; run
voxel_thinfirst and it becomes cheap. A KD-tree/indexed neighbour search is the planned upgrade (see Roadmap). - Tile large areas. Grids are plain lists — process a site in tiles with
crop()+ per-tilegrid_minimum(), or stream points throughiter_chunks()/cloud.chunks(n)for bounded-memory pipelines. - Grid-first workflows. The standard LiDAR scaling strategy is rasterize early: classify and grid once, then do all downstream analysis (volumes, differencing) on grids, which are orders of magnitude smaller than the source cloud.
- Duck-typed points mean zero-copy numpy use. Pass numpy structured
arrays or memory-mapped point buffers straight in; only
as_pointcopies, and only where you ask for it.
classify_ground_gridis a first-order filter: it assumes terrain is locally smooth withincell_size. Steep terrain, dense low vegetation, and bridges over ground will misclassify. Progressive morphological or surface-based filters are planned.interpolate_idwis brute force; a spatial index (KD-tree) will make it scale to dense clouds.- Planned: TIN construction and TIN-based volumes, normal estimation, intensity normalization, height-above-ground normalization, and laspy/PDAL I/O adapters (outside the engine).
python -m unittest discover -s tests32 tests covering the point model, thinning/chunking, ground classification on a synthetic scene, DEM gridding (min/max/IDW against hand-computed values), and volumes. All pass.
survey-pointcloud/
├── src/pointcloud/
│ ├── __init__.py # public API
│ ├── point.py # Point model, ASPRS codes, duck-typed accessors
│ ├── cloud.py # PointCloud, voxel_thin, iter_chunks, stats
│ ├── classify.py # grid-minimum ground classification
│ ├── dem.py # grid_minimum / grid_maximum / interpolate_idw
│ └── volumes.py # volume_grid / volume_between (cut/fill)
├── tests/test_pointcloud.py
├── pyproject.toml
├── CHANGELOG.md
└── LICENSE
MIT — see LICENSE.