Point Clouds
terrio reads and writes lidar and laser scan point clouds in pure Rust:
| Name | Format | Read | Write | Extensions |
|---|---|---|---|---|
| LAS | ASPRS LAS 1.0 to 1.4, point formats 0 to 10, full waveforms | ✓ | ✓ | las |
| LAZ | LASzip-compressed LAS (chunked, layered, and LASzip 1.x's unchunked compressor) | ✓ | ✓ | laz |
| COPC | Cloud Optimized Point Cloud | ✓ | ✓ | copc.laz |
| EPT | Entwine Point Tile (LASzip, binary and Zstandard tiles), local or remote | ✓ | ✓ | ept.json |
| PLY | Polygon File Format, ASCII and binary (the vertices) | ✓ | ✓ | ply |
| E57 | ASTM E2807 laser scans (every scan, posed) | ✓ | ✓ | e57 |
Every dimension is read, extra bytes included (every type, scaled, arrays, several extra-bytes
records), with the VLRs and EVLRs and the CRS from the OGC WKT record or the GeoTIFF GeoKeys
(compound CRSs are split into their horizontal and vertical parts). Points are held in columns,
one typed array per dimension, named as laspy names them: x, y, z (with the scale and
offset applied), intensity, return_number, number_of_returns, classification,
scan_angle_rank or scan_angle, gps_time, red, green, blue, nir...
LASzip comes from the pure-Rust laz crate, the codec of laspy's
lazrs backend; terrio reads and writes the LAS container and the COPC structures itself. E57
files are read and written with the pure-Rust e57 crate, and
triangulations come from delaunator.
Dimensions that are not LAS's are named as PDAL names them, in snake case: normal_x,
spherical_range, origin_id. PLY properties named like PDAL's dimensions, in any case (red,
Intensity, returnnumber, nx), take terrio's names; others keep theirs.
EPT, local or remote
An EPT dataset is an octree of tiles with its hierarchy in JSON pages. terrio reads it as PDAL's
readers.ept does: a read walks the hierarchy through the nodes that overlap --bounds, down
to the depth --resolution needs (or --max-level), and fetches the tiles it selects in
parallel. Remote datasets (https://, s3://) are read in place:
terrio info s3://usgs-lidar-public/WI_8County_Columbia_2020/ept.json # 11.5 billion points
terrio convert https://s3-us-west-2.amazonaws.com/usgs-lidar-public/WI_8County_Columbia_2020/ept.json \
sub.laz --bounds -9951440.5 5382600 -9951140.5 5382900 --resolution 2
# the same 347 376 points as PDAL's readers.ept, in 10 s
Writing an EPT dataset (an output named ept.json, or an ept:// path) builds the same octree
as a COPC file's (a cube around the points, each node an even sample of one point per cell of a
128³ grid, down to nodes of at most 100 000 points), in bounded memory, and writes each node as a
LASzip file (Entwine's default), binary records in the schema's order or Zstandard-compressed
records (--ept-data-type laszip|binary|zstandard), the hierarchy as a JSON page and ept.json
with PDAL's dimension names:
terrio convert madison.copc.laz madison-ept/ept.json # 4.07 million points, 12 s
terrio convert tile.laz tile-ept/ept.json --ept-data-type binary
PDAL's readers.ept reads the result as it reads the source, every dimension of every point
(binary tiles: x, y and z as PDAL 2.9 rounds them; Zstandard tiles need a PDAL built with
Zstandard).
PLY and E57
PLY files are read and written as PDAL's readers.ply and writers.ply do: x, y and z as
float64, other properties with their PLY type; terrio writes PDAL's names (returnnumber,
gpstime) and sized types (uint8), in ASCII by default (--ply-encoding
binary_little_endian for binary). Unlike PDAL's ASCII writer, which keeps 6 significant digits
by default, terrio writes as many digits as needed to read the same values back
(--ply-precision N for N decimals).
E57 files are read as PDAL's readers.e57 reads them: the points of every scan with its pose
applied, x, y and z from spherical coordinates when needed, colours and intensities scaled
to 16 bits by 65535 / (limit max - limit min) (colours of 0 to 255 become 0 to 65535). Written
E57 files have one scan, intensities and colours of 0 to 65535, normals in the nor extension
and the other dimensions in terrio's own.
Larger than memory
terrio convert (to every point cloud format: LAS, LAZ, COPC, PLY, E57 and EPT), terrio info
--stats, terrio grid (the statistics), terrio filter range and terrio filter expression
read and write points in chunks (--chunk-points, a million by default), so their memory does
not grow with the file. On a 3.05 GB LAS file of 101.7 million points:
| Command | Time | Peak memory |
|---|---|---|
terrio info big.las --stats |
18.5 s | 66 MB |
terrio convert big.las big.laz |
139 s | 117 MB |
terrio convert big.laz big.copc.laz |
267 s | 251 MB |
terrio grid big.las dsm.tif --tr 5 --stat max |
25 s | 769 MB (the 5001 × 5001 grid) |
terrio filter range big.laz ground.laz --limits "Classification[2:2]" |
73 s | 120 MB |
PDAL writers.gdal, the same grid (identical) |
52 s | 6.3 GB |
COPC files and EPT datasets written in chunks spill the points to a temporary file (in TMPDIR),
then build the octree one node of a coarse level at a time: the octree is the one built in
memory. E57 files are written packet by packet as the points come (converting the 4.07 million
points of USGS 3DEP Madison to E57 takes 59 MB, against 207 MB when held whole). In Rust,
PointCloudReader::chunks and PointWriter do the same; in Python, PointCloudFile.chunks()
and PointCloudWriter.
Waveforms
LAS point formats 4, 5, 9 and 10 locate each point's waveform in the waveform data packets, in
the file or in a .wdp file next to it. terrio reads the wave packet descriptors and a point's
samples (PointCloudReader::waveform, PointCloudFile.waveform in Python), and keeps the
packets when the points are written to LAS or LAZ (and the .wdp file next to a converted file).
Filters
terrio filter (and the library's pointcloud::filters, Python's and JavaScript's
PointCloud methods) runs PDAL's filters, with PDAL's options and defaults:
| terrio | PDAL | What it does |
|---|---|---|
filter smrf |
filters.smrf |
ground classification (Simple Morphological Filter): last and only returns become 2 (ground) or 1 |
filter pmf |
filters.pmf |
ground classification (Progressive Morphological Filter) |
filter csf |
filters.csf |
ground classification (Cloth Simulation Filter) |
filter elm |
filters.elm |
low noise (Extended Local Minimum) becomes 7 |
filter outlier |
filters.outlier |
statistical or radius outliers become 7 (noise); --remove drops them |
filter hag_nn |
filters.hag_nn |
heights above the nearest ground points (HeightAboveGround) |
filter hag_delaunay |
filters.hag_delaunay |
heights above a triangulation of the nearest ground points |
filter range |
filters.range |
the points within ranges, such as Classification[2:2],Z[0:100) |
filter expression |
filters.expression |
the points for which an expression holds, such as Classification == 2 && Z > 100 |
filter assign |
filters.assign |
values assigned by range (Classification[1:1]=2) or expression (Z = Z * 0.3048 WHERE Classification == 2) |
filter crop |
filters.crop |
the points in boxes, polygons (WKT or GeoJSON), circles or spheres, or outside them |
filter decimation |
filters.decimation |
every n-th point |
filter merge |
filters.merge |
several point clouds as one |
filter splitter |
filters.splitter |
squares of a grid (a file each with # in the output name) |
filter chipper |
filters.chipper |
squarish chips of at most --capacity points |
filter sample |
filters.sample |
Poisson sampling: no two points closer than --radius |
filter voxeldownsize |
filters.voxeldownsize |
the first point of each voxel (moved to its centre in center mode) |
filter voxelcenternearestneighbor |
filters.voxelcenternearestneighbor |
the point nearest each voxel's centre |
filter normal |
filters.normal |
normals and curvature (NormalX, NormalY, NormalZ, Curvature) |
filter cluster |
filters.cluster |
Euclidean clusters (ClusterID) |
They give PDAL's results exactly, PDAL's quirks included (filters.sample takes the first
point's y as the origin's z; SMRF fills empty cells with the mean of the 8 nearest as
nanoflann orders them; PMF and ELM find a point's cell from the floor of its offset before
dividing by the cell size; filters.decimation keeps the points it keeps when streaming, as
pdal translate runs it; filters.normal's refine changes nothing in PDAL 2.9 and is left
out). Reprojection (filters.reprojection) is terrio convert --t-srs.
terrio filter smrf tile.laz classified.laz
terrio filter outlier tile.laz clean.laz --method statistical --mean-k 8 --multiplier 2 --remove
terrio filter voxeldownsize tile.laz thin.laz --cell 1 --mode first
terrio filter csf tile.laz classified.laz --cloth-resolution 0.5
terrio filter hag_delaunay classified.laz hag.laz
terrio filter expression tile.laz high.laz --expression "Classification == 2 && Z > 100"
terrio filter assign tile.laz fixed.laz --value "Classification = 7 WHERE Z < -10"
terrio filter crop tile.laz part.laz --polygon "POLYGON((0 0, 100 0, 50 80, 0 0))"
terrio filter splitter tile.laz "tiles/tile_#.laz" --length 500
terrio filter merge a.laz b.laz merged.laz
COPC, local or remote
A COPC file is a LAZ file whose points are organized in an octree, each node a separately compressed chunk, with the hierarchy of nodes in pages. terrio reads COPC files in place: opening one fetches its header and root hierarchy page, and a read fetches only the nodes that intersect the bounds asked for, down to the level asked for, with HTTP range requests (in as few requests as possible).
terrio info https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz
# 2 requests, 120 kB of an 81 MB file: header, CRS, dimensions, and the octree's depth and
# points per level
terrio convert https://s3.amazonaws.com/hobu-lidar/autzen-classified.copc.laz sub.laz \
--bounds 637000 851000 637500 851500 --resolution 7.27
# 8 requests, 3.7 MB: the same 18901 points as PDAL's readers.copc
The levels read are chosen with --max-level (0 is the root), --resolution (the levels needed
for a point spacing, as PDAL's readers.copc chooses them) or --max-points (the deepest level
that keeps the count within it). Points come in file order.
Writing COPC builds the octree: each node keeps an even sample of its points (one per cell of a 128³ grid), down to nodes of at most 100 000 points, which keep all theirs. Within a node, points are in GPS time order, which LASzip compresses best.
Command line
terrio info tile.laz # header, CRS, dimensions, VLRs, points by return
terrio info tile.laz --stats # and classification counts, each dimension's range
terrio convert tile.las tile.laz # LAS <-> LAZ <-> COPC
terrio convert tile.laz tile.copc.laz
terrio convert tile.laz ground.laz --class 2 --returns last,only
terrio convert tile.laz sub.las --bounds 637000 851000 637500 851500 --z-range 0 500
terrio convert tile.laz wgs84.laz --t-srs EPSG:4326
terrio convert tile.las tile14.las --point-format 6 --las-version 1.4 --scale 0.001 0.001 0.001
terrio convert tile.laz points.gpkg --dims z,intensity,classification # a layer of 3D points
terrio grid tile.laz dsm.tif --tr 1 --stat max
terrio grid tile.laz dtm.tif --tr 1 --stat idw --class 2 --window-size 3
terrio grid tile.laz density.tif --tr 5 --stat count --bin
terrio grid tile.laz dtm.tif --tr 1 --class 2 --method linear # a TIN, as gdal_grid -a linear
terrio grid tile.laz nn.tif --tr 1 --method nearest # as gdal_grid -a nearest
terrio convert scan.e57 scan.laz # E57, PLY, EPT to LAS and back
terrio convert tile.laz tile.ply --ply-encoding binary_little_endian
terrio grid grids points into a raster as PDAL's writers.gdal does: each point counts towards
every cell whose centre is closer than --radius (by default the cell diagonal), or with --bin
towards its own cell only; the statistics (--stat, one band each) are min, max, mean,
idw (inverse distance weighted, power --power), count and stdev. Empty cells are nodata,
or are filled from their neighbours within --window-size cells. The grid starts at the points'
minimum (or --te), as PDAL's does.
--method linear interpolates linearly in the triangles of the points' Delaunay triangulation (a
TIN), as gdal_grid -a linear; cells outside it take a near point's value as GDAL finds it (the
nearest of the points in the smallest square around the cell that has some), within --radius,
or nodata with --radius 0. --method nearest takes the nearest point's value, as
gdal_grid -a nearest (within the square of half-side --radius with one).
Library
```rust,no_run use terrio::pointcloud::{self, PointFilter, PointReadOptions};
let mut f = pointcloud::open_points("lidar.copc.laz")?; let options = PointReadOptions { filter: PointFilter { classes: vec![2], ..Default::default() }, max_level: Some(4), ..Default::default() }; let ground = f.read(&options)?; let z: &[f64] = ground.coords("z").unwrap(); pointcloud::write_points(&ground, "ground.laz", &Default::default())?; let grid = pointcloud::grid::GridOptions { resolution: 1.0, ..Default::default() }; let dtm = pointcloud::grid::grid(&ground, &grid)?; // a RasterDataset terrio::write_raster_dataset(&dtm, "dtm.tif", &Default::default())?;
Ok::<(), terrio::Error>(())
`pointcloud::vector::to_layer` and `from_layer` turn points into vector layers and back, and
`from_raster` turns raster cells into points.
## Python
```python
import terrio
f = terrio.open_points("https://host/lidar.copc.laz")
f.format, f.point_count, f.crs, f.bounds, f.dimensions
points = f.read(bounds=(x0, y0, x1, y1), max_level=4, classes=[2])
points["z"] # numpy float64; points["intensity"] is uint16...
points.to_numpy() # a structured array
points.write("ground.copc.laz")
dem = points.grid(1.0, stat="idw") # a Raster
tin = points.grid(1.0, method="linear") # a TIN, as gdal_grid
gdf = points.to_layer().to_geopandas() # points with attributes
ground = points.smrf() # PDAL's filters.smrf
clean = points.outlier().range("Classification![7:7]")
hag = points.csf().hag_nn()["height_above_ground"]
high = points.expression("Classification == 2 && Z > 100")
tiles = points.splitter(500.0) # a list of PointClouds
both = terrio.merge_points([points, other])
points.write("tile-ept/ept.json") # an EPT dataset
# Larger than memory: chunks in, chunks out.
f = terrio.open_points("huge.laz")
with terrio.PointCloudWriter("huge.copc.laz", f.template()) as w:
for chunk in f.chunks(1_000_000):
w.write(chunk)
dsm = terrio.grid_points("huge.laz", 1.0, stat="max") # in bounded memory
JavaScript
const pc = readPoints(lazBytes, "tile.laz", { classes: [2] });
pc.get("z"); // Float64Array
pc.toBytes("tile.copc.laz"); // LAS, LAZ or COPC bytes
pc.grid(1, { stat: ["max"] }); // a raster Dataset
pc.grid(1, { method: "linear" }); // a TIN
pc.smrf().range("Classification[2:2]");
readPoints(plyBytes, "scan.ply"); // PLY and E57 bytes too
Compared with laspy and PDAL
scripts/compare_pointclouds.py reads every test file (the PDAL and laspy test suites, USGS 3DEP
lidar over Madison as COPC, and laspy-written files of every point format with every extra-bytes
type) with terrio and compares:
| Check | Reference | Result |
|---|---|---|
| Every value of every dimension, 55 files | laspy 2.7 | identical |
| Header fields, counts by return, VLR and EVLR counts | laspy | identical |
| Count, version, format, scale, offset, bounds, EPSG code, COPC | pdal info --metadata |
identical |
| LAS, LAZ and COPC written by terrio, read back | laspy, PDAL readers.las, readers.copc |
identical values, 294 checks |
| COPC reads by bounds, resolution and both | PDAL readers.copc |
the same points |
| Gridding: min, max, mean, IDW, count, stdev; radius, power, bin mode, window filling | PDAL writers.gdal |
identical to 1e-9 |
| Reprojection (same datum: NAD83 to NAD83 geographic and Albers, RGF93 to WGS 84) | PDAL filters.reprojection |
within 2e-7 degrees, 1 mm |
| PLY (7 files), E57 (13 files), EPT (LASzip, binary, Zstandard): every value | PDAL readers.ply, readers.e57, readers.ept |
identical |
| EPT reads by bounds and resolution, local and USGS 3DEP (11.5 billion points) | PDAL readers.ept |
the same points |
| PLY and E57 written by terrio | PDAL readers.ply, readers.e57 |
identical |
| EPT datasets written by terrio (LASzip and binary tiles; 4.07 million points of USGS 3DEP Madison) | PDAL readers.ept |
identical |
| Waveform samples; packets kept through LAS and LAZ | the file's bytes (LASlib's offsets) | identical |
| SMRF, outliers (statistical, radius), range, sample, voxels, on autzen and USGS 3DEP Madison (4.07 million points) | PDAL's filters | identical classes and points |
| PMF, CSF, ELM, HAG (nearest, Delaunay), crop, decimation, expression, assign, merge, splitter, chipper, normals, clusters, on autzen and half a million points of USGS 3DEP Madison | PDAL's filters | identical classes, heights, tiles, cluster ids and points; normals within 1e-6 |
| TIN (linear) grids | gdal_grid -a linear |
99.91% of cells identical (see below) |
| Nearest-neighbour grids | gdal_grid -a nearest |
identical but for ties |
| A grid of 101.7 million points, read in chunks | PDAL writers.gdal |
identical |
PDAL 2.9 rounds the coordinates of binary EPT tiles to integers (it writes the scaled values
back into their integer fields); terrio keeps them (and matches PDAL once rounded the same way).
TIN grids differ from gdal_grid's where several points share an x and y (qhull and terrio's
triangulation keep different ones), and, at large coordinates, where qhull's triangulation loses
precision and is not Delaunay: on 568 119 points of USGS 3DEP lidar in state plane feet, 98.8% of
the cells are identical, and 99.996% once the coordinates are moved near the origin.
terrio also reads LASzip 1.2's first, unchunked compressor, which laspy's lazrs and PDAL's
lazperf cannot read. PDAL does not read point formats 4, 5, 9 and 10 (waveforms); those files are
compared with laspy only. terrio is faster than PDAL on these filters (SMRF on 4.07 million
points: 48 s, PDAL 164 s; statistical outliers: 0.8 s, PDAL 21 s; CSF on autzen: 11.5 s, PDAL
22 s).