Validating Classification Against Manual Samples
Classification results are almost always accepted on sight. Somebody colours the cloud by class, rotates it, decides the buildings look like buildings, and the run proceeds. That check is real but narrow: it detects gross failures — everything in one class, a roof shaped like a hill — and is blind to the two errors that actually reach a client. A uniform bias in the ground class produces a terrain model that is smooth, plausible and wrong. A modest confusion rate between vegetation and buildings produces footprints that are fine everywhere the reviewer happened to look.
This page covers the sampling design and the statistics that turn “it looks right” into a number, and it is the check referenced throughout classifying point clouds with PDAL and Python.
Why sighted inspection fails on exactly the errors that matter
A rendering shows the shape of a surface. Shape is a function of the spread of errors around their own mean — the roughness — and vegetation misclassification barely changes that. A ground class that consistently sits on top of 25 cm of grass is as smooth as one sitting on the soil; it is simply 25 cm higher. There is no visual cue whatsoever, because there is nothing in the frame to compare against.
The same logic applies to class confusion. If four percent of tree points are labelled building, they form scattered fragments rather than a visible block, and a reviewer rotating the cloud sees exactly what they expect. The error surfaces later, when somebody dissolves the building class into polygons and gets three hundred spurious footprints.
Both errors are detectable with very few samples, because both are mean effects rather than tail effects, and means converge quickly.
Figure 1 — The economics of validation. Thirty samples resolve the errors that matter; nobody needs three hundred.
Minimal reproducible solution
The sample set must be placed by a rule rather than by hand, because hand-placed samples land where the technician expects to find problems, and that is a biased estimator of the site. Stratify by class and by an independent covariate — slope works well — then draw at random inside each stratum.
import json
import numpy as np
import pdal
def draw_validation_samples(las_path: str, per_stratum: int = 8,
seed: int = 0) -> list[dict]:
"""Stratified random sample locations for manual classification checking.
Strata are (class, slope band). Hand-picked samples cluster where the
technician suspects trouble, which measures the technician rather than the
classification; a seeded random draw inside each stratum does not.
"""
pipe = pdal.Pipeline(json.dumps({"pipeline": [
las_path,
{"type": "filters.hag_nn"},
{"type": "filters.covariancefeatures", "knn": 24,
"feature_set": "Dimensionality"},
]}))
pipe.execute()
a = pipe.arrays[0]
rng = np.random.default_rng(seed)
slope_band = np.digitize(1.0 - a["Planarity"], [0.05, 0.2, 0.5])
samples = []
for cls in np.unique(a["Classification"]):
for band in range(4):
idx = np.flatnonzero((a["Classification"] == cls) & (slope_band == band))
if idx.size == 0:
continue
take = rng.choice(idx, size=min(per_stratum, idx.size), replace=False)
for i in take:
samples.append({
"x": float(a["X"][i]), "y": float(a["Y"][i]),
"z": float(a["Z"][i]),
"assigned_class": int(a["Classification"][i]),
"slope_band": int(band),
"true_class": None, # filled in by the reviewer
})
return samples
Leaving true_class as None and having a reviewer fill it in from the imagery — without seeing assigned_class — is what keeps the exercise honest. A reviewer shown the machine’s answer agrees with it far more often than one who is not.
From the completed sheet, two numbers come out: a confusion matrix for the class labels, and a height bias for the ground class.
import numpy as np
def confusion_and_bias(samples: list[dict],
measured_ground_z: dict[int, float]) -> dict:
"""Confusion matrix over all classes, plus ground-class height bias.
`measured_ground_z` maps a sample index to a surveyed ground height where
one exists — checkpoints, or a total-station shot. Absent that, the bias
term is skipped and only the label agreement is reported.
"""
labels = sorted({s["assigned_class"] for s in samples}
| {s["true_class"] for s in samples if s["true_class"]})
index = {c: i for i, c in enumerate(labels)}
m = np.zeros((len(labels), len(labels)), dtype=int)
for s in samples:
if s["true_class"] is None:
continue
m[index[s["true_class"]], index[s["assigned_class"]]] += 1
per_class = {}
for c, i in index.items():
tp = m[i, i]
per_class[int(c)] = {
"recall": float(tp / m[i].sum()) if m[i].sum() else float("nan"),
"precision": float(tp / m[:, i].sum()) if m[:, i].sum() else float("nan"),
}
resid = [samples[i]["z"] - z for i, z in measured_ground_z.items()
if samples[i]["assigned_class"] == 2]
r = np.asarray(resid, dtype=float)
return {
"labels": [int(c) for c in labels],
"matrix": m.tolist(),
"per_class": per_class,
"overall_accuracy": float(np.trace(m) / m.sum()) if m.sum() else float("nan"),
"ground_bias_m": float(np.mean(r)) if r.size else None,
"ground_rmse_m": float(np.sqrt(np.mean(r ** 2))) if r.size else None,
"ground_n": int(r.size),
}
Edge-case matrix
| Sampling variant | What it measures | Verdict |
|---|---|---|
| Hand-picked “interesting” points | The reviewer’s suspicions | Not a validation |
| Uniform random over the whole cloud | Mostly ground, since ground dominates | Rare classes never sampled |
| Stratified by class | Every class, including rare ones | Correct; weight results by class share |
| Stratified by class and slope | Class and terrain interaction | Best; catches slope-dependent failures |
| Reviewer sees the assigned class | Agreement, not accuracy | Blind the reviewer |
| Samples drawn from one flight line | That line’s geometry | Spread across the survey |
| Fewer than ~10 per class | Nothing usable | Increase or drop the class from the claim |
| Checkpoints reused as samples | Ground bias only | Fine for bias; not for label accuracy |
The last row is worth taking advantage of. Any project doing checkpoint-based accuracy validation already has surveyed ground points, and they are exactly the truth data the ground-bias term needs — at no extra field cost.
Figure 2 — One matrix, two findings. The off-diagonal cell in the second row is both a label error and the cause of a systematic elevation offset.
Verification snippet
The validation itself needs a gate, or it becomes a number somebody records and nobody reads.
def gate_classification(report: dict, *, min_ground_recall: float = 0.90,
max_ground_bias_m: float = 0.08,
min_overall: float = 0.85) -> None:
"""Fail the run on a classification that does not meet the stated standard."""
g = report["per_class"].get(2, {})
if g.get("recall", 0) < min_ground_recall:
raise ValueError(
f"ground recall {g.get('recall'):.2f} below {min_ground_recall} — "
"terrain products would inherit the gaps")
bias = report.get("ground_bias_m")
if bias is not None and abs(bias) > max_ground_bias_m:
raise ValueError(
f"ground bias {bias:+.3f} m exceeds {max_ground_bias_m} m — "
"every elevation in the deliverable is offset by this amount")
if report["overall_accuracy"] < min_overall:
raise ValueError(f"overall accuracy {report['overall_accuracy']:.2f} below standard")
Stating the bias tolerance in metres rather than as a percentage is deliberate: a client’s tolerance is a distance, and converting it into a classification metric is the pipeline’s job, not theirs.
Figure 3 — Validation as a stage with an exit condition, not a report appended after the fact.
Keeping the validation cheap enough to actually run
A check that takes a day is a check that gets skipped under deadline, and a skipped check is worse than no check because the pipeline still claims to have one. Three practices keep the cost low enough that it happens on every job.
Reuse the review effort across runs. The sample locations are coordinates, and the true labels attached to them do not change when a parameter does. Store the reviewed sheet keyed by coordinate, and a re-run after a parameter change is a re-scoring rather than a re-review — seconds instead of an hour. Only new strata, or a materially different flight, need fresh labelling.
import json
from pathlib import Path
def rescore_against_cached_truth(samples: list[dict], truth_cache: str,
tol: float = 0.05) -> list[dict]:
"""Attach previously reviewed labels to a freshly classified cloud.
Matching on rounded coordinates is enough: the samples are drawn from the
same survey, so a point either exists at that location or the sample is
new and needs a reviewer.
"""
cache = {tuple(k.split(",")): v
for k, v in json.loads(Path(truth_cache).read_text()).items()}
out = []
for s in samples:
key = (f"{s['x']:.2f}", f"{s['y']:.2f}")
s = dict(s, true_class=cache.get(key))
out.append(s)
return out
Review in the imagery, not in the cloud. Deciding whether a point is building or vegetation is far faster looking at the orthomosaic at that coordinate than rotating a three-dimensional view. A reviewer can label sixty points in twenty minutes from a tiled image viewer; the same work in a point-cloud viewer takes an hour and produces worse labels, because occlusion makes the three-dimensional view genuinely harder to read.
Stop at the precision you need. The confidence interval on the bias estimate narrows with the square root of the sample count, so the marginal value of sample sixty-one is small. Fixing the count at thirty per class, and only increasing it when a result lands near a gate threshold, keeps the effort proportional to the decision being made.
A fourth practice is organisational rather than technical: record the validation result in the same manifest as the classification parameters, so a run’s claimed accuracy travels with the file rather than living in a spreadsheet that diverges from it. The same manifest discipline is described for job orchestration in writing a manifest-driven batch runner for ODM.
When to escalate
- Ground recall is high and the bias persists. The classifier is finding ground correctly and the ground itself is offset — that is a vertical datum problem, handled in geoid models and vertical datum automation, not a classification one.
- Accuracy varies strongly between strata. A classification that works on flat ground and fails on slopes needs terrain-adaptive parameters, not a different threshold.
- No surveyed ground exists anywhere on site. Label accuracy can still be measured; height bias cannot. Say so explicitly in the deliverable rather than implying an accuracy that was never tested.