Building a Pit-Free Canopy Height Model

TL;DR: Normalize heights, then build several partial CHMs: one from all first returns and one each from first returns above 2, 5, 10, 15, 20 m … . Interpolate each partial CHM with a small search radius so gaps between high returns are not filled with low values, and take the per-cell maximum across the stack. Pits — low values punched into crowns by pulses that penetrated gaps — disappear because each crown is also represented by a layer that never saw the lower returns.

# Context and Motivation

This guide is part of Canopy Height Models. A CHM built as the maximum height above ground per cell has pits: cells inside crowns whose highest return came from a branch or the ground below, because the laser passed through a gap. Pits fragment crowns and break individual tree segmentation, which relies on smooth crowns with a single peak. Khosravipour, Skidmore, Isenburg and colleagues (2014) proposed a simple fix: compute partial CHMs using only returns above a series of height thresholds, so that within a tall crown the lower returns that caused pits are excluded, then combine the layers by taking the highest value per cell. The approach is easy to implement with PDAL and NumPy, and it improves tree detection noticeably in open-canopy forests.

Layers above thresholds, combined by maximum Three stacked profiles of a crown. The all-returns layer has a deep pit in the crown centre where a pulse reached the ground. The layer from returns above 5 metres omits that low return, so the crown surface is continuous. The layer above 10 metres covers only the crown top. The maximum of the stack follows the crown without the pit. all returns returns > 5 m returns > 10 m pit at the crown centre no low return: no pit crown top only pit-free CHM = per-cell maximum of all layers

# Prerequisites and Assumptions

  • A ground-classified tile for height normalization.
  • PDAL with writers.gdal, and NumPy and rasterio.
  • Density of about 5 pts/m² or more; very sparse data produces sparse upper layers.
  • A target CHM resolution, commonly 0.5 m.

# Step-by-Step Implementation

# Step 1 — Normalize heights

filters.hag_nn computes HeightAboveGround; ferry it into Z so rasterization works on heights.

# Step 2 — Choose thresholds

0 (all first returns), 2, 5, 10, 15, 20, 25 … up to the canopy’s maximum height, in steps of about 5 m.

# Step 3 — Rasterize each layer with a tight radius

For each threshold, keep first returns above it and rasterize the maximum with a radius of about one cell. The tight radius is what stops a layer from spreading high values into gaps; the original method uses a TIN with an edge-length limit for the same purpose.

# Step 4 — Stack by maximum

Read all layers and take np.fmax across them, ignoring NoData.

# Step 5 — Fill remaining gaps lightly

Cells still empty (true gaps between crowns) take the all-returns value, which is usually near zero there.

# Complete Working Example

python
"""Pit-free CHM by stacking partial CHMs from successive height thresholds."""
from __future__ import annotations

import json
import math
from pathlib import Path

import numpy as np
import pdal
import rasterio

SRC = "tiles/forest_0822.laz"
RES = 0.5
THRESHOLDS = [0, 2, 5, 10, 15, 20, 25, 30]
OUT = Path("out/chm_layers")


def grid(src: str) -> dict:
    b = pdal.Pipeline(json.dumps({"pipeline": [src]})).quickinfo["readers.las"]["bounds"]
    ox, oy = math.floor(b["minx"]), math.floor(b["miny"])
    return {"origin_x": ox, "origin_y": oy,
            "width": math.ceil((b["maxx"] - ox) / RES), "height": math.ceil((b["maxy"] - oy) / RES)}


def build_layers(src: str) -> list[Path]:
    OUT.mkdir(parents=True, exist_ok=True)
    g = grid(src)
    stages = [
        src,
        {"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
        {"type": "filters.hag_nn", "count": 2},
        {"type": "filters.ferry", "dimensions": "HeightAboveGround=>Z"},
        {"type": "filters.range", "limits": "ReturnNumber[1:1],Z[0:80]", "tag": "first"},
    ]
    paths = []
    for t in THRESHOLDS:
        path = OUT / f"chm_gt{t:02d}.tif"
        stages += [
            {"type": "filters.range", "inputs": ["first"], "limits": f"Z[{t}:80]", "tag": f"gt{t}"},
            {"type": "writers.gdal", "inputs": [f"gt{t}"], "filename": str(path), "resolution": RES,
             "radius": RES * 1.0, "output_type": "max", "data_type": "float32", "nodata": -9999, **g},
        ]
        paths.append(path)
    pdal.Pipeline(json.dumps({"pipeline": stages})).execute()
    return paths


def stack(paths: list[Path], dst: Path) -> np.ndarray:
    layers = []
    for p in paths:
        with rasterio.open(p) as ds:
            layers.append(ds.read(1, masked=True).filled(np.nan))
            profile = ds.profile
    chm = np.nanmax(np.stack(layers), axis=0)          # per-cell maximum across layers
    chm = np.where(np.isnan(chm), 0.0, np.clip(chm, 0, None))
    with rasterio.open(dst, "w", **profile) as out:
        out.write(chm.astype("float32"), 1)
    return chm


if __name__ == "__main__":
    layers = build_layers(SRC)
    chm = stack(layers, Path("out/chm_pitfree.tif"))
    print(f"pit-free CHM: max {chm.max():.1f} m, canopy cells {np.mean(chm > 2):.1%}")

All layers are written in one branched pipeline, so the tile is read and normalized once — see branching a PDAL pipeline with tags.

Fewer pits, better tree counts Two pairs of bars. Pit cells per hectare fall from about 1,900 in the standard maximum CHM to about 150 in the pit-free CHM. Tree detection F-score against field plots rises from 0.71 to 0.82 with the pit-free CHM, because crowns are no longer fragmented into several peaks. 1,9001500.710.82 pit cells per ha tree detection F-score standard pit-free illustrative open conifer stand at 12 pts/m²

# Key Parameter Table

Setting Typical value Effect
thresholds 0, 2, 5, 10, 15, 20 … Step of about 5 m up to canopy maximum
layer radius ≈ 1 cell Small enough not to fill crown gaps from edges
output_type max Top of each layer
resolution 0.5 m Balance of crown detail and density
stacking per-cell maximum Keeps the highest non-pit value
gap fill all-returns layer True gaps stay low

# Verification

  • Pit count. Count cells more than 2 m below the 3×3 median in the standard and pit-free CHMs; the pit-free count should fall sharply.
  • Heights preserved. Tree tops (local maxima) should have the same heights in both CHMs; the method removes pits, it does not raise tops.
  • Tree segmentation. Run watershed segmentation on both and compare against plots.

# Gotchas and Edge Cases

Radius too large. A generous radius in upper layers spreads crown-top values sideways into gaps between trees, merging neighbouring crowns. Keep it near one cell.

Sparse upper layers. On low-density data, the highest layers contain only a handful of returns and add speckle. Stop the thresholds where layers become too sparse to form crowns.

Thresholds relative to tree size. Short stands (under 10 m) need finer steps (1–2 m); tall forest can use 5 m steps.

Keep the layer radius tight Two crowns separated by a narrow gap. With a one-cell radius, the upper layer keeps the gap, and two crowns remain distinct. With a three-cell radius, high values spread across the gap and the two crowns merge into one blob, which segmentation would count as one tree. radius 1 cell radius 3 cells two crowns, gap kept crowns merged across the gap

Buildings. In mixed urban areas, roofs appear in the CHM too. Exclude class 6 before building layers if the CHM is meant for vegetation only.

# Frequently Asked Questions

What is a pit-free canopy height model?

A CHM in which the spurious low cells inside crowns, caused by laser pulses passing through gaps, have been removed. The layered method builds partial CHMs from returns above successive heights and combines them with a per-cell maximum.

Why do pits matter?

They fragment crowns into several apparent peaks and holes, which makes individual tree detection count too many trees and distorts crown metrics and canopy cover.

Is a pit-free CHM the same as smoothing?

No. Smoothing lowers tree tops and blurs crown edges. The layered method keeps measured heights and only replaces pit cells with values from higher layers.

How many layers do I need?

Enough to span the canopy height range in steps of about 5 metres for tall forest, or 1 to 2 metres for short vegetation. Beyond the tallest trees, extra layers are empty and add nothing.