Miroslav Simko <ms@iolabs.ch> 2026-09-01T15:11:22+02:00
Commit #100 ยท 10 snippets
.../segment_mapper.py | 36 ++++++++++++----- tests/test_number_of_returns.py | 47 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-)
| 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.""" |
| 2 | import concurrent.futures | 2 | import concurrent.futures |
| 3 | import copy | 3 | import copy |
| 4 | import dataclasses | ||
| 4 | import gc | 5 | import gc |
| 5 | import itertools | 6 | import itertools |
| 6 | import json | 7 | import json |
| 7 | import logging | 8 | import logging |
| 11 | import time | 12 | import time |
| 12 | import types | 13 | import types |
| 13 | import zipfile | 14 | import zipfile |
| 14 | from collections.abc import Mapping | 15 | from collections.abc import Mapping |
| 15 | from dataclasses import dataclass | ||
| 16 | from pathlib import Path | 16 | from pathlib import Path |
| 17 | from typing import Any | 17 | from typing import Any |
| 18 | 18 | ||
| 19 | import laspy | 19 | import laspy |
| 262 | rewritten[int(segment_idx)] = _deduplicate_keep_order(mapped_paths) | 262 | rewritten[int(segment_idx)] = _deduplicate_keep_order(mapped_paths) |
| 263 | return rewritten | 263 | return rewritten |
| 264 | 264 | ||
| 265 | 265 | ||
| 266 | @dataclass | 266 | @dataclasses.dataclass |
| 267 | class _PlaneSplitAttemptResult: | 267 | class _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: int | 270 | processed_points: int |
| 275 | after_overflow: int | 275 | after_overflow: int |
| 276 | non_prefix_rejected_points: int | 276 | non_prefix_rejected_points: int |
| 277 | 277 | ||
| 278 | 278 | ||
| 279 | @dataclass | 279 | @dataclasses.dataclass |
| 280 | class BranchGeometry: | 280 | class BranchGeometry: |
| 281 | branch_index: int | 281 | branch_index: int |
| 282 | branch_id: str | 282 | branch_id: str |
| 283 | geometry_dir: Path | 283 | geometry_dir: Path |
| 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. |
| 2604 | 2604 | ||
| 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"): |
| 2627 | point_count=int(red.shape[0]), | 2634 | point_count=int(red.shape[0]), |
| 2628 | source="LAS file", | 2635 | source="LAS file", |
| 2629 | ) | 2636 | ) |
| 2630 | 2637 | ||
| 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 | ) |
| 1 | import dataclasses | ||
| 1 | import logging | 2 | import logging |
| 2 | from pathlib import Path | 3 | from pathlib import Path |
| 3 | 4 | ||
| 5 | import laspy | ||
| 4 | import numpy as np | 6 | import numpy as np |
| 5 | import pytest | 7 | import pytest |
| 8 | from iolabs.common import color_intensity_data | ||
| 6 | 9 | ||
| 7 | from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm | 10 | from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm |
| 8 | 11 | ||
| 9 | DIVISION_PLANES = [ | 12 | DIVISION_PLANES = [ |
| 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 | |||
| 122 | def _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 | |||
| 138 | def 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) |
| 1 | import dataclasses | ||
| 1 | import logging | 2 | import logging |
| 2 | from pathlib import Path | 3 | from pathlib import Path |
| 3 | 4 | ||
| 5 | import laspy | ||
| 4 | import numpy as np | 6 | import numpy as np |
| 5 | import pytest | 7 | import pytest |
| 8 | from iolabs.common import color_intensity_data | ||
| 6 | 9 | ||
| 7 | from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm | 10 | from iolabs_point_cloud_segmentation_trajectory import segment_mapper as sm |
| 8 | 11 | ||
| 9 | DIVISION_PLANES = [ | 12 | DIVISION_PLANES = [ |
| 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 | |||
| 122 | def _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 | |||
| 138 | def 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) |