Back to report index

iolabs-common (shared config layer) 270b56b: AI3D-382 Carry number_of_returns through ColorIntensityData

Miroslav Simko <ms@iolabs.ch> 2026-09-01T10:43:42+02:00

Commit #84 ยท 11 snippets

 src/iolabs/common/color_intensity_data.py | 30 ++++++++++++++++++--
 tests/test_color_intensity_data.py        | 46 +++++++++++++++++++++++++++++++
 2 files changed, 74 insertions(+), 2 deletions(-)
Importance #1: src/iolabs/common/color_intensity_data.py @@ -1,20 +1,42 @@
1"""Color, intensity, and scan angle data aligned with point clouds."""1"""Color, intensity, scan angle, and return-count data aligned with point clouds."""
22
3from dataclasses import dataclass3from dataclasses import dataclass
44
5import numpy as np5import numpy as np
66
7#: Storage dtype of :attr:`ColorIntensityData.number_of_returns` (LAS values 1-7).
8NUMBER_OF_RETURNS_DTYPE = np.uint8
9
710
8@dataclass11@dataclass
9class ColorIntensityData:12class ColorIntensityData:
10 """Stores per-point RGB, intensity, and scan angle arrays."""13 """Stores per-point RGB, intensity, scan angle, and return-count arrays.
14
15 Attributes:
16 red: Per-point red channel.
17 green: Per-point green channel.
18 blue: Per-point blue channel.
19 intensity: Per-point LAS intensity.
20 scan_angle_rank: Per-point LAS scan angle.
21 number_of_returns: Per-point LAS return count as uint8, added by
22 AI3D-382. Callers that have no such data omit it and get zeros:
23 0 is not a legal LAS return count, so it reads as "unknown".
24 Never substitute 1 -- that fabricates a measurement. Non-``None``
25 after construction.
26 """
1127
12 red: np.ndarray28 red: np.ndarray
13 green: np.ndarray29 green: np.ndarray
14 blue: np.ndarray30 blue: np.ndarray
15 intensity: np.ndarray31 intensity: np.ndarray
16 scan_angle_rank: np.ndarray32 scan_angle_rank: np.ndarray
33 number_of_returns: np.ndarray | None = None
34
35 def __post_init__(self) -> None:
36 """Fill an omitted ``number_of_returns`` with zeros (unknown) per point."""
37 if self.number_of_returns is None:
38 self.number_of_returns = np.zeros(len(self.red), dtype=NUMBER_OF_RETURNS_DTYPE)
1739
18 def select_by_mask(self, mask: np.ndarray) -> "ColorIntensityData":40 def select_by_mask(self, mask: np.ndarray) -> "ColorIntensityData":
19 """Return a new ColorIntensityData containing only points where mask is True."""41 """Return a new ColorIntensityData containing only points where mask is True."""
20 return ColorIntensityData(42 return ColorIntensityData(
Importance #2: src/iolabs/common/color_intensity_data.py @@ -22,8 +44,9 @@
22 green=self.green[mask],44 green=self.green[mask],
23 blue=self.blue[mask],45 blue=self.blue[mask],
24 intensity=self.intensity[mask],46 intensity=self.intensity[mask],
25 scan_angle_rank=self.scan_angle_rank[mask],47 scan_angle_rank=self.scan_angle_rank[mask],
48 number_of_returns=self.number_of_returns[mask],
26 )49 )
2750
28 def append(self, other: "ColorIntensityData") -> "ColorIntensityData":51 def append(self, other: "ColorIntensityData") -> "ColorIntensityData":
29 """Concatenate this instance with another, returning a new ColorIntensityData."""52 """Concatenate this instance with another, returning a new ColorIntensityData."""
Importance #3: src/iolabs/common/color_intensity_data.py @@ -32,5 +55,8 @@
32 green=np.concatenate([self.green, other.green]),55 green=np.concatenate([self.green, other.green]),
33 blue=np.concatenate([self.blue, other.blue]),56 blue=np.concatenate([self.blue, other.blue]),
34 intensity=np.concatenate([self.intensity, other.intensity]),57 intensity=np.concatenate([self.intensity, other.intensity]),
35 scan_angle_rank=np.concatenate([self.scan_angle_rank, other.scan_angle_rank]),58 scan_angle_rank=np.concatenate([self.scan_angle_rank, other.scan_angle_rank]),
59 number_of_returns=np.concatenate(
60 [self.number_of_returns, other.number_of_returns]
61 ),
36 )62 )
Importance #4: tests/test_color_intensity_data.py @@ -11,8 +11,9 @@
11 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),11 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),
12 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),12 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),
13 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),13 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),
14 scan_angle_rank=np.arange(offset + 200, offset + 200 + n, dtype=np.float64),14 scan_angle_rank=np.arange(offset + 200, offset + 200 + n, dtype=np.float64),
15 number_of_returns=np.arange(offset, offset + n, dtype=np.uint8) % 7 + 1,
15 )16 )
1617
1718
18class TestSelectByMask:19class TestSelectByMask:
Importance #5: tests/test_color_intensity_data.py @@ -27,8 +28,11 @@
27 np.testing.assert_array_equal(result.green, data.green[mask])28 np.testing.assert_array_equal(result.green, data.green[mask])
28 np.testing.assert_array_equal(result.blue, data.blue[mask])29 np.testing.assert_array_equal(result.blue, data.blue[mask])
29 np.testing.assert_array_equal(result.intensity, data.intensity[mask])30 np.testing.assert_array_equal(result.intensity, data.intensity[mask])
30 np.testing.assert_array_equal(result.scan_angle_rank, data.scan_angle_rank[mask])31 np.testing.assert_array_equal(result.scan_angle_rank, data.scan_angle_rank[mask])
32 np.testing.assert_array_equal(
33 result.number_of_returns, data.number_of_returns[mask]
34 )
3135
32 def test_integer_index_mask(self):36 def test_integer_index_mask(self):
33 """Verify selection with an integer index array."""37 """Verify selection with an integer index array."""
34 data = _make_sample(5)38 data = _make_sample(5)
Importance #6: tests/test_color_intensity_data.py @@ -57,8 +61,12 @@
5761
58 assert len(result.red) == 562 assert len(result.red) == 5
59 np.testing.assert_array_equal(result.red, np.concatenate([a.red, b.red]))63 np.testing.assert_array_equal(result.red, np.concatenate([a.red, b.red]))
60 np.testing.assert_array_equal(result.intensity, np.concatenate([a.intensity, b.intensity]))64 np.testing.assert_array_equal(result.intensity, np.concatenate([a.intensity, b.intensity]))
65 np.testing.assert_array_equal(
66 result.number_of_returns,
67 np.concatenate([a.number_of_returns, b.number_of_returns]),
68 )
6169
62 def test_append_empty(self):70 def test_append_empty(self):
63 """Verify appending empty data returns the original unchanged."""71 """Verify appending empty data returns the original unchanged."""
64 a = _make_sample(3)72 a = _make_sample(3)
Importance #7: tests/test_color_intensity_data.py @@ -66,4 +74,42 @@
66 result = a.append(b)74 result = a.append(b)
6775
68 assert len(result.red) == 376 assert len(result.red) == 3
69 np.testing.assert_array_equal(result.red, a.red)77 np.testing.assert_array_equal(result.red, a.red)
78 np.testing.assert_array_equal(result.number_of_returns, a.number_of_returns)
79
80
81class TestNumberOfReturns:
82 """Tests for the AI3D-382 number_of_returns field."""
83
84 def test_defaults_to_zeros_when_omitted(self):
85 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""
86 data = ColorIntensityData(
87 red=np.zeros(4, dtype=np.uint8),
88 green=np.zeros(4, dtype=np.uint8),
89 blue=np.zeros(4, dtype=np.uint8),
90 intensity=np.zeros(4, dtype=np.float64),
91 scan_angle_rank=np.zeros(4, dtype=np.float64),
92 )
93
94 assert data.number_of_returns.shape == (4,)
95 assert data.number_of_returns.dtype == np.uint8
96 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))
97
98 def test_zero_fill_survives_mask_and_append(self):
99 """A defaulted field stays aligned through the mask/concat paths."""
100 legacy = ColorIntensityData(
101 red=np.zeros(3, dtype=np.uint8),
102 green=np.zeros(3, dtype=np.uint8),
103 blue=np.zeros(3, dtype=np.uint8),
104 intensity=np.zeros(3, dtype=np.float64),
105 scan_angle_rank=np.zeros(3, dtype=np.float64),
106 )
107 modern = _make_sample(2)
108
109 merged = legacy.select_by_mask(np.array([True, False, True])).append(modern)
110
111 assert len(merged.number_of_returns) == 4
112 np.testing.assert_array_equal(
113 merged.number_of_returns,
114 np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]),
115 )
Importance #8: tests/test_color_intensity_data.py @@ -11,8 +11,9 @@
11 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),11 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),
12 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),12 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),
13 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),13 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),
14 scan_angle_rank=np.arange(offset + 200, offset + 200 + n, dtype=np.float64),14 scan_angle_rank=np.arange(offset + 200, offset + 200 + n, dtype=np.float64),
15 number_of_returns=np.arange(offset, offset + n, dtype=np.uint8) % 7 + 1,
15 )16 )
1617
1718
18class TestSelectByMask:19class TestSelectByMask:
Importance #9: tests/test_color_intensity_data.py @@ -27,8 +28,11 @@
27 np.testing.assert_array_equal(result.green, data.green[mask])28 np.testing.assert_array_equal(result.green, data.green[mask])
28 np.testing.assert_array_equal(result.blue, data.blue[mask])29 np.testing.assert_array_equal(result.blue, data.blue[mask])
29 np.testing.assert_array_equal(result.intensity, data.intensity[mask])30 np.testing.assert_array_equal(result.intensity, data.intensity[mask])
30 np.testing.assert_array_equal(result.scan_angle_rank, data.scan_angle_rank[mask])31 np.testing.assert_array_equal(result.scan_angle_rank, data.scan_angle_rank[mask])
32 np.testing.assert_array_equal(
33 result.number_of_returns, data.number_of_returns[mask]
34 )
3135
32 def test_integer_index_mask(self):36 def test_integer_index_mask(self):
33 """Verify selection with an integer index array."""37 """Verify selection with an integer index array."""
34 data = _make_sample(5)38 data = _make_sample(5)
Importance #10: tests/test_color_intensity_data.py @@ -57,8 +61,12 @@
5761
58 assert len(result.red) == 562 assert len(result.red) == 5
59 np.testing.assert_array_equal(result.red, np.concatenate([a.red, b.red]))63 np.testing.assert_array_equal(result.red, np.concatenate([a.red, b.red]))
60 np.testing.assert_array_equal(result.intensity, np.concatenate([a.intensity, b.intensity]))64 np.testing.assert_array_equal(result.intensity, np.concatenate([a.intensity, b.intensity]))
65 np.testing.assert_array_equal(
66 result.number_of_returns,
67 np.concatenate([a.number_of_returns, b.number_of_returns]),
68 )
6169
62 def test_append_empty(self):70 def test_append_empty(self):
63 """Verify appending empty data returns the original unchanged."""71 """Verify appending empty data returns the original unchanged."""
64 a = _make_sample(3)72 a = _make_sample(3)
Importance #11: tests/test_color_intensity_data.py @@ -66,4 +74,42 @@
66 result = a.append(b)74 result = a.append(b)
6775
68 assert len(result.red) == 376 assert len(result.red) == 3
69 np.testing.assert_array_equal(result.red, a.red)77 np.testing.assert_array_equal(result.red, a.red)
78 np.testing.assert_array_equal(result.number_of_returns, a.number_of_returns)
79
80
81class TestNumberOfReturns:
82 """Tests for the AI3D-382 number_of_returns field."""
83
84 def test_defaults_to_zeros_when_omitted(self):
85 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""
86 data = ColorIntensityData(
87 red=np.zeros(4, dtype=np.uint8),
88 green=np.zeros(4, dtype=np.uint8),
89 blue=np.zeros(4, dtype=np.uint8),
90 intensity=np.zeros(4, dtype=np.float64),
91 scan_angle_rank=np.zeros(4, dtype=np.float64),
92 )
93
94 assert data.number_of_returns.shape == (4,)
95 assert data.number_of_returns.dtype == np.uint8
96 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))
97
98 def test_zero_fill_survives_mask_and_append(self):
99 """A defaulted field stays aligned through the mask/concat paths."""
100 legacy = ColorIntensityData(
101 red=np.zeros(3, dtype=np.uint8),
102 green=np.zeros(3, dtype=np.uint8),
103 blue=np.zeros(3, dtype=np.uint8),
104 intensity=np.zeros(3, dtype=np.float64),
105 scan_angle_rank=np.zeros(3, dtype=np.float64),
106 )
107 modern = _make_sample(2)
108
109 merged = legacy.select_by_mask(np.array([True, False, True])).append(modern)
110
111 assert len(merged.number_of_returns) == 4
112 np.testing.assert_array_equal(
113 merged.number_of_returns,
114 np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]),
115 )