Computing Crown Metrics per Tree
TL;DR: Group TreeID-labelled points by tree, and for each compute height (maximum HeightAboveGround), height percentiles (p25, p50, p75, p95), crown base height (the lowest height of a continuous run of crown points), crown area from a Shapely concave hull of the plan coordinates, equivalent crown diameter, and an approximate crown volume from a stacked-slice hull — then write one row per tree.
# Context and Motivation
This guide is part of Individual Tree Segmentation from LiDAR. A segmented tree is only useful once it is described in the numbers foresters, ecologists and arborists work with. Height and crown diameter feed allometric equations for stem diameter, biomass and carbon. Crown base height matters for fire behaviour models, because it decides whether a surface fire can climb into the canopy. Crown volume and height percentiles describe structure for habitat studies and growth monitoring.
All of these can be computed from the points labelled with each tree, and computing them from points rather than from a CHM keeps information that rasterization discards — especially the lower crown, which the CHM cannot see at all.
# Prerequisites and Assumptions
- Points labelled with
TreeIDand carryingHeightAboveGround, from point-based segmentation or from crown polygons burned onto points withfilters.overlay. - Python with pandas, NumPy and Shapely 2.0+ (for
shapely.concave_hull). - A projected CRS in metres.
- A decision on the minimum height counted as crown rather than understorey; 2 m is common.
# Step-by-Step Implementation
# Step 1 — Load labelled points into a data frame
Read X, Y, HeightAboveGround and TreeID, and drop label 0.
# Step 2 — Compute height statistics
Maximum height is tree height. The 95th percentile is a steadier alternative when the very top return may be a bird or noise. Keep p25, p50 and p75 as structural descriptors.
# Step 3 — Estimate crown base height
Histogram each tree’s heights in 0.5 m bins. Starting from the top, walk down while bins contain points; the first run of two or more empty bins marks the gap between crown and stem, and the bottom of the last occupied bin above it is the crown base.
# Step 4 — Compute crown area and diameter
Build a concave hull of the plan coordinates with a ratio of 0.3 to 0.5; the convex hull overestimates irregular crowns. Equivalent diameter is 2·sqrt(area/π).
# Step 5 — Approximate crown volume
Slice the crown into 1 m height layers, compute the convex hull area of each layer, and sum area times thickness. It is an approximation, but a consistent one.
# Complete Working Example
"""Per-tree crown metrics from TreeID-labelled points."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import pdal
import shapely
from shapely.geometry import MultiPoint
def load(path: Path) -> pd.DataFrame:
p = pdal.Pipeline(f'["{path}"]')
p.execute()
a = p.arrays[0]
hag = a["HeightAboveGround"] if "HeightAboveGround" in a.dtype.names else a["Z"]
df = pd.DataFrame({"x": a["X"], "y": a["Y"], "h": hag, "tree": a["TreeID"]})
return df[df.tree > 0]
def crown_base(h: np.ndarray, bin_m: float = 0.5, gap_bins: int = 2, floor: float = 2.0) -> float:
edges = np.arange(floor, h.max() + bin_m, bin_m)
counts, _ = np.histogram(h, bins=edges)
empty_run = 0
for i in range(len(counts) - 1, -1, -1):
if counts[i] == 0:
empty_run += 1
if empty_run >= gap_bins:
return float(edges[i + gap_bins])
else:
empty_run = 0
return float(floor)
def crown_volume(g: pd.DataFrame, base: float, slice_m: float = 1.0) -> float:
vol = 0.0
for lo in np.arange(base, g.h.max(), slice_m):
layer = g[(g.h >= lo) & (g.h < lo + slice_m)]
if len(layer) >= 3:
vol += MultiPoint(layer[["x", "y"]].to_numpy()).convex_hull.area * slice_m
return vol
def metrics(df: pd.DataFrame, ratio: float = 0.4) -> pd.DataFrame:
rows = []
for tid, g in df.groupby("tree"):
if len(g) < 20:
continue
h = g.h.to_numpy()
hull = shapely.concave_hull(MultiPoint(g[["x", "y"]].to_numpy()), ratio=ratio)
area = hull.area
base = crown_base(h)
top = g.loc[g.h.idxmax()]
rows.append({
"tree_id": int(tid), "x": round(top.x, 2), "y": round(top.y, 2),
"height_m": round(float(h.max()), 2),
"p95_m": round(float(np.percentile(h, 95)), 2),
"p50_m": round(float(np.percentile(h, 50)), 2),
"crown_base_m": round(base, 2),
"crown_length_m": round(float(h.max()) - base, 2),
"crown_area_m2": round(area, 1),
"crown_diam_m": round(2 * np.sqrt(area / np.pi), 2),
"crown_volume_m3": round(crown_volume(g, base), 1),
"points": len(g),
})
return pd.DataFrame(rows)
if __name__ == "__main__":
table = metrics(load(Path("out/stand_12/litree.laz")))
table.to_csv("out/stand_12/tree_metrics.csv", index=False)
print(table.describe().round(2).T[["mean", "min", "max"]])# Key Parameter Table
| Parameter | Type | Default | Guidance |
|---|---|---|---|
| minimum points per tree | int | 20 | Fewer gives unstable hulls and percentiles |
concave hull ratio |
float | 0.4 | 0 is tightest, 1 equals convex hull; 0.3–0.5 follows crowns well |
| histogram bin | float, m | 0.5 | Coarser misses short gaps; finer creates spurious gaps on sparse data |
gap_bins |
int | 2 | Empty bins in a row that count as the stem gap (1 m at 0.5 m bins) |
floor |
float, m | 2.0 | Heights below are ignored for crown base |
| volume slice | float, m | 1.0 | Thinner slices capture shape better, at more noise |
# Verification
- Ranges. Crown base must be below height, crown length positive, and diameter plausible for the species (a 25 m conifer with a 20 m crown diameter is suspicious).
- Allometric consistency. Plot crown diameter against height; most forest types show a clear positive trend. Points far off the trend are usually merged or split segments.
- Against plots. Where field crown base heights exist, compare them; LiDAR crown base is typically within one to two metres, with larger errors under dense upper canopy that hides the lower crown.
t = pd.read_csv("out/stand_12/tree_metrics.csv")
assert (t.crown_base_m < t.height_m).all()
assert (t.crown_diam_m.between(0.5, 30)).all()
print(t[["height_m", "crown_diam_m"]].corr().iloc[0, 1])# Gotchas and Edge Cases
Occluded lower crowns. In dense stands, few pulses reach the lower crown, so crown base can be overestimated. Treat it as a lower-confidence metric and report point counts in the lower crown alongside it.
Understorey inside a tree’s label. Point-based segmentation sometimes assigns shrub or sapling returns beneath a crown to the tree. The histogram gap method is robust to that, but percentiles like p25 are not; compute them above crown base.
Concave hull ratio too small. Very small ratios create spiky, fragmented hulls, sometimes MultiPolygons. If area jumps between neighbouring ratio values, raise it.
Crown volume is relative. Slice-hull volume depends on density, slice thickness and hull choice. Use it to compare trees within one dataset, not as an absolute volume across projects.
# Frequently Asked Questions
How is crown base height defined from LiDAR?
As the height where the continuous vertical run of crown returns ends, walking down from the top — in practice the top of the first gap of a metre or more below the crown. Field definitions vary, such as lowest live branch or lowest branch whorl, so state which one your comparison uses.
Should I use a convex or concave hull for crown area?
A concave hull with a moderate ratio follows irregular crowns more faithfully; the convex hull overestimates area for asymmetric and gappy crowns. For round conifer crowns the difference is small.
Can I estimate stem diameter from these metrics?
Only through allometric equations that relate height and crown size to diameter for a species or region. LiDAR does not measure the stem directly from the air; the accuracy depends entirely on the equation’s fit.
Why use the 95th percentile instead of maximum height?
The maximum can be a single noisy return or a bird. The 95th percentile is stable, but slightly lower than the true top; report maximum as height and keep p95 as a robustness check.
# Related
- Individual Tree Segmentation from LiDAR — producing the tree labels
- Segmenting Trees Directly from Points — TreeID from filters.litree
- Segmenting Trees with a Watershed on a CHM — crowns from a raster
- Computing Canopy Cover from LiDAR — the area-based counterpart to tree metrics
- Extracting Objects from Segment Labels — the general per-object table pattern