Computing Geometric Features for Classification
TL;DR: Chain filters.hag_nn, filters.covariancefeatures at a small and a large knn (renaming the first set with filters.ferry so the second does not overwrite it), filters.normal, and a return-ratio column computed in Python; write the result once as LAZ with typed extra_dims and reuse it for every training run.
# Context and Motivation
This guide is part of Machine Learning Point Classification for LiDAR. A classifier can only separate what its features describe, and the most common reason a point classifier plateaus is a feature set computed at one neighbourhood scale. At a small scale, a roof edge and a branch look alike; at a large scale, a narrow wall and a hedge look alike. Computing the same descriptors at two scales lets the model see both the local surface and its surroundings, and it is usually the single largest accuracy gain available after height above ground.
There is a PDAL-specific wrinkle. filters.covariancefeatures always writes to the same dimension names — Linearity, Planarity, Scattering, Verticality. Running it twice overwrites the first result unless you move it aside, which is what filters.ferry is for.
# Prerequisites and Assumptions
- PDAL 2.4+ and Python bindings; NumPy.
- Ground classified to class 2; noise classes 7 and 18 present or already removed.
- Enough memory for roughly 100 extra bytes per point once all features are attached (see the table below).
# Step-by-Step Implementation
# Step 1 — Normalize heights
filters.hag_nn with count: 2 writes HeightAboveGround, the most important single feature.
# Step 2 — Compute small-scale covariance features and move them aside
Run filters.covariancefeatures with knn: 10, then filters.ferry to copy each feature to a suffixed name. Ferry copies rather than renames, so the originals will be overwritten by the next stage, which is what we want.
{ "type": "filters.ferry",
"dimensions": "Linearity=>Linearity_s, Planarity=>Planarity_s, Scattering=>Scattering_s, Verticality=>Verticality_s" }# Step 3 — Compute large-scale covariance features
Run filters.covariancefeatures again with knn: 40; its outputs remain under the default names and serve as the large-scale set.
# Step 4 — Add normals and curvature
filters.normal with knn: 12 adds NormalZ (surface orientation) and Curvature (local roughness).
# Step 5 — Add derived columns in Python and cache
Compute ReturnRatio = ReturnNumber / NumberOfReturns in NumPy, then write everything to LAZ with explicit float types for compactness.
# Complete Working Example
"""Compute a two-scale feature set and cache it as LAZ extra bytes."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import numpy.lib.recfunctions as rfn
import pdal
ABOVE = "Classification != 2 && Classification != 7 && Classification != 18"
FEATURES = ["Linearity", "Planarity", "Scattering", "Verticality"]
def feature_pipeline(src: Path, small: int = 10, large: int = 40) -> list[dict]:
ferry = ", ".join(f"{f}=>{f}_s" for f in FEATURES)
return [
{"type": "readers.las", "filename": str(src)},
{"type": "filters.hag_nn", "count": 2},
{"type": "filters.covariancefeatures", "knn": small, "threads": 4,
"feature_set": "Dimensionality", "where": ABOVE},
{"type": "filters.ferry", "dimensions": ferry},
{"type": "filters.covariancefeatures", "knn": large, "threads": 4,
"feature_set": "Dimensionality", "where": ABOVE},
{"type": "filters.normal", "knn": 12, "always_up": True, "where": ABOVE},
]
def compute(src: Path, dst: Path) -> np.ndarray:
p = pdal.Pipeline(json.dumps({"pipeline": feature_pipeline(src)}))
p.execute()
a = p.arrays[0]
ratio = (a["ReturnNumber"] / np.maximum(a["NumberOfReturns"], 1)).astype("f4")
a = rfn.append_fields(a, "ReturnRatio", ratio, usemask=False)
keep = (["HeightAboveGround", "ReturnRatio", "NormalZ", "Curvature"]
+ FEATURES + [f"{f}_s" for f in FEATURES])
extra = ",".join(f"{k}=float" for k in keep)
pdal.Writer.las(filename=str(dst), minor_version=4, dataformat_id=6,
forward="all", extra_dims=extra).pipeline(a).execute()
print(f"{dst.name}: {len(a)} points, {len(keep)} feature dimensions")
return a
if __name__ == "__main__":
compute(Path("labelled/tile_6011_4402.laz"), Path("features/tile_6011_4402.laz"))# Key Parameter Table
| Feature | Stage | Scale | Why it helps |
|---|---|---|---|
HeightAboveGround |
filters.hag_nn |
— | Separates vegetation strata; places roofs and wires in bands |
*_s features |
covariance, knn 10 | small | Local surface: edges, wires, leaves |
| unsuffixed features | covariance, knn 40 | large | Context: roof interior around an edge, crown around a branch |
NormalZ |
filters.normal |
knn 12 | Flat roofs and roads versus walls and slopes |
Curvature |
filters.normal |
knn 12 | Rough vegetation versus smooth surfaces |
ReturnRatio |
NumPy | per pulse | Last and single returns versus penetrable targets |
Memory: each float feature costs 8 bytes per point in PDAL’s table and 4 bytes on disk when written as float. Twelve features add about 96 bytes per point in memory — plan tile sizes accordingly.
# Verification
- Schema check.
pdal info --schema features/tile_6011_4402.lazmust list every feature name; a missing one means a writer option or awhereclause excluded it. - Scales differ. The correlation between
PlanarityandPlanarity_sshould be clearly below 1. Near-perfect correlation means the ferry step ran after the second stage, copying large-scale values. - No NaNs in features for above-ground points. Points excluded by
wherecarry zeros; points with too few neighbours can carry NaN.
import numpy as np
r = np.corrcoef(a["Planarity"][a["HeightAboveGround"] > 2], a["Planarity_s"][a["HeightAboveGround"] > 2])[0, 1]
assert r < 0.95, f"scales nearly identical (r={r:.2f}); check stage order"
for k in ("Planarity", "Planarity_s", "NormalZ"):
assert not np.isnan(a[k]).any(), f"NaN in {k}"# Gotchas and Edge Cases
Feature values depend on density. A model trained on features from 10 pts/m² data sees different distributions on 40 pts/m² data with the same knn. Either keep density consistent, thin to a common density before computing features, or use radius neighbourhoods.
where leaves zeros, not NaN. Ground points excluded from the covariance stage get 0 for every feature. That is fine as long as the model never sees ground points; if it does, zeros look like a strongly meaningful value.
Ferry after, never before. Ferrying before the first covariance stage copies empty columns; ferrying after the second copies large-scale values. The order in Step 2 is the only one that works.
Thread count and reproducibility. Feature values are deterministic for a given input and knn, but rounding of ties can differ with thread count on some builds. Pin threads along with the PDAL version for bit-identical reruns.
# Frequently Asked Questions
Why compute features at more than one scale?
A single neighbourhood size cannot see both local detail and surrounding context. Small neighbourhoods describe edges, wires and leaves; large ones describe the surface or volume those details belong to. Together they resolve confusions neither can alone.
How do I stop the second covariance stage overwriting the first?
Copy the first stage’s outputs to new names with filters.ferry immediately after it. The second stage then overwrites only the original names, and both sets survive.
Which features matter most?
Height above ground almost always ranks first, followed by scattering and planarity at one scale or the other. Return ratio and normal Z are valuable for specific confusions. Intensity is often the least transferable between flights.
Should I store features in the LAZ or in a separate table?
Storing them as typed extra bytes in LAZ keeps features and points together and is readable by PDAL and laspy. A Parquet table is faster for repeated training on sampled rows; many workflows keep both.
# Related
- Machine Learning Point Classification — the full training loop
- Training a Random Forest Point Classifier — using these features
- Evaluating Point Classification with a Confusion Matrix — measuring the gain from each feature
- Copying Dimensions with filters.ferry — the renaming trick in detail
- Detecting Planar Roofs with Covariance Features — the same features used with thresholds