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.
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"
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.
Related
- Fuel Load Mapping from LiDAR — the wider fuel characterisation this belongs to
- Estimating Canopy Bulk Density from LiDAR — the density profile this thresholds
- Computing Vertical Canopy Complexity Metrics from LiDAR — the habitat view of the same profile
- Computing Flame Length and Fireline Intensity in Python — the surface-fire side of crown-fire initiation
Up: Fuel Load Mapping from LiDAR · Fire Risk & Fuel Assessment