Choosing SMRF Parameters for Ground Classification
filters.smrf has a handful of parameters and a reputation for needing trial and error. Most of that reputation comes from tuning them against how the output looks rather than deriving them from the site, because three of the four are physical measurements of the terrain and only one is a genuine trade-off.
This page derives each from the site, names the failure each wrong value produces, and gives a check that separates over-filtering from under-filtering.
What each parameter controls
window is the maximum size of the morphological opening, in the cloud’s horizontal units. It must exceed the largest non-ground object on the site: anything wider than the window is treated as terrain, because the opening cannot see across it to establish that the ground is lower.
slope is the maximum terrain gradient the filter will accept as ground, as a rise over run. It must exceed the steepest genuine slope on the site; set below it, real ground is rejected and the DTM is trenched.
threshold is the vertical distance above the provisional ground surface at which a point becomes non-ground. It should be a few times the cloud’s own vertical noise and well below the height of the shortest object to remove.
scalar scales the threshold with the local slope, allowing more tolerance where the surface is steep. On flat sites it does nothing; on steep ones it prevents the threshold from rejecting ground that is simply going uphill.
Figure 1 — Each parameter has a physical referent on the ground. Measuring those four things is faster than trying values, and it produces a setting that transfers to the next survey of the same site.
Minimal reproducible solution
Derive the parameters from the cloud and from a description of the site, rather than from defaults.
import json
import subprocess
import numpy as np
def derive_smrf(cloud_path: str, widest_object_m: float,
steepest_slope_pct: float, cell_m: float | None = None) -> dict:
"""SMRF parameters from site measurements and the cloud's own statistics."""
info = json.loads(subprocess.run(
["pdal", "info", "--stats", cloud_path],
capture_output=True, text=True, check=True).stdout)
stats = {s["name"]: s for s in info["stats"]["statistic"]}
count = int(info["stats"]["statistic"][0]["count"])
bounds = info.get("summary", {}).get("bounds", {})
area = max((bounds.get("maxx", 1) - bounds.get("minx", 0)) *
(bounds.get("maxy", 1) - bounds.get("miny", 0)), 1.0)
spacing = float(np.sqrt(area / max(count, 1)))
# Vertical noise: a robust spread over a locally flat subsample would be
# better, but the z standard deviation bounds it usefully for a threshold.
z_std = float(stats.get("Z", {}).get("stddev", 0.05))
return {
"type": "filters.smrf",
"cell": round(cell_m or max(spacing * 1.5, 0.5), 2),
"window": round(widest_object_m * 1.2, 1), # exceed the widest object
"slope": round(steepest_slope_pct / 100.0 * 1.25, 3),
"threshold": round(max(3 * z_std, 0.15), 2),
"scalar": 1.25,
}
The multipliers are margins rather than tuning. window at 1.2 times the widest object accounts for the fact that a building measured on a map is smaller than the point cluster on its roof. slope at 1.25 times the steepest gradient allows for the noise in the gradient estimate itself. threshold at three times the vertical noise makes a false rejection of ground a three-sigma event rather than a routine one.
Two of the three inputs are properties of the site rather than of the data, which is the point: they are stated once per project and reused across every flight of it.
Edge-case matrix
| Wrong value | Symptom in the DTM | Correct derivation |
|---|---|---|
window too small |
Large buildings become terrain | 1.2 × the widest object |
window too large |
Slow; real hills flattened | Do not exceed the site’s landform scale |
slope too small |
Trenches down steep banks | 1.25 × the steepest real gradient |
slope too large |
Low vegetation retained on slopes | Measure the gradient, do not guess |
threshold too small |
Ground points rejected as noise | 3 × the cloud’s vertical noise |
threshold too large |
Kerbs and low walls retained | Below the shortest object to remove |
cell too small |
Noisy provisional surface | ≈ 1.5 × the mean point spacing |
scalar = 0 |
Ground rejected on every slope | 1.0–1.5 on terrain with relief |
The two window rows are a genuine tension rather than a right answer. A window large enough to remove a warehouse is also large enough to flatten a small hill, because the filter cannot distinguish a wide flat-topped object from a landform. On sites with both, classifying in two passes — a large window to remove the buildings, a smaller one to recover the terrain detail — is more effective than a compromise value.
Verification snippet
The check that matters is against surveyed ground points, split by surface, because the two failure directions have opposite signs.
import numpy as np
def assert_classification_balanced(dtm_at_checks: np.ndarray,
surveyed_z: np.ndarray,
on_vegetation: np.ndarray,
tol_m: float = 0.10) -> None:
"""Over- and under-filtering produce opposite biases; test for both.
on_vegetation is a boolean array marking check points under or beside
vegetation, which is where retained non-ground shows up.
"""
dz = dtm_at_checks - surveyed_z
open_bias = float(np.median(dz[~on_vegetation]))
assert abs(open_bias) <= tol_m, (
f"bias on open ground is {open_bias:+.3f} m — with no vegetation to "
"retain, this is a datum or rasterisation problem, not classification")
veg_bias = float(np.median(dz[on_vegetation]))
if veg_bias > tol_m:
raise AssertionError(
f"surface sits {veg_bias:+.3f} m above ground under vegetation — "
"under-filtered: lower the threshold or raise the window")
if veg_bias < -tol_m:
raise AssertionError(
f"surface sits {veg_bias:+.3f} m below ground under vegetation — "
"over-filtered: real ground was rejected and interpolated across")
Separating the two groups is what makes the direction readable. A single combined bias averages an over-filtered trench against an under-filtered canopy and can report zero while both faults are present at full strength.
Figure 2 — The two failure directions cancel in a combined statistic. Splitting the check points by surface is what makes the sign — and therefore the fix — readable.
Recording the parameters as site properties
Because three of the four parameters describe the site rather than the flight, they belong in a per-site configuration rather than in a per-run one. A quarry’s widest structure and steepest bank do not change between Tuesday’s flight and next month’s, so the values derived once are the values every subsequent survey of that site should use.
That has a practical consequence worth stating: a change in the classification parameters between two surveys of the same site makes those surveys incomparable. A volume computed between two epochs differences two surfaces, and if one was classified with a 40 m window and the other with 60 m, part of the reported change is a change in the filter rather than in the ground. Pinning the parameters per site — and recording them in the run manifest so the pinning is visible — is what makes repeat surveys of a working quarry mean anything.
The one parameter that legitimately varies per flight is cell, because it follows from the point density, which follows from the flight altitude and the overlap. Deriving it from the cloud each time is correct; deriving the other three each time is how two comparable surveys become two incomparable ones.
When to escalate
- No parameter set satisfies both surfaces. The site has objects at two very different scales, so a single window cannot serve both. Classify in two passes and merge, rather than compromising.
- The DTM is correct on open ground and wrong under dense canopy regardless of settings. The cloud has no ground points there. This is the photogrammetric limit described in removing vegetation from DTM ground surfaces, and no filter recovers ground the camera never saw.
- Classification is stable and the surface still has voids. Voids come from the rasterisation rather than the classification when the cell size is finer than the ground-point spacing; see fixing holes and voids in drone DSMs.
Related
- Generating DSM and DTM from point clouds with PDAL
- Removing vegetation from DTM ground surfaces
- Fixing PDAL pipeline JSON schema errors
← Generating DSM and DTM from Point Clouds with PDAL
Figure 3 — The escape from the window tension. Nothing about SMRF requires a single pass, and a site with a warehouse and a kerb is a two-pass site.