Computing Height Above Ground with filters.hag_nn

TL;DR: {"type": "filters.hag_nn", "count": 6, "max_distance": 25.0, "allow_extrapolation": false} after a ground classification, then check that ground points come back at a height of zero — if they do not, the problem is the classifier, not the filter.

# Context and Motivation

This guide is part of Canopy Height Models with filters.hag_nn. The parent covers the whole workflow; this page is about the one stage that does the arithmetic, because nearly every bad canopy height traces back to how it was configured.

What the stage does is simple enough to state exactly. For each point not classified as ground, it finds the count nearest points that are classified as ground, averages their elevations to get an estimated ground level beneath the point, and writes the difference into a new HeightAboveGround dimension. Every part of that sentence is a place where a decision has to be made, and PDAL’s defaults are not the right ones for forestry.

One canopy point, six ground neighbours A cross-section with a canopy return above sloping terrain. The six nearest ground-classified points are highlighted, their elevations averaged to give an estimated ground level directly beneath the canopy point, and the height above ground is the difference between the point and that estimate. With a count of one the estimate would be a single ground return and would carry that return's own error at full amplitude. canopy return at 148.6 m HeightAboveGround = 26.4 m the six nearest ground points, averaged → 122.2 m beneath this point with count 1 the estimate is a single return and inherits that one return’s error in full

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.4+
Classification 2 present the filter measures against ground-classified points and nothing else
Projected metric CRS max_distance is in CRS units
Memory the stage builds an index over the ground points and does not stream
Clean input blunders in the ground class corrupt every neighbour that uses them

# Step-by-Step Implementation

# Step 1 — Confirm ground exists

bash
pdal info tile.laz --stats --dimensions Classification | grep -i counts

If class 2 is absent, hag_nn writes zero everywhere and reports success.

# Step 2 — Choose count

Six is a good default. One makes each height depend on a single ground return. Above about eight the surface stops improving and the search cost keeps rising.

# Step 3 — Bound the search with max_distance

json
{"type": "filters.hag_nn", "count": 6, "max_distance": 25.0}

Without it, a point over a large ground-free area searches arbitrarily far and produces a height measured against terrain a hundred metres away.

# Step 4 — Decide about extrapolation deliberately

allow_extrapolation: false leaves points beyond the ground hull unestimated. That is usually right: a gap you can see beats a number you cannot trust.

# Step 5 — Persist the dimension

json
{"type": "writers.las", "extra_dims": "HeightAboveGround=float",
 "minor_version": 4, "dataformat_id": 6}

# Complete Working Example

python
"""Compute height above ground and verify it against the ground class."""
from __future__ import annotations

import json
import logging
from pathlib import Path

import numpy as np
import pdal

LOG = logging.getLogger("hag")


def normalise(src: Path, dst: Path, count: int = 6, max_distance: float = 25.0) -> int:
    spec = json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(src)},
        {"type": "filters.hag_nn", "count": count,
         "max_distance": max_distance, "allow_extrapolation": False},
        {"type": "writers.las", "filename": str(dst), "compression": "laszip",
         "minor_version": 4, "dataformat_id": 6,
         "extra_dims": "HeightAboveGround=float", "forward": "all"},
    ]})
    return pdal.Pipeline(spec).execute()


def verify(path: Path) -> dict:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(path)}]}))
    p.execute()
    arr = p.arrays[0]

    if "HeightAboveGround" not in arr.dtype.names:
        raise AssertionError("dimension absent — extra_dims was not set on the writer")

    hag = arr["HeightAboveGround"]
    ground = hag[arr["Classification"] == 2]
    if len(ground) == 0:
        raise AssertionError("no ground-classified points — classify before running hag_nn")

    ground_median = float(np.median(ground))
    if abs(ground_median) > 0.10:
        raise AssertionError(
            f"ground sits at {ground_median:.2f} m rather than zero — the classifier is suspect"
        )

    return {
        "points": int(len(hag)),
        "ground_median": round(ground_median, 3),
        "negative_fraction": round(float((hag < -0.5).mean()), 5),
        "p99": round(float(np.percentile(hag, 99)), 2),
        "max": round(float(hag.max()), 2),
    }


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    normalise(Path("classified.laz"), Path("normalised.laz"))
    print(json.dumps(verify(Path("normalised.laz")), indent=2))
What max_distance stops happening A canopy point in the middle of a wide closed-canopy patch. Without a distance cap the nearest ground neighbours are eighty metres away on the far side of a slope, and the height is computed against terrain that has nothing to do with this location. With a twenty-five metre cap the point is simply left unestimated, which is visible rather than plausible. closed canopy — no ground returns nearest ground: 80 m away, and 12 m lower without max_distance this point reports a canopy height of 34 m; with a 25 m cap it reports nothing, and the gap in the raster tells the truth about what the acquisition captured.

# Key Parameter Table

Parameter Type Default Guidance
count int 1 4–8; one makes every height depend on a single return
max_distance float unbounded 15–30 m for airborne forestry; always set it
allow_extrapolation bool false Leave false unless you can defend an invented ground surface
ground_class int 2 Change only if your schema differs from ASPRS
output dimension HeightAboveGround Must be listed in extra_dims to reach the file

# Verification

Ground is at zero. The strongest single check, asserted above. A systematic offset means the ground class is wrong, not the filter.

The dimension reached the file. Also asserted — the failure mode where everything works in memory and nothing is written.

Negative heights are rare. A handful is normal around breaklines. A percent or more below −0.5 m means blunders in the ground class.

Heights are physically plausible. The 99th percentile against the tallest species locally.

What count does to the interpolated ground The interpolated ground surface beneath a canopy patch, drawn for four values of count. With one neighbour the surface is jagged and follows every individual ground return, including a low blunder. With four it is smoother. With eight it is smooth but has begun to cut the corner on a real break in slope. The true terrain is drawn for comparison. true terrain count 1 — follows every return, blunders included count 6 — smooth, still follows the break count 20 — smoother, and has cut the corner on a real slope break smoothing the ground raises canopy heights above concave terrain

# Gotchas and Edge Cases

All heights zero, no error. No ground class. This is the single most common report, and the fix is upstream.

A ring of tall values around a clearing. Extrapolation at the hull edge. Turn it off.

The stage dominates the runtime. Expected: it is a nearest-neighbour search per non-ground point. Reduce the input before it rather than tuning it.

Buildings become “canopy”. hag_nn measures height above ground regardless of what the point struck. If the product is about vegetation, filter to the vegetation classes after normalising — the codes are in understanding ASPRS classification codes.

# Frequently Asked Questions

Why is every height above ground zero?

Because no points carry Classification 2. The filter measures against ground-classified points, and with none present it has nothing to subtract, so it writes zero and the pipeline succeeds. Classification has to happen before the stage, in the same pipeline or an earlier one.

What does the count parameter actually change?

How many ground returns are averaged to estimate the ground beneath each point. With one, every height inherits that single return’s error at full amplitude. With six the estimate is smooth and still follows real breaks in slope. Above about eight the surface stops improving and starts cutting corners on concave terrain.

Should I set max_distance?

Always. Without it a point over a large ground-free area searches arbitrarily far and ends up measured against terrain a hundred metres away, which produces a confident and meaningless number. Fifteen to thirty metres suits most airborne forestry work.

Why do buildings appear in my canopy height model?

Because height above ground is exactly that — it does not care what the point struck. Filter to the vegetation classes after normalising if the product is about vegetation; the stage itself is deliberately agnostic.