Benchmarking SMRF Against Reference Ground Points

TL;DR: Interpolate the classified ground surface at each control point, difference it against the surveyed elevation, and report RMSE, mean bias and the 95th percentile separately for open, sloped and vegetated terrain — a single aggregate number hides exactly the failure you are looking for.

# Context and Motivation

This guide is part of SMRF Ground Classification in PDAL. Tuning without measurement is guesswork with confidence, and the measurement is not difficult — it just has to be set up so it can fail.

The subtlety is that ground classification has two independent error modes and one aggregate statistic cannot express both. Omission is real ground rejected, which shows up as voids and, where interpolation fills them, as a surface pulled toward whatever survived. Commission is objects accepted, which shows up as a surface pulled up. A parameter change that halves one usually increases the other, so an RMSE that improved tells you nothing about which trade you made. Reporting them separately, and by terrain class, is what makes the number actionable.

One aggregate number, three different stories Ground surface RMSE for two SMRF parameter sets, broken down by terrain class. The aggregate RMSE is nearly identical at 0.13 and 0.14 metres. Underneath, the conservative settings are twice as accurate on open ground and much worse on steep slopes, while the relaxed settings are the reverse. The aggregate figure conceals the whole comparison. RMSE against 412 surveyed control points slope 0.15 slope 0.35 aggregate 0.13 m 0.14 m open, flat 0.06 m 0.12 m steep slope 0.29 m — ridges stripped 0.11 m under canopy 0.18 m 0.24 m

# Prerequisites and Assumptions

Requirement Detail
Control points surveyed elevations, with a documented vertical datum
Matching datums control and cloud in the same vertical CRS, per setting a vertical CRS
Terrain classes each control point labelled open, sloped or vegetated
PDAL and numpy for interpolation and statistics
Enough points per class 30 is a working minimum; 10 tells you nothing

The datum row is where most benchmarks go wrong before they start. Control in orthometric heights against a cloud in ellipsoidal heights produces a uniform thirty-metre bias that looks like a catastrophic classification failure.

# Step-by-Step Implementation

# Step 1 — Classify and keep ground only

json
{"type": "filters.range", "limits": "Classification[2:2]"}

# Step 2 — Interpolate the surface at each control location

Take the mean elevation of ground returns within a small radius — one metre is typical — of each control point. A control point with no ground returns nearby is an omission failure and must be counted as such rather than dropped.

# Step 3 — Difference and split by class

Signed differences, so the mean is a bias and not another magnitude.

# Step 4 — Report bias, RMSE and the tail, per class

The 95th percentile of absolute error is what a client notices; the mean bias is what a systematic problem looks like.

# Complete Working Example

python
"""Benchmark a classified ground surface against surveyed control points."""
from __future__ import annotations

import json
from pathlib import Path

import numpy as np
import pdal


def ground_points(src: Path) -> np.ndarray:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(src)},
        {"type": "filters.range", "limits": "Classification[2:2]"},
    ]}))
    p.execute()
    return p.arrays[0]


def surface_at(ground: np.ndarray, x: float, y: float, radius: float = 1.0) -> float | None:
    dx = ground["X"] - x
    dy = ground["Y"] - y
    near = (dx * dx + dy * dy) <= radius * radius
    if not near.any():
        return None
    return float(ground["Z"][near].mean())


def benchmark(src: Path, control: list[dict], radius: float = 1.0) -> dict:
    ground = ground_points(src)
    per_class: dict[str, list[float]] = {}
    missing: dict[str, int] = {}

    for pt in control:
        cls = pt["terrain"]
        z = surface_at(ground, pt["x"], pt["y"], radius)
        if z is None:
            missing[cls] = missing.get(cls, 0) + 1
            continue
        per_class.setdefault(cls, []).append(z - pt["z"])

    out = {}
    for cls, diffs in sorted(per_class.items()):
        d = np.asarray(diffs)
        out[cls] = {
            "n": int(d.size),
            "no_ground_nearby": missing.get(cls, 0),
            "bias_m": round(float(d.mean()), 3),
            "rmse_m": round(float(np.sqrt((d ** 2).mean())), 3),
            "p95_abs_m": round(float(np.percentile(np.abs(d), 95)), 3),
        }
    all_diffs = np.concatenate([np.asarray(v) for v in per_class.values()])
    out["aggregate"] = {
        "n": int(all_diffs.size),
        "bias_m": round(float(all_diffs.mean()), 3),
        "rmse_m": round(float(np.sqrt((all_diffs ** 2).mean())), 3),
    }
    return out


if __name__ == "__main__":
    control = json.loads(Path("control.json").read_text())
    print(json.dumps(benchmark(Path("classified.laz"), control), indent=2))
Every parameter set is a point on one trade-off Six SMRF parameter sets plotted with omission — real ground rejected — on one axis and commission — objects accepted as ground — on the other. They fall along a curve: no setting reduces both at once. The set closest to the origin is the best compromise for this terrain, and which point on the curve you want depends on whether voids or bumps hurt more downstream. slope 0.05 0.10 0.15 — best compromise here 0.25 0.35 0.50 commission — objects kept as ground omission an RMSE that improved tells you the point moved; it does not tell you which way along the curve

# Key Parameter Table

Choice Value Why
search radius 1.0 m Large enough to find returns, small enough not to smooth terrain
statistic bias, RMSE, p95 Three numbers, three different failures
grouping terrain class The whole point; aggregates hide the trade
missing points counted, not dropped A control point with no ground nearby is the omission signal
minimum n per class 30 Below that the RMSE is noise

# Verification

No uniform bias. A mean of −30 m across every class is a datum problem, not a classifier problem.

Missing counts are reported. Silently dropping control points where classification found no ground turns the worst failure into a better-looking number.

The comparison is fair. Both parameter sets benchmarked against the same control on the same tile, with only the parameters changed.

A benchmark is only as honest as its control The 412 control points in this benchmark split unevenly: 268 on open flat ground, 96 on slopes and 48 under canopy. The class that most needs measuring has the fewest points, which is typical because surveying under canopy is hard — and it means the aggregate RMSE is dominated by the easiest terrain. open, flat 268 — 65% of the control sloped 96 points under canopy 48 — the class that matters most where the surveyor could actually stand report per-class statistics and the per-class counts together — a 0.24 m RMSE from 48 points is a different claim from the same number out of 400, and only one of them supports a parameter change.

# Gotchas and Edge Cases

Control points on hard surfaces are easy. A benchmark made only of road-centreline points reports a flattering RMSE that says nothing about vegetated terrain.

Interpolation radius interacts with slope. On steep ground a one-metre radius spans real elevation change, which appears as error. Shrink it or model the local slope.

A better RMSE can be a worse surface. Halving commission by rejecting more ground improves nothing if it strips ridges — which is precisely what the per-class breakdown reveals.

# Frequently Asked Questions

Why report errors by terrain class?

Because ground classification has two opposing error modes and one aggregate number cannot express both. Two parameter sets can produce almost identical overall RMSE while one is twice as accurate on open ground and far worse on slopes. The breakdown is what makes the comparison actionable.

What should I do with control points where no ground was found?

Count them. A control point with no ground return nearby is the clearest possible omission signal, and dropping it from the statistics converts the worst failure mode into a better-looking RMSE.

My benchmark shows a uniform 30 metre bias — what is wrong?

The datums, almost certainly. Control in orthometric heights compared against a cloud in ellipsoidal heights produces exactly this, and it looks like a catastrophic classification failure. Check the vertical CRS on both sides before touching any parameter.

How many control points do I need?

At least thirty per terrain class for the RMSE to mean anything. A benchmark with ten points in a class reports noise, and a benchmark made entirely of road-centreline points reports a flattering number that says nothing about vegetated ground.