LiDAR Classification and Feature Extraction in Python

Ground classification answers one question about every point — is it the bare earth or not? — and most LiDAR deliverables need a great deal more than that. An asset owner wants building footprints with roof heights, a utility wants every conductor span and every tree within striking distance of it, a forester wants stems per hectare and crown diameters, and a hydrologist wants lakes flattened and bridges removed from the terrain before water is routed across it. This section is for the LiDAR analysts, Python GIS developers and surveying teams who have to deliver those products from the same classified tiles. It covers the per-point geometric features PDAL can compute, the segmentation stages that group points into candidate objects, the rules and models that decide what each object is, and the checks that tell you whether the classification is good enough to ship.

From classified points to object layers A left-to-right flow. A ground-classified tile passes through height normalization, then per-point geometric features, then segmentation into candidate objects. Candidate objects branch to four products: building footprints, individual trees, power-line spans, and water and bridge surfaces. A feedback arrow runs from an accuracy check back to the feature step. classified tile ground = class 2 normalize height above ground features planarity, linearity segment ClusterID per object building footprints individual trees power-line spans water and bridges accuracy check per class retune every product starts from the same normalized, feature-rich cloud

# Why Feature Extraction Is Its Own Discipline

A LiDAR tile that has been through SMRF ground classification is already worth something: it yields a terrain model and, with a little more work, a surface model. But everything above the ground is still lumped together as “not ground”, and the questions people actually pay for — how many buildings, how tall, how close is that oak to the 132 kV line — live in that lump. Extracting them is a different kind of problem from ground filtering, for three reasons.

First, the unit of reasoning changes from the point to the object. SMRF can decide about each point by comparing it with an interpolated surface; deciding that a set of points is a roof requires knowing that they are coplanar, contiguous, elevated and large enough, which are properties of the set, not of any member. That is why this section leans so heavily on segmentation: until points are grouped, most object rules cannot even be expressed.

Second, the evidence is geometric and local. Intensity and return number help at the margins, but the dependable signal for most above-ground classes is the shape of each point’s neighbourhood — flat for a roof, linear for a wire, volumetric and scattered for a crown. PDAL computes those shape descriptors from the eigenvalues of each neighbourhood’s covariance matrix, and a surprisingly large share of practical classification is thresholding them sensibly.

Third, the output is usually vector or per-object, not a raster. A footprint layer, a tree table, a list of conductor spans with clearance violations: these are what people consume, and the point cloud is the evidence behind them. The pages here therefore end in GeoPackages and data frames as often as in LAS files.

The ASPRS classes you will be writing are covered in ASPRS classification codes: 6 for buildings, 3 to 5 for vegetation by height, 9 for water, 13 to 16 for the wire and tower family, 17 for bridge decks. Getting the codes right matters because every downstream consumer — a DTM builder, a GIS viewer, a client QA script — keys on them.

# Conceptual Architecture: Normalize, Describe, Group, Decide

Every workflow in this section follows the same four moves, in the same order, and most failures come from skipping or reordering one.

  1. Normalize heights. Compute HeightAboveGround with filters.hag_nn or filters.hag_delaunay so that “tall” means tall relative to the terrain, not relative to sea level. A two-storey house on a hillside and one on a plain then look alike.
  2. Describe neighbourhoods. Run filters.covariancefeatures, filters.normal or filters.eigenvalues to attach per-point shape descriptors — linearity, planarity, scattering, verticality, normal vectors.
  3. Group into candidates. Use filters.cluster (Euclidean) or filters.dbscan to give every point in a contiguous object the same ClusterID, usually after restricting the input to a height band.
  4. Decide per object. Aggregate the descriptors per ClusterID in pandas — median planarity, height range, footprint area, point count — and assign an ASPRS class or reject the group. Rules first; a trained model when rules plateau.

The order is not arbitrary. Features computed before normalization mix terrain slope into verticality. Segmentation before height filtering merges a roof with the lawn around it. And deciding per point instead of per object produces the speckle — isolated “building” points in a tree crown — that makes a classification look amateur however good its aggregate accuracy.

Three shapes, three descriptors Three panels. A linear neighbourhood along a wire has one dominant eigenvalue and high linearity. A flat neighbourhood on a roof has two dominant eigenvalues and high planarity. A volumetric neighbourhood inside a tree crown has three similar eigenvalues and high scattering. Each panel shows the eigenvalue bars beneath a sketch of the points. wire: linear roof: planar crown: scattered λ1 λ2 λ3 λ1 λ2 λ3 λ1 λ2 λ3 Linearity near 1 Planarity near 1 Scattering high

# Core Components

The stages below do almost all of the work in this section. None of them classifies anything on its own; they produce the evidence that your rules or your model will read.

Stage Writes Typical options Role
filters.hag_nn HeightAboveGround count: 2, max_distance: 0 Height normalization from nearest ground points
filters.hag_delaunay HeightAboveGround count: 10 Smoother normalization on sparse ground
filters.covariancefeatures Linearity, Planarity, Scattering, Verticality knn: 16, feature_set: "Dimensionality", threads: 4 Neighbourhood shape descriptors
filters.normal NormalX, NormalY, NormalZ, Curvature knn: 12, always_up: true Surface orientation for roof pitch and aspect
filters.eigenvalues Eigenvalue0..2 knn: 10 Raw eigenvalues when you want your own ratios
filters.approximatecoplanar Coplanar knn: 8, thresh1: 25, thresh2: 6 Quick boolean planarity test
filters.cluster ClusterID tolerance: 1.0, min_points: 50, is3d: true Euclidean connected-component segmentation
filters.dbscan ClusterID eps: 1.2, min_points: 10, dimensions: "X,Y,Z" Density-based segmentation that labels noise -1
filters.litree TreeID min_points: 50, min_height: 3.0, radius: 100 Point-based individual tree segmentation
filters.overlay any dimension datasource, column, dimension Burn vector attributes (footprints, water polygons) into points
filters.neighborclassifier Classification k: 9, candidate, domain Majority-vote smoothing of noisy labels

Two practical notes. knn is the parameter that matters most in every feature stage: too small and descriptors are noisy, too large and neighbourhoods straddle object boundaries. Scale it to point density — a value that works at 8 pts/m² is far too small at 60 pts/m². And every one of these stages needs a spatial index over the whole input, which means none of them streams; the implications are covered in which filters break streaming mode.

# Annotated Reference Pipeline

The pipeline below takes a ground-classified tile to the point where every candidate building has a ClusterID and every point carries the descriptors needed to decide. JSON has no comments, so the reasoning for each stage follows the block.

json
{
  "pipeline": [
    { "type": "readers.las", "filename": "tile_4710_5285.laz" },
    { "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]" },
    { "type": "filters.hag_nn", "count": 2 },
    { "type": "filters.range", "limits": "HeightAboveGround[2.5:120]" },
    {
      "type": "filters.covariancefeatures",
      "knn": 16,
      "threads": 4,
      "feature_set": "Dimensionality"
    },
    { "type": "filters.normal", "knn": 12, "always_up": true },
    {
      "type": "filters.cluster",
      "tolerance": 1.0,
      "min_points": 60,
      "is3d": true,
      "where": "Planarity > 0.6"
    },
    {
      "type": "writers.las",
      "filename": "tile_4710_5285_features.laz",
      "minor_version": 4,
      "dataformat_id": 6,
      "extra_dims": "all"
    }
  ]
}
  • Noise first. Classes 7 and 18 are removed before anything computes neighbourhoods; a single high-noise return above a roof can swing its planarity and pull the normal off vertical.
  • count: 2 for HAG. Averaging two ground neighbours smooths the normalization on rough terrain without the cost of a triangulation.
  • Height band 2.5 to 120 m. Below 2.5 m sit cars, hedges and garden sheds; above 120 m sit only birds and towers. Restricting the band before segmentation is what stops roofs merging with the ground around them.
  • knn: 16 for covariance. At roughly 10 to 20 pts/m², sixteen neighbours span about a metre — small enough to stay on one roof plane, large enough for a stable eigen-decomposition.
  • where: "Planarity > 0.6" on the cluster stage. Only planar points are grouped, so tree crowns never join a building segment even when they overhang it. Non-planar points pass through with ClusterID 0.
  • extra_dims: "all". Without it the features you just paid to compute are dropped at write time, which is the single most common surprise in this workflow.

# Python Integration

The same pipeline, driven from Python, becomes a function that returns a per-object table — the form in which classification decisions are easiest to write and to test.

python
"""Compute per-object building candidates from a ground-classified tile."""
from __future__ import annotations

import json
import logging
from pathlib import Path

import numpy as np
import pandas as pd
import pdal

log = logging.getLogger("features")


def feature_pipeline(src: Path, dst: Path, knn: int = 16) -> pdal.Pipeline:
    stages = [
        {"type": "readers.las", "filename": str(src)},
        {"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
        {"type": "filters.hag_nn", "count": 2},
        {"type": "filters.range", "limits": "HeightAboveGround[2.5:120]"},
        {"type": "filters.covariancefeatures", "knn": knn, "threads": 4,
         "feature_set": "Dimensionality"},
        {"type": "filters.normal", "knn": 12, "always_up": True},
        {"type": "filters.cluster", "tolerance": 1.0, "min_points": 60, "is3d": True,
         "where": "Planarity > 0.6"},
        {"type": "writers.las", "filename": str(dst), "minor_version": 4,
         "dataformat_id": 6, "extra_dims": "all"},
    ]
    return pdal.Pipeline(json.dumps({"pipeline": stages}))


def object_table(points: np.ndarray) -> pd.DataFrame:
    df = pd.DataFrame({name: points[name] for name in (
        "X", "Y", "HeightAboveGround", "Planarity", "Scattering", "NormalZ", "ClusterID")})
    df = df[df["ClusterID"] > 0]
    grouped = df.groupby("ClusterID").agg(
        n=("X", "size"),
        hag_p90=("HeightAboveGround", lambda s: float(np.percentile(s, 90))),
        planarity=("Planarity", "median"),
        scattering=("Scattering", "median"),
        flat_share=("NormalZ", lambda s: float((s > 0.95).mean())),
        x_span=("X", lambda s: float(s.max() - s.min())),
        y_span=("Y", lambda s: float(s.max() - s.min())),
    )
    grouped["bbox_area"] = grouped["x_span"] * grouped["y_span"]
    return grouped


def run(src: Path, dst: Path) -> pd.DataFrame:
    pipeline = feature_pipeline(src, dst)
    n = pipeline.execute()
    log.info("%s: %d points above 2.5 m", src.name, n)
    table = object_table(pipeline.arrays[0])
    table["is_building"] = (
        (table["planarity"] > 0.75)
        & (table["scattering"] < 0.08)
        & (table["bbox_area"] > 30.0)
        & (table["hag_p90"] > 3.0)
    )
    log.info("%d candidate objects, %d kept as buildings",
             len(table), int(table["is_building"].sum()))
    return table


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    print(run(Path("tile_4710_5285.laz"), Path("tile_4710_5285_features.laz")).head())

The design choice worth copying is the split between feature_pipeline, which is pure PDAL, and object_table, which is pure pandas. The first is expensive and changes rarely; the second is cheap and is where you will iterate on thresholds for days. Caching the feature output and re-running only the aggregation turns a ten-minute tuning loop into a two-second one.

# Schema and Data-Flow Considerations

Feature extraction adds more dimensions to a cloud than any other workflow on this site, and each one has to survive every stage and writer between where it is computed and where it is read.

New dimensions are doubles. Linearity, Planarity, HeightAboveGround and the normals are created as 8-byte floats. Four covariance features, three normal components, curvature, HAG and a ClusterID add about 76 bytes per point — more than doubling the in-memory footprint of a PDRF 6 cloud. Plan memory accordingly, or drop what you no longer need before the next blocking stage.

LAS stores them as extra bytes. writers.las with extra_dims: "all" writes each as an extra-bytes VLR entry, and readers.las restores them by name. If you want smaller files, declare types explicitly — "extra_dims": "Planarity=float,HeightAboveGround=float,ClusterID=uint32" — because a 4-byte float is ample precision for a shape descriptor.

ClusterID is per-run, not global. Segment numbers restart in every tile and every execution. Anything that must be joined across tiles — a building that straddles a tile edge — needs a global key built from the tile name plus the local ID, and a buffered tiling step so that the straddling object is seen whole in at least one tile.

Classification is written last. Keep candidate labels in a scratch dimension (CandidateClass via filters.ferry) until the per-object decision is made, then copy into Classification with filters.assign and a WHERE clause. Writing into Classification early destroys the ground/non-ground evidence that later stages read.

# Performance and Scaling

Neighbourhood features dominate the run time of every pipeline in this section, and they scale with knn roughly linearly and with point count slightly worse than linearly. The table gives illustrative timings for a 1 km² tile at about 18 pts/m² (roughly 18 million points after the height band removes ground) on an 8-core worker; use it for proportions rather than absolutes and measure your own hardware.

Stage and setting Threads Wall time Peak memory
filters.hag_nn, count: 2 1 40 s 2.1 GB
filters.covariancefeatures, knn: 10 4 95 s 3.0 GB
filters.covariancefeatures, knn: 16 4 150 s 3.2 GB
filters.covariancefeatures, knn: 16 8 90 s 3.3 GB
filters.normal, knn: 12 1 110 s 3.4 GB
filters.cluster, tolerance: 1.0 1 35 s 3.5 GB
filters.dbscan, eps: 1.2, min_points: 10 1 60 s 3.6 GB

Three levers matter. Cut the input first — the height band and a class filter routinely remove 60 to 80 percent of points before any neighbourhood is computed. Use the threads option on filters.covariancefeatures, which is one of the few PDAL stages that parallelizes internally. And parallelize across tiles rather than within them, exactly as in parallel tile processing, giving each process a thread count so that processes times threads does not exceed the core count.

Shrink the cloud before you describe it A horizontal bar chart of point counts through the preprocessing steps of a 1 square kilometre tile. The raw tile has about 42 million points. Removing noise barely changes it. Removing ground points drops it to about 19 million. Applying a height band of 2.5 to 120 metres leaves about 11 million. Feature computation then runs on a quarter of the original points. raw tile 42 M noise removed 41.5 M ground removed 19 M height band 2.5–120 m 11 M points entering the neighbourhood stages, 1 km² urban tile at about 42 pts/m² each feature stage now runs on roughly a quarter of the tile

# Production Deployment Patterns

Classification pipelines change more often than any other kind on this site, because the thresholds are tuned against each new project’s vegetation, roof styles and sensor. That makes versioning and regression testing unusually important.

  • Version thresholds with the pipeline. Keep rule thresholds in the same repository as the pipeline JSON, ideally as parameters in a templated pipeline, so that a delivered tile can always be traced to the exact numbers that classified it.
  • Keep a labelled reference tile per project. A few hundred metres of hand-edited classification is enough to compute per-class precision and recall on every change; the method is in evaluating point classification with a confusion matrix.
  • Separate the expensive pass. Store the feature-enriched LAZ once, then run the cheap per-object decisions as a second job. The split mirrors splitting a blocking pipeline into two passes and makes reclassification after a threshold change nearly free.
  • Run in the same container everywhere. Feature values differ slightly between PDAL releases as neighbourhood code evolves, so the image that tuned the thresholds must be the image that applies them — see pinning PDAL versions with conda-lock.

# Failure Modes and Debugging

Everything is planar. On sparse data (under 4 pts/m²) with a small knn, three or four neighbours always look planar and Planarity saturates near 1 for crowns as well as roofs. Raise knn, or switch to a radius-based neighbourhood, and check the distribution with a histogram before trusting any threshold.

Roofs merge with trees. Euclidean clustering links any two points closer than tolerance, so an overhanging branch bridges a roof and a crown into one segment. Restrict clustering with a where clause on planarity, or use DBSCAN with a min_points high enough that a thin branch is not dense enough to bridge.

ClusterID is all zero. The where clause excluded everything, usually because the feature dimension name was misspelled or the feature stage was placed after the cluster stage. Set "loglevel": 4 in the pipeline options or read pipeline.log after execution; PDAL reports how many points each stage saw.

Extra dimensions vanish. A writer without extra_dims silently drops them. Check the output with pdal info --schema out.laz and assert the names you expect before shipping the tile.

Results change between machines. Covariance features on ties and near-duplicate points are order-sensitive, and different thread counts can visit neighbourhoods in different orders. The differences are tiny per point but can flip a borderline object. Pin threads along with the PDAL version when reproducibility matters.

# Frequently Asked Questions

Do I need machine learning to classify buildings and vegetation?

Not usually to start. Height above ground plus planarity and scattering, aggregated per segment, separates buildings from vegetation well on most airborne data. A trained model earns its place when rules plateau — mixed urban canopy, unusual roof materials, or many classes at once — and it uses the same features as input.

Which neighbourhood size should I use for covariance features?

Aim for a neighbourhood about one metre across on the objects you care about. At 8 pts/m² that is roughly 10 neighbours; at 30 pts/m² it is closer to 25. Plot the feature histogram for known roofs and known crowns and pick the knn at which the two distributions separate most cleanly.

Why compute height above ground before segmentation?

Because segmentation without a height band merges objects with the terrain beneath them. Removing everything under 2 or 3 metres above ground leaves elevated objects isolated, so connected-component segmentation finds each roof or crown as its own group.

Can these stages run in streaming mode on huge tiles?

No. Every neighbourhood stage needs a spatial index over the full input, so they all block streaming. Keep tiles to a size that fits in memory, cut points early with range filters, and use buffered tiles so objects at edges are seen whole.

How do I keep the computed features in the output file?

Set extra_dims to all, or to an explicit list with types, on writers.las. LAS 1.4 stores them as extra bytes with names, and PDAL and laspy both read them back by name.