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.

Shaved ridges and how to keep them A mountain profile with a sharp ridge and a steep river bank. The default SMRF ground surface, dashed, cuts across the ridge top and pulls back from the bank edge, losing several metres of terrain. The tuned surface, solid, follows the ridge and bank closely. Shaded areas show terrain misclassified as non-ground by the default settings. default: ridge shaved bank edge pulled back grey: true terrain · dashed: default SMRF · solid: steep-terrain settings

# 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

python
"""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:

text
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.0127

On 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.

The overall rate hides the problem Grouped bars of type I error. For all cells, default settings show 6.1 percent and steep settings 2.4 percent. For cells steeper than 20 degrees, default settings show 18.7 percent and steep settings 4.5 percent. The steep subset reveals how much ground the default settings were discarding on slopes. 6.1 %2.4 %18.7 %4.5 % all cells slope > 20° default steep

# 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.

Settings by terrain class Three terrain classes by median tile slope. Below 8 degrees, default settings with slope 0.15 and window 18. Between 8 and 20 degrees, intermediate settings with slope 0.3 and window 14. Above 20 degrees, steep settings with slope 0.6 and window 10. Each tile's median slope selects its class. 20°40° slope 0.15window 18slope 0.3window 14slope 0.6window 10 median tile slope selects the parameter set

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.