Back to report index

Step 3 segmentationtrajectory 014ab30: AI3D-382 Build ColorIntensityData kwargs by feature detection so old iolabs-common works

Miroslav Simko <ms@iolabs.ch> 2026-09-01T15:11:22+02:00

Commit #14 ยท 10 snippets

 .../segment_mapper.py                              | 36 ++++++++++++-----
 tests/test_number_of_returns.py                    | 47 ++++++++++++++++++++++
 2 files changed, 74 insertions(+), 9 deletions(-)

Cross-version compatibility: the producer's load_color_intensity_data builds kwargs filtered by the installed dataclass's declared fields, so it works against pinned common 0.3.2 (no such field) and against 0.7.1+ (field carried). Zero production callers today; latent.

Importance #1: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -2603,8 +2603,15 @@

kwargs filtered by dataclasses.fields(ColorIntensityData) so old common does not raise TypeError on an unknown kwarg.

2603 """Loads the color, intensity, and return-count data from the LAS file.2603 """Loads the color, intensity, and return-count data from the LAS file.
26042604
2605 A LAS without a `number_of_returns` field degrades to zeros (unknown)2605 A LAS without a `number_of_returns` field degrades to zeros (unknown)
2606 instead of raising, mirroring the chunked read path.2606 instead of raising, mirroring the chunked read path.
2607
2608 The returned dataclass is populated by feature detection: only members
2609 the *installed* `iolabs-common` declares are passed, so this stays
2610 importable and callable against a release that predates a field.
2611 `number_of_returns` is therefore dropped -- not faked -- on an
2612 `iolabs-common` whose `ColorIntensityData` has no such member, which is
2613 correct because nothing downstream of that release could carry it.
2607 """2614 """
2608 if not (hasattr(las, "red") and hasattr(las, "green") and hasattr(las, "blue")):2615 if not (hasattr(las, "red") and hasattr(las, "green") and hasattr(las, "blue")):
2609 raise ValueError("No color information found in the LAS file")2616 raise ValueError("No color information found in the LAS file")
2610 if not hasattr(las, "intensity"):2617 if not hasattr(las, "intensity"):
Importance #2: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -2627,12 +2634,23 @@
2627 point_count=int(red.shape[0]),2634 point_count=int(red.shape[0]),
2628 source="LAS file",2635 source="LAS file",
2629 )2636 )
26302637
2638 candidate_kwargs: dict[str, Any] = {
2639 "red": red,
2640 "green": green,
2641 "blue": blue,
2642 "intensity": intensity,
2643 "scan_angle_rank": scan_angle,
2644 NUMBER_OF_RETURNS_KEY: number_of_returns,
2645 }
2646 supported_names = {
2647 field.name
2648 for field in dataclasses.fields(color_intensity_data.ColorIntensityData)
2649 }
2631 return color_intensity_data.ColorIntensityData(2650 return color_intensity_data.ColorIntensityData(
2632 red=red,2651 **{
2633 green=green,2652 name: value
2634 blue=blue,2653 for name, value in candidate_kwargs.items()
2635 intensity=intensity,2654 if name in supported_names
2636 scan_angle_rank=scan_angle,2655 }
2637 number_of_returns=number_of_returns,
2638 )2656 )
Importance #3: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -1,7 +1,8 @@
1"""Segment mapper: divides trajectory into segments using perpendicular planes and maps LAS files to them."""1"""Segment mapper: divides trajectory into segments using perpendicular planes and maps LAS files to them."""
2import concurrent.futures2import concurrent.futures
3import copy3import copy
4import dataclasses
4import gc5import gc
5import itertools6import itertools
6import json7import json
7import logging8import logging
Importance #4: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -11,9 +12,8 @@
11import time12import time
12import types13import types
13import zipfile14import zipfile
14from collections.abc import Mapping15from collections.abc import Mapping
15from dataclasses import dataclass
16from pathlib import Path16from pathlib import Path
17from typing import Any17from typing import Any
1818
19import laspy19import laspy
Importance #5: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -262,9 +262,9 @@
262 rewritten[int(segment_idx)] = _deduplicate_keep_order(mapped_paths)262 rewritten[int(segment_idx)] = _deduplicate_keep_order(mapped_paths)
263 return rewritten263 return rewritten
264264
265265
266@dataclass266@dataclasses.dataclass
267class _PlaneSplitAttemptResult:267class _PlaneSplitAttemptResult:
268 point_count_by_segment: dict[int, int]268 point_count_by_segment: dict[int, int]
269 field_dtypes: dict[str, np.dtype]269 field_dtypes: dict[str, np.dtype]
270 processed_points: int270 processed_points: int
Importance #6: src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.py @@ -275,9 +275,9 @@
275 after_overflow: int275 after_overflow: int
276 non_prefix_rejected_points: int276 non_prefix_rejected_points: int
277277
278278
279@dataclass279@dataclasses.dataclass
280class BranchGeometry:280class BranchGeometry:
281 branch_index: int281 branch_index: int
282 branch_id: str282 branch_id: str
283 geometry_dir: Path283 geometry_dir: Path
Importance #7: tests/test_number_of_returns.py @@ -113,4 +116,48 @@
113 np.testing.assert_array_equal(116 np.testing.assert_array_equal(
114 record["number_of_returns"],117 record["number_of_returns"],
115 np.zeros(3, dtype=np.uint8),118 np.zeros(3, dtype=np.uint8),
116 )119 )
120
121
122def _in_memory_las() -> laspy.LasData:
123 """A 3-point LAS with RGB, intensity, scan angle and return counts."""
124 header = laspy.LasHeader(point_format=3, version="1.2")
125 las = laspy.LasData(header)
126 las.x = np.array([1.0, 2.0, 3.0])
127 las.y = np.zeros(3)
128 las.z = np.zeros(3)
129 las.intensity = np.array([10, 20, 30], dtype=np.uint16)
130 las.red = np.array([1, 2, 3], dtype=np.uint16)
131 las.green = np.array([4, 5, 6], dtype=np.uint16)
132 las.blue = np.array([7, 8, 9], dtype=np.uint16)
133 las.scan_angle_rank = np.array([0, 1, 2], dtype=np.int8)
134 las.number_of_returns = np.array([1, 2, 3], dtype=np.uint8)
135 return las
136
137
138def test_load_color_intensity_data_works_against_installed_common() -> None:
139 """`load_color_intensity_data` must not pass a kwarg the dataclass lacks.
140
141 Written version-agnostically: it asserts the return counts are carried when
142 the installed `ColorIntensityData` declares the member, and that the call
143 still succeeds (dropping them) when it does not.
144 """
145 data = sm.SegmentMapper.load_color_intensity_data(_in_memory_las())
146
147 np.testing.assert_array_equal(data.red, np.array([1, 2, 3], dtype=np.uint16))
148 np.testing.assert_array_equal(data.intensity, np.array([10, 20, 30], dtype=np.uint16))
149 np.testing.assert_array_equal(data.scan_angle_rank, np.array([0, 1, 2], dtype=np.int8))
150
151 declared = {
152 field.name
153 for field in dataclasses.fields(color_intensity_data.ColorIntensityData)
154 }
155 if sm.NUMBER_OF_RETURNS_KEY in declared:
156 carried = getattr(data, sm.NUMBER_OF_RETURNS_KEY)
157 assert carried.dtype == sm.NUMBER_OF_RETURNS_DTYPE
158 np.testing.assert_array_equal(
159 carried,
160 np.array([1, 2, 3], dtype=sm.NUMBER_OF_RETURNS_DTYPE),
161 )
162 else:
163 assert not hasattr(data, sm.NUMBER_OF_RETURNS_KEY)
Importance #8: tests/test_number_of_returns.py @@ -1,9 +1,12 @@
1import dataclasses
1import logging2import logging
2from pathlib import Path3from pathlib import Path
34
5import laspy
4import numpy as np6import numpy as np
5import pytest7import pytest
8from iolabs.common import color_intensity_data
69
7from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm10from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm
811
9DIVISION_PLANES = [12DIVISION_PLANES = [
Importance #9: tests/test_number_of_returns.py @@ -1,9 +1,12 @@
1import dataclasses
1import logging2import logging
2from pathlib import Path3from pathlib import Path
34
5import laspy
4import numpy as np6import numpy as np
5import pytest7import pytest
8from iolabs.common import color_intensity_data
69
7from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm10from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm
811
9DIVISION_PLANES = [12DIVISION_PLANES = [
Importance #10: tests/test_number_of_returns.py @@ -113,4 +116,48 @@
113 np.testing.assert_array_equal(116 np.testing.assert_array_equal(
114 record["number_of_returns"],117 record["number_of_returns"],
115 np.zeros(3, dtype=np.uint8),118 np.zeros(3, dtype=np.uint8),
116 )119 )
120
121
122def _in_memory_las() -> laspy.LasData:
123 """A 3-point LAS with RGB, intensity, scan angle and return counts."""
124 header = laspy.LasHeader(point_format=3, version="1.2")
125 las = laspy.LasData(header)
126 las.x = np.array([1.0, 2.0, 3.0])
127 las.y = np.zeros(3)
128 las.z = np.zeros(3)
129 las.intensity = np.array([10, 20, 30], dtype=np.uint16)
130 las.red = np.array([1, 2, 3], dtype=np.uint16)
131 las.green = np.array([4, 5, 6], dtype=np.uint16)
132 las.blue = np.array([7, 8, 9], dtype=np.uint16)
133 las.scan_angle_rank = np.array([0, 1, 2], dtype=np.int8)
134 las.number_of_returns = np.array([1, 2, 3], dtype=np.uint8)
135 return las
136
137
138def test_load_color_intensity_data_works_against_installed_common() -> None:
139 """`load_color_intensity_data` must not pass a kwarg the dataclass lacks.
140
141 Written version-agnostically: it asserts the return counts are carried when
142 the installed `ColorIntensityData` declares the member, and that the call
143 still succeeds (dropping them) when it does not.
144 """
145 data = sm.SegmentMapper.load_color_intensity_data(_in_memory_las())
146
147 np.testing.assert_array_equal(data.red, np.array([1, 2, 3], dtype=np.uint16))
148 np.testing.assert_array_equal(data.intensity, np.array([10, 20, 30], dtype=np.uint16))
149 np.testing.assert_array_equal(data.scan_angle_rank, np.array([0, 1, 2], dtype=np.int8))
150
151 declared = {
152 field.name
153 for field in dataclasses.fields(color_intensity_data.ColorIntensityData)
154 }
155 if sm.NUMBER_OF_RETURNS_KEY in declared:
156 carried = getattr(data, sm.NUMBER_OF_RETURNS_KEY)
157 assert carried.dtype == sm.NUMBER_OF_RETURNS_DTYPE
158 np.testing.assert_array_equal(
159 carried,
160 np.array([1, 2, 3], dtype=sm.NUMBER_OF_RETURNS_DTYPE),
161 )
162 else:
163 assert not hasattr(data, sm.NUMBER_OF_RETURNS_KEY)