Miroslav Simko <ms@iolabs.ch> 2026-09-01T14:55:33+02:00
Commit #12 ยท 6 snippets
src/iolabs/common/color_intensity_data.py | 36 ++++++++++++-------------- tests/test_color_intensity_data.py | 43 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 19 deletions(-)
| 36 | """Fill an omitted ``number_of_returns`` with zeros (unknown) per point.""" | 42 | """Fill an omitted ``number_of_returns`` with zeros (unknown) per point.""" |
| 37 | if self.number_of_returns is None: | 43 | if self.number_of_returns is None: |
| 38 | self.number_of_returns = np.zeros(len(self.red), dtype=NUMBER_OF_RETURNS_DTYPE) | 44 | self.number_of_returns = np.zeros(len(self.red), dtype=NUMBER_OF_RETURNS_DTYPE) |
| 39 | 45 | ||
| 46 | def _map_fields( | ||
| 47 | self, transform: Callable[[str], np.ndarray] | ||
| 48 | ) -> "ColorIntensityData": | ||
| 49 | """Rebuild this instance's type by applying *transform* to every field name.""" | ||
| 50 | return type(self)(**{field.name: transform(field.name) for field in fields(self)}) | ||
| 51 | |||
| 40 | def select_by_mask(self, mask: np.ndarray) -> "ColorIntensityData": | 52 | def select_by_mask(self, mask: np.ndarray) -> "ColorIntensityData": |
| 41 | """Return a new ColorIntensityData containing only points where mask is True.""" | 53 | """Return a new ColorIntensityData containing only points where mask is True.""" |
| 42 | return ColorIntensityData( | 54 | return self._map_fields(lambda name: getattr(self, name)[mask]) |
| 43 | red=self.red[mask], | ||
| 44 | green=self.green[mask], | ||
| 45 | blue=self.blue[mask], | ||
| 46 | intensity=self.intensity[mask], | ||
| 47 | scan_angle_rank=self.scan_angle_rank[mask], | ||
| 48 | number_of_returns=self.number_of_returns[mask], | ||
| 49 | ) | ||
| 50 | 55 | ||
| 51 | def append(self, other: "ColorIntensityData") -> "ColorIntensityData": | 56 | def append(self, other: "ColorIntensityData") -> "ColorIntensityData": |
| 52 | """Concatenate this instance with another, returning a new ColorIntensityData.""" | 57 | """Concatenate this instance with another, returning a new ColorIntensityData.""" |
| 53 | return ColorIntensityData( | 58 | return self._map_fields( |
| 54 | red=np.concatenate([self.red, other.red]), | 59 | lambda name: np.concatenate([getattr(self, name), getattr(other, name)]) |
| 55 | green=np.concatenate([self.green, other.green]), | ||
| 56 | blue=np.concatenate([self.blue, other.blue]), | ||
| 57 | intensity=np.concatenate([self.intensity, other.intensity]), | ||
| 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 | ), | ||
| 62 | ) | 60 | ) |
| 1 | """Color, intensity, scan angle, and return-count 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 | Both transforms below (:meth:`ColorIntensityData.select_by_mask` and |
| 4 | :meth:`ColorIntensityData.append`) walk :func:`dataclasses.fields`, so a new | ||
| 5 | per-point attribute is carried through them by declaring the field alone. | ||
| 6 | """ | ||
| 7 | |||
| 8 | from collections.abc import Callable | ||
| 9 | from dataclasses import dataclass, fields | ||
| 4 | 10 | ||
| 5 | import numpy as np | 11 | import numpy as np |
| 6 | 12 | ||
| 7 | #: Storage dtype of :attr:`ColorIntensityData.number_of_returns` (LAS values 1-7). | 13 | #: Storage dtype of :attr:`ColorIntensityData.number_of_returns` (LAS values 1-7). |
| 1 | """Tests for ColorIntensityData selection and concatenation operations.""" | 1 | """Tests for ColorIntensityData selection and concatenation operations.""" |
| 2 | from dataclasses import dataclass | ||
| 3 | |||
| 2 | import numpy as np | 4 | import numpy as np |
| 3 | 5 | ||
| 4 | from iolabs.common.color_intensity_data import ColorIntensityData | 6 | from iolabs.common.color_intensity_data import ColorIntensityData |
| 5 | 7 |
| 112 | np.testing.assert_array_equal( | 114 | np.testing.assert_array_equal( |
| 113 | merged.number_of_returns, | 115 | merged.number_of_returns, |
| 114 | np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]), | 116 | np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]), |
| 115 | ) | 117 | ) |
| 118 | |||
| 119 | |||
| 120 | class TestFieldDrivenTransforms: | ||
| 121 | """A new per-point field must ride along without touching the methods.""" | ||
| 122 | |||
| 123 | def test_extra_field_flows_through_mask_and_append(self): | ||
| 124 | """A subclass field is masked and concatenated by the generic transforms.""" | ||
| 125 | |||
| 126 | @dataclass | ||
| 127 | class WithClassification(ColorIntensityData): | ||
| 128 | classification: np.ndarray | None = None | ||
| 129 | |||
| 130 | def _make(n: int, offset: int) -> WithClassification: | ||
| 131 | base = _make_sample(n, offset) | ||
| 132 | return WithClassification( | ||
| 133 | red=base.red, | ||
| 134 | green=base.green, | ||
| 135 | blue=base.blue, | ||
| 136 | intensity=base.intensity, | ||
| 137 | scan_angle_rank=base.scan_angle_rank, | ||
| 138 | number_of_returns=base.number_of_returns, | ||
| 139 | classification=np.arange(offset, offset + n, dtype=np.uint8), | ||
| 140 | ) | ||
| 141 | |||
| 142 | first = _make(4, 0) | ||
| 143 | second = _make(2, 50) | ||
| 144 | mask = np.array([True, False, True, False]) | ||
| 145 | |||
| 146 | masked = first.select_by_mask(mask) | ||
| 147 | merged = masked.append(second) | ||
| 148 | |||
| 149 | assert isinstance(masked, WithClassification) | ||
| 150 | np.testing.assert_array_equal(masked.classification, first.classification[mask]) | ||
| 151 | np.testing.assert_array_equal( | ||
| 152 | merged.classification, | ||
| 153 | np.concatenate([first.classification[mask], second.classification]), | ||
| 154 | ) | ||
| 155 | np.testing.assert_array_equal( | ||
| 156 | merged.number_of_returns, | ||
| 157 | np.concatenate([first.number_of_returns[mask], second.number_of_returns]), | ||
| 158 | ) |
| 1 | """Tests for ColorIntensityData selection and concatenation operations.""" | 1 | """Tests for ColorIntensityData selection and concatenation operations.""" |
| 2 | from dataclasses import dataclass | ||
| 3 | |||
| 2 | import numpy as np | 4 | import numpy as np |
| 3 | 5 | ||
| 4 | from iolabs.common.color_intensity_data import ColorIntensityData | 6 | from iolabs.common.color_intensity_data import ColorIntensityData |
| 5 | 7 |
| 112 | np.testing.assert_array_equal( | 114 | np.testing.assert_array_equal( |
| 113 | merged.number_of_returns, | 115 | merged.number_of_returns, |
| 114 | np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]), | 116 | np.concatenate([np.zeros(2, dtype=np.uint8), modern.number_of_returns]), |
| 115 | ) | 117 | ) |
| 118 | |||
| 119 | |||
| 120 | class TestFieldDrivenTransforms: | ||
| 121 | """A new per-point field must ride along without touching the methods.""" | ||
| 122 | |||
| 123 | def test_extra_field_flows_through_mask_and_append(self): | ||
| 124 | """A subclass field is masked and concatenated by the generic transforms.""" | ||
| 125 | |||
| 126 | @dataclass | ||
| 127 | class WithClassification(ColorIntensityData): | ||
| 128 | classification: np.ndarray | None = None | ||
| 129 | |||
| 130 | def _make(n: int, offset: int) -> WithClassification: | ||
| 131 | base = _make_sample(n, offset) | ||
| 132 | return WithClassification( | ||
| 133 | red=base.red, | ||
| 134 | green=base.green, | ||
| 135 | blue=base.blue, | ||
| 136 | intensity=base.intensity, | ||
| 137 | scan_angle_rank=base.scan_angle_rank, | ||
| 138 | number_of_returns=base.number_of_returns, | ||
| 139 | classification=np.arange(offset, offset + n, dtype=np.uint8), | ||
| 140 | ) | ||
| 141 | |||
| 142 | first = _make(4, 0) | ||
| 143 | second = _make(2, 50) | ||
| 144 | mask = np.array([True, False, True, False]) | ||
| 145 | |||
| 146 | masked = first.select_by_mask(mask) | ||
| 147 | merged = masked.append(second) | ||
| 148 | |||
| 149 | assert isinstance(masked, WithClassification) | ||
| 150 | np.testing.assert_array_equal(masked.classification, first.classification[mask]) | ||
| 151 | np.testing.assert_array_equal( | ||
| 152 | merged.classification, | ||
| 153 | np.concatenate([first.classification[mask], second.classification]), | ||
| 154 | ) | ||
| 155 | np.testing.assert_array_equal( | ||
| 156 | merged.number_of_returns, | ||
| 157 | np.concatenate([first.number_of_returns[mask], second.number_of_returns]), | ||
| 158 | ) |
select_by_maskandappendare derived fromdataclasses.fields()instead of listing members, so a future field needs no edits there.