Tuning SMRF for Steep Terrain
TL;DR: On steep terrain, raise slope from the default 0.15 toward 0.4–0.8, shrink window from 18 m to 8–12 m, and raise scalar to about 1.5–2 so the elevation threshold grows with local slope. Evaluate type I errors on steep cells separately — the overall rate hides the ridges and banks SMRF is cutting off — and switch settings per tile by median slope rather than using one compromise everywhere.
# Context and Motivation
This guide is part of SMRF Ground Classification. SMRF builds a minimum surface on a grid, opens it with progressively larger windows, and rejects points that rise above the opened surface by more than a threshold that depends on local slope. The default settings suit rolling and urban terrain. On mountains, the progressive opening treats the tops of steep slopes the way it treats buildings: as things standing above the local minimum. The result is a DTM with ridges shaved flat, cliff edges rounded and river banks pushed back — type I errors concentrated exactly where terrain matters for slope stability, hydrology and road design.
Three parameters control this behaviour, and they have to move together.
# Prerequisites and Assumptions
- PDAL 2.x with
filters.smrf. - Noise removed (classes 7, 18) and a reference ground classification or checkpoints for at least one steep tile.
- A slope raster, or a quick DTM to compute one, for stratifying the evaluation.
# Step-by-Step Implementation
# Step 1 — Measure the terrain
Compute the median and 90th-percentile slope per tile from a coarse DTM. Tiles with median slope above about 15° need steep settings.
# Step 2 — Raise slope
SMRF’s slope is a gradient (rise over run), not degrees. A value of 0.15 corresponds to about 8.5°; mountainous terrain needs 0.4–0.8 (about 22–39°).
# Step 3 — Shrink the window
window should be only as large as the largest non-ground object — in mountains usually trees, not warehouses. 8–12 m keeps the opening from spanning ridges.
# Step 4 — Scale the threshold with slope
threshold is the base elevation tolerance; scalar multiplies local slope to add to it. Raising scalar lets points on steep ground sit further above the opened surface and still count as ground.
# Step 5 — Evaluate on steep cells
Report type I and II errors separately for cells above 20° — that is where the settings are tested.
# Complete Working Example
"""Compare default and steep-terrain SMRF settings, stratified by slope."""
from __future__ import annotations
import json
import numpy as np
import pandas as pd
import pdal
REF = "reference/mountain_ref_1204.laz" # reference classification in Classification
SETTINGS = {
"default": {"slope": 0.15, "window": 18.0, "threshold": 0.5, "scalar": 1.25, "cell": 1.0},
"steep": {"slope": 0.6, "window": 10.0, "threshold": 0.45, "scalar": 1.8, "cell": 1.0},
}
def classify(params: dict) -> np.ndarray:
spec = {"pipeline": [
REF,
{"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
{"type": "filters.ferry", "dimensions": "Classification=>RefClass"},
{"type": "filters.assign", "value": ["Classification = 1"]},
{"type": "filters.smrf", **params},
]}
p = pdal.Pipeline(json.dumps(spec))
p.execute()
return p.arrays[0]
def local_slope(a: np.ndarray, cell: float = 5.0) -> np.ndarray:
"""Slope in degrees per point from a coarse min-Z grid of reference ground."""
g = a[a["RefClass"] == 2]
x0, y0 = a["X"].min(), a["Y"].min()
cols = int((a["X"].max() - x0) // cell) + 1
rows = int((a["Y"].max() - y0) // cell) + 1
z = np.full((rows, cols), np.nan)
r, c = ((g["Y"] - y0) // cell).astype(int), ((g["X"] - x0) // cell).astype(int)
np.fmin.at(z, (r, c), g["Z"])
gy, gx = np.gradient(z, cell)
s = np.degrees(np.arctan(np.hypot(gx, gy)))
return s[((a["Y"] - y0) // cell).astype(int), ((a["X"] - x0) // cell).astype(int)]
def score(a: np.ndarray, mask: np.ndarray) -> tuple[float, float]:
ref, got = (a["RefClass"] == 2) & mask, (a["Classification"] == 2) & mask
notref = (a["RefClass"] != 2) & mask
return float((ref & ~got).sum() / max(ref.sum(), 1)), float((notref & got).sum() / max(notref.sum(), 1))
if __name__ == "__main__":
rows = []
for name, params in SETTINGS.items():
a = classify(params)
s = local_slope(a)
for label, m in (("all", np.ones(len(a), bool)), ("slope>20°", np.nan_to_num(s) > 20)):
t1, t2 = score(a, m)
rows.append({"settings": name, "cells": label, "type1": round(t1, 4), "type2": round(t2, 4)})
print(pd.DataFrame(rows).to_string(index=False))Illustrative results on a mountain tile:
settings cells type1 type2
default all 0.0612 0.0049
default slope>20° 0.1874 0.0061
steep all 0.0241 0.0093
steep slope>20° 0.0452 0.0127On steep cells, the default settings reject nearly one in five ground points; the steep settings cut that to under 5 percent at the cost of a modest rise in type II errors.
# Key Parameter Table
| Parameter | Default | Steep terrain | Why |
|---|---|---|---|
slope |
0.15 | 0.4–0.8 | Gradient allowed before a point is considered non-ground |
window |
18 m | 8–12 m | Largest object to remove; smaller keeps ridges |
threshold |
0.5 m | 0.4–0.5 m | Base elevation tolerance |
scalar |
1.25 | 1.5–2.0 | Threshold growth with local slope |
cell |
1.0 m | 1.0 m | Grid for the minimum surface; near point spacing |
Slope as gradient: 0.15 ≈ 8.5°, 0.3 ≈ 16.7°, 0.5 ≈ 26.6°, 0.8 ≈ 38.7°.
# Verification
- Stratified errors, as above; steep-cell type I should drop substantially without type II doubling.
- Ridge profiles. Draw profiles across a few ridge crests and banks from the new DTM against the reference ground; the tuned surface should reach the crest.
- Hillshade. Look for flat-topped ridges (still too aggressive) and lumpy slopes (now accepting shrubs).
# Gotchas and Edge Cases
Mixed tiles. A tile with a valley floor town and steep sides needs both behaviours. Split by slope: classify with steep settings, then reclassify the flat part with default settings using a where clause on a slope-derived dimension, or tile smaller.
Buildings on slopes. Raising slope lets more building roofs pass as ground on hillside towns. Keep window large enough for the largest building, or run a building classification afterwards and remove class 6 from ground.
Cliffs. Near-vertical faces return few ground points and SMRF has little to work with. Accept that cliff faces will be interpolated, and use breaklines if the deliverable must show them.
Units of slope. Passing degrees (for example 30) as slope makes SMRF accept almost everything as ground. The parameter is a gradient.
# Frequently Asked Questions
Why does SMRF remove ridges and bank tops?
Its progressive morphological opening treats anything that rises steeply above the local minimum surface as a non-ground object. On steep terrain, ridge crests and bank edges look like that, so with default settings they are rejected.
What SMRF slope value should I use in mountains?
Typically 0.4 to 0.8, which is a gradient of roughly 22 to 39 degrees, together with a smaller window and a higher scalar. Tune against reference ground on a representative steep tile.
Is the SMRF slope parameter in degrees?
No. It is a gradient, rise over run. A value of 0.15 is about 8.5 degrees; convert with the tangent of the angle.
Can I use different settings within one tile?
Yes, by computing a slope class per point or per cell and running SMRF with different parameters under where clauses, or more simply by tiling smaller so each tile is mostly one terrain type.
# Related
- SMRF Ground Classification — how SMRF works
- Tuning SMRF for Forested Terrain — the canopy case
- Benchmarking SMRF Against Reference Ground Points — scoring methods
- CSF vs SMRF for Forested Ground — an alternative filter on slopes
- Generating Slope and Aspect Rasters with gdaldem — the slope raster for stratification