Classifying Point Clouds with PDAL and Python

Every bare-earth product a survey delivers rests on one decision made millions of times: is this point ground, or is it something standing on the ground. The classifier that makes that decision is a handful of geometric rules with four or five parameters, and the difference between a defensible digital terrain model and a plausible-looking fiction is whether those parameters matched the terrain and whether anybody checked.

This page covers classification as an automated stage in a Python pipeline: how the progressive morphological family of filters actually works, which parameter to reach for when a specific artefact appears, how to use the colour photogrammetry gives you for free, and how to prove the result rather than admire it. The classes produced here feed the surface generation in generating DSM and DTM from point clouds with PDAL and every volume computed in computing volumes and stockpiles in Python.

Audience and prerequisites. Python 3.10+, PDAL 2.5+ with its Python bindings, and a denoised dense cloud in a projected CRS. Classification on a cloud still in geographic coordinates will not work: every window and threshold parameter is in the horizontal unit of the file, and degrees are not metres.

Prerequisites

Library / tool Minimum version Install command Role
PDAL ≥ 2.5 conda install -c conda-forge pdal The filters themselves
pdal (Python) ≥ 3.2 pip install pdal Running pipelines and reading arrays back
numpy ≥ 1.24 pip install numpy Residual statistics, colour ratios
laspy ≥ 2.5 pip install "laspy[lazrs]" Header inspection without a full pipeline
shapely ≥ 2.0 pip install shapely Clipping validation samples to test areas

Conceptual architecture

The progressive morphological approach, and the Simple Morphological Filter (SMRF) that refines it, both rest on the same idea. Start from the lowest point in each cell of a coarse grid. Those points are provisionally ground. Then grow a window outward, repeatedly, and at each scale ask whether a point sits too far above the surface implied by its neighbourhood. Points that do are non-ground; the surface is re-fitted; the window grows again.

The consequence worth internalising is that the window parameter is a size of object, not a tuning knob. It is the largest thing the filter can recognise as standing on the ground. A window of 10 m over a site with a 30 m warehouse cannot see the warehouse as an object — from the filter’s perspective at that scale the roof is the terrain, and it will be classified as ground. Almost every “the building is in my DTM” report resolves to a window smaller than the building.

The threshold parameter is the complementary one: it is the height above the provisional surface at which a point stops being ground. Too small and real terrain roughness is stripped away; too large and low vegetation survives into the ground class, producing exactly the systematic bias described in validating classification against manual samples.

How the morphological window size decides what counts as an object A terrain cross-section carrying a small bush, a hedge and a wide warehouse. Three horizontal bars above it show the reach of three window sizes. A five metre window spans the bush only, so the hedge and the warehouse are both read as terrain. An eighteen metre window spans the bush and the hedge, leaving the warehouse as terrain. A forty metre window spans all three, so all three are classified as objects standing on the ground. A note states that the window must exceed the widest non-ground feature present or that feature becomes part of the terrain surface. true bare earth bush hedge 9 m warehouse 30 m window 5 m — hedge and warehouse become terrain window 18 m — warehouse still becomes terrain window 40 m — all three seen as objects The window is a size of object, not a sensitivity dial. Anything wider than the window is, by construction, part of the ground surface.

Figure 1 — Why a building ends up in the terrain model. The filter has no concept of “building”; it has a scale, and anything wider than that scale is ground.

Step 1: Classify with SMRF and keep the parameters

The minimum useful pipeline is three stages: assert a projected CRS, run the filter, write the result. The parameters go into the manifest alongside the output, because a cloud classified with different parameters is a different cloud and six months later nobody will remember which was used.

import json
import subprocess
from pathlib import Path


def classify_ground(src: str, dst: str, *, window: float = 18.0,
                    slope: float = 0.15, threshold: float = 0.45,
                    scalar: float = 1.25, cell: float = 1.0) -> dict:
    """Ground-classify a denoised cloud with SMRF and record the parameters.

    Every value here is in the file's horizontal units. Running this on a
    cloud in EPSG:4326 silently produces nonsense, so the caller must have
    reprojected first.
    """
    params = {"window": window, "slope": slope, "threshold": threshold,
              "scalar": scalar, "cell": cell}
    pipeline = {"pipeline": [
        src,
        # assign everything to 'never classified' first so a re-run is
        # idempotent rather than layering on a previous classification
        {"type": "filters.assign", "value": "Classification = 0"},
        {"type": "filters.smrf", **params},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

    Path(dst).with_suffix(".params.json").write_text(
        json.dumps({"filter": "smrf", **params}, indent=2))
    return params

filters.assign resetting the classification first is what makes the function safe to re-run. Without it a second pass over an already-classified file layers new decisions on top of old ones, and the result depends on how many times the pipeline has been run — a class of non-determinism that is miserable to debug because the file looks fine.

"forward": "all" on the writer carries the source header through: scale, offset, CRS and the vendor metadata. Omit it and PDAL writes defaults, which is the most common route to the precision loss described in fixing LAS scale and offset precision loss.

Step 2: Use the colour before using the geometry

A photogrammetric point carries the RGB of the pixels that produced it, and vegetation is spectrally distinct from soil, gravel and concrete even in plain visible light. An excess-green index separates most of it in one arithmetic pass, and doing so before the morphological filter runs removes the class of points most likely to confuse it.

import json
import subprocess


EXCESS_GREEN = "(2.0 * Green - Red - Blue) / (Red + Green + Blue + 1)"


def pre_mask_vegetation(src: str, dst: str, *, egi_threshold: float = 0.08) -> None:
    """Mark strongly green points as high vegetation (class 5) before SMRF.

    The +1 in the denominator avoids a divide-by-zero on pure-black points,
    which a shadowed cloud has plenty of. The threshold is deliberately
    conservative: this stage should only catch points that are obviously
    vegetation, and leave the ambiguous ones to the geometric filter.
    """
    pipeline = {"pipeline": [
        src,
        {"type": "filters.ferry", "dimensions": "=>ExcessGreen"},
        {"type": "filters.assign",
         "value": [f"ExcessGreen = {EXCESS_GREEN}",
                   f"Classification = 5 WHERE ExcessGreen > {egi_threshold}"]},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all", "extra_dims": "ExcessGreen=float"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

Keeping ExcessGreen as an extra dimension rather than discarding it is worth the file size. When a classification result is questioned, being able to colour the cloud by the index that drove the decision turns an argument into an inspection. The threshold itself is site-dependent — dry grass in August is not green — and separating buildings from vegetation in point clouds covers how to calibrate it against a known patch.

Step 3: Separate buildings from the rest of the non-ground

SMRF answers one question: ground or not. Splitting the “not” into vegetation, buildings and everything else needs a second pass, and the cheapest discriminator is local planarity. A roof is flat over several metres; a tree canopy is not flat at any scale.

import json
import subprocess


def classify_buildings(src: str, dst: str, *, knn: int = 24,
                       planarity_min: float = 0.92,
                       min_height: float = 2.0) -> None:
    """Split non-ground points into buildings (6) and vegetation (5).

    filters.covariancefeatures computes eigenvalue-derived shape descriptors
    per point; Planarity near 1 means the neighbourhood lies in a plane.
    Combined with a height above ground it is a serviceable roof detector.
    """
    pipeline = {"pipeline": [
        src,
        {"type": "filters.hag_nn"},                       # HeightAboveGround
        {"type": "filters.covariancefeatures", "knn": knn,
         "feature_set": "Dimensionality", "threads": 4},
        {"type": "filters.assign", "value": [
            f"Classification = 6 WHERE Classification != 2 "
            f"&& Planarity > {planarity_min} && HeightAboveGround > {min_height}",
            f"Classification = 5 WHERE Classification != 2 "
            f"&& Classification != 6 && HeightAboveGround > {min_height}",
            f"Classification = 3 WHERE Classification != 2 "
            f"&& Classification != 6 && HeightAboveGround <= {min_height} "
            f"&& HeightAboveGround > 0.3",
        ]},
        {"type": "writers.las", "filename": dst, "compression": "laszip",
         "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

filters.hag_nn must come first because every rule after it is expressed in height above ground rather than absolute elevation — the only frame in which “two metres up” means the same thing on a hillside as on a flat apron. The ordering constraint is easy to miss and produces a classification that is correct on flat sites and wrong on sloped ones, which is the worst kind of bug to find in production.

Planarity and height above ground as a two-axis class separator A scatter plot with height above ground on the vertical axis and local planarity on the horizontal axis. Points near the bottom at any planarity are ground. A cluster at high planarity and height above two metres is buildings. A diffuse cloud at low planarity and heights from two to fifteen metres is tree canopy. A band between three tenths of a metre and two metres at mixed planarity is low vegetation and site clutter, which is the region where the classification is genuinely ambiguous. Dividing lines mark the thresholds used in the code. local planarity → height above ground → 0.3 m 2.0 m planarity 0.92 ground buildings tree canopy low vegetation and clutter — genuinely ambiguous The two clean corners classify themselves; the band in the middle is where a site-specific rule earns its keep.

Figure 2 — Two cheap features do most of the work. The disagreements live in one band, and that band is where sampled validation should concentrate.

Step 4: Run it over a survey without loading it

A site cloud is routinely 300 million points. Classification is a neighbourhood operation, so it cannot be done point-by-point in a stream, but it can be done tile-by-tile with a buffer — and the buffer is the part that is usually wrong.

import json
import subprocess
from pathlib import Path


def classify_tiled(src: str, out_dir: str, *, tile: float = 500.0,
                   buffer: float = 40.0, **smrf) -> None:
    """Tile, classify each tile with a halo, then trim the halo away.

    The buffer must exceed the SMRF window, or points near a tile edge are
    classified against a truncated neighbourhood and a grid of seams appears
    in the ground class exactly one tile apart.
    """
    if buffer <= smrf.get("window", 18.0):
        raise ValueError("buffer must exceed the morphological window")

    Path(out_dir).mkdir(parents=True, exist_ok=True)
    pipeline = {"pipeline": [
        src,
        {"type": "filters.splitter", "length": tile, "buffer": buffer},
        {"type": "filters.smrf", **smrf},
        # drop the halo so tiles can be concatenated without duplicates
        {"type": "filters.crop", "a_srs": "", "bounds": ""},
        {"type": "writers.las", "filename": f"{out_dir}/tile_#.laz",
         "compression": "laszip", "forward": "all"},
    ]}
    subprocess.run(["pdal", "pipeline", "--stdin"],
                   input=json.dumps(pipeline), text=True, check=True)

The visible symptom of an inadequate buffer is a regular grid of artefacts in the DTM at exactly the tile spacing. It is unmistakable once seen and completely invisible in any per-tile check, because each tile is internally consistent. The tiling discipline is the same one applied to imagery in structuring drone imagery for batch processing.

Parameter deep-dive

Parameter Type Default Valid range Effect
window float, metres 18.0 6–40 Largest object recognisable as non-ground; must exceed the widest building
slope float, rise/run 0.15 0.05–1.0 Terrain steepness tolerated; too low strips real slopes into non-ground
threshold float, metres 0.45 0.1–1.0 Height above the provisional surface still called ground
scalar float 1.25 0.5–2.5 Scales the threshold with local slope; raise on broken terrain
cell float, metres 1.0 0.3–3.0 Provisional-surface grid; below the point spacing wastes time
ignore expression none any Excludes classes from the filter, e.g. pre-masked vegetation
knn (covariance) int 24 8–64 Neighbourhood for planarity; small values make roofs look rough
planarity_min float 0.92 0.85–0.98 Roof cutoff; lower catches pitched roofs and more tree tops
min_height float, metres 2.0 1.0–4.0 Floor for building candidacy; below it clutter dominates
egi_threshold float 0.08 0.02–0.20 Excess-green cutoff; season-dependent, calibrate per site
tile / buffer float, metres 500 / 40 Buffer must exceed window or seams appear at the tile spacing

The three that change the answer materially are window, threshold and scalar. The rest change the runtime or the edge cases. A useful discipline is to hold everything but those three fixed across a fleet of sites and record the three in the manifest, so a year of runs is comparable.

Verification and output inspection

Classification is verified in three ways, all cheap, and a production pipeline should do all three on every run.

import json
import numpy as np
import pdal


def classification_report(path: str) -> dict:
    """Class histogram, ground coverage and height sanity, straight from the file."""
    pipe = pdal.Pipeline(json.dumps({"pipeline": [
        path, {"type": "filters.hag_nn"}]}))
    pipe.execute()
    arr = pipe.arrays[0]

    classes, counts = np.unique(arr["Classification"], return_counts=True)
    hist = {int(c): int(n) for c, n in zip(classes, counts)}
    total = int(arr.size)

    ground = arr[arr["Classification"] == 2]
    report = {
        "total_points": total,
        "class_histogram": hist,
        "ground_fraction": len(ground) / total if total else 0.0,
        # Ground points must sit at height-above-ground zero by construction;
        # any spread here means hag_nn and the classification disagree.
        "ground_hag_p99": float(np.percentile(ground["HeightAboveGround"], 99))
        if len(ground) else float("nan"),
        "max_hag": float(arr["HeightAboveGround"].max()) if total else float("nan"),
    }
    return report


def assert_classification_sane(report: dict, *, min_ground: float = 0.15,
                               max_feature_height: float = 60.0) -> None:
    """Deterministic gates a classified survey cloud must clear."""
    if report["ground_fraction"] < min_ground:
        raise ValueError(f"ground is only {report['ground_fraction']:.1%} of points")
    if report["max_hag"] > max_feature_height:
        raise ValueError(
            f"something stands {report['max_hag']:.1f} m above ground — "
            "almost certainly un-removed noise, not a structure")
    if not (0 in report["class_histogram"] or 1 in report["class_histogram"]) \
            and len(report["class_histogram"]) < 2:
        raise ValueError("everything landed in one class — the filter did not run")

The max_hag check is the one that earns its place most often. Surviving outliers hundreds of metres above the site are common in photogrammetric clouds, they defeat every subsequent auto-scaled visualisation, and they are a single comparison away from being caught.

The third verification is the sampled residual against measured ground described in the point cloud processing section overview. Run it whenever checkpoints exist — which, on any project with ground control point optimization, they do.

Three ways a ground classification fails, and what each looks like Three failure modes are listed with their appearance and their usual cause. Over-aggressive classification strips low walls, kerbs and vegetation stubble into the ground class, producing a terrain model that has eaten the features on it, and is usually caused by a cell size or slope threshold set too permissive. Under-aggressive classification leaves dense vegetation and vehicles in the ground class, producing a terrain surface with lumps on it, usually from a window too small for the vegetation present. Edge failure misclassifies the survey boundary where there is ground on one side only, producing a rim that pulls contours, and is handled by clipping inside the boundary rather than by retuning. over-aggressive walls, kerbs and stubble absorbed into ground — window too permissive under-aggressive vegetation and vehicles left in ground — window too small for the cover edge failure the survey boundary misclassified — clip inside it rather than retune Two of these are parameter problems. The third is not, and tuning for it damages the interior.

Figure 3 — Recognising which of the three you have is most of the fix.

Troubleshooting

The building roof is in my terrain model. The window is smaller than the building. Set window above the widest structure on site and re-run; no threshold adjustment will fix this, because at that scale the roof genuinely is the lowest surface across the window.

Steep slopes are being classified as non-ground. slope is too low for the terrain. It is a rise-over-run tolerance, so a 30° hillside needs at least 0.58. Raising scalar alongside it lets the threshold grow with the local gradient instead of applying a flat-ground value on a bank.

The ground class follows the top of the grass. threshold is above the vegetation height, so low growth never exceeds it. Lower it toward 0.2 m and pre-mask with the excess-green filter. Confirm with sampled residuals rather than by eye — this is a bias, and biases are invisible in renderings.

A regular grid of ridges appears in the DTM. The tile buffer is at or below the morphological window, so edge points were classified against a truncated neighbourhood. Set the buffer to at least twice the window and re-run the tiled job.

Everything is class 1, unclassified. The pipeline ran but the filter did not: either filters.smrf was never reached because an earlier stage emptied the cloud, or a filters.range upstream excluded everything. Print the point count between stages; a stage that outputs zero points is silent otherwise.

Classification differs between two runs on the same file. The classification was not reset before filtering, so the second run built on the first. Add filters.assign setting Classification = 0 at the head of the pipeline.

Point Cloud Processing & 3D Deliverables