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.

One tree, six numbers A side view of a conifer. Annotations mark tree height at the top point, crown base height where continuous crown points begin above the bare stem, crown length between them, and crown diameter across the widest part. A plan-view inset shows the concave hull of the crown's points whose area is the crown area. height 24.6 m crown base 9.1 m crown length diameter 6.8 m plan view concave hull area 34.2 m²

# Prerequisites and Assumptions

  • Points labelled with TreeID and carrying HeightAboveGround, from point-based segmentation or from crown polygons burned onto points with filters.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

python
"""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"]])
Finding the crown base in a histogram A vertical histogram of point counts per half-metre height bin for one tree. Counts are high between 9 and 24 metres, the crown. Between 5 and 9 metres the bins are empty apart from one stray return, the bare stem. A few points near 2 to 4 metres belong to shrubs. The crown base is placed at 9.1 metres, where the continuous run of occupied bins ends when walking down from the top. crown base 9.1 m crown stem gap (one stray return ignored) shrub layer, not part of the crown 24 m 2 m

# 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.
python
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.

Three hulls around one crown Three copies of the same asymmetric crown point pattern. The convex hull encloses a large empty notch and reports 41 square metres. A concave hull at ratio 0.4 follows the notch and reports 33 square metres. A concave hull at ratio 0.05 breaks into spiky fragments and reports 19 square metres. convex: 41 m² ratio 0.4: 33 m² ratio 0.05: 19 m² overstates notched crowns follows the crown spiky, understates

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.