Miroslav Simko <ms@iolabs.ch> 2026-09-01T10:43:42+02:00
Commit #9 ยท 11 snippets
src/iolabs/common/color_intensity_data.py | 30 ++++++++++++++++++-- tests/test_color_intensity_data.py | 46 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-)
| 1 | """Color, intensity, and scan angle data aligned with point clouds.""" | 1 | """Color, intensity, scan angle, and return-count data aligned with point clouds.""" |
| 2 | 2 | ||
| 3 | from dataclasses import dataclass | 3 | from dataclasses import dataclass |
| 4 | 4 | ||
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | 6 | ||
| 7 | #: Storage dtype of :attr:`ColorIntensityData.number_of_returns` (LAS values 1-7). | ||
| 8 | NUMBER_OF_RETURNS_DTYPE = np.uint8 | ||
| 9 | |||
| 7 | 10 | ||
| 8 | @dataclass | 11 | @dataclass |
| 9 | class ColorIntensityData: | 12 | class 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 | """ | ||
| 11 | 27 | ||
| 12 | red: np.ndarray | 28 | red: np.ndarray |
| 13 | green: np.ndarray | 29 | green: np.ndarray |
| 14 | blue: np.ndarray | 30 | blue: np.ndarray |
| 15 | intensity: np.ndarray | 31 | intensity: np.ndarray |
| 16 | scan_angle_rank: np.ndarray | 32 | 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) | ||
| 17 | 39 | ||
| 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( |
| 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 | ) |
| 27 | 50 | ||
| 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.""" |
| 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 | ) |
| 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 | ) |
| 16 | 17 | ||
| 17 | 18 | ||
| 18 | class TestSelectByMask: | 19 | class TestSelectByMask: |
| 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 | ) | ||
| 31 | 35 | ||
| 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) |
| 57 | 61 | ||
| 58 | assert len(result.red) == 5 | 62 | 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 | ) | ||
| 61 | 69 | ||
| 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) |
| 66 | result = a.append(b) | 74 | result = a.append(b) |
| 67 | 75 | ||
| 68 | assert len(result.red) == 3 | 76 | 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 | |||
| 81 | class 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 | ) |
| 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 | ) |
| 16 | 17 | ||
| 17 | 18 | ||
| 18 | class TestSelectByMask: | 19 | class TestSelectByMask: |
| 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 | ) | ||
| 31 | 35 | ||
| 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) |
| 57 | 61 | ||
| 58 | assert len(result.red) == 5 | 62 | 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 | ) | ||
| 61 | 69 | ||
| 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) |
| 66 | result = a.append(b) | 74 | result = a.append(b) |
| 67 | 75 | ||
| 68 | assert len(result.red) == 3 | 76 | 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 | |||
| 81 | class 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 | ) |
ColorIntensityData(the in-memory record shared by consumers) gains an optionalnumber_of_returnsmember;select_by_mask/appendcarry it.