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 |
|
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.
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'
|
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, |
voxel_size |
float
|
Voxel edge (m). |
density |
ndarray
|
Leaf area density (m² 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
|
|
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 |
required | |
field
|
str
|
Density field; |
'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
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 |
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 |
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.
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 |
{}
|
Returns:
| Type | Description |
|---|---|
ndarray or tuple
|
Boolean per input point, True for wood; with |
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 ( |
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 ( |
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 ( |
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 |
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
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
|
None
|
leaf_width
|
float | None
|
Leaf size (m); the shape's own size by default. Area per leaf is
|
None
|
shape
|
LeafShape | None
|
Blade to cut the leaves from, such as one scanned from life
( |
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 |
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 |
required |
sides
|
int
|
Facets around each cylinder. |
12
|
contiguous
|
bool
|
One continuous tube per branch (see |
False
|