Skip to content

Commit a94efdd

Browse files
feat: use_inventory_data_to_skip_stem_detection
1 parent 47d722f commit a94efdd

2 files changed

Lines changed: 147 additions & 38 deletions

File tree

src/pointtree/instance_segmentation/_tree_x_algorithm.py

Lines changed: 89 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import itertools
66
import multiprocessing
77
from pathlib import Path
8-
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
8+
from typing import Any, cast, Dict, List, Literal, Optional, Tuple, Union
99

1010
from circle_detection import MEstimator, Ransac
1111
import numpy as np
@@ -115,7 +115,12 @@ class TreeXAlgorithm(InstanceSegmentationAlgorithm): # pylint: disable=too-many
115115
.. rubric:: 3. Detection of Tree Stems
116116
117117
The aim of this step is to identify clusters of points that represent individual tree stems, i.e., each stem should
118-
be represented by a single cluster. For this purpose, a horizontal layer is extracted from the point cloud that
118+
be represented by a single cluster. If the stem positions and diameters at breast height are already known (e.g.,
119+
from field measurements), this step can be skipped entirely by passing the known stem positions and diameters to the
120+
:code:`stem_positions` and :code:`stem_diameters` parameters of :code:`__call__`. In that case, the stem detection described below is not executed, and the provided stem positions and diameters are
121+
used directly as input to the subsequent region growing step.
122+
123+
For this purpose, a horizontal layer is extracted from the point cloud that
119124
contains all points within a certain height range above the terrain (the height range is defined by
120125
:code:`stem_search_min_z` and :code:`stem_search_max_z`). This layer should be chosen so that it contains all tree
121126
stems and as few other objects as possible. The points within this slice are downsampled using voxel-based
@@ -293,12 +298,14 @@ class TreeXAlgorithm(InstanceSegmentationAlgorithm): # pylint: disable=too-many
293298
points to be processed or the maximum number of iterations is reached.
294299
295300
To select the initial seed points for a given tree, the following approach is used: (1) All points that were
296-
assigned to the respective stem during the stem detection stage are used as seed points. (2) Additionally, a
297-
cylinder with a height of :code:`tree_seg_seed_layer_height` and a diameter of
298-
:code:`tree_seg_seed_diameter_factor * d` is considered, where :code:`d` is the tree's
299-
stem diameter at breast height, which has been computed in the previous step. The cylinder's center is
300-
positioned at the stem center at breast height, which also has been computed in the previous stage. All points
301-
within the cylinder that have not yet been selected as seed points for other trees are selected as seed points.
301+
assigned to the respective stem during the stem detection stage are used as seed points (if the stem detection
302+
stage was skipped because stem positions and diameters were directly provided by the user, this source of seed
303+
points is not available). (2) Additionally, a cylinder with a height of :code:`tree_seg_seed_layer_height` and a
304+
diameter of :code:`tree_seg_seed_diameter_factor * d` is considered, where :code:`d` is the tree's
305+
stem diameter at breast height, which has either been computed in the previous step or provided by the user. The
306+
cylinder's center is positioned at the stem center at breast height, which has likewise either been computed in
307+
the previous stage or provided by the user. All points within the cylinder that have not yet been selected as
308+
seed points for other trees are selected as seed points.
302309
303310
The search radius for the iterative region growing procedure is set as follows: First, the search radius is set
304311
to the voxel size used for voxel-based subsampling, which is done before starting the region growing procedure.
@@ -1988,6 +1995,8 @@ def __call__( # pylint: disable=too-many-locals
19881995
intensities: Optional[FloatArray] = None,
19891996
point_cloud_id: Optional[str] = None,
19901997
crs: Optional[str] = None,
1998+
stem_positions: Optional[FloatArray] = None,
1999+
stem_diameters: Optional[FloatArray] = None,
19912000
) -> Tuple[LongArray, FloatArray, FloatArray]:
19922001
r"""
19932002
Runs the tree instance segmentation for the given point cloud.
@@ -2001,26 +2010,41 @@ def __call__( # pylint: disable=too-many-locals
20012010
crs: EPSG code of the coordinate reference system of the input point cloud. The EPSG code is used to set the
20022011
coordinate reference system when exporting intermediate data, such as a digital terrain model file.
20032012
If set to :code:`None`, no coordinate reference system is set for the exported data.
2013+
stem_positions: Known stem positions (xy-coordinates of the stem center at breast height) to use instead of
2014+
detecting the stems automatically. If set together with :code:`stem_diameters`, the stem detection step
2015+
is skipped and the provided stem positions and diameters are used directly as input for the region
2016+
growing step. If set to :code:`None`, the stems are detected automatically.
2017+
stem_diameters: Known stem diameters at breast height to use instead of detecting the stems automatically.
2018+
Must be set together with :code:`stem_positions` and have the same length. If set to :code:`None`, the
2019+
stems are detected automatically.
20042020
20052021
Returns:
20062022
:Tuple of three arrays:
20072023
- Tree instance labels for all points. For points not belonging to any tree, the label is set to
20082024
:code:`invalid_instance_id` (constructor parameter).
2009-
- Stem positions of the detected trees (xy-coordinates of the stem center at breast height).
2010-
- Stem diameters at breast height of the detected trees.
2025+
- Stem positions of the trees (xy-coordinates of the stem center at breast height). If
2026+
:code:`stem_positions` was set, this is equal to the input :code:`stem_positions`.
2027+
- Stem diameters at breast height of the trees. If :code:`stem_diameters` was set, this is equal to the
2028+
input :code:`stem_diameters`.
20112029
20122030
Raises:
2013-
ValueError: If :code:`intensities` is not :code:`None`.
2014-
ValueError: If :code:`xyz` and :code:`intensities` have different lengths.
2031+
ValueError: If :code:`intensities` is not :code:`None` and :code:`xyz` and :code:`intensities` have
2032+
different lengths.
2033+
ValueError: If exactly one of :code:`stem_positions` and :code:`stem_diameters` is set to :code:`None`.
2034+
ValueError: If :code:`stem_positions` and :code:`stem_diameters` are set and have different lengths, or if
2035+
:code:`stem_positions` does not have shape :math:`(S, 2)`.
20152036
20162037
Shape:
20172038
- :code:`xyz`: :math:`(N, 3)`
20182039
- :code:`intensities`: :math:`(N)`
2040+
- :code:`stem_positions`: :math:`(S, 2)`
2041+
- :code:`stem_diameters`: :math:`(S)`
20192042
- Output: :math:`(N)`, :math:`(T)`, :math:`(T)`
20202043
20212044
| where
20222045
|
20232046
| :math:`N = \text{ number of points}`
2047+
| :math:`S = \text{ number of known stems}`
20242048
| :math:`T = \text{ number of detected trees}`
20252049
20262050
**Example**::
@@ -2034,13 +2058,32 @@ def __call__( # pylint: disable=too-many-locals
20342058
intensities = point_cloud["intensity"].to_numpy()
20352059
20362060
instance_ids, stem_positions, stem_diameters = algorithm(xyz, intensities)
2061+
2062+
If the stem positions and diameters are already known, they can be passed to the algorithm in order to skip
2063+
the stem detection step::
2064+
2065+
instance_ids, _, _ = algorithm(
2066+
xyz, intensities, stem_positions=known_stem_positions, stem_diameters=known_stem_diameters
2067+
)
20372068
"""
20382069

20392070
self._random_generator = np.random.default_rng(seed=self._random_seed)
20402071

20412072
if intensities is not None and len(xyz) != len(intensities):
20422073
raise ValueError("xyz and intensities must have the same length.")
20432074

2075+
if (stem_positions is None) != (stem_diameters is None):
2076+
raise ValueError("stem_positions and stem_diameters must either both be set or both be None.")
2077+
2078+
stems_provided = stem_positions is not None and stem_diameters is not None
2079+
if stems_provided:
2080+
stem_positions = np.asarray(stem_positions)
2081+
stem_diameters = np.asarray(stem_diameters)
2082+
if stem_positions.ndim != 2 or stem_positions.shape[1] != 2:
2083+
raise ValueError("stem_positions must have shape (S, 2).")
2084+
if stem_diameters.ndim != 1 or len(stem_diameters) != len(stem_positions):
2085+
raise ValueError("stem_diameters must have shape (S,) and the same length as stem_positions.")
2086+
20442087
with Profiler("Construction of digital terrain model", self._performance_tracker):
20452088
with Profiler("Terrain classification", self._performance_tracker):
20462089
self._logger.info("Detect terrain points...")
@@ -2073,39 +2116,47 @@ def __call__( # pylint: disable=too-many-locals
20732116
dists_to_dtm = distance_to_dtm(xyz, dtm, dtm_offset, self._dtm_resolution)
20742117

20752118
with Profiler("Detection of tree stems", self._performance_tracker):
2076-
self._logger.info("Detect stems...")
2077-
stem_layer_filter = np.flatnonzero(
2078-
np.logical_and(
2079-
dists_to_dtm >= self._stem_search_min_z,
2080-
dists_to_dtm < self._stem_search_max_z,
2119+
if stems_provided:
2120+
self._logger.info("Using user-provided stem positions and diameters, skipping stem detection...")
2121+
stem_positions = cast(npt.NDArray, stem_positions).astype(xyz.dtype)
2122+
stem_diameters = cast(npt.NDArray, stem_diameters).astype(xyz.dtype)
2123+
# no points are used as region growing seed points based on the stem detection result (seed points are
2124+
# still selected using the cylinder-based approach)
2125+
cluster_labels_full = np.full(len(xyz), fill_value=-1, dtype=np.int64)
2126+
else:
2127+
self._logger.info("Detect stems...")
2128+
stem_layer_filter = np.flatnonzero(
2129+
np.logical_and(
2130+
dists_to_dtm >= self._stem_search_min_z,
2131+
dists_to_dtm < self._stem_search_max_z,
2132+
)
20812133
)
2082-
)
2083-
stem_layer_xyz = xyz[stem_layer_filter]
2134+
stem_layer_xyz = xyz[stem_layer_filter]
20842135

2085-
if self._visualization_folder is not None and point_cloud_id is not None:
2086-
self.export_point_cloud(
2136+
if self._visualization_folder is not None and point_cloud_id is not None:
2137+
self.export_point_cloud(
2138+
stem_layer_xyz,
2139+
{"dist_to_dtm": dists_to_dtm[stem_layer_filter]},
2140+
"stem_layer",
2141+
point_cloud_id,
2142+
crs=crs,
2143+
)
2144+
2145+
stem_positions, stem_diameters, cluster_labels = self.detect_stems(
20872146
stem_layer_xyz,
2088-
{"dist_to_dtm": dists_to_dtm[stem_layer_filter]},
2089-
"stem_layer",
2090-
point_cloud_id,
2147+
dtm,
2148+
dtm_offset,
2149+
intensities=intensities[stem_layer_filter] if intensities is not None else None,
2150+
point_cloud_id=point_cloud_id,
20912151
crs=crs,
20922152
)
2093-
2094-
stem_positions, stem_diameters, cluster_labels = self.detect_stems(
2095-
stem_layer_xyz,
2096-
dtm,
2097-
dtm_offset,
2098-
intensities=intensities[stem_layer_filter] if intensities is not None else None,
2099-
point_cloud_id=point_cloud_id,
2100-
crs=crs,
2101-
)
2102-
cluster_labels_full = np.full(len(xyz), fill_value=-1, dtype=np.int64)
2103-
cluster_labels_full[stem_layer_filter] = cluster_labels
2153+
cluster_labels_full = np.full(len(xyz), fill_value=-1, dtype=np.int64)
2154+
cluster_labels_full[stem_layer_filter] = cluster_labels
2155+
del stem_layer_filter
2156+
del stem_layer_xyz
2157+
del cluster_labels
21042158
del dtm
21052159
del dtm_offset
2106-
del stem_layer_filter
2107-
del stem_layer_xyz
2108-
del cluster_labels
21092160

21102161
if len(stem_positions) == 0:
21112162
return (

test/instance_segmentation/test_tree_x_algorithm.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,32 @@ def test_full_algorithm(
10061006
np.testing.assert_almost_equal(expected_stem_diameters, stem_diameters, decimal=2)
10071007
np.testing.assert_almost_equal(expected_tree_heights, tree_heights, decimal=2)
10081008

1009+
@pytest.mark.parametrize("scalar_type", [np.float32, np.float64])
1010+
def test_full_algorithm_with_known_stems(self, scalar_type: np.dtype, cache_dir):
1011+
xyz, _, expected_stem_positions, expected_stem_diameters, expected_tree_heights = generate_tree_point_cloud(
1012+
scalar_type, "C", generate_intensities=False
1013+
)
1014+
1015+
algorithm = TreeXAlgorithm(tree_seg_cum_search_dist_include_terrain=2)
1016+
1017+
instance_ids, stem_positions, stem_diameters = algorithm(
1018+
xyz, stem_positions=expected_stem_positions, stem_diameters=expected_stem_diameters
1019+
)
1020+
1021+
np.testing.assert_array_equal(expected_stem_positions.astype(scalar_type), stem_positions)
1022+
np.testing.assert_array_equal(expected_stem_diameters.astype(scalar_type), stem_diameters)
1023+
1024+
assert len(xyz) == len(instance_ids)
1025+
1026+
tree_heights = np.empty(len(np.unique(instance_ids)) - 1, dtype=np.float64)
1027+
for instance_id in np.unique(instance_ids):
1028+
instance_points = xyz[instance_ids == instance_id]
1029+
if len(instance_points) > 0:
1030+
tree_heights[instance_id] = instance_points[:, 2].max() - instance_points[:, 2].min()
1031+
1032+
assert len(np.unique(instance_ids)) == 3
1033+
np.testing.assert_almost_equal(expected_tree_heights, tree_heights, decimal=2)
1034+
10091035
@pytest.mark.parametrize("stem_search_refined_circle_fitting", [True, False])
10101036
@pytest.mark.parametrize("scalar_type", [np.float32, np.float64])
10111037
def test_full_algorithm_no_trees_detected(
@@ -1038,6 +1064,38 @@ def test_full_algorithm_invalid_inputs(self):
10381064
with pytest.raises(ValueError):
10391065
algorithm(xyz, intensities)
10401066

1067+
def test_full_algorithm_only_stem_positions_set(self):
1068+
algorithm = TreeXAlgorithm()
1069+
1070+
xyz = np.zeros((10, 3), dtype=np.float64)
1071+
1072+
with pytest.raises(ValueError):
1073+
algorithm(xyz, stem_positions=np.zeros((1, 2), dtype=np.float64))
1074+
1075+
def test_full_algorithm_only_stem_diameters_set(self):
1076+
algorithm = TreeXAlgorithm()
1077+
1078+
xyz = np.zeros((10, 3), dtype=np.float64)
1079+
1080+
with pytest.raises(ValueError):
1081+
algorithm(xyz, stem_diameters=np.zeros(1, dtype=np.float64))
1082+
1083+
def test_full_algorithm_invalid_stem_positions_shape(self):
1084+
algorithm = TreeXAlgorithm()
1085+
1086+
xyz = np.zeros((10, 3), dtype=np.float64)
1087+
1088+
with pytest.raises(ValueError):
1089+
algorithm(xyz, stem_positions=np.zeros((1, 3), dtype=np.float64), stem_diameters=np.zeros(1))
1090+
1091+
def test_full_algorithm_stem_positions_diameters_length_mismatch(self):
1092+
algorithm = TreeXAlgorithm()
1093+
1094+
xyz = np.zeros((10, 3), dtype=np.float64)
1095+
1096+
with pytest.raises(ValueError):
1097+
algorithm(xyz, stem_positions=np.zeros((2, 2), dtype=np.float64), stem_diameters=np.zeros(1))
1098+
10411099
def test_invalid_tree_id(self):
10421100
with pytest.raises(ValueError):
10431101
TreeXAlgorithm(invalid_tree_id=1)

0 commit comments

Comments
 (0)