Generating a High-Resolution DTM from ALS Data: A Production PDAL Workflow

Airborne Laser Scanning (ALS) delivers the vertical structure data that underpins forest ecology, hydrological routing, and slope analysis — but turning a raw point cloud into a sub-meter bare-earth surface is a narrow, repeatable engineering task with its own failure modes. This page walks the exact PDAL-driven pipeline for producing a high-resolution Digital Terrain Model (DTM) from ALS returns: format-safe ingestion, statistical noise suppression, algorithmic ground filtering, and min-return rasterization. It is one concrete recipe within Digital Terrain Model Generation, which sits inside the wider Canopy Height Modeling & Terrain Extraction workflow. The hard parts — leaf-on canopy attenuation, steep gradients, and preserving micro-topographic depressions that drive flow and understory light — are all parameter-calibration problems, and this guide names the parameters that matter.

ALS point cloud to high-resolution DTM pipeline A left-to-right PDAL pipeline. Raw LAZ is read with laspy bound to the lazrs backend, statistical outliers are stripped and reclassified to noise code 18, then ground filtering branches on terrain type — CSF for smooth-to-moderate slopes, SMRF for karst and abrupt breaks — before writers.gdal rasterizes the minimum ground return per cell into a sub-meter bare-earth DTM GeoTIFF. Ingest laspy · lazrs raw LAZ tile Denoise filters.outlier noise → class 18 Ground filter choose by terrain keeps class 2 CSF smooth–moderate SMRF karst · abrupt breaks Rasterize writers.gdal output_type=min 0.5 m cells Bare-earth DTM float32 GeoTIFF Highlighted stage = the terrain-dependent decision: pick CSF or SMRF before tuning. Only Classification == 2 (ground) returns reach the grid.

When to use this approach

ALS-to-DTM is not a one-algorithm problem; the ground filter you pick is dictated by terrain and vegetation structure. Use the table below to choose a starting configuration before you tune.

Scenario Recommended ground filter Why Avoid
Moderate slopes, mixed conifer, leaf-off Cloth Simulation Filter (CSF) Cloth model tracks smooth terrain well at low cost SMRF (over-segments gentle ground)
Dense broadleaf understory, leaf-on CSF with reduced class_threshold Tighter threshold stops the cloth sagging into shrub returns Default CSF (misclassifies shrub as ground)
Karst, terraces, abrupt breaks Simple Morphological Filter (SMRF) Morphological opening handles sharp elevation steps CSF (produces artificial terracing)
Sparse ground returns (<1 pt/m²) SMRF with wide window, manual review Larger window bridges gaps; flag for field check Any filter at face value — interpolation noise dominates

If you only need height-normalized points rather than a gridded surface, do not rasterize at all — feed classified ground returns into LiDAR Point Cloud Preprocessing and compute height-above-ground there instead. Rasterization is only the right move when a downstream raster operation (hillshade, flow accumulation, CHM subtraction) needs a continuous grid.

Minimal reproducible example

The end-to-end pipeline below ingests a compressed tile, strips noise, classifies ground with CSF, and writes a 0.5 m bare-earth GeoTIFF. It is a single PDAL pipeline executed from Python so the whole sequence stays reproducible and version-controllable.

Step 1 — Bind the lazrs backend and inspect the tile

Reading compressed LAZ with laspy frequently raises ValueError: invalid point format or silently truncates extended classification fields. Bind the lazrs backend explicitly for parallel, specification-compliant decompression, then check what classification you actually received:

import laspy

# Bind lazrs for parallel decompression and extended-attribute support
with laspy.open("forest_tile.laz", laz_backend=laspy.LazBackend.LazrsParallel) as fh:
    las = fh.read()

print(f"Points:  {len(las.points):,}")
print(f"Format:  {las.header.point_format.id}")

# ALS data rarely arrives fully classified; unclassified (1) and
# vegetation (3-5) usually dominate before ground filtering.
import numpy as np
codes, counts = np.unique(las.classification, return_counts=True)
for code, count in zip(codes, counts):
    print(f"  class {code:>2}: {count:,}")

Verify custom classification schemes against the ASPRS LAS Specification before trusting any code values.

Step 2 — Run the full PDAL pipeline

This declarative pipeline chains statistical outlier removal, noise reclassification, CSF ground filtering, and min-return rasterization in one pass:

import json
import pdal

pipeline_def = {
    "pipeline": [
        {"type": "readers.las", "filename": "forest_tile.laz"},
        {
            "type": "filters.outlier",
            "method": "statistical",
            "mean_k": 8,
            "multiplier": 2.5,
        },
        {
            "type": "filters.assign",
            "assignment": "Classification[Classification==7]=18",
        },
        {
            "type": "filters.csf",
            "ignore": "Classification[18:18]",
            "class_threshold": 0.3,
            "cloth_resolution": 1.0,
            "rigidness": 3,
            "iterations": 500,
        },
        {
            "type": "writers.gdal",
            "filename": "dtm_0.5m.tif",
            "resolution": 0.5,
            "output_type": "min",
            "data_type": "float32",
            "where": "Classification == 2",
            "gdalopts": "COMPRESS=DEFLATE,PREDICTOR=2",
        },
    ]
}

pipeline = pdal.Pipeline(json.dumps(pipeline_def))
count = pipeline.execute()
print(f"{count:,} points processed -> dtm_0.5m.tif")

The filters.outlier stage flags statistical outliers as ASPRS code 7, then filters.assign reclassifies them to code 18 (high noise) so the CSF stage can ignore them. The where: "Classification == 2" clause on the writer guarantees only ground-classified returns reach the grid. For karst or terraced sites, swap the filters.csf stage for filters.smrf with slope=0.15, window=18.0, threshold=0.5, and scalar=1.25 — its morphological opening/closing handles abrupt elevation steps that make the cloth model produce false terraces.

Step 3 — Tile large surveys to stay inside memory

A single regional survey will exhaust RAM if loaded whole. Wrap the pipeline in a per-tile function and process tiles independently; PDAL pools memory per execution so workers stay isolated:

import pathlib
from concurrent.futures import ProcessPoolExecutor


def build_dtm(tile: pathlib.Path) -> str:
    out = pathlib.Path("dtm") / f"{tile.stem}.tif"
    defn = json.loads(json.dumps(pipeline_def))           # deep copy
    defn["pipeline"][0]["filename"] = str(tile)
    defn["pipeline"][-1]["filename"] = str(out)
    pdal.Pipeline(json.dumps(defn)).execute()
    return out.name


tiles = sorted(pathlib.Path("raw_tiles").glob("*.laz"))
with ProcessPoolExecutor(max_workers=4) as pool:
    for name in pool.map(build_dtm, tiles):
        print(f"wrote {name}")

Mosaic the per-tile rasters afterward with gdal_merge.py or rasterio.merge, keeping the cell size identical so the tiles align on the same grid.

Parameter reference

Parameter Stage Type Default Recommended range Ecological rationale
mean_k filters.outlier int 8 8–16 Neighbours used for the statistical distance test; raise it for dense surveys so isolated canopy returns are caught without trimming valid ground
multiplier filters.outlier float 2.5 2.2–3.0 Standard-deviation cutoff; lower is more aggressive — too low erases legitimate steep-slope returns
class_threshold filters.csf float 0.5 0.25–0.35 (leaf-on) Max cloth-to-point distance for a ground hit; tighten in dense understory so shrub returns are not pulled into the ground class
cloth_resolution filters.csf float 0.5 0.5–1.0 Cloth grid spacing; coarsen toward 1.0 in thick canopy to avoid overfitting micro-noise while preserving macro-topography
rigidness filters.csf int 3 2–3 Cloth stiffness; 3 suits flat-to-moderate terrain, drop to 2 for rugged ground
slope filters.smrf float 0.15 0.10–0.20 Max ground slope tolerated by the morphological filter; raise on steep terrain to keep valley walls
window filters.smrf float 18.0 16–20 Max structure size removed as non-ground; widen to bridge sparse-return gaps
resolution writers.gdal float 0.5–1.0 m Output cell size; match it to pulse density so the grid is not artificially smoothed below the data’s true resolution
output_type writers.gdal enum min Per-cell aggregation; min captures the lowest return per cell, the correct choice for bare-earth surfaces

output_type selects PDAL’s per-cell aggregation (min, max, mean, idw, count, stdev), while data_type sets the on-disk pixel dtype — conflating the two is a common copy-paste bug that only surfaces when GDAL refuses to open the result.

Expected output and verification

A correct run produces a single-band float32 GeoTIFF at the requested cell size, populated everywhere ground returns exist and NaN/nodata only in genuine voids (water bodies, building footprints, deep canopy with no ground penetration). Validate it before trusting it downstream:

import numpy as np
import rasterio

with rasterio.open("dtm_0.5m.tif") as src:
    dtm = src.read(1, masked=True)
    assert src.res == (0.5, 0.5), f"unexpected cell size {src.res}"
    assert src.crs is not None, "DTM is missing a CRS — check input header"

    # A clean bare-earth tile should be mostly filled; large void
    # fractions usually mean the ground filter was too aggressive.
    void_fraction = dtm.mask.mean()
    print(f"void fraction: {void_fraction:.1%}")
    assert void_fraction < 0.15, "too many voids — relax class_threshold"

    print(f"elevation range: {dtm.min():.1f}-{dtm.max():.1f} m")

For a visual check, derive a hillshade and overlay the classified ground points: systematic voids or a terraced look in the hillshade signal misclassification. Cross-reference the surface against USGS 3D Elevation Program benchmarks where coverage exists, and confirm hydrological plausibility by running flow accumulation with richdem or whitebox — micro-topographic depressions like vernal pools must survive into the final grid.

Common pitfalls

  • Min-return picks up low noise. output_type="min" is correct for bare earth but will latch onto a single sub-ground noise return if outlier removal was skipped — always run filters.outlier first and reclassify noise to code 18 so it is excluded.
  • CSF sags into leaf-on understory. Default class_threshold=0.5 classifies dense shrub returns as ground in leaf-on broadleaf stands, inflating the DTM by tens of centimetres. Drop to 0.25–0.35 and coarsen cloth_resolution to 1.0.
  • Cell size finer than pulse density. Requesting a 0.25 m grid from a 4 pt/m² survey produces empty cells that interpolation must guess — match resolution to true density to avoid fabricated micro-topography.
  • output_type vs data_type swap. Writing output_type="float32" (invalid) or data_type="min" (invalid) yields a file GDAL silently mishandles or refuses to open — keep aggregation and pixel dtype distinct.

Frequently Asked Questions

Why use output_type="min" instead of mean or idw for a DTM?

Bare-earth elevation is the lowest valid return in each cell, so min is the physically correct aggregation once noise is removed. mean and idw blend ground with any residual low vegetation, biasing the surface upward; reserve them for canopy or intensity rasters, not terrain.

How do I decide between CSF and SMRF?

Start with CSF on smooth-to-moderate terrain because it is fast and forgiving. Switch to SMRF when the terrain has abrupt breaks — karst, terraces, road cuts — where the cloth model produces artificial terracing. The decision table near the top of this page lists the trigger conditions.

My DTM has large empty patches. What went wrong?

Excessive voids almost always mean the ground filter was too aggressive (often a class_threshold left at the default in dense canopy) or the requested cell size is finer than the pulse density. Relax class_threshold, coarsen resolution, and confirm there are ground returns in the void areas before assuming the data is the problem.

Do I need to normalize the point cloud before building the DTM?

No — DTM generation works directly on absolute elevations of classified ground returns. Height-above-ground normalization is a separate step that builds on the DTM, covered under LiDAR Point Cloud Preprocessing. The DTM you produce here becomes the reference surface for that normalization.

How does this DTM feed into a canopy height model?

The DTM is the bare-earth term subtracted from a first-return Digital Surface Model to produce a normalized canopy surface. Grid alignment and cell size must match exactly between the two, which is why Canopy Height Model Creation depends on a clean, correctly-resolved DTM from this step.