Skip to content

sylva.leaves

sylva.leaves

Foliage for a structure model: leaf/wood labels, leaf angle distribution, leaf area density, and leaf meshes placed on a QSM.

A QSM describes the wood. For radiative transfer or visualisation the leaves are added as flat polygons whose total area, spatial distribution and orientation follow what was measured::

from sylva import leaves, qsm

wood = leaves.classify_leaf_wood(tree)              # bool per point
model = qsm.build_qsm(qsm.wood_points(tree))
foliage = tree[~wood]
angles = leaves.leaf_angle_distribution(foliage)     # from point normals
area = leaves.leaf_area_density(foliage, voxel_size=0.25)
mesh = leaves.add_leaves(model, area, angles, leaf_points=foliage)
leaves.write_tree_obj("tree.obj", model, mesh)

Leaf area from points counts what the scanner saw, so occluded foliage is missing and the figure is a lower bound. With pulse data, take the leaf area density of a ray-traced voxel grid instead (LeafAreaGrid.from_voxels), or scale to a known total with LeafAreaGrid.scaled_to.

LeafAngleDistribution dataclass

Leaf inclination angle distribution (inclination = angle between the leaf normal and the vertical; 0 deg is a horizontal leaf).

Build with leaf_angle_distribution from points, or from_type for a textbook distribution.

Attributes:

Name Type Description
bin_centres ndarray

Inclination bin centres over 0-π/2 (rad).

density ndarray

Probability per bin, sums to 1.

mean, std

Mean and standard deviation of the inclination (rad).

beta_a, beta_b

Beta distribution fitted on t = 2θ/π by the method of moments, in the usual order: the density is proportional to t^(beta_a - 1) (1 - t)^(beta_b - 1). Goel and Strebel (1984) write the same distribution with mu = beta_b and nu = beta_a; see goel_strebel.

chi float

Campbell's (1990) ellipsoidal parameter (1 spherical, > 1 planophile).

de_wit str | None

Nearest de Wit (1965) type by name.

goel_strebel property

goel_strebel: tuple[float, float]

The beta fit as Goel and Strebel's (1984) (mu, nu): (beta_b, beta_a).

Their density on t = 2θ/π is proportional to (1 - t)^(mu - 1) t^(nu - 1); pass the pair as lad_params with lad="twoParamBeta" to sylva.voxels.ray_voxelize or sylva.voxels.leaf_projection.

mean_deg property

mean_deg: float

Mean leaf inclination in degrees (57.3 for spherical).

g

g(beam_zenith) -> np.ndarray

Leaf projection function G for this distribution (Wilson 1960).

Parameters:

Name Type Description Default
beam_zenith

Beam zenith angle(s) in radians.

required

Returns:

Type Description
ndarray

G per angle (0.5 everywhere for a spherical distribution).

from_type classmethod

from_type(name: str = 'spherical', n_bins: int = 18) -> 'LeafAngleDistribution'

A textbook de Wit (1965) distribution.

Parameters:

Name Type Description Default
name str

spherical, uniform, planophile, erectophile, plagiophile or extremophile.

'spherical'
n_bins int

Bins over 0-90 degrees.

18

Returns:

Type Description
LeafAngleDistribution

Raises:

Type Description
KeyError

For an unknown name.

LeafAreaGrid dataclass

One-sided leaf area density on a regular grid.

Build with leaf_area_density (from points) or from_voxels (from a ray-traced grid).

Attributes:

Name Type Description
origin ndarray

Minimum corner, (x, y, z).

voxel_size float

Voxel edge (m).

density ndarray

Leaf area density (m² m⁻³), (nz, ny, nx).

area property

area: ndarray

Leaf area per voxel (m²), shaped like density.

total_area property

total_area: float

Total one-sided leaf area (m²).

scaled_to

scaled_to(total_area: float) -> 'LeafAreaGrid'

Rescale to a known total leaf area, keeping the spatial pattern.

Use with an independent total, e.g. from allometry, litter traps or a ray-traced grid, since point-based totals miss occluded foliage.

Parameters:

Name Type Description Default
total_area float

Target one-sided leaf area (m²).

required

Returns:

Type Description
LeafAreaGrid

profile

profile() -> tuple[np.ndarray, np.ndarray]

Vertical leaf area profile.

Returns:

Type Description
z, area : numpy.ndarray

z of each layer's centre (grid frame, not height above ground) and its leaf area (m²).

cells

cells() -> tuple[np.ndarray, np.ndarray]

Voxels that hold leaf area.

Returns:

Name Type Description
centres ndarray

(n, 3) voxel centres.

area ndarray

Leaf area of each (m²).

from_voxels classmethod

from_voxels(grid, field: str = 'pad_fpl') -> 'LeafAreaGrid'

Leaf area density from a ray-traced grid.

Parameters:

Name Type Description Default
grid

A sylva.voxels.RayVoxelGrid.

required
field str

Density field; "lad_fpl" for leaf area when the grid was built with leaf_classes, "pad_fpl" for plant area.

'pad_fpl'

Returns:

Type Description
LeafAreaGrid

NaN (unobserved) voxels become 0.

LeafShape dataclass

The blade one leaf is cut from, and how big it is.

The blade lives in unit leaf space — (along, across, up) with the base at the origin, the tip at along = 1 and the greatest width 1 across — and is scaled by length along and by width across and up when placed. It may therefore be curled or made of several leaflets, not only a flat outline. Default is the built-in six-sided blade at 8 × 4 cm.

Examples:

A bigger leaf of the built-in shape, and one scanned from life::

shape = leaves.LeafShape(length=0.15, width=0.06)
shape = leaves.LeafShape.from_obj("eucalypt_leaf.obj")  # keeps its own size

area property

area: float

One-sided area of one leaf (m²): the sum of its triangles.

resized

resized(length: float | None = None, width: float | None = None) -> 'LeafShape'

The same blade at a new size.

Parameters:

Name Type Description Default
length float | None

New length and width (m); unchanged where None.

None
width float | None

New length and width (m); unchanged where None.

None

Returns:

Type Description
LeafShape

scaled_to

scaled_to(area: float) -> 'LeafShape'

The same blade and aspect ratio, scaled to a one-sided area (m²).

Parameters:

Name Type Description Default
area float

Wanted area of one leaf (m²).

required

Returns:

Type Description
LeafShape

from_mesh classmethod

from_mesh(vertices, faces, length: float | None = None, width: float | None = None, normalise: bool = True) -> 'LeafShape'

A custom blade from a mesh of one leaf.

The mesh is read in leaf space: the base at the smallest x, the tip along +x, the blade across ±y and any curl in z. With normalise it is mapped into unit space and its own extent becomes the default size, so a leaf modelled in metres keeps the size it was drawn at.

Parameters:

Name Type Description Default
vertices

(V, 3) mesh vertices, or (V, 2) for a flat blade.

required
faces

(F, 3) triangles into vertices.

required
length float | None

Size to place the blade at (m); the mesh's own extent by default.

None
width float | None

Size to place the blade at (m); the mesh's own extent by default.

None
normalise bool

Map the mesh into unit leaf space. Pass False for vertices that are already in it.

True

Returns:

Type Description
LeafShape

Raises:

Type Description
ValueError

If the mesh is empty or flat along the blade.

from_obj classmethod

from_obj(path: str | Path, length: float | None = None, width: float | None = None, normalise: bool = True) -> 'LeafShape'

A custom blade from an OBJ file holding one leaf.

Only v and f lines are read; polygons are fanned into triangles and texture and normal indices are ignored.

Parameters:

Name Type Description Default
path str | Path

OBJ file of a single leaf, oriented as in from_mesh.

required
length float | None

Size to place the blade at (m); the mesh's own extent by default.

None
width float | None

Size to place the blade at (m); the mesh's own extent by default.

None
normalise bool

Map the mesh into unit leaf space.

True

Returns:

Type Description
LeafShape

Raises:

Type Description
ValueError

If the file holds no faces.

LeafMesh dataclass

Leaf polygons from add_leaves; len() is the leaf count.

total_area property

total_area: float

One-sided leaf area of all leaves (m²).

to_obj

to_obj(path: str | Path) -> None

Write the leaves as one OBJ object named leaves.

Parameters:

Name Type Description Default
path str | Path

Output file.

required

classify_leaf_wood

classify_leaf_wood(cloud: PointCloud, voxel_size: float = 0.02, method: str = 'gbs', return_scores: bool = False, **wood_params)

Wood (True) / leaf (False) for every point of one tree.

method="gbs" (default) is the graph-based separation of Tian and Li (2022): edges joining points that grow in different directions are cut, the cut graph is split into shells of path length at several intervals and a piece is wood when it spans its shell and is cylindrical or linear, with a taper check down its path. By default the shells are 0.1-1 m with a 45 deg direction limit for trees under 15 m and 0.5-3 m with 27 deg for taller ones (the authors' two settings). Options: intervals, max_angle, linearity, circle_error, graph_k, max_edge, min_points. On 30 manually labelled tropical trees (Van den Broeck et al. 2025) it scores accuracy 0.90 and mIoU 0.79; the passage filter below scores 0.68 and 0.51.

method="passage":

The leaf/wood filter of sylva.qsm.wood_points (path passage from the base plus local anisotropy) runs on the cloud thinned to voxel_size, here with a second anisotropy scale of 10 cm (scale_radius): a single leaf is as planar as bark over a few centimetres, but wider than a leaf foliage is a jumble of orientations. Each point takes the label of its nearest thinned neighbour. wood_params are that filter's options (threshold is passed as high_threshold).

return_scores=True adds a wood confidence in [0, 1] per point, a weight for sylva.qsm.build_qsm (weights=) and sylva.qsm.build_plot (wood=). For "passage" it is 1 where the filter keeps the point and 0.4 times the point's anisotropy (planarity + linearity) where it drops it: dropped points still count a little towards a circle but are never confident wood. For "gbs" it is half the label plus half the share of the shell scales (intervals) at which the point's piece was classified wood: 1 for a piece that is cylindrical or linear at every scale, 0.5 for a point that is wood only by lying on a path or next to wood, 0 for a leaf. The labels are unchanged.

Parameters:

Name Type Description Default
cloud PointCloud

One segmented tree.

required
voxel_size float

Work on the cloud thinned to this spacing (m); every input point gets the label of its nearest thinned point. 0 uses every point.

0.02
method ('gbs', 'passage')

Classifier; see above.

"gbs"
return_scores bool

Also return the wood confidence per point (see above).

False
**wood_params

For "gbs": intervals (shell thicknesses, m), max_angle (rad), linearity [0.9], circle_error [0.2, relative to the radius], graph_k [8], max_edge [1.0], base_height [0.25], min_points [10]. For "passage": the options of sylva.qsm.wood_points.

{}

Returns:

Type Description
ndarray or tuple

Boolean per input point, True for wood; with return_scores, the tuple (labels, confidence), the confidence a float per point.

Raises:

Type Description
ValueError

For an unknown method.

See Also

sylva.qsm.wood_points : the filter that prepares QSM input.

leaf_angle_distribution

leaf_angle_distribution(leaf_points, k: int = 12, n_bins: int = 18, weights=None, inclinations: bool = False, res: float | None = 0.0) -> LeafAngleDistribution

Leaf angle distribution from leaf points.

Normals come from a PCA over k neighbours, as in Vicari et al. (2019). By default the points are first thinned to res (0: chosen from the point spacing) and each is weighted by the leaf area it stands for, so densely scanned leaves do not dominate; pass res=None to use every point equally. With inclinations=True the first argument is an array of inclinations (rad) instead of points.

Parameters:

Name Type Description Default
leaf_points

Leaf points of a tree or plot (~sylva.PointCloud or (N, 3)), e.g. tree[~classify_leaf_wood(tree)]; or inclinations with inclinations=True.

required
k int

Neighbours for the PCA normals. Too many blur across leaves; too few follow scanner noise.

12
n_bins int

Histogram bins over 0-90 degrees.

18
weights

Weights for inclinations or for unthinned points (res=None).

None
inclinations bool

Treat the first argument as inclinations.

False
res float | None

Thinning cube size (m); 0 picks 3.5 × the median spacing; None disables thinning and area weighting.

0.0

Returns:

Type Description
LeafAngleDistribution
Notes

Range noise tilts point normals towards random, which biases planophile canopies towards spherical. Use well-registered, close-range data.

leaf_area_density

leaf_area_density(leaf_points, voxel_size: float = 0.25, res: float | None = None, k: int = 12) -> LeafAreaGrid

Leaf area density from leaf points alone.

The points are thinned to one per cube of side res; a surface with unit normal n crosses (|nx| + |ny| + |nz|) / res**2 such cubes per unit area, so each survivor stands for res**2 / (|nx| + |ny| + |nz|). res must be a few times the point spacing (smaller cubes fall between the points and undercount) and well under the leaf size (larger ones overcount at the edges); by default it is 3.5 times the median nearest-neighbour spacing, within about 15 % on unoccluded synthetic leaves. Only foliage the scanner saw is counted.

Parameters:

Name Type Description Default
leaf_points

Leaf points (~sylva.PointCloud or (N, 3)).

required
voxel_size float

Output grid resolution (m).

0.25
res float | None

Thinning cube size (m); None picks 3.5 × the median spacing.

None
k int

Neighbours for the normals.

12

Returns:

Type Description
LeafAreaGrid

Aligned to multiples of voxel_size. A lower bound on the true leaf area wherever foliage was occluded.

single_leaf_area

single_leaf_area(length: float, width: float, shape: 'LeafShape | None' = None) -> float

One-sided area of one leaf of the given size.

Parameters:

Name Type Description Default
length float

Leaf length and width (m).

required
width float

Leaf length and width (m).

required
shape 'LeafShape | None'

Blade to measure; the built-in outline by default.

None

Returns:

Type Description
float

Area (m²), 0.562 × length × width for the built-in outline.

default_leaf

default_leaf() -> LeafShape

The leaf add_leaves uses when none is given.

Returns:

Type Description
LeafShape

set_default_leaf

set_default_leaf(shape: LeafShape | None = None, length: float | None = None, width: float | None = None) -> LeafShape

Set the leaf add_leaves uses when none is given.

Sets it for the process, so a site's leaf size or a scanned blade is chosen once rather than at every call::

leaves.set_default_leaf(length=0.15, width=0.06)

Parameters:

Name Type Description Default
shape LeafShape | None

Blade to make the default; the current default if None.

None
length float | None

Size to set on it (m); unchanged where None.

None
width float | None

Size to set on it (m); unchanged where None.

None

Returns:

Type Description
LeafShape

The previous default, to restore it later.

add_leaves

add_leaves(model: QSM | None, leaf_area: LeafAreaGrid | float, angles: LeafAngleDistribution | str = 'spherical', leaf_points=None, leaf_length: float | None = None, leaf_width: float | None = None, shape: LeafShape | None = None, max_branch_distance: float = 0.5, jitter: float = 0.01, seed: int = 1) -> LeafMesh

Leaf polygons for a QSM.

leaf_area is a LeafAreaGrid, or a total (m2) to spread over leaf_points in proportion to the area they show. Each voxel receives leaves until its area is met, centred on leaf points of that voxel where there are any (uniformly inside it otherwise), with normals drawn from angles (a distribution or a de Wit type name) and a uniform azimuth; blades within max_branch_distance of a cylinder point away from it. Leaves may intersect: no collision test is made, unlike the insertion of Åkerblom et al. (2018).

Parameters:

Name Type Description Default
model QSM | None

The tree's QSM, or None for leaves without wood.

required
leaf_area LeafAreaGrid | float

Where and how much leaf area: a grid, or a total (m²).

required
angles LeafAngleDistribution | str

Leaf angle distribution, or a de Wit type name.

'spherical'
leaf_points

Leaf points to centre leaves on; required with a total area.

None
leaf_length float | None

Leaf size (m); the shape's own size by default. Area per leaf is LeafShape.area.

None
leaf_width float | None

Leaf size (m); the shape's own size by default. Area per leaf is LeafShape.area.

None
shape LeafShape | None

Blade to cut the leaves from, such as one scanned from life (LeafShape.from_obj); default_leaf by default.

None
max_branch_distance float

Leaves within this distance (m) of a cylinder are attached to it.

0.5
jitter float

Random offset (m) of leaf centres.

0.01
seed int

Random seed; the same inputs and seed give the same leaves.

1

Returns:

Type Description
LeafMesh

Raises:

Type Description
ValueError

With a total area but no leaf_points.

write_tree_obj

write_tree_obj(path: str | Path, model: QSM, leaf_mesh: LeafMesh, sides: int = 12, contiguous: bool = False) -> None

Write wood cylinders and leaves to one OBJ, as objects wood and leaves.

Parameters:

Name Type Description Default
path str | Path

Output file.

required
model QSM

The tree's QSM.

required
leaf_mesh LeafMesh

Leaves from add_leaves.

required
sides int

Facets around each cylinder.

12
contiguous bool

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

False