Local Maxima Tree Detection with a Variable Window Filter in Python

The window radius is the only parameter that materially changes a local-maxima tree count, and most workflows inherit it from a paper about a different forest. This guide is about the narrow task of choosing that radius from your own data: fitting a crown-radius-on-height relationship from plot measurements, encoding it as a search window, and sweeping the coefficients until precision and recall balance. It is one step inside Individual Tree Detection and Crown Segmentation, which itself belongs to Canopy Height Modeling & Terrain Extraction.

When to use this approach

A local-maxima filter over a raster is the right detector when the trees you care about have an apex visible from above. It is fast, deterministic, and easy to defend. It cannot see a suppressed stem beneath a closed overstory, and no coefficient choice will make it.

Approach Detects suppressed stems Speed Density needed Main tuning risk
Fixed-radius local maxima No Fastest Any One radius cannot suit both a sapling and a veteran oak
Variable-window local maxima No Fast Any Coefficients borrowed from another forest type
Point-cloud clustering (Li 2012 and similar) Partially Slow ≥ 8 returns/m² Distance thresholds interact with pulse density
Region growing on the CHM No Moderate Any Seeds still come from a maxima filter

Choose the variable window whenever the stand spans more than one size class, which in practice is nearly always. The fixed radius is defensible only in a uniform, even-aged plantation where every crown really is the same width.

Fit the allometry before you pick a radius

Crown radius scales with height, and the scaling is local. Measure crown radius and height on a sample of stems — two perpendicular crown widths averaged is the field standard — and fit the straight line the filter will use:

The intercept is the radius floor: the crown radius of the smallest tree you intend to detect. The slope is the part that varies by species and stocking, from around 0.05 m per metre in dense conifer plantations to 0.14 in open-grown broadleaf woodland.

import numpy as np
import pandas as pd


def fit_window_coefficients(plots: pd.DataFrame) -> dict:
    """Fit r = a + b*h from field crown widths.

    plots needs columns: height_m, crown_width_m (mean of two perpendicular widths).
    """
    h = plots["height_m"].to_numpy(dtype="float64")
    r = plots["crown_width_m"].to_numpy(dtype="float64") / 2.0
    b, a = np.polyfit(h, r, 1)
    pred = a + b * h
    ss_res = float(((r - pred) ** 2).sum())
    ss_tot = float(((r - r.mean()) ** 2).sum())
    return {
        "a": float(a),
        "b": float(b),
        "r2": 1.0 - ss_res / ss_tot if ss_tot else float("nan"),
        "rmse_m": float(np.sqrt(((r - pred) ** 2).mean())),
        "n": int(len(h)),
    }

An below about 0.4 is a warning that one line does not describe the stand — usually because two species with very different crown architectures are pooled. Fit them separately and pick the coefficients for whichever dominates, or accept that the detector will systematically over-segment one of them.

Crown radius against height for two species groups A scatter plot with tree height on the horizontal axis from zero to forty metres and crown radius on the vertical axis from zero to six metres. Filled circles are conifers and follow a shallow line rising from about 1.2 metres at zero height to 3.6 metres at forty. Hollow circles are broadleaves and follow a steeper line rising to about 5.6 metres at forty. A dashed pooled fit runs between the two, over-estimating conifer crowns and under-estimating broadleaf crowns. 0 2 4 6 0 10 20 30 40 tree height (m) crown radius (m) conifer — b ≈ 0.06 broadleaf — b ≈ 0.12 pooled fit — suits neither

Minimal reproducible example

The filter itself is short. The one subtlety is that the search radius depends on the pixel being tested, so a single call to maximum_filter is not enough — you need one pass per distinct radius, then a per-pixel selection of the pass that matches that pixel’s own height.

import numpy as np
import rasterio
from scipy.ndimage import gaussian_filter, maximum_filter
from scipy import ndimage as ndi


def detect_tops(chm_path: str, a: float = 1.2, b: float = 0.09,
                min_height: float = 2.0, sigma: float = 0.6,
                radius_step_px: int = 2):
    """Variable-window local maxima detection. Returns (rows, cols, heights)."""
    with rasterio.open(chm_path) as src:
        chm = src.read(1, masked=True).astype("float32")
        res = abs(src.transform.a)
    h = chm.filled(0.0)
    h[h < 0] = 0.0
    h = gaussian_filter(h, sigma=sigma, mode="nearest")

    # Quantise radii so the filter bank stays small: cost scales with distinct radii.
    radii_px = np.round((a + b * h) / res / radius_step_px) * radius_step_px
    radii_px = np.clip(radii_px, 1, 25).astype("int16")

    tops = np.zeros(h.shape, dtype=bool)
    for k in np.unique(radii_px):
        local_max = maximum_filter(h, size=int(2 * k + 1), mode="nearest")
        tops |= (radii_px == k) & (h == local_max) & (h >= min_height)

    labels, n = ndi.label(tops, structure=np.ones((3, 3), dtype=int))
    if n == 0:
        return np.array([]), np.array([]), np.array([])
    centres = ndi.center_of_mass(tops, labels, range(1, n + 1))
    rows = np.array([int(round(r)) for r, _ in centres])
    cols = np.array([int(round(c)) for _, c in centres])
    return rows, cols, h[rows, cols]

Quantising the radius to every second pixel usually leaves five to eight passes over a temperate stand. Doubling the number of passes buys no measurable accuracy because the underlying allometry has an RMSE of half a metre or more anyway.

Parameter reference

Parameter Type Default Recommended range Ecological rationale
a float (m) 1.2 0.8–2.0 Radius floor; set it to the crown radius of the smallest tree you intend to count
b float (m per m) 0.09 0.05–0.14 Height-to-radius slope; fit it from plot data rather than borrowing it
min_height float (m) 2.0 1.5–5.0 Candidate floor; must match the field tally’s inclusion rule
sigma float (px) 0.6 0.4–1.0 Removes branch-tip spikes; above 1.0 the apex of leaning crowns migrates
radius_step_px int (px) 2 1–3 Radius quantisation; larger means fewer filter passes and a coarser window law
CHM resolution float (m) 0.5 0.25–1.0 Must be finer than the radius floor or small crowns cannot be separated at all

Sweep the coefficients against a validation plot

Coefficients fitted from crown widths are a starting point, not an answer: field crown width and the apparent crown extent in a height raster are not the same quantity. Sweep b around the fitted value and pick the peak F₁ against a plot where every stem is mapped.

import numpy as np


def sweep_slope(chm_path, field, evaluate, b_values=np.arange(0.04, 0.16, 0.01),
                a: float = 1.2):
    """Grid-search the window slope; `evaluate` returns a dict with an 'f1' key."""
    results = []
    for b in b_values:
        rows, cols, heights = detect_tops(chm_path, a=a, b=float(b))
        scores = evaluate(rows, cols, heights, field)
        results.append({"b": float(b), **scores})
    best = max(results, key=lambda r: r["f1"])
    return best, results
Precision, recall and F1 across the window slope coefficient Three curves plotted against the window slope coefficient from 0.04 to 0.15. Precision climbs steadily as the window widens because fewer branch tips are promoted to trees. Recall falls as the window widens because neighbouring stems are absorbed into their dominant neighbour. Their harmonic mean, F1, rises then falls, peaking near a slope of 0.09, which is marked with a vertical line. 0.4 0.6 0.8 1.0 0.04 0.07 0.10 0.13 0.16 window slope b (m per m of height) score precision recall F₁ peak F₁ — adopt this b

The shape of that plot is the diagnosis. If precision is already high at the smallest slope, the CHM is smoother than you think and the real problem is recall. If recall never exceeds about 0.7 at any slope, the stand is multi-layered and the missing trees have no apex to detect — a raster method has reached its ceiling and the answer is point-cloud segmentation, not more tuning.

Expected output and verification

detect_tops returns three parallel arrays of equal length. Two cheap assertions catch nearly every wiring mistake before the result reaches a map:

rows, cols, heights = detect_tops("chm_0p5m.tif", a=1.2, b=0.09)

assert len(rows) == len(cols) == len(heights)
assert heights.min() >= 2.0, "a detection below the minimum-height threshold"

with rasterio.open("chm_0p5m.tif") as src:
    area_ha = (src.width * src.height * abs(src.transform.a) ** 2) / 10_000
density = len(rows) / area_ha
print(f"{len(rows)} trees over {area_ha:.1f} ha = {density:.0f} stems/ha")
assert 20 < density < 3000, "implausible stem density — check the window coefficients"

A density in the low hundreds is normal for mature temperate forest; four figures means the window is far too small for the stand, and a number below about 20 usually means the minimum-height threshold is filtering everything because the CHM is in the wrong units or still un-normalised.

Collapsing a plateau of tied maxima into one marker On the left, a grid of canopy height values shows a broad apex where six adjacent pixels share the same maximum height, and every one of them satisfies the local maximum test, producing six candidate tree tops. On the right, the same pixels after connected-component labelling form one group, whose centroid becomes a single tree marker. raw maxima test after labelling 27.427.427.4 27.427.427.4 6 candidate tops, 1 tree one connected component 1 marker at the centroid Without this step every wide flat apex inflates the stem count.

Common pitfalls

  • Fitting the allometry on dominant trees only. Plot crews often measure crown width on the plot’s largest stems, which biases the slope upward and produces a window that erases the understory. Sample the whole diameter range.
  • Quantising the radius too coarsely. A radius_step_px of 4 or more collapses the window law into two or three classes, which is a fixed window in disguise.
  • Running the filter before pit-filling. A CHM pit creates a ring of pixels that are all local maxima relative to the hole; every one becomes a tree.
  • Comparing sweep results across different matching tolerances. F₁ is only comparable when max_dist and the height tolerance are held constant across the whole sweep.

Frequently Asked Questions

Can I use one set of coefficients across a whole region?

Only where the forest type is genuinely uniform. The slope changes with species mix and with stocking, so a coefficient fitted in a closed plantation will over-segment the open-grown edges of the same ownership. The practical compromise is to fit per stratum — species group crossed with a broad density class — and store the coefficients alongside the stratum layer so every run is reproducible.

Why do two adjacent pixels sometimes both come back as tree tops?

Because the maximum filter copies a value rather than computing one, a flat plateau of identical heights satisfies the equality test at every pixel in the plateau. That is why the detections are labelled into connected components and reduced to a centroid before use — without that step a wide flat crown apex yields a small cluster of duplicate trees.

Does smoothing bias the detected heights downward?

Slightly, and predictably. A Gaussian with sigma below one pixel lowers a sharp apex by a few centimetres, which is well inside the LiDAR height bias you already carry. Read the smoothed surface for finding tops but sample the unsmoothed CHM at those locations when you record the height attribute, which keeps the detection stable and the measurement honest.

Up: Individual Tree Detection & Segmentation · Canopy Height Modeling & Terrain Extraction