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 |
x, y |
Stem centre at breast height. |
|
dbh |
float
|
Stem diameter at breast height (m) from the taper fit. |
height |
float
|
Filled by |
n_points |
int
|
Stem points after detection; all points after |
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 |
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
|
|
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
|
|
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 ( |
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 |
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 |
required |
labels
|
ndarray
|
Point labels from |
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 |
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 |
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,
|
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 |
required | |
area
|
float
|
Plot area (m²), e.g. |
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. |
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 |
0.75
|
Returns:
| Type | Description |
|---|---|
ndarray
|
|
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 |
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 |
1.5
|
seed_radius
|
float
|
Graph nodes within |
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 |
True
|
height_prior_radius
|
bool
|
Scale costs by 1 / tree height; tree height is estimated from points
within |
True
|
low_height
|
float
|
Near-ground points farther than |
0.5
|
low_radius
|
float
|
Near-ground points farther than |
0.5
|
wood_costs
|
bool
|
Enable wood/leaf edge factors; |
False
|
wood_k
|
bool
|
Enable wood/leaf edge factors; |
False
|
wood_threshold
|
bool
|
Enable wood/leaf edge factors; |
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 |
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 |
10.0
|
voxel_origin
|
|
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
int64 per point: the |
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 |
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
|
|
{}
|
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 |
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 |
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 |
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
|
|
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 |
required |
height_attr
|
str
|
Attribute holding heights. |
'height'
|
crown_base_fraction
|
float
|
Density threshold for the crown base. |
0.1
|
Returns:
| Type | Description |
|---|---|
dict
|
|
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_heightslice; 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 belowloware 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 |
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
|
|
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
|
|
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 |
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³. |