Skip to content

sylva.trees

sylva.trees

Tree-level processing: stem detection, DBH, tree height, segmentation, crowns.

Tree dataclass

A detected tree and its stem fit.

Lengths are metres and positions are in the cloud's frame.

Attributes:

Name Type Description
tree_id int

1..n; matches the labels of segment_trees.

x, y

Stem centre at breast height.

dbh float

Stem diameter at breast height (m) from the taper fit.

height float

Filled by tree_heights; NaN until then.

n_points int

Stem points after detection; all points after tree_heights.

n_slices int

Layers the stem was found in.

rmse float

Circle-fit residual (m).

lean_deg float

Stem lean from vertical (degrees).

extra dict

Free-form per-tree values, written out by as_dict.

inlier_fraction class-attribute instance-attribute

inlier_fraction: float = float('nan')

Fraction of the stem circumference observed (angular coverage).

quality class-attribute instance-attribute

quality: float = float('nan')

0-1 confidence combining fit residual, coverage and slice support.

as_dict

as_dict() -> dict

Flat dict of every field, with extra merged in; one row of a tree table.

Examples:

>>> import pandas as pd
>>> table = pd.DataFrame([t.as_dict() for t in stems])

fit_circle

fit_circle(xy: ndarray) -> tuple[float, float, float, float]

Least-squares circle through 2D points.

An algebraic fit (Kåsa 1976) refined by Levenberg-Marquardt on the geometric distance. Not robust to outliers; use fit_circle_ransac on raw slices.

Parameters:

Name Type Description Default
xy ndarray

(N, 2) points, N >= 3.

required

Returns:

Type Description
cx, cy, r, rmse : float

Centre, radius and RMS distance of the points from the circle.

fit_circle_ransac

fit_circle_ransac(xy: ndarray, threshold: float = 0.01, iterations: int = 200, min_radius: float = 0.02, max_radius: float = 1.5, seed: int = 0) -> tuple[float, float, float, np.ndarray]

Circle fit that tolerates outliers (twigs, noise, a second stem).

RANSAC (Fischler & Bolles 1981) on circles through three points, then a least-squares refit on the inliers.

Parameters:

Name Type Description Default
xy ndarray

(N, 2) points of a stem slice.

required
threshold float

Inlier distance from the circle (m).

0.01
iterations int

RANSAC trials.

200
min_radius float

Radius limits (m); candidate circles outside are rejected.

0.02
max_radius float

Radius limits (m); candidate circles outside are rejected.

0.02
seed int

Random seed.

0

Returns:

Name Type Description
cx, cy, r : float

Circle refit on the inliers.

inlier_mask ndarray

Boolean, one per input point.

Raises:

Type Description
ValueError

If no circle within the radius limits is found.

detect_stems

detect_stems(cloud: PointCloud, height_attr: str = 'height', **params) -> list[Tree]

Detect stems in a height-normalised cloud by linking circles across slices.

The 1-5 m band is cut into 0.3 m layers every 0.25 m; each layer is clustered in 2-D and circles fitted by RANSAC (Fischler & Bolles 1981) with an angular-coverage check; circles are linked across layers into chains that must span min_slices (3) layers and lean under max_lean_deg (25). DBH is read from a linear taper at reference_height (1.3 m).

Keyword parameters (defaults in brackets): slice_min [1.0], slice_max [5.0], slice_thickness [0.3], slice_step [0.25], reference_height [1.3], min_radius [0.015], max_radius [0.75], cluster_cell [0.06], min_cluster_points [12], max_cluster_extent [2.0], ransac_iterations [120], ransac_tolerance [0.02], max_circles_per_cluster [3], min_circle_inliers [10], min_coverage [0.12] (fraction of circumference seen; ~0.5 for single scans), min_arc_deg [0] (longest contiguous arc; 130+ suits merged multi-scan plots), max_circle_rmse [0.02], link_radius [0.2], link_radius_ratio [0.45], link_radius_abs [0.02], min_slices [3], max_lean_deg [25], prefilter [True] (keep only locally planar points with a near-horizontal normal, i.e. bark, before slicing; prefilter_k [16], prefilter_max_nz [0.6], prefilter_max_variation [0.15]), seed [0], cluster_seeds [False] (a random stream per cluster rather than per layer, each cluster's points in input order, and the prefilter's neighbours at equal distances taken by index, so that each stem depends only on the points around it; sylva.als.tiles.detect_stems uses it, and gives the same stems as this function with it).

Parameters:

Name Type Description Default
cloud PointCloud

A cloud with height above ground (sylva.ground.normalize_height). Merged multi-scan clouds work best; thin to ~1 cm for speed.

required
height_attr str

Attribute holding heights; z is used if absent.

'height'
**params

The keyword parameters above.

{}

Returns:

Type Description
list of Tree

Sorted by quality with ids 1..n. height is NaN until tree_heights.

Notes

Detection favours recall: some candidates are low branches, shrubs or duplicates. The operational sequence is merge_branches, segment_trees, tree_heights and prune_trees, which removes candidates without a canopy above them. Benchmarked on manually segmented plots in Benchmarks > Tree detection.

prune_trees

prune_trees(trees: list[Tree], labels: ndarray, min_height: float = 3.0, merge_radius: float = 0.2, max_dbh: float | None = None, min_quality_short: float = 0.0, short_slices: int = 4, min_slenderness: float = 0.0, slender_min_dbh: float = 0.2) -> tuple[list[Tree], np.ndarray]

Drop short candidates and merge duplicates after segmentation.

Trees lower than min_height (from tree_heights; NaN, i.e. no points, counts as low) are removed; of any two stems closer than merge_radius the one with more points survives and absorbs the other's points. min_quality_short additionally drops candidates supported by fewer than short_slices layers whose quality is below it (0.15 helps in conifer stands with many low branches, at a small recall cost in dense rainforest). min_slenderness drops stems wider than slender_min_dbh whose height is less than that many times their DBH: a shrub or an understorey clump fitted as a stem gives a DBH of a metre on a tree a few metres tall. In a tall eucalypt forest with a dense understorey 10 removed 55 such stems (38 m² ha⁻¹ of basal area) and no tree taller than 15 m, whose height / DBH was at least 13.5.

Parameters:

Name Type Description Default
trees list[Tree]

Trees with height and n_points from tree_heights.

required
labels ndarray

Point labels from segment_trees.

required
min_height float

Minimum tree height (m).

3.0
merge_radius float

Candidates closer together than this are treated as one stem. A coppice stool or a low fork puts real stems 0.3 m apart, so this is deliberately small; raise it where a single stem is scanned twice. Stems closer than this (m) are merged.

0.2
max_dbh float | None

Drop stems wider than this (m), e.g. to remove walls or rocks.

None
min_quality_short float

Minimum quality for short-chain candidates.

0.0
short_slices int

Candidates with fewer layers than this count as short.

4
min_slenderness float

Minimum height / DBH of stems wider than slender_min_dbh; 0 (the default) keeps every stem.

0.0
slender_min_dbh float

Stems no wider than this (m) are not tested for slenderness.

0.2

Returns:

Name Type Description
trees list of Tree

Survivors sorted by DBH (largest first), ids renumbered 1..n, n_points recounted.

labels ndarray

Labels remapped to the new ids; points of dropped trees are -1.

basal_area

basal_area(trees, area: float, min_dbh: float = 0.0) -> float

Basal area of a plot (m²/ha): the stems' cross-sections at breast height.

sum(pi * (dbh / 2) ** 2) / area * 1e4 over stems with dbh >= min_dbh; stems without a DBH (NaN) are left out. Restrict trees to the plot first (e.g. stems within the plot radius) so that stems and area cover the same ground.

Parameters:

Name Type Description Default
trees

Trees (DBH from detect_stems, usually after prune_trees), or their DBHs (m) as an array.

required
area float

Plot area (m²), e.g. np.pi * radius**2 for a circular plot.

required
min_dbh float

Smallest DBH counted (m); 0.1 is a common inventory threshold.

0.0

Returns:

Type Description
float

Basal area in m²/ha.

Examples:

>>> radius = 50.0
>>> in_plot = [t for t in stems if np.hypot(t.x, t.y) <= radius]
>>> trees.basal_area(in_plot, np.pi * radius**2, min_dbh=0.1)

dbh_profile

dbh_profile(cloud: PointCloud, center_xy, height_attr: str = 'height', heights: ndarray | None = None, slice_thickness: float = 0.1, search_radius: float = 0.75) -> np.ndarray

Stem diameter at a series of heights (a taper curve).

A RANSAC circle is fitted to each thin slice around the stem.

Parameters:

Name Type Description Default
cloud PointCloud

A height-normalised cloud.

required
center_xy

Stem position, e.g. (tree.x, tree.y).

required
height_attr str

Attribute holding heights.

'height'
heights ndarray | None

Heights to measure at (m); 0.5 to 10 m every 0.5 m if None.

None
slice_thickness float

Slice thickness (m).

0.1
search_radius float

Only points within this horizontal distance (m) of center_xy. Keep it below the spacing to the next stem.

0.75

Returns:

Type Description
ndarray

(n, 2) rows of (height, diameter) in m; diameter is NaN where a slice has fewer than 10 points or no circle fits.

segment_trees

segment_trees(cloud: PointCloud, trees: list[Tree], height_attr: str = 'height', k: int = 6, max_edge: float = 1.0, voxel_size: float = 0.03, seed_height: float = 1.5, seed_radius: float = 0.25, seed_ring: bool = True, power: float = 6.0, angle_penalty: bool = True, gravity: float = 0.0, cut_above_ground: float = 0.25, height_prior: bool = True, height_prior_radius: float = 1.5, height_prior_power: float = 1.0, low_height: float = 0.5, low_radius: float = 1.0, wood_costs: bool = False, wood_k: int = 20, wood_threshold: float = 0.9, understorey_height: float = 10.0, understorey_band: float = 0.5, voxel_origin=None) -> np.ndarray

Assign each point to a stem by least-cost path through a directed kNN graph (multi-source Dijkstra from stem seeds), after raycloudtools' rayextract trees (Devereux et al. 2026).

The edge cost is d ** power times an angle penalty min(exp(0.046 * deg_from_vertical), 100) -- climbing is free, horizontal edges cost x63 and downward ones x100 -- so paths run up through a tree instead of leaking across the ground or understorey. gravity adds raycloudtools' term 1 + g * lateral**2 from the seed (0.3 favours vertical trees). height_prior scales each tree's costs by 1 / height (raycloudtools' per-root scaling) so tall trees win contested crown points over understorey stems. wood_costs classifies graph nodes as wood by local anisotropy and applies class factors (wood->wood x0.1, leaf->leaf x20, wood->leaf x1000) so foliage cannot bridge neighbouring trees. Points below cut_above_ground and unreachable points get -1, and so do points below low_height further than low_radius (or 1.5 DBH) from their tree's base: ground remnants, litter and understorey reached along the surface would otherwise be modelled as part of the tree. Returns tree_id per point.

The graph is built on a voxel_size downsample; labels propagate to the full cloud by nearest graph node (set 0 to use every point).

Parameters:

Name Type Description Default
cloud PointCloud

A height-normalised cloud of the plot.

required
trees list[Tree]

Stems from detect_stems (after merge_branches).

required
height_attr str

Attribute holding heights.

'height'
k int

Neighbours per graph node.

6
max_edge float

Longest graph edge (m); gaps wider than this are not crossed.

1.0
voxel_size float

Graph resolution (m); 0 uses every point (slow on plots).

0.03
seed_height float

Graph nodes within seed_radius of a stem and below seed_height start that tree's search.

1.5
seed_radius float

Graph nodes within seed_radius of a stem and below seed_height start that tree's search.

1.5
power float

Exponent on edge length; higher prefers many short steps, so paths stay inside a crown rather than jumping the gaps between neighbouring crowns. 6 was chosen on the validation areas of the Cherlet et al. (2026) benchmark, where it raised F1 over the earlier 4 on every plot with interlocking crowns (see Benchmarks > Tree detection).

6.0
angle_penalty bool

Apply the angle factor above.

True
gravity float

Lateral-distance term; 0 disables it.

0.0
cut_above_ground float

Points below this height (m) are left unassigned.

0.25
height_prior bool

Scale costs by 1 / tree height; tree height is estimated from points within height_prior_radius of the stem.

True
height_prior_radius bool

Scale costs by 1 / tree height; tree height is estimated from points within height_prior_radius of the stem.

True
low_height float

Near-ground points farther than low_radius from their stem are unassigned.

0.5
low_radius float

Near-ground points farther than low_radius from their stem are unassigned.

0.5
wood_costs bool

Enable wood/leaf edge factors; wood_k neighbours and wood_threshold anisotropy classify nodes.

False
wood_k bool

Enable wood/leaf edge factors; wood_k neighbours and wood_threshold anisotropy classify nodes.

False
wood_threshold bool

Enable wood/leaf edge factors; wood_k neighbours and wood_threshold anisotropy classify nodes.

False
understorey_height float

Let the understorey compete, after raycloudtools, where every point's path runs to the ground and small plants keep their own. Graph nodes up to understorey_band above cut_above_ground and more than max(low_radius, 1.5 DBH) from every stem become extra sources, with path costs scaled as for a tree understorey_height tall (with height_prior). Grass, shrubs and saplings around a stem go to them and are left unassigned instead of joining the tree. 0 disables. On the CHERLET test blocks the default raises F1 at Litchfield from 0.73 to 0.83 and cuts the share of tree points that are really understorey from 17 % to 6 %; scales above about 20 start to take points from real trees.

10.0
understorey_band float

Let the understorey compete, after raycloudtools, where every point's path runs to the ground and small plants keep their own. Graph nodes up to understorey_band above cut_above_ground and more than max(low_radius, 1.5 DBH) from every stem become extra sources, with path costs scaled as for a tree understorey_height tall (with height_prior). Grass, shrubs and saplings around a stem go to them and are left unassigned instead of joining the tree. 0 disables. On the CHERLET test blocks the default raises F1 at Litchfield from 0.73 to 0.83 and cuts the share of tree points that are really understorey from 17 % to 6 %; scales above about 20 start to take points from real trees.

10.0
voxel_origin

(x, y, z) of a corner of the graph's voxel grid; None anchors it at the lowest point above cut_above_ground. A fixed corner puts the graph on the grid sylva.als.tiles.segment_trees uses, which then gives these labels tile by tile.

None

Returns:

Type Description
ndarray

int64 per point: the tree_id of its tree, or -1.

Notes

Accuracy against manually segmented plots is in Benchmarks > Tree detection; the losses are crown leakage between interlocking neighbours. Labels that have to be right can be corrected by hand in Segfix (https://github.com/tim-devereux/segfix), which reads a tree_id column from LAS/LAZ and writes it back in place; map -1 to 0 and use int32 first, because Segfix reads a negative id as noise.

merge_branches

merge_branches(cloud: PointCloud, trees: list[Tree], height_attr: str = 'height', ground_height: float = 0.5, trunk_scale: float = 1.5, trunk_min: float = 0.15, search_radius: float = 6.0, **graph_params) -> tuple[list[Tree], np.ndarray]

Drop candidates that are branches or secondary stems of another candidate.

Every graph node's least-cost path to the ground is traced (as in raycloudtools; Devereux et al. 2026); a candidate whose seed routes to the ground through another candidate's trunk (within max(trunk_scale * radius, trunk_min) of its axis, below its seed height) is merged into it. graph_params are the segment_trees graph settings. Returns (surviving trees, merged_into) where merged_into[i] is the id the i-th input tree now belongs to. Call before segment_trees.

Parameters:

Name Type Description Default
cloud PointCloud

A height-normalised cloud of the plot.

required
trees list[Tree]

Candidates from detect_stems.

required
height_attr str

Attribute holding heights.

'height'
ground_height float

Graph nodes below this height (m) count as ground.

0.5
trunk_scale float

Width of the trunk zone around a candidate's axis, in stem radii and its minimum (m).

1.5
trunk_min float

Width of the trunk zone around a candidate's axis, in stem radii and its minimum (m).

1.5
search_radius float

Only candidates within this distance (m) are compared.

6.0
**graph_params

segment_trees graph settings (k, voxel_size, voxel_origin, ...).

{}

Returns:

Name Type Description
trees list of Tree

The surviving candidates.

merged_into ndarray

For each input tree, the id it now belongs to (its own if kept).

tree_heights

tree_heights(cloud: PointCloud, labels: ndarray, trees: list[Tree], height_attr: str = 'height', percentile: float = 100.0) -> list[Tree]

Set each tree's height and point count from the segmentation.

Parameters:

Name Type Description Default
cloud PointCloud

The cloud that was segmented.

required
labels ndarray

Point labels from segment_trees.

required
trees list[Tree]

Trees to update; modified in place and returned.

required
height_attr str

Attribute holding heights.

'height'
percentile float

Height percentile of the tree's points; 100 is the top point, 99 is more robust to stray points above the crown.

100.0

Returns:

Type Description
list of Tree

The same objects, with height (NaN if a tree has no points) and n_points set.

crown_metrics

crown_metrics(cloud: PointCloud, labels: ndarray, tree_id: int, height_attr: str = 'height', crown_base_fraction: float = 0.1) -> dict

Crown size of one segmented tree.

Crown base is found from a height histogram of the tree's points in 0.5 m bins: the first bin, from a quarter of the tree height upwards, holding at least crown_base_fraction of the fullest bin.

Parameters:

Name Type Description Default
cloud PointCloud

The cloud that was segmented.

required
labels ndarray

Point labels from segment_trees.

required
tree_id int

Tree to measure.

required
height_attr str

Attribute holding heights.

'height'
crown_base_fraction float

Density threshold for the crown base (0-1).

0.1

Returns:

Type Description
dict

crown_area (convex hull of the points above crown base, m²), crown_base_height (m), crown_depth (top minus base, m) and crown_diameter (diameter of a circle of the same area, m). Empty if the tree has fewer than 4 points.

See Also

crown_shape : volume, surface and asymmetry.

crown_metrics_all

crown_metrics_all(cloud: PointCloud, labels: ndarray, height_attr: str = 'height', crown_base_fraction: float = 0.1) -> dict[int, dict]

crown_metrics for every tree in one pass (parallel).

Parameters:

Name Type Description Default
cloud PointCloud

The cloud that was segmented.

required
labels ndarray

Point labels from segment_trees; -1 is ignored.

required
height_attr str

Attribute holding heights.

'height'
crown_base_fraction float

Density threshold for the crown base.

0.1

Returns:

Type Description
dict

{tree_id: metrics}; trees with fewer than 4 points are missing.

detect_buttress

detect_buttress(cloud: PointCloud, base_xy=None, height_attr: str = 'height', max_radius: float = 4.0, slice_height: float = 0.1, max_height: float = 6.0, low: float = 1.0, bins: int = 36, max_circle_fit: float = 0.55, min_ridges: int = 2, bark_only: bool = True, voxel: float = 0.02) -> dict

Does a stem have buttresses, and how high do they reach?

Flanges are thin walls radiating from the stem, continuous from the ground up to where they merge into the trunk. Two signals capture that:

  • A circle stops explaining the base. A RANSAC circle is fitted to every slice_height slice; on a round stem it explains most of the bark points at every height, on a buttressed one only a small share near the ground.
  • Protrusions persist at the same angle. Around the stem centre, angular bins holding points well outside the stem radius (1.4 r + 0.1 m) in at least 60 % of the slices below low are ridges; neighbouring ridges are split where persistence dips. Clutter (grass, shrubs, litter) does not line up from slice to slice.

With bark_only (the default), only bark-like points take part: locally planar (planarity >= 0.4 over 20 neighbours) with a normal within 60 degrees of horizontal. Flanges and round bark are both vertical surfaces; grass, shrubs and resprouts clumped around a stem in plot data are not, and without this filter they read as flanges.

A tree is buttressed when the median circle fit below low is under max_circle_fit and there are at least min_ridges ridges. On 97 visually labelled harvest trees (Cameroon, Peru, Guyana, Indonesia, Wytham) this rule gets 94.8 % right in leave-one-out (26 of 29 buttressed trees found, 2 false alarms in 68).

Parameters:

Name Type Description Default
cloud PointCloud

One tree's points (or a plot's; only points within max_radius of the stem are used), with height above ground.

required
base_xy

Stem centre; the median of the points 2.5-3.5 m up (1.2-1.8 m if those are too few) if None.

None
height_attr str

Attribute holding height above ground.

'height'
max_radius float

Horizontal reach from the stem centre (m).

4.0
slice_height float

Slice thickness (m).

0.1
max_height float

Highest slice (m); also the highest possible top.

6.0
low float

Top of the base zone the decision looks at (m).

1.0
bins int

Angular bins for the ridges.

36
max_circle_fit float

Decision thresholds.

0.55
min_ridges float

Decision thresholds.

0.55
bark_only bool

Use only locally planar, near-vertical surface points.

True
voxel float

The points are thinned to this spacing first (m), so the result does not depend on the density they come at; 0 keeps every point.

0.02

Returns:

Type Description
dict

buttressed (bool); base_circle_fit and stem_circle_fit (median share of bark points a circle explains below low and above 2 m); stem_radius (m, from the round slices above 2 m); ridges and ridge_share (share of the angle with persistent protrusions); spread (95th percentile distance of the base points from the centre, in stem radii); top (m above ground, where a circle explains the stem again, NaN if not buttressed); centre.

See Also

sylva.qsm.buttress_mesh : rebuild the base once it is known to be buttressed.

convex_hull_area

convex_hull_area(xy: ndarray) -> float

Area of the convex hull of 2D points.

Parameters:

Name Type Description Default
xy ndarray

(N, 2) points; non-finite rows are ignored.

required

Returns:

Type Description
float

Area in squared input units (NaN for fewer than 3 distinct points).

crown_shape

crown_shape(points, base_xy=None, crown_base: float | None = None, slice_height: float = 0.5) -> dict

Crown shape from a tree's points (or any 3-D outline of the crown).

Points below crown_base (an absolute z) are ignored. Returns the vertical projection (convex hull projected_area, equivalent diameter, max_width), volume and surface of convex hulls stacked every slice_height metres (they follow the crown's taper, unlike one 3-D hull), base_height and top_height (z of the lowest and highest crown point), and the horizontal offset of the crown's centroid from base_xy (the stem; the lowest point when None), its offset_direction (deg, counter-clockwise from +x) and asymmetry (offset over the equivalent crown radius).

Parameters:

Name Type Description Default
points

A ~sylva.PointCloud or (N, 3) array of one tree, e.g. cloud[labels == tree_id] or points sampled from a QSM's leaves.

required
base_xy

Stem position the offset is measured from.

None
crown_base float | None

Absolute z of the crown base; everything is crown if None.

None
slice_height float

Thickness (m) of the stacked hull slices.

0.5

Returns:

Type Description
dict

The keys above, lengths in m, area m², volume m³.