Miroslav Simko <ms@iolabs.ch> 2026-09-02T07:50:11+02:00
Commit #107 ยท 199 snippets
src/iolabs_point_cloud_segmentation_3d/cli.py | 6 +- src/iolabs_point_cloud_segmentation_3d/io_npz.py | 47 ++-- tests/test_fusion.py | 286 +++++++++++------------ 3 files changed, 157 insertions(+), 182 deletions(-)
| 7 | from dataclasses import asdict, dataclass | 7 | from dataclasses import asdict, dataclass |
| 8 | from pathlib import Path | 8 | from pathlib import Path |
| 9 | 9 | ||
| 10 | import numpy as np | 10 | import numpy as np |
| 11 | from iolabs.common import run_stats | 11 | from iolabs.common import run_stats, segment_points_io |
| 12 | from iolabs.common.cli import add_log_level_argument, configure_logging | 12 | from iolabs.common.cli import add_log_level_argument, configure_logging |
| 13 | from iolabs.common.crs import looks_georeferenced | 13 | from iolabs.common.crs import looks_georeferenced |
| 14 | from iolabs.common.segments import ( | 14 | from iolabs.common.segments import ( |
| 15 | SEGMENT_DIR_PREFIX, | 15 | SEGMENT_DIR_PREFIX, |
| 260 | logger.error( | 260 | logger.error( |
| 261 | "segment %s: %s exists but the cloud is already in world " | 261 | "segment %s: %s exists but the cloud is already in world " |
| 262 | "coordinates -- refusing to apply the geoshift twice. Remove " | 262 | "coordinates -- refusing to apply the geoshift twice. Remove " |
| 263 | "the file or run with --set las_georeference=false.", | 263 | "the file or run with --set las_georeference=false.", |
| 264 | seg_name, io_npz.GEOSHIFT_NAME, | 264 | seg_name, segment_points_io.GEOSHIFT_NAME, |
| 265 | ) | 265 | ) |
| 266 | return None | 266 | return None |
| 267 | world = geoshift is not None or already_world | 267 | world = geoshift is not None or already_world |
| 268 | crs_epsg = config.las_crs_epsg if (config.las_crs_epsg and world) else None | 268 | crs_epsg = config.las_crs_epsg if (config.las_crs_epsg and world) else None |
| 274 | elif not world: | 274 | elif not world: |
| 275 | logger.warning( | 275 | logger.warning( |
| 276 | "segment %s: no %s found and coordinates look local -- LAS " | 276 | "segment %s: no %s found and coordinates look local -- LAS " |
| 277 | "written in the pipeline frame with no CRS", seg_name, | 277 | "written in the pipeline frame with no CRS", seg_name, |
| 278 | io_npz.GEOSHIFT_NAME, | 278 | segment_points_io.GEOSHIFT_NAME, |
| 279 | ) | 279 | ) |
| 280 | return geoshift, crs_epsg | 280 | return geoshift, crs_epsg |
| 281 | 281 | ||
| 282 | 282 |
| 8 | (the road-surface key set, its recall metric) and the `SegmentCloud` shape the | 8 | (the road-surface key set, its recall metric) and the `SegmentCloud` shape the |
| 9 | fusion pipeline consumes. | 9 | fusion pipeline consumes. |
| 10 | """ | 10 | """ |
| 11 | 11 | ||
| 12 | from dataclasses import dataclass, field | 12 | import dataclasses |
| 13 | from pathlib import Path | 13 | import pathlib |
| 14 | 14 | ||
| 15 | import numpy as np | 15 | import numpy as np |
| 16 | from iolabs.common.point_hash import ( | 16 | from iolabs.common import point_hash, segment_points_io, segments |
| 17 | DEFAULT_UNITS_PER_M, | ||
| 18 | key_match_rate, | ||
| 19 | position_keys, | ||
| 20 | ) | ||
| 21 | from iolabs.common.segment_points_io import ( | ||
| 22 | GEOSHIFT_NAME, | ||
| 23 | RecordSpan, | ||
| 24 | load_run3_segment, | ||
| 25 | ) | ||
| 26 | from iolabs.common.segment_points_io import ( | ||
| 27 | load_geoshift as _load_geoshift_file, | ||
| 28 | ) | ||
| 29 | from iolabs.common.segments import segment_record_files | ||
| 30 | 17 | ||
| 31 | # Storage dtypes every run3 record is coerced to on load. These are the | 18 | # Storage dtypes every run3 record is coerced to on load. These are the |
| 32 | # historical seg3d coercions and they are load-bearing: `points` must stay | 19 | # historical seg3d coercions and they are load-bearing: `points` must stay |
| 33 | # float64 all the way into the hash rounding, and the uint16/int8 channels are | 20 | # float64 all the way into the hash rounding, and the uint16/int8 channels are |
| 61 | #: Filename suffix of the run4 road-surface records joined against run3. | 48 | #: Filename suffix of the run4 road-surface records joined against run3. |
| 62 | RUN4_SURFACE_SUFFIX = "_run4_road_surface.npz" | 49 | RUN4_SURFACE_SUFFIX = "_run4_road_surface.npz" |
| 63 | 50 | ||
| 64 | #: One input record's identity and row range within the concatenated cloud. | 51 | #: One input record's identity and row range within the concatenated cloud. |
| 65 | Record = RecordSpan | 52 | Record = segment_points_io.RecordSpan |
| 66 | 53 | ||
| 67 | 54 | ||
| 68 | def surface_keys( | 55 | def surface_keys( |
| 69 | xyz: np.ndarray, units_per_m: float = DEFAULT_UNITS_PER_M | 56 | xyz: np.ndarray, units_per_m: float = point_hash.DEFAULT_UNITS_PER_M |
| 70 | ) -> np.ndarray: | 57 | ) -> np.ndarray: |
| 71 | """Returns structured integer position keys for a `(N, 3)` coordinate array. | 58 | """Returns structured integer position keys for a `(N, 3)` coordinate array. |
| 72 | 59 | ||
| 73 | Thin wrapper over :func:`iolabs.common.point_hash.position_keys`, kept so | 60 | Thin wrapper over :func:`iolabs.common.point_hash.position_keys`, kept so |
| 81 | 68 | ||
| 82 | Returns: | 69 | Returns: |
| 83 | `(N,)` array of packed position keys, in input order. | 70 | `(N,)` array of packed position keys, in input order. |
| 84 | """ | 71 | """ |
| 85 | return position_keys(xyz, units_per_m) | 72 | return point_hash.position_keys(xyz, units_per_m) |
| 86 | 73 | ||
| 87 | 74 | ||
| 88 | @dataclass | 75 | @dataclasses.dataclass |
| 89 | class SegmentCloud: | 76 | class SegmentCloud: |
| 90 | """A segment's concatenated run3 point cloud plus run4 surface tagging. | 77 | """A segment's concatenated run3 point cloud plus run4 surface tagging. |
| 91 | 78 | ||
| 92 | Attributes: | 79 | Attributes: |
| 116 | scan_angle: np.ndarray | 103 | scan_angle: np.ndarray |
| 117 | is_surface: np.ndarray | 104 | is_surface: np.ndarray |
| 118 | records: list[Record] | 105 | records: list[Record] |
| 119 | surface_match_rate: float | 106 | surface_match_rate: float |
| 120 | extra: dict[str, np.ndarray] = field(default_factory=dict) | 107 | extra: dict[str, np.ndarray] = dataclasses.field(default_factory=dict) |
| 121 | 108 | ||
| 122 | @property | 109 | @property |
| 123 | def n(self) -> int: | 110 | def n(self) -> int: |
| 124 | """Number of points in the concatenated cloud.""" | 111 | """Number of points in the concatenated cloud.""" |
| 146 | """Returns the contributing record basenames, in concatenation order.""" | 133 | """Returns the contributing record basenames, in concatenation order.""" |
| 147 | return [r.name for r in self.records] | 134 | return [r.name for r in self.records] |
| 148 | 135 | ||
| 149 | 136 | ||
| 150 | def load_geoshift(seg_dir: Path) -> np.ndarray | None: | 137 | def load_geoshift(seg_dir: pathlib.Path) -> np.ndarray | None: |
| 151 | """Loads the per-dataset geoshift that run3 subtracted, if present. | 138 | """Loads the per-dataset geoshift that run3 subtracted, if present. |
| 152 | 139 | ||
| 153 | The upstream trajectory step writes run3 points as `source - geoshift` | 140 | The upstream trajectory step writes run3 points as `source - geoshift` |
| 154 | (the geoshift is the spline centroid) and records the offset in | 141 | (the geoshift is the spline centroid) and records the offset in |
| 172 | 159 | ||
| 173 | Raises: | 160 | Raises: |
| 174 | ValueError: The file exists but is not a `{"x", "y", "z"}` object. | 161 | ValueError: The file exists but is not a `{"x", "y", "z"}` object. |
| 175 | """ | 162 | """ |
| 176 | path = Path(seg_dir).parent / GEOSHIFT_NAME | 163 | path = pathlib.Path(seg_dir).parent / segment_points_io.GEOSHIFT_NAME |
| 177 | if not path.exists(): | 164 | if not path.exists(): |
| 178 | return None | 165 | return None |
| 179 | return _load_geoshift_file(path) | 166 | return segment_points_io.load_geoshift(path) |
| 180 | 167 | ||
| 181 | 168 | ||
| 182 | def _load_surface_key_set( | 169 | def _load_surface_key_set( |
| 183 | files: list[Path], units_per_m: float | 170 | files: list[pathlib.Path], units_per_m: float |
| 184 | ) -> np.ndarray | None: | 171 | ) -> np.ndarray | None: |
| 185 | """Builds the unique run4 road-surface key set for a segment. | 172 | """Builds the unique run4 road-surface key set for a segment. |
| 186 | 173 | ||
| 187 | Args: | 174 | Args: |
| 204 | return out | 191 | return out |
| 205 | 192 | ||
| 206 | 193 | ||
| 207 | def load_segment_cloud( | 194 | def load_segment_cloud( |
| 208 | seg_dir: Path, units_per_m: float = DEFAULT_UNITS_PER_M | 195 | seg_dir: pathlib.Path, units_per_m: float = point_hash.DEFAULT_UNITS_PER_M |
| 209 | ) -> SegmentCloud: | 196 | ) -> SegmentCloud: |
| 210 | """Loads and concatenates all run3 records, tagging run4 surface membership. | 197 | """Loads and concatenates all run3 records, tagging run4 surface membership. |
| 211 | 198 | ||
| 212 | Records are read in sorted glob order; record boundaries (name, offset, | 199 | Records are read in sorted glob order; record boundaries (name, offset, |
| 224 | Raises: | 211 | Raises: |
| 225 | FileNotFoundError: The directory holds no complete run3 record. | 212 | FileNotFoundError: The directory holds no complete run3 record. |
| 226 | ValueError: A record violates the point-record schema. | 213 | ValueError: A record violates the point-record schema. |
| 227 | """ | 214 | """ |
| 228 | seg_dir = Path(seg_dir) | 215 | seg_dir = pathlib.Path(seg_dir) |
| 229 | record, records = load_run3_segment( | 216 | record, records = segment_points_io.load_run3_segment( |
| 230 | seg_dir, target_dtypes=RUN3_TARGET_DTYPES | 217 | seg_dir, target_dtypes=RUN3_TARGET_DTYPES |
| 231 | ) | 218 | ) |
| 232 | 219 | ||
| 233 | points = record["points"] | 220 | points = record["points"] |
| 234 | surf_set = _load_surface_key_set( | 221 | surf_set = _load_surface_key_set( |
| 235 | segment_record_files(seg_dir, RUN4_SURFACE_SUFFIX), units_per_m | 222 | segments.segment_record_files(seg_dir, RUN4_SURFACE_SUFFIX), units_per_m |
| 236 | ) | 223 | ) |
| 237 | if surf_set is None: | 224 | if surf_set is None: |
| 238 | is_surface = np.zeros(points.shape[0], dtype=bool) | 225 | is_surface = np.zeros(points.shape[0], dtype=bool) |
| 239 | surface_match_rate = 0.0 | 226 | surface_match_rate = 0.0 |
| 240 | else: | 227 | else: |
| 241 | keys = surface_keys(points, units_per_m) | 228 | keys = surface_keys(points, units_per_m) |
| 242 | is_surface = np.isin(keys, surf_set, assume_unique=False) | 229 | is_surface = np.isin(keys, surf_set, assume_unique=False) |
| 243 | surface_match_rate = key_match_rate(surf_set, keys) | 230 | surface_match_rate = point_hash.key_match_rate(surf_set, keys) |
| 244 | del keys | 231 | del keys |
| 245 | 232 | ||
| 246 | return SegmentCloud( | 233 | return SegmentCloud( |
| 247 | points=points, | 234 | points=points, |
| 1 | """Synthetic micro-cloud tests for the fusion pipeline (no real data).""" | 1 | """Synthetic micro-cloud tests for the fusion pipeline (no real data).""" |
| 2 | 2 | ||
| 3 | import json | 3 | import json |
| 4 | import logging | 4 | import logging |
| 5 | import pathlib | ||
| 5 | 6 | ||
| 6 | import numpy as np | 7 | import numpy as np |
| 7 | import pytest | 8 | import pytest |
| 8 | from iolabs.common import crs | 9 | import shapely |
| 9 | from iolabs.common.segments import parse_segment_ids, parse_segment_names | 10 | from iolabs.common import crs, segment_points_io, segments |
| 10 | from scipy.spatial import cKDTree | 11 | from scipy import spatial |
| 11 | from shapely import Polygon | ||
| 12 | 12 | ||
| 13 | from iolabs_point_cloud_segmentation_3d import ( | 13 | from iolabs_point_cloud_segmentation_3d import ( |
| 14 | classes, | 14 | classes, |
| 15 | clusters, | 15 | clusters, |
| 16 | config, | ||
| 17 | fuse, | ||
| 16 | ground, | 18 | ground, |
| 17 | guardrail_json, | 19 | guardrail_json, |
| 18 | io_npz, | 20 | io_npz, |
| 19 | lines_xml, | 21 | lines_xml, |
| 22 | vegetation, | 24 | vegetation, |
| 23 | voxel, | 25 | voxel, |
| 24 | writer, | 26 | writer, |
| 25 | ) | 27 | ) |
| 26 | from iolabs_point_cloud_segmentation_3d import fuse as fuse_mod | ||
| 27 | from iolabs_point_cloud_segmentation_3d.config import ( | ||
| 28 | Seg3dConfig, | ||
| 29 | config_from_dict, | ||
| 30 | ) | ||
| 31 | from iolabs_point_cloud_segmentation_3d.fuse import ( | ||
| 32 | AlignmentError, | ||
| 33 | FuseResult, | ||
| 34 | _paint_lines, | ||
| 35 | fuse_segment, | ||
| 36 | paint_signs_from_json, | ||
| 37 | ) | ||
| 38 | 28 | ||
| 39 | # The legacy output base name for segment 007: the writers take a resolved | 29 | # The legacy output base name for segment 007: the writers take a resolved |
| 40 | # base (see `naming`), not a segment id. | 30 | # base (see `naming`), not a segment id. |
| 41 | BASE_007 = "segment_007_seg3d" | 31 | BASE_007 = "segment_007_seg3d" |
| 95 | Splitting them would let a tube voxel lose the count tie-break to the | 85 | Splitting them would let a tube voxel lose the count tie-break to the |
| 96 | rail it was carved out of, which is the exact failure the tier exists | 86 | rail it was carved out of, which is the exact failure the tier exists |
| 97 | to prevent. | 87 | to prevent. |
| 98 | """ | 88 | """ |
| 99 | cfg = Seg3dConfig(priority_support=9) | 89 | cfg = config.Seg3dConfig(priority_support=9) |
| 100 | plut = classes.priority_lut(cfg) | 90 | plut = classes.priority_lut(cfg) |
| 101 | assert plut[classes.BY_NAME["guardrail_support"].las_code] == 9 | 91 | assert plut[classes.BY_NAME["guardrail_support"].las_code] == 9 |
| 102 | assert plut[classes.BY_NAME["guardrail_top_rail"].las_code] == 9 | 92 | assert plut[classes.BY_NAME["guardrail_top_rail"].las_code] == 9 |
| 103 | assert plut[classes.BY_NAME["guardrail"].las_code] == 4 | 93 | assert plut[classes.BY_NAME["guardrail"].las_code] == 4 |
| 157 | """A record key the loader grows reaches consumers with no edit here.""" | 147 | """A record key the loader grows reaches consumers with no edit here.""" |
| 158 | seg = tmp_path / "segment_004" | 148 | seg = tmp_path / "segment_004" |
| 159 | seg.mkdir() | 149 | seg.mkdir() |
| 160 | _write_run3(seg / "a_run3_points.npz", np.zeros((3, 3))) | 150 | _write_run3(seg / "a_run3_points.npz", np.zeros((3, 3))) |
| 161 | real_load = io_npz.load_run3_segment | 151 | real_load = segment_points_io.load_run3_segment |
| 162 | 152 | ||
| 163 | def _with_future_key(seg_dir, **kwargs): | 153 | def _with_future_key(seg_dir, **kwargs): |
| 164 | record, records = real_load(seg_dir, **kwargs) | 154 | record, records = real_load(seg_dir, **kwargs) |
| 165 | record = dict(record) | 155 | record = dict(record) |
| 167 | record["points"].shape[0], dtype=np.uint8 | 157 | record["points"].shape[0], dtype=np.uint8 |
| 168 | ) | 158 | ) |
| 169 | return record, records | 159 | return record, records |
| 170 | 160 | ||
| 171 | monkeypatch.setattr(io_npz, "load_run3_segment", _with_future_key) | 161 | monkeypatch.setattr(segment_points_io, "load_run3_segment", _with_future_key) |
| 172 | cloud = io_npz.load_segment_cloud(seg) | 162 | cloud = io_npz.load_segment_cloud(seg) |
| 173 | 163 | ||
| 174 | expected = np.arange(3, dtype=np.uint8) | 164 | expected = np.arange(3, dtype=np.uint8) |
| 175 | np.testing.assert_array_equal(cloud.extra["future_channel"], expected) | 165 | np.testing.assert_array_equal(cloud.extra["future_channel"], expected) |
| 180 | # pavement / polygon classify | 170 | # pavement / polygon classify |
| 181 | # --------------------------------------------------------------------------- # | 171 | # --------------------------------------------------------------------------- # |
| 182 | def test_polygon_classify(): | 172 | def test_polygon_classify(): |
| 183 | corridor = pavement.Corridor( | 173 | corridor = pavement.Corridor( |
| 184 | polygon=Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), | 174 | polygon=shapely.Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), |
| 185 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), | 175 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), |
| 186 | ) | 176 | ) |
| 187 | pts = np.array( | 177 | pts = np.array( |
| 188 | [ | 178 | [ |
| 197 | 187 | ||
| 198 | 188 | ||
| 199 | def test_classify_above_corridor_ignores_surface_gate(): | 189 | def test_classify_above_corridor_ignores_surface_gate(): |
| 200 | corridor = pavement.Corridor( | 190 | corridor = pavement.Corridor( |
| 201 | polygon=Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), | 191 | polygon=shapely.Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), |
| 202 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), | 192 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), |
| 203 | ) | 193 | ) |
| 204 | pts = np.array( | 194 | pts = np.array( |
| 205 | [ | 195 | [ |
| 220 | p = tmp_path / "segment_003_edges.npz" | 210 | p = tmp_path / "segment_003_edges.npz" |
| 221 | np.savez(p, left_polyline_points=left, right_polyline_points=right) | 211 | np.savez(p, left_polyline_points=left, right_polyline_points=right) |
| 222 | corr = pavement.build_corridor(p) | 212 | corr = pavement.build_corridor(p) |
| 223 | assert corr is not None | 213 | assert corr is not None |
| 224 | assert corr.polygon.contains(Polygon([(1, 1), (2, 1), (2, 3)]).centroid) | 214 | assert corr.polygon.contains(shapely.Polygon([(1, 1), (2, 1), (2, 3)]).centroid) |
| 225 | 215 | ||
| 226 | 216 | ||
| 227 | def test_build_corridor_bowtie_keeps_both_lobes(tmp_path): | 217 | def test_build_corridor_bowtie_keeps_both_lobes(tmp_path): |
| 228 | # Self-intersecting (bowtie) ring: buffer(0) would silently keep only | 218 | # Self-intersecting (bowtie) ring: buffer(0) would silently keep only |
| 257 | [0.10, 0.0, 1.0], # within XY but dz 1.0 > 0.5 -> no | 247 | [0.10, 0.0, 1.0], # within XY but dz 1.0 > 0.5 -> no |
| 258 | ] | 248 | ] |
| 259 | ) | 249 | ) |
| 260 | is_asphalt = np.ones(3, dtype=bool) | 250 | is_asphalt = np.ones(3, dtype=bool) |
| 261 | solid, dashed = _paint_lines(pts, is_asphalt, verts) | 251 | solid, dashed = fuse._paint_lines(pts, is_asphalt, verts) |
| 262 | np.testing.assert_array_equal(solid, [True, False, False]) | 252 | np.testing.assert_array_equal(solid, [True, False, False]) |
| 263 | assert not dashed.any() | 253 | assert not dashed.any() |
| 264 | 254 | ||
| 265 | 255 |
| 271 | solid_xyz=np.array([[0.01, 0.0, 1.0], [0.10, 0.0, 0.0]]), | 261 | solid_xyz=np.array([[0.01, 0.0, 1.0], [0.10, 0.0, 0.0]]), |
| 272 | dashed_xyz=np.empty((0, 3)), | 262 | dashed_xyz=np.empty((0, 3)), |
| 273 | ) | 263 | ) |
| 274 | pts = np.array([[0.0, 0.0, 0.0]]) | 264 | pts = np.array([[0.0, 0.0, 0.0]]) |
| 275 | solid, dashed = _paint_lines(pts, np.ones(1, bool), verts) | 265 | solid, dashed = fuse._paint_lines(pts, np.ones(1, bool), verts) |
| 276 | assert solid[0] | 266 | assert solid[0] |
| 277 | assert not dashed[0] | 267 | assert not dashed[0] |
| 278 | 268 | ||
| 279 | 269 |
| 282 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), | 272 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 283 | dashed_xyz=np.array([[0.0, 0.0, 0.0]]), | 273 | dashed_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 284 | ) | 274 | ) |
| 285 | pts = np.array([[0.05, 0.0, 0.0]]) | 275 | pts = np.array([[0.05, 0.0, 0.0]]) |
| 286 | solid, dashed = _paint_lines(pts, np.ones(1, bool), verts) | 276 | solid, dashed = fuse._paint_lines(pts, np.ones(1, bool), verts) |
| 287 | assert solid[0] and not dashed[0] | 277 | assert solid[0] and not dashed[0] |
| 288 | 278 | ||
| 289 | 279 | ||
| 290 | def test_paint_lines_only_asphalt(): | 280 | def test_paint_lines_only_asphalt(): |
| 292 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), | 282 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 293 | dashed_xyz=np.empty((0, 3)), | 283 | dashed_xyz=np.empty((0, 3)), |
| 294 | ) | 284 | ) |
| 295 | pts = np.array([[0.0, 0.0, 0.0]]) | 285 | pts = np.array([[0.0, 0.0, 0.0]]) |
| 296 | solid, _ = _paint_lines(pts, np.zeros(1, bool), verts) | 286 | solid, _ = fuse._paint_lines(pts, np.zeros(1, bool), verts) |
| 297 | assert not solid.any() # not asphalt -> never painted | 287 | assert not solid.any() # not asphalt -> never painted |
| 298 | 288 | ||
| 299 | 289 | ||
| 300 | # --------------------------------------------------------------------------- # | 290 | # --------------------------------------------------------------------------- # |
| 509 | "<EndPoint><X>4.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 499 | "<EndPoint><X>4.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 510 | "</Line></Lines></Feature></HighwayData>" | 500 | "</Line></Lines></Feature></HighwayData>" |
| 511 | ) | 501 | ) |
| 512 | 502 | ||
| 513 | result = fuse_segment( | 503 | result = fuse.fuse_segment( |
| 514 | seg_dir=seg_dir, | 504 | seg_dir=seg_dir, |
| 515 | seg_name="066", | 505 | seg_name="066", |
| 516 | edges_dirs=[edges], | 506 | edges_dirs=[edges], |
| 517 | xml_path=xml_path, | 507 | xml_path=xml_path, |
| 558 | "<EndPoint><X>3.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 548 | "<EndPoint><X>3.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 559 | "</Line></Lines></Feature></HighwayData>" | 549 | "</Line></Lines></Feature></HighwayData>" |
| 560 | ) | 550 | ) |
| 561 | 551 | ||
| 562 | result = fuse_segment( | 552 | result = fuse.fuse_segment( |
| 563 | seg_dir=seg_dir, | 553 | seg_dir=seg_dir, |
| 564 | seg_name="067", | 554 | seg_name="067", |
| 565 | edges_dirs=[edges], | 555 | edges_dirs=[edges], |
| 566 | xml_path=xml_path, | 556 | xml_path=xml_path, |
| 597 | "<EndPoint><X>50001.0</X><Y>50000.0</Y><Z>0.0</Z></EndPoint>" | 587 | "<EndPoint><X>50001.0</X><Y>50000.0</Y><Z>0.0</Z></EndPoint>" |
| 598 | "</Line></Lines></Feature></HighwayData>" | 588 | "</Line></Lines></Feature></HighwayData>" |
| 599 | ) | 589 | ) |
| 600 | 590 | ||
| 601 | with pytest.raises(AlignmentError): | 591 | with pytest.raises(fuse.AlignmentError): |
| 602 | fuse_segment( | 592 | fuse.fuse_segment( |
| 603 | seg_dir=seg_dir, | 593 | seg_dir=seg_dir, |
| 604 | seg_name="068", | 594 | seg_name="068", |
| 605 | edges_dirs=[edges], | 595 | edges_dirs=[edges], |
| 606 | xml_path=xml_path, | 596 | xml_path=xml_path, |
| 637 | "<EndPoint><X>7.1</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 627 | "<EndPoint><X>7.1</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 638 | "</Line></Lines></Feature></HighwayData>" | 628 | "</Line></Lines></Feature></HighwayData>" |
| 639 | ) | 629 | ) |
| 640 | 630 | ||
| 641 | result = fuse_segment( | 631 | result = fuse.fuse_segment( |
| 642 | seg_dir=seg_dir, | 632 | seg_dir=seg_dir, |
| 643 | seg_name="069", | 633 | seg_name="069", |
| 644 | edges_dirs=[edges], | 634 | edges_dirs=[edges], |
| 645 | xml_path=xml_path, | 635 | xml_path=xml_path, |
| 667 | 657 | ||
| 668 | import tempfile | 658 | import tempfile |
| 669 | 659 | ||
| 670 | with tempfile.TemporaryDirectory() as d: | 660 | with tempfile.TemporaryDirectory() as d: |
| 671 | from pathlib import Path | 661 | path = pathlib.Path(d) / "point_masks.npz" |
| 672 | |||
| 673 | path = Path(d) / "point_masks.npz" | ||
| 674 | np.savez( | 662 | np.savez( |
| 675 | path, | 663 | path, |
| 676 | record_names=np.array(["a_run3_points.npz", "b_run3_points.npz"]), | 664 | record_names=np.array(["a_run3_points.npz", "b_run3_points.npz"]), |
| 677 | record_id=np.array([0, 1, 1], dtype=np.uint16), | 665 | record_id=np.array([0, 1, 1], dtype=np.uint16), |
| 791 | instance_type=np.array(["w_beam", "guardrail_support"]), | 779 | instance_type=np.array(["w_beam", "guardrail_support"]), |
| 792 | instance_json_index=np.array([0, 1], dtype=np.int32), | 780 | instance_json_index=np.array([0, 1], dtype=np.int32), |
| 793 | ) | 781 | ) |
| 794 | 782 | ||
| 795 | result = fuse_segment( | 783 | result = fuse.fuse_segment( |
| 796 | seg_dir=seg_dir, | 784 | seg_dir=seg_dir, |
| 797 | seg_name="081", | 785 | seg_name="081", |
| 798 | edges_dirs=[], | 786 | edges_dirs=[], |
| 799 | xml_path=None, | 787 | xml_path=None, |
| 848 | instance_type=np.array(["delineator"]), | 836 | instance_type=np.array(["delineator"]), |
| 849 | instance_json_index=np.array([0], dtype=np.int32), | 837 | instance_json_index=np.array([0], dtype=np.int32), |
| 850 | ) | 838 | ) |
| 851 | 839 | ||
| 852 | result = fuse_segment( | 840 | result = fuse.fuse_segment( |
| 853 | seg_dir=seg_dir, | 841 | seg_dir=seg_dir, |
| 854 | seg_name="082", | 842 | seg_name="082", |
| 855 | edges_dirs=[], | 843 | edges_dirs=[], |
| 856 | xml_path=None, | 844 | xml_path=None, |
| 953 | detections = [ | 941 | detections = [ |
| 954 | _sign_detection(10.0, 10.0), | 942 | _sign_detection(10.0, 10.0), |
| 955 | _sign_detection(20.0, 10.0), | 943 | _sign_detection(20.0, 10.0), |
| 956 | ] | 944 | ] |
| 957 | instances, metrics = paint_signs_from_json( | 945 | instances, metrics = fuse.paint_signs_from_json( |
| 958 | pts, cls, detections, [covered], Seg3dConfig(), seg_name="001" | 946 | pts, cls, detections, [covered], config.Seg3dConfig(), seg_name="001" |
| 959 | ) | 947 | ) |
| 960 | assert len(instances) == 1 | 948 | assert len(instances) == 1 |
| 961 | inst = instances[0] | 949 | inst = instances[0] |
| 962 | assert inst.kind == "sign" | 950 | assert inst.kind == "sign" |
| 982 | json_index=0, | 970 | json_index=0, |
| 983 | local_index=0, | 971 | local_index=0, |
| 984 | global_rows=np.empty(0, dtype=np.int64), | 972 | global_rows=np.empty(0, dtype=np.int64), |
| 985 | ) | 973 | ) |
| 986 | instances, metrics = paint_signs_from_json( | 974 | instances, metrics = fuse.paint_signs_from_json( |
| 987 | pts, cls, [_sign_detection(10.0, 10.0)], [empty], Seg3dConfig() | 975 | pts, cls, [_sign_detection(10.0, 10.0)], [empty], config.Seg3dConfig() |
| 988 | ) | 976 | ) |
| 989 | assert len(instances) == 1 | 977 | assert len(instances) == 1 |
| 990 | assert instances[0].json_index == 0 | 978 | assert instances[0].json_index == 0 |
| 991 | assert instances[0].local_index == 1 | 979 | assert instances[0].local_index == 1 |
| 1010 | local_index=0, | 998 | local_index=0, |
| 1011 | global_rows=np.arange(20), | 999 | global_rows=np.arange(20), |
| 1012 | ) | 1000 | ) |
| 1013 | detections = [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0)] | 1001 | detections = [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0)] |
| 1014 | instances, metrics = paint_signs_from_json( | 1002 | instances, metrics = fuse.paint_signs_from_json( |
| 1015 | pts, cls, detections, [legacy], Seg3dConfig() | 1003 | pts, cls, detections, [legacy], config.Seg3dConfig() |
| 1016 | ) | 1004 | ) |
| 1017 | assert [i.json_index for i in instances] == [1] | 1005 | assert [i.json_index for i in instances] == [1] |
| 1018 | assert metrics["signs_json_painted"] == 1.0 | 1006 | assert metrics["signs_json_painted"] == 1.0 |
| 1019 | assert metrics["signs_json_skipped"] == 0.0 | 1007 | assert metrics["signs_json_skipped"] == 0.0 |
| 1067 | ) | 1055 | ) |
| 1068 | with caplog.at_level( | 1056 | with caplog.at_level( |
| 1069 | logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.fuse" | 1057 | logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.fuse" |
| 1070 | ): | 1058 | ): |
| 1071 | instances, metrics = paint_signs_from_json( | 1059 | instances, metrics = fuse.paint_signs_from_json( |
| 1072 | pts, cls, detections, mask, Seg3dConfig(), seg_name="023" | 1060 | pts, cls, detections, mask, config.Seg3dConfig(), seg_name="023" |
| 1073 | ) | 1061 | ) |
| 1074 | # The shifted half-post is painted; every mask-covered detection is | 1062 | # The shifted half-post is painted; every mask-covered detection is |
| 1075 | # recognised by geometry and not painted a second time. | 1063 | # recognised by geometry and not painted a second time. |
| 1076 | assert [i.json_index for i in instances] == [7] | 1064 | assert [i.json_index for i in instances] == [7] |
| 1101 | json_index=0, | 1089 | json_index=0, |
| 1102 | local_index=0, | 1090 | local_index=0, |
| 1103 | global_rows=np.empty(0, dtype=np.int64), | 1091 | global_rows=np.empty(0, dtype=np.int64), |
| 1104 | ) | 1092 | ) |
| 1105 | instances, _ = paint_signs_from_json( | 1093 | instances, _ = fuse.paint_signs_from_json( |
| 1106 | pts, cls, [_sign_detection(10.0, 10.0)], [rail], Seg3dConfig() | 1094 | pts, cls, [_sign_detection(10.0, 10.0)], [rail], config.Seg3dConfig() |
| 1107 | ) | 1095 | ) |
| 1108 | assert [i.json_index for i in instances] == [0] | 1096 | assert [i.json_index for i in instances] == [0] |
| 1109 | 1097 | ||
| 1110 | 1098 |
| 1121 | json_index=0, | 1109 | json_index=0, |
| 1122 | local_index=0, | 1110 | local_index=0, |
| 1123 | global_rows=np.arange(pts.shape[0]), | 1111 | global_rows=np.arange(pts.shape[0]), |
| 1124 | ) | 1112 | ) |
| 1125 | instances, metrics = paint_signs_from_json( | 1113 | instances, metrics = fuse.paint_signs_from_json( |
| 1126 | pts, cls, [_sign_detection(10.0, 10.0)], [covered], Seg3dConfig() | 1114 | pts, cls, [_sign_detection(10.0, 10.0)], [covered], config.Seg3dConfig() |
| 1127 | ) | 1115 | ) |
| 1128 | assert instances == [] | 1116 | assert instances == [] |
| 1129 | assert metrics["signs_json_painted"] == 0.0 | 1117 | assert metrics["signs_json_painted"] == 0.0 |
| 1130 | assert metrics["signs_json_skipped"] == 0.0 | 1118 | assert metrics["signs_json_skipped"] == 0.0 |
| 1145 | 4: "solid_line", | 1133 | 4: "solid_line", |
| 1146 | } | 1134 | } |
| 1147 | for row, name in keep.items(): | 1135 | for row, name in keep.items(): |
| 1148 | cls[row] = classes.BY_NAME[name].las_code | 1136 | cls[row] = classes.BY_NAME[name].las_code |
| 1149 | instances, _ = paint_signs_from_json( | 1137 | instances, _ = fuse.paint_signs_from_json( |
| 1150 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1138 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1151 | ) | 1139 | ) |
| 1152 | assert len(instances) == 1 | 1140 | assert len(instances) == 1 |
| 1153 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), | 1141 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), |
| 1154 | np.arange(5, 25)) | 1142 | np.arange(5, 25)) |
| 1160 | def test_paint_signs_from_json_skips_null_z_top(): | 1148 | def test_paint_signs_from_json_skips_null_z_top(): |
| 1161 | pts = _post_points(10.0, 10.0) | 1149 | pts = _post_points(10.0, 10.0) |
| 1162 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1150 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1163 | detections = [_sign_detection(10.0, 10.0, z_top=None)] | 1151 | detections = [_sign_detection(10.0, 10.0, z_top=None)] |
| 1164 | instances, metrics = paint_signs_from_json( | 1152 | instances, metrics = fuse.paint_signs_from_json( |
| 1165 | pts, cls, detections, [], Seg3dConfig() | 1153 | pts, cls, detections, [], config.Seg3dConfig() |
| 1166 | ) | 1154 | ) |
| 1167 | assert instances == [] | 1155 | assert instances == [] |
| 1168 | assert metrics["signs_json_skipped"] == 1.0 | 1156 | assert metrics["signs_json_skipped"] == 1.0 |
| 1169 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1157 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1175 | detections = [ | 1163 | detections = [ |
| 1176 | _sign_detection(10.0, 10.0, seg_type="tree", experimental=True), | 1164 | _sign_detection(10.0, 10.0, seg_type="tree", experimental=True), |
| 1177 | _sign_detection(10.0, 10.0, seg_type="field_stake"), | 1165 | _sign_detection(10.0, 10.0, seg_type="field_stake"), |
| 1178 | ] | 1166 | ] |
| 1179 | instances, metrics = paint_signs_from_json( | 1167 | instances, metrics = fuse.paint_signs_from_json( |
| 1180 | pts, cls, detections, [], Seg3dConfig() | 1168 | pts, cls, detections, [], config.Seg3dConfig() |
| 1181 | ) | 1169 | ) |
| 1182 | assert instances == [] | 1170 | assert instances == [] |
| 1183 | assert metrics["signs_json_skipped"] == 2.0 | 1171 | assert metrics["signs_json_skipped"] == 2.0 |
| 1184 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1172 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1238 | _sign_detection(10.0, 10.0), | 1226 | _sign_detection(10.0, 10.0), |
| 1239 | "junk", | 1227 | "junk", |
| 1240 | _sign_detection(20.0, 10.0), | 1228 | _sign_detection(20.0, 10.0), |
| 1241 | ] | 1229 | ] |
| 1242 | instances, metrics = paint_signs_from_json( | 1230 | instances, metrics = fuse.paint_signs_from_json( |
| 1243 | pts, cls, detections, [covered], Seg3dConfig() | 1231 | pts, cls, detections, [covered], config.Seg3dConfig() |
| 1244 | ) | 1232 | ) |
| 1245 | # Only the genuinely uncovered detection 0 is painted. | 1233 | # Only the genuinely uncovered detection 0 is painted. |
| 1246 | assert [i.json_index for i in instances] == [0] | 1234 | assert [i.json_index for i in instances] == [0] |
| 1247 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), | 1235 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), |
| 1253 | # min_points=0 must not turn a detection that matched nothing into a | 1241 | # min_points=0 must not turn a detection that matched nothing into a |
| 1254 | # zero-row instance. | 1242 | # zero-row instance. |
| 1255 | pts = _post_points(500.0, 500.0, n=20) # nowhere near the detection | 1243 | pts = _post_points(500.0, 500.0, n=20) # nowhere near the detection |
| 1256 | cls = np.full(20, classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1244 | cls = np.full(20, classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1257 | config = config_from_dict({"signs_json_paint_min_points": 0}) | 1245 | cfg = config.config_from_dict({"signs_json_paint_min_points": 0}) |
| 1258 | instances, metrics = paint_signs_from_json( | 1246 | instances, metrics = fuse.paint_signs_from_json( |
| 1259 | pts, cls, [_sign_detection(10.0, 10.0)], [], config | 1247 | pts, cls, [_sign_detection(10.0, 10.0)], [], cfg |
| 1260 | ) | 1248 | ) |
| 1261 | assert instances == [] | 1249 | assert instances == [] |
| 1262 | assert metrics["signs_json_painted"] == 0.0 | 1250 | assert metrics["signs_json_painted"] == 0.0 |
| 1263 | assert metrics["signs_json_skipped"] == 1.0 | 1251 | assert metrics["signs_json_skipped"] == 1.0 |
| 1268 | # 5 points in the cylinder, below the default floor of 10 -> no phantom | 1256 | # 5 points in the cylinder, below the default floor of 10 -> no phantom |
| 1269 | # instance and nothing painted. | 1257 | # instance and nothing painted. |
| 1270 | pts = _post_points(10.0, 10.0, n=5) | 1258 | pts = _post_points(10.0, 10.0, n=5) |
| 1271 | cls = np.full(5, classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1259 | cls = np.full(5, classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1272 | instances, metrics = paint_signs_from_json( | 1260 | instances, metrics = fuse.paint_signs_from_json( |
| 1273 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1261 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1274 | ) | 1262 | ) |
| 1275 | assert instances == [] | 1263 | assert instances == [] |
| 1276 | assert metrics["signs_json_skipped"] == 1.0 | 1264 | assert metrics["signs_json_skipped"] == 1.0 |
| 1277 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1265 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1278 | # Lowering the floor paints the same detection. | 1266 | # Lowering the floor paints the same detection. |
| 1279 | instances, _ = paint_signs_from_json( | 1267 | instances, _ = fuse.paint_signs_from_json( |
| 1280 | pts, cls, [_sign_detection(10.0, 10.0)], [], | 1268 | pts, cls, [_sign_detection(10.0, 10.0)], [], |
| 1281 | config_from_dict({"signs_json_paint_min_points": 3}), | 1269 | config.config_from_dict({"signs_json_paint_min_points": 3}), |
| 1282 | ) | 1270 | ) |
| 1283 | assert len(instances) == 1 | 1271 | assert len(instances) == 1 |
| 1284 | 1272 | ||
| 1285 | 1273 |
| 1291 | outside_xy = _post_points(10.5, 10.0, n=12) # 0.5 m out -> outside | 1279 | outside_xy = _post_points(10.5, 10.0, n=12) # 0.5 m out -> outside |
| 1292 | above = _post_points(10.0, 10.0, n=12, z0=1.9, z1=3.0) # above z_top+0.30 | 1280 | above = _post_points(10.0, 10.0, n=12, z0=1.9, z1=3.0) # above z_top+0.30 |
| 1293 | pts = np.vstack([inside, far_xy, outside_xy, above]) | 1281 | pts = np.vstack([inside, far_xy, outside_xy, above]) |
| 1294 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1282 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1295 | instances, _ = paint_signs_from_json( | 1283 | instances, _ = fuse.paint_signs_from_json( |
| 1296 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1284 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1297 | ) | 1285 | ) |
| 1298 | assert len(instances) == 1 | 1286 | assert len(instances) == 1 |
| 1299 | painted = np.zeros(pts.shape[0], dtype=bool) | 1287 | painted = np.zeros(pts.shape[0], dtype=bool) |
| 1300 | painted[instances[0].global_rows] = True | 1288 | painted[instances[0].global_rows] = True |
| 1313 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1301 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1314 | gantry = _sign_detection( | 1302 | gantry = _sign_detection( |
| 1315 | 10.0, 10.0, "gantry_or_gate", footprint_m=[10.0, 0.5] | 1303 | 10.0, 10.0, "gantry_or_gate", footprint_m=[10.0, 0.5] |
| 1316 | ) | 1304 | ) |
| 1317 | instances, metrics = paint_signs_from_json( | 1305 | instances, metrics = fuse.paint_signs_from_json( |
| 1318 | pts, cls, [gantry], [], Seg3dConfig() | 1306 | pts, cls, [gantry], [], config.Seg3dConfig() |
| 1319 | ) | 1307 | ) |
| 1320 | assert instances == [] | 1308 | assert instances == [] |
| 1321 | assert metrics["signs_json_painted"] == 0.0 | 1309 | assert metrics["signs_json_painted"] == 0.0 |
| 1322 | assert metrics["signs_json_skipped"] == 1.0 | 1310 | assert metrics["signs_json_skipped"] == 1.0 |
| 1329 | below = _post_points(10.0, 10.0, n=12, z0=-0.14, z1=-0.01) | 1317 | below = _post_points(10.0, 10.0, n=12, z0=-0.14, z1=-0.01) |
| 1330 | above = _post_points(10.0, 10.0, n=12, z0=0.0, z1=1.5) | 1318 | above = _post_points(10.0, 10.0, n=12, z0=0.0, z1=1.5) |
| 1331 | pts = np.vstack([below, above]) | 1319 | pts = np.vstack([below, above]) |
| 1332 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1320 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1333 | instances, _ = paint_signs_from_json( | 1321 | instances, _ = fuse.paint_signs_from_json( |
| 1334 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1322 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1335 | ) | 1323 | ) |
| 1336 | assert len(instances) == 1 | 1324 | assert len(instances) == 1 |
| 1337 | np.testing.assert_array_equal( | 1325 | np.testing.assert_array_equal( |
| 1338 | np.sort(instances[0].global_rows), np.arange(12, 24) | 1326 | np.sort(instances[0].global_rows), np.arange(12, 24) |
| 1339 | ) | 1327 | ) |
| 1340 | # The knob still reaches under the ground when a run asks for it. | 1328 | # The knob still reaches under the ground when a run asks for it. |
| 1341 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1329 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1342 | instances, _ = paint_signs_from_json( | 1330 | instances, _ = fuse.paint_signs_from_json( |
| 1343 | pts, cls, [_sign_detection(10.0, 10.0)], [], | 1331 | pts, cls, [_sign_detection(10.0, 10.0)], [], |
| 1344 | config_from_dict({"signs_json_paint_z_pad_bottom_m": 0.15}), | 1332 | config.config_from_dict({"signs_json_paint_z_pad_bottom_m": 0.15}), |
| 1345 | ) | 1333 | ) |
| 1346 | np.testing.assert_array_equal( | 1334 | np.testing.assert_array_equal( |
| 1347 | np.sort(instances[0].global_rows), np.arange(24) | 1335 | np.sort(instances[0].global_rows), np.arange(24) |
| 1348 | ) | 1336 | ) |
| 1360 | signs, "080", | 1348 | signs, "080", |
| 1361 | [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0, "sign_post")], | 1349 | [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0, "sign_post")], |
| 1362 | ) | 1350 | ) |
| 1363 | 1351 | ||
| 1364 | result = fuse_segment( | 1352 | result = fuse.fuse_segment( |
| 1365 | seg_dir=seg_dir, | 1353 | seg_dir=seg_dir, |
| 1366 | seg_name="080", | 1354 | seg_name="080", |
| 1367 | edges_dirs=[], | 1355 | edges_dirs=[], |
| 1368 | xml_path=None, | 1356 | xml_path=None, |
| 1401 | seg_dir = tmp_path / "segment_007" | 1389 | seg_dir = tmp_path / "segment_007" |
| 1402 | seg_dir.mkdir() | 1390 | seg_dir.mkdir() |
| 1403 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) | 1391 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) |
| 1404 | 1392 | ||
| 1405 | config = config_from_dict( | 1393 | cfg = config.config_from_dict( |
| 1406 | {"edge_extend_m": 12.5, "priority_detector": 7, "las_crs_epsg": 2056} | 1394 | {"edge_extend_m": 12.5, "priority_detector": 7, "las_crs_epsg": 2056} |
| 1407 | ) | 1395 | ) |
| 1408 | result = fuse_segment( | 1396 | result = fuse.fuse_segment( |
| 1409 | seg_dir=seg_dir, | 1397 | seg_dir=seg_dir, |
| 1410 | seg_name="007", | 1398 | seg_name="007", |
| 1411 | edges_dirs=[], | 1399 | edges_dirs=[], |
| 1412 | xml_path=None, | 1400 | xml_path=None, |
| 1413 | guardrail_masks_dir=None, | 1401 | guardrail_masks_dir=None, |
| 1414 | signs_masks_dir=None, | 1402 | signs_masks_dir=None, |
| 1415 | voxel=0.25, | 1403 | voxel=0.25, |
| 1416 | config=config, | 1404 | config=cfg, |
| 1417 | ) | 1405 | ) |
| 1418 | 1406 | ||
| 1419 | params = result.stats["params"] | 1407 | params = result.stats["params"] |
| 1420 | assert params["edge_extend_m"] == 12.5 | 1408 | assert params["edge_extend_m"] == 12.5 |
| 1421 | assert params["priority_detector"] == 7 | 1409 | assert params["priority_detector"] == 7 |
| 1422 | assert params["las_crs_epsg"] == 2056 | 1410 | assert params["las_crs_epsg"] == 2056 |
| 1423 | # `--voxel` beats `config.voxel_size_m`, and the params say so. | 1411 | # `--voxel` beats `config.voxel_size_m`, and the params say so. |
| 1424 | assert params["voxel_size_m"] == 0.25 | 1412 | assert params["voxel_size_m"] == 0.25 |
| 1425 | assert config.voxel_size_m != 0.25 | 1413 | assert cfg.voxel_size_m != 0.25 |
| 1426 | # Whatever ends up in the params has to survive the stats json. | 1414 | # Whatever ends up in the params has to survive the stats json. |
| 1427 | assert json.loads(json.dumps(params)) == params | 1415 | assert json.loads(json.dumps(params)) == params |
| 1428 | 1416 | ||
| 1429 | 1417 |
| 1441 | seg_dir = tmp_path / "segment_007" | 1429 | seg_dir = tmp_path / "segment_007" |
| 1442 | seg_dir.mkdir() | 1430 | seg_dir.mkdir() |
| 1443 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) | 1431 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) |
| 1444 | 1432 | ||
| 1445 | config = config_from_dict({"las_split": "none", "write_ply": False}) | 1433 | cfg = config.config_from_dict({"las_split": "none", "write_ply": False}) |
| 1446 | result = fuse_segment( | 1434 | result = fuse.fuse_segment( |
| 1447 | seg_dir=seg_dir, | 1435 | seg_dir=seg_dir, |
| 1448 | seg_name="007", | 1436 | seg_name="007", |
| 1449 | edges_dirs=[], | 1437 | edges_dirs=[], |
| 1450 | xml_path=None, | 1438 | xml_path=None, |
| 1451 | guardrail_masks_dir=None, | 1439 | guardrail_masks_dir=None, |
| 1452 | signs_masks_dir=None, | 1440 | signs_masks_dir=None, |
| 1453 | voxel=0.25, | 1441 | voxel=0.25, |
| 1454 | config=config, | 1442 | config=cfg, |
| 1455 | ) | 1443 | ) |
| 1456 | summary = { | 1444 | summary = { |
| 1457 | "segment": "007", | 1445 | "segment": "007", |
| 1458 | "peak_rss_gb": 0.1, | 1446 | "peak_rss_gb": 0.1, |
| 1459 | "params": result.stats["params"], | 1447 | "params": result.stats["params"], |
| 1460 | } | 1448 | } |
| 1461 | path = cli._write_run_summary( | 1449 | path = cli._write_run_summary( |
| 1462 | tmp_path / "out", config, [summary], | 1450 | tmp_path / "out", cfg, [summary], |
| 1463 | voxel_override=0.25, | 1451 | voxel_override=0.25, |
| 1464 | las_split_override="class", | 1452 | las_split_override="class", |
| 1465 | write_ply_override=True, | 1453 | write_ply_override=True, |
| 1466 | ) | 1454 | ) |
| 1489 | _write_signs_sidecars( | 1477 | _write_signs_sidecars( |
| 1490 | signs, "081", detections, mask=[(0, list(range(20)))] | 1478 | signs, "081", detections, mask=[(0, list(range(20)))] |
| 1491 | ) | 1479 | ) |
| 1492 | 1480 | ||
| 1493 | result = fuse_segment( | 1481 | result = fuse.fuse_segment( |
| 1494 | seg_dir=seg_dir, | 1482 | seg_dir=seg_dir, |
| 1495 | seg_name="081", | 1483 | seg_name="081", |
| 1496 | edges_dirs=[], | 1484 | edges_dirs=[], |
| 1497 | xml_path=None, | 1485 | xml_path=None, |
| 1535 | 1523 | ||
| 1536 | signs = tmp_path / "signs" | 1524 | signs = tmp_path / "signs" |
| 1537 | _write_signs_sidecars(signs, "083", [_sign_detection(10.0, 10.0)]) | 1525 | _write_signs_sidecars(signs, "083", [_sign_detection(10.0, 10.0)]) |
| 1538 | 1526 | ||
| 1539 | result = fuse_segment( | 1527 | result = fuse.fuse_segment( |
| 1540 | seg_dir=seg_dir, | 1528 | seg_dir=seg_dir, |
| 1541 | seg_name="083", | 1529 | seg_name="083", |
| 1542 | edges_dirs=[], | 1530 | edges_dirs=[], |
| 1543 | xml_path=None, | 1531 | xml_path=None, |
| 1558 | 1546 | ||
| 1559 | signs = tmp_path / "signs" | 1547 | signs = tmp_path / "signs" |
| 1560 | _write_signs_sidecars(signs, "082", [_sign_detection(10.0, 10.0)]) | 1548 | _write_signs_sidecars(signs, "082", [_sign_detection(10.0, 10.0)]) |
| 1561 | 1549 | ||
| 1562 | result = fuse_segment( | 1550 | result = fuse.fuse_segment( |
| 1563 | seg_dir=seg_dir, | 1551 | seg_dir=seg_dir, |
| 1564 | seg_name="082", | 1552 | seg_name="082", |
| 1565 | edges_dirs=[], | 1553 | edges_dirs=[], |
| 1566 | xml_path=None, | 1554 | xml_path=None, |
| 1567 | guardrail_masks_dir=None, | 1555 | guardrail_masks_dir=None, |
| 1568 | signs_masks_dir=signs, | 1556 | signs_masks_dir=signs, |
| 1569 | config=config_from_dict({"signs_json_paint_enabled": False}), | 1557 | config=config.config_from_dict({"signs_json_paint_enabled": False}), |
| 1570 | ) | 1558 | ) |
| 1571 | assert np.all(result.classification == classes.UNCLASSIFIED_CODE) | 1559 | assert np.all(result.classification == classes.UNCLASSIFIED_CODE) |
| 1572 | assert "signs_json_painted" not in result.stats["guard_metrics"] | 1560 | assert "signs_json_painted" not in result.stats["guard_metrics"] |
| 1573 | 1561 |
| 1589 | instance_type=np.array(["delineator"]), | 1577 | instance_type=np.array(["delineator"]), |
| 1590 | instance_json_index=np.array([0], dtype=np.int32), | 1578 | instance_json_index=np.array([0], dtype=np.int32), |
| 1591 | ) | 1579 | ) |
| 1592 | 1580 | ||
| 1593 | result = fuse_segment( | 1581 | result = fuse.fuse_segment( |
| 1594 | seg_dir=seg_dir, | 1582 | seg_dir=seg_dir, |
| 1595 | seg_name="084", | 1583 | seg_name="084", |
| 1596 | edges_dirs=[], | 1584 | edges_dirs=[], |
| 1597 | xml_path=None, | 1585 | xml_path=None, |
| 2102 | (mdir / "guardrails.json").write_text( | 2090 | (mdir / "guardrails.json").write_text( |
| 2103 | json.dumps({"guardrails": [_rail_entry(0), _support_entry(1)]}) | 2091 | json.dumps({"guardrails": [_rail_entry(0), _support_entry(1)]}) |
| 2104 | ) | 2092 | ) |
| 2105 | 2093 | ||
| 2106 | result = fuse_segment( | 2094 | result = fuse.fuse_segment( |
| 2107 | seg_dir=seg_dir, | 2095 | seg_dir=seg_dir, |
| 2108 | seg_name="082", | 2096 | seg_name="082", |
| 2109 | edges_dirs=[], | 2097 | edges_dirs=[], |
| 2110 | xml_path=None, | 2098 | xml_path=None, |
| 2144 | assert masks.guardrails_json_file(tmp_path / "gmasks", "084") == ( | 2132 | assert masks.guardrails_json_file(tmp_path / "gmasks", "084") == ( |
| 2145 | mdir / "guardrails.json" | 2133 | mdir / "guardrails.json" |
| 2146 | ) | 2134 | ) |
| 2147 | 2135 | ||
| 2148 | result = fuse_segment( | 2136 | result = fuse.fuse_segment( |
| 2149 | seg_dir=seg_dir, | 2137 | seg_dir=seg_dir, |
| 2150 | seg_name="084", | 2138 | seg_name="084", |
| 2151 | edges_dirs=[], | 2139 | edges_dirs=[], |
| 2152 | xml_path=None, | 2140 | xml_path=None, |
| 2179 | mdir = tmp_path / "gmasks" / "segment_084" | 2167 | mdir = tmp_path / "gmasks" / "segment_084" |
| 2180 | mdir.mkdir(parents=True) | 2168 | mdir.mkdir(parents=True) |
| 2181 | (mdir / "guardrails.json").write_text('{"guardrails": [{"id": 0,') | 2169 | (mdir / "guardrails.json").write_text('{"guardrails": [{"id": 0,') |
| 2182 | 2170 | ||
| 2183 | result = fuse_segment( | 2171 | result = fuse.fuse_segment( |
| 2184 | seg_dir=seg_dir, | 2172 | seg_dir=seg_dir, |
| 2185 | seg_name="084", | 2173 | seg_name="084", |
| 2186 | edges_dirs=[], | 2174 | edges_dirs=[], |
| 2187 | xml_path=None, | 2175 | xml_path=None, |
| 2227 | (mdir / "guardrails.json").write_text(json.dumps({"guardrails": [ | 2215 | (mdir / "guardrails.json").write_text(json.dumps({"guardrails": [ |
| 2228 | _rail_entry(0), _support_entry(1), _top_rail_entry(2), | 2216 | _rail_entry(0), _support_entry(1), _top_rail_entry(2), |
| 2229 | ]})) | 2217 | ]})) |
| 2230 | 2218 | ||
| 2231 | result = fuse_segment( | 2219 | result = fuse.fuse_segment( |
| 2232 | seg_dir=seg_dir, | 2220 | seg_dir=seg_dir, |
| 2233 | seg_name="083", | 2221 | seg_name="083", |
| 2234 | edges_dirs=[], | 2222 | edges_dirs=[], |
| 2235 | xml_path=None, | 2223 | xml_path=None, |
| 2370 | _write_tablecloth_mask( | 2358 | _write_tablecloth_mask( |
| 2371 | gm / "a_tablecloth_masks.npz", np.ones(6, dtype=bool) | 2359 | gm / "a_tablecloth_masks.npz", np.ones(6, dtype=bool) |
| 2372 | ) | 2360 | ) |
| 2373 | 2361 | ||
| 2374 | result = fuse_segment( | 2362 | result = fuse.fuse_segment( |
| 2375 | seg_dir=seg_dir, | 2363 | seg_dir=seg_dir, |
| 2376 | seg_name="070", | 2364 | seg_name="070", |
| 2377 | edges_dirs=[edges], | 2365 | edges_dirs=[edges], |
| 2378 | xml_path=None, | 2366 | xml_path=None, |
| 2430 | xml_path=None, | 2418 | xml_path=None, |
| 2431 | guardrail_masks_dir=None, | 2419 | guardrail_masks_dir=None, |
| 2432 | signs_masks_dir=None, | 2420 | signs_masks_dir=None, |
| 2433 | ) | 2421 | ) |
| 2434 | without = fuse_segment(**kwargs).stats["counts_full"] | 2422 | without = fuse.fuse_segment(**kwargs).stats["counts_full"] |
| 2435 | with_g = fuse_segment( | 2423 | with_g = fuse.fuse_segment( |
| 2436 | **kwargs, ground_masks_dir=tmp_path / "ground" | 2424 | **kwargs, ground_masks_dir=tmp_path / "ground" |
| 2437 | ).stats["counts_full"] | 2425 | ).stats["counts_full"] |
| 2438 | 2426 | ||
| 2439 | for name in ("asphalt", "solid_line", "dashed_line"): | 2427 | for name in ("asphalt", "solid_line", "dashed_line"): |
| 2451 | 2439 | ||
| 2452 | gm = tmp_path / "ground" / "segment_071" | 2440 | gm = tmp_path / "ground" / "segment_071" |
| 2453 | gm.mkdir(parents=True) # subdir exists but holds no mask files | 2441 | gm.mkdir(parents=True) # subdir exists but holds no mask files |
| 2454 | 2442 | ||
| 2455 | result = fuse_segment( | 2443 | result = fuse.fuse_segment( |
| 2456 | seg_dir=seg_dir, | 2444 | seg_dir=seg_dir, |
| 2457 | seg_name="071", | 2445 | seg_name="071", |
| 2458 | edges_dirs=[], | 2446 | edges_dirs=[], |
| 2459 | xml_path=None, | 2447 | xml_path=None, |
| 2493 | 2481 | ||
| 2494 | def test_fuse_segment_vehicle_paints_unclassified_inside_corridor(tmp_path): | 2482 | def test_fuse_segment_vehicle_paints_unclassified_inside_corridor(tmp_path): |
| 2495 | seg_dir, edges = _vehicle_segment(tmp_path, "073") | 2483 | seg_dir, edges = _vehicle_segment(tmp_path, "073") |
| 2496 | 2484 | ||
| 2497 | result = fuse_segment( | 2485 | result = fuse.fuse_segment( |
| 2498 | seg_dir=seg_dir, | 2486 | seg_dir=seg_dir, |
| 2499 | seg_name="073", | 2487 | seg_name="073", |
| 2500 | edges_dirs=[edges], | 2488 | edges_dirs=[edges], |
| 2501 | xml_path=None, | 2489 | xml_path=None, |
| 2552 | gm / "a_tablecloth_masks.npz", | 2540 | gm / "a_tablecloth_masks.npz", |
| 2553 | [True] * 7 + [False, False], | 2541 | [True] * 7 + [False, False], |
| 2554 | ) | 2542 | ) |
| 2555 | 2543 | ||
| 2556 | result = fuse_segment( | 2544 | result = fuse.fuse_segment( |
| 2557 | seg_dir=seg_dir, | 2545 | seg_dir=seg_dir, |
| 2558 | seg_name="074", | 2546 | seg_name="074", |
| 2559 | edges_dirs=[edges], | 2547 | edges_dirs=[edges], |
| 2560 | xml_path=None, | 2548 | xml_path=None, |
| 2573 | 2561 | ||
| 2574 | def test_fuse_segment_vehicle_skipped_without_corridor(tmp_path): | 2562 | def test_fuse_segment_vehicle_skipped_without_corridor(tmp_path): |
| 2575 | seg_dir, _ = _vehicle_segment(tmp_path, "075") | 2563 | seg_dir, _ = _vehicle_segment(tmp_path, "075") |
| 2576 | 2564 | ||
| 2577 | result = fuse_segment( | 2565 | result = fuse.fuse_segment( |
| 2578 | seg_dir=seg_dir, | 2566 | seg_dir=seg_dir, |
| 2579 | seg_name="075", | 2567 | seg_name="075", |
| 2580 | edges_dirs=[], | 2568 | edges_dirs=[], |
| 2581 | xml_path=None, | 2569 | xml_path=None, |
| 2593 | # so the corridor interior is the *un-painted* carriageway -- sweeping it | 2581 | # so the corridor interior is the *un-painted* carriageway -- sweeping it |
| 2594 | # would relabel the whole road as vehicles. | 2582 | # would relabel the whole road as vehicles. |
| 2595 | seg_dir, edges = _vehicle_segment(tmp_path, "076", with_run4=False) | 2583 | seg_dir, edges = _vehicle_segment(tmp_path, "076", with_run4=False) |
| 2596 | 2584 | ||
| 2597 | result = fuse_segment( | 2585 | result = fuse.fuse_segment( |
| 2598 | seg_dir=seg_dir, | 2586 | seg_dir=seg_dir, |
| 2599 | seg_name="076", | 2587 | seg_name="076", |
| 2600 | edges_dirs=[edges], | 2588 | edges_dirs=[edges], |
| 2601 | xml_path=None, | 2589 | xml_path=None, |
| 2610 | 2598 | ||
| 2611 | def test_fuse_segment_vehicle_disabled_by_config(tmp_path): | 2599 | def test_fuse_segment_vehicle_disabled_by_config(tmp_path): |
| 2612 | seg_dir, edges = _vehicle_segment(tmp_path, "077") | 2600 | seg_dir, edges = _vehicle_segment(tmp_path, "077") |
| 2613 | 2601 | ||
| 2614 | result = fuse_segment( | 2602 | result = fuse.fuse_segment( |
| 2615 | seg_dir=seg_dir, | 2603 | seg_dir=seg_dir, |
| 2616 | seg_name="077", | 2604 | seg_name="077", |
| 2617 | edges_dirs=[edges], | 2605 | edges_dirs=[edges], |
| 2618 | xml_path=None, | 2606 | xml_path=None, |
| 2619 | guardrail_masks_dir=None, | 2607 | guardrail_masks_dir=None, |
| 2620 | signs_masks_dir=None, | 2608 | signs_masks_dir=None, |
| 2621 | config=Seg3dConfig(vehicle_enabled=False), | 2609 | config=config.Seg3dConfig(vehicle_enabled=False), |
| 2622 | ) | 2610 | ) |
| 2623 | assert ( | 2611 | assert ( |
| 2624 | "vehicle: disabled by config (vehicle_enabled=false)" | 2612 | "vehicle: disabled by config (vehicle_enabled=false)" |
| 2625 | in result.stats["skips"] | 2613 | in result.stats["skips"] |
| 2702 | guard = classes.BY_NAME["guardrail"].las_code | 2690 | guard = classes.BY_NAME["guardrail"].las_code |
| 2703 | support = classes.BY_NAME["guardrail_support"].las_code | 2691 | support = classes.BY_NAME["guardrail_support"].las_code |
| 2704 | pts = np.array([[0.1, 0.1, 0.0], [0.2, 0.2, 0.0], [0.9, 0.9, 0.0]]) | 2692 | pts = np.array([[0.1, 0.1, 0.0], [0.2, 0.2, 0.0], [0.9, 0.9, 0.0]]) |
| 2705 | cls = np.array([guard, guard, support], dtype=np.uint8) | 2693 | cls = np.array([guard, guard, support], dtype=np.uint8) |
| 2706 | plut = classes.priority_lut(Seg3dConfig()) | 2694 | plut = classes.priority_lut(config.Seg3dConfig()) |
| 2707 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut) | 2695 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut) |
| 2708 | assert cls[rep[0]] == support | 2696 | assert cls[rep[0]] == support |
| 2709 | plut_flat = classes.priority_lut(Seg3dConfig(priority_support=4)) | 2697 | plut_flat = classes.priority_lut(config.Seg3dConfig(priority_support=4)) |
| 2710 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut_flat) | 2698 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut_flat) |
| 2711 | assert cls[rep[0]] == guard | 2699 | assert cls[rep[0]] == guard |
| 2712 | 2700 | ||
| 2713 | 2701 |
| 2762 | is_surface=np.zeros(pts.shape[0], bool), | 2750 | is_surface=np.zeros(pts.shape[0], bool), |
| 2763 | records=[io_npz.Record("a_run3_points.npz", 0, pts.shape[0])], | 2751 | records=[io_npz.Record("a_run3_points.npz", 0, pts.shape[0])], |
| 2764 | surface_match_rate=0.0, | 2752 | surface_match_rate=0.0, |
| 2765 | ) | 2753 | ) |
| 2766 | return FuseResult( | 2754 | return fuse.FuseResult( |
| 2767 | cloud=cloud, | 2755 | cloud=cloud, |
| 2768 | classification=cls, | 2756 | classification=cls, |
| 2769 | rep_index=np.arange(pts.shape[0]), | 2757 | rep_index=np.arange(pts.shape[0]), |
| 2770 | instances=[], | 2758 | instances=[], |
| 3058 | # --------------------------------------------------------------------------- # | 3046 | # --------------------------------------------------------------------------- # |
| 3059 | def test_parse_segments(): | 3047 | def test_parse_segments(): |
| 3060 | # seg3d semantics the CLI relies on: first-seen order, ascending-only | 3048 | # seg3d semantics the CLI relies on: first-seen order, ascending-only |
| 3061 | # ranges, duplicates dropped -- the shared parser's defaults. | 3049 | # ranges, duplicates dropped -- the shared parser's defaults. |
| 3062 | assert parse_segment_ids("066-069,038") == [66, 67, 68, 69, 38] | 3050 | assert segments.parse_segment_ids("066-069,038") == [66, 67, 68, 69, 38] |
| 3063 | assert parse_segment_ids("5") == [5] | 3051 | assert segments.parse_segment_ids("5") == [5] |
| 3064 | assert parse_segment_ids("1,1,2") == [1, 2] | 3052 | assert segments.parse_segment_ids("1,1,2") == [1, 2] |
| 3065 | assert parse_segment_ids("074-066") == [] | 3053 | assert segments.parse_segment_ids("074-066") == [] |
| 3066 | assert parse_segment_names("066-068,38") == ["066", "067", "068", "038"] | 3054 | assert segments.parse_segment_names("066-068,38") == ["066", "067", "068", "038"] |
| 3067 | 3055 | ||
| 3068 | 3056 | ||
| 3069 | def test_extend_polyline_reaches_beyond_ends(): | 3057 | def test_extend_polyline_reaches_beyond_ends(): |
| 3070 | xy = np.column_stack([np.linspace(0, 10, 11), np.zeros(11)]) | 3058 | xy = np.column_stack([np.linspace(0, 10, 11), np.zeros(11)]) |
| 3137 | io_npz.Record("a_run3_points.npz", 0, 3), | 3125 | io_npz.Record("a_run3_points.npz", 0, 3), |
| 3138 | io_npz.Record("b_run3_points.npz", 3, 3), | 3126 | io_npz.Record("b_run3_points.npz", 3, 3), |
| 3139 | ] | 3127 | ] |
| 3140 | rep, vop = voxel.decimate_with_map(pts, cls, voxel_size) | 3128 | rep, vop = voxel.decimate_with_map(pts, cls, voxel_size) |
| 3141 | return FuseResult( | 3129 | return fuse.FuseResult( |
| 3142 | cloud=_map_cloud(pts, records), | 3130 | cloud=_map_cloud(pts, records), |
| 3143 | classification=cls, | 3131 | classification=cls, |
| 3144 | rep_index=rep, | 3132 | rep_index=rep, |
| 3145 | instances=[], | 3133 | instances=[], |
| 3373 | [[0.0, 1.0, 0.0], [0.3, 1.2, 0.1], [4.0, 1.0, 0.0], [9.0, 1.0, 0.0]] | 3361 | [[0.0, 1.0, 0.0], [0.3, 1.2, 0.1], [4.0, 1.0, 0.0], [9.0, 1.0, 0.0]] |
| 3374 | ) | 3362 | ) |
| 3375 | _write_run3(seg_dir / "a_run3_points.npz", pts) | 3363 | _write_run3(seg_dir / "a_run3_points.npz", pts) |
| 3376 | 3364 | ||
| 3377 | result = fuse_segment( | 3365 | result = fuse.fuse_segment( |
| 3378 | seg_dir=seg_dir, | 3366 | seg_dir=seg_dir, |
| 3379 | seg_name="078", | 3367 | seg_name="078", |
| 3380 | edges_dirs=[], | 3368 | edges_dirs=[], |
| 3381 | xml_path=None, | 3369 | xml_path=None, |
| 3456 | 3444 | ||
| 3457 | pts = np.empty((0, 3)) | 3445 | pts = np.empty((0, 3)) |
| 3458 | cls = np.empty(0, dtype=np.uint8) | 3446 | cls = np.empty(0, dtype=np.uint8) |
| 3459 | rep, vop = voxel.decimate_with_map(pts, cls, 1.0) | 3447 | rep, vop = voxel.decimate_with_map(pts, cls, 1.0) |
| 3460 | result = FuseResult( | 3448 | result = fuse.FuseResult( |
| 3461 | cloud=_map_cloud(pts, []), | 3449 | cloud=_map_cloud(pts, []), |
| 3462 | classification=cls, | 3450 | classification=cls, |
| 3463 | rep_index=rep, | 3451 | rep_index=rep, |
| 3464 | instances=[], | 3452 | instances=[], |
| 3528 | pts[0] = [10.0, 20.0, 30.0] | 3516 | pts[0] = [10.0, 20.0, 30.0] |
| 3529 | pts[1] = [10.009, 20.009, 30.009] | 3517 | pts[1] = [10.009, 20.009, 30.009] |
| 3530 | pts[2] = [10.011, 20.0, 30.0] | 3518 | pts[2] = [10.011, 20.0, 30.0] |
| 3531 | 3519 | ||
| 3532 | keys = voxel._voxel_keys(pts, Seg3dConfig().voxel_size_m) | 3520 | keys = voxel._voxel_keys(pts, config.Seg3dConfig().voxel_size_m) |
| 3533 | ijk = np.floor(pts / Seg3dConfig().voxel_size_m).astype(np.int64) | 3521 | ijk = np.floor(pts / config.Seg3dConfig().voxel_size_m).astype(np.int64) |
| 3534 | # Same voxel <=> same key, in both directions: the two labelings | 3522 | # Same voxel <=> same key, in both directions: the two labelings |
| 3535 | # differ only by a permutation, so their pairing has as many distinct | 3523 | # differ only by a permutation, so their pairing has as many distinct |
| 3536 | # values as either side alone (no merge, no split). | 3524 | # values as either side alone (no merge, no split). |
| 3537 | _, want = np.unique(ijk, axis=0, return_inverse=True) | 3525 | _, want = np.unique(ijk, axis=0, return_inverse=True) |
| 3586 | """Config with the tiny-cloud DTM floor, plus the test's overrides.""" | 3574 | """Config with the tiny-cloud DTM floor, plus the test's overrides.""" |
| 3587 | # Five asphalt rows are a ground surface here; production wants 1000. | 3575 | # Five asphalt rows are a ground surface here; production wants 1000. |
| 3588 | params = {"vegetation_min_ground_points": 4, **_VEG_LEGACY} | 3576 | params = {"vegetation_min_ground_points": 4, **_VEG_LEGACY} |
| 3589 | params.update(overrides) | 3577 | params.update(overrides) |
| 3590 | return Seg3dConfig(**params) | 3578 | return config.Seg3dConfig(**params) |
| 3591 | 3579 | ||
| 3592 | 3580 | ||
| 3593 | def _veg_segment(tmp_path, seg_name, *, extra=(), kept_mask=None): | 3581 | def _veg_segment(tmp_path, seg_name, *, extra=(), kept_mask=None): |
| 3594 | """Builds the vegetation scenario segment. | 3582 | """Builds the vegetation scenario segment. |
| 3635 | ) | 3623 | ) |
| 3636 | return seg_dir, edges, ground_dir | 3624 | return seg_dir, edges, ground_dir |
| 3637 | 3625 | ||
| 3638 | 3626 | ||
| 3639 | def _fuse_veg(tmp_path, seg_name, *, config=None, extra=(), kept_mask=None, | 3627 | def _fuse_veg(tmp_path, seg_name, *, config_override=None, extra=(), kept_mask=None, |
| 3640 | signs_dir=None, guardrail_dir=None): | 3628 | signs_dir=None, guardrail_dir=None): |
| 3641 | seg_dir, edges, ground_dir = _veg_segment( | 3629 | seg_dir, edges, ground_dir = _veg_segment( |
| 3642 | tmp_path, seg_name, extra=extra, kept_mask=kept_mask | 3630 | tmp_path, seg_name, extra=extra, kept_mask=kept_mask |
| 3643 | ) | 3631 | ) |
| 3644 | return fuse_segment( | 3632 | return fuse.fuse_segment( |
| 3645 | seg_dir=seg_dir, | 3633 | seg_dir=seg_dir, |
| 3646 | seg_name=seg_name, | 3634 | seg_name=seg_name, |
| 3647 | edges_dirs=[edges], | 3635 | edges_dirs=[edges], |
| 3648 | xml_path=None, | 3636 | xml_path=None, |
| 3649 | guardrail_masks_dir=guardrail_dir, | 3637 | guardrail_masks_dir=guardrail_dir, |
| 3650 | signs_masks_dir=signs_dir, | 3638 | signs_masks_dir=signs_dir, |
| 3651 | ground_masks_dir=ground_dir, | 3639 | ground_masks_dir=ground_dir, |
| 3652 | config=config or _veg_config(), | 3640 | config=config_override or _veg_config(), |
| 3653 | ) | 3641 | ) |
| 3654 | 3642 | ||
| 3655 | 3643 | ||
| 3656 | def _write_guardrail_mask(tmp_path, seg_name, instances, record_name="a"): | 3644 | def _write_guardrail_mask(tmp_path, seg_name, instances, record_name="a"): |
| 3711 | 3699 | ||
| 3712 | 3700 | ||
| 3713 | def test_fuse_segment_vegetation_tall_class_tree(tmp_path): | 3701 | def test_fuse_segment_vegetation_tall_class_tree(tmp_path): |
| 3714 | result = _fuse_veg( | 3702 | result = _fuse_veg( |
| 3715 | tmp_path, "081", config=_veg_config(vegetation_tall_class="tree") | 3703 | tmp_path, "081", config_override=_veg_config(vegetation_tall_class="tree") |
| 3716 | ) | 3704 | ) |
| 3717 | cls = result.classification | 3705 | cls = result.classification |
| 3718 | assert cls[5] == classes.BY_NAME["low_vegetation"].las_code | 3706 | assert cls[5] == classes.BY_NAME["low_vegetation"].las_code |
| 3719 | assert cls[6] == classes.BY_NAME["medium_vegetation"].las_code | 3707 | assert cls[6] == classes.BY_NAME["medium_vegetation"].las_code |
| 3722 | 3710 | ||
| 3723 | def test_fuse_segment_vegetation_tall_class_unclassified(tmp_path): | 3711 | def test_fuse_segment_vegetation_tall_class_unclassified(tmp_path): |
| 3724 | result = _fuse_veg( | 3712 | result = _fuse_veg( |
| 3725 | tmp_path, "082", | 3713 | tmp_path, "082", |
| 3726 | config=_veg_config(vegetation_tall_class="unclassified"), | 3714 | config_override=_veg_config(vegetation_tall_class="unclassified"), |
| 3727 | ) | 3715 | ) |
| 3728 | # "unclassified" LEAVES the tall band as it was rather than stripping it. | 3716 | # "unclassified" LEAVES the tall band as it was rather than stripping it. |
| 3729 | assert result.classification[7] == classes.UNCLASSIFIED_CODE | 3717 | assert result.classification[7] == classes.UNCLASSIFIED_CODE |
| 3730 | assert result.stats["guard_metrics"]["vegetation_tall_points"] == 1.0 | 3718 | assert result.stats["guard_metrics"]["vegetation_tall_points"] == 1.0 |
| 3732 | 3720 | ||
| 3733 | def test_fuse_segment_vegetation_corridor_rule_drops_the_median(tmp_path): | 3721 | def test_fuse_segment_vegetation_corridor_rule_drops_the_median(tmp_path): |
| 3734 | result = _fuse_veg( | 3722 | result = _fuse_veg( |
| 3735 | tmp_path, "083", | 3723 | tmp_path, "083", |
| 3736 | config=_veg_config(vegetation_asphalt_rule="corridor"), | 3724 | config_override=_veg_config(vegetation_asphalt_rule="corridor"), |
| 3737 | ) | 3725 | ) |
| 3738 | cls = result.classification | 3726 | cls = result.classification |
| 3739 | # Inside the polygon -> not vegetation, and the sweep takes it instead. | 3727 | # Inside the polygon -> not vegetation, and the sweep takes it instead. |
| 3740 | assert cls[10] == classes.BY_NAME["vehicle"].las_code | 3728 | assert cls[10] == classes.BY_NAME["vehicle"].las_code |
| 3760 | assert np.all(column.classification[rows] == medium) | 3748 | assert np.all(column.classification[rows] == medium) |
| 3761 | 3749 | ||
| 3762 | per_point = _fuse_veg( | 3750 | per_point = _fuse_veg( |
| 3763 | tmp_path, "085", extra=hedge, | 3751 | tmp_path, "085", extra=hedge, |
| 3764 | config=_veg_config(vegetation_band_mode="point"), | 3752 | config_override=_veg_config(vegetation_band_mode="point"), |
| 3765 | ) | 3753 | ) |
| 3766 | # Per point the same hedge grows a low skirt -- what the column rule is | 3754 | # Per point the same hedge grows a low skirt -- what the column rule is |
| 3767 | # there to prevent. | 3755 | # there to prevent. |
| 3768 | assert per_point.classification[11] == low | 3756 | assert per_point.classification[11] == low |
| 3775 | ground_code = classes.BY_NAME["ground"].las_code | 3763 | ground_code = classes.BY_NAME["ground"].las_code |
| 3776 | 3764 | ||
| 3777 | on = _fuse_veg( | 3765 | on = _fuse_veg( |
| 3778 | tmp_path, "086", kept_mask=kept, | 3766 | tmp_path, "086", kept_mask=kept, |
| 3779 | config=_veg_config(vegetation_from_ground=True), | 3767 | config_override=_veg_config(vegetation_from_ground=True), |
| 3780 | ) | 3768 | ) |
| 3781 | assert on.classification[5] == classes.BY_NAME["low_vegetation"].las_code | 3769 | assert on.classification[5] == classes.BY_NAME["low_vegetation"].las_code |
| 3782 | 3770 | ||
| 3783 | off = _fuse_veg( | 3771 | off = _fuse_veg( |
| 3784 | tmp_path, "087", kept_mask=kept, | 3772 | tmp_path, "087", kept_mask=kept, |
| 3785 | config=_veg_config(vegetation_from_ground=False), | 3773 | config_override=_veg_config(vegetation_from_ground=False), |
| 3786 | ) | 3774 | ) |
| 3787 | assert off.classification[5] == ground_code | 3775 | assert off.classification[5] == ground_code |
| 3788 | assert off.stats["guard_metrics"]["vegetation_candidates"] == 5.0 | 3776 | assert off.stats["guard_metrics"]["vegetation_candidates"] == 5.0 |
| 3789 | 3777 |
| 3797 | 3785 | ||
| 3798 | 3786 | ||
| 3799 | def test_fuse_segment_vegetation_disabled(tmp_path): | 3787 | def test_fuse_segment_vegetation_disabled(tmp_path): |
| 3800 | result = _fuse_veg( | 3788 | result = _fuse_veg( |
| 3801 | tmp_path, "088", config=_veg_config(vegetation_enabled=False) | 3789 | tmp_path, "088", config_override=_veg_config(vegetation_enabled=False) |
| 3802 | ) | 3790 | ) |
| 3803 | cls = result.classification | 3791 | cls = result.classification |
| 3804 | assert np.all(cls[5:9] == classes.UNCLASSIFIED_CODE) | 3792 | assert np.all(cls[5:9] == classes.UNCLASSIFIED_CODE) |
| 3805 | # A fuse-level skip carries the SAME metric keys as a run, all zero, | 3793 | # A fuse-level skip carries the SAME metric keys as a run, all zero, |
| 3826 | _write_edges( | 3814 | _write_edges( |
| 3827 | edges / "segment_089_edges.npz", | 3815 | edges / "segment_089_edges.npz", |
| 3828 | left=[[0, -4, 0], [4, -4, 0]], right=[[0, 4, 0], [4, 4, 0]], | 3816 | left=[[0, -4, 0], [4, -4, 0]], right=[[0, 4, 0], [4, 4, 0]], |
| 3829 | ) | 3817 | ) |
| 3830 | result = fuse_segment( | 3818 | result = fuse.fuse_segment( |
| 3831 | seg_dir=seg_dir, | 3819 | seg_dir=seg_dir, |
| 3832 | seg_name="089", | 3820 | seg_name="089", |
| 3833 | edges_dirs=[edges], | 3821 | edges_dirs=[edges], |
| 3834 | xml_path=None, | 3822 | xml_path=None, |
| 3841 | 3829 | ||
| 3842 | 3830 | ||
| 3843 | def test_fuse_segment_vegetation_skips_without_a_ground_surface(tmp_path): | 3831 | def test_fuse_segment_vegetation_skips_without_a_ground_surface(tmp_path): |
| 3844 | # The packaged floor (1000 hard points) against five asphalt rows. | 3832 | # The packaged floor (1000 hard points) against five asphalt rows. |
| 3845 | result = _fuse_veg(tmp_path, "090", config=Seg3dConfig()) | 3833 | result = _fuse_veg(tmp_path, "090", config_override=config.Seg3dConfig()) |
| 3846 | skip = [s for s in result.stats["skips"] if s.startswith("vegetation")] | 3834 | skip = [s for s in result.stats["skips"] if s.startswith("vegetation")] |
| 3847 | assert skip == ["vegetation: too few hard-surface points (5 < 1000)"] | 3835 | assert skip == ["vegetation: too few hard-surface points (5 < 1000)"] |
| 3848 | assert result.classification[5] == classes.UNCLASSIFIED_CODE | 3836 | assert result.classification[5] == classes.UNCLASSIFIED_CODE |
| 3849 | assert result.stats["guard_metrics"]["vegetation_candidates"] == 0.0 | 3837 | assert result.stats["guard_metrics"]["vegetation_candidates"] == 0.0 |
| 3877 | mask=[(0, list(range(11, 16)))], | 3865 | mask=[(0, list(range(11, 16)))], |
| 3878 | ) | 3866 | ) |
| 3879 | result = _fuse_veg( | 3867 | result = _fuse_veg( |
| 3880 | tmp_path, "092", extra=tree, signs_dir=signs, | 3868 | tmp_path, "092", extra=tree, signs_dir=signs, |
| 3881 | config=_veg_config(vegetation_tree_min_height_m=3.0), | 3869 | config_override=_veg_config(vegetation_tree_min_height_m=3.0), |
| 3882 | ) | 3870 | ) |
| 3883 | medium = classes.BY_NAME["medium_vegetation"].las_code | 3871 | medium = classes.BY_NAME["medium_vegetation"].las_code |
| 3884 | # The whole instance moves, never point by point. | 3872 | # The whole instance moves, never point by point. |
| 3885 | assert np.all(result.classification[11:16] == medium) | 3873 | assert np.all(result.classification[11:16] == medium) |
| 3895 | # stage shipped with, limiters or not. | 3883 | # stage shipped with, limiters or not. |
| 3896 | result = _fuse_veg( | 3884 | result = _fuse_veg( |
| 3897 | tmp_path, | 3885 | tmp_path, |
| 3898 | "089", | 3886 | "089", |
| 3899 | config=Seg3dConfig( | 3887 | config_override=config.Seg3dConfig( |
| 3900 | vegetation_min_ground_points=4, | 3888 | vegetation_min_ground_points=4, |
| 3901 | vegetation_green_rg_ratio=1.0, | 3889 | vegetation_green_rg_ratio=1.0, |
| 3902 | vegetation_asphalt_cell_m=0.5, | 3890 | vegetation_asphalt_cell_m=0.5, |
| 3903 | vegetation_asphalt_dilate_cells=1, | 3891 | vegetation_asphalt_dilate_cells=1, |
| 3936 | # points (so the column guard rejects nothing), and the scene has no | 3924 | # points (so the column guard rejects nothing), and the scene has no |
| 3937 | # barrier, so the corridor-barrier rule takes both in-corridor rows | 3925 | # barrier, so the corridor-barrier rule takes both in-corridor rows |
| 3938 | # instead and the sweep gets them. | 3926 | # instead and the sweep gets them. |
| 3939 | result = _fuse_veg( | 3927 | result = _fuse_veg( |
| 3940 | tmp_path, "090", config=Seg3dConfig(vegetation_min_ground_points=4) | 3928 | tmp_path, "090", config_override=config.Seg3dConfig(vegetation_min_ground_points=4) |
| 3941 | ) | 3929 | ) |
| 3942 | cls = result.classification | 3930 | cls = result.classification |
| 3943 | vehicle = classes.BY_NAME["vehicle"].las_code | 3931 | vehicle = classes.BY_NAME["vehicle"].las_code |
| 3944 | assert list(cls[5:9]) == [classes.UNCLASSIFIED_CODE] * 4 | 3932 | assert list(cls[5:9]) == [classes.UNCLASSIFIED_CODE] * 4 |
| 3980 | # leaves behind. With no barrier next to it the rule hands it back to | 3968 | # leaves behind. With no barrier next to it the rule hands it back to |
| 3981 | # the vehicle sweep. | 3969 | # the vehicle sweep. |
| 3982 | result = _fuse_veg( | 3970 | result = _fuse_veg( |
| 3983 | tmp_path, "093", | 3971 | tmp_path, "093", |
| 3984 | config=_veg_config(vegetation_corridor_rail_m=2.0), | 3972 | config_override=_veg_config(vegetation_corridor_rail_m=2.0), |
| 3985 | ) | 3973 | ) |
| 3986 | cls = result.classification | 3974 | cls = result.classification |
| 3987 | vehicle = classes.BY_NAME["vehicle"].las_code | 3975 | vehicle = classes.BY_NAME["vehicle"].las_code |
| 3988 | assert cls[10] == vehicle | 3976 | assert cls[10] == vehicle |
| 4004 | rail = [(2.5, -2.0, 0.5, GREY_RGB)] # row 11 | 3992 | rail = [(2.5, -2.0, 0.5, GREY_RGB)] # row 11 |
| 4005 | guardrails = _write_guardrail_mask(tmp_path, "094", [("w_beam", [11])]) | 3993 | guardrails = _write_guardrail_mask(tmp_path, "094", [("w_beam", [11])]) |
| 4006 | result = _fuse_veg( | 3994 | result = _fuse_veg( |
| 4007 | tmp_path, "094", extra=rail, guardrail_dir=guardrails, | 3995 | tmp_path, "094", extra=rail, guardrail_dir=guardrails, |
| 4008 | config=_veg_config(vegetation_corridor_rail_m=2.0), | 3996 | config_override=_veg_config(vegetation_corridor_rail_m=2.0), |
| 4009 | ) | 3997 | ) |
| 4010 | cls = result.classification | 3998 | cls = result.classification |
| 4011 | assert cls[11] == classes.BY_NAME["guardrail"].las_code | 3999 | assert cls[11] == classes.BY_NAME["guardrail"].las_code |
| 4012 | assert cls[10] == classes.BY_NAME["medium_vegetation"].las_code | 4000 | assert cls[10] == classes.BY_NAME["medium_vegetation"].las_code |
| 4027 | ] | 4015 | ] |
| 4028 | guardrails = _write_guardrail_mask(tmp_path, "095", [("w_beam", [11])]) | 4016 | guardrails = _write_guardrail_mask(tmp_path, "095", [("w_beam", [11])]) |
| 4029 | result = _fuse_veg( | 4017 | result = _fuse_veg( |
| 4030 | tmp_path, "095", extra=extra, guardrail_dir=guardrails, | 4018 | tmp_path, "095", extra=extra, guardrail_dir=guardrails, |
| 4031 | config=_veg_config( | 4019 | config_override=_veg_config( |
| 4032 | vegetation_corridor_rail_m=2.0, | 4020 | vegetation_corridor_rail_m=2.0, |
| 4033 | vegetation_corridor_max_height_m=0.5, | 4021 | vegetation_corridor_max_height_m=0.5, |
| 4034 | ), | 4022 | ), |
| 4035 | ) | 4023 | ) |
| 4053 | ] | 4041 | ] |
| 4054 | guardrails = _write_guardrail_mask(tmp_path, "096", [("w_beam", [11])]) | 4042 | guardrails = _write_guardrail_mask(tmp_path, "096", [("w_beam", [11])]) |
| 4055 | result = _fuse_veg( | 4043 | result = _fuse_veg( |
| 4056 | tmp_path, "096", extra=extra, guardrail_dir=guardrails, | 4044 | tmp_path, "096", extra=extra, guardrail_dir=guardrails, |
| 4057 | config=_veg_config( | 4045 | config_override=_veg_config( |
| 4058 | vegetation_corridor_rail_m=2.0, | 4046 | vegetation_corridor_rail_m=2.0, |
| 4059 | vegetation_corridor_max_height_m=0.0, | 4047 | vegetation_corridor_max_height_m=0.0, |
| 4060 | ), | 4048 | ), |
| 4061 | ) | 4049 | ) |
| 4068 | 4056 | ||
| 4069 | # --------------------------------------------------------------------------- # | 4057 | # --------------------------------------------------------------------------- # |
| 4070 | # vegetation: re-band ownership and instance identity (AI3D-373) | 4058 | # vegetation: re-band ownership and instance identity (AI3D-373) |
| 4071 | # --------------------------------------------------------------------------- # | 4059 | # --------------------------------------------------------------------------- # |
| 4072 | def _short_tree_with_a_support(tmp_path, seg_name, config): | 4060 | def _short_tree_with_a_support(tmp_path, seg_name, config_override): |
| 4073 | """Fuses a short detector tree whose last two rows a support took.""" | 4061 | """Fuses a short detector tree whose last two rows a support took.""" |
| 4074 | tree = [(8.0, 10.0, float(z), GREEN_RGB) for z in np.linspace(0.0, 1.0, 5)] | 4062 | tree = [(8.0, 10.0, float(z), GREEN_RGB) for z in np.linspace(0.0, 1.0, 5)] |
| 4075 | signs = tmp_path / f"signs_{seg_name}" | 4063 | signs = tmp_path / f"signs_{seg_name}" |
| 4076 | _write_signs_sidecars( | 4064 | _write_signs_sidecars( |
| 4082 | tmp_path, seg_name, [("guardrail_support", [14, 15])] | 4070 | tmp_path, seg_name, [("guardrail_support", [14, 15])] |
| 4083 | ) | 4071 | ) |
| 4084 | return _fuse_veg( | 4072 | return _fuse_veg( |
| 4085 | tmp_path, seg_name, extra=tree, signs_dir=signs, | 4073 | tmp_path, seg_name, extra=tree, signs_dir=signs, |
| 4086 | guardrail_dir=guardrails, config=config, | 4074 | guardrail_dir=guardrails, config_override=config_override, |
| 4087 | ) | 4075 | ) |
| 4088 | 4076 | ||
| 4089 | 4077 | ||
| 4090 | def test_fuse_segment_reband_leaves_rows_a_support_took_from_the_tree( | 4078 | def test_fuse_segment_reband_leaves_rows_a_support_took_from_the_tree( |
| 4177 | seg_dir / "a_run3_points.npz", pts, | 4165 | seg_dir / "a_run3_points.npz", pts, |
| 4178 | rgb=np.zeros((pts.shape[0], 3), dtype=np.uint16), | 4166 | rgb=np.zeros((pts.shape[0], 3), dtype=np.uint16), |
| 4179 | ) | 4167 | ) |
| 4180 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) | 4168 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) |
| 4181 | result = fuse_segment( | 4169 | result = fuse.fuse_segment( |
| 4182 | seg_dir=seg_dir, | 4170 | seg_dir=seg_dir, |
| 4183 | seg_name="101", | 4171 | seg_name="101", |
| 4184 | edges_dirs=[], | 4172 | edges_dirs=[], |
| 4185 | xml_path=None, | 4173 | xml_path=None, |
| 4207 | rgb = np.zeros((pts.shape[0], 3), dtype=np.uint16) | 4195 | rgb = np.zeros((pts.shape[0], 3), dtype=np.uint16) |
| 4208 | rgb[:, 0] = 30000 | 4196 | rgb[:, 0] = 30000 |
| 4209 | _write_run3(seg_dir / "a_run3_points.npz", pts, rgb=rgb) | 4197 | _write_run3(seg_dir / "a_run3_points.npz", pts, rgb=rgb) |
| 4210 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) | 4198 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) |
| 4211 | result = fuse_segment( | 4199 | result = fuse.fuse_segment( |
| 4212 | seg_dir=seg_dir, | 4200 | seg_dir=seg_dir, |
| 4213 | seg_name="102", | 4201 | seg_name="102", |
| 4214 | edges_dirs=[], | 4202 | edges_dirs=[], |
| 4215 | xml_path=None, | 4203 | xml_path=None, |
| 4230 | # a stage skip: without it a run that could not guard the carriageway | 4218 | # a stage skip: without it a run that could not guard the carriageway |
| 4231 | # looked exactly like one whose guards found nothing. | 4219 | # looked exactly like one whose guards found nothing. |
| 4232 | kept = [True] * 5 + [False] * 6 # the tablecloth carries the DTM here | 4220 | kept = [True] * 5 + [False] * 6 # the tablecloth carries the DTM here |
| 4233 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "103", kept_mask=kept) | 4221 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "103", kept_mask=kept) |
| 4234 | config = _veg_config( | 4222 | cfg = _veg_config( |
| 4235 | vegetation_corridor_rail_m=2.0, | 4223 | vegetation_corridor_rail_m=2.0, |
| 4236 | vegetation_corridor_max_height_m=0.5, | 4224 | vegetation_corridor_max_height_m=0.5, |
| 4237 | ) | 4225 | ) |
| 4238 | 4226 | ||
| 4239 | result = fuse_segment( | 4227 | result = fuse.fuse_segment( |
| 4240 | seg_dir=seg_dir, | 4228 | seg_dir=seg_dir, |
| 4241 | seg_name="103", | 4229 | seg_name="103", |
| 4242 | edges_dirs=[], | 4230 | edges_dirs=[], |
| 4243 | xml_path=None, | 4231 | xml_path=None, |
| 4244 | guardrail_masks_dir=None, | 4232 | guardrail_masks_dir=None, |
| 4245 | signs_masks_dir=None, | 4233 | signs_masks_dir=None, |
| 4246 | ground_masks_dir=ground_dir, | 4234 | ground_masks_dir=ground_dir, |
| 4247 | config=config, | 4235 | config=cfg, |
| 4248 | ) | 4236 | ) |
| 4249 | 4237 | ||
| 4250 | assert ( | 4238 | assert ( |
| 4251 | "vegetation: no corridor -> corridor guards inert" | 4239 | "vegetation: no corridor -> corridor guards inert" |
| 4271 | # corridor means no exclusion at all. | 4259 | # corridor means no exclusion at all. |
| 4272 | kept = [True] * 5 + [False] * 6 | 4260 | kept = [True] * 5 + [False] * 6 |
| 4273 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "104", kept_mask=kept) | 4261 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "104", kept_mask=kept) |
| 4274 | 4262 | ||
| 4275 | result = fuse_segment( | 4263 | result = fuse.fuse_segment( |
| 4276 | seg_dir=seg_dir, | 4264 | seg_dir=seg_dir, |
| 4277 | seg_name="104", | 4265 | seg_name="104", |
| 4278 | edges_dirs=[], | 4266 | edges_dirs=[], |
| 4279 | xml_path=None, | 4267 | xml_path=None, |
| 4300 | d = np.full(axy.shape[0], np.inf) | 4288 | d = np.full(axy.shape[0], np.inf) |
| 4301 | ok = np.zeros(axy.shape[0], dtype=bool) | 4289 | ok = np.zeros(axy.shape[0], dtype=bool) |
| 4302 | if vxyz.shape[0] == 0: | 4290 | if vxyz.shape[0] == 0: |
| 4303 | return d, ok | 4291 | return d, ok |
| 4304 | tree = cKDTree(vxyz[:, :2]) | 4292 | tree = spatial.cKDTree(vxyz[:, :2]) |
| 4305 | vz = vxyz[:, 2] | 4293 | vz = vxyz[:, 2] |
| 4306 | for idx, cand in enumerate(tree.query_ball_point(axy, r=xy_radius)): | 4294 | for idx, cand in enumerate(tree.query_ball_point(axy, r=xy_radius)): |
| 4307 | if not cand: | 4295 | if not cand: |
| 4308 | continue | 4296 | continue |
| 4321 | def test_nearest_gated_vertex_matches_the_per_point_reference(seed): | 4309 | def test_nearest_gated_vertex_matches_the_per_point_reference(seed): |
| 4322 | rng = np.random.default_rng(seed) | 4310 | rng = np.random.default_rng(seed) |
| 4323 | pts = rng.uniform(-1.0, 1.0, size=(120, 3)) | 4311 | pts = rng.uniform(-1.0, 1.0, size=(120, 3)) |
| 4324 | verts = rng.uniform(-1.0, 1.0, size=(40, 3)) | 4312 | verts = rng.uniform(-1.0, 1.0, size=(40, 3)) |
| 4325 | got_d, got_ok = fuse_mod._nearest_gated_vertex( | 4313 | got_d, got_ok = fuse._nearest_gated_vertex( |
| 4326 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 | 4314 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 |
| 4327 | ) | 4315 | ) |
| 4328 | want_d, want_ok = _reference_nearest_gated_vertex( | 4316 | want_d, want_ok = _reference_nearest_gated_vertex( |
| 4329 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 | 4317 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 |
| 4337 | """Blocking the radius join must not change a single distance.""" | 4325 | """Blocking the radius join must not change a single distance.""" |
| 4338 | rng = np.random.default_rng(7) | 4326 | rng = np.random.default_rng(7) |
| 4339 | pts = rng.uniform(-1.0, 1.0, size=(200, 3)) | 4327 | pts = rng.uniform(-1.0, 1.0, size=(200, 3)) |
| 4340 | verts = rng.uniform(-1.0, 1.0, size=(60, 3)) | 4328 | verts = rng.uniform(-1.0, 1.0, size=(60, 3)) |
| 4341 | one_d, one_ok = fuse_mod._nearest_gated_vertex( | 4329 | one_d, one_ok = fuse._nearest_gated_vertex( |
| 4342 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 | 4330 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 |
| 4343 | ) | 4331 | ) |
| 4344 | monkeypatch.setattr(fuse_mod, "_LINE_QUERY_BLOCK", 13) | 4332 | monkeypatch.setattr(fuse, "_LINE_QUERY_BLOCK", 13) |
| 4345 | many_d, many_ok = fuse_mod._nearest_gated_vertex( | 4333 | many_d, many_ok = fuse._nearest_gated_vertex( |
| 4346 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 | 4334 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 |
| 4347 | ) | 4335 | ) |
| 4348 | np.testing.assert_array_equal(one_ok, many_ok) | 4336 | np.testing.assert_array_equal(one_ok, many_ok) |
| 4349 | np.testing.assert_allclose(one_d, many_d) | 4337 | np.testing.assert_allclose(one_d, many_d) |
| 4359 | verts = lines_xml.LineVertices( | 4347 | verts = lines_xml.LineVertices( |
| 4360 | solid_xyz=solid_xyz, dashed_xyz=dashed_xyz | 4348 | solid_xyz=solid_xyz, dashed_xyz=dashed_xyz |
| 4361 | ) | 4349 | ) |
| 4362 | 4350 | ||
| 4363 | solid, dashed = _paint_lines( | 4351 | solid, dashed = fuse._paint_lines( |
| 4364 | pts, is_asphalt, verts, xy_radius=0.3, z_gate=0.4 | 4352 | pts, is_asphalt, verts, xy_radius=0.3, z_gate=0.4 |
| 4365 | ) | 4353 | ) |
| 4366 | 4354 | ||
| 4367 | asph_idx = np.nonzero(is_asphalt)[0] | 4355 | asph_idx = np.nonzero(is_asphalt)[0] |
| 4437 | dtype=np.uint8, | 4425 | dtype=np.uint8, |
| 4438 | ) | 4426 | ) |
| 4439 | 4427 | ||
| 4440 | got = start.copy() | 4428 | got = start.copy() |
| 4441 | fuse_mod._paint_mask_rows(got, rows, seg_classes) | 4429 | fuse._paint_mask_rows(got, rows, seg_classes) |
| 4442 | want = start.copy() | 4430 | want = start.copy() |
| 4443 | _reference_paint_mask_rows(want, rows, seg_classes) | 4431 | _reference_paint_mask_rows(want, rows, seg_classes) |
| 4444 | 4432 | ||
| 4445 | np.testing.assert_array_equal(got, want) | 4433 | np.testing.assert_array_equal(got, want) |
| 4455 | ], | 4443 | ], |
| 4456 | dtype=np.uint8, | 4444 | dtype=np.uint8, |
| 4457 | ) | 4445 | ) |
| 4458 | got = start.copy() | 4446 | got = start.copy() |
| 4459 | fuse_mod._paint_mask_rows( | 4447 | fuse._paint_mask_rows( |
| 4460 | got, | 4448 | got, |
| 4461 | np.array([0, 1, 2]), | 4449 | np.array([0, 1, 2]), |
| 4462 | np.array(["low_vegetation"] * 3, dtype=np.str_), | 4450 | np.array(["low_vegetation"] * 3, dtype=np.str_), |
| 4463 | ) | 4451 | ) |
| 4472 | 4460 | ||
| 4473 | 4461 | ||
| 4474 | def test_paint_mask_rows_gives_a_contested_row_to_the_support(): | 4462 | def test_paint_mask_rows_gives_a_contested_row_to_the_support(): |
| 4475 | got = np.array([classes.UNCLASSIFIED_CODE] * 2, dtype=np.uint8) | 4463 | got = np.array([classes.UNCLASSIFIED_CODE] * 2, dtype=np.uint8) |
| 4476 | fuse_mod._paint_mask_rows( | 4464 | fuse._paint_mask_rows( |
| 4477 | got, | 4465 | got, |
| 4478 | np.array([0, 0, 1, 1]), | 4466 | np.array([0, 0, 1, 1]), |
| 4479 | np.array( | 4467 | np.array( |
| 4480 | ["guardrail_support", "guardrail", "guardrail_top_rail", | 4468 | ["guardrail_support", "guardrail", "guardrail_top_rail", |
| 8 | (the road-surface key set, its recall metric) and the `SegmentCloud` shape the | 8 | (the road-surface key set, its recall metric) and the `SegmentCloud` shape the |
| 9 | fusion pipeline consumes. | 9 | fusion pipeline consumes. |
| 10 | """ | 10 | """ |
| 11 | 11 | ||
| 12 | from dataclasses import dataclass, field | 12 | import dataclasses |
| 13 | from pathlib import Path | 13 | import pathlib |
| 14 | 14 | ||
| 15 | import numpy as np | 15 | import numpy as np |
| 16 | from iolabs.common.point_hash import ( | 16 | from iolabs.common import point_hash, segment_points_io, segments |
| 17 | DEFAULT_UNITS_PER_M, | ||
| 18 | key_match_rate, | ||
| 19 | position_keys, | ||
| 20 | ) | ||
| 21 | from iolabs.common.segment_points_io import ( | ||
| 22 | GEOSHIFT_NAME, | ||
| 23 | RecordSpan, | ||
| 24 | load_run3_segment, | ||
| 25 | ) | ||
| 26 | from iolabs.common.segment_points_io import ( | ||
| 27 | load_geoshift as _load_geoshift_file, | ||
| 28 | ) | ||
| 29 | from iolabs.common.segments import segment_record_files | ||
| 30 | 17 | ||
| 31 | # Storage dtypes every run3 record is coerced to on load. These are the | 18 | # Storage dtypes every run3 record is coerced to on load. These are the |
| 32 | # historical seg3d coercions and they are load-bearing: `points` must stay | 19 | # historical seg3d coercions and they are load-bearing: `points` must stay |
| 33 | # float64 all the way into the hash rounding, and the uint16/int8 channels are | 20 | # float64 all the way into the hash rounding, and the uint16/int8 channels are |
| 61 | #: Filename suffix of the run4 road-surface records joined against run3. | 48 | #: Filename suffix of the run4 road-surface records joined against run3. |
| 62 | RUN4_SURFACE_SUFFIX = "_run4_road_surface.npz" | 49 | RUN4_SURFACE_SUFFIX = "_run4_road_surface.npz" |
| 63 | 50 | ||
| 64 | #: One input record's identity and row range within the concatenated cloud. | 51 | #: One input record's identity and row range within the concatenated cloud. |
| 65 | Record = RecordSpan | 52 | Record = segment_points_io.RecordSpan |
| 66 | 53 | ||
| 67 | 54 | ||
| 68 | def surface_keys( | 55 | def surface_keys( |
| 69 | xyz: np.ndarray, units_per_m: float = DEFAULT_UNITS_PER_M | 56 | xyz: np.ndarray, units_per_m: float = point_hash.DEFAULT_UNITS_PER_M |
| 70 | ) -> np.ndarray: | 57 | ) -> np.ndarray: |
| 71 | """Returns structured integer position keys for a `(N, 3)` coordinate array. | 58 | """Returns structured integer position keys for a `(N, 3)` coordinate array. |
| 72 | 59 | ||
| 73 | Thin wrapper over :func:`iolabs.common.point_hash.position_keys`, kept so | 60 | Thin wrapper over :func:`iolabs.common.point_hash.position_keys`, kept so |
| 81 | 68 | ||
| 82 | Returns: | 69 | Returns: |
| 83 | `(N,)` array of packed position keys, in input order. | 70 | `(N,)` array of packed position keys, in input order. |
| 84 | """ | 71 | """ |
| 85 | return position_keys(xyz, units_per_m) | 72 | return point_hash.position_keys(xyz, units_per_m) |
| 86 | 73 | ||
| 87 | 74 | ||
| 88 | @dataclass | 75 | @dataclasses.dataclass |
| 89 | class SegmentCloud: | 76 | class SegmentCloud: |
| 90 | """A segment's concatenated run3 point cloud plus run4 surface tagging. | 77 | """A segment's concatenated run3 point cloud plus run4 surface tagging. |
| 91 | 78 | ||
| 92 | Attributes: | 79 | Attributes: |
| 116 | scan_angle: np.ndarray | 103 | scan_angle: np.ndarray |
| 117 | is_surface: np.ndarray | 104 | is_surface: np.ndarray |
| 118 | records: list[Record] | 105 | records: list[Record] |
| 119 | surface_match_rate: float | 106 | surface_match_rate: float |
| 120 | extra: dict[str, np.ndarray] = field(default_factory=dict) | 107 | extra: dict[str, np.ndarray] = dataclasses.field(default_factory=dict) |
| 121 | 108 | ||
| 122 | @property | 109 | @property |
| 123 | def n(self) -> int: | 110 | def n(self) -> int: |
| 124 | """Number of points in the concatenated cloud.""" | 111 | """Number of points in the concatenated cloud.""" |
| 146 | """Returns the contributing record basenames, in concatenation order.""" | 133 | """Returns the contributing record basenames, in concatenation order.""" |
| 147 | return [r.name for r in self.records] | 134 | return [r.name for r in self.records] |
| 148 | 135 | ||
| 149 | 136 | ||
| 150 | def load_geoshift(seg_dir: Path) -> np.ndarray | None: | 137 | def load_geoshift(seg_dir: pathlib.Path) -> np.ndarray | None: |
| 151 | """Loads the per-dataset geoshift that run3 subtracted, if present. | 138 | """Loads the per-dataset geoshift that run3 subtracted, if present. |
| 152 | 139 | ||
| 153 | The upstream trajectory step writes run3 points as `source - geoshift` | 140 | The upstream trajectory step writes run3 points as `source - geoshift` |
| 154 | (the geoshift is the spline centroid) and records the offset in | 141 | (the geoshift is the spline centroid) and records the offset in |
| 172 | 159 | ||
| 173 | Raises: | 160 | Raises: |
| 174 | ValueError: The file exists but is not a `{"x", "y", "z"}` object. | 161 | ValueError: The file exists but is not a `{"x", "y", "z"}` object. |
| 175 | """ | 162 | """ |
| 176 | path = Path(seg_dir).parent / GEOSHIFT_NAME | 163 | path = pathlib.Path(seg_dir).parent / segment_points_io.GEOSHIFT_NAME |
| 177 | if not path.exists(): | 164 | if not path.exists(): |
| 178 | return None | 165 | return None |
| 179 | return _load_geoshift_file(path) | 166 | return segment_points_io.load_geoshift(path) |
| 180 | 167 | ||
| 181 | 168 | ||
| 182 | def _load_surface_key_set( | 169 | def _load_surface_key_set( |
| 183 | files: list[Path], units_per_m: float | 170 | files: list[pathlib.Path], units_per_m: float |
| 184 | ) -> np.ndarray | None: | 171 | ) -> np.ndarray | None: |
| 185 | """Builds the unique run4 road-surface key set for a segment. | 172 | """Builds the unique run4 road-surface key set for a segment. |
| 186 | 173 | ||
| 187 | Args: | 174 | Args: |
| 204 | return out | 191 | return out |
| 205 | 192 | ||
| 206 | 193 | ||
| 207 | def load_segment_cloud( | 194 | def load_segment_cloud( |
| 208 | seg_dir: Path, units_per_m: float = DEFAULT_UNITS_PER_M | 195 | seg_dir: pathlib.Path, units_per_m: float = point_hash.DEFAULT_UNITS_PER_M |
| 209 | ) -> SegmentCloud: | 196 | ) -> SegmentCloud: |
| 210 | """Loads and concatenates all run3 records, tagging run4 surface membership. | 197 | """Loads and concatenates all run3 records, tagging run4 surface membership. |
| 211 | 198 | ||
| 212 | Records are read in sorted glob order; record boundaries (name, offset, | 199 | Records are read in sorted glob order; record boundaries (name, offset, |
| 224 | Raises: | 211 | Raises: |
| 225 | FileNotFoundError: The directory holds no complete run3 record. | 212 | FileNotFoundError: The directory holds no complete run3 record. |
| 226 | ValueError: A record violates the point-record schema. | 213 | ValueError: A record violates the point-record schema. |
| 227 | """ | 214 | """ |
| 228 | seg_dir = Path(seg_dir) | 215 | seg_dir = pathlib.Path(seg_dir) |
| 229 | record, records = load_run3_segment( | 216 | record, records = segment_points_io.load_run3_segment( |
| 230 | seg_dir, target_dtypes=RUN3_TARGET_DTYPES | 217 | seg_dir, target_dtypes=RUN3_TARGET_DTYPES |
| 231 | ) | 218 | ) |
| 232 | 219 | ||
| 233 | points = record["points"] | 220 | points = record["points"] |
| 234 | surf_set = _load_surface_key_set( | 221 | surf_set = _load_surface_key_set( |
| 235 | segment_record_files(seg_dir, RUN4_SURFACE_SUFFIX), units_per_m | 222 | segments.segment_record_files(seg_dir, RUN4_SURFACE_SUFFIX), units_per_m |
| 236 | ) | 223 | ) |
| 237 | if surf_set is None: | 224 | if surf_set is None: |
| 238 | is_surface = np.zeros(points.shape[0], dtype=bool) | 225 | is_surface = np.zeros(points.shape[0], dtype=bool) |
| 239 | surface_match_rate = 0.0 | 226 | surface_match_rate = 0.0 |
| 240 | else: | 227 | else: |
| 241 | keys = surface_keys(points, units_per_m) | 228 | keys = surface_keys(points, units_per_m) |
| 242 | is_surface = np.isin(keys, surf_set, assume_unique=False) | 229 | is_surface = np.isin(keys, surf_set, assume_unique=False) |
| 243 | surface_match_rate = key_match_rate(surf_set, keys) | 230 | surface_match_rate = point_hash.key_match_rate(surf_set, keys) |
| 244 | del keys | 231 | del keys |
| 245 | 232 | ||
| 246 | return SegmentCloud( | 233 | return SegmentCloud( |
| 247 | points=points, | 234 | points=points, |
| 1 | """Synthetic micro-cloud tests for the fusion pipeline (no real data).""" | 1 | """Synthetic micro-cloud tests for the fusion pipeline (no real data).""" |
| 2 | 2 | ||
| 3 | import json | 3 | import json |
| 4 | import logging | 4 | import logging |
| 5 | import pathlib | ||
| 5 | 6 | ||
| 6 | import numpy as np | 7 | import numpy as np |
| 7 | import pytest | 8 | import pytest |
| 8 | from iolabs.common import crs | 9 | import shapely |
| 9 | from iolabs.common.segments import parse_segment_ids, parse_segment_names | 10 | from iolabs.common import crs, segment_points_io, segments |
| 10 | from scipy.spatial import cKDTree | 11 | from scipy import spatial |
| 11 | from shapely import Polygon | ||
| 12 | 12 | ||
| 13 | from iolabs_point_cloud_segmentation_3d import ( | 13 | from iolabs_point_cloud_segmentation_3d import ( |
| 14 | classes, | 14 | classes, |
| 15 | clusters, | 15 | clusters, |
| 16 | config, | ||
| 17 | fuse, | ||
| 16 | ground, | 18 | ground, |
| 17 | guardrail_json, | 19 | guardrail_json, |
| 18 | io_npz, | 20 | io_npz, |
| 19 | lines_xml, | 21 | lines_xml, |
| 22 | vegetation, | 24 | vegetation, |
| 23 | voxel, | 25 | voxel, |
| 24 | writer, | 26 | writer, |
| 25 | ) | 27 | ) |
| 26 | from iolabs_point_cloud_segmentation_3d import fuse as fuse_mod | ||
| 27 | from iolabs_point_cloud_segmentation_3d.config import ( | ||
| 28 | Seg3dConfig, | ||
| 29 | config_from_dict, | ||
| 30 | ) | ||
| 31 | from iolabs_point_cloud_segmentation_3d.fuse import ( | ||
| 32 | AlignmentError, | ||
| 33 | FuseResult, | ||
| 34 | _paint_lines, | ||
| 35 | fuse_segment, | ||
| 36 | paint_signs_from_json, | ||
| 37 | ) | ||
| 38 | 28 | ||
| 39 | # The legacy output base name for segment 007: the writers take a resolved | 29 | # The legacy output base name for segment 007: the writers take a resolved |
| 40 | # base (see `naming`), not a segment id. | 30 | # base (see `naming`), not a segment id. |
| 41 | BASE_007 = "segment_007_seg3d" | 31 | BASE_007 = "segment_007_seg3d" |
| 95 | Splitting them would let a tube voxel lose the count tie-break to the | 85 | Splitting them would let a tube voxel lose the count tie-break to the |
| 96 | rail it was carved out of, which is the exact failure the tier exists | 86 | rail it was carved out of, which is the exact failure the tier exists |
| 97 | to prevent. | 87 | to prevent. |
| 98 | """ | 88 | """ |
| 99 | cfg = Seg3dConfig(priority_support=9) | 89 | cfg = config.Seg3dConfig(priority_support=9) |
| 100 | plut = classes.priority_lut(cfg) | 90 | plut = classes.priority_lut(cfg) |
| 101 | assert plut[classes.BY_NAME["guardrail_support"].las_code] == 9 | 91 | assert plut[classes.BY_NAME["guardrail_support"].las_code] == 9 |
| 102 | assert plut[classes.BY_NAME["guardrail_top_rail"].las_code] == 9 | 92 | assert plut[classes.BY_NAME["guardrail_top_rail"].las_code] == 9 |
| 103 | assert plut[classes.BY_NAME["guardrail"].las_code] == 4 | 93 | assert plut[classes.BY_NAME["guardrail"].las_code] == 4 |
| 157 | """A record key the loader grows reaches consumers with no edit here.""" | 147 | """A record key the loader grows reaches consumers with no edit here.""" |
| 158 | seg = tmp_path / "segment_004" | 148 | seg = tmp_path / "segment_004" |
| 159 | seg.mkdir() | 149 | seg.mkdir() |
| 160 | _write_run3(seg / "a_run3_points.npz", np.zeros((3, 3))) | 150 | _write_run3(seg / "a_run3_points.npz", np.zeros((3, 3))) |
| 161 | real_load = io_npz.load_run3_segment | 151 | real_load = segment_points_io.load_run3_segment |
| 162 | 152 | ||
| 163 | def _with_future_key(seg_dir, **kwargs): | 153 | def _with_future_key(seg_dir, **kwargs): |
| 164 | record, records = real_load(seg_dir, **kwargs) | 154 | record, records = real_load(seg_dir, **kwargs) |
| 165 | record = dict(record) | 155 | record = dict(record) |
| 167 | record["points"].shape[0], dtype=np.uint8 | 157 | record["points"].shape[0], dtype=np.uint8 |
| 168 | ) | 158 | ) |
| 169 | return record, records | 159 | return record, records |
| 170 | 160 | ||
| 171 | monkeypatch.setattr(io_npz, "load_run3_segment", _with_future_key) | 161 | monkeypatch.setattr(segment_points_io, "load_run3_segment", _with_future_key) |
| 172 | cloud = io_npz.load_segment_cloud(seg) | 162 | cloud = io_npz.load_segment_cloud(seg) |
| 173 | 163 | ||
| 174 | expected = np.arange(3, dtype=np.uint8) | 164 | expected = np.arange(3, dtype=np.uint8) |
| 175 | np.testing.assert_array_equal(cloud.extra["future_channel"], expected) | 165 | np.testing.assert_array_equal(cloud.extra["future_channel"], expected) |
| 180 | # pavement / polygon classify | 170 | # pavement / polygon classify |
| 181 | # --------------------------------------------------------------------------- # | 171 | # --------------------------------------------------------------------------- # |
| 182 | def test_polygon_classify(): | 172 | def test_polygon_classify(): |
| 183 | corridor = pavement.Corridor( | 173 | corridor = pavement.Corridor( |
| 184 | polygon=Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), | 174 | polygon=shapely.Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), |
| 185 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), | 175 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), |
| 186 | ) | 176 | ) |
| 187 | pts = np.array( | 177 | pts = np.array( |
| 188 | [ | 178 | [ |
| 197 | 187 | ||
| 198 | 188 | ||
| 199 | def test_classify_above_corridor_ignores_surface_gate(): | 189 | def test_classify_above_corridor_ignores_surface_gate(): |
| 200 | corridor = pavement.Corridor( | 190 | corridor = pavement.Corridor( |
| 201 | polygon=Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), | 191 | polygon=shapely.Polygon([(0, 0), (10, 0), (10, 4), (0, 4)]), |
| 202 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), | 192 | vertices_xy=np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=float), |
| 203 | ) | 193 | ) |
| 204 | pts = np.array( | 194 | pts = np.array( |
| 205 | [ | 195 | [ |
| 220 | p = tmp_path / "segment_003_edges.npz" | 210 | p = tmp_path / "segment_003_edges.npz" |
| 221 | np.savez(p, left_polyline_points=left, right_polyline_points=right) | 211 | np.savez(p, left_polyline_points=left, right_polyline_points=right) |
| 222 | corr = pavement.build_corridor(p) | 212 | corr = pavement.build_corridor(p) |
| 223 | assert corr is not None | 213 | assert corr is not None |
| 224 | assert corr.polygon.contains(Polygon([(1, 1), (2, 1), (2, 3)]).centroid) | 214 | assert corr.polygon.contains(shapely.Polygon([(1, 1), (2, 1), (2, 3)]).centroid) |
| 225 | 215 | ||
| 226 | 216 | ||
| 227 | def test_build_corridor_bowtie_keeps_both_lobes(tmp_path): | 217 | def test_build_corridor_bowtie_keeps_both_lobes(tmp_path): |
| 228 | # Self-intersecting (bowtie) ring: buffer(0) would silently keep only | 218 | # Self-intersecting (bowtie) ring: buffer(0) would silently keep only |
| 257 | [0.10, 0.0, 1.0], # within XY but dz 1.0 > 0.5 -> no | 247 | [0.10, 0.0, 1.0], # within XY but dz 1.0 > 0.5 -> no |
| 258 | ] | 248 | ] |
| 259 | ) | 249 | ) |
| 260 | is_asphalt = np.ones(3, dtype=bool) | 250 | is_asphalt = np.ones(3, dtype=bool) |
| 261 | solid, dashed = _paint_lines(pts, is_asphalt, verts) | 251 | solid, dashed = fuse._paint_lines(pts, is_asphalt, verts) |
| 262 | np.testing.assert_array_equal(solid, [True, False, False]) | 252 | np.testing.assert_array_equal(solid, [True, False, False]) |
| 263 | assert not dashed.any() | 253 | assert not dashed.any() |
| 264 | 254 | ||
| 265 | 255 |
| 271 | solid_xyz=np.array([[0.01, 0.0, 1.0], [0.10, 0.0, 0.0]]), | 261 | solid_xyz=np.array([[0.01, 0.0, 1.0], [0.10, 0.0, 0.0]]), |
| 272 | dashed_xyz=np.empty((0, 3)), | 262 | dashed_xyz=np.empty((0, 3)), |
| 273 | ) | 263 | ) |
| 274 | pts = np.array([[0.0, 0.0, 0.0]]) | 264 | pts = np.array([[0.0, 0.0, 0.0]]) |
| 275 | solid, dashed = _paint_lines(pts, np.ones(1, bool), verts) | 265 | solid, dashed = fuse._paint_lines(pts, np.ones(1, bool), verts) |
| 276 | assert solid[0] | 266 | assert solid[0] |
| 277 | assert not dashed[0] | 267 | assert not dashed[0] |
| 278 | 268 | ||
| 279 | 269 |
| 282 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), | 272 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 283 | dashed_xyz=np.array([[0.0, 0.0, 0.0]]), | 273 | dashed_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 284 | ) | 274 | ) |
| 285 | pts = np.array([[0.05, 0.0, 0.0]]) | 275 | pts = np.array([[0.05, 0.0, 0.0]]) |
| 286 | solid, dashed = _paint_lines(pts, np.ones(1, bool), verts) | 276 | solid, dashed = fuse._paint_lines(pts, np.ones(1, bool), verts) |
| 287 | assert solid[0] and not dashed[0] | 277 | assert solid[0] and not dashed[0] |
| 288 | 278 | ||
| 289 | 279 | ||
| 290 | def test_paint_lines_only_asphalt(): | 280 | def test_paint_lines_only_asphalt(): |
| 292 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), | 282 | solid_xyz=np.array([[0.0, 0.0, 0.0]]), |
| 293 | dashed_xyz=np.empty((0, 3)), | 283 | dashed_xyz=np.empty((0, 3)), |
| 294 | ) | 284 | ) |
| 295 | pts = np.array([[0.0, 0.0, 0.0]]) | 285 | pts = np.array([[0.0, 0.0, 0.0]]) |
| 296 | solid, _ = _paint_lines(pts, np.zeros(1, bool), verts) | 286 | solid, _ = fuse._paint_lines(pts, np.zeros(1, bool), verts) |
| 297 | assert not solid.any() # not asphalt -> never painted | 287 | assert not solid.any() # not asphalt -> never painted |
| 298 | 288 | ||
| 299 | 289 | ||
| 300 | # --------------------------------------------------------------------------- # | 290 | # --------------------------------------------------------------------------- # |
| 509 | "<EndPoint><X>4.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 499 | "<EndPoint><X>4.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 510 | "</Line></Lines></Feature></HighwayData>" | 500 | "</Line></Lines></Feature></HighwayData>" |
| 511 | ) | 501 | ) |
| 512 | 502 | ||
| 513 | result = fuse_segment( | 503 | result = fuse.fuse_segment( |
| 514 | seg_dir=seg_dir, | 504 | seg_dir=seg_dir, |
| 515 | seg_name="066", | 505 | seg_name="066", |
| 516 | edges_dirs=[edges], | 506 | edges_dirs=[edges], |
| 517 | xml_path=xml_path, | 507 | xml_path=xml_path, |
| 558 | "<EndPoint><X>3.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 548 | "<EndPoint><X>3.0</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 559 | "</Line></Lines></Feature></HighwayData>" | 549 | "</Line></Lines></Feature></HighwayData>" |
| 560 | ) | 550 | ) |
| 561 | 551 | ||
| 562 | result = fuse_segment( | 552 | result = fuse.fuse_segment( |
| 563 | seg_dir=seg_dir, | 553 | seg_dir=seg_dir, |
| 564 | seg_name="067", | 554 | seg_name="067", |
| 565 | edges_dirs=[edges], | 555 | edges_dirs=[edges], |
| 566 | xml_path=xml_path, | 556 | xml_path=xml_path, |
| 597 | "<EndPoint><X>50001.0</X><Y>50000.0</Y><Z>0.0</Z></EndPoint>" | 587 | "<EndPoint><X>50001.0</X><Y>50000.0</Y><Z>0.0</Z></EndPoint>" |
| 598 | "</Line></Lines></Feature></HighwayData>" | 588 | "</Line></Lines></Feature></HighwayData>" |
| 599 | ) | 589 | ) |
| 600 | 590 | ||
| 601 | with pytest.raises(AlignmentError): | 591 | with pytest.raises(fuse.AlignmentError): |
| 602 | fuse_segment( | 592 | fuse.fuse_segment( |
| 603 | seg_dir=seg_dir, | 593 | seg_dir=seg_dir, |
| 604 | seg_name="068", | 594 | seg_name="068", |
| 605 | edges_dirs=[edges], | 595 | edges_dirs=[edges], |
| 606 | xml_path=xml_path, | 596 | xml_path=xml_path, |
| 637 | "<EndPoint><X>7.1</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" | 627 | "<EndPoint><X>7.1</X><Y>1.0</Y><Z>0.0</Z></EndPoint>" |
| 638 | "</Line></Lines></Feature></HighwayData>" | 628 | "</Line></Lines></Feature></HighwayData>" |
| 639 | ) | 629 | ) |
| 640 | 630 | ||
| 641 | result = fuse_segment( | 631 | result = fuse.fuse_segment( |
| 642 | seg_dir=seg_dir, | 632 | seg_dir=seg_dir, |
| 643 | seg_name="069", | 633 | seg_name="069", |
| 644 | edges_dirs=[edges], | 634 | edges_dirs=[edges], |
| 645 | xml_path=xml_path, | 635 | xml_path=xml_path, |
| 667 | 657 | ||
| 668 | import tempfile | 658 | import tempfile |
| 669 | 659 | ||
| 670 | with tempfile.TemporaryDirectory() as d: | 660 | with tempfile.TemporaryDirectory() as d: |
| 671 | from pathlib import Path | 661 | path = pathlib.Path(d) / "point_masks.npz" |
| 672 | |||
| 673 | path = Path(d) / "point_masks.npz" | ||
| 674 | np.savez( | 662 | np.savez( |
| 675 | path, | 663 | path, |
| 676 | record_names=np.array(["a_run3_points.npz", "b_run3_points.npz"]), | 664 | record_names=np.array(["a_run3_points.npz", "b_run3_points.npz"]), |
| 677 | record_id=np.array([0, 1, 1], dtype=np.uint16), | 665 | record_id=np.array([0, 1, 1], dtype=np.uint16), |
| 791 | instance_type=np.array(["w_beam", "guardrail_support"]), | 779 | instance_type=np.array(["w_beam", "guardrail_support"]), |
| 792 | instance_json_index=np.array([0, 1], dtype=np.int32), | 780 | instance_json_index=np.array([0, 1], dtype=np.int32), |
| 793 | ) | 781 | ) |
| 794 | 782 | ||
| 795 | result = fuse_segment( | 783 | result = fuse.fuse_segment( |
| 796 | seg_dir=seg_dir, | 784 | seg_dir=seg_dir, |
| 797 | seg_name="081", | 785 | seg_name="081", |
| 798 | edges_dirs=[], | 786 | edges_dirs=[], |
| 799 | xml_path=None, | 787 | xml_path=None, |
| 848 | instance_type=np.array(["delineator"]), | 836 | instance_type=np.array(["delineator"]), |
| 849 | instance_json_index=np.array([0], dtype=np.int32), | 837 | instance_json_index=np.array([0], dtype=np.int32), |
| 850 | ) | 838 | ) |
| 851 | 839 | ||
| 852 | result = fuse_segment( | 840 | result = fuse.fuse_segment( |
| 853 | seg_dir=seg_dir, | 841 | seg_dir=seg_dir, |
| 854 | seg_name="082", | 842 | seg_name="082", |
| 855 | edges_dirs=[], | 843 | edges_dirs=[], |
| 856 | xml_path=None, | 844 | xml_path=None, |
| 953 | detections = [ | 941 | detections = [ |
| 954 | _sign_detection(10.0, 10.0), | 942 | _sign_detection(10.0, 10.0), |
| 955 | _sign_detection(20.0, 10.0), | 943 | _sign_detection(20.0, 10.0), |
| 956 | ] | 944 | ] |
| 957 | instances, metrics = paint_signs_from_json( | 945 | instances, metrics = fuse.paint_signs_from_json( |
| 958 | pts, cls, detections, [covered], Seg3dConfig(), seg_name="001" | 946 | pts, cls, detections, [covered], config.Seg3dConfig(), seg_name="001" |
| 959 | ) | 947 | ) |
| 960 | assert len(instances) == 1 | 948 | assert len(instances) == 1 |
| 961 | inst = instances[0] | 949 | inst = instances[0] |
| 962 | assert inst.kind == "sign" | 950 | assert inst.kind == "sign" |
| 982 | json_index=0, | 970 | json_index=0, |
| 983 | local_index=0, | 971 | local_index=0, |
| 984 | global_rows=np.empty(0, dtype=np.int64), | 972 | global_rows=np.empty(0, dtype=np.int64), |
| 985 | ) | 973 | ) |
| 986 | instances, metrics = paint_signs_from_json( | 974 | instances, metrics = fuse.paint_signs_from_json( |
| 987 | pts, cls, [_sign_detection(10.0, 10.0)], [empty], Seg3dConfig() | 975 | pts, cls, [_sign_detection(10.0, 10.0)], [empty], config.Seg3dConfig() |
| 988 | ) | 976 | ) |
| 989 | assert len(instances) == 1 | 977 | assert len(instances) == 1 |
| 990 | assert instances[0].json_index == 0 | 978 | assert instances[0].json_index == 0 |
| 991 | assert instances[0].local_index == 1 | 979 | assert instances[0].local_index == 1 |
| 1010 | local_index=0, | 998 | local_index=0, |
| 1011 | global_rows=np.arange(20), | 999 | global_rows=np.arange(20), |
| 1012 | ) | 1000 | ) |
| 1013 | detections = [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0)] | 1001 | detections = [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0)] |
| 1014 | instances, metrics = paint_signs_from_json( | 1002 | instances, metrics = fuse.paint_signs_from_json( |
| 1015 | pts, cls, detections, [legacy], Seg3dConfig() | 1003 | pts, cls, detections, [legacy], config.Seg3dConfig() |
| 1016 | ) | 1004 | ) |
| 1017 | assert [i.json_index for i in instances] == [1] | 1005 | assert [i.json_index for i in instances] == [1] |
| 1018 | assert metrics["signs_json_painted"] == 1.0 | 1006 | assert metrics["signs_json_painted"] == 1.0 |
| 1019 | assert metrics["signs_json_skipped"] == 0.0 | 1007 | assert metrics["signs_json_skipped"] == 0.0 |
| 1067 | ) | 1055 | ) |
| 1068 | with caplog.at_level( | 1056 | with caplog.at_level( |
| 1069 | logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.fuse" | 1057 | logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.fuse" |
| 1070 | ): | 1058 | ): |
| 1071 | instances, metrics = paint_signs_from_json( | 1059 | instances, metrics = fuse.paint_signs_from_json( |
| 1072 | pts, cls, detections, mask, Seg3dConfig(), seg_name="023" | 1060 | pts, cls, detections, mask, config.Seg3dConfig(), seg_name="023" |
| 1073 | ) | 1061 | ) |
| 1074 | # The shifted half-post is painted; every mask-covered detection is | 1062 | # The shifted half-post is painted; every mask-covered detection is |
| 1075 | # recognised by geometry and not painted a second time. | 1063 | # recognised by geometry and not painted a second time. |
| 1076 | assert [i.json_index for i in instances] == [7] | 1064 | assert [i.json_index for i in instances] == [7] |
| 1101 | json_index=0, | 1089 | json_index=0, |
| 1102 | local_index=0, | 1090 | local_index=0, |
| 1103 | global_rows=np.empty(0, dtype=np.int64), | 1091 | global_rows=np.empty(0, dtype=np.int64), |
| 1104 | ) | 1092 | ) |
| 1105 | instances, _ = paint_signs_from_json( | 1093 | instances, _ = fuse.paint_signs_from_json( |
| 1106 | pts, cls, [_sign_detection(10.0, 10.0)], [rail], Seg3dConfig() | 1094 | pts, cls, [_sign_detection(10.0, 10.0)], [rail], config.Seg3dConfig() |
| 1107 | ) | 1095 | ) |
| 1108 | assert [i.json_index for i in instances] == [0] | 1096 | assert [i.json_index for i in instances] == [0] |
| 1109 | 1097 | ||
| 1110 | 1098 |
| 1121 | json_index=0, | 1109 | json_index=0, |
| 1122 | local_index=0, | 1110 | local_index=0, |
| 1123 | global_rows=np.arange(pts.shape[0]), | 1111 | global_rows=np.arange(pts.shape[0]), |
| 1124 | ) | 1112 | ) |
| 1125 | instances, metrics = paint_signs_from_json( | 1113 | instances, metrics = fuse.paint_signs_from_json( |
| 1126 | pts, cls, [_sign_detection(10.0, 10.0)], [covered], Seg3dConfig() | 1114 | pts, cls, [_sign_detection(10.0, 10.0)], [covered], config.Seg3dConfig() |
| 1127 | ) | 1115 | ) |
| 1128 | assert instances == [] | 1116 | assert instances == [] |
| 1129 | assert metrics["signs_json_painted"] == 0.0 | 1117 | assert metrics["signs_json_painted"] == 0.0 |
| 1130 | assert metrics["signs_json_skipped"] == 0.0 | 1118 | assert metrics["signs_json_skipped"] == 0.0 |
| 1145 | 4: "solid_line", | 1133 | 4: "solid_line", |
| 1146 | } | 1134 | } |
| 1147 | for row, name in keep.items(): | 1135 | for row, name in keep.items(): |
| 1148 | cls[row] = classes.BY_NAME[name].las_code | 1136 | cls[row] = classes.BY_NAME[name].las_code |
| 1149 | instances, _ = paint_signs_from_json( | 1137 | instances, _ = fuse.paint_signs_from_json( |
| 1150 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1138 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1151 | ) | 1139 | ) |
| 1152 | assert len(instances) == 1 | 1140 | assert len(instances) == 1 |
| 1153 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), | 1141 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), |
| 1154 | np.arange(5, 25)) | 1142 | np.arange(5, 25)) |
| 1160 | def test_paint_signs_from_json_skips_null_z_top(): | 1148 | def test_paint_signs_from_json_skips_null_z_top(): |
| 1161 | pts = _post_points(10.0, 10.0) | 1149 | pts = _post_points(10.0, 10.0) |
| 1162 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1150 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1163 | detections = [_sign_detection(10.0, 10.0, z_top=None)] | 1151 | detections = [_sign_detection(10.0, 10.0, z_top=None)] |
| 1164 | instances, metrics = paint_signs_from_json( | 1152 | instances, metrics = fuse.paint_signs_from_json( |
| 1165 | pts, cls, detections, [], Seg3dConfig() | 1153 | pts, cls, detections, [], config.Seg3dConfig() |
| 1166 | ) | 1154 | ) |
| 1167 | assert instances == [] | 1155 | assert instances == [] |
| 1168 | assert metrics["signs_json_skipped"] == 1.0 | 1156 | assert metrics["signs_json_skipped"] == 1.0 |
| 1169 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1157 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1175 | detections = [ | 1163 | detections = [ |
| 1176 | _sign_detection(10.0, 10.0, seg_type="tree", experimental=True), | 1164 | _sign_detection(10.0, 10.0, seg_type="tree", experimental=True), |
| 1177 | _sign_detection(10.0, 10.0, seg_type="field_stake"), | 1165 | _sign_detection(10.0, 10.0, seg_type="field_stake"), |
| 1178 | ] | 1166 | ] |
| 1179 | instances, metrics = paint_signs_from_json( | 1167 | instances, metrics = fuse.paint_signs_from_json( |
| 1180 | pts, cls, detections, [], Seg3dConfig() | 1168 | pts, cls, detections, [], config.Seg3dConfig() |
| 1181 | ) | 1169 | ) |
| 1182 | assert instances == [] | 1170 | assert instances == [] |
| 1183 | assert metrics["signs_json_skipped"] == 2.0 | 1171 | assert metrics["signs_json_skipped"] == 2.0 |
| 1184 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1172 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1238 | _sign_detection(10.0, 10.0), | 1226 | _sign_detection(10.0, 10.0), |
| 1239 | "junk", | 1227 | "junk", |
| 1240 | _sign_detection(20.0, 10.0), | 1228 | _sign_detection(20.0, 10.0), |
| 1241 | ] | 1229 | ] |
| 1242 | instances, metrics = paint_signs_from_json( | 1230 | instances, metrics = fuse.paint_signs_from_json( |
| 1243 | pts, cls, detections, [covered], Seg3dConfig() | 1231 | pts, cls, detections, [covered], config.Seg3dConfig() |
| 1244 | ) | 1232 | ) |
| 1245 | # Only the genuinely uncovered detection 0 is painted. | 1233 | # Only the genuinely uncovered detection 0 is painted. |
| 1246 | assert [i.json_index for i in instances] == [0] | 1234 | assert [i.json_index for i in instances] == [0] |
| 1247 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), | 1235 | np.testing.assert_array_equal(np.sort(instances[0].global_rows), |
| 1253 | # min_points=0 must not turn a detection that matched nothing into a | 1241 | # min_points=0 must not turn a detection that matched nothing into a |
| 1254 | # zero-row instance. | 1242 | # zero-row instance. |
| 1255 | pts = _post_points(500.0, 500.0, n=20) # nowhere near the detection | 1243 | pts = _post_points(500.0, 500.0, n=20) # nowhere near the detection |
| 1256 | cls = np.full(20, classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1244 | cls = np.full(20, classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1257 | config = config_from_dict({"signs_json_paint_min_points": 0}) | 1245 | cfg = config.config_from_dict({"signs_json_paint_min_points": 0}) |
| 1258 | instances, metrics = paint_signs_from_json( | 1246 | instances, metrics = fuse.paint_signs_from_json( |
| 1259 | pts, cls, [_sign_detection(10.0, 10.0)], [], config | 1247 | pts, cls, [_sign_detection(10.0, 10.0)], [], cfg |
| 1260 | ) | 1248 | ) |
| 1261 | assert instances == [] | 1249 | assert instances == [] |
| 1262 | assert metrics["signs_json_painted"] == 0.0 | 1250 | assert metrics["signs_json_painted"] == 0.0 |
| 1263 | assert metrics["signs_json_skipped"] == 1.0 | 1251 | assert metrics["signs_json_skipped"] == 1.0 |
| 1268 | # 5 points in the cylinder, below the default floor of 10 -> no phantom | 1256 | # 5 points in the cylinder, below the default floor of 10 -> no phantom |
| 1269 | # instance and nothing painted. | 1257 | # instance and nothing painted. |
| 1270 | pts = _post_points(10.0, 10.0, n=5) | 1258 | pts = _post_points(10.0, 10.0, n=5) |
| 1271 | cls = np.full(5, classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1259 | cls = np.full(5, classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1272 | instances, metrics = paint_signs_from_json( | 1260 | instances, metrics = fuse.paint_signs_from_json( |
| 1273 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1261 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1274 | ) | 1262 | ) |
| 1275 | assert instances == [] | 1263 | assert instances == [] |
| 1276 | assert metrics["signs_json_skipped"] == 1.0 | 1264 | assert metrics["signs_json_skipped"] == 1.0 |
| 1277 | assert np.all(cls == classes.UNCLASSIFIED_CODE) | 1265 | assert np.all(cls == classes.UNCLASSIFIED_CODE) |
| 1278 | # Lowering the floor paints the same detection. | 1266 | # Lowering the floor paints the same detection. |
| 1279 | instances, _ = paint_signs_from_json( | 1267 | instances, _ = fuse.paint_signs_from_json( |
| 1280 | pts, cls, [_sign_detection(10.0, 10.0)], [], | 1268 | pts, cls, [_sign_detection(10.0, 10.0)], [], |
| 1281 | config_from_dict({"signs_json_paint_min_points": 3}), | 1269 | config.config_from_dict({"signs_json_paint_min_points": 3}), |
| 1282 | ) | 1270 | ) |
| 1283 | assert len(instances) == 1 | 1271 | assert len(instances) == 1 |
| 1284 | 1272 | ||
| 1285 | 1273 |
| 1291 | outside_xy = _post_points(10.5, 10.0, n=12) # 0.5 m out -> outside | 1279 | outside_xy = _post_points(10.5, 10.0, n=12) # 0.5 m out -> outside |
| 1292 | above = _post_points(10.0, 10.0, n=12, z0=1.9, z1=3.0) # above z_top+0.30 | 1280 | above = _post_points(10.0, 10.0, n=12, z0=1.9, z1=3.0) # above z_top+0.30 |
| 1293 | pts = np.vstack([inside, far_xy, outside_xy, above]) | 1281 | pts = np.vstack([inside, far_xy, outside_xy, above]) |
| 1294 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1282 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1295 | instances, _ = paint_signs_from_json( | 1283 | instances, _ = fuse.paint_signs_from_json( |
| 1296 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1284 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1297 | ) | 1285 | ) |
| 1298 | assert len(instances) == 1 | 1286 | assert len(instances) == 1 |
| 1299 | painted = np.zeros(pts.shape[0], dtype=bool) | 1287 | painted = np.zeros(pts.shape[0], dtype=bool) |
| 1300 | painted[instances[0].global_rows] = True | 1288 | painted[instances[0].global_rows] = True |
| 1313 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1301 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1314 | gantry = _sign_detection( | 1302 | gantry = _sign_detection( |
| 1315 | 10.0, 10.0, "gantry_or_gate", footprint_m=[10.0, 0.5] | 1303 | 10.0, 10.0, "gantry_or_gate", footprint_m=[10.0, 0.5] |
| 1316 | ) | 1304 | ) |
| 1317 | instances, metrics = paint_signs_from_json( | 1305 | instances, metrics = fuse.paint_signs_from_json( |
| 1318 | pts, cls, [gantry], [], Seg3dConfig() | 1306 | pts, cls, [gantry], [], config.Seg3dConfig() |
| 1319 | ) | 1307 | ) |
| 1320 | assert instances == [] | 1308 | assert instances == [] |
| 1321 | assert metrics["signs_json_painted"] == 0.0 | 1309 | assert metrics["signs_json_painted"] == 0.0 |
| 1322 | assert metrics["signs_json_skipped"] == 1.0 | 1310 | assert metrics["signs_json_skipped"] == 1.0 |
| 1329 | below = _post_points(10.0, 10.0, n=12, z0=-0.14, z1=-0.01) | 1317 | below = _post_points(10.0, 10.0, n=12, z0=-0.14, z1=-0.01) |
| 1330 | above = _post_points(10.0, 10.0, n=12, z0=0.0, z1=1.5) | 1318 | above = _post_points(10.0, 10.0, n=12, z0=0.0, z1=1.5) |
| 1331 | pts = np.vstack([below, above]) | 1319 | pts = np.vstack([below, above]) |
| 1332 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1320 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1333 | instances, _ = paint_signs_from_json( | 1321 | instances, _ = fuse.paint_signs_from_json( |
| 1334 | pts, cls, [_sign_detection(10.0, 10.0)], [], Seg3dConfig() | 1322 | pts, cls, [_sign_detection(10.0, 10.0)], [], config.Seg3dConfig() |
| 1335 | ) | 1323 | ) |
| 1336 | assert len(instances) == 1 | 1324 | assert len(instances) == 1 |
| 1337 | np.testing.assert_array_equal( | 1325 | np.testing.assert_array_equal( |
| 1338 | np.sort(instances[0].global_rows), np.arange(12, 24) | 1326 | np.sort(instances[0].global_rows), np.arange(12, 24) |
| 1339 | ) | 1327 | ) |
| 1340 | # The knob still reaches under the ground when a run asks for it. | 1328 | # The knob still reaches under the ground when a run asks for it. |
| 1341 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) | 1329 | cls = np.full(pts.shape[0], classes.UNCLASSIFIED_CODE, dtype=np.uint8) |
| 1342 | instances, _ = paint_signs_from_json( | 1330 | instances, _ = fuse.paint_signs_from_json( |
| 1343 | pts, cls, [_sign_detection(10.0, 10.0)], [], | 1331 | pts, cls, [_sign_detection(10.0, 10.0)], [], |
| 1344 | config_from_dict({"signs_json_paint_z_pad_bottom_m": 0.15}), | 1332 | config.config_from_dict({"signs_json_paint_z_pad_bottom_m": 0.15}), |
| 1345 | ) | 1333 | ) |
| 1346 | np.testing.assert_array_equal( | 1334 | np.testing.assert_array_equal( |
| 1347 | np.sort(instances[0].global_rows), np.arange(24) | 1335 | np.sort(instances[0].global_rows), np.arange(24) |
| 1348 | ) | 1336 | ) |
| 1360 | signs, "080", | 1348 | signs, "080", |
| 1361 | [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0, "sign_post")], | 1349 | [_sign_detection(10.0, 10.0), _sign_detection(20.0, 10.0, "sign_post")], |
| 1362 | ) | 1350 | ) |
| 1363 | 1351 | ||
| 1364 | result = fuse_segment( | 1352 | result = fuse.fuse_segment( |
| 1365 | seg_dir=seg_dir, | 1353 | seg_dir=seg_dir, |
| 1366 | seg_name="080", | 1354 | seg_name="080", |
| 1367 | edges_dirs=[], | 1355 | edges_dirs=[], |
| 1368 | xml_path=None, | 1356 | xml_path=None, |
| 1401 | seg_dir = tmp_path / "segment_007" | 1389 | seg_dir = tmp_path / "segment_007" |
| 1402 | seg_dir.mkdir() | 1390 | seg_dir.mkdir() |
| 1403 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) | 1391 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) |
| 1404 | 1392 | ||
| 1405 | config = config_from_dict( | 1393 | cfg = config.config_from_dict( |
| 1406 | {"edge_extend_m": 12.5, "priority_detector": 7, "las_crs_epsg": 2056} | 1394 | {"edge_extend_m": 12.5, "priority_detector": 7, "las_crs_epsg": 2056} |
| 1407 | ) | 1395 | ) |
| 1408 | result = fuse_segment( | 1396 | result = fuse.fuse_segment( |
| 1409 | seg_dir=seg_dir, | 1397 | seg_dir=seg_dir, |
| 1410 | seg_name="007", | 1398 | seg_name="007", |
| 1411 | edges_dirs=[], | 1399 | edges_dirs=[], |
| 1412 | xml_path=None, | 1400 | xml_path=None, |
| 1413 | guardrail_masks_dir=None, | 1401 | guardrail_masks_dir=None, |
| 1414 | signs_masks_dir=None, | 1402 | signs_masks_dir=None, |
| 1415 | voxel=0.25, | 1403 | voxel=0.25, |
| 1416 | config=config, | 1404 | config=cfg, |
| 1417 | ) | 1405 | ) |
| 1418 | 1406 | ||
| 1419 | params = result.stats["params"] | 1407 | params = result.stats["params"] |
| 1420 | assert params["edge_extend_m"] == 12.5 | 1408 | assert params["edge_extend_m"] == 12.5 |
| 1421 | assert params["priority_detector"] == 7 | 1409 | assert params["priority_detector"] == 7 |
| 1422 | assert params["las_crs_epsg"] == 2056 | 1410 | assert params["las_crs_epsg"] == 2056 |
| 1423 | # `--voxel` beats `config.voxel_size_m`, and the params say so. | 1411 | # `--voxel` beats `config.voxel_size_m`, and the params say so. |
| 1424 | assert params["voxel_size_m"] == 0.25 | 1412 | assert params["voxel_size_m"] == 0.25 |
| 1425 | assert config.voxel_size_m != 0.25 | 1413 | assert cfg.voxel_size_m != 0.25 |
| 1426 | # Whatever ends up in the params has to survive the stats json. | 1414 | # Whatever ends up in the params has to survive the stats json. |
| 1427 | assert json.loads(json.dumps(params)) == params | 1415 | assert json.loads(json.dumps(params)) == params |
| 1428 | 1416 | ||
| 1429 | 1417 |
| 1441 | seg_dir = tmp_path / "segment_007" | 1429 | seg_dir = tmp_path / "segment_007" |
| 1442 | seg_dir.mkdir() | 1430 | seg_dir.mkdir() |
| 1443 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) | 1431 | _write_run3(seg_dir / "a_run3_points.npz", np.zeros((3, 3))) |
| 1444 | 1432 | ||
| 1445 | config = config_from_dict({"las_split": "none", "write_ply": False}) | 1433 | cfg = config.config_from_dict({"las_split": "none", "write_ply": False}) |
| 1446 | result = fuse_segment( | 1434 | result = fuse.fuse_segment( |
| 1447 | seg_dir=seg_dir, | 1435 | seg_dir=seg_dir, |
| 1448 | seg_name="007", | 1436 | seg_name="007", |
| 1449 | edges_dirs=[], | 1437 | edges_dirs=[], |
| 1450 | xml_path=None, | 1438 | xml_path=None, |
| 1451 | guardrail_masks_dir=None, | 1439 | guardrail_masks_dir=None, |
| 1452 | signs_masks_dir=None, | 1440 | signs_masks_dir=None, |
| 1453 | voxel=0.25, | 1441 | voxel=0.25, |
| 1454 | config=config, | 1442 | config=cfg, |
| 1455 | ) | 1443 | ) |
| 1456 | summary = { | 1444 | summary = { |
| 1457 | "segment": "007", | 1445 | "segment": "007", |
| 1458 | "peak_rss_gb": 0.1, | 1446 | "peak_rss_gb": 0.1, |
| 1459 | "params": result.stats["params"], | 1447 | "params": result.stats["params"], |
| 1460 | } | 1448 | } |
| 1461 | path = cli._write_run_summary( | 1449 | path = cli._write_run_summary( |
| 1462 | tmp_path / "out", config, [summary], | 1450 | tmp_path / "out", cfg, [summary], |
| 1463 | voxel_override=0.25, | 1451 | voxel_override=0.25, |
| 1464 | las_split_override="class", | 1452 | las_split_override="class", |
| 1465 | write_ply_override=True, | 1453 | write_ply_override=True, |
| 1466 | ) | 1454 | ) |
| 1489 | _write_signs_sidecars( | 1477 | _write_signs_sidecars( |
| 1490 | signs, "081", detections, mask=[(0, list(range(20)))] | 1478 | signs, "081", detections, mask=[(0, list(range(20)))] |
| 1491 | ) | 1479 | ) |
| 1492 | 1480 | ||
| 1493 | result = fuse_segment( | 1481 | result = fuse.fuse_segment( |
| 1494 | seg_dir=seg_dir, | 1482 | seg_dir=seg_dir, |
| 1495 | seg_name="081", | 1483 | seg_name="081", |
| 1496 | edges_dirs=[], | 1484 | edges_dirs=[], |
| 1497 | xml_path=None, | 1485 | xml_path=None, |
| 1535 | 1523 | ||
| 1536 | signs = tmp_path / "signs" | 1524 | signs = tmp_path / "signs" |
| 1537 | _write_signs_sidecars(signs, "083", [_sign_detection(10.0, 10.0)]) | 1525 | _write_signs_sidecars(signs, "083", [_sign_detection(10.0, 10.0)]) |
| 1538 | 1526 | ||
| 1539 | result = fuse_segment( | 1527 | result = fuse.fuse_segment( |
| 1540 | seg_dir=seg_dir, | 1528 | seg_dir=seg_dir, |
| 1541 | seg_name="083", | 1529 | seg_name="083", |
| 1542 | edges_dirs=[], | 1530 | edges_dirs=[], |
| 1543 | xml_path=None, | 1531 | xml_path=None, |
| 1558 | 1546 | ||
| 1559 | signs = tmp_path / "signs" | 1547 | signs = tmp_path / "signs" |
| 1560 | _write_signs_sidecars(signs, "082", [_sign_detection(10.0, 10.0)]) | 1548 | _write_signs_sidecars(signs, "082", [_sign_detection(10.0, 10.0)]) |
| 1561 | 1549 | ||
| 1562 | result = fuse_segment( | 1550 | result = fuse.fuse_segment( |
| 1563 | seg_dir=seg_dir, | 1551 | seg_dir=seg_dir, |
| 1564 | seg_name="082", | 1552 | seg_name="082", |
| 1565 | edges_dirs=[], | 1553 | edges_dirs=[], |
| 1566 | xml_path=None, | 1554 | xml_path=None, |
| 1567 | guardrail_masks_dir=None, | 1555 | guardrail_masks_dir=None, |
| 1568 | signs_masks_dir=signs, | 1556 | signs_masks_dir=signs, |
| 1569 | config=config_from_dict({"signs_json_paint_enabled": False}), | 1557 | config=config.config_from_dict({"signs_json_paint_enabled": False}), |
| 1570 | ) | 1558 | ) |
| 1571 | assert np.all(result.classification == classes.UNCLASSIFIED_CODE) | 1559 | assert np.all(result.classification == classes.UNCLASSIFIED_CODE) |
| 1572 | assert "signs_json_painted" not in result.stats["guard_metrics"] | 1560 | assert "signs_json_painted" not in result.stats["guard_metrics"] |
| 1573 | 1561 |
| 1589 | instance_type=np.array(["delineator"]), | 1577 | instance_type=np.array(["delineator"]), |
| 1590 | instance_json_index=np.array([0], dtype=np.int32), | 1578 | instance_json_index=np.array([0], dtype=np.int32), |
| 1591 | ) | 1579 | ) |
| 1592 | 1580 | ||
| 1593 | result = fuse_segment( | 1581 | result = fuse.fuse_segment( |
| 1594 | seg_dir=seg_dir, | 1582 | seg_dir=seg_dir, |
| 1595 | seg_name="084", | 1583 | seg_name="084", |
| 1596 | edges_dirs=[], | 1584 | edges_dirs=[], |
| 1597 | xml_path=None, | 1585 | xml_path=None, |
| 2102 | (mdir / "guardrails.json").write_text( | 2090 | (mdir / "guardrails.json").write_text( |
| 2103 | json.dumps({"guardrails": [_rail_entry(0), _support_entry(1)]}) | 2091 | json.dumps({"guardrails": [_rail_entry(0), _support_entry(1)]}) |
| 2104 | ) | 2092 | ) |
| 2105 | 2093 | ||
| 2106 | result = fuse_segment( | 2094 | result = fuse.fuse_segment( |
| 2107 | seg_dir=seg_dir, | 2095 | seg_dir=seg_dir, |
| 2108 | seg_name="082", | 2096 | seg_name="082", |
| 2109 | edges_dirs=[], | 2097 | edges_dirs=[], |
| 2110 | xml_path=None, | 2098 | xml_path=None, |
| 2144 | assert masks.guardrails_json_file(tmp_path / "gmasks", "084") == ( | 2132 | assert masks.guardrails_json_file(tmp_path / "gmasks", "084") == ( |
| 2145 | mdir / "guardrails.json" | 2133 | mdir / "guardrails.json" |
| 2146 | ) | 2134 | ) |
| 2147 | 2135 | ||
| 2148 | result = fuse_segment( | 2136 | result = fuse.fuse_segment( |
| 2149 | seg_dir=seg_dir, | 2137 | seg_dir=seg_dir, |
| 2150 | seg_name="084", | 2138 | seg_name="084", |
| 2151 | edges_dirs=[], | 2139 | edges_dirs=[], |
| 2152 | xml_path=None, | 2140 | xml_path=None, |
| 2179 | mdir = tmp_path / "gmasks" / "segment_084" | 2167 | mdir = tmp_path / "gmasks" / "segment_084" |
| 2180 | mdir.mkdir(parents=True) | 2168 | mdir.mkdir(parents=True) |
| 2181 | (mdir / "guardrails.json").write_text('{"guardrails": [{"id": 0,') | 2169 | (mdir / "guardrails.json").write_text('{"guardrails": [{"id": 0,') |
| 2182 | 2170 | ||
| 2183 | result = fuse_segment( | 2171 | result = fuse.fuse_segment( |
| 2184 | seg_dir=seg_dir, | 2172 | seg_dir=seg_dir, |
| 2185 | seg_name="084", | 2173 | seg_name="084", |
| 2186 | edges_dirs=[], | 2174 | edges_dirs=[], |
| 2187 | xml_path=None, | 2175 | xml_path=None, |
| 2227 | (mdir / "guardrails.json").write_text(json.dumps({"guardrails": [ | 2215 | (mdir / "guardrails.json").write_text(json.dumps({"guardrails": [ |
| 2228 | _rail_entry(0), _support_entry(1), _top_rail_entry(2), | 2216 | _rail_entry(0), _support_entry(1), _top_rail_entry(2), |
| 2229 | ]})) | 2217 | ]})) |
| 2230 | 2218 | ||
| 2231 | result = fuse_segment( | 2219 | result = fuse.fuse_segment( |
| 2232 | seg_dir=seg_dir, | 2220 | seg_dir=seg_dir, |
| 2233 | seg_name="083", | 2221 | seg_name="083", |
| 2234 | edges_dirs=[], | 2222 | edges_dirs=[], |
| 2235 | xml_path=None, | 2223 | xml_path=None, |
| 2370 | _write_tablecloth_mask( | 2358 | _write_tablecloth_mask( |
| 2371 | gm / "a_tablecloth_masks.npz", np.ones(6, dtype=bool) | 2359 | gm / "a_tablecloth_masks.npz", np.ones(6, dtype=bool) |
| 2372 | ) | 2360 | ) |
| 2373 | 2361 | ||
| 2374 | result = fuse_segment( | 2362 | result = fuse.fuse_segment( |
| 2375 | seg_dir=seg_dir, | 2363 | seg_dir=seg_dir, |
| 2376 | seg_name="070", | 2364 | seg_name="070", |
| 2377 | edges_dirs=[edges], | 2365 | edges_dirs=[edges], |
| 2378 | xml_path=None, | 2366 | xml_path=None, |
| 2430 | xml_path=None, | 2418 | xml_path=None, |
| 2431 | guardrail_masks_dir=None, | 2419 | guardrail_masks_dir=None, |
| 2432 | signs_masks_dir=None, | 2420 | signs_masks_dir=None, |
| 2433 | ) | 2421 | ) |
| 2434 | without = fuse_segment(**kwargs).stats["counts_full"] | 2422 | without = fuse.fuse_segment(**kwargs).stats["counts_full"] |
| 2435 | with_g = fuse_segment( | 2423 | with_g = fuse.fuse_segment( |
| 2436 | **kwargs, ground_masks_dir=tmp_path / "ground" | 2424 | **kwargs, ground_masks_dir=tmp_path / "ground" |
| 2437 | ).stats["counts_full"] | 2425 | ).stats["counts_full"] |
| 2438 | 2426 | ||
| 2439 | for name in ("asphalt", "solid_line", "dashed_line"): | 2427 | for name in ("asphalt", "solid_line", "dashed_line"): |
| 2451 | 2439 | ||
| 2452 | gm = tmp_path / "ground" / "segment_071" | 2440 | gm = tmp_path / "ground" / "segment_071" |
| 2453 | gm.mkdir(parents=True) # subdir exists but holds no mask files | 2441 | gm.mkdir(parents=True) # subdir exists but holds no mask files |
| 2454 | 2442 | ||
| 2455 | result = fuse_segment( | 2443 | result = fuse.fuse_segment( |
| 2456 | seg_dir=seg_dir, | 2444 | seg_dir=seg_dir, |
| 2457 | seg_name="071", | 2445 | seg_name="071", |
| 2458 | edges_dirs=[], | 2446 | edges_dirs=[], |
| 2459 | xml_path=None, | 2447 | xml_path=None, |
| 2493 | 2481 | ||
| 2494 | def test_fuse_segment_vehicle_paints_unclassified_inside_corridor(tmp_path): | 2482 | def test_fuse_segment_vehicle_paints_unclassified_inside_corridor(tmp_path): |
| 2495 | seg_dir, edges = _vehicle_segment(tmp_path, "073") | 2483 | seg_dir, edges = _vehicle_segment(tmp_path, "073") |
| 2496 | 2484 | ||
| 2497 | result = fuse_segment( | 2485 | result = fuse.fuse_segment( |
| 2498 | seg_dir=seg_dir, | 2486 | seg_dir=seg_dir, |
| 2499 | seg_name="073", | 2487 | seg_name="073", |
| 2500 | edges_dirs=[edges], | 2488 | edges_dirs=[edges], |
| 2501 | xml_path=None, | 2489 | xml_path=None, |
| 2552 | gm / "a_tablecloth_masks.npz", | 2540 | gm / "a_tablecloth_masks.npz", |
| 2553 | [True] * 7 + [False, False], | 2541 | [True] * 7 + [False, False], |
| 2554 | ) | 2542 | ) |
| 2555 | 2543 | ||
| 2556 | result = fuse_segment( | 2544 | result = fuse.fuse_segment( |
| 2557 | seg_dir=seg_dir, | 2545 | seg_dir=seg_dir, |
| 2558 | seg_name="074", | 2546 | seg_name="074", |
| 2559 | edges_dirs=[edges], | 2547 | edges_dirs=[edges], |
| 2560 | xml_path=None, | 2548 | xml_path=None, |
| 2573 | 2561 | ||
| 2574 | def test_fuse_segment_vehicle_skipped_without_corridor(tmp_path): | 2562 | def test_fuse_segment_vehicle_skipped_without_corridor(tmp_path): |
| 2575 | seg_dir, _ = _vehicle_segment(tmp_path, "075") | 2563 | seg_dir, _ = _vehicle_segment(tmp_path, "075") |
| 2576 | 2564 | ||
| 2577 | result = fuse_segment( | 2565 | result = fuse.fuse_segment( |
| 2578 | seg_dir=seg_dir, | 2566 | seg_dir=seg_dir, |
| 2579 | seg_name="075", | 2567 | seg_name="075", |
| 2580 | edges_dirs=[], | 2568 | edges_dirs=[], |
| 2581 | xml_path=None, | 2569 | xml_path=None, |
| 2593 | # so the corridor interior is the *un-painted* carriageway -- sweeping it | 2581 | # so the corridor interior is the *un-painted* carriageway -- sweeping it |
| 2594 | # would relabel the whole road as vehicles. | 2582 | # would relabel the whole road as vehicles. |
| 2595 | seg_dir, edges = _vehicle_segment(tmp_path, "076", with_run4=False) | 2583 | seg_dir, edges = _vehicle_segment(tmp_path, "076", with_run4=False) |
| 2596 | 2584 | ||
| 2597 | result = fuse_segment( | 2585 | result = fuse.fuse_segment( |
| 2598 | seg_dir=seg_dir, | 2586 | seg_dir=seg_dir, |
| 2599 | seg_name="076", | 2587 | seg_name="076", |
| 2600 | edges_dirs=[edges], | 2588 | edges_dirs=[edges], |
| 2601 | xml_path=None, | 2589 | xml_path=None, |
| 2610 | 2598 | ||
| 2611 | def test_fuse_segment_vehicle_disabled_by_config(tmp_path): | 2599 | def test_fuse_segment_vehicle_disabled_by_config(tmp_path): |
| 2612 | seg_dir, edges = _vehicle_segment(tmp_path, "077") | 2600 | seg_dir, edges = _vehicle_segment(tmp_path, "077") |
| 2613 | 2601 | ||
| 2614 | result = fuse_segment( | 2602 | result = fuse.fuse_segment( |
| 2615 | seg_dir=seg_dir, | 2603 | seg_dir=seg_dir, |
| 2616 | seg_name="077", | 2604 | seg_name="077", |
| 2617 | edges_dirs=[edges], | 2605 | edges_dirs=[edges], |
| 2618 | xml_path=None, | 2606 | xml_path=None, |
| 2619 | guardrail_masks_dir=None, | 2607 | guardrail_masks_dir=None, |
| 2620 | signs_masks_dir=None, | 2608 | signs_masks_dir=None, |
| 2621 | config=Seg3dConfig(vehicle_enabled=False), | 2609 | config=config.Seg3dConfig(vehicle_enabled=False), |
| 2622 | ) | 2610 | ) |
| 2623 | assert ( | 2611 | assert ( |
| 2624 | "vehicle: disabled by config (vehicle_enabled=false)" | 2612 | "vehicle: disabled by config (vehicle_enabled=false)" |
| 2625 | in result.stats["skips"] | 2613 | in result.stats["skips"] |
| 2702 | guard = classes.BY_NAME["guardrail"].las_code | 2690 | guard = classes.BY_NAME["guardrail"].las_code |
| 2703 | support = classes.BY_NAME["guardrail_support"].las_code | 2691 | support = classes.BY_NAME["guardrail_support"].las_code |
| 2704 | pts = np.array([[0.1, 0.1, 0.0], [0.2, 0.2, 0.0], [0.9, 0.9, 0.0]]) | 2692 | pts = np.array([[0.1, 0.1, 0.0], [0.2, 0.2, 0.0], [0.9, 0.9, 0.0]]) |
| 2705 | cls = np.array([guard, guard, support], dtype=np.uint8) | 2693 | cls = np.array([guard, guard, support], dtype=np.uint8) |
| 2706 | plut = classes.priority_lut(Seg3dConfig()) | 2694 | plut = classes.priority_lut(config.Seg3dConfig()) |
| 2707 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut) | 2695 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut) |
| 2708 | assert cls[rep[0]] == support | 2696 | assert cls[rep[0]] == support |
| 2709 | plut_flat = classes.priority_lut(Seg3dConfig(priority_support=4)) | 2697 | plut_flat = classes.priority_lut(config.Seg3dConfig(priority_support=4)) |
| 2710 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut_flat) | 2698 | rep = voxel.decimate(pts, cls, voxel=1.0, priorities=plut_flat) |
| 2711 | assert cls[rep[0]] == guard | 2699 | assert cls[rep[0]] == guard |
| 2712 | 2700 | ||
| 2713 | 2701 |
| 2762 | is_surface=np.zeros(pts.shape[0], bool), | 2750 | is_surface=np.zeros(pts.shape[0], bool), |
| 2763 | records=[io_npz.Record("a_run3_points.npz", 0, pts.shape[0])], | 2751 | records=[io_npz.Record("a_run3_points.npz", 0, pts.shape[0])], |
| 2764 | surface_match_rate=0.0, | 2752 | surface_match_rate=0.0, |
| 2765 | ) | 2753 | ) |
| 2766 | return FuseResult( | 2754 | return fuse.FuseResult( |
| 2767 | cloud=cloud, | 2755 | cloud=cloud, |
| 2768 | classification=cls, | 2756 | classification=cls, |
| 2769 | rep_index=np.arange(pts.shape[0]), | 2757 | rep_index=np.arange(pts.shape[0]), |
| 2770 | instances=[], | 2758 | instances=[], |
| 3058 | # --------------------------------------------------------------------------- # | 3046 | # --------------------------------------------------------------------------- # |
| 3059 | def test_parse_segments(): | 3047 | def test_parse_segments(): |
| 3060 | # seg3d semantics the CLI relies on: first-seen order, ascending-only | 3048 | # seg3d semantics the CLI relies on: first-seen order, ascending-only |
| 3061 | # ranges, duplicates dropped -- the shared parser's defaults. | 3049 | # ranges, duplicates dropped -- the shared parser's defaults. |
| 3062 | assert parse_segment_ids("066-069,038") == [66, 67, 68, 69, 38] | 3050 | assert segments.parse_segment_ids("066-069,038") == [66, 67, 68, 69, 38] |
| 3063 | assert parse_segment_ids("5") == [5] | 3051 | assert segments.parse_segment_ids("5") == [5] |
| 3064 | assert parse_segment_ids("1,1,2") == [1, 2] | 3052 | assert segments.parse_segment_ids("1,1,2") == [1, 2] |
| 3065 | assert parse_segment_ids("074-066") == [] | 3053 | assert segments.parse_segment_ids("074-066") == [] |
| 3066 | assert parse_segment_names("066-068,38") == ["066", "067", "068", "038"] | 3054 | assert segments.parse_segment_names("066-068,38") == ["066", "067", "068", "038"] |
| 3067 | 3055 | ||
| 3068 | 3056 | ||
| 3069 | def test_extend_polyline_reaches_beyond_ends(): | 3057 | def test_extend_polyline_reaches_beyond_ends(): |
| 3070 | xy = np.column_stack([np.linspace(0, 10, 11), np.zeros(11)]) | 3058 | xy = np.column_stack([np.linspace(0, 10, 11), np.zeros(11)]) |
| 3137 | io_npz.Record("a_run3_points.npz", 0, 3), | 3125 | io_npz.Record("a_run3_points.npz", 0, 3), |
| 3138 | io_npz.Record("b_run3_points.npz", 3, 3), | 3126 | io_npz.Record("b_run3_points.npz", 3, 3), |
| 3139 | ] | 3127 | ] |
| 3140 | rep, vop = voxel.decimate_with_map(pts, cls, voxel_size) | 3128 | rep, vop = voxel.decimate_with_map(pts, cls, voxel_size) |
| 3141 | return FuseResult( | 3129 | return fuse.FuseResult( |
| 3142 | cloud=_map_cloud(pts, records), | 3130 | cloud=_map_cloud(pts, records), |
| 3143 | classification=cls, | 3131 | classification=cls, |
| 3144 | rep_index=rep, | 3132 | rep_index=rep, |
| 3145 | instances=[], | 3133 | instances=[], |
| 3373 | [[0.0, 1.0, 0.0], [0.3, 1.2, 0.1], [4.0, 1.0, 0.0], [9.0, 1.0, 0.0]] | 3361 | [[0.0, 1.0, 0.0], [0.3, 1.2, 0.1], [4.0, 1.0, 0.0], [9.0, 1.0, 0.0]] |
| 3374 | ) | 3362 | ) |
| 3375 | _write_run3(seg_dir / "a_run3_points.npz", pts) | 3363 | _write_run3(seg_dir / "a_run3_points.npz", pts) |
| 3376 | 3364 | ||
| 3377 | result = fuse_segment( | 3365 | result = fuse.fuse_segment( |
| 3378 | seg_dir=seg_dir, | 3366 | seg_dir=seg_dir, |
| 3379 | seg_name="078", | 3367 | seg_name="078", |
| 3380 | edges_dirs=[], | 3368 | edges_dirs=[], |
| 3381 | xml_path=None, | 3369 | xml_path=None, |
| 3456 | 3444 | ||
| 3457 | pts = np.empty((0, 3)) | 3445 | pts = np.empty((0, 3)) |
| 3458 | cls = np.empty(0, dtype=np.uint8) | 3446 | cls = np.empty(0, dtype=np.uint8) |
| 3459 | rep, vop = voxel.decimate_with_map(pts, cls, 1.0) | 3447 | rep, vop = voxel.decimate_with_map(pts, cls, 1.0) |
| 3460 | result = FuseResult( | 3448 | result = fuse.FuseResult( |
| 3461 | cloud=_map_cloud(pts, []), | 3449 | cloud=_map_cloud(pts, []), |
| 3462 | classification=cls, | 3450 | classification=cls, |
| 3463 | rep_index=rep, | 3451 | rep_index=rep, |
| 3464 | instances=[], | 3452 | instances=[], |
| 3528 | pts[0] = [10.0, 20.0, 30.0] | 3516 | pts[0] = [10.0, 20.0, 30.0] |
| 3529 | pts[1] = [10.009, 20.009, 30.009] | 3517 | pts[1] = [10.009, 20.009, 30.009] |
| 3530 | pts[2] = [10.011, 20.0, 30.0] | 3518 | pts[2] = [10.011, 20.0, 30.0] |
| 3531 | 3519 | ||
| 3532 | keys = voxel._voxel_keys(pts, Seg3dConfig().voxel_size_m) | 3520 | keys = voxel._voxel_keys(pts, config.Seg3dConfig().voxel_size_m) |
| 3533 | ijk = np.floor(pts / Seg3dConfig().voxel_size_m).astype(np.int64) | 3521 | ijk = np.floor(pts / config.Seg3dConfig().voxel_size_m).astype(np.int64) |
| 3534 | # Same voxel <=> same key, in both directions: the two labelings | 3522 | # Same voxel <=> same key, in both directions: the two labelings |
| 3535 | # differ only by a permutation, so their pairing has as many distinct | 3523 | # differ only by a permutation, so their pairing has as many distinct |
| 3536 | # values as either side alone (no merge, no split). | 3524 | # values as either side alone (no merge, no split). |
| 3537 | _, want = np.unique(ijk, axis=0, return_inverse=True) | 3525 | _, want = np.unique(ijk, axis=0, return_inverse=True) |
| 3586 | """Config with the tiny-cloud DTM floor, plus the test's overrides.""" | 3574 | """Config with the tiny-cloud DTM floor, plus the test's overrides.""" |
| 3587 | # Five asphalt rows are a ground surface here; production wants 1000. | 3575 | # Five asphalt rows are a ground surface here; production wants 1000. |
| 3588 | params = {"vegetation_min_ground_points": 4, **_VEG_LEGACY} | 3576 | params = {"vegetation_min_ground_points": 4, **_VEG_LEGACY} |
| 3589 | params.update(overrides) | 3577 | params.update(overrides) |
| 3590 | return Seg3dConfig(**params) | 3578 | return config.Seg3dConfig(**params) |
| 3591 | 3579 | ||
| 3592 | 3580 | ||
| 3593 | def _veg_segment(tmp_path, seg_name, *, extra=(), kept_mask=None): | 3581 | def _veg_segment(tmp_path, seg_name, *, extra=(), kept_mask=None): |
| 3594 | """Builds the vegetation scenario segment. | 3582 | """Builds the vegetation scenario segment. |
| 3635 | ) | 3623 | ) |
| 3636 | return seg_dir, edges, ground_dir | 3624 | return seg_dir, edges, ground_dir |
| 3637 | 3625 | ||
| 3638 | 3626 | ||
| 3639 | def _fuse_veg(tmp_path, seg_name, *, config=None, extra=(), kept_mask=None, | 3627 | def _fuse_veg(tmp_path, seg_name, *, config_override=None, extra=(), kept_mask=None, |
| 3640 | signs_dir=None, guardrail_dir=None): | 3628 | signs_dir=None, guardrail_dir=None): |
| 3641 | seg_dir, edges, ground_dir = _veg_segment( | 3629 | seg_dir, edges, ground_dir = _veg_segment( |
| 3642 | tmp_path, seg_name, extra=extra, kept_mask=kept_mask | 3630 | tmp_path, seg_name, extra=extra, kept_mask=kept_mask |
| 3643 | ) | 3631 | ) |
| 3644 | return fuse_segment( | 3632 | return fuse.fuse_segment( |
| 3645 | seg_dir=seg_dir, | 3633 | seg_dir=seg_dir, |
| 3646 | seg_name=seg_name, | 3634 | seg_name=seg_name, |
| 3647 | edges_dirs=[edges], | 3635 | edges_dirs=[edges], |
| 3648 | xml_path=None, | 3636 | xml_path=None, |
| 3649 | guardrail_masks_dir=guardrail_dir, | 3637 | guardrail_masks_dir=guardrail_dir, |
| 3650 | signs_masks_dir=signs_dir, | 3638 | signs_masks_dir=signs_dir, |
| 3651 | ground_masks_dir=ground_dir, | 3639 | ground_masks_dir=ground_dir, |
| 3652 | config=config or _veg_config(), | 3640 | config=config_override or _veg_config(), |
| 3653 | ) | 3641 | ) |
| 3654 | 3642 | ||
| 3655 | 3643 | ||
| 3656 | def _write_guardrail_mask(tmp_path, seg_name, instances, record_name="a"): | 3644 | def _write_guardrail_mask(tmp_path, seg_name, instances, record_name="a"): |
| 3711 | 3699 | ||
| 3712 | 3700 | ||
| 3713 | def test_fuse_segment_vegetation_tall_class_tree(tmp_path): | 3701 | def test_fuse_segment_vegetation_tall_class_tree(tmp_path): |
| 3714 | result = _fuse_veg( | 3702 | result = _fuse_veg( |
| 3715 | tmp_path, "081", config=_veg_config(vegetation_tall_class="tree") | 3703 | tmp_path, "081", config_override=_veg_config(vegetation_tall_class="tree") |
| 3716 | ) | 3704 | ) |
| 3717 | cls = result.classification | 3705 | cls = result.classification |
| 3718 | assert cls[5] == classes.BY_NAME["low_vegetation"].las_code | 3706 | assert cls[5] == classes.BY_NAME["low_vegetation"].las_code |
| 3719 | assert cls[6] == classes.BY_NAME["medium_vegetation"].las_code | 3707 | assert cls[6] == classes.BY_NAME["medium_vegetation"].las_code |
| 3722 | 3710 | ||
| 3723 | def test_fuse_segment_vegetation_tall_class_unclassified(tmp_path): | 3711 | def test_fuse_segment_vegetation_tall_class_unclassified(tmp_path): |
| 3724 | result = _fuse_veg( | 3712 | result = _fuse_veg( |
| 3725 | tmp_path, "082", | 3713 | tmp_path, "082", |
| 3726 | config=_veg_config(vegetation_tall_class="unclassified"), | 3714 | config_override=_veg_config(vegetation_tall_class="unclassified"), |
| 3727 | ) | 3715 | ) |
| 3728 | # "unclassified" LEAVES the tall band as it was rather than stripping it. | 3716 | # "unclassified" LEAVES the tall band as it was rather than stripping it. |
| 3729 | assert result.classification[7] == classes.UNCLASSIFIED_CODE | 3717 | assert result.classification[7] == classes.UNCLASSIFIED_CODE |
| 3730 | assert result.stats["guard_metrics"]["vegetation_tall_points"] == 1.0 | 3718 | assert result.stats["guard_metrics"]["vegetation_tall_points"] == 1.0 |
| 3732 | 3720 | ||
| 3733 | def test_fuse_segment_vegetation_corridor_rule_drops_the_median(tmp_path): | 3721 | def test_fuse_segment_vegetation_corridor_rule_drops_the_median(tmp_path): |
| 3734 | result = _fuse_veg( | 3722 | result = _fuse_veg( |
| 3735 | tmp_path, "083", | 3723 | tmp_path, "083", |
| 3736 | config=_veg_config(vegetation_asphalt_rule="corridor"), | 3724 | config_override=_veg_config(vegetation_asphalt_rule="corridor"), |
| 3737 | ) | 3725 | ) |
| 3738 | cls = result.classification | 3726 | cls = result.classification |
| 3739 | # Inside the polygon -> not vegetation, and the sweep takes it instead. | 3727 | # Inside the polygon -> not vegetation, and the sweep takes it instead. |
| 3740 | assert cls[10] == classes.BY_NAME["vehicle"].las_code | 3728 | assert cls[10] == classes.BY_NAME["vehicle"].las_code |
| 3760 | assert np.all(column.classification[rows] == medium) | 3748 | assert np.all(column.classification[rows] == medium) |
| 3761 | 3749 | ||
| 3762 | per_point = _fuse_veg( | 3750 | per_point = _fuse_veg( |
| 3763 | tmp_path, "085", extra=hedge, | 3751 | tmp_path, "085", extra=hedge, |
| 3764 | config=_veg_config(vegetation_band_mode="point"), | 3752 | config_override=_veg_config(vegetation_band_mode="point"), |
| 3765 | ) | 3753 | ) |
| 3766 | # Per point the same hedge grows a low skirt -- what the column rule is | 3754 | # Per point the same hedge grows a low skirt -- what the column rule is |
| 3767 | # there to prevent. | 3755 | # there to prevent. |
| 3768 | assert per_point.classification[11] == low | 3756 | assert per_point.classification[11] == low |
| 3775 | ground_code = classes.BY_NAME["ground"].las_code | 3763 | ground_code = classes.BY_NAME["ground"].las_code |
| 3776 | 3764 | ||
| 3777 | on = _fuse_veg( | 3765 | on = _fuse_veg( |
| 3778 | tmp_path, "086", kept_mask=kept, | 3766 | tmp_path, "086", kept_mask=kept, |
| 3779 | config=_veg_config(vegetation_from_ground=True), | 3767 | config_override=_veg_config(vegetation_from_ground=True), |
| 3780 | ) | 3768 | ) |
| 3781 | assert on.classification[5] == classes.BY_NAME["low_vegetation"].las_code | 3769 | assert on.classification[5] == classes.BY_NAME["low_vegetation"].las_code |
| 3782 | 3770 | ||
| 3783 | off = _fuse_veg( | 3771 | off = _fuse_veg( |
| 3784 | tmp_path, "087", kept_mask=kept, | 3772 | tmp_path, "087", kept_mask=kept, |
| 3785 | config=_veg_config(vegetation_from_ground=False), | 3773 | config_override=_veg_config(vegetation_from_ground=False), |
| 3786 | ) | 3774 | ) |
| 3787 | assert off.classification[5] == ground_code | 3775 | assert off.classification[5] == ground_code |
| 3788 | assert off.stats["guard_metrics"]["vegetation_candidates"] == 5.0 | 3776 | assert off.stats["guard_metrics"]["vegetation_candidates"] == 5.0 |
| 3789 | 3777 |
| 3797 | 3785 | ||
| 3798 | 3786 | ||
| 3799 | def test_fuse_segment_vegetation_disabled(tmp_path): | 3787 | def test_fuse_segment_vegetation_disabled(tmp_path): |
| 3800 | result = _fuse_veg( | 3788 | result = _fuse_veg( |
| 3801 | tmp_path, "088", config=_veg_config(vegetation_enabled=False) | 3789 | tmp_path, "088", config_override=_veg_config(vegetation_enabled=False) |
| 3802 | ) | 3790 | ) |
| 3803 | cls = result.classification | 3791 | cls = result.classification |
| 3804 | assert np.all(cls[5:9] == classes.UNCLASSIFIED_CODE) | 3792 | assert np.all(cls[5:9] == classes.UNCLASSIFIED_CODE) |
| 3805 | # A fuse-level skip carries the SAME metric keys as a run, all zero, | 3793 | # A fuse-level skip carries the SAME metric keys as a run, all zero, |
| 3826 | _write_edges( | 3814 | _write_edges( |
| 3827 | edges / "segment_089_edges.npz", | 3815 | edges / "segment_089_edges.npz", |
| 3828 | left=[[0, -4, 0], [4, -4, 0]], right=[[0, 4, 0], [4, 4, 0]], | 3816 | left=[[0, -4, 0], [4, -4, 0]], right=[[0, 4, 0], [4, 4, 0]], |
| 3829 | ) | 3817 | ) |
| 3830 | result = fuse_segment( | 3818 | result = fuse.fuse_segment( |
| 3831 | seg_dir=seg_dir, | 3819 | seg_dir=seg_dir, |
| 3832 | seg_name="089", | 3820 | seg_name="089", |
| 3833 | edges_dirs=[edges], | 3821 | edges_dirs=[edges], |
| 3834 | xml_path=None, | 3822 | xml_path=None, |
| 3841 | 3829 | ||
| 3842 | 3830 | ||
| 3843 | def test_fuse_segment_vegetation_skips_without_a_ground_surface(tmp_path): | 3831 | def test_fuse_segment_vegetation_skips_without_a_ground_surface(tmp_path): |
| 3844 | # The packaged floor (1000 hard points) against five asphalt rows. | 3832 | # The packaged floor (1000 hard points) against five asphalt rows. |
| 3845 | result = _fuse_veg(tmp_path, "090", config=Seg3dConfig()) | 3833 | result = _fuse_veg(tmp_path, "090", config_override=config.Seg3dConfig()) |
| 3846 | skip = [s for s in result.stats["skips"] if s.startswith("vegetation")] | 3834 | skip = [s for s in result.stats["skips"] if s.startswith("vegetation")] |
| 3847 | assert skip == ["vegetation: too few hard-surface points (5 < 1000)"] | 3835 | assert skip == ["vegetation: too few hard-surface points (5 < 1000)"] |
| 3848 | assert result.classification[5] == classes.UNCLASSIFIED_CODE | 3836 | assert result.classification[5] == classes.UNCLASSIFIED_CODE |
| 3849 | assert result.stats["guard_metrics"]["vegetation_candidates"] == 0.0 | 3837 | assert result.stats["guard_metrics"]["vegetation_candidates"] == 0.0 |
| 3877 | mask=[(0, list(range(11, 16)))], | 3865 | mask=[(0, list(range(11, 16)))], |
| 3878 | ) | 3866 | ) |
| 3879 | result = _fuse_veg( | 3867 | result = _fuse_veg( |
| 3880 | tmp_path, "092", extra=tree, signs_dir=signs, | 3868 | tmp_path, "092", extra=tree, signs_dir=signs, |
| 3881 | config=_veg_config(vegetation_tree_min_height_m=3.0), | 3869 | config_override=_veg_config(vegetation_tree_min_height_m=3.0), |
| 3882 | ) | 3870 | ) |
| 3883 | medium = classes.BY_NAME["medium_vegetation"].las_code | 3871 | medium = classes.BY_NAME["medium_vegetation"].las_code |
| 3884 | # The whole instance moves, never point by point. | 3872 | # The whole instance moves, never point by point. |
| 3885 | assert np.all(result.classification[11:16] == medium) | 3873 | assert np.all(result.classification[11:16] == medium) |
| 3895 | # stage shipped with, limiters or not. | 3883 | # stage shipped with, limiters or not. |
| 3896 | result = _fuse_veg( | 3884 | result = _fuse_veg( |
| 3897 | tmp_path, | 3885 | tmp_path, |
| 3898 | "089", | 3886 | "089", |
| 3899 | config=Seg3dConfig( | 3887 | config_override=config.Seg3dConfig( |
| 3900 | vegetation_min_ground_points=4, | 3888 | vegetation_min_ground_points=4, |
| 3901 | vegetation_green_rg_ratio=1.0, | 3889 | vegetation_green_rg_ratio=1.0, |
| 3902 | vegetation_asphalt_cell_m=0.5, | 3890 | vegetation_asphalt_cell_m=0.5, |
| 3903 | vegetation_asphalt_dilate_cells=1, | 3891 | vegetation_asphalt_dilate_cells=1, |
| 3936 | # points (so the column guard rejects nothing), and the scene has no | 3924 | # points (so the column guard rejects nothing), and the scene has no |
| 3937 | # barrier, so the corridor-barrier rule takes both in-corridor rows | 3925 | # barrier, so the corridor-barrier rule takes both in-corridor rows |
| 3938 | # instead and the sweep gets them. | 3926 | # instead and the sweep gets them. |
| 3939 | result = _fuse_veg( | 3927 | result = _fuse_veg( |
| 3940 | tmp_path, "090", config=Seg3dConfig(vegetation_min_ground_points=4) | 3928 | tmp_path, "090", config_override=config.Seg3dConfig(vegetation_min_ground_points=4) |
| 3941 | ) | 3929 | ) |
| 3942 | cls = result.classification | 3930 | cls = result.classification |
| 3943 | vehicle = classes.BY_NAME["vehicle"].las_code | 3931 | vehicle = classes.BY_NAME["vehicle"].las_code |
| 3944 | assert list(cls[5:9]) == [classes.UNCLASSIFIED_CODE] * 4 | 3932 | assert list(cls[5:9]) == [classes.UNCLASSIFIED_CODE] * 4 |
| 3980 | # leaves behind. With no barrier next to it the rule hands it back to | 3968 | # leaves behind. With no barrier next to it the rule hands it back to |
| 3981 | # the vehicle sweep. | 3969 | # the vehicle sweep. |
| 3982 | result = _fuse_veg( | 3970 | result = _fuse_veg( |
| 3983 | tmp_path, "093", | 3971 | tmp_path, "093", |
| 3984 | config=_veg_config(vegetation_corridor_rail_m=2.0), | 3972 | config_override=_veg_config(vegetation_corridor_rail_m=2.0), |
| 3985 | ) | 3973 | ) |
| 3986 | cls = result.classification | 3974 | cls = result.classification |
| 3987 | vehicle = classes.BY_NAME["vehicle"].las_code | 3975 | vehicle = classes.BY_NAME["vehicle"].las_code |
| 3988 | assert cls[10] == vehicle | 3976 | assert cls[10] == vehicle |
| 4004 | rail = [(2.5, -2.0, 0.5, GREY_RGB)] # row 11 | 3992 | rail = [(2.5, -2.0, 0.5, GREY_RGB)] # row 11 |
| 4005 | guardrails = _write_guardrail_mask(tmp_path, "094", [("w_beam", [11])]) | 3993 | guardrails = _write_guardrail_mask(tmp_path, "094", [("w_beam", [11])]) |
| 4006 | result = _fuse_veg( | 3994 | result = _fuse_veg( |
| 4007 | tmp_path, "094", extra=rail, guardrail_dir=guardrails, | 3995 | tmp_path, "094", extra=rail, guardrail_dir=guardrails, |
| 4008 | config=_veg_config(vegetation_corridor_rail_m=2.0), | 3996 | config_override=_veg_config(vegetation_corridor_rail_m=2.0), |
| 4009 | ) | 3997 | ) |
| 4010 | cls = result.classification | 3998 | cls = result.classification |
| 4011 | assert cls[11] == classes.BY_NAME["guardrail"].las_code | 3999 | assert cls[11] == classes.BY_NAME["guardrail"].las_code |
| 4012 | assert cls[10] == classes.BY_NAME["medium_vegetation"].las_code | 4000 | assert cls[10] == classes.BY_NAME["medium_vegetation"].las_code |
| 4027 | ] | 4015 | ] |
| 4028 | guardrails = _write_guardrail_mask(tmp_path, "095", [("w_beam", [11])]) | 4016 | guardrails = _write_guardrail_mask(tmp_path, "095", [("w_beam", [11])]) |
| 4029 | result = _fuse_veg( | 4017 | result = _fuse_veg( |
| 4030 | tmp_path, "095", extra=extra, guardrail_dir=guardrails, | 4018 | tmp_path, "095", extra=extra, guardrail_dir=guardrails, |
| 4031 | config=_veg_config( | 4019 | config_override=_veg_config( |
| 4032 | vegetation_corridor_rail_m=2.0, | 4020 | vegetation_corridor_rail_m=2.0, |
| 4033 | vegetation_corridor_max_height_m=0.5, | 4021 | vegetation_corridor_max_height_m=0.5, |
| 4034 | ), | 4022 | ), |
| 4035 | ) | 4023 | ) |
| 4053 | ] | 4041 | ] |
| 4054 | guardrails = _write_guardrail_mask(tmp_path, "096", [("w_beam", [11])]) | 4042 | guardrails = _write_guardrail_mask(tmp_path, "096", [("w_beam", [11])]) |
| 4055 | result = _fuse_veg( | 4043 | result = _fuse_veg( |
| 4056 | tmp_path, "096", extra=extra, guardrail_dir=guardrails, | 4044 | tmp_path, "096", extra=extra, guardrail_dir=guardrails, |
| 4057 | config=_veg_config( | 4045 | config_override=_veg_config( |
| 4058 | vegetation_corridor_rail_m=2.0, | 4046 | vegetation_corridor_rail_m=2.0, |
| 4059 | vegetation_corridor_max_height_m=0.0, | 4047 | vegetation_corridor_max_height_m=0.0, |
| 4060 | ), | 4048 | ), |
| 4061 | ) | 4049 | ) |
| 4068 | 4056 | ||
| 4069 | # --------------------------------------------------------------------------- # | 4057 | # --------------------------------------------------------------------------- # |
| 4070 | # vegetation: re-band ownership and instance identity (AI3D-373) | 4058 | # vegetation: re-band ownership and instance identity (AI3D-373) |
| 4071 | # --------------------------------------------------------------------------- # | 4059 | # --------------------------------------------------------------------------- # |
| 4072 | def _short_tree_with_a_support(tmp_path, seg_name, config): | 4060 | def _short_tree_with_a_support(tmp_path, seg_name, config_override): |
| 4073 | """Fuses a short detector tree whose last two rows a support took.""" | 4061 | """Fuses a short detector tree whose last two rows a support took.""" |
| 4074 | tree = [(8.0, 10.0, float(z), GREEN_RGB) for z in np.linspace(0.0, 1.0, 5)] | 4062 | tree = [(8.0, 10.0, float(z), GREEN_RGB) for z in np.linspace(0.0, 1.0, 5)] |
| 4075 | signs = tmp_path / f"signs_{seg_name}" | 4063 | signs = tmp_path / f"signs_{seg_name}" |
| 4076 | _write_signs_sidecars( | 4064 | _write_signs_sidecars( |
| 4082 | tmp_path, seg_name, [("guardrail_support", [14, 15])] | 4070 | tmp_path, seg_name, [("guardrail_support", [14, 15])] |
| 4083 | ) | 4071 | ) |
| 4084 | return _fuse_veg( | 4072 | return _fuse_veg( |
| 4085 | tmp_path, seg_name, extra=tree, signs_dir=signs, | 4073 | tmp_path, seg_name, extra=tree, signs_dir=signs, |
| 4086 | guardrail_dir=guardrails, config=config, | 4074 | guardrail_dir=guardrails, config_override=config_override, |
| 4087 | ) | 4075 | ) |
| 4088 | 4076 | ||
| 4089 | 4077 | ||
| 4090 | def test_fuse_segment_reband_leaves_rows_a_support_took_from_the_tree( | 4078 | def test_fuse_segment_reband_leaves_rows_a_support_took_from_the_tree( |
| 4177 | seg_dir / "a_run3_points.npz", pts, | 4165 | seg_dir / "a_run3_points.npz", pts, |
| 4178 | rgb=np.zeros((pts.shape[0], 3), dtype=np.uint16), | 4166 | rgb=np.zeros((pts.shape[0], 3), dtype=np.uint16), |
| 4179 | ) | 4167 | ) |
| 4180 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) | 4168 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) |
| 4181 | result = fuse_segment( | 4169 | result = fuse.fuse_segment( |
| 4182 | seg_dir=seg_dir, | 4170 | seg_dir=seg_dir, |
| 4183 | seg_name="101", | 4171 | seg_name="101", |
| 4184 | edges_dirs=[], | 4172 | edges_dirs=[], |
| 4185 | xml_path=None, | 4173 | xml_path=None, |
| 4207 | rgb = np.zeros((pts.shape[0], 3), dtype=np.uint16) | 4195 | rgb = np.zeros((pts.shape[0], 3), dtype=np.uint16) |
| 4208 | rgb[:, 0] = 30000 | 4196 | rgb[:, 0] = 30000 |
| 4209 | _write_run3(seg_dir / "a_run3_points.npz", pts, rgb=rgb) | 4197 | _write_run3(seg_dir / "a_run3_points.npz", pts, rgb=rgb) |
| 4210 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) | 4198 | _write_run4(seg_dir / "a_run4_road_surface.npz", pts[:5]) |
| 4211 | result = fuse_segment( | 4199 | result = fuse.fuse_segment( |
| 4212 | seg_dir=seg_dir, | 4200 | seg_dir=seg_dir, |
| 4213 | seg_name="102", | 4201 | seg_name="102", |
| 4214 | edges_dirs=[], | 4202 | edges_dirs=[], |
| 4215 | xml_path=None, | 4203 | xml_path=None, |
| 4230 | # a stage skip: without it a run that could not guard the carriageway | 4218 | # a stage skip: without it a run that could not guard the carriageway |
| 4231 | # looked exactly like one whose guards found nothing. | 4219 | # looked exactly like one whose guards found nothing. |
| 4232 | kept = [True] * 5 + [False] * 6 # the tablecloth carries the DTM here | 4220 | kept = [True] * 5 + [False] * 6 # the tablecloth carries the DTM here |
| 4233 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "103", kept_mask=kept) | 4221 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "103", kept_mask=kept) |
| 4234 | config = _veg_config( | 4222 | cfg = _veg_config( |
| 4235 | vegetation_corridor_rail_m=2.0, | 4223 | vegetation_corridor_rail_m=2.0, |
| 4236 | vegetation_corridor_max_height_m=0.5, | 4224 | vegetation_corridor_max_height_m=0.5, |
| 4237 | ) | 4225 | ) |
| 4238 | 4226 | ||
| 4239 | result = fuse_segment( | 4227 | result = fuse.fuse_segment( |
| 4240 | seg_dir=seg_dir, | 4228 | seg_dir=seg_dir, |
| 4241 | seg_name="103", | 4229 | seg_name="103", |
| 4242 | edges_dirs=[], | 4230 | edges_dirs=[], |
| 4243 | xml_path=None, | 4231 | xml_path=None, |
| 4244 | guardrail_masks_dir=None, | 4232 | guardrail_masks_dir=None, |
| 4245 | signs_masks_dir=None, | 4233 | signs_masks_dir=None, |
| 4246 | ground_masks_dir=ground_dir, | 4234 | ground_masks_dir=ground_dir, |
| 4247 | config=config, | 4235 | config=cfg, |
| 4248 | ) | 4236 | ) |
| 4249 | 4237 | ||
| 4250 | assert ( | 4238 | assert ( |
| 4251 | "vegetation: no corridor -> corridor guards inert" | 4239 | "vegetation: no corridor -> corridor guards inert" |
| 4271 | # corridor means no exclusion at all. | 4259 | # corridor means no exclusion at all. |
| 4272 | kept = [True] * 5 + [False] * 6 | 4260 | kept = [True] * 5 + [False] * 6 |
| 4273 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "104", kept_mask=kept) | 4261 | seg_dir, _, ground_dir = _veg_segment(tmp_path, "104", kept_mask=kept) |
| 4274 | 4262 | ||
| 4275 | result = fuse_segment( | 4263 | result = fuse.fuse_segment( |
| 4276 | seg_dir=seg_dir, | 4264 | seg_dir=seg_dir, |
| 4277 | seg_name="104", | 4265 | seg_name="104", |
| 4278 | edges_dirs=[], | 4266 | edges_dirs=[], |
| 4279 | xml_path=None, | 4267 | xml_path=None, |
| 4300 | d = np.full(axy.shape[0], np.inf) | 4288 | d = np.full(axy.shape[0], np.inf) |
| 4301 | ok = np.zeros(axy.shape[0], dtype=bool) | 4289 | ok = np.zeros(axy.shape[0], dtype=bool) |
| 4302 | if vxyz.shape[0] == 0: | 4290 | if vxyz.shape[0] == 0: |
| 4303 | return d, ok | 4291 | return d, ok |
| 4304 | tree = cKDTree(vxyz[:, :2]) | 4292 | tree = spatial.cKDTree(vxyz[:, :2]) |
| 4305 | vz = vxyz[:, 2] | 4293 | vz = vxyz[:, 2] |
| 4306 | for idx, cand in enumerate(tree.query_ball_point(axy, r=xy_radius)): | 4294 | for idx, cand in enumerate(tree.query_ball_point(axy, r=xy_radius)): |
| 4307 | if not cand: | 4295 | if not cand: |
| 4308 | continue | 4296 | continue |
| 4321 | def test_nearest_gated_vertex_matches_the_per_point_reference(seed): | 4309 | def test_nearest_gated_vertex_matches_the_per_point_reference(seed): |
| 4322 | rng = np.random.default_rng(seed) | 4310 | rng = np.random.default_rng(seed) |
| 4323 | pts = rng.uniform(-1.0, 1.0, size=(120, 3)) | 4311 | pts = rng.uniform(-1.0, 1.0, size=(120, 3)) |
| 4324 | verts = rng.uniform(-1.0, 1.0, size=(40, 3)) | 4312 | verts = rng.uniform(-1.0, 1.0, size=(40, 3)) |
| 4325 | got_d, got_ok = fuse_mod._nearest_gated_vertex( | 4313 | got_d, got_ok = fuse._nearest_gated_vertex( |
| 4326 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 | 4314 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 |
| 4327 | ) | 4315 | ) |
| 4328 | want_d, want_ok = _reference_nearest_gated_vertex( | 4316 | want_d, want_ok = _reference_nearest_gated_vertex( |
| 4329 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 | 4317 | pts[:, :2], pts[:, 2], verts, 0.25, 0.4 |
| 4337 | """Blocking the radius join must not change a single distance.""" | 4325 | """Blocking the radius join must not change a single distance.""" |
| 4338 | rng = np.random.default_rng(7) | 4326 | rng = np.random.default_rng(7) |
| 4339 | pts = rng.uniform(-1.0, 1.0, size=(200, 3)) | 4327 | pts = rng.uniform(-1.0, 1.0, size=(200, 3)) |
| 4340 | verts = rng.uniform(-1.0, 1.0, size=(60, 3)) | 4328 | verts = rng.uniform(-1.0, 1.0, size=(60, 3)) |
| 4341 | one_d, one_ok = fuse_mod._nearest_gated_vertex( | 4329 | one_d, one_ok = fuse._nearest_gated_vertex( |
| 4342 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 | 4330 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 |
| 4343 | ) | 4331 | ) |
| 4344 | monkeypatch.setattr(fuse_mod, "_LINE_QUERY_BLOCK", 13) | 4332 | monkeypatch.setattr(fuse, "_LINE_QUERY_BLOCK", 13) |
| 4345 | many_d, many_ok = fuse_mod._nearest_gated_vertex( | 4333 | many_d, many_ok = fuse._nearest_gated_vertex( |
| 4346 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 | 4334 | pts[:, :2], pts[:, 2], verts, 0.3, 0.5 |
| 4347 | ) | 4335 | ) |
| 4348 | np.testing.assert_array_equal(one_ok, many_ok) | 4336 | np.testing.assert_array_equal(one_ok, many_ok) |
| 4349 | np.testing.assert_allclose(one_d, many_d) | 4337 | np.testing.assert_allclose(one_d, many_d) |
| 4359 | verts = lines_xml.LineVertices( | 4347 | verts = lines_xml.LineVertices( |
| 4360 | solid_xyz=solid_xyz, dashed_xyz=dashed_xyz | 4348 | solid_xyz=solid_xyz, dashed_xyz=dashed_xyz |
| 4361 | ) | 4349 | ) |
| 4362 | 4350 | ||
| 4363 | solid, dashed = _paint_lines( | 4351 | solid, dashed = fuse._paint_lines( |
| 4364 | pts, is_asphalt, verts, xy_radius=0.3, z_gate=0.4 | 4352 | pts, is_asphalt, verts, xy_radius=0.3, z_gate=0.4 |
| 4365 | ) | 4353 | ) |
| 4366 | 4354 | ||
| 4367 | asph_idx = np.nonzero(is_asphalt)[0] | 4355 | asph_idx = np.nonzero(is_asphalt)[0] |
| 4437 | dtype=np.uint8, | 4425 | dtype=np.uint8, |
| 4438 | ) | 4426 | ) |
| 4439 | 4427 | ||
| 4440 | got = start.copy() | 4428 | got = start.copy() |
| 4441 | fuse_mod._paint_mask_rows(got, rows, seg_classes) | 4429 | fuse._paint_mask_rows(got, rows, seg_classes) |
| 4442 | want = start.copy() | 4430 | want = start.copy() |
| 4443 | _reference_paint_mask_rows(want, rows, seg_classes) | 4431 | _reference_paint_mask_rows(want, rows, seg_classes) |
| 4444 | 4432 | ||
| 4445 | np.testing.assert_array_equal(got, want) | 4433 | np.testing.assert_array_equal(got, want) |
| 4455 | ], | 4443 | ], |
| 4456 | dtype=np.uint8, | 4444 | dtype=np.uint8, |
| 4457 | ) | 4445 | ) |
| 4458 | got = start.copy() | 4446 | got = start.copy() |
| 4459 | fuse_mod._paint_mask_rows( | 4447 | fuse._paint_mask_rows( |
| 4460 | got, | 4448 | got, |
| 4461 | np.array([0, 1, 2]), | 4449 | np.array([0, 1, 2]), |
| 4462 | np.array(["low_vegetation"] * 3, dtype=np.str_), | 4450 | np.array(["low_vegetation"] * 3, dtype=np.str_), |
| 4463 | ) | 4451 | ) |
| 4472 | 4460 | ||
| 4473 | 4461 | ||
| 4474 | def test_paint_mask_rows_gives_a_contested_row_to_the_support(): | 4462 | def test_paint_mask_rows_gives_a_contested_row_to_the_support(): |
| 4475 | got = np.array([classes.UNCLASSIFIED_CODE] * 2, dtype=np.uint8) | 4463 | got = np.array([classes.UNCLASSIFIED_CODE] * 2, dtype=np.uint8) |
| 4476 | fuse_mod._paint_mask_rows( | 4464 | fuse._paint_mask_rows( |
| 4477 | got, | 4465 | got, |
| 4478 | np.array([0, 0, 1, 1]), | 4466 | np.array([0, 0, 1, 1]), |
| 4479 | np.array( | 4467 | np.array( |
| 4480 | ["guardrail_support", "guardrail", "guardrail_top_rail", | 4468 | ["guardrail_support", "guardrail", "guardrail_top_rail", |