CSF vs SMRF vs PMF: Choosing a Ground Filter for Dense Forest Canopy

Under a closed forest canopy the lowest laser return in a grid cell is often a shrub, a fallen log, or coarse woody debris rather than true bare earth, so the ground filter you pick decides whether your terrain surface is honest or systematically inflated. This guide compares the three ground-classification algorithms that ship with PDAL — filters.csf, filters.smrf, and filters.pmf — as one focused decision inside LiDAR Point Cloud Preprocessing, which itself sits within the wider Canopy Height Modeling & Terrain Extraction workflow. Once you have a clean Class 2 ground surface, the same classified cloud feeds both Digital Terrain Model Generation and the height-above-ground step in Normalizing LiDAR Point Clouds with PDAL. Getting this choice right up front is the single highest-leverage decision in the preprocessing chain.

All three filters solve the same problem — separate ground returns from vegetation — but they use fundamentally different physics. CSF (Cloth Simulation Filter) drapes a simulated elastic cloth over the inverted cloud. PMF (Progressive Morphological Filter) opens the surface with a monotonically growing window. SMRF (Simple Morphological Filter) is a refinement of PMF that adds an explicit, slope-aware elevation tolerance. Their differences only matter at the margins that define forestry work: steep slopes, dense multi-layered canopy, and sparse ground returns.

When to use each filter

The choice is driven by terrain gradient, canopy density, and ground-return density. The table below summarizes the behavior that matters for dense-canopy work.

Criterion filters.csf filters.smrf filters.pmf
Underlying model Draped cloth simulation Slope-bounded morphology Progressive morphological opening
Key parameters resolution, rigidness, threshold, step slope, window, scalar, threshold cell_size, max_window_size, slope, initial_distance, max_distance
Steep terrain (>30°) Good if rigidness lowered to 1; can sag on cliffs Strong — explicit slope term tracks abrupt breaks Weakest — fixed slope term over-clips terrace edges
Dense / multi-layer canopy Strong — physics penetrates understory, preserves breaklines Good with tuned scalar Fair — large windows bridge gaps and gouge ground
Low point density (<1 pt/m²) Fair — coarsen resolution to interpolate across gaps Good — window growth spans gaps predictably Fair — sensitive to initial_distance
Speed Slower (iterative simulation) Fast Fast
Parameter intuition Least intuitive (physics) Moderate (slope + window) Moderate but many coupled params
PDAL stage name filters.csf filters.smrf filters.pmf

The short version: reach for filters.csf first on forested, topographically complex sites; it penetrates dense understory while preserving breaklines. Switch to filters.smrf when the terrain has abrupt breaks — karst, terraces, road cuts — where its explicit slope tolerance beats a cloth that tends to bridge narrow gullies. Treat filters.pmf as the legacy baseline: it is well understood and fast, but its fixed morphological window makes it the most likely of the three to either bridge canopy gaps or over-clip real terrace edges in complex forest terrain.

Recommendation for dense canopy

For temperate and tropical closed-canopy forest over steep terrain — the hardest and most common case in ecological LiDAR — start with filters.csf, rigidness=2, resolution=0.5, and threshold=0.3. The cloth model settles through gaps in the overstory to reach genuine ground and, unlike morphology-based filters, it does not require you to guess a maximum window size that matches your largest canopy opening. Lower rigidness toward 1 on slopes above 30° so the cloth can follow the gradient rather than sagging across it. Reserve SMRF for sites dominated by sharp breaklines, and validate whichever you pick against RTK control points as described under Digital Terrain Model Generation.

Minimal reproducible examples

Each filter is a single PDAL stage. The pipelines below share the same structure — read, strip outliers, classify ground, write — so you can swap the classifier stage and compare Class 2 counts on an identical input. Statistical outlier removal must run before any ground filter so a high-noise return is never anchored as ground.

CSF for dense canopy on steep terrain:

{
  "pipeline": [
    "input_tile.laz",
    {
      "type": "filters.outlier",
      "method": "statistical",
      "mean_k": 12,
      "multiplier": 2.2
    },
    {
      "type": "filters.csf",
      "resolution": 0.5,
      "rigidness": 2,
      "threshold": 0.3,
      "step": 0.65,
      "iterations": 500,
      "classify": true
    },
    {
      "type": "writers.las",
      "filename": "ground_csf.laz",
      "forward": "all"
    }
  ]
}

SMRF for abrupt breaklines and terraces:

{
  "pipeline": [
    "input_tile.laz",
    {
      "type": "filters.outlier",
      "method": "statistical",
      "mean_k": 12,
      "multiplier": 2.2
    },
    {
      "type": "filters.smrf",
      "slope": 0.2,
      "window": 16.0,
      "scalar": 1.2,
      "threshold": 0.45
    },
    {
      "type": "writers.las",
      "filename": "ground_smrf.laz",
      "forward": "all"
    }
  ]
}

PMF as the legacy morphological baseline:

{
  "pipeline": [
    "input_tile.laz",
    {
      "type": "filters.outlier",
      "method": "statistical",
      "mean_k": 12,
      "multiplier": 2.2
    },
    {
      "type": "filters.pmf",
      "cell_size": 1.0,
      "max_window_size": 24,
      "slope": 1.0,
      "initial_distance": 0.5,
      "max_distance": 3.0
    },
    {
      "type": "writers.las",
      "filename": "ground_pmf.laz",
      "forward": "all"
    }
  ]
}

Run any of them from Python and count the ground returns each produces, so the comparison is quantitative rather than visual:

import json
import pdal

def count_ground(pipeline_json: str) -> int:
    """Execute a PDAL pipeline and return the number of Class 2 ground points."""
    p = pdal.Pipeline(pipeline_json)
    p.execute()
    arr = p.arrays[0]
    return int((arr["Classification"] == 2).sum())

for name in ("ground_csf", "ground_smrf", "ground_pmf"):
    with open(f"{name}.json") as fh:
        n = count_ground(fh.read())
    print(f"{name}: {n:,} ground returns")

Parameter reference

The parameters that most affect dense-canopy accuracy, with forested starting values and the ecological reason each matters.

Parameter Filter Type Default Recommended range Ecological rationale
resolution filters.csf float (m) 1.0 0.5–2.0 Cloth grid spacing; tighten for fine breaklines, coarsen to interpolate across sparse ground
rigidness filters.csf int 3 1–2 Cloth stiffness; lower to 1 on steep slopes so the cloth follows the gradient instead of sagging
threshold filters.csf float (m) 0.5 0.25–0.4 Max cloth-to-point distance kept as ground; tighten so low shrubs stay out of Class 2
step filters.csf float 0.65 0.5–0.9 Cloth displacement per iteration; smaller is more stable but slower
slope filters.smrf float 0.15 0.15–0.3 Slope tolerance (rise/run); raise on steep terrain to keep genuine slope returns
window filters.smrf float (m) 18.0 12–20 Max morphological window; size to the widest canopy opening you must span
scalar filters.smrf float 1.25 0.5–1.5 Multiplies the slope-based elevation threshold; lower rejects more aggressively
threshold filters.smrf float (m) 0.5 0.3–0.5 Elevation tolerance floor; lower under dense canopy to reject low vegetation
max_window_size filters.pmf int 33 16–33 Largest opening window; must exceed the largest canopy gap or ground is gouged
slope filters.pmf float 1.0 0.5–1.5 Slope factor scaling the elevation-difference threshold as the window grows
initial_distance filters.pmf float (m) 0.15 0.15–0.5 First-pass elevation tolerance; raise slightly in noisy understory
max_distance filters.pmf float (m) 2.5 2.5–3.5 Ceiling on the elevation-difference threshold at the largest window

Expected output and verification

Every one of these pipelines writes a LAZ in which terrain points carry ASPRS Classification value 2. A correct dense-canopy run classifies a plausible fraction of returns as ground — typically 5–25% depending on canopy closure — and, once you rasterize and normalize, produces near-zero height for bare-ground returns. The fastest cross-check is to grid each classifier’s ground returns to a DTM and compare RMSE against survey control, but a first-pass sanity check compares ground counts and looks for the failure signatures each filter is prone to:

import numpy as np
import laspy

def ground_fraction(laz_path: str) -> float:
    """Fraction of returns classified as ASPRS Class 2 ground."""
    with laspy.open(laz_path) as fh:
        las = fh.read()
    cls = np.asarray(las.classification)
    frac = (cls == 2).sum() / len(cls)
    return float(frac)

for path in ("ground_csf.laz", "ground_smrf.laz", "ground_pmf.laz"):
    frac = ground_fraction(path)
    print(f"{path}: {frac:.1%} ground")
    assert 0.02 < frac < 0.6, "implausible ground fraction — retune the classifier"

A ground fraction near zero means the filter rejected true terrain (window too small in PMF, or CSF threshold too tight). A fraction above ~50% under closed canopy means low vegetation leaked into the ground class (CSF rigidness too high, or SMRF scalar too loose). Grid each result to a bare-earth surface and validate against RTK points — the filter with the lowest RMSE and a mean error nearest zero wins for that stand type.

Common pitfalls

  • Using PMF defaults under closed canopy. The default max_window_size of 33 with a large slope bridges wide canopy gaps and gouges genuine ground beneath them. Size max_window_size to your largest real opening and validate the bias.
  • Leaving CSF rigidness at 3 on steep slopes. A rigid cloth spans gullies and misclassifies the gully floor as vegetation. Lower rigidness to 1 and threshold to 0.3 together on gradients above 30°.
  • Skipping outlier removal. All three filters can anchor on a single high-noise or low-noise return. Always place filters.outlier (statistical) ahead of the classifier so noise is never a candidate ground point.
  • Comparing filters on different tiles. Ground fraction and RMSE only compare meaningfully on the same input with outlier removal held constant. Swap only the classifier stage between runs.

Frequently Asked Questions

Is SMRF just a faster version of PMF?

They share a morphological lineage, but SMRF is a distinct algorithm: it adds an explicit, slope-dependent elevation threshold and a net-cut infilling step that makes it markedly more robust on abrupt breaklines than classic PMF. In practice SMRF supersedes PMF for most forestry work, and PMF is kept mainly for reproducing older products or comparison baselines.

Why does CSF beat morphological filters under dense canopy?

CSF models ground as a physical cloth that settles from above, so it does not need a maximum-window parameter tuned to your largest canopy gap. Morphological filters must span the widest opening with their window, and a window large enough to bridge a big gap will also flatten real micro-relief. The cloth simply drapes to the lowest coherent surface, which is why it preserves breaklines in complex forest terrain.

Which filter should I use if I only have one shot at a regional survey?

Standardize on filters.csf with forest-tuned parameters (rigidness=2, resolution=0.5, threshold=0.3) and validate a representative sample of tiles against control points. A single consistent CSF configuration avoids the step changes at tile boundaries that appear when classification settings drift between batches, which matters most for multi-temporal change detection.

Up: LiDAR Point Cloud Preprocessing · Canopy Height Modeling & Terrain Extraction