Skip to content

sylva.qsm

sylva.qsm

Quantitative structure models: skeletonisation and cylinder fitting.

A QSM is a set of connected cylinders. build_qsm bins geodesic distance from the base over a kNN graph, splits bins into connected segments (Verroust and Lazarus 2000; Xu et al. 2007), fits a RANSAC cylinder to each and links parents.

Buttress dataclass

An irregular stem base as a closed mesh; build with buttress_mesh.

Attributes:

Name Type Description
vertices, faces

Closed triangle mesh ((n, 3) coordinates, (m, 3) 0-based vertex indices) from the ground to top.

volume float

Volume below top (m³), the sum of the slice areas.

top, top_z

Where the buttress ends: height above ground (m) and absolute z.

heights, areas, solidities

Per slice: bottom height (m), cross-section area (m²), and solidity (area over convex-hull area; about 1 for a round stem, low for flanges).

open ndarray

Per slice, True where the outline did not close (part of the bark unseen): the seen bark was kept, thickened to close_radius, plus a circle where the points form a good arc.

total_volume

total_volume(model: QSM) -> float

Buttress volume plus the QSM's volume above the buttress.

Parameters:

Name Type Description Default
model QSM

The tree's cylinder model.

required

Returns:

Type Description
float

Volume (m³).

fuse

fuse(model: QSM, sides: int = 12, contiguous: bool = True, overlap: float = 0.1) -> 'TreeMesh'

Join this buttress to a QSM as one mesh of the whole stem.

The buttress replaces the cylinders below its top: the model is cut at top_z (QSM.above) and both surfaces go into one mesh, so nothing is counted twice and the volume is the one total_volume reports. The parts stay watertight and separately labelled rather than being welded into a single shell — a boolean union of a flanged base and a thousand tubes is not something a triangle mesh survives cleanly.

A cut tube ends square to its own axis, so a leaning stem would hang over the buttress top by up to its radius times the lean. overlap cuts the wood that much lower, letting it reach down inside the base where the seam cannot be seen. It changes no volume: those are read from top_z either way.

Parameters:

Name Type Description Default
model QSM

The tree's cylinder model, in the same frame as the buttress.

required
sides int

Facets around each cylinder.

12
contiguous bool

One continuous tube per branch (see QSM.mesh).

True
overlap float

How far below top_z (m) the wood is cut, so that it meets the buttress inside it. 0 abuts the two exactly at the plane.

0.1

Returns:

Type Description
TreeMesh

Vertices, faces, a part label per face, and the volumes.

Examples:

>>> b = buttress_mesh(cloud, base_xy=(x, y))
>>> b.fuse(model).to_ply("tree.ply")

to_obj

to_obj(path: str | Path) -> None

Write the mesh as a Wavefront OBJ object named buttress.

Parameters:

Name Type Description Default
path str | Path

Output file.

required

to_ply

to_ply(path: str | Path) -> None

Write the mesh as a binary PLY.

Parameters:

Name Type Description Default
path str | Path

Output file.

required

TreeMesh dataclass

A buttress and a QSM as one mesh; build with Buttress.fuse.

Attributes:

Name Type Description
vertices, faces

The two surfaces in one mesh ((n, 3) coordinates, (m, 3) 0-based vertex indices).

part ndarray

Per face: 0 for the buttress, 1 for the wood above it.

buttress_volume, wood_volume

Volume below and above top_z (m³).

top_z float

Absolute height where the buttress ends and the cylinders start.

offset float

Distance (m) between the middle of the base and the middle of the wood at the join. A stem that sits over its base is a few centimetres out.

overhang float

Share of the wood's cross-section at the join that lies outside the base. Anything much above zero means the two do not agree about where the stem is: usually a buttress top found too low, a stem axis pulled off by the flanges, or a neighbour's wood in the cloud.

volume property

volume: float

Whole-stem volume (m³): buttress plus the wood above it.

to_obj

to_obj(path: str | Path) -> None

Write the mesh as objects buttress and wood.

Parameters:

Name Type Description Default
path str | Path

Output file.

required

to_ply

to_ply(path: str | Path, color=None) -> None

Write the mesh as a binary PLY, the buttress darker than the wood.

Parameters:

Name Type Description Default
path str | Path

Output file.

required
color

One RGB triple (0-255) for every face; by default the buttress is bark brown and the wood keeps the stem colour.

None

QSM dataclass

A tree as connected cylinders; build with build_qsm.

Attributes:

Name Type Description
cylinders ndarray

(n, 12) float array, one row per cylinder, with columns COLUMNS:

Column Meaning
sx, sy, sz start (base) of the cylinder
ax, ay, az unit axis, pointing away from the tree base
length, radius m
parent row of the parent cylinder, -1 for the first
branch_order 0 stem, 1 first-order branch, ...
branch_id branch the cylinder belongs to
n_points points the fit used; 0 where the radius came from priors

Examples:

>>> q = build_qsm(wood_points(tree_cloud))
>>> q.total_volume, q.dbh
>>> q.to_csv("tree_001.csv"); q.to_ply("tree_001.ply")

start property

start: ndarray

Cylinder start points, (n, 3).

axis property

axis: ndarray

Unit axes, (n, 3).

end property

end: ndarray

Cylinder end points, start + axis * length.

volumes property

volumes: ndarray

Volume of each cylinder (m³).

total_volume property

total_volume: float

Woody volume of the tree (m³); multiply by basic density for biomass.

stem_volume property

stem_volume: float

Volume of the order-0 cylinders (m³).

branch_volume property

branch_volume: float

Volume of all branches, order >= 1 (m³).

total_length property

total_length: float

Summed cylinder length (m).

max_branch_order property

max_branch_order: int

Highest branch order (0 for a bare stem or empty model).

dbh property

dbh: float

Stem diameter at 1.3 m above the base (m), from the stem cylinders.

column

column(name: str) -> np.ndarray

One column of cylinders.

Parameters:

Name Type Description Default
name str

A name from COLUMNS, e.g. "radius".

required

Returns:

Type Description
ndarray

A view, length len(self).

Raises:

Type Description
ValueError

For an unknown name.

metrics

metrics(crown_branch_length: float = 1.0, crown_slice: float = 0.5) -> dict

Tree architecture from the cylinders.

Height, DBH, volumes and lengths (total, stem, branches, and per branch order), branch and tip counts, path fraction (mean base-to-tip path over the longest), crown base height (lowest first-order branch at least crown_branch_length long), stem lean and its direction, sweep (greatest offset of the stem from the chord between the base and the crown base, over its length), the stem taper profile, the crown outlined by the branches (see sylva.trees.crown_shape, slices crown_slice high), length-weighted median insertion and zenith angles of first-order branches (deg), and the share of volume and length whose radius was fitted to points rather than filled in by the taper and pipe-model priors -- a quality flag for the model. Heights are above the stem base.

Parameters:

Name Type Description Default
crown_branch_length float

Shortest first-order branch (m) that marks the crown base.

1.0
crown_slice float

Slice height (m) of the stacked crown hulls.

0.5

Returns:

Type Description
dict

height, dbh, total_volume, stem_volume, branch_volume, total_length, stem_length, max_order, n_branches_by_order, length_by_order, volume_by_order (lists indexed by order), n_tips, path_fraction, crown_base_height, lean and lean_direction (deg), sweep, taper_heights and taper_radii (arrays), crown (dict as sylva.trees.crown_shape), measured_volume_fraction, measured_length_fraction, median_insertion_angle and median_branch_zenith (deg). Lengths m, volumes m³.

Notes

Validated against the source meshes of simulated trees: height -2 %, DBH -1 %, crown area -3 %, branch zenith error 3 degrees (Benchmarks > QSMs). A low measured_volume_fraction means most of the volume came from priors; treat such trees with care.

branches

branches() -> dict[str, np.ndarray]

One row per branch (branch_id chain; the stem is order 0), as columns: id, order, parent (branch it grows from, -1 for the stem), n_cylinders, length (m), volume (m3), base_radius and length-weighted mean_radius (m), base_height and tip_height (m above the stem base), insertion_angle (deg between its direction over 0.5 m past its first cylinder, which only joins it to the parent's axis, and the parent's direction over 0.5 m either side of the junction), zenith and azimuth of the base-to-tip chord (deg), tortuosity (length over chord), n_children and measured_fraction (share of the length fitted to points).

Returns:

Type Description
dict

{column: array}, one entry per branch; ready for pandas.DataFrame.

summary

summary() -> dict

Headline numbers with units in the keys.

Returns:

Type Description
dict

n_cylinders, total_volume_m3, stem_volume_m3, branch_volume_m3, total_length_m, max_branch_order and dbh_m. Use metrics for the full set.

to_csv

to_csv(path: str | Path) -> None

Write the cylinders as CSV with a header of COLUMNS.

Parameters:

Name Type Description Default
path str | Path

Output file; read back with from_csv.

required

to_treefile

to_treefile(path: str | Path) -> None

Write in the raycloudtools/treetools _trees.txt format.

Parameters:

Name Type Description Default
path str | Path

Output file, for treeinfo, treemesh and other treetools.

required

from_csv classmethod

from_csv(path: str | Path) -> QSM

Read cylinders written by to_csv.

Parameters:

Name Type Description Default
path str | Path

CSV with a header line and the 12 COLUMNS.

required

Returns:

Type Description
QSM

mesh

mesh(sides: int = 12, contiguous: bool = False) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Triangle mesh of the cylinders.

Parameters:

Name Type Description Default
sides int

Facets around each cylinder.

12
contiguous bool

One continuous tube per branch instead of one closed tube per cylinder: consecutive cylinders share a ring, so a branch has no caps inside it and its surface runs unbroken from base to tip. The ring frame is carried along the branch so the facets do not twist, and a shared ring takes the mean of the two radii. Each branch is still its own closed surface, pushed into its parent rather than welded to it, and the mesh is about half the size.

False

Returns:

Name Type Description
vertices ndarray

(n, 3) float.

faces ndarray

(m, 3) vertex indices, 0-based.

owner ndarray

Cylinder row of each face.

to_obj

to_obj(path: str | Path, sides: int = 12, contiguous: bool = False) -> None

Write the cylinder mesh as a Wavefront OBJ (Blender, MeshLab, CloudCompare).

Parameters:

Name Type Description Default
path str | Path

Output file.

required
sides int

Facets around each cylinder.

12
contiguous bool

One continuous tube per branch (see mesh).

False

volume_above

volume_above(z: float) -> float

Cylinder volume above a horizontal plane, cutting cylinders that cross it.

Use it to join a buttress_mesh to the model: the buttress volume below its top plus the cylinders above it.

Parameters:

Name Type Description Default
z float

Absolute height of the plane (e.g. Buttress.top_z).

required

Returns:

Type Description
float

Volume (m³); a cylinder crossing the plane counts by the share of its axis above it.

above

above(z: float) -> QSM

The model above a horizontal plane, cutting cylinders that cross it.

The companion of volume_above: a cylinder crossing the plane keeps the part above it, one below it is dropped, and a child whose parent went is made a branch of its own. Use it to leave room for a Buttress under the wood.

Parameters:

Name Type Description Default
z float

Absolute height of the plane (e.g. Buttress.top_z).

required

Returns:

Type Description
QSM

A new model; the original is unchanged.

Notes

A cylinder is cut where its axis crosses the plane, so a leaning one keeps a slanted stub rather than being squared off.

to_ply

to_ply(path: str | Path, sides: int = 12, color=None, contiguous: bool = False) -> None

Write the cylinder mesh as a binary PLY with face colours.

Parameters:

Name Type Description Default
path str | Path

Output file.

required
sides int

Facets around each cylinder.

12
color

One RGB triple (0-255) for every face; by default faces are coloured by branch order, brown stem to green twigs.

None
contiguous bool

One continuous tube per branch (see mesh).

False

PlotQSMs dataclass

Every tree of a plot modelled; build with build_plot.

Attributes:

Name Type Description
models dict[int, QSM]

{tree_id: QSM} for the trees that were fitted.

buttresses dict[int, 'Buttress']

{tree_id: Buttress} where a buttress was found and meshed.

skipped dict[int, str]

{tree_id: reason} for the trees that were not modelled: too few points, or the message of the fit that failed.

total_volume property

total_volume: float

Wood volume of the whole plot (m³).

volume

volume(tree_id: int) -> float

Wood volume of one tree (m³), buttress included where there is one.

Parameters:

Name Type Description Default
tree_id int

Which tree.

required

Returns:

Type Description
float

table

table() -> list[dict]

One row per tree, ready for a CSV.

Returns:

Type Description
list of dict

tree_id, points, volume_m3, dbh_m, height_m, n_cylinders, measured_volume and measured_length (the share of the model that was fitted to points rather than taken from the taper and pipe-model priors), and buttress_m3 / buttress_top_m (blank without a buttress).

to_csv

to_csv(path: str | Path) -> None

Write table as a CSV.

Parameters:

Name Type Description Default
path str | Path

Output file.

required

write_meshes

write_meshes(directory: str | Path, fmt: str = 'ply', sides: int = 12, contiguous: bool = True, prefix: str = 'tree') -> list[Path]

Write a surface mesh per tree into directory.

A tree with a buttress is written fused (Buttress.fuse), so the flanged base and the cylinders above it come out as one file; every other tree is its cylinder mesh.

Parameters:

Name Type Description Default
directory str | Path

Created if it does not exist.

required
fmt ('ply', 'obj')

PLY is binary and carries face colours; OBJ is text and keeps the buttress and the wood as named objects.

"ply"
sides int

Facets around each cylinder.

12
contiguous bool

One continuous tube per branch (see QSM.mesh).

True
prefix str

File name stem; files are <prefix><tree_id>.<fmt>.

'tree'

Returns:

Type Description
list of pathlib.Path

The files written, in tree order.

Raises:

Type Description
ValueError

For an unknown format.

write_cylinders

write_cylinders(directory: str | Path, prefix: str = 'tree') -> None

Write one cylinder CSV per tree into directory.

Parameters:

Name Type Description Default
directory str | Path

Created if it does not exist.

required
prefix str

File name stem; files are <prefix><tree_id>.csv.

'tree'

buttress_mesh

buttress_mesh(cloud: PointCloud, base_xy, ground_z: float | None = None, height_attr: str = 'height', resolution: float = 0.02, slice_height: float = 0.05, close_radius: float = 0.08, max_radius: float = 4.0, max_height: float = 6.0, top: float | None = None, solidity: float = 0.9, max_flare: float = 1.0, smooth: int = 10) -> Buttress

Rebuild a buttressed or otherwise irregular stem base as a closed mesh.

A cylinder cannot follow a flanged base: a circle fitted to a star-shaped section misses the flanges or spans the gaps between them (at 1.3 m a big tropical tree can be a 3 m wide star that a circle explains 15 % of). Here the base is rebuilt volumetrically, so any shape works:

  1. The tree's points are cut into slice_height slices and rasterised at resolution.
  2. A morphological closing of close_radius bridges gaps that occlusion leaves in the bark, and flood-filling from outside gives the solid cross-section. Where the outline does not close (part of the bark unseen), the seen bark is kept, thickened to close_radius (a flange seen from outside becomes a flange of that thickness), plus a circle where the points form a good arc of a round stem.
  3. Slices are built from the top down. A buttress only widens towards the ground, so each section contains the one above, and the part of a slice kept is the part connected to the section above, and no wider than max_flare allows: neighbouring stems, shrubs and logs stay out, litter and ground around the base are not closed into the solid, and the core carries down where near the ground only the outsides of the flanges were seen.
  4. The buttress ends where the section turns convex (solidity at or above solidity for four slices), unless top is given.
  5. The stacked sections become a watertight surface (surface nets, Gibson 1998, smoothed as in Taubin 1995); the volume is the sum of slice areas, with the boundary cells counted as half.

Parameters:

Name Type Description Default
cloud PointCloud

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

required
base_xy

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

required
ground_z float | None

Terrain elevation at the stem, to place the mesh; z - height of the lowest points near the stem if None.

None
height_attr str

Attribute holding height above ground.

'height'
resolution float

Raster cell (m).

0.02
slice_height float

Slice thickness (m).

0.05
close_radius float

Gaps in the outline up to twice this wide are bridged (m). Larger closes more occlusion but also fills narrow gaps between flanges.

0.08
max_radius float

Horizontal reach from the stem centre (m).

4.0
max_height float

Highest possible top (m above ground).

6.0
top float | None

Buttress top (m above ground); found from the solidity if None.

None
solidity float

Solidity at which a section counts as a round stem.

0.9
max_flare float

How fast a section may widen going down (m out per m down); the default 1.0 is 45°. Flanges flare well within this, while litter and the ground around the base do not, so a scan line that rings the stem is no longer closed into the solid. 0 lifts the limit.

1.0
smooth int

Taubin smoothing passes over the mesh.

10

Returns:

Type Description
Buttress

Empty (no faces, zero volume) if too few points are near the stem.

Examples:

>>> b = qsm.buttress_mesh(tree, (t.x, t.y))
>>> b.volume, b.top, b.total_volume(model)
>>> b.to_obj("buttress.obj")

write_obj

write_obj(path: str | Path, meshes: list[tuple[ndarray, ndarray]], names: list[str] | None = None) -> None

Write several meshes to one OBJ file, one named object each.

Parameters:

Name Type Description Default
path str | Path

Output file.

required
meshes list[tuple[ndarray, ndarray]]

(vertices, faces) pairs with 0-based face indices, e.g. from QSM.mesh for every tree of a plot.

required
names list[str] | None

Object names; tree_1, tree_2, ... if None.

None

write_ply_mesh

write_ply_mesh(path: str | Path, vertices: ndarray, faces: ndarray, face_rgb: ndarray | None = None) -> None

Write a binary little-endian PLY triangle mesh.

Parameters:

Name Type Description Default
path str | Path

Output file.

required
vertices ndarray

(n, 3) coordinates, stored as float32.

required
faces ndarray

(m, 3) 0-based vertex indices.

required
face_rgb ndarray | None

Optional (m, 3) uint8 colour per face.

None

build_qsm

build_qsm(cloud: PointCloud, base_xy=None, k: int = 15, max_edge: float = 1.0, bin_length: float = 0.1, min_points: int = 1, ransac_threshold: float = 0.02, max_radius: float = 1.0, taper_limit: float = 1.1, max_rmse: float = 0.03, smooth_steps: int = 10, apex_radius: float = 0.0025, min_arc_deg: float = 90.0, min_inlier_fraction: float = 0.05, prune_points: int = 5, fit_min_points: int = 50, crop_length: float = 0.0, butt_height: float = 0.6, relative_tolerance: float = 0.08, base_radius: float = 0.0, allometry_tolerance: float = 0.3, stem_radius_cap: float = 0.0, buttress_equivalent_area: bool = True, buttress_max_inlier_fraction: float = 0.3, pipe_slack: float = 1.2, branch_min_inlier_fraction: float = 0.3, spacing_scale: float = 1.5, radius_power: float = 0.0, power_above_spacing: float = 0.025, sensor_noise: float = 0.02, cluster_eps: float = 0.1, centre_fit_points: int = 100, radius_smooth_steps: int = 15, butt_swell: float = 1.1, butt_vertical_run: int = 4, butt_max_lean_deg: float = 50.0, chain_max_d: float = 0.1, fourier_min_radius: float = 0.15, min_weight: float = 0.0, min_mean_weight: float = 0.5, weights=None) -> QSM

Skeletonise then fit cylinders for a single segmented tree.

Skeleton nodes (geodesic bins of bin_length) are Taubin-smoothed and a RANSAC circle is fitted to each node's points in the plane perpendicular to the skeleton; accepted fits need a contiguous arc of min_arc_deg and min_inlier_fraction inliers. Radii are then regularised along every root-to-tip path: an allometric prior anchored on base_radius (pass the measured DBH / 2) replaces weak fits more than allometry_tolerance away, chains are made non-increasing, gaps are interpolated and unmeasured branches take the prior, a pipe model (Shinozaki et al. 1964) that shares a parent's cross-section among its children. Past 2.5 cm point spacing the radius is a power mean of the distances to the axis, after raycloudtools (Devereux et al. 2026). Leafy tips shorter than crop_length are not reconstructed. Run wood_points first on leafy trees.

Parameters:

Name Type Description Default
cloud PointCloud

One segmented tree, ideally wood only (wood_points), in metres with z up. 1-2 cm spacing is enough; denser input is slower without being more accurate.

required
base_xy

Stem position (e.g. (tree.x, tree.y)); the lowest points if None.

None
k int

kNN graph: neighbours per point and longest edge (m). Raise max_edge if occlusion leaves gaps along branches.

15
max_edge int

kNN graph: neighbours per point and longest edge (m). Raise max_edge if occlusion leaves gaps along branches.

15
bin_length float

Geodesic shell width (m); sets the cylinder length.

0.1
min_points int

Smallest segment kept.

1
ransac_threshold float

Circle inlier distance (m).

0.02
max_radius float

Upper limit on any radius (m); set from the measured DBH.

1.0
taper_limit float

A child may be at most this times its parent's radius; wider fits are clamped.

1.1
max_rmse float

Circle fits with a larger RMSE (m) are rejected.

0.03
smooth_steps int

Smoothing passes over skeleton node positions.

10
apex_radius float

Smallest tip radius (m).

0.0025
min_arc_deg float

Contiguous arc a circle fit must cover (degrees).

90.0
min_inlier_fraction float

Inlier share a stem / branch circle needs.

0.05
branch_min_inlier_fraction float

Inlier share a stem / branch circle needs.

0.05
prune_points int

Unmeasured leaf segments with fewer points are pruned as foliage.

5
fit_min_points int

Segments with fewer points take their radius from the taper model.

50
crop_length float

Leafy tips with less subtree length (m) are not reconstructed.

0.0
butt_height float

Below this height (m) only the largest component per shell is kept.

0.6
relative_tolerance float

Refit band around the circle, as a fraction of its radius.

0.08
base_radius float

Breast-height radius (m) anchoring the taper prior; 0 estimates it. Pass a field DBH / 2 when you have one.

0.0
allometry_tolerance float

Weak fits further than this fraction from the prior are replaced.

0.3
stem_radius_cap float

With a base_radius, anchor the taper prior on it and set aside every main-stem circle wider than this many times it, strong or not; the stem follows the prior there and those cylinders count as unmeasured. For boles sheathed in foliage (epicormic regrowth after fire), whose circles fit the foliage rather than the bark; a stem is nowhere above breast height much wider than at it. 1.5 brought 12 sheathed Tumbarumba eucalypts from a median QSM / field DBH of 2.2 to 0.98 (their volume from 105 to 36 m³) and changed 8 clean ones by 2 % in volume. 0 (default) disables. build_plot passes each tree's DBH / 2 as base_radius.

0.0
buttress_equivalent_area bool

Use an equivalent-area radius where a circle explains fewer than that share of a section's points (buttresses, fluting).

True
buttress_max_inlier_fraction bool

Use an equivalent-area radius where a circle explains fewer than that share of a section's points (buttresses, fluting).

True
pipe_slack float

Children's summed cross-section may exceed the parent's by this factor (pipe model); 0 disables.

1.2
cluster_eps float

Clustering radius within a shell (m); 0 uses graph components.

0.1
centre_fit_points int

Clusters with at least this many points get a circle-fitted centre.

100
radius_smooth_steps int

Smoothing passes over radii along each axis.

15
butt_swell float

Unmeasured stem nodes below the lowest fit may exceed it by this factor.

1.1
butt_vertical_run int

Replace the stem below the first run of this many cylinders within butt_max_lean_deg of vertical by a vertical stump; 0 disables.

4
butt_max_lean_deg int

Replace the stem below the first run of this many cylinders within butt_max_lean_deg of vertical by a vertical stump; 0 disables.

4
chain_max_d float

Skeleton chaining radius (m).

0.1
fourier_min_radius float

Sections at least this wide (m) and well covered use an equivalent-area Fourier contour instead of a circle; 0 disables.

0.15
min_weight float

With weights: points with a lower weight are dropped before the graph is built. 0 (default) keeps every point.

0.0
min_mean_weight float

With weights: a circle whose inliers have a lower mean weight is not a measurement, and the section takes its radius from the priors.

0.5
weights

Wood weight per point in [0, 1], for example a wood confidence (sylva.leaves.classify_leaf_wood with return_scores=True). Every point with at least min_weight builds the graph and the skeleton, so connectivity is that of the whole tree, but the weights decide each section's radius: RANSAC scores a candidate circle by the summed weight of its inliers, the circle is refitted by weighted least squares, a section needs a summed weight of fit_min_points to be fitted, its inliers a summed weight of min_points and a mean weight of min_mean_weight, and the inlier share is a share of weight. The arc and the contour of a section use its confident points (weight at least 0.5), and a cylinder's n_points counts its confident inliers. Unmeasured leaf segments are pruned by summed weight (prune_points). Weights of one everywhere give exactly the unweighted model. None (default) fits every point alike.

None

Returns:

Type Description
QSM

Raises:

Type Description
ValueError

If weights does not have one value per point, or holds a value that is NaN or outside [0, 1].

Notes

On 72 destructively harvested trees volume has -3.8 % bias and 19.9 % rRMSE (Benchmarks > QSMs). On simulated trees the deficit is in twigs: about a third of the twig length is recovered, so measured_length_fraction is low even when volume is good.

fit_cylinder

fit_cylinder(xyz: ndarray, axis_init=None) -> dict

Least-squares cylinder through 3D points.

Parameters:

Name Type Description Default
xyz ndarray

(N, 3) points on a roughly cylindrical surface (a stem section).

required
axis_init

Starting axis direction; the principal direction of the points if None.

None

Returns:

Type Description
dict

point (a point on the axis), axis (unit vector), radius and rmse (m).

fit_cylinder_ransac

fit_cylinder_ransac(xyz: ndarray, threshold: float = 0.02, iterations: int = 100, sample_size: int = 12, seed: int = 0) -> dict

Cylinder fit that tolerates outliers (leaves, twigs, noise).

Parameters:

Name Type Description Default
xyz ndarray

(N, 3) points.

required
threshold float

Inlier distance from the surface (m).

0.02
iterations int

RANSAC trials.

100
sample_size int

Points per trial fit.

12
seed int

Random seed.

0

Returns:

Type Description
dict

As fit_cylinder, plus inliers (boolean per point).

skeletonize

skeletonize(cloud: PointCloud, base_xy=None, k: int = 15, max_edge: float = 1.0, bin_length: float = 0.1) -> dict

Graph skeleton of one tree, the first stage of build_qsm.

Parameters:

Name Type Description Default
cloud PointCloud

One tree's (wood) points.

required
base_xy

Stem position; the lowest points if None.

None
k int

Neighbours in the kNN graph.

15
max_edge float

Longest graph edge (m).

1.0
bin_length float

Width of the geodesic distance bins (m).

0.1

Returns:

Type Description
dict

segment_id per point (-1 if disconnected from the base), geodesic distance from the base per point (m), segment centres and (child, parent) edges.

build_plot

build_plot(cloud: PointCloud, labels, stems=None, voxel_size: float = 0.01, wood=True, buttress: bool = False, min_points: int = 2000, height_attr: str = 'height', **params) -> PlotQSMs

A QSM for every tree of a segmented plot.

The loop that build_qsm needs around it: each tree's points are taken from labels, thinned, put through the wood filter and fitted, and a tree that cannot be fitted is recorded rather than raising. Progress is reported (sylva.util.progress), so a plot of a few hundred trees is not silent.

Parameters:

Name Type Description Default
cloud PointCloud

The whole plot, height-normalised (needed for buttress).

required
labels

Tree id per point, as sylva.trees.segment_trees returns; anything below 0 is not part of a tree.

required
stems

The detected trees, used for the stem centre each model is built around, and their DBH, which anchors each model's base radius (base_radius = dbh / 2) unless base_radius is given. Without that anchor, a small tree with a leafy crown can take its trunk radius from a foliage clump. Without stems the centre is the middle of the tree's own points between 0.5 and 1.5 m.

None
voxel_size float

Thin each tree to this spacing first (m); 0 keeps every point.

0.01
wood (bool, array or str)

Where each tree's wood comes from. True (default) runs wood_points on each tree; False fits every point (clouds that are wood already). A boolean array with one value per point gives the wood directly (True is wood; for instance from sylva.leaves.classify_leaf_wood): each tree is fitted on its wood points. A float array of weights in [0, 1] (a wood confidence, classify_leaf_wood(..., return_scores=True)) fits each tree on all its points with those weights (build_qsm(weights=)). A string names a cloud attribute: integer or boolean values are labels (1 or True is wood, 0 and -1 are not), floating-point values are weights. A tree's wood points are selected before they are thinned; with weights each point kept by the thinning keeps its own weight.

True
buttress bool

Look for a buttress on each tree (sylva.trees.detect_buttress) and mesh it, so the volume of a flanged base is not left to the cylinders. Needs height_attr on the cloud.

False
min_points int

Trees with fewer points than this are skipped.

2000
height_attr str

Attribute holding height above ground.

'height'
**params

Passed to build_qsm.

{}

Returns:

Type Description
PlotQSMs

The models, any buttresses, and why a tree was skipped.

Examples:

>>> labels = trees.segment_trees(cloud, stems)
>>> plot = qsm.build_plot(cloud, labels, stems)
>>> plot.total_volume, len(plot), plot.skipped
>>> plot.to_csv("trees.csv"); plot.write_cylinders("qsms/")
Notes

Volumes are the cylinders' own unless a buttress was meshed, in which case PlotQSMs.volume is the mesh below its top plus the cylinders above it.

A QSM needs points on the stem surface: with roughly 1 cm spacing the default 0.1 m shells hold plenty, but a cloud thinned to 3-5 cm leaves most shells with too few, and those cylinders take their radius from the taper and pipe-model priors instead. That runs large - on one 20 m savanna tree, 0.34 m DBH at full resolution against 1.10 m at 5 cm - so build_plot warns when the median model was hardly fitted at all, and measured_length in PlotQSMs.table says so per tree.

wood_points

wood_points(cloud: PointCloud, k: int = 20, threshold: float = 0.85, voxel_size: float | None = 0.02, medium_threshold: float = 0.75, scale_radius: float = 0.0, passage: bool = True, min_passage: int = 3, target_res: float = 0.2, graph_k: int = 10, max_edge: float = 1.0, base_height: float = 0.25, assign_dist: float = 0.05, assign_scale: float = 0.0, component_res: float = 0.05, component_min: int = 200, sor_k: int = 50, sor_std: float = 1.0, dilate_dist: float = 0.03, method: str = 'passage') -> PointCloud

Leaf / wood separation for one tree's points, returning the wood thinned to voxel_size.

Two cues are combined. Local anisotropy -- planarity + linearity over k neighbours (and, with scale_radius > 0, again over that wider neighbourhood, the lower score counting: better leaf labels, but it removes wood a QSM needs, see sylva.leaves.classify_leaf_wood) -- marks bark and branch surfaces above threshold (high likelihood) and medium_threshold (kept only after statistical outlier removal, sor_k / sor_std, and next to wood already found; set it at or above threshold to skip the step, which on small leafy crowns pulls foliage in around the stem). Topology recovers what anisotropy misses (the path-frequency cue of Vicari et al. 2019): shortest paths from the base over a kNN graph are traced to one target per target_res cell, and any point that at least min_passage of those paths run through is wood, with its neighbours within assign_dist -- a roughly or thinly scanned trunk is neither planar nor linear locally but every path to the crown crosses it. A path follows one side of a stem, so this keeps the points on and beside the paths rather than the whole section; assign_scale (try 0.03) widens the reach with the share of the tree a point carries, up to that fraction of the tree height on the trunk. High-likelihood points survive in connected components (component_res) that touch passage wood or hold component_min points; the result is dilated by dilate_dist for thin branches. passage=False gives the anisotropy-only filter.

This filter is tuned to give a QSM what it needs (every stem and branch surface), not the most accurate leaf labels; for leaf area and leaf angles use sylva.leaves.classify_leaf_wood.

Parameters:

Name Type Description Default
cloud PointCloud

One segmented tree.

required
k int

Neighbours for the anisotropy features.

20
threshold float

High- and medium-likelihood anisotropy cut-offs (0-1).

0.85
medium_threshold float

High- and medium-likelihood anisotropy cut-offs (0-1).

0.85
voxel_size float | None

Thin the input to this spacing (m) first; None or 0 keeps every point.

0.02
scale_radius float

Second, wider feature scale (m); 0 disables.

0.0
passage bool

Path-passage wood: enable, paths a point must carry, and target cell size (m).

True
min_passage bool

Path-passage wood: enable, paths a point must carry, and target cell size (m).

True
target_res bool

Path-passage wood: enable, paths a point must carry, and target cell size (m).

True
graph_k int

kNN graph neighbours, longest edge (m), and height (m) of the base region the paths start from.

10
max_edge int

kNN graph neighbours, longest edge (m), and height (m) of the base region the paths start from.

10
base_height int

kNN graph neighbours, longest edge (m), and height (m) of the base region the paths start from.

10
assign_dist float

Reach (m) around passage points, and its growth with the share of the tree a point carries.

0.05
assign_scale float

Reach (m) around passage points, and its growth with the share of the tree a point carries.

0.05
component_res float

Connected-component cell size (m) and minimum size.

0.05
component_min float

Connected-component cell size (m) and minimum size.

0.05
sor_k int

Statistical outlier removal for the medium-likelihood points.

50
sor_std int

Statistical outlier removal for the medium-likelihood points.

50
dilate_dist float

Final dilation (m).

0.03
method ('passage', 'gbs')

"gbs" uses the graph-based labeller of sylva.leaves.classify_leaf_wood instead.

"passage"

Returns:

Type Description
PointCloud

The wood points, thinned to voxel_size, with attributes.