Removing Vegetation from DTM Ground Surfaces

Your bare-earth DTM should be smooth, but a hillshade of it shows the opposite: a stippled carpet of bumps where the tree line runs, raised welts along hedgerows, and soft mounds over dense shrubs. filters.smrf classified those canopy and understorey returns as ground, so they rasterized into the DTM as false terrain — and any contour, slope map, or cut/fill volume computed from it inherits the error. This page explains why vegetation survives ground filtering, gives one focused re-tuning of SMRF plus a height guard, and shows when the cloud itself is the limit.

Why vegetation survives the ground filter

filters.smrf decides ground membership morphologically: it slides a growing structuring element (the window) across the cloud and marks a point as ground when it sits within an elevation tolerance of the locally opened surface. That tolerance is not a single number — it scales with the local slope through scalar, on top of a base threshold. Vegetation slips through in three ways. Low, dense shrubs sit within threshold of the true ground and are simply indistinguishable by height alone. Tree canopy survives when window is smaller than the tree crown, because SMRF’s opening never fully removes an object wider than its largest structuring element. And on genuinely steep terrain a slope/scalar pair tuned for flat ground inflates the tolerance so far that mid-height vegetation qualifies as ground.

The fix is therefore never a single knob. You widen window until it exceeds your largest vegetation footprint, tighten threshold so low canopy no longer qualifies, set slope to the steepest real terrain grade so the tolerance is not over-inflated, and then add a second, independent filter that rejects points by their height above the SMRF surface — catching whatever morphology missed. This is the most common DTM defect out of generating DSM and DTM from point clouds with PDAL, and getting it right is what separates a survey-grade bare-earth model from a decorative one.

Minimal reproducible solution

The pipeline below re-runs ground classification with SMRF parameters matched to a vegetated site, then uses filters.hag_delaunay to compute each point’s Height Above Ground and filters.range to keep only ground points that are also within a few centimetres of that surface. The height filter is the crucial addition: SMRF assigns the class, and the HAG gate independently vetoes any “ground” point floating above the terrain.

import json
import pdal


def build_devegetated_dtm(infile: str, outfile: str, resolution: float = 0.25) -> dict:
    """PDAL pipeline: SMRF ground classification + height-above-ground veto."""
    return {
        "pipeline": [
            {"type": "readers.las", "filename": infile},
            {
                "type": "filters.smrf",
                "slope": 0.25,     # set to the steepest REAL terrain grade
                "window": 33.0,    # exceed the widest tree crown on site
                "threshold": 0.30, # tighter: low canopy no longer counts as ground
                "scalar": 1.10,    # modest slope-scaling; avoid over-inflating tolerance
            },
            {"type": "filters.range", "limits": "Classification[2:2]"},  # keep ground
            {"type": "filters.hag_delaunay"},   # adds HeightAboveGround dimension
            # Veto any "ground" point sitting >0.15 m above the terrain surface.
            {"type": "filters.range", "limits": "HeightAboveGround[:0.15]"},
            {
                "type": "writers.gdal",
                "filename": outfile,
                "resolution": resolution,
                "output_type": "idw",
                "radius": resolution * 2.0,
                "gdaldriver": "GTiff",
                "nodata": -9999.0,
            },
        ]
    }


pipeline = pdal.Pipeline(json.dumps(build_devegetated_dtm("cloud.laz", "dtm.tif")))
pipeline.execute()

The order matters: filters.hag_delaunay must run after SMRF has set classes so it references the SMRF ground surface, and the second filters.range runs after HAG so it can read the new HeightAboveGround dimension. Set the HAG ceiling (0.15 m here) to just above your survey’s vertical noise floor — tight enough to drop grass and low shrubs, loose enough not to erode real micro-relief. Because this widens window, watch the DTM for over-smoothing across real ridgelines; if breaklines soften, reduce window a step and lean harder on the HAG gate instead.

Edge-case matrix

Input variant Downstream symptom if unchecked Expected handling
Dense low shrubs within threshold DTM sits 10–30 cm high across scrub Tighten threshold; add HAG veto below the shrub height
Tree crowns wider than window Circular mounds under every large tree Raise window above the widest crown diameter
Steep slope with flat-tuned scalar Mid-height brush admitted as ground on hillsides Set slope to the true max grade; lower scalar
Real breakline (retaining wall, cut) Over-wide window erases the wall edge Reduce window; rely on HAG gate, not morphology, for veg
Ground points genuinely sparse under canopy Tightening filters leaves NoData holes Accept NoData or fill; do not loosen to backfill vegetation
Building edges misread as vegetation Roof eaves clipped into the DTM HAG gate removes them correctly; verify against a footprint layer

The steep-slope case is the subtle one: operators raise scalar to stop SMRF eating real terrain on a hillside, and in doing so widen the tolerance enough to re-admit brush. Fix it by first setting slope to the actual steepest grade on site, which lets you keep scalar modest and preserves both the hillside and the de-vegetation.

Verification snippet

Compare the de-vegetated DTM against surveyed check points on bare ground: every check point should now sit within tolerance, and the DTM’s local roughness over known-vegetated polygons should collapse toward the ground’s true smoothness.

import numpy as np
import rasterio

CHECKS = [(500123.4, 5210987.6, 212.44), (500140.1, 5211002.3, 213.07)]  # x, y, z_true
with rasterio.open("dtm.tif") as src:
    residuals = []
    for x, y, z_true in CHECKS:
        z_dtm = next(src.sample([(x, y)]))[0]     # bilinear-free nearest sample
        residuals.append(z_dtm - z_true)

residuals = np.array(residuals)
rmse = float(np.sqrt(np.mean(residuals**2)))
# Ground check points must clear the survey tolerance now that veg is gone.
assert rmse < 0.10, f"DTM still {rmse:.3f} m off ground truth — veg likely remains"
print(f"ground-check RMSE {rmse:.3f} m, max |residual| {np.abs(residuals).max():.3f} m")

A persistently high RMSE on bare-ground checks after re-tuning means vegetation is still classified as ground; tighten the HAG ceiling before touching SMRF again, since the height veto is the more surgical control.

When to escalate

SMRF and a height gate remove vegetation the cloud saw through; they cannot manufacture ground the sensor never reached. Return to generating DSM and DTM from point clouds with PDAL or reconsider capture when:

  • Canopy is closed and no ground returns exist beneath it; photogrammetry (unlike multi-return LiDAR) sees only the top surface, so no filter can recover terrain that was never reconstructed.
  • De-vegetation leaves large NoData under the tree line, which is a coverage limit, not a tuning problem — the residual voids are handled in fixing holes and voids in drone-derived DSMs.
  • The pipeline itself errors on filters.hag_delaunay or the HAG range, which is a stage-name or dimension problem covered in fixing PDAL pipeline JSON schema errors.

Generating DSM and DTM from Point Clouds with PDAL