Deriving Canopy Base Height from LiDAR Vertical Profiles in Python

Canopy base height is the single most important number in crown-fire initiation: it is the vertical gap a surface fire has to bridge, and the models that predict torching are more sensitive to it than to almost anything else. It is also the hardest canopy metric to measure in the field and the one LiDAR is best placed to supply, provided the profile is built and thresholded carefully. This guide derives it, inside Fuel Load Mapping from LiDAR, part of Fire Risk & Fuel Assessment.

When to use each definition

Canopy base height is not one quantity. Three definitions circulate and they disagree by metres on the same stand.

Definition Where it comes from Typical value Use when
Lowest live branch Field measurement per tree Lowest Individual-tree work
Threshold on bulk density profile LiDAR or FVS Middle Stand-level crown-fire modelling — the operational default
Effective canopy base height Weighted by crown fuel load Highest Comparability with published fire behaviour outputs

The threshold method is what fire behaviour models expect: the lowest height at which canopy bulk density exceeds a stated value, conventionally 0.011 kg/m³, computed on a running mean over a vertical window. Report which definition you used, because a stand can have a 2 m lowest live branch and an 8 m threshold-based base height, and the two predict entirely different torching behaviour.

Three canopy base height definitions on one stand On the left a vertical profile of canopy bulk density is drawn as horizontal bars against height. The profile is near zero below two metres, small but non-zero between two and eight metres where ladder fuel sits, and rises sharply above eight metres. A vertical threshold line at 0.011 kilograms per cubic metre crosses the profile at eight metres. On the right a stand cross-section shows the corresponding heights: the lowest live branch at two metres, the ladder fuel band between, and the threshold-based canopy base height at eight metres where the main crown mass begins. 0.011 kg/m³ 0 8 16 24 height (m) canopy bulk density lowest live branch — 2 m threshold CBH — 8 m ladder fuel band stand cross-section The six metres between the two definitions is exactly the ladder fuel that decides whether the stand torches.

From returns to bulk density

The profile is built by binning normalized returns by height and converting return proportion to a mass estimate. The conversion is the modelling step, and it is where local calibration belongs:

where is the proportion of canopy returns in the bin at height , the bin width in metres, the cell area, and the total crown fuel mass in the cell — usually from an allometric equation driven by basal area or by LiDAR-predicted biomass.

The key assumption is that return proportion is proportional to foliage mass, which holds reasonably in the mid-canopy and less well at the top, where the first returns are concentrated regardless of foliage density. Correcting for occlusion — scaling each bin by the fraction of pulses that reached it — improves the lower profile noticeably and is worth the extra pass.

Minimal reproducible example

import numpy as np

BIN_M = 1.0
CBD_THRESHOLD = 0.011          # kg/m³, the conventional crown-fire criterion


def bulk_density_profile(heights: np.ndarray, crown_mass_kg: float,
                         cell_area_m2: float, bin_m: float = BIN_M,
                         min_h: float = 0.5, max_h: float | None = None):
    """Canopy bulk density per height bin from normalized returns."""
    h = heights[np.isfinite(heights) & (heights >= min_h)]
    if h.size < 30:
        return np.array([]), np.array([])
    top = max_h if max_h is not None else float(np.ceil(h.max()))
    edges = np.arange(min_h, top + bin_m, bin_m)
    counts, _ = np.histogram(h, bins=edges)
    if counts.sum() == 0:
        return np.array([]), edges
    p = counts / counts.sum()
    cbd = (crown_mass_kg / cell_area_m2) * p / bin_m       # kg/m³ per bin
    centres = edges[:-1] + bin_m / 2.0
    return cbd, centres


def canopy_base_height(cbd: np.ndarray, centres: np.ndarray,
                       threshold: float = CBD_THRESHOLD,
                       window_m: float = 1.0, bin_m: float = BIN_M) -> float:
    """Lowest height whose running-mean bulk density exceeds the threshold."""
    if cbd.size == 0:
        return float("nan")
    k = max(1, int(round(window_m / bin_m)))
    kernel = np.ones(k) / k
    smooth = np.convolve(cbd, kernel, mode="same")
    above = np.where(smooth >= threshold)[0]
    if above.size == 0:
        return float("nan")
    # Walk down from the densest bin to the lowest CONTIGUOUS bin above threshold,
    # so an isolated shrub bin near the ground does not become the base height.
    peak = int(np.argmax(smooth))
    i = peak
    while i > 0 and smooth[i - 1] >= threshold:
        i -= 1
    return float(centres[i])

The contiguity walk is what separates a usable estimate from a noisy one. Taking simply the lowest bin above threshold puts the base height in the shrub layer wherever a dense understory exists — which is precisely the stand where the answer matters most, and precisely where it would be most wrong.

Detect the ladder fuel separately

The gap between the lowest live branch and the threshold base height is the ladder fuel band, and it deserves its own metric rather than being collapsed into a single number.

def ladder_metrics(cbd: np.ndarray, centres: np.ndarray, cbh: float,
                   threshold: float = CBD_THRESHOLD) -> dict:
    """Characterise the fuel between the ground and the canopy base."""
    if cbd.size == 0 or not np.isfinite(cbh):
        return {"ladder_load_frac": np.nan, "max_gap_m": np.nan}
    below = centres < cbh
    if not below.any():
        return {"ladder_load_frac": 0.0, "max_gap_m": float(cbh)}

    # Largest vertical run below the canopy base that is essentially empty.
    empty = cbd[below] < (threshold * 0.25)
    gaps, run = [], 0
    for e in empty:
        run = run + 1 if e else 0
        gaps.append(run)
    return {
        "ladder_load_frac": float(cbd[below].sum() / cbd.sum()),
        "max_gap_m": float(max(gaps) * (centres[1] - centres[0]) if len(centres) > 1 else 0.0),
        "cbh_m": float(cbh),
    }

max_gap_m is the operationally useful number: a stand with a continuous but sparse ladder is far more likely to torch than one with the same total ladder load concentrated in a band separated from the crown by a clean four-metre gap.

Parameter reference

Parameter Type Default Range Rationale
bin_m float (m) 1.0 0.5–2.0 Finer bins resolve the ladder band; below 0.5 m the counts get noisy
CBD_THRESHOLD kg/m³ 0.011 0.010–0.037 The conventional criterion; higher values push base height upward
window_m float (m) 1.0 1.0–3.0 Running-mean window; wider smooths noise and raises the estimate
min_h float (m) 0.5 0.3–2.0 Excludes ground returns that would swamp the profile
cell size float (m) 20 10–30 Must hold enough returns for a stable profile
minimum returns int 30 30–200 Below this the profile is noise; return NaN rather than a number
crown mass source allometric The calibration that turns proportions into kilograms

Expected output and verification

Field validation is possible here, unlike for most canopy metrics, because crews do measure lowest live branch and crown ratio on plots.

import numpy as np


def validate_cbh(predicted: np.ndarray, field_cbh: np.ndarray) -> dict:
    """Compare LiDAR-derived canopy base height against field measurements."""
    ok = np.isfinite(predicted) & np.isfinite(field_cbh)
    p, f = predicted[ok], field_cbh[ok]
    resid = p - f
    slope, intercept = np.polyfit(f, p, 1)
    return {
        "n": int(ok.sum()),
        "bias_m": round(float(resid.mean()), 2),
        "rmse_m": round(float(np.sqrt((resid ** 2).mean())), 2),
        "slope": round(float(slope), 3),
        "r2": round(float(np.corrcoef(p, f)[0, 1] ** 2), 3),
    }


res = validate_cbh(cbh_pred, cbh_field)
assert res["rmse_m"] < 3.0, f"canopy base height error too large for fire modelling: {res}"
assert abs(res["bias_m"]) < 1.5, f"systematic bias in canopy base height: {res}"

An RMSE of 2–3 m is a good published result; below 1.5 m is unusual and worth checking for circularity — if crown mass came from an allometry fitted on the same plots, validation against those plots is not independent.

The standing test is the one the profile logic must refuse:

rng = np.random.default_rng(20260809)

# A stand with a clean bole: nothing between 0.5 and 10 m, dense crown above.
clean = np.concatenate([rng.uniform(10.0, 24.0, 4000)])
cbd, centres = bulk_density_profile(clean, crown_mass_kg=9000.0, cell_area_m2=400.0)
assert canopy_base_height(cbd, centres) >= 9.0, \
    "a clean bole was assigned a low base height — the contiguity walk is broken"

# The same stand with a sparse shrub layer added: base height must not collapse to the shrubs.
laddered = np.concatenate([clean, rng.uniform(1.0, 3.0, 120)])
cbd2, centres2 = bulk_density_profile(laddered, crown_mass_kg=9000.0, cell_area_m2=400.0)
assert canopy_base_height(cbd2, centres2) >= 8.0, \
    "a sparse shrub layer captured the base height"
Sensitivity of canopy base height to the density threshold Three horizontal bars show the canopy base height estimated for the same stand under bulk density thresholds of 0.011, 0.020 and 0.037 kilograms per cubic metre. The estimate rises from about eight metres to eleven and then to fifteen metres. A dashed marker shows the surface flame height that would be needed to reach each, and only the lowest threshold puts the canopy within reach of a typical surface fire. 0.011 kg/m³ 0.020 kg/m³ 0.037 kg/m³ 8.0 m 11.0 m 15.0 m reach of a 4 m surface flame 0 10 20 canopy base height (m) Only the lowest threshold puts this stand within torching range — so the threshold must be stated with the map. Occlusion under-samples the lower canopy A stacked pair of profiles. The upper bar chart shows the fraction of emitted pulses that still reach each height layer, falling from one hundred percent at the canopy top to under fifteen percent near the ground. The lower pair of profiles compares the raw return proportion with the profile after each bin is divided by the fraction of pulses that reached it. The corrected profile places noticeably more mass in the lower canopy, which lowers the estimated base height. pulses surviving to each layer 100% 13% raw occlusion-corrected raw CBH corrected CBH, lower Only 13% of pulses reach the lowest bin, so its returns are worth roughly eight times each. Ignoring occlusion makes the lower canopy look emptier than it is and raises base height by metres. That error runs in the safe-looking direction, which is what makes it dangerous.

Common pitfalls

  • Taking the lowest bin above threshold. A dense shrub layer becomes the base height, which under-predicts by many metres in exactly the stands that matter.
  • Ignoring occlusion. Lower bins are under-sampled because the canopy above intercepted the pulses. Weighting by the pulses that reached each bin raises lower-canopy density and lowers the base height estimate.
  • Mixing definitions between the model and its validation. Field crews measure lowest live branch; the model expects a threshold-based value. Comparing them directly produces a large apparent bias that is purely definitional.
  • Cells too small for a stable profile. At 10 m and 6 returns/m² a cell holds 600 returns, spread across 25 bins — thin, but workable. At 5 m it is not.
  • Applying one crown-mass allometry across species. The proportion-to-mass conversion is species-specific; a single equation across a mixed stand introduces a bias that varies spatially with composition.

Frequently Asked Questions

Which threshold should I use?

Use the one the downstream fire model expects, and say which it is. The 0.011 kg/m³ criterion is the most common and pairs with most crown-fire initiation models; higher values appear in some regional adaptations. The threshold moves the answer by several metres, so it is not a detail that can be left implicit.

Can I derive canopy base height per tree instead of per cell?

Yes, if the point cloud is segmented — the fifth percentile of a tree’s own returns is a reasonable per-tree base height, as produced in Point Cloud Tree Segmentation with the Li 2012 Algorithm. Per-tree values are useful for stand description, but fire behaviour models are written for stand-level canopy fuel, so they will need aggregating anyway.

How does this interact with a fuel treatment?

Directly, and it is the main way treatment effect is measured remotely. Pruning and thinning raise canopy base height and reduce ladder continuity; a repeat LiDAR acquisition measures both. Reporting the change in base height and in maximum ladder gap together is far more informative than either alone.

Up: Fuel Load Mapping from LiDAR · Fire Risk & Fuel Assessment