Extracting Individual Tree Heights from a CHM
TL;DR: Smooth the CHM slightly, find local maxima with a window scaled to expected crown radius, and treat every result as a candidate until it survives a minimum-height and minimum-separation test — then report the distribution, because individual heights carry a systematic negative bias no algorithm removes.
# Context and Motivation
This guide is part of Canopy Height Models with filters.hag_nn. Having a raster of canopy height invites the obvious next question — how tall is each tree — and the honest answer is that a CHM constrains it rather than answers it.
Two facts set the limits. A laser pulse strikes the upper crown somewhere, rarely the apex, so measured heights run one to two metres low, more in narrow-crowned conifers and at low pulse density. And a local maximum in a raster is a bump, not a tree: a large crown produces several, two adjacent small crowns produce one, and a branch tip near a gap edge produces one that belongs to nothing. Detection is therefore a search with parameters, and the parameters encode what you already believe about the stand.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| A CHM | from rasterizing a canopy height model |
scipy and rasterio |
for the filtering and raster I/O |
| Expected crown radius | from the species and age of the stand; the single most important input |
| Field plots | if the output will be quoted as absolute heights rather than compared |
# Step-by-Step Implementation
# Step 1 — Smooth, but only just
A CHM at one metre carries single-cell spikes from individual branch returns. A small Gaussian removes them without moving the apexes.
smoothed = scipy.ndimage.gaussian_filter(chm, sigma=0.7)# Step 2 — Find local maxima with a crown-scaled window
peaks = smoothed == scipy.ndimage.maximum_filter(smoothed, size=window_cells)The window should be roughly the diameter of the smallest crown you intend to resolve. Too small splits crowns; too large merges them.
# Step 3 — Reject candidates below a minimum height
Anything under two metres is not a tree for most purposes, and rejecting it removes the majority of spurious detections at once.
# Step 4 — Enforce a minimum separation
Where two candidates sit within one crown radius, keep the taller. This is the cheap approximation to crown delineation and gets most of the benefit.
# Step 5 — Report the distribution, not a table of trees
Stem density, height percentiles and the height histogram are defensible. A list of individual trees with heights to two decimal places is not, unless it has been validated against plots.
# Complete Working Example
"""Detect candidate tree tops in a CHM and report the stand distribution."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import rasterio
from scipy import ndimage
def detect(chm_path: Path, crown_radius_m: float = 2.5,
min_height_m: float = 2.0, sigma_cells: float = 0.7) -> dict:
with rasterio.open(chm_path) as src:
chm = src.read(1).astype("float32")
transform = src.transform
nodata = src.nodata
cell = abs(transform.a)
valid = np.isfinite(chm)
if nodata is not None:
valid &= chm != nodata
work = np.where(valid, chm, 0.0)
smoothed = ndimage.gaussian_filter(work, sigma=sigma_cells)
window = max(3, int(round(2 * crown_radius_m / cell)) | 1) # odd, >= 3
maxima = smoothed == ndimage.maximum_filter(smoothed, size=window)
candidates = maxima & valid & (work >= min_height_m)
rows, cols = np.nonzero(candidates)
heights = work[rows, cols]
# Enforce minimum separation by keeping the tallest in each labelled clump.
keep = np.ones(len(rows), dtype=bool)
order = np.argsort(-heights)
taken: list[tuple[int, int]] = []
min_sep_cells = crown_radius_m / cell
for idx in order:
r, c = rows[idx], cols[idx]
if any((r - tr) ** 2 + (c - tc) ** 2 < min_sep_cells ** 2 for tr, tc in taken):
keep[idx] = False
else:
taken.append((r, c))
kept = heights[keep]
area_ha = valid.sum() * cell * cell / 10_000.0
return {
"cell_size_m": cell,
"detection_window_cells": window,
"candidates": int(len(heights)),
"after_separation": int(len(kept)),
"stems_per_ha": round(float(len(kept) / max(area_ha, 1e-9)), 1),
"height_p50": round(float(np.percentile(kept, 50)), 2) if len(kept) else None,
"height_p95": round(float(np.percentile(kept, 95)), 2) if len(kept) else None,
"height_max": round(float(kept.max()), 2) if len(kept) else None,
}
if __name__ == "__main__":
print(json.dumps(detect(Path("chm.tif"), crown_radius_m=2.5), indent=2))# Key Parameter Table
| Parameter | Typical | Effect |
|---|---|---|
crown_radius_m |
1.5–5 | Sets the detection window; the dominant parameter |
min_height_m |
2.0 | Rejects understory and ground noise |
sigma_cells |
0.5–1.0 | Removes single-cell spikes; larger flattens real apexes |
| CHM cell size | 0.5–1.0 m | Finer than crown radius, or crowns cannot be separated |
| minimum separation | ≈ crown radius | Cheap stand-in for crown delineation |
# Verification
Stem density is plausible. A managed conifer plantation runs 800–2,000 stems per hectare; open oak woodland 50–200. An answer of 12,000 means the window is too small.
The height distribution matches the stand. An even-aged plantation should produce a narrow mode. A broad, flat distribution in a plantation means the detection is picking branches.
Sensitivity is bounded. Re-run with the crown radius halved and doubled. If stem density moves by an order of magnitude, the number is a parameter choice rather than a measurement — say so when reporting it.
# Gotchas and Edge Cases
Heights are biased low and the bias is not constant. It grows with narrower crowns and lower pulse density, so it does not cancel between a dense and a sparse acquisition.
A CHM coarser than the crowns cannot separate them. At a two-metre cell in a stand with three-metre crowns, detection is measuring the raster.
Buildings and poles detect beautifully. Filter to vegetation classes before rasterizing, as in rasterizing a canopy height model.
Validate before quoting absolutes. Relative comparisons across one block are robust; absolute stem counts and heights need plots.
# Frequently Asked Questions
Why are LiDAR tree heights lower than field measurements?
Because a pulse strikes the upper crown somewhere, rarely the apex. The resulting bias is typically one to two metres and grows with narrower crowns and lower pulse density, so it does not cancel between a dense and a sparse acquisition. Relative comparisons within one block are robust; absolute heights need local calibration.
How do I choose the detection window?
From the stand, not from the raster. The window should be roughly the diameter of the smallest crown you intend to resolve — too small and one broad crown becomes several detections, too large and two adjacent crowns become one. Expect it to be wrong somewhere in any mixed-species or mixed-age stand.
Is a local maximum a tree?
No, it is a bump. A large crown can produce several, two small adjacent crowns can produce one, and a branch tip at a gap edge produces one belonging to nothing. Treating detections as candidates that must survive a height and separation test is what makes the output defensible.
How do I know whether my stem count is real?
Run the sensitivity test. If halving and doubling the crown radius moves stem density by an order of magnitude — which it usually does — the number is a parameter choice, and it should be reported as one unless field plots have pinned the parameter down.
# Related
- Canopy Height Models with filters.hag_nn — the parent workflow
- Rasterizing a Canopy Height Model from HAG — producing the raster this searches
- Computing Height Above Ground with filters.hag_nn — the per-point heights behind the raster
- Measuring Ground Point Density Under Canopy — whether the acquisition supports the cell size the detection needs
- Ground Filtering and DTM/DSM Generation with PDAL — the section overview