Back to report index

Step 3 3dsegmentation e47779f: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <developer.ai@iolabs.ch> 2026-09-02T08:39:21+02:00

Commit #21 ยท 57 snippets

 README.md                                          |  11 +-
 docs/fusion_spec.md                                |   5 +-
 pyproject.toml                                     |   3 +-
 scripts/veg_sweep.py                               |  22 +-
 .../_config_model.py                               | 417 ++++++++++++++
 src/iolabs_point_cloud_segmentation_3d/cli.py      |   2 +-
 src/iolabs_point_cloud_segmentation_3d/config.py   | 606 ++-------------------
 src/iolabs_point_cloud_segmentation_3d/fuse.py     |  12 +-
 .../las_modes.py                                   |   4 +-
 tests/test_config.py                               |  55 +-
 tests/test_vegetation.py                           |   4 +-
 11 files changed, 557 insertions(+), 584 deletions(-)
Importance #1: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -0,0 +1,417 @@
1"""The `Seg3dConfig` schema: every knob, its default and its range.
2
3The model mirrors `seg3d.default.json` key for key -- adding a knob is a
4field here plus the same key with the same default there. Unknown keys,
5value coercion and the error messages come from
6`iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field`
7bounds, cross-field rules are model validators. `config.py` is the public
8entry point (loading, merging, `--set` parsing) and re-exports both names.
9
10Distances are in metres unless the field name says otherwise.
11"""
12
13import logging
14from typing import Literal
15
16import pydantic
17from iolabs.common import config_loader
18
19from . import naming
20
21logger = logging.getLogger(__name__)
22
23# Upper bound of `vegetation_asphalt_dilate_cells`: an accepted value must
24# stay cheap to iterate (each cell is a full 3x3 dilation of the asphalt
25# raster, so a mistyped 500 grinds for minutes inside a fusion run).
26# 64 cells is 16 m of reach at the shipped 0.25 m cell, far past any verge.
27_MAX_DILATE_CELLS = 64
28
29# The LAS output-mode whitelists, as the type the config validates against.
30# They mirror `las_modes`, which the CLI (`choices=`) and the writer use;
31# `tests/test_config.py` pins the two spellings together.
32LasRgbMode = Literal["sensor", "class"]
33LasSplitMode = Literal["none", "class", "instance"]
34
35
36class ConfigError(config_loader.ConfigError):
37 """Raised when the seg3d config contains unsupported keys."""
38
39
40class Seg3dConfig(config_loader.ConfigModel):
41 """Numeric thresholds for the fusion pipeline (metres unless stated).
42
43 The per-field comments below carry the rationale; this section is the
44 map of the groups they fall into.
45
46 Attributes:
47 voxel_size_m, edge_extend_m: Voxel decimation grid and the extra
48 length kept around a segment.
49 line_*: XML line painting (the fallback line source).
50 guard_*: Alignment guards on the mask and vertex hit rates.
51 hash_round_units_per_m: Quantization of the integer XYZ hash join.
52 surface_mesh_tolerance_m: Fallback road-surface distance to the
53 step-9 carriageway meshes.
54 vehicle_enabled: Paint the residual above the carriageway.
55 signs_json_paint_*: Cylinder paint for sign detections that carry no
56 point mask.
57 vegetation_*: Colour-plus-height vegetation stage (greenness,
58 asphalt/corridor guards, height banding, ground model).
59 priority_*: Class priority tiers (high wins) for the voxel
60 representative pick.
61 las_rgb_mode, las_split, las_georeference, las_crs_epsg, write_ply:
62 Output files, their colours and georeferencing.
63 memory_budget_gb: Peak-RSS warning threshold, in GB.
64 name_template, dataset_tag, branch_tag, date_tag: Output file
65 naming (see `naming.py`).
66 """
67
68 # Voxel decimation. 1 cm is the production default: it keeps thin
69 # painted features (lines, posts, rail tubes) intact for annotation, at
70 # the cost of a much larger decimated cloud and higher peak memory (see
71 # `memory_budget_gb`).
72 voxel_size_m: float = 0.01
73 edge_extend_m: float = 60.0
74
75 # XML line painting (fallback line source).
76 line_xy_radius_m: float = 0.20
77 line_z_gate_m: float = 0.5
78 line_resample_step_m: float = 0.05
79 line_bbox_pad_m: float = 2.0
80
81 # Alignment guards.
82 guard_bbox_pad_m: float = 5.0
83 guard_mask_rate: float = 0.99
84 guard_vertex_rate: float = 0.95
85
86 # Quantization of the integer XYZ hash join, in units per metre (1000 ->
87 # 1 mm). Threaded through to `iolabs.common.point_hash`; a join is only
88 # exact if every producer of the joined data used the same value, so
89 # changing it is a fleet-wide decision, not a per-run knob.
90 hash_round_units_per_m: float = pydantic.Field(
91 default=1000.0, gt=0.0, allow_inf_nan=False
92 )
93
94 # Fallback road-surface source for datasets whose pipeline generation
95 # emits no run4 road-surface npz: distance to the step-9 carriageway
96 # meshes below which a point counts as road surface (see
97 # surface_mesh.py). Covers scan noise and the mesh's own cell size.
98 surface_mesh_tolerance_m: float = 0.15
99
100 # Vehicles / noise above the carriageway: paint every point still
101 # unclassified after all other stages whose XY falls between the outer
102 # asphalt edges. Set false to reproduce pre-feature output.
103 vehicle_enabled: bool = True
104
105 # Sign detections that exist only in `verticalsigns.json`. The detector
106 # writes `point_masks.npz` during its per-segment pass but keeps appending
107 # detections in later corridor-level post-passes (lattice admission,
108 # reject-rescue, rail half-posts), so those records carry no mask points
109 # and would be invisible in the fused output. They are painted instead
110 # from a cylinder around the detection record: XY radius
111 # clamp(0.5 * max(footprint_m), 0.15, radius_max) + 0.10 m, z window
112 # [z_ground - z_pad_bottom, z_top + z_pad_top]. Only points still
113 # UNCLASSIFIED are painted -- the pass runs after the detector masks and
114 # the guardrail JSON fallback, so a guardrail, its support or any other
115 # class keeps precedence -- and a detection needs at least `min_points`
116 # of them to be painted at all (no phantom instances). `gantry_or_gate`
117 # records are skipped outright: their `position` is the midpoint between
118 # the two posts and `footprint_m[0]` the post separation, so the cylinder
119 # would sit over the carriageway and reach neither post.
120 #
121 # `z_pad_bottom` is 0 by default -- the pass starts AT the detection's
122 # `z_ground`. Note the asymmetry with `guardrail_json`, which starts its
123 # rail band at ground + 0.05 to stay off the ground itself; here the
124 # ground is already painted and only unclassified points are taken, so
125 # no lift is needed and a negative pad only reached under the ground.
126 # The knob stays for detector builds whose `z_ground` runs optimistic.
127 signs_json_paint_enabled: bool = True
128 signs_json_paint_radius_max_m: float = 2.0
129 signs_json_paint_z_pad_top_m: float = 0.30
130 signs_json_paint_z_pad_bottom_m: float = 0.0
131 signs_json_paint_min_points: int = 10
132
133 # Vegetation from colour + height above ground (see `vegetation.py`).
134 # Runs between the sign JSON paint and the vehicle sweep, on points no
135 # earlier stage claimed: a point that is GREEN and not over asphalt is
136 # vegetation, and its height above a ground model decides whether it is
137 # low (grass), medium (bushes, hedges) or tall (tree). Candidates are the
138 # points still UNCLASSIFIED plus -- `from_ground` defaults to TRUE --
139 # the tablecloth's `ground` rows, for the common case where the
140 # tablecloth kept the verge grass and only colour tells it from gravel.
141 # `from_ground=false` restores Miro's original "green and ABOVE the
142 # ground layer" and leaves the whole tablecloth as ground.
143 #
144 # WHY the default is on: the owner compared delivered LAS with both
145 # settings (2026-08-28, segment 085). The rows the rule adds are a
146 # continuous grass carpet on the verge, so they are wanted as LAS 3/4;
147 # the median is unaffected either way (lost to the guardrail/vehicle
148 # guards before this stage runs).
149 #
150 # Every detector class, the lines and the asphalt keep precedence by
151 # construction.
152 #
153 # Greenness is the chromatic excess green ExG = (2G - R - B) / (R + G + B)
154 # >= `green_exg_min`, with G >= `green_rg_ratio` * R and G > B. Dividing
155 # by the sum makes it
156 # invariant to exposure and to the storage scale, so the same threshold
157 # holds for the 16-bit A1 clouds and for datasets that keep 8-bit values
158 # in the uint16 RGB fields. `green_min_brightness` is an optional floor on
159 # R+G+B *in the source's own scale* (hence 0 = off, not a fraction): very
160 # dark returns have noisy chroma. `green_rg_ratio` is 0.90 rather than the
161 # strict G > R the rule started with: dry khaki grass has R ~ G with a
162 # strong blue deficit, so ExG clears 0.10 on the blue alone while G > R
163 # threw the grass away (337k of the 484k ExG candidates on A1 segment
164 # 085, ~98 % of the misses on 002/003). Brown soil sits near ExG 0.05, so
165 # ExG still separates it. 1.0 means "G at least R", i.e. the old rule.
166 # 0.90 rather than 0.95 after the AI3D-373 visual sweep (two segments,
167 # four vision judges): it recovers straw/khaki grass and dim shrubs -- on
168 # 085 the khaki bank went 8.9k -> 13.3k painted points and the recruits
169 # have mean RGB 114/109/58 with R > G in 79 % of them -- while the
170 # ground, asphalt and guardrail counts stayed byte-identical across all
171 # nine colour settings, so the extra green is taken from `unclassified`
172 # only. `green_exg_min` stays 0.10: 0.06 was the judges' favourite but
173 # painted ~50 near-black airborne points over a carriageway, and it can
174 # be revisited once a `green_min_brightness` gate is validated.
175 #
176 # `asphalt_rule` decides what "over the road" means.
177 # `"asphalt_column"` (default) rasterises the asphalt points into
178 # `asphalt_cell_m` (0.25 m) XY cells, counts a cell occupied from
179 # `asphalt_min_points` (5) asphalt points, grows the occupancy by
180 # `asphalt_dilate_cells` 3x3 dilations (0 = none) and rejects every
181 # candidate landing in an occupied cell. The three knobs are separate
182 # from the band grid because the first version (0.5 m cells, one
183 # dilation, one point is enough) reached 1-1.5 m past the pavement edge:
184 # wider than a median strip, which it then masked from both sides, and it
185 # ate the first metres of every verge (75k points on A1 085) while a
186 # single stray asphalt-class point inside the median seeded the guard.
187 # The alternative `"corridor"` rejects everything inside the corridor
188 # polygon, which on a dual carriageway also swallows the median strip and
189 # leaves its grass and hedges to the vehicle sweep.
190 #
191 # `corridor_rail_m` (2 m) is a SECOND, independent guard that only runs
192 # under `"asphalt_column"` and only where a corridor exists: a candidate
193 # inside the corridor polygon is rejected unless a barrier point
194 # (`guardrail`, `guardrail_support`, `guardrail_top_rail`, `wall`) lies
195 # within this distance, measured on the `asphalt_cell_m` raster. WHY: a
196 # road stretch the step-9 surface mesh missed carries no asphalt class at
197 # all (A1 085 matched 77 % of its mesh), so its occupancy cells are empty
198 # and the ghost trails of passing vehicles standing on it passed the
199 # column guard and were painted vegetation -- before the vegetation stage
200 # existed the vehicle sweep claimed them, and the sweep runs after this
201 # stage and only takes unclassified rows. A median is recognisable by the
202 # barriers running down it: on 085 all but 321 of the 50,946 in-corridor
203 # vegetation points sit within 2 m of a rail (p90 = 0.43 m), and 270 of
204 # those 321 were `vehicle` one run earlier. 0 disables the rule.
205 #
206 # `corridor_max_height_m` (0.5 m) is the SECOND half of that rule and the
207 # reason the first half is not enough: the barrier a median is recognised
208 # by stands on the median, so the rail's own green returns -- and the
209 # ghost of whatever brushed past it -- are always within `rail_m` of a
210 # barrier and the exception waves them all through. A median carries mown
211 # grass and nothing else, so an in-corridor candidate more than this above
212 # the DTM is the rail or a road ghost. On A1 085 the in-corridor
213 # `low_vegetation` reaches p95 = 0.40 m while 70 % of the in-corridor
214 # `medium_vegetation` stands above 0.5 m, 0.6-1.5 m up, within 0.05 m of a
215 # guardrail point and in 0.25 m cells holding 80-550 of them: the
216 # lane-parallel teal streaks the QC raster of 085 showed along the median
217 # and its shoulders. Because the cap runs BEFORE the banding it also
218 # un-bands the grass around what it takes: the column p95 that made a
219 # whole cell `medium` drops back to grass height once the rail's returns
220 # are gone. Same scope as `corridor_rail_m` (`"asphalt_column"` only, a
221 # corridor only), and 0 disables it -- which, with a median hedge to keep,
222 # is the setting to reach for.
223 #
224 # `band_mode="column"` (default) bands whole `band_cell_m` XY columns by
225 # the `column_percentile` of the candidate heights in them, so a hedge is
226 # medium from its foot up and a tree is tall down to its trunk;
227 # `"point"` bands each point by its own height and gives every bush a low
228 # skirt. Either way a cell with fewer than `min_cell_points` (10) green
229 # candidates is dropped whole (counted as `vegetation_sparse_rejected`):
230 # such cells are isolated speckle -- 13.5 % of the medium cells on 085
231 # were 1-4-cell blocks of column-p95 noise at the guardrail foot, and
232 # 12 % of the low points on 002 were single green returns on the hard
233 # shoulder. `min_height_m` drops candidates below the ground model (DTM
234 # artefacts).
235 #
236 # `tall_class` decides what the tall band is painted, and its default is
237 # `"medium_vegetation"`: a hedge is tall vegetation that the detector did
238 # NOT call a tree, and hedges and trees are exclusive, so this stage
239 # never creates `tree` on its own -- green residual above `low_max_m` is
240 # medium however tall it grows. `medium_max_m` therefore only bites under
241 # `tall_class="tree"` (trust colour+height with the detector) or
242 # `"unclassified"` (leave the tall residual to a human).
243 #
244 # `low_max_m` is 0.7 m (AI3D-373): at 0.5 m the khaki toe-grass along the
245 # guardrails and the sparse rims of the verge banks were banded medium,
246 # and 0.7 m is the only threshold in the sweep that flips exactly those
247 # and nothing else -- bush and hedge interiors were pixel-identical
248 # between the two settings. `column_percentile` stays 95: p80 under-reads
249 # canopy tops and puts low skirts back inside the hedges.
250 #
251 # `tree_min_height_m` > 0 is a safety net for the detector: a
252 # verticalsigns `tree` instance whose points are all shorter than this is
253 # repainted `medium_vegetation` (a bush the detector called a tree). 0 is
254 # off, which leaves every detector tree exactly as the mask painted it.
255 vegetation_enabled: bool = True
256 vegetation_from_ground: bool = True
257 vegetation_asphalt_rule: Literal["asphalt_column", "corridor"] = "asphalt_column"
258 vegetation_asphalt_cell_m: float = pydantic.Field(
259 default=0.25, gt=0.0, allow_inf_nan=False
260 )
261 vegetation_asphalt_dilate_cells: int = pydantic.Field(
262 default=0, ge=0, le=_MAX_DILATE_CELLS
263 )
264 vegetation_asphalt_min_points: int = pydantic.Field(default=5, ge=0)
265 vegetation_corridor_rail_m: float = pydantic.Field(
266 default=2.0, ge=0.0, allow_inf_nan=False
267 )
268 vegetation_corridor_max_height_m: float = pydantic.Field(
269 default=0.5, ge=0.0, allow_inf_nan=False
270 )
271 vegetation_tree_min_height_m: float = pydantic.Field(
272 default=0.0, allow_inf_nan=False
273 )
274 vegetation_low_max_m: float = pydantic.Field(
275 default=0.7, gt=0.0, allow_inf_nan=False
276 )
277 vegetation_medium_max_m: float = pydantic.Field(
278 default=2.0, gt=0.0, allow_inf_nan=False
279 )
280 vegetation_min_height_m: float = pydantic.Field(
281 default=-0.5, allow_inf_nan=False
282 )
283 vegetation_tall_class: Literal[
284 "medium_vegetation", "tree", "unclassified"
285 ] = "medium_vegetation"
286 vegetation_band_mode: Literal["column", "point"] = "column"
287 vegetation_band_cell_m: float = pydantic.Field(
288 default=0.5, gt=0.0, allow_inf_nan=False
289 )
290 vegetation_column_percentile: float = pydantic.Field(
291 default=95.0, ge=0.0, le=100.0, allow_inf_nan=False
292 )
293 vegetation_min_cell_points: int = pydantic.Field(default=10, ge=0)
294 vegetation_green_exg_min: float = pydantic.Field(
295 default=0.10, allow_inf_nan=False
296 )
297 vegetation_green_rg_ratio: float = pydantic.Field(
298 default=0.90, gt=0.0, allow_inf_nan=False
299 )
300 vegetation_green_min_brightness: float = pydantic.Field(
301 default=0.0, allow_inf_nan=False
302 )
303 vegetation_ground_cell_m: float = pydantic.Field(
304 default=1.0, gt=0.0, allow_inf_nan=False
305 )
306 vegetation_ground_percentile: float = pydantic.Field(
307 default=10.0, ge=0.0, le=100.0, allow_inf_nan=False
308 )
309 vegetation_min_ground_points: int = pydantic.Field(default=1000, ge=1)
310
311 # Class priority tiers (high wins) used by the voxel representative pick.
312 priority_unclassified: int = 0
313 priority_ground: int = 1
314 priority_asphalt: int = 2
315 priority_line: int = 3
316 priority_detector: int = 4
317 # Guardrail supports (posts) and the median rail's top rail (box tube)
318 # sit above the other detector classes: such a voxel almost always also
319 # holds rail points and would lose the tier-4 count tie-break, dropping
320 # the decomposition out of the decimated cloud. One knob for the whole of
321 # tier 5 -- post and tube are two halves of the same decomposition.
322 priority_support: int = 5
323
324 # ReCap LAS export RGB: "sensor" keeps source-cloud colors, "class"
325 # bakes the class palette in (fallback for viewers without LAS
326 # classification display).
327 las_rgb_mode: LasRgbMode = "sensor"
328
329 # Extra LAS files for per-object review in ReCap (each imported file is
330 # an isolatable scan in the Project Navigator): "none", "class" (one
331 # file per class) or "instance" (one file per detected object).
332 las_split: LasSplitMode = "none"
333
334 # Write the class-colored PLY next to the npz/LAS. Off by default: the
335 # deliverable is the LAS, and at the 1 cm voxel the PLY is a large file
336 # nobody in the annotation loop opens. CLI `--ply` turns it back on.
337 write_ply: bool = False
338
339 # Add the dataset's run3 geoshift back to the LAS coordinates so the
340 # deliverable is in true world coordinates (the npz/ply stay in the
341 # pipeline frame, which is the hash-join key).
342 las_georeference: bool = True
343
344 # EPSG code embedded as a LAS 1.4 WKT VLR; 0 disables. 25832 is
345 # ETRS89 / UTM zone 32N, the CRS of this project's source scans. Only
346 # written when the exported coordinates are actually world coordinates.
347 las_crs_epsg: int = pydantic.Field(default=25832, ge=0)
348
349 # Reporting: warn when peak RSS exceeds this (GB); recorded in run_summary.
350 memory_budget_gb: float = 16.0
351
352 # Output file naming (see `naming.py`). Every per-segment file is one
353 # base name plus a fixed suffix; the `segment_NNN/` directory itself is
354 # never renamed. Default `name_template` is the production scheme
355 # `naming.PRODUCTION_TEMPLATE` ("{dataset}_{branch}_seg{seg}_{date}",
356 # e.g. A1_b000_seg085_260827); empty placeholders are collapsed (an
357 # untagged run yields seg085_260827). Legacy names are opt-in via
358 # `--name-template segment_{seg}_seg3d`. Placeholders: {seg} {date}
359 # {dataset} {branch}. `date_tag` is empty (use today) or YYMMDD.
360 # CLI: --name-template, --dataset-tag, --branch-tag, --date-tag.
361 name_template: str = naming.PRODUCTION_TEMPLATE
362 dataset_tag: str = ""
363 branch_tag: str = ""
364 date_tag: str = ""
365
366 @pydantic.model_validator(mode="after")
367 def _check_band_bounds(self) -> "Seg3dConfig":
368 """Rejects a low band that ends above the medium band."""
369 if self.vegetation_low_max_m > self.vegetation_medium_max_m:
370 raise ValueError(
371 f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not "
372 f"supported: it must not exceed "
373 f"vegetation_medium_max_m="
374 f"{self.vegetation_medium_max_m!r} -- the low band ends "
375 f"where the medium band starts."
376 )
377 return self
378
379 @pydantic.model_validator(mode="after")
380 def _check_naming(self) -> "Seg3dConfig":
381 """Resolves the naming knobs so a typo fails at load, not mid-run."""
382 try:
383 naming.validate_naming_config(self)
384 except naming.NamingError as exc:
385 # Surfaced as a config value error so `--set dataset_tag=...`
386 # fails like any other bad value, at load time rather than on
387 # the first write.
388 raise ValueError(str(exc)) from exc
389 return self
390
391 @pydantic.model_validator(mode="after")
392 def _warn_non_increasing_tiers(self) -> "Seg3dConfig":
393 """Warns when the priority tiers are not strictly increasing.
394
395 A warning, not an error: single-tier boosts are legitimate. The
396 sharp edge is pre-ground overlay configs that pin the old numbers
397 (asphalt=1, line=2, detector=3): the new `priority_ground=1`
398 default then ties asphalt, and ground would win voxels over
399 asphalt.
400 """
401 tiers = [
402 ("priority_unclassified", self.priority_unclassified),
403 ("priority_ground", self.priority_ground),
404 ("priority_asphalt", self.priority_asphalt),
405 ("priority_line", self.priority_line),
406 ("priority_detector", self.priority_detector),
407 ("priority_support", self.priority_support),
408 ]
409 for (lo_name, lo), (hi_name, hi) in zip(tiers, tiers[1:], strict=False):
410 if lo >= hi:
411 logger.warning(
412 "%s=%d >= %s=%d: priority tiers are not strictly "
413 "increasing; the voxel representative pick will not "
414 "follow the default class ordering",
415 lo_name, lo, hi_name, hi,
416 )
417 return self
0
Importance #2: src/iolabs_point_cloud_segmentation_3d/config.py @@ -2,357 +2,62 @@
22
3Mirrors the config convention used by the sibling iolabs point-cloud3Mirrors the config convention used by the sibling iolabs point-cloud
4packages (`guardrails` / `verticalsigns`): the package owns a4packages (`guardrails` / `verticalsigns`): the package owns a
5`seg3d.default.json` algorithm config, and a frozen typed params object5`seg3d.default.json` algorithm config, and a frozen typed params object
6(`Seg3dConfig`) is loaded from it at CLI start. Every dataclass field6(`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it
7default is kept identical to `seg3d.default.json` (guarded by7at CLI start. Every model field default is kept identical to
8`tests/test_config.py`), so `Seg3dConfig()` and `load_config` agree.8`seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()`
9Runtime overrides are applied through repeatable `--set KEY=VALUE` flags9and `load_config` agree. Runtime overrides are applied through repeatable
10or a `--config` JSON file, never repo-local edits to the packaged default.10`--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to
1111the packaged default.
12Distances are in metres unless the field name says otherwise.12
13Adding a knob is two edits: a field on `Seg3dConfig` (with its range
14expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the
15same key with the same default in `seg3d.default.json`.
13"""16"""
1417
15import copy
16import json
17import logging18import logging
18import math
19from dataclasses import dataclass
20from pathlib import Path19from pathlib import Path
21from typing import Any20from typing import Any
2221
23from iolabs.common.config_loader import ConfigError as _CommonConfigError22from iolabs.common import config_loader
24from iolabs.common.config_loader import dataclass_from_mapping, load_packaged_json23
25from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides24from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig
2625
27from .las_modes import LAS_RGB_MODES, LAS_SPLIT_CONFIG_MODES26__all__ = [
28from .naming import PRODUCTION_TEMPLATE, NamingError, validate_naming_config27 "ConfigError",
28 "LasRgbMode",
29 "LasSplitMode",
30 "Seg3dConfig",
31 "config_from_dict",
32 "load_config",
33 "load_default_config_dict",
34 "parse_set_overrides",
35]
2936
30logger = logging.getLogger(__name__)37logger = logging.getLogger(__name__)
3138
32_PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d"39_PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d"
33_DEFAULT_CONFIG_NAME = "seg3d.default.json"40_DEFAULT_CONFIG_NAME = "seg3d.default.json"
3441_CONFIG_CONTEXT = "seg3d config"
35# Upper bound of `vegetation_asphalt_dilate_cells`, see
36# `validate_config`: an accepted value must stay cheap to iterate.
37_MAX_DILATE_CELLS = 64
38
39
40@dataclass(frozen=True)
41class Seg3dConfig:
42 """Numeric thresholds for the fusion pipeline (metres unless stated).
43
44 The per-field comments below carry the rationale; this section is the
45 map of the groups they fall into.
46
47 Attributes:
48 voxel_size_m, edge_extend_m: Voxel decimation grid and the extra
49 length kept around a segment.
50 line_*: XML line painting (the fallback line source).
51 guard_*: Alignment guards on the mask and vertex hit rates.
52 hash_round_units_per_m: Quantization of the integer XYZ hash join.
53 surface_mesh_tolerance_m: Fallback road-surface distance to the
54 step-9 carriageway meshes.
55 vehicle_enabled: Paint the residual above the carriageway.
56 signs_json_paint_*: Cylinder paint for sign detections that carry no
57 point mask.
58 vegetation_*: Colour-plus-height vegetation stage (greenness,
59 asphalt/corridor guards, height banding, ground model).
60 priority_*: Class priority tiers (high wins) for the voxel
61 representative pick.
62 las_rgb_mode, las_split, las_georeference, las_crs_epsg, write_ply:
63 Output files, their colours and georeferencing.
64 memory_budget_gb: Peak-RSS warning threshold, in GB.
65 name_template, dataset_tag, branch_tag, date_tag: Output file
66 naming (see `naming.py`).
67 """
68
69 # Voxel decimation. 1 cm is the production default: it keeps thin
70 # painted features (lines, posts, rail tubes) intact for annotation, at
71 # the cost of a much larger decimated cloud and higher peak memory (see
72 # `memory_budget_gb`).
73 voxel_size_m: float = 0.01
74 edge_extend_m: float = 60.0
75
76 # XML line painting (fallback line source).
77 line_xy_radius_m: float = 0.20
78 line_z_gate_m: float = 0.5
79 line_resample_step_m: float = 0.05
80 line_bbox_pad_m: float = 2.0
81
82 # Alignment guards.
83 guard_bbox_pad_m: float = 5.0
84 guard_mask_rate: float = 0.99
85 guard_vertex_rate: float = 0.95
86
87 # Quantization of the integer XYZ hash join, in units per metre (1000 ->
88 # 1 mm). Threaded through to `iolabs.common.point_hash`; a join is only
89 # exact if every producer of the joined data used the same value, so
90 # changing it is a fleet-wide decision, not a per-run knob.
91 hash_round_units_per_m: float = 1000.0
92
93 # Fallback road-surface source for datasets whose pipeline generation
94 # emits no run4 road-surface npz: distance to the step-9 carriageway
95 # meshes below which a point counts as road surface (see
96 # surface_mesh.py). Covers scan noise and the mesh's own cell size.
97 surface_mesh_tolerance_m: float = 0.15
98
99 # Vehicles / noise above the carriageway: paint every point still
100 # unclassified after all other stages whose XY falls between the outer
101 # asphalt edges. Set false to reproduce pre-feature output.
102 vehicle_enabled: bool = True
103
104 # Sign detections that exist only in `verticalsigns.json`. The detector
105 # writes `point_masks.npz` during its per-segment pass but keeps appending
106 # detections in later corridor-level post-passes (lattice admission,
107 # reject-rescue, rail half-posts), so those records carry no mask points
108 # and would be invisible in the fused output. They are painted instead
109 # from a cylinder around the detection record: XY radius
110 # clamp(0.5 * max(footprint_m), 0.15, radius_max) + 0.10 m, z window
111 # [z_ground - z_pad_bottom, z_top + z_pad_top]. Only points still
112 # UNCLASSIFIED are painted -- the pass runs after the detector masks and
113 # the guardrail JSON fallback, so a guardrail, its support or any other
114 # class keeps precedence -- and a detection needs at least `min_points`
115 # of them to be painted at all (no phantom instances). `gantry_or_gate`
116 # records are skipped outright: their `position` is the midpoint between
117 # the two posts and `footprint_m[0]` the post separation, so the cylinder
118 # would sit over the carriageway and reach neither post.
119 #
120 # `z_pad_bottom` is 0 by default -- the pass starts AT the detection's
121 # `z_ground`. Note the asymmetry with `guardrail_json`, which starts its
122 # rail band at ground + 0.05 to stay off the ground itself; here the
123 # ground is already painted and only unclassified points are taken, so
124 # no lift is needed and a negative pad only reached under the ground.
125 # The knob stays for detector builds whose `z_ground` runs optimistic.
126 signs_json_paint_enabled: bool = True
127 signs_json_paint_radius_max_m: float = 2.0
128 signs_json_paint_z_pad_top_m: float = 0.30
129 signs_json_paint_z_pad_bottom_m: float = 0.0
130 signs_json_paint_min_points: int = 10
131
132 # Vegetation from colour + height above ground (see `vegetation.py`).
133 # Runs between the sign JSON paint and the vehicle sweep, on points no
134 # earlier stage claimed: a point that is GREEN and not over asphalt is
135 # vegetation, and its height above a ground model decides whether it is
136 # low (grass), medium (bushes, hedges) or tall (tree). Candidates are the
137 # points still UNCLASSIFIED plus -- `from_ground` defaults to TRUE --
138 # the tablecloth's `ground` rows, for the common case where the
139 # tablecloth kept the verge grass and only colour tells it from gravel.
140 # `from_ground=false` restores Miro's original "green and ABOVE the
141 # ground layer" and leaves the whole tablecloth as ground.
142 #
143 # WHY the default is on: the owner compared delivered LAS with both
144 # settings (2026-08-28, segment 085). The rows the rule adds are a
145 # continuous grass carpet on the verge, so they are wanted as LAS 3/4;
146 # the median is unaffected either way (lost to the guardrail/vehicle
147 # guards before this stage runs).
148 #
149 # Every detector class, the lines and the asphalt keep precedence by
150 # construction.
151 #
152 # Greenness is the chromatic excess green ExG = (2G - R - B) / (R + G + B)
153 # >= `green_exg_min`, with G >= `green_rg_ratio` * R and G > B. Dividing
154 # by the sum makes it
155 # invariant to exposure and to the storage scale, so the same threshold
156 # holds for the 16-bit A1 clouds and for datasets that keep 8-bit values
157 # in the uint16 RGB fields. `green_min_brightness` is an optional floor on
158 # R+G+B *in the source's own scale* (hence 0 = off, not a fraction): very
159 # dark returns have noisy chroma. `green_rg_ratio` is 0.90 rather than the
160 # strict G > R the rule started with: dry khaki grass has R ~ G with a
161 # strong blue deficit, so ExG clears 0.10 on the blue alone while G > R
162 # threw the grass away (337k of the 484k ExG candidates on A1 segment
163 # 085, ~98 % of the misses on 002/003). Brown soil sits near ExG 0.05, so
164 # ExG still separates it. 1.0 means "G at least R", i.e. the old rule.
165 # 0.90 rather than 0.95 after the AI3D-373 visual sweep (two segments,
166 # four vision judges): it recovers straw/khaki grass and dim shrubs -- on
167 # 085 the khaki bank went 8.9k -> 13.3k painted points and the recruits
168 # have mean RGB 114/109/58 with R > G in 79 % of them -- while the
169 # ground, asphalt and guardrail counts stayed byte-identical across all
170 # nine colour settings, so the extra green is taken from `unclassified`
171 # only. `green_exg_min` stays 0.10: 0.06 was the judges' favourite but
172 # painted ~50 near-black airborne points over a carriageway, and it can
173 # be revisited once a `green_min_brightness` gate is validated.
174 #
175 # `asphalt_rule` decides what "over the road" means.
176 # `"asphalt_column"` (default) rasterises the asphalt points into
177 # `asphalt_cell_m` (0.25 m) XY cells, counts a cell occupied from
178 # `asphalt_min_points` (5) asphalt points, grows the occupancy by
179 # `asphalt_dilate_cells` 3x3 dilations (0 = none) and rejects every
180 # candidate landing in an occupied cell. The three knobs are separate
181 # from the band grid because the first version (0.5 m cells, one
182 # dilation, one point is enough) reached 1-1.5 m past the pavement edge:
183 # wider than a median strip, which it then masked from both sides, and it
184 # ate the first metres of every verge (75k points on A1 085) while a
185 # single stray asphalt-class point inside the median seeded the guard.
186 # The alternative `"corridor"` rejects everything inside the corridor
187 # polygon, which on a dual carriageway also swallows the median strip and
188 # leaves its grass and hedges to the vehicle sweep.
189 #
190 # `corridor_rail_m` (2 m) is a SECOND, independent guard that only runs
191 # under `"asphalt_column"` and only where a corridor exists: a candidate
192 # inside the corridor polygon is rejected unless a barrier point
193 # (`guardrail`, `guardrail_support`, `guardrail_top_rail`, `wall`) lies
194 # within this distance, measured on the `asphalt_cell_m` raster. WHY: a
195 # road stretch the step-9 surface mesh missed carries no asphalt class at
196 # all (A1 085 matched 77 % of its mesh), so its occupancy cells are empty
197 # and the ghost trails of passing vehicles standing on it passed the
198 # column guard and were painted vegetation -- before the vegetation stage
199 # existed the vehicle sweep claimed them, and the sweep runs after this
200 # stage and only takes unclassified rows. A median is recognisable by the
201 # barriers running down it: on 085 all but 321 of the 50,946 in-corridor
202 # vegetation points sit within 2 m of a rail (p90 = 0.43 m), and 270 of
203 # those 321 were `vehicle` one run earlier. 0 disables the rule.
204 #
205 # `corridor_max_height_m` (0.5 m) is the SECOND half of that rule and the
206 # reason the first half is not enough: the barrier a median is recognised
207 # by stands on the median, so the rail's own green returns -- and the
208 # ghost of whatever brushed past it -- are always within `rail_m` of a
209 # barrier and the exception waves them all through. A median carries mown
210 # grass and nothing else, so an in-corridor candidate more than this above
211 # the DTM is the rail or a road ghost. On A1 085 the in-corridor
212 # `low_vegetation` reaches p95 = 0.40 m while 70 % of the in-corridor
213 # `medium_vegetation` stands above 0.5 m, 0.6-1.5 m up, within 0.05 m of a
214 # guardrail point and in 0.25 m cells holding 80-550 of them: the
215 # lane-parallel teal streaks the QC raster of 085 showed along the median
216 # and its shoulders. Because the cap runs BEFORE the banding it also
217 # un-bands the grass around what it takes: the column p95 that made a
218 # whole cell `medium` drops back to grass height once the rail's returns
219 # are gone. Same scope as `corridor_rail_m` (`"asphalt_column"` only, a
220 # corridor only), and 0 disables it -- which, with a median hedge to keep,
221 # is the setting to reach for.
222 #
223 # `band_mode="column"` (default) bands whole `band_cell_m` XY columns by
224 # the `column_percentile` of the candidate heights in them, so a hedge is
225 # medium from its foot up and a tree is tall down to its trunk;
226 # `"point"` bands each point by its own height and gives every bush a low
227 # skirt. Either way a cell with fewer than `min_cell_points` (10) green
228 # candidates is dropped whole (counted as `vegetation_sparse_rejected`):
229 # such cells are isolated speckle -- 13.5 % of the medium cells on 085
230 # were 1-4-cell blocks of column-p95 noise at the guardrail foot, and
231 # 12 % of the low points on 002 were single green returns on the hard
232 # shoulder. `min_height_m` drops candidates below the ground model (DTM
233 # artefacts).
234 #
235 # `tall_class` decides what the tall band is painted, and its default is
236 # `"medium_vegetation"`: a hedge is tall vegetation that the detector did
237 # NOT call a tree, and hedges and trees are exclusive, so this stage
238 # never creates `tree` on its own -- green residual above `low_max_m` is
239 # medium however tall it grows. `medium_max_m` therefore only bites under
240 # `tall_class="tree"` (trust colour+height with the detector) or
241 # `"unclassified"` (leave the tall residual to a human).
242 #
243 # `low_max_m` is 0.7 m (AI3D-373): at 0.5 m the khaki toe-grass along the
244 # guardrails and the sparse rims of the verge banks were banded medium,
245 # and 0.7 m is the only threshold in the sweep that flips exactly those
246 # and nothing else -- bush and hedge interiors were pixel-identical
247 # between the two settings. `column_percentile` stays 95: p80 under-reads
248 # canopy tops and puts low skirts back inside the hedges.
249 #
250 # `tree_min_height_m` > 0 is a safety net for the detector: a
251 # verticalsigns `tree` instance whose points are all shorter than this is
252 # repainted `medium_vegetation` (a bush the detector called a tree). 0 is
253 # off, which leaves every detector tree exactly as the mask painted it.
254 vegetation_enabled: bool = True
255 vegetation_from_ground: bool = True
256 vegetation_asphalt_rule: str = "asphalt_column"
257 vegetation_asphalt_cell_m: float = 0.25
258 vegetation_asphalt_dilate_cells: int = 0
259 vegetation_asphalt_min_points: int = 5
260 vegetation_corridor_rail_m: float = 2.0
261 vegetation_corridor_max_height_m: float = 0.5
262 vegetation_tree_min_height_m: float = 0.0
263 vegetation_low_max_m: float = 0.7
264 vegetation_medium_max_m: float = 2.0
265 vegetation_min_height_m: float = -0.5
266 vegetation_tall_class: str = "medium_vegetation"
267 vegetation_band_mode: str = "column"
268 vegetation_band_cell_m: float = 0.5
269 vegetation_column_percentile: float = 95.0
270 vegetation_min_cell_points: int = 10
271 vegetation_green_exg_min: float = 0.10
272 vegetation_green_rg_ratio: float = 0.90
273 vegetation_green_min_brightness: float = 0.0
274 vegetation_ground_cell_m: float = 1.0
275 vegetation_ground_percentile: float = 10.0
276 vegetation_min_ground_points: int = 1000
277
278 # Class priority tiers (high wins) used by the voxel representative pick.
279 priority_unclassified: int = 0
280 priority_ground: int = 1
281 priority_asphalt: int = 2
282 priority_line: int = 3
283 priority_detector: int = 4
284 # Guardrail supports (posts) and the median rail's top rail (box tube)
285 # sit above the other detector classes: such a voxel almost always also
286 # holds rail points and would lose the tier-4 count tie-break, dropping
287 # the decomposition out of the decimated cloud. One knob for the whole of
288 # tier 5 -- post and tube are two halves of the same decomposition.
289 priority_support: int = 5
290
291 # ReCap LAS export RGB: "sensor" keeps source-cloud colors, "class"
292 # bakes the class palette in (fallback for viewers without LAS
293 # classification display).
294 las_rgb_mode: str = "sensor"
295
296 # Extra LAS files for per-object review in ReCap (each imported file is
297 # an isolatable scan in the Project Navigator): "none", "class" (one
298 # file per class) or "instance" (one file per detected object).
299 las_split: str = "none"
300
301 # Write the class-colored PLY next to the npz/LAS. Off by default: the
302 # deliverable is the LAS, and at the 1 cm voxel the PLY is a large file
303 # nobody in the annotation loop opens. CLI `--ply` turns it back on.
304 write_ply: bool = False
305
306 # Add the dataset's run3 geoshift back to the LAS coordinates so the
307 # deliverable is in true world coordinates (the npz/ply stay in the
308 # pipeline frame, which is the hash-join key).
309 las_georeference: bool = True
310
311 # EPSG code embedded as a LAS 1.4 WKT VLR; 0 disables. 25832 is
312 # ETRS89 / UTM zone 32N, the CRS of this project's source scans. Only
313 # written when the exported coordinates are actually world coordinates.
314 las_crs_epsg: int = 25832
315
316 # Reporting: warn when peak RSS exceeds this (GB); recorded in run_summary.
317 memory_budget_gb: float = 16.0
318
319 # Output file naming (see `naming.py`). Every per-segment file is one
320 # base name plus a fixed suffix; the `segment_NNN/` directory itself is
321 # never renamed. Default `name_template` is the production scheme
322 # `naming.PRODUCTION_TEMPLATE` ("{dataset}_{branch}_seg{seg}_{date}",
323 # e.g. A1_b000_seg085_260827); empty placeholders are collapsed (an
324 # untagged run yields seg085_260827). Legacy names are opt-in via
325 # `--name-template segment_{seg}_seg3d`. Placeholders: {seg} {date}
326 # {dataset} {branch}. `date_tag` is empty (use today) or YYMMDD.
327 # CLI: --name-template, --dataset-tag, --branch-tag, --date-tag.
328 name_template: str = PRODUCTION_TEMPLATE
329 dataset_tag: str = ""
330 branch_tag: str = ""
331 date_tag: str = ""
332
333
334class ConfigError(_CommonConfigError):
335 """Raised when the seg3d config contains unsupported keys."""
33642
33743
338def load_default_config_dict() -> dict[str, Any]:44def load_default_config_dict() -> dict[str, Any]:
339 """Returns the package-owned default config as a plain dict."""45 """Returns the package-owned default config as a plain dict."""
340 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)46 return config_loader.load_packaged_json(
47 __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME
48 )
34149
34250
343def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:51def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:
344 """Builds a validated `Seg3dConfig` from a raw mapping.52 """Builds a validated `Seg3dConfig` from a raw mapping.
34553
346 Unknown keys are rejected and each raw value is coerced to its54 Unknown keys are rejected and each raw value is coerced to its field's
347 dataclass field's declared type by55 declared type by `iolabs.common.config_loader.ConfigModel`, which is
348 `iolabs.common.config_loader.dataclass_from_mapping`, which is strict:56 strict: a bool typo (`"flase"`), a bool given as an int other than 0/1
349 a bool typo (`"flase"`), a bool given as an int other than 0/1 and a57 and a non-integral value for an int field are errors, not silent
350 non-integral value for an int field are errors, not silent58 misconfigurations. Missing keys fall back to the field defaults, which
351 misconfigurations. After construction, tier priorities are checked59 are the packaged JSON's.
352 and any non-strictly-increasing tier ordering is logged as a warning
353 (not an error, since single-tier boosts are legitimate) -- see the
354 inline note below for why this matters.
35560
356 Args:61 Args:
357 raw: Flat mapping of `Seg3dConfig` field names to raw values62 raw: Flat mapping of `Seg3dConfig` field names to raw values
358 (e.g. parsed JSON or `--set KEY=VALUE` overrides).63 (e.g. parsed JSON or `--set KEY=VALUE` overrides).
Importance #3: src/iolabs_point_cloud_segmentation_3d/config.py @@ -360,228 +65,16 @@
360 Returns:65 Returns:
361 The validated `Seg3dConfig`.66 The validated `Seg3dConfig`.
36267
363 Raises:68 Raises:
364 ConfigError: `raw` contains an unknown key or a value that is not69 ConfigError: `raw` contains an unknown key, or a value that is not
365 valid for its field's declared type,70 valid for its field's declared type or outside its declared
366 `hash_round_units_per_m` is not finite and positive,71 range (see the `pydantic.Field` bounds and the model
367 `las_rgb_mode` is not `"sensor"`/`"class"`, `las_split` is not72 validators on `Seg3dConfig`, including the naming knobs).
368 `"none"`/`"class"`/`"instance"`, a vegetation enum
369 (`vegetation_tall_class`, `vegetation_band_mode`,
370 `vegetation_asphalt_rule`) is not one of its allowed values, a
371 vegetation limiter is out of range
372 (`vegetation_green_rg_ratio`, `vegetation_asphalt_cell_m`,
373 `vegetation_ground_cell_m`, `vegetation_band_cell_m`,
374 `vegetation_low_max_m` and `vegetation_medium_max_m` must be
375 finite and positive, with `low_max_m <= medium_max_m`;
376 `vegetation_asphalt_dilate_cells`,
377 `vegetation_asphalt_min_points`,
378 `vegetation_min_cell_points` and
379 `vegetation_corridor_rail_m` and
380 `vegetation_corridor_max_height_m` non-negative, with
381 `vegetation_asphalt_dilate_cells` at most
382 `_MAX_DILATE_CELLS`;
383 `vegetation_min_ground_points` at least 1;
384 `vegetation_column_percentile` and
385 `vegetation_ground_percentile` in `[0, 100]`;
386 `vegetation_min_height_m`, `vegetation_green_exg_min`,
387 `vegetation_green_min_brightness` and
388 `vegetation_tree_min_height_m` finite),
389 `las_crs_epsg` is negative, or a naming knob (`name_template`,
390 `dataset_tag`, `branch_tag`, `date_tag`) does not resolve to a
391 usable base name.
392 """73 """
393 config = dataclass_from_mapping(74 return config_loader.validate_config(
394 Seg3dConfig, raw, context="seg3d config", error_cls=ConfigError75 Seg3dConfig, raw, context=_CONFIG_CONTEXT, error_cls=ConfigError
395 )76 )
396 units = config.hash_round_units_per_m
397 if not math.isfinite(units) or units <= 0.0:
398 # The hash join is a real config parameter now (threaded into
399 # `iolabs.common.point_hash` and recorded in the run stats), but a
400 # non-positive resolution is never a valid one -- catch it at load
401 # time rather than mid-run on the first join.
402 raise ConfigError(
403 f"hash_round_units_per_m={units!r} is not supported: expected a "
404 f"finite, strictly positive number of quantization units per "
405 f"metre (1000 = 1 mm, the fleet-wide contract value)."
406 )
407 if config.las_rgb_mode not in LAS_RGB_MODES:
408 raise ConfigError(
409 f"las_rgb_mode={config.las_rgb_mode!r} is not supported: "
410 f"expected 'sensor' or 'class'."
411 )
412 if config.las_split not in LAS_SPLIT_CONFIG_MODES:
413 raise ConfigError(
414 f"las_split={config.las_split!r} is not supported: expected "
415 f"'none', 'class' or 'instance'."
416 )
417 # The three vegetation enums are checked here rather than in
418 # `vegetation.py`: a typo in `--set vegetation_band_mode=colum` must fail
419 # at load time, not silently fall through to the other branch on a 3.5
420 # min fusion run.
421 for name, allowed in (
422 (
423 "vegetation_tall_class",
424 ("medium_vegetation", "tree", "unclassified"),
425 ),
426 ("vegetation_band_mode", ("column", "point")),
427 ("vegetation_asphalt_rule", ("asphalt_column", "corridor")),
428 ):
429 value = getattr(config, name)
430 if value not in allowed:
431 quoted = [repr(a) for a in allowed]
432 options = " or ".join([", ".join(quoted[:-1]), quoted[-1]])
433 raise ConfigError(
434 f"{name}={value!r} is not supported: expected {options}."
435 )
436 # The vegetation limiters, same argument: a cell size of 0 divides by
437 # zero deep inside the rasteriser and a negative count silently means
438 # "no floor", both of which must fail at load time.
439 # Every cell size divides deep inside a rasteriser, and every one of
440 # these has been observed to fail LATE rather than loudly: a
441 # `band_cell_m` of 0 raised OverflowError 3.5 minutes into a fuse, and
442 # a `min_ground_points` of 0 reached numpy as a zero-size reduction.
443 for name in (
444 "vegetation_green_rg_ratio",
445 "vegetation_asphalt_cell_m",
446 "vegetation_ground_cell_m",
447 "vegetation_band_cell_m",
448 "vegetation_low_max_m",
449 "vegetation_medium_max_m",
450 ):
451 value = float(getattr(config, name))
452 if not math.isfinite(value) or value <= 0.0:
453 raise ConfigError(
454 f"{name}={value!r} is not supported: expected a finite, "
455 f"strictly positive number."
456 )
457 if config.vegetation_low_max_m > config.vegetation_medium_max_m:
458 raise ConfigError(
459 f"vegetation_low_max_m={config.vegetation_low_max_m!r} is not "
460 f"supported: it must not exceed "
461 f"vegetation_medium_max_m="
462 f"{config.vegetation_medium_max_m!r} -- the low band ends "
463 f"where the medium band starts."
464 )
465 for name in (
466 "vegetation_asphalt_dilate_cells",
467 "vegetation_asphalt_min_points",
468 "vegetation_min_cell_points",
469 ):
470 value = int(getattr(config, name))
471 if value < 0:
472 raise ConfigError(
473 f"{name}={value!r} is not supported: expected a "
474 f"non-negative count."
475 )
476 # The one knob with an upper bound too: every iteration is a full 3x3
477 # `binary_dilation` over the asphalt raster, so a mistyped 500 (metres
478 # meant as cells, a stray zero) grinds for minutes inside a fusion run
479 # rather than failing at load time. 64 cells is 16 m of reach at the
480 # shipped 0.25 m cell, i.e. far past any real verge.
481 if config.vegetation_asphalt_dilate_cells > _MAX_DILATE_CELLS:
482 raise ConfigError(
483 f"vegetation_asphalt_dilate_cells="
484 f"{config.vegetation_asphalt_dilate_cells!r} is not supported: "
485 f"expected at most {_MAX_DILATE_CELLS} cells (each one is a "
486 f"full 3x3 dilation of the asphalt raster)."
487 )
488 if config.vegetation_min_ground_points < 1:
489 raise ConfigError(
490 f"vegetation_min_ground_points="
491 f"{config.vegetation_min_ground_points!r} is not supported: "
492 f"expected at least 1 (a DTM needs a point to be built from; "
493 f"raise the floor to skip the stage instead)."
494 )
495 for name in (
496 "vegetation_column_percentile",
497 "vegetation_ground_percentile",
498 ):
499 value = float(getattr(config, name))
500 if not math.isfinite(value) or not 0.0 <= value <= 100.0:
501 raise ConfigError(
502 f"{name}={value!r} is not supported: expected a "
503 f"percentile in [0, 100]."
504 )
505 # Not bounded: -0.5 m (well under the model) is the shipped value and
506 # a large negative is a legitimate "off". Only nan/inf is nonsense --
507 # every comparison against it is False, so the filter would silently
508 # drop the whole candidate set.
509 min_height = float(config.vegetation_min_height_m)
510 if not math.isfinite(min_height):
511 raise ConfigError(
512 f"vegetation_min_height_m={min_height!r} is not supported: "
513 f"expected a finite height in metres."
514 )
515 # Finite-only, same argument as `vegetation_min_height_m` and with
516 # the same sharp edge: every comparison against a nan is False, so a
517 # nan here does not raise, it silently turns the knob OFF (nothing is
518 # green enough / bright enough; no detector tree is short enough to
519 # re-band). No range: a negative `exg_min` is a legitimate "take
520 # everything that is not red", and 0 is the documented "off" for the
521 # other two.
522 for name in (
523 "vegetation_green_exg_min",
524 "vegetation_green_min_brightness",
525 "vegetation_tree_min_height_m",
526 ):
527 value = float(getattr(config, name))
528 if not math.isfinite(value):
529 raise ConfigError(
530 f"{name}={value!r} is not supported: expected a finite "
531 f"number."
532 )
533 # A negative reach would dilate by a negative count deep inside the
534 # rasteriser; 0 is the documented "rule off" setting.
535 rail_m = float(config.vegetation_corridor_rail_m)
536 if not math.isfinite(rail_m) or rail_m < 0.0:
537 raise ConfigError(
538 f"vegetation_corridor_rail_m={rail_m!r} is not supported: "
539 f"expected a finite, non-negative distance in metres "
540 f"(0 disables the rule)."
541 )
542 # Same shape as `rail_m`: 0 is the documented "cap off", a negative
543 # would reject every in-corridor candidate including the median grass,
544 # and a nan compares False against every height, i.e. silently off.
545 max_h = float(config.vegetation_corridor_max_height_m)
546 if not math.isfinite(max_h) or max_h < 0.0:
547 raise ConfigError(
548 f"vegetation_corridor_max_height_m={max_h!r} is not supported: "
549 f"expected a finite, non-negative height in metres "
550 f"(0 disables the cap)."
551 )
552 if config.las_crs_epsg < 0:
553 raise ConfigError(
554 f"las_crs_epsg={config.las_crs_epsg!r} is not supported: "
555 f"expected a non-negative EPSG code (0 disables the CRS VLR)."
556 )
557 try:
558 validate_naming_config(config)
559 except NamingError as exc:
560 # Surfaced as a ConfigError so `--set dataset_tag=...` fails like any
561 # other bad config value, at load time rather than on the first write.
562 raise ConfigError(str(exc)) from exc
563 # Warn (not error: single-tier boosts are legitimate) when the tiers are
564 # not strictly increasing. The sharp edge is pre-ground overlay configs
565 # that pin the old numbers (asphalt=1, line=2, detector=3): the new
566 # priority_ground=1 default then ties asphalt, and ground would win
567 # voxels over asphalt.
568 tiers = [
569 ("priority_unclassified", config.priority_unclassified),
570 ("priority_ground", config.priority_ground),
571 ("priority_asphalt", config.priority_asphalt),
572 ("priority_line", config.priority_line),
573 ("priority_detector", config.priority_detector),
574 ("priority_support", config.priority_support),
575 ]
576 for (lo_name, lo), (hi_name, hi) in zip(tiers, tiers[1:], strict=False):
577 if lo >= hi:
578 logger.warning(
579 "%s=%d >= %s=%d: priority tiers are not strictly increasing; "
580 "the voxel representative pick will not follow the default "
581 "class ordering", lo_name, lo, hi_name, hi,
582 )
583 return config
58477
58578
586def load_config(79def load_config(
587 config_path: Path | None = None,80 config_path: Path | None = None,
Importance #4: src/iolabs_point_cloud_segmentation_3d/config.py @@ -599,16 +92,17 @@
59992
600 Raises:93 Raises:
601 ConfigError: An override key or value is not valid.94 ConfigError: An override key or value is not valid.
602 """95 """
603 merged = copy.deepcopy(load_default_config_dict())96 config = config_loader.load_config(
604 if config_path is not None:97 Seg3dConfig,
605 with Path(config_path).open("r", encoding="utf-8") as handle:98 package=__package__ or _PACKAGE_NAME,
606 file_cfg = json.load(handle)99 filename=_DEFAULT_CONFIG_NAME,
607 merged.update(file_cfg)100 overrides=overrides,
608 for key, value in (overrides or {}).items():101 config_path=config_path,
609 merged[key] = value102 context=_CONFIG_CONTEXT,
610 config = config_from_dict(merged)103 error_cls=ConfigError,
104 )
611 if config_path is not None:105 if config_path is not None:
612 logger.info("Config file applied: %s", config_path)106 logger.info("Config file applied: %s", config_path)
613 if overrides:107 if overrides:
614 logger.info(108 logger.info(
Importance #5: src/iolabs_point_cloud_segmentation_3d/config.py @@ -633,5 +127,7 @@
633127
634 Raises:128 Raises:
635 ConfigError: An override is missing its `=`.129 ConfigError: An override is missing its `=`.
636 """130 """
637 return _parse_set_overrides(raw_overrides, error_cls=ConfigError)131 return config_loader.parse_set_overrides(
132 raw_overrides, error_cls=ConfigError
133 )
Importance #6: scripts/veg_sweep.py @@ -64,9 +64,9 @@
64import json64import json
65import logging65import logging
66import sys66import sys
67import time67import time
68from dataclasses import asdict, dataclass, field68from dataclasses import dataclass, field
69from pathlib import Path69from pathlib import Path
7070
71import numpy as np71import numpy as np
72from iolabs.common.crs import looks_georeferenced72from iolabs.common.crs import looks_georeferenced
Importance #7: scripts/veg_sweep.py @@ -286,9 +286,12 @@
286 Raises:286 Raises:
287 SystemExit: Via `ConfigError` on a malformed spec, an unknown or287 SystemExit: Via `ConfigError` on a malformed spec, an unknown or
288 non-`vegetation_*` key, or a repeated key.288 non-`vegetation_*` key, or a repeated key.
289 """289 """
290 allowed = {f for f in asdict(Seg3dConfig()) if f.startswith("vegetation_")}290 allowed = {
291 name for name in Seg3dConfig.model_fields
292 if name.startswith("vegetation_")
293 }
291 grid: dict[str, list] = {}294 grid: dict[str, list] = {}
292 for spec in specs:295 for spec in specs:
293 if "=" not in spec:296 if "=" not in spec:
294 raise ConfigError(297 raise ConfigError(
Importance #8: scripts/veg_sweep.py @@ -390,9 +393,11 @@
390 )393 )
391 seg_dir = seg_dirs[0]394 seg_dir = seg_dirs[0]
392 seg_name = seg_dir.name.removeprefix(SEGMENT_DIR_PREFIX)395 seg_name = seg_dir.name.removeprefix(SEGMENT_DIR_PREFIX)
393396
394 off = config_from_dict({**asdict(config), "vegetation_enabled": False})397 off = config_from_dict(
398 {**config.model_dump(), "vegetation_enabled": False}
399 )
395 t0 = time.perf_counter()400 t0 = time.perf_counter()
396 with capture_asphalt_mask() as captured:401 with capture_asphalt_mask() as captured:
397 result = fuse_segment(402 result = fuse_segment(
398 seg_dir=seg_dir,403 seg_dir=seg_dir,
Importance #9: scripts/veg_sweep.py @@ -440,9 +445,9 @@
440 classification=classification,445 classification=classification,
441 candidate_class=_rollback_vehicles(classification),446 candidate_class=_rollback_vehicles(classification),
442 rep_index=np.asarray(result.rep_index, dtype=np.int64),447 rep_index=np.asarray(result.rep_index, dtype=np.int64),
443 is_asphalt=np.ascontiguousarray(is_asphalt, dtype=bool),448 is_asphalt=np.ascontiguousarray(is_asphalt, dtype=bool),
444 base_config=json.dumps(asdict(off)),449 base_config=json.dumps(off.model_dump()),
445 geoshift=geoshift,450 geoshift=geoshift,
446 tree_rows=[451 tree_rows=[
447 np.asarray(inst.global_rows, dtype=np.int64)452 np.asarray(inst.global_rows, dtype=np.int64)
448 for inst in result.instances453 for inst in result.instances
Importance #10: scripts/veg_sweep.py @@ -491,9 +496,9 @@
491 """496 """
492 if not base.base_config:497 if not base.base_config:
493 return []498 return []
494 was = json.loads(base.base_config)499 was = json.loads(base.base_config)
495 now = asdict(config)500 now = config.model_dump()
496 return sorted(501 return sorted(
497 k for k in now502 k for k in now
498 if not k.startswith("vegetation_") and was.get(k, now[k]) != now[k]503 if not k.startswith("vegetation_") and was.get(k, now[k]) != now[k]
499 )504 )
Importance #11: scripts/veg_sweep.py @@ -646,9 +651,10 @@
646651
647def veg_params(config: Seg3dConfig) -> dict:652def veg_params(config: Seg3dConfig) -> dict:
648 """Returns every `vegetation_*` knob of a config."""653 """Returns every `vegetation_*` knob of a config."""
649 return {654 return {
650 k: v for k, v in asdict(config).items() if k.startswith("vegetation_")655 k: v for k, v in config.model_dump().items()
656 if k.startswith("vegetation_")
651 }657 }
652658
653659
654def column_height_histogram(660def column_height_histogram(
Importance #12: scripts/veg_sweep.py @@ -774,9 +780,9 @@
774780
775781
776def _default_distance(combo: dict) -> int:782def _default_distance(combo: dict) -> int:
777 """Number of grid values in `combo` that differ from the defaults."""783 """Number of grid values in `combo` that differ from the defaults."""
778 defaults = asdict(Seg3dConfig())784 defaults = Seg3dConfig().model_dump()
779 return sum(1 for k, v in combo.items() if defaults[k] != v)785 return sum(1 for k, v in combo.items() if defaults[k] != v)
780786
781787
782def _cached_geoshift(788def _cached_geoshift(
Importance #13: scripts/veg_sweep.py @@ -901,9 +907,9 @@
901 combos[i]907 combos[i]
902 ))908 ))
903 summary: list[dict] = []909 summary: list[dict] = []
904 for i, (combo, label) in enumerate(zip(combos, labels, strict=True)):910 for i, (combo, label) in enumerate(zip(combos, labels, strict=True)):
905 cfg = config_from_dict({**asdict(base_cfg), **combo})911 cfg = config_from_dict({**base_cfg.model_dump(), **combo})
906 t0 = time.perf_counter()912 t0 = time.perf_counter()
907 if cfg.vegetation_enabled:913 if cfg.vegetation_enabled:
908 veg = vegetation.classify_vegetation(914 veg = vegetation.classify_vegetation(
909 base.points,915 base.points,
Importance #14: src/iolabs_point_cloud_segmentation_3d/cli.py @@ -447,9 +447,9 @@
447 """447 """
448 out_root = Path(out_root)448 out_root = Path(out_root)
449 out_root.mkdir(parents=True, exist_ok=True)449 out_root.mkdir(parents=True, exist_ok=True)
450 path = out_root / "run_summary.json"450 path = out_root / "run_summary.json"
451 config_dict = asdict(config)451 config_dict = config.model_dump()
452 if voxel_override is not None:452 if voxel_override is not None:
453 # `--voxel` overrides config.voxel_size_m per-segment (see453 # `--voxel` overrides config.voxel_size_m per-segment (see
454 # fuse_segment); reflect that in the resolved config reported here so454 # fuse_segment); reflect that in the resolved config reported here so
455 # it doesn't disagree with segments[*].params.voxel_size_m.455 # it doesn't disagree with segments[*].params.voxel_size_m.
Importance #15: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -5,9 +5,9 @@
5Produces a `FuseResult` for the writers and renderer.5Produces a `FuseResult` for the writers and renderer.
6"""6"""
77
8import logging8import logging
9from dataclasses import asdict, dataclass, field, fields9from dataclasses import dataclass, field
10from pathlib import Path10from pathlib import Path
1111
12import numpy as np12import numpy as np
13from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps13from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps
Importance #16: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -39,13 +39,11 @@
39logger = logging.getLogger(__name__)39logger = logging.getLogger(__name__)
4040
41# Default voxel size for CLI help / smoke scripts; the authoritative default41# Default voxel size for CLI help / smoke scripts; the authoritative default
42# lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off42# lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off
43# the dataclass field rather than by building a `Seg3dConfig()` at import43# the model field rather than by building a `Seg3dConfig()` at import
44# time -- importing this module must not construct (and validate) a config.44# time -- importing this module must not construct (and validate) a config.
45DEFAULT_VOXEL: float = next(45DEFAULT_VOXEL: float = Seg3dConfig.model_fields["voxel_size_m"].default
46 f.default for f in fields(Seg3dConfig) if f.name == "voxel_size_m"
47)
4846
4947
50LINES_SOURCES = ("auto", "clusters", "xml")48LINES_SOURCES = ("auto", "clusters", "xml")
5149
Importance #17: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -1653,9 +1651,9 @@
16531651
1654def _stats_params(config: Seg3dConfig, voxel: float) -> dict:1652def _stats_params(config: Seg3dConfig, voxel: float) -> dict:
1655 """Returns the per-segment record of the knobs the run used.1653 """Returns the per-segment record of the knobs the run used.
16561654
1657 Built from the config dataclass itself rather than a hand-written1655 Built from the config model itself rather than a hand-written
1658 list, so a knob added to `Seg3dConfig` is recorded automatically; see1656 list, so a knob added to `Seg3dConfig` is recorded automatically; see
1659 `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is1657 `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is
1660 the one runtime override `fuse_segment` is told about, and it is laid1658 the one runtime override `fuse_segment` is told about, and it is laid
1661 over `config.voxel_size_m` here; the knobs the CLI overrides behind1659 over `config.voxel_size_m` here; the knobs the CLI overrides behind
Importance #18: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -1669,9 +1667,9 @@
1669 A plain JSON-serialisable dict of parameter name -> value.1667 A plain JSON-serialisable dict of parameter name -> value.
1670 """1668 """
1671 params = {1669 params = {
1672 name: value1670 name: value
1673 for name, value in asdict(config).items()1671 for name, value in config.model_dump().items()
1674 if name not in _PARAMS_EXCLUDED_FIELDS1672 if name not in _PARAMS_EXCLUDED_FIELDS
1675 }1673 }
1676 params["voxel_size_m"] = float(voxel)1674 params["voxel_size_m"] = float(voxel)
1677 return params1675 return params
Importance #19: src/iolabs_point_cloud_segmentation_3d/las_modes.py @@ -3,9 +3,11 @@
3`config` validates them at load time, `cli` offers them as argparse3`config` validates them at load time, `cli` offers them as argparse
4`choices=` and `writer` re-checks them at its own entry points. They live in4`choices=` and `writer` re-checks them at its own entry points. They live in
5their own module so neither the config loader nor the CLI parser has to5their own module so neither the config loader nor the CLI parser has to
6import the writer (and with it laspy/open3d) just to know what a valid mode6import the writer (and with it laspy/open3d) just to know what a valid mode
7string is; adding a mode is then a one-line change here.7string is. `config` states the same whitelists as `Literal` types on the
8config model (`LasRgbMode` / `LasSplitMode`), pinned to these tuples by
9`tests/test_config.py`, so adding a mode is one edit here and one there.
8"""10"""
911
10# Valid values for the ``las_rgb_mode`` config knob.12# Valid values for the ``las_rgb_mode`` config knob.
11LAS_RGB_MODES = ("sensor", "class")13LAS_RGB_MODES = ("sensor", "class")
Importance #20: tests/test_config.py @@ -1,23 +1,41 @@
1"""Config parity tests.1"""Config parity tests.
22
3Every dataclass default must equal the packaged JSON default, and the3Every model field default must equal the packaged JSON default, and the
4priority/hash-rounding values must match the code that consumes them.4priority/hash-rounding values must match the code that consumes them.
5"""5"""
66
7import typing
8
7import pytest9import pytest
810
9from iolabs_point_cloud_segmentation_3d import classes11from iolabs_point_cloud_segmentation_3d import classes, las_modes
10from iolabs_point_cloud_segmentation_3d.config import (12from iolabs_point_cloud_segmentation_3d.config import (
11 ConfigError,13 ConfigError,
14 LasRgbMode,
15 LasSplitMode,
12 Seg3dConfig,16 Seg3dConfig,
13 config_from_dict,17 config_from_dict,
14 load_config,18 load_config,
15 load_default_config_dict,19 load_default_config_dict,
16 parse_set_overrides,20 parse_set_overrides,
17)21)
1822
1923
24def test_model_defaults_match_the_packaged_json():
25 assert Seg3dConfig().model_dump() == config_from_dict(
26 load_default_config_dict()
27 ).model_dump()
28 assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields)
29
30
31def test_las_mode_literals_match_las_modes():
32 # The CLI offers `las_modes` as argparse choices and the writer
33 # re-checks them; the config model validates its own Literals.
34 assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES
35 assert typing.get_args(LasSplitMode) == las_modes.LAS_SPLIT_CONFIG_MODES
36
37
20def test_priority_lut_override_changes_lut():38def test_priority_lut_override_changes_lut():
21 cfg = config_from_dict({**load_default_config_dict(), "priority_line": 9})39 cfg = config_from_dict({**load_default_config_dict(), "priority_line": 9})
22 lut = classes.priority_lut(cfg)40 lut = classes.priority_lut(cfg)
23 assert lut[classes.BY_NAME["solid_line"].las_code] == 941 assert lut[classes.BY_NAME["solid_line"].las_code] == 9
Importance #21: tests/test_config.py @@ -29,9 +47,9 @@
29 # the loader must warn so ground can't silently win voxels over asphalt.47 # the loader must warn so ground can't silently win voxels over asphalt.
30 import logging48 import logging
3149
32 with caplog.at_level(50 with caplog.at_level(
33 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.config"51 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d._config_model"
34 ):52 ):
35 config_from_dict({53 config_from_dict({
36 **load_default_config_dict(),54 **load_default_config_dict(),
37 "priority_asphalt": 1, "priority_line": 2, "priority_detector": 3,55 "priority_asphalt": 1, "priority_line": 2, "priority_detector": 3,
Importance #22: tests/test_config.py @@ -42,14 +60,14 @@
42def test_strictly_increasing_tiers_do_not_warn(caplog):60def test_strictly_increasing_tiers_do_not_warn(caplog):
43 import logging61 import logging
4462
45 with caplog.at_level(63 with caplog.at_level(
46 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.config"64 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d._config_model"
47 ):65 ):
48 config_from_dict(load_default_config_dict())66 config_from_dict(load_default_config_dict())
49 assert not [67 assert not [
50 r for r in caplog.records68 r for r in caplog.records
51 if r.name == "iolabs_point_cloud_segmentation_3d.config"69 if r.name == "iolabs_point_cloud_segmentation_3d._config_model"
52 ]70 ]
5371
5472
55def test_hash_rounding_must_be_positive():73def test_hash_rounding_must_be_positive():
Importance #23: tests/test_config.py @@ -368,8 +386,35 @@
368 {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0}386 {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0}
369 )387 )
370388
371389
390def test_cross_field_rule_reports_only_its_own_message():
391 # A cross-field rule is a whole-model validator, so it carries no field
392 # location; the message must stay the rule's own text and not grow a
393 # dump of every config key (which is what an unlocated value error
394 # would otherwise echo back).
395 with pytest.raises(ConfigError) as excinfo:
396 config_from_dict(
397 {**load_default_config_dict(), "vegetation_low_max_m": 3.0}
398 )
399 assert str(excinfo.value) == (
400 "vegetation_low_max_m=3.0 is not supported: it must not exceed "
401 "vegetation_medium_max_m=2.0 -- the low band ends where the medium "
402 "band starts."
403 )
404
405
406def test_naming_rule_reports_only_its_own_message():
407 with pytest.raises(ConfigError) as excinfo:
408 config_from_dict(
409 {**load_default_config_dict(), "date_tag": "notadate"}
410 )
411 assert str(excinfo.value) == (
412 "date_tag='notadate' is not supported: expected empty or a "
413 "six-digit YYMMDD tag."
414 )
415
416
372def test_vegetation_limiters_validated_through_set_overrides():417def test_vegetation_limiters_validated_through_set_overrides():
373 # `--set` is the path these values actually arrive on in a sweep.418 # `--set` is the path these values actually arrive on in a sweep.
374 for override in (419 for override in (
375 "vegetation_band_cell_m=0",420 "vegetation_band_cell_m=0",
Importance #24: tests/test_vegetation.py @@ -498,9 +498,11 @@
498 assert extra["over_asphalt"] in result.medium_rows498 assert extra["over_asphalt"] in result.medium_rows
499499
500500
501def test_classify_vegetation_rejects_unknown_asphalt_rule():501def test_classify_vegetation_rejects_unknown_asphalt_rule():
502 with pytest.raises(ValueError, match="asphalt rule"):502 # The rule is a Literal on the config model, so an unknown value dies
503 # at config construction -- before vegetation.py's defensive branch.
504 with pytest.raises(ValueError, match="vegetation_asphalt_rule"):
503 _run(_config(vegetation_asphalt_rule="nonsense"))505 _run(_config(vegetation_asphalt_rule="nonsense"))
504506
505507
506def test_classify_vegetation_rebands_short_detector_trees():508def test_classify_vegetation_rebands_short_detector_trees():
Importance #25: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-segmentation-3d"2name = "iolabs-point-cloud-segmentation-3d"
3version = "0.3.1"3version = "0.3.2"
4description = "3D segmentation of MLS LiDAR point clouds"4description = "3D segmentation of MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11,<3.13"6requires-python = ">=3.11,<3.13"
7dependencies = [7dependencies = [
Importance #26: pyproject.toml @@ -9,8 +9,9 @@
9 "scipy>=1.13",9 "scipy>=1.13",
10 "shapely>=2.0",10 "shapely>=2.0",
11 "open3d>=0.19.0",11 "open3d>=0.19.0",
12 "laspy>=2.5",12 "laspy>=2.5",
13 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",14 "iolabs-common>=0.8.0",
14 "iolabs-geometry-geometry>=0.11.0",15 "iolabs-geometry-geometry>=0.11.0",
15]16]
1617
Importance #27: README.md @@ -142,11 +142,16 @@
142142
143## Config143## Config
144144
145All numeric thresholds live in the package-owned `seg3d.default.json`, loaded145All numeric thresholds live in the package-owned `seg3d.default.json`, loaded
146into a frozen `Seg3dConfig` dataclass (`config.py`). Every dataclass field146into a frozen `Seg3dConfig` pydantic model (`_config_model.py`, derived from
147default is kept identical to the JSON, asserted by `tests/test_config.py`, so147`iolabs.common.config_loader.ConfigModel`; `config.py` is the loading entry
148`Seg3dConfig()` and `load_config()` always agree.148point). Every model field default is kept identical to the JSON, asserted by
149`tests/test_config.py`, so `Seg3dConfig()` and `load_config()` always agree.
150Adding a knob is two edits: the field on `Seg3dConfig` (its range expressed as
151`pydantic.Field(...)` bounds or a model validator) and the same key with the
152same default in `seg3d.default.json` -- unknown-key rejection, value coercion
153and the error messages come from the shared layer.
149154
150Override without editing the packaged default:155Override without editing the packaged default:
151156
152```bash157```bash
Importance #28: docs/fusion_spec.md @@ -544,9 +544,10 @@
544544
545Match `verticalsigns` / `asphaltedge` / `guardrails` style:545Match `verticalsigns` / `asphaltedge` / `guardrails` style:
546546
547- Package-owned algorithm config `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`547- Package-owned algorithm config `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`
548 + `config.py` with a frozen dataclass loader; every dataclass default identical to548 + `config.py` loading the frozen `Seg3dConfig` pydantic model (`_config_model.py`,
549 on `iolabs.common.config_loader.ConfigModel`); every model default identical to
549 the JSON, asserted by `tests/test_config.py` (guardrails pattern). CLI `--set KEY=VALUE`550 the JSON, asserted by `tests/test_config.py` (guardrails pattern). CLI `--set KEY=VALUE`
550 or `--config` overrides; thresholds (voxel size, paint radius, z-gate, priorities'551 or `--config` overrides; thresholds (voxel size, paint radius, z-gate, priorities'
551 numeric values, hash rounding) live in the config, not as scattered constants.552 numeric values, hash rounding) live in the config, not as scattered constants.
552- Write `<out>/run_summary.json` with per-segment `name_base`,553- Write `<out>/run_summary.json` with per-segment `name_base`,
Importance #29: docs/fusion_spec.md @@ -554,9 +555,9 @@
554 (guardrails/asphaltedge pattern).555 (guardrails/asphaltedge pattern).
555- `pyproject.toml`: hatch `force-include` of the default JSON into the wheel556- `pyproject.toml`: hatch `force-include` of the default JSON into the wheel
556 (verticalsigns pattern), `[tool.uv] publish-url` nexus, dev group pytest.557 (verticalsigns pattern), `[tool.uv] publish-url` nexus, dev group pytest.
557- README: short intro, `uv sync` + `uv run seg3d-fuse โ€ฆ` example, config section558- README: short intro, `uv sync` + `uv run seg3d-fuse โ€ฆ` example, config section
558 explaining the default-JSON/dataclass parity, class table.559 explaining the default-JSON/model parity, class table.
559- Logging via the stdlib `logging` module with `--log-level` flag, not prints.560- Logging via the stdlib `logging` module with `--log-level` flag, not prints.
560561
561## Dependencies562## Dependencies
562563
Importance #30: docs/fusion_spec.md @@ -544,9 +544,10 @@
544544
545Match `verticalsigns` / `asphaltedge` / `guardrails` style:545Match `verticalsigns` / `asphaltedge` / `guardrails` style:
546546
547- Package-owned algorithm config `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`547- Package-owned algorithm config `src/iolabs_point_cloud_segmentation_3d/seg3d.default.json`
548 + `config.py` with a frozen dataclass loader; every dataclass default identical to548 + `config.py` loading the frozen `Seg3dConfig` pydantic model (`_config_model.py`,
549 on `iolabs.common.config_loader.ConfigModel`); every model default identical to
549 the JSON, asserted by `tests/test_config.py` (guardrails pattern). CLI `--set KEY=VALUE`550 the JSON, asserted by `tests/test_config.py` (guardrails pattern). CLI `--set KEY=VALUE`
550 or `--config` overrides; thresholds (voxel size, paint radius, z-gate, priorities'551 or `--config` overrides; thresholds (voxel size, paint radius, z-gate, priorities'
551 numeric values, hash rounding) live in the config, not as scattered constants.552 numeric values, hash rounding) live in the config, not as scattered constants.
552- Write `<out>/run_summary.json` with per-segment `name_base`,553- Write `<out>/run_summary.json` with per-segment `name_base`,
Importance #31: docs/fusion_spec.md @@ -554,9 +555,9 @@
554 (guardrails/asphaltedge pattern).555 (guardrails/asphaltedge pattern).
555- `pyproject.toml`: hatch `force-include` of the default JSON into the wheel556- `pyproject.toml`: hatch `force-include` of the default JSON into the wheel
556 (verticalsigns pattern), `[tool.uv] publish-url` nexus, dev group pytest.557 (verticalsigns pattern), `[tool.uv] publish-url` nexus, dev group pytest.
557- README: short intro, `uv sync` + `uv run seg3d-fuse โ€ฆ` example, config section558- README: short intro, `uv sync` + `uv run seg3d-fuse โ€ฆ` example, config section
558 explaining the default-JSON/dataclass parity, class table.559 explaining the default-JSON/model parity, class table.
559- Logging via the stdlib `logging` module with `--log-level` flag, not prints.560- Logging via the stdlib `logging` module with `--log-level` flag, not prints.
560561
561## Dependencies562## Dependencies
562563
Importance #32: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-segmentation-3d"2name = "iolabs-point-cloud-segmentation-3d"
3version = "0.3.1"3version = "0.3.2"
4description = "3D segmentation of MLS LiDAR point clouds"4description = "3D segmentation of MLS LiDAR point clouds"
5readme = "README.md"5readme = "README.md"
6requires-python = ">=3.11,<3.13"6requires-python = ">=3.11,<3.13"
7dependencies = [7dependencies = [
Importance #33: pyproject.toml @@ -9,8 +9,9 @@
9 "scipy>=1.13",9 "scipy>=1.13",
10 "shapely>=2.0",10 "shapely>=2.0",
11 "open3d>=0.19.0",11 "open3d>=0.19.0",
12 "laspy>=2.5",12 "laspy>=2.5",
13 "pydantic>=2.7",
13 "iolabs-common>=0.8.0",14 "iolabs-common>=0.8.0",
14 "iolabs-geometry-geometry>=0.11.0",15 "iolabs-geometry-geometry>=0.11.0",
15]16]
1617
Importance #34: scripts/veg_sweep.py @@ -64,9 +64,9 @@
64import json64import json
65import logging65import logging
66import sys66import sys
67import time67import time
68from dataclasses import asdict, dataclass, field68from dataclasses import dataclass, field
69from pathlib import Path69from pathlib import Path
7070
71import numpy as np71import numpy as np
72from iolabs.common.crs import looks_georeferenced72from iolabs.common.crs import looks_georeferenced
Importance #35: scripts/veg_sweep.py @@ -286,9 +286,12 @@
286 Raises:286 Raises:
287 SystemExit: Via `ConfigError` on a malformed spec, an unknown or287 SystemExit: Via `ConfigError` on a malformed spec, an unknown or
288 non-`vegetation_*` key, or a repeated key.288 non-`vegetation_*` key, or a repeated key.
289 """289 """
290 allowed = {f for f in asdict(Seg3dConfig()) if f.startswith("vegetation_")}290 allowed = {
291 name for name in Seg3dConfig.model_fields
292 if name.startswith("vegetation_")
293 }
291 grid: dict[str, list] = {}294 grid: dict[str, list] = {}
292 for spec in specs:295 for spec in specs:
293 if "=" not in spec:296 if "=" not in spec:
294 raise ConfigError(297 raise ConfigError(
Importance #36: scripts/veg_sweep.py @@ -390,9 +393,11 @@
390 )393 )
391 seg_dir = seg_dirs[0]394 seg_dir = seg_dirs[0]
392 seg_name = seg_dir.name.removeprefix(SEGMENT_DIR_PREFIX)395 seg_name = seg_dir.name.removeprefix(SEGMENT_DIR_PREFIX)
393396
394 off = config_from_dict({**asdict(config), "vegetation_enabled": False})397 off = config_from_dict(
398 {**config.model_dump(), "vegetation_enabled": False}
399 )
395 t0 = time.perf_counter()400 t0 = time.perf_counter()
396 with capture_asphalt_mask() as captured:401 with capture_asphalt_mask() as captured:
397 result = fuse_segment(402 result = fuse_segment(
398 seg_dir=seg_dir,403 seg_dir=seg_dir,
Importance #37: scripts/veg_sweep.py @@ -440,9 +445,9 @@
440 classification=classification,445 classification=classification,
441 candidate_class=_rollback_vehicles(classification),446 candidate_class=_rollback_vehicles(classification),
442 rep_index=np.asarray(result.rep_index, dtype=np.int64),447 rep_index=np.asarray(result.rep_index, dtype=np.int64),
443 is_asphalt=np.ascontiguousarray(is_asphalt, dtype=bool),448 is_asphalt=np.ascontiguousarray(is_asphalt, dtype=bool),
444 base_config=json.dumps(asdict(off)),449 base_config=json.dumps(off.model_dump()),
445 geoshift=geoshift,450 geoshift=geoshift,
446 tree_rows=[451 tree_rows=[
447 np.asarray(inst.global_rows, dtype=np.int64)452 np.asarray(inst.global_rows, dtype=np.int64)
448 for inst in result.instances453 for inst in result.instances
Importance #38: scripts/veg_sweep.py @@ -491,9 +496,9 @@
491 """496 """
492 if not base.base_config:497 if not base.base_config:
493 return []498 return []
494 was = json.loads(base.base_config)499 was = json.loads(base.base_config)
495 now = asdict(config)500 now = config.model_dump()
496 return sorted(501 return sorted(
497 k for k in now502 k for k in now
498 if not k.startswith("vegetation_") and was.get(k, now[k]) != now[k]503 if not k.startswith("vegetation_") and was.get(k, now[k]) != now[k]
499 )504 )
Importance #39: scripts/veg_sweep.py @@ -646,9 +651,10 @@
646651
647def veg_params(config: Seg3dConfig) -> dict:652def veg_params(config: Seg3dConfig) -> dict:
648 """Returns every `vegetation_*` knob of a config."""653 """Returns every `vegetation_*` knob of a config."""
649 return {654 return {
650 k: v for k, v in asdict(config).items() if k.startswith("vegetation_")655 k: v for k, v in config.model_dump().items()
656 if k.startswith("vegetation_")
651 }657 }
652658
653659
654def column_height_histogram(660def column_height_histogram(
Importance #40: scripts/veg_sweep.py @@ -774,9 +780,9 @@
774780
775781
776def _default_distance(combo: dict) -> int:782def _default_distance(combo: dict) -> int:
777 """Number of grid values in `combo` that differ from the defaults."""783 """Number of grid values in `combo` that differ from the defaults."""
778 defaults = asdict(Seg3dConfig())784 defaults = Seg3dConfig().model_dump()
779 return sum(1 for k, v in combo.items() if defaults[k] != v)785 return sum(1 for k, v in combo.items() if defaults[k] != v)
780786
781787
782def _cached_geoshift(788def _cached_geoshift(
Importance #41: scripts/veg_sweep.py @@ -901,9 +907,9 @@
901 combos[i]907 combos[i]
902 ))908 ))
903 summary: list[dict] = []909 summary: list[dict] = []
904 for i, (combo, label) in enumerate(zip(combos, labels, strict=True)):910 for i, (combo, label) in enumerate(zip(combos, labels, strict=True)):
905 cfg = config_from_dict({**asdict(base_cfg), **combo})911 cfg = config_from_dict({**base_cfg.model_dump(), **combo})
906 t0 = time.perf_counter()912 t0 = time.perf_counter()
907 if cfg.vegetation_enabled:913 if cfg.vegetation_enabled:
908 veg = vegetation.classify_vegetation(914 veg = vegetation.classify_vegetation(
909 base.points,915 base.points,
Importance #42: src/iolabs_point_cloud_segmentation_3d/_config_model.py @@ -0,0 +1,417 @@
1"""The `Seg3dConfig` schema: every knob, its default and its range.
2
3The model mirrors `seg3d.default.json` key for key -- adding a knob is a
4field here plus the same key with the same default there. Unknown keys,
5value coercion and the error messages come from
6`iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field`
7bounds, cross-field rules are model validators. `config.py` is the public
8entry point (loading, merging, `--set` parsing) and re-exports both names.
9
10Distances are in metres unless the field name says otherwise.
11"""
12
13import logging
14from typing import Literal
15
16import pydantic
17from iolabs.common import config_loader
18
19from . import naming
20
21logger = logging.getLogger(__name__)
22
23# Upper bound of `vegetation_asphalt_dilate_cells`: an accepted value must
24# stay cheap to iterate (each cell is a full 3x3 dilation of the asphalt
25# raster, so a mistyped 500 grinds for minutes inside a fusion run).
26# 64 cells is 16 m of reach at the shipped 0.25 m cell, far past any verge.
27_MAX_DILATE_CELLS = 64
28
29# The LAS output-mode whitelists, as the type the config validates against.
30# They mirror `las_modes`, which the CLI (`choices=`) and the writer use;
31# `tests/test_config.py` pins the two spellings together.
32LasRgbMode = Literal["sensor", "class"]
33LasSplitMode = Literal["none", "class", "instance"]
34
35
36class ConfigError(config_loader.ConfigError):
37 """Raised when the seg3d config contains unsupported keys."""
38
39
40class Seg3dConfig(config_loader.ConfigModel):
41 """Numeric thresholds for the fusion pipeline (metres unless stated).
42
43 The per-field comments below carry the rationale; this section is the
44 map of the groups they fall into.
45
46 Attributes:
47 voxel_size_m, edge_extend_m: Voxel decimation grid and the extra
48 length kept around a segment.
49 line_*: XML line painting (the fallback line source).
50 guard_*: Alignment guards on the mask and vertex hit rates.
51 hash_round_units_per_m: Quantization of the integer XYZ hash join.
52 surface_mesh_tolerance_m: Fallback road-surface distance to the
53 step-9 carriageway meshes.
54 vehicle_enabled: Paint the residual above the carriageway.
55 signs_json_paint_*: Cylinder paint for sign detections that carry no
56 point mask.
57 vegetation_*: Colour-plus-height vegetation stage (greenness,
58 asphalt/corridor guards, height banding, ground model).
59 priority_*: Class priority tiers (high wins) for the voxel
60 representative pick.
61 las_rgb_mode, las_split, las_georeference, las_crs_epsg, write_ply:
62 Output files, their colours and georeferencing.
63 memory_budget_gb: Peak-RSS warning threshold, in GB.
64 name_template, dataset_tag, branch_tag, date_tag: Output file
65 naming (see `naming.py`).
66 """
67
68 # Voxel decimation. 1 cm is the production default: it keeps thin
69 # painted features (lines, posts, rail tubes) intact for annotation, at
70 # the cost of a much larger decimated cloud and higher peak memory (see
71 # `memory_budget_gb`).
72 voxel_size_m: float = 0.01
73 edge_extend_m: float = 60.0
74
75 # XML line painting (fallback line source).
76 line_xy_radius_m: float = 0.20
77 line_z_gate_m: float = 0.5
78 line_resample_step_m: float = 0.05
79 line_bbox_pad_m: float = 2.0
80
81 # Alignment guards.
82 guard_bbox_pad_m: float = 5.0
83 guard_mask_rate: float = 0.99
84 guard_vertex_rate: float = 0.95
85
86 # Quantization of the integer XYZ hash join, in units per metre (1000 ->
87 # 1 mm). Threaded through to `iolabs.common.point_hash`; a join is only
88 # exact if every producer of the joined data used the same value, so
89 # changing it is a fleet-wide decision, not a per-run knob.
90 hash_round_units_per_m: float = pydantic.Field(
91 default=1000.0, gt=0.0, allow_inf_nan=False
92 )
93
94 # Fallback road-surface source for datasets whose pipeline generation
95 # emits no run4 road-surface npz: distance to the step-9 carriageway
96 # meshes below which a point counts as road surface (see
97 # surface_mesh.py). Covers scan noise and the mesh's own cell size.
98 surface_mesh_tolerance_m: float = 0.15
99
100 # Vehicles / noise above the carriageway: paint every point still
101 # unclassified after all other stages whose XY falls between the outer
102 # asphalt edges. Set false to reproduce pre-feature output.
103 vehicle_enabled: bool = True
104
105 # Sign detections that exist only in `verticalsigns.json`. The detector
106 # writes `point_masks.npz` during its per-segment pass but keeps appending
107 # detections in later corridor-level post-passes (lattice admission,
108 # reject-rescue, rail half-posts), so those records carry no mask points
109 # and would be invisible in the fused output. They are painted instead
110 # from a cylinder around the detection record: XY radius
111 # clamp(0.5 * max(footprint_m), 0.15, radius_max) + 0.10 m, z window
112 # [z_ground - z_pad_bottom, z_top + z_pad_top]. Only points still
113 # UNCLASSIFIED are painted -- the pass runs after the detector masks and
114 # the guardrail JSON fallback, so a guardrail, its support or any other
115 # class keeps precedence -- and a detection needs at least `min_points`
116 # of them to be painted at all (no phantom instances). `gantry_or_gate`
117 # records are skipped outright: their `position` is the midpoint between
118 # the two posts and `footprint_m[0]` the post separation, so the cylinder
119 # would sit over the carriageway and reach neither post.
120 #
121 # `z_pad_bottom` is 0 by default -- the pass starts AT the detection's
122 # `z_ground`. Note the asymmetry with `guardrail_json`, which starts its
123 # rail band at ground + 0.05 to stay off the ground itself; here the
124 # ground is already painted and only unclassified points are taken, so
125 # no lift is needed and a negative pad only reached under the ground.
126 # The knob stays for detector builds whose `z_ground` runs optimistic.
127 signs_json_paint_enabled: bool = True
128 signs_json_paint_radius_max_m: float = 2.0
129 signs_json_paint_z_pad_top_m: float = 0.30
130 signs_json_paint_z_pad_bottom_m: float = 0.0
131 signs_json_paint_min_points: int = 10
132
133 # Vegetation from colour + height above ground (see `vegetation.py`).
134 # Runs between the sign JSON paint and the vehicle sweep, on points no
135 # earlier stage claimed: a point that is GREEN and not over asphalt is
136 # vegetation, and its height above a ground model decides whether it is
137 # low (grass), medium (bushes, hedges) or tall (tree). Candidates are the
138 # points still UNCLASSIFIED plus -- `from_ground` defaults to TRUE --
139 # the tablecloth's `ground` rows, for the common case where the
140 # tablecloth kept the verge grass and only colour tells it from gravel.
141 # `from_ground=false` restores Miro's original "green and ABOVE the
142 # ground layer" and leaves the whole tablecloth as ground.
143 #
144 # WHY the default is on: the owner compared delivered LAS with both
145 # settings (2026-08-28, segment 085). The rows the rule adds are a
146 # continuous grass carpet on the verge, so they are wanted as LAS 3/4;
147 # the median is unaffected either way (lost to the guardrail/vehicle
148 # guards before this stage runs).
149 #
150 # Every detector class, the lines and the asphalt keep precedence by
151 # construction.
152 #
153 # Greenness is the chromatic excess green ExG = (2G - R - B) / (R + G + B)
154 # >= `green_exg_min`, with G >= `green_rg_ratio` * R and G > B. Dividing
155 # by the sum makes it
156 # invariant to exposure and to the storage scale, so the same threshold
157 # holds for the 16-bit A1 clouds and for datasets that keep 8-bit values
158 # in the uint16 RGB fields. `green_min_brightness` is an optional floor on
159 # R+G+B *in the source's own scale* (hence 0 = off, not a fraction): very
160 # dark returns have noisy chroma. `green_rg_ratio` is 0.90 rather than the
161 # strict G > R the rule started with: dry khaki grass has R ~ G with a
162 # strong blue deficit, so ExG clears 0.10 on the blue alone while G > R
163 # threw the grass away (337k of the 484k ExG candidates on A1 segment
164 # 085, ~98 % of the misses on 002/003). Brown soil sits near ExG 0.05, so
165 # ExG still separates it. 1.0 means "G at least R", i.e. the old rule.
166 # 0.90 rather than 0.95 after the AI3D-373 visual sweep (two segments,
167 # four vision judges): it recovers straw/khaki grass and dim shrubs -- on
168 # 085 the khaki bank went 8.9k -> 13.3k painted points and the recruits
169 # have mean RGB 114/109/58 with R > G in 79 % of them -- while the
170 # ground, asphalt and guardrail counts stayed byte-identical across all
171 # nine colour settings, so the extra green is taken from `unclassified`
172 # only. `green_exg_min` stays 0.10: 0.06 was the judges' favourite but
173 # painted ~50 near-black airborne points over a carriageway, and it can
174 # be revisited once a `green_min_brightness` gate is validated.
175 #
176 # `asphalt_rule` decides what "over the road" means.
177 # `"asphalt_column"` (default) rasterises the asphalt points into
178 # `asphalt_cell_m` (0.25 m) XY cells, counts a cell occupied from
179 # `asphalt_min_points` (5) asphalt points, grows the occupancy by
180 # `asphalt_dilate_cells` 3x3 dilations (0 = none) and rejects every
181 # candidate landing in an occupied cell. The three knobs are separate
182 # from the band grid because the first version (0.5 m cells, one
183 # dilation, one point is enough) reached 1-1.5 m past the pavement edge:
184 # wider than a median strip, which it then masked from both sides, and it
185 # ate the first metres of every verge (75k points on A1 085) while a
186 # single stray asphalt-class point inside the median seeded the guard.
187 # The alternative `"corridor"` rejects everything inside the corridor
188 # polygon, which on a dual carriageway also swallows the median strip and
189 # leaves its grass and hedges to the vehicle sweep.
190 #
191 # `corridor_rail_m` (2 m) is a SECOND, independent guard that only runs
192 # under `"asphalt_column"` and only where a corridor exists: a candidate
193 # inside the corridor polygon is rejected unless a barrier point
194 # (`guardrail`, `guardrail_support`, `guardrail_top_rail`, `wall`) lies
195 # within this distance, measured on the `asphalt_cell_m` raster. WHY: a
196 # road stretch the step-9 surface mesh missed carries no asphalt class at
197 # all (A1 085 matched 77 % of its mesh), so its occupancy cells are empty
198 # and the ghost trails of passing vehicles standing on it passed the
199 # column guard and were painted vegetation -- before the vegetation stage
200 # existed the vehicle sweep claimed them, and the sweep runs after this
201 # stage and only takes unclassified rows. A median is recognisable by the
202 # barriers running down it: on 085 all but 321 of the 50,946 in-corridor
203 # vegetation points sit within 2 m of a rail (p90 = 0.43 m), and 270 of
204 # those 321 were `vehicle` one run earlier. 0 disables the rule.
205 #
206 # `corridor_max_height_m` (0.5 m) is the SECOND half of that rule and the
207 # reason the first half is not enough: the barrier a median is recognised
208 # by stands on the median, so the rail's own green returns -- and the
209 # ghost of whatever brushed past it -- are always within `rail_m` of a
210 # barrier and the exception waves them all through. A median carries mown
211 # grass and nothing else, so an in-corridor candidate more than this above
212 # the DTM is the rail or a road ghost. On A1 085 the in-corridor
213 # `low_vegetation` reaches p95 = 0.40 m while 70 % of the in-corridor
214 # `medium_vegetation` stands above 0.5 m, 0.6-1.5 m up, within 0.05 m of a
215 # guardrail point and in 0.25 m cells holding 80-550 of them: the
216 # lane-parallel teal streaks the QC raster of 085 showed along the median
217 # and its shoulders. Because the cap runs BEFORE the banding it also
218 # un-bands the grass around what it takes: the column p95 that made a
219 # whole cell `medium` drops back to grass height once the rail's returns
220 # are gone. Same scope as `corridor_rail_m` (`"asphalt_column"` only, a
221 # corridor only), and 0 disables it -- which, with a median hedge to keep,
222 # is the setting to reach for.
223 #
224 # `band_mode="column"` (default) bands whole `band_cell_m` XY columns by
225 # the `column_percentile` of the candidate heights in them, so a hedge is
226 # medium from its foot up and a tree is tall down to its trunk;
227 # `"point"` bands each point by its own height and gives every bush a low
228 # skirt. Either way a cell with fewer than `min_cell_points` (10) green
229 # candidates is dropped whole (counted as `vegetation_sparse_rejected`):
230 # such cells are isolated speckle -- 13.5 % of the medium cells on 085
231 # were 1-4-cell blocks of column-p95 noise at the guardrail foot, and
232 # 12 % of the low points on 002 were single green returns on the hard
233 # shoulder. `min_height_m` drops candidates below the ground model (DTM
234 # artefacts).
235 #
236 # `tall_class` decides what the tall band is painted, and its default is
237 # `"medium_vegetation"`: a hedge is tall vegetation that the detector did
238 # NOT call a tree, and hedges and trees are exclusive, so this stage
239 # never creates `tree` on its own -- green residual above `low_max_m` is
240 # medium however tall it grows. `medium_max_m` therefore only bites under
241 # `tall_class="tree"` (trust colour+height with the detector) or
242 # `"unclassified"` (leave the tall residual to a human).
243 #
244 # `low_max_m` is 0.7 m (AI3D-373): at 0.5 m the khaki toe-grass along the
245 # guardrails and the sparse rims of the verge banks were banded medium,
246 # and 0.7 m is the only threshold in the sweep that flips exactly those
247 # and nothing else -- bush and hedge interiors were pixel-identical
248 # between the two settings. `column_percentile` stays 95: p80 under-reads
249 # canopy tops and puts low skirts back inside the hedges.
250 #
251 # `tree_min_height_m` > 0 is a safety net for the detector: a
252 # verticalsigns `tree` instance whose points are all shorter than this is
253 # repainted `medium_vegetation` (a bush the detector called a tree). 0 is
254 # off, which leaves every detector tree exactly as the mask painted it.
255 vegetation_enabled: bool = True
256 vegetation_from_ground: bool = True
257 vegetation_asphalt_rule: Literal["asphalt_column", "corridor"] = "asphalt_column"
258 vegetation_asphalt_cell_m: float = pydantic.Field(
259 default=0.25, gt=0.0, allow_inf_nan=False
260 )
261 vegetation_asphalt_dilate_cells: int = pydantic.Field(
262 default=0, ge=0, le=_MAX_DILATE_CELLS
263 )
264 vegetation_asphalt_min_points: int = pydantic.Field(default=5, ge=0)
265 vegetation_corridor_rail_m: float = pydantic.Field(
266 default=2.0, ge=0.0, allow_inf_nan=False
267 )
268 vegetation_corridor_max_height_m: float = pydantic.Field(
269 default=0.5, ge=0.0, allow_inf_nan=False
270 )
271 vegetation_tree_min_height_m: float = pydantic.Field(
272 default=0.0, allow_inf_nan=False
273 )
274 vegetation_low_max_m: float = pydantic.Field(
275 default=0.7, gt=0.0, allow_inf_nan=False
276 )
277 vegetation_medium_max_m: float = pydantic.Field(
278 default=2.0, gt=0.0, allow_inf_nan=False
279 )
280 vegetation_min_height_m: float = pydantic.Field(
281 default=-0.5, allow_inf_nan=False
282 )
283 vegetation_tall_class: Literal[
284 "medium_vegetation", "tree", "unclassified"
285 ] = "medium_vegetation"
286 vegetation_band_mode: Literal["column", "point"] = "column"
287 vegetation_band_cell_m: float = pydantic.Field(
288 default=0.5, gt=0.0, allow_inf_nan=False
289 )
290 vegetation_column_percentile: float = pydantic.Field(
291 default=95.0, ge=0.0, le=100.0, allow_inf_nan=False
292 )
293 vegetation_min_cell_points: int = pydantic.Field(default=10, ge=0)
294 vegetation_green_exg_min: float = pydantic.Field(
295 default=0.10, allow_inf_nan=False
296 )
297 vegetation_green_rg_ratio: float = pydantic.Field(
298 default=0.90, gt=0.0, allow_inf_nan=False
299 )
300 vegetation_green_min_brightness: float = pydantic.Field(
301 default=0.0, allow_inf_nan=False
302 )
303 vegetation_ground_cell_m: float = pydantic.Field(
304 default=1.0, gt=0.0, allow_inf_nan=False
305 )
306 vegetation_ground_percentile: float = pydantic.Field(
307 default=10.0, ge=0.0, le=100.0, allow_inf_nan=False
308 )
309 vegetation_min_ground_points: int = pydantic.Field(default=1000, ge=1)
310
311 # Class priority tiers (high wins) used by the voxel representative pick.
312 priority_unclassified: int = 0
313 priority_ground: int = 1
314 priority_asphalt: int = 2
315 priority_line: int = 3
316 priority_detector: int = 4
317 # Guardrail supports (posts) and the median rail's top rail (box tube)
318 # sit above the other detector classes: such a voxel almost always also
319 # holds rail points and would lose the tier-4 count tie-break, dropping
320 # the decomposition out of the decimated cloud. One knob for the whole of
321 # tier 5 -- post and tube are two halves of the same decomposition.
322 priority_support: int = 5
323
324 # ReCap LAS export RGB: "sensor" keeps source-cloud colors, "class"
325 # bakes the class palette in (fallback for viewers without LAS
326 # classification display).
327 las_rgb_mode: LasRgbMode = "sensor"
328
329 # Extra LAS files for per-object review in ReCap (each imported file is
330 # an isolatable scan in the Project Navigator): "none", "class" (one
331 # file per class) or "instance" (one file per detected object).
332 las_split: LasSplitMode = "none"
333
334 # Write the class-colored PLY next to the npz/LAS. Off by default: the
335 # deliverable is the LAS, and at the 1 cm voxel the PLY is a large file
336 # nobody in the annotation loop opens. CLI `--ply` turns it back on.
337 write_ply: bool = False
338
339 # Add the dataset's run3 geoshift back to the LAS coordinates so the
340 # deliverable is in true world coordinates (the npz/ply stay in the
341 # pipeline frame, which is the hash-join key).
342 las_georeference: bool = True
343
344 # EPSG code embedded as a LAS 1.4 WKT VLR; 0 disables. 25832 is
345 # ETRS89 / UTM zone 32N, the CRS of this project's source scans. Only
346 # written when the exported coordinates are actually world coordinates.
347 las_crs_epsg: int = pydantic.Field(default=25832, ge=0)
348
349 # Reporting: warn when peak RSS exceeds this (GB); recorded in run_summary.
350 memory_budget_gb: float = 16.0
351
352 # Output file naming (see `naming.py`). Every per-segment file is one
353 # base name plus a fixed suffix; the `segment_NNN/` directory itself is
354 # never renamed. Default `name_template` is the production scheme
355 # `naming.PRODUCTION_TEMPLATE` ("{dataset}_{branch}_seg{seg}_{date}",
356 # e.g. A1_b000_seg085_260827); empty placeholders are collapsed (an
357 # untagged run yields seg085_260827). Legacy names are opt-in via
358 # `--name-template segment_{seg}_seg3d`. Placeholders: {seg} {date}
359 # {dataset} {branch}. `date_tag` is empty (use today) or YYMMDD.
360 # CLI: --name-template, --dataset-tag, --branch-tag, --date-tag.
361 name_template: str = naming.PRODUCTION_TEMPLATE
362 dataset_tag: str = ""
363 branch_tag: str = ""
364 date_tag: str = ""
365
366 @pydantic.model_validator(mode="after")
367 def _check_band_bounds(self) -> "Seg3dConfig":
368 """Rejects a low band that ends above the medium band."""
369 if self.vegetation_low_max_m > self.vegetation_medium_max_m:
370 raise ValueError(
371 f"vegetation_low_max_m={self.vegetation_low_max_m!r} is not "
372 f"supported: it must not exceed "
373 f"vegetation_medium_max_m="
374 f"{self.vegetation_medium_max_m!r} -- the low band ends "
375 f"where the medium band starts."
376 )
377 return self
378
379 @pydantic.model_validator(mode="after")
380 def _check_naming(self) -> "Seg3dConfig":
381 """Resolves the naming knobs so a typo fails at load, not mid-run."""
382 try:
383 naming.validate_naming_config(self)
384 except naming.NamingError as exc:
385 # Surfaced as a config value error so `--set dataset_tag=...`
386 # fails like any other bad value, at load time rather than on
387 # the first write.
388 raise ValueError(str(exc)) from exc
389 return self
390
391 @pydantic.model_validator(mode="after")
392 def _warn_non_increasing_tiers(self) -> "Seg3dConfig":
393 """Warns when the priority tiers are not strictly increasing.
394
395 A warning, not an error: single-tier boosts are legitimate. The
396 sharp edge is pre-ground overlay configs that pin the old numbers
397 (asphalt=1, line=2, detector=3): the new `priority_ground=1`
398 default then ties asphalt, and ground would win voxels over
399 asphalt.
400 """
401 tiers = [
402 ("priority_unclassified", self.priority_unclassified),
403 ("priority_ground", self.priority_ground),
404 ("priority_asphalt", self.priority_asphalt),
405 ("priority_line", self.priority_line),
406 ("priority_detector", self.priority_detector),
407 ("priority_support", self.priority_support),
408 ]
409 for (lo_name, lo), (hi_name, hi) in zip(tiers, tiers[1:], strict=False):
410 if lo >= hi:
411 logger.warning(
412 "%s=%d >= %s=%d: priority tiers are not strictly "
413 "increasing; the voxel representative pick will not "
414 "follow the default class ordering",
415 lo_name, lo, hi_name, hi,
416 )
417 return self
0
Importance #43: src/iolabs_point_cloud_segmentation_3d/cli.py @@ -447,9 +447,9 @@
447 """447 """
448 out_root = Path(out_root)448 out_root = Path(out_root)
449 out_root.mkdir(parents=True, exist_ok=True)449 out_root.mkdir(parents=True, exist_ok=True)
450 path = out_root / "run_summary.json"450 path = out_root / "run_summary.json"
451 config_dict = asdict(config)451 config_dict = config.model_dump()
452 if voxel_override is not None:452 if voxel_override is not None:
453 # `--voxel` overrides config.voxel_size_m per-segment (see453 # `--voxel` overrides config.voxel_size_m per-segment (see
454 # fuse_segment); reflect that in the resolved config reported here so454 # fuse_segment); reflect that in the resolved config reported here so
455 # it doesn't disagree with segments[*].params.voxel_size_m.455 # it doesn't disagree with segments[*].params.voxel_size_m.
Importance #44: src/iolabs_point_cloud_segmentation_3d/config.py @@ -2,357 +2,62 @@
22
3Mirrors the config convention used by the sibling iolabs point-cloud3Mirrors the config convention used by the sibling iolabs point-cloud
4packages (`guardrails` / `verticalsigns`): the package owns a4packages (`guardrails` / `verticalsigns`): the package owns a
5`seg3d.default.json` algorithm config, and a frozen typed params object5`seg3d.default.json` algorithm config, and a frozen typed params object
6(`Seg3dConfig`) is loaded from it at CLI start. Every dataclass field6(`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it
7default is kept identical to `seg3d.default.json` (guarded by7at CLI start. Every model field default is kept identical to
8`tests/test_config.py`), so `Seg3dConfig()` and `load_config` agree.8`seg3d.default.json` (guarded by `tests/test_config.py`), so `Seg3dConfig()`
9Runtime overrides are applied through repeatable `--set KEY=VALUE` flags9and `load_config` agree. Runtime overrides are applied through repeatable
10or a `--config` JSON file, never repo-local edits to the packaged default.10`--set KEY=VALUE` flags or a `--config` JSON file, never repo-local edits to
1111the packaged default.
12Distances are in metres unless the field name says otherwise.12
13Adding a knob is two edits: a field on `Seg3dConfig` (with its range
14expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the
15same key with the same default in `seg3d.default.json`.
13"""16"""
1417
15import copy
16import json
17import logging18import logging
18import math
19from dataclasses import dataclass
20from pathlib import Path19from pathlib import Path
21from typing import Any20from typing import Any
2221
23from iolabs.common.config_loader import ConfigError as _CommonConfigError22from iolabs.common import config_loader
24from iolabs.common.config_loader import dataclass_from_mapping, load_packaged_json23
25from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides24from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig
2625
27from .las_modes import LAS_RGB_MODES, LAS_SPLIT_CONFIG_MODES26__all__ = [
28from .naming import PRODUCTION_TEMPLATE, NamingError, validate_naming_config27 "ConfigError",
28 "LasRgbMode",
29 "LasSplitMode",
30 "Seg3dConfig",
31 "config_from_dict",
32 "load_config",
33 "load_default_config_dict",
34 "parse_set_overrides",
35]
2936
30logger = logging.getLogger(__name__)37logger = logging.getLogger(__name__)
3138
32_PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d"39_PACKAGE_NAME = "iolabs_point_cloud_segmentation_3d"
33_DEFAULT_CONFIG_NAME = "seg3d.default.json"40_DEFAULT_CONFIG_NAME = "seg3d.default.json"
3441_CONFIG_CONTEXT = "seg3d config"
35# Upper bound of `vegetation_asphalt_dilate_cells`, see
36# `validate_config`: an accepted value must stay cheap to iterate.
37_MAX_DILATE_CELLS = 64
38
39
40@dataclass(frozen=True)
41class Seg3dConfig:
42 """Numeric thresholds for the fusion pipeline (metres unless stated).
43
44 The per-field comments below carry the rationale; this section is the
45 map of the groups they fall into.
46
47 Attributes:
48 voxel_size_m, edge_extend_m: Voxel decimation grid and the extra
49 length kept around a segment.
50 line_*: XML line painting (the fallback line source).
51 guard_*: Alignment guards on the mask and vertex hit rates.
52 hash_round_units_per_m: Quantization of the integer XYZ hash join.
53 surface_mesh_tolerance_m: Fallback road-surface distance to the
54 step-9 carriageway meshes.
55 vehicle_enabled: Paint the residual above the carriageway.
56 signs_json_paint_*: Cylinder paint for sign detections that carry no
57 point mask.
58 vegetation_*: Colour-plus-height vegetation stage (greenness,
59 asphalt/corridor guards, height banding, ground model).
60 priority_*: Class priority tiers (high wins) for the voxel
61 representative pick.
62 las_rgb_mode, las_split, las_georeference, las_crs_epsg, write_ply:
63 Output files, their colours and georeferencing.
64 memory_budget_gb: Peak-RSS warning threshold, in GB.
65 name_template, dataset_tag, branch_tag, date_tag: Output file
66 naming (see `naming.py`).
67 """
68
69 # Voxel decimation. 1 cm is the production default: it keeps thin
70 # painted features (lines, posts, rail tubes) intact for annotation, at
71 # the cost of a much larger decimated cloud and higher peak memory (see
72 # `memory_budget_gb`).
73 voxel_size_m: float = 0.01
74 edge_extend_m: float = 60.0
75
76 # XML line painting (fallback line source).
77 line_xy_radius_m: float = 0.20
78 line_z_gate_m: float = 0.5
79 line_resample_step_m: float = 0.05
80 line_bbox_pad_m: float = 2.0
81
82 # Alignment guards.
83 guard_bbox_pad_m: float = 5.0
84 guard_mask_rate: float = 0.99
85 guard_vertex_rate: float = 0.95
86
87 # Quantization of the integer XYZ hash join, in units per metre (1000 ->
88 # 1 mm). Threaded through to `iolabs.common.point_hash`; a join is only
89 # exact if every producer of the joined data used the same value, so
90 # changing it is a fleet-wide decision, not a per-run knob.
91 hash_round_units_per_m: float = 1000.0
92
93 # Fallback road-surface source for datasets whose pipeline generation
94 # emits no run4 road-surface npz: distance to the step-9 carriageway
95 # meshes below which a point counts as road surface (see
96 # surface_mesh.py). Covers scan noise and the mesh's own cell size.
97 surface_mesh_tolerance_m: float = 0.15
98
99 # Vehicles / noise above the carriageway: paint every point still
100 # unclassified after all other stages whose XY falls between the outer
101 # asphalt edges. Set false to reproduce pre-feature output.
102 vehicle_enabled: bool = True
103
104 # Sign detections that exist only in `verticalsigns.json`. The detector
105 # writes `point_masks.npz` during its per-segment pass but keeps appending
106 # detections in later corridor-level post-passes (lattice admission,
107 # reject-rescue, rail half-posts), so those records carry no mask points
108 # and would be invisible in the fused output. They are painted instead
109 # from a cylinder around the detection record: XY radius
110 # clamp(0.5 * max(footprint_m), 0.15, radius_max) + 0.10 m, z window
111 # [z_ground - z_pad_bottom, z_top + z_pad_top]. Only points still
112 # UNCLASSIFIED are painted -- the pass runs after the detector masks and
113 # the guardrail JSON fallback, so a guardrail, its support or any other
114 # class keeps precedence -- and a detection needs at least `min_points`
115 # of them to be painted at all (no phantom instances). `gantry_or_gate`
116 # records are skipped outright: their `position` is the midpoint between
117 # the two posts and `footprint_m[0]` the post separation, so the cylinder
118 # would sit over the carriageway and reach neither post.
119 #
120 # `z_pad_bottom` is 0 by default -- the pass starts AT the detection's
121 # `z_ground`. Note the asymmetry with `guardrail_json`, which starts its
122 # rail band at ground + 0.05 to stay off the ground itself; here the
123 # ground is already painted and only unclassified points are taken, so
124 # no lift is needed and a negative pad only reached under the ground.
125 # The knob stays for detector builds whose `z_ground` runs optimistic.
126 signs_json_paint_enabled: bool = True
127 signs_json_paint_radius_max_m: float = 2.0
128 signs_json_paint_z_pad_top_m: float = 0.30
129 signs_json_paint_z_pad_bottom_m: float = 0.0
130 signs_json_paint_min_points: int = 10
131
132 # Vegetation from colour + height above ground (see `vegetation.py`).
133 # Runs between the sign JSON paint and the vehicle sweep, on points no
134 # earlier stage claimed: a point that is GREEN and not over asphalt is
135 # vegetation, and its height above a ground model decides whether it is
136 # low (grass), medium (bushes, hedges) or tall (tree). Candidates are the
137 # points still UNCLASSIFIED plus -- `from_ground` defaults to TRUE --
138 # the tablecloth's `ground` rows, for the common case where the
139 # tablecloth kept the verge grass and only colour tells it from gravel.
140 # `from_ground=false` restores Miro's original "green and ABOVE the
141 # ground layer" and leaves the whole tablecloth as ground.
142 #
143 # WHY the default is on: the owner compared delivered LAS with both
144 # settings (2026-08-28, segment 085). The rows the rule adds are a
145 # continuous grass carpet on the verge, so they are wanted as LAS 3/4;
146 # the median is unaffected either way (lost to the guardrail/vehicle
147 # guards before this stage runs).
148 #
149 # Every detector class, the lines and the asphalt keep precedence by
150 # construction.
151 #
152 # Greenness is the chromatic excess green ExG = (2G - R - B) / (R + G + B)
153 # >= `green_exg_min`, with G >= `green_rg_ratio` * R and G > B. Dividing
154 # by the sum makes it
155 # invariant to exposure and to the storage scale, so the same threshold
156 # holds for the 16-bit A1 clouds and for datasets that keep 8-bit values
157 # in the uint16 RGB fields. `green_min_brightness` is an optional floor on
158 # R+G+B *in the source's own scale* (hence 0 = off, not a fraction): very
159 # dark returns have noisy chroma. `green_rg_ratio` is 0.90 rather than the
160 # strict G > R the rule started with: dry khaki grass has R ~ G with a
161 # strong blue deficit, so ExG clears 0.10 on the blue alone while G > R
162 # threw the grass away (337k of the 484k ExG candidates on A1 segment
163 # 085, ~98 % of the misses on 002/003). Brown soil sits near ExG 0.05, so
164 # ExG still separates it. 1.0 means "G at least R", i.e. the old rule.
165 # 0.90 rather than 0.95 after the AI3D-373 visual sweep (two segments,
166 # four vision judges): it recovers straw/khaki grass and dim shrubs -- on
167 # 085 the khaki bank went 8.9k -> 13.3k painted points and the recruits
168 # have mean RGB 114/109/58 with R > G in 79 % of them -- while the
169 # ground, asphalt and guardrail counts stayed byte-identical across all
170 # nine colour settings, so the extra green is taken from `unclassified`
171 # only. `green_exg_min` stays 0.10: 0.06 was the judges' favourite but
172 # painted ~50 near-black airborne points over a carriageway, and it can
173 # be revisited once a `green_min_brightness` gate is validated.
174 #
175 # `asphalt_rule` decides what "over the road" means.
176 # `"asphalt_column"` (default) rasterises the asphalt points into
177 # `asphalt_cell_m` (0.25 m) XY cells, counts a cell occupied from
178 # `asphalt_min_points` (5) asphalt points, grows the occupancy by
179 # `asphalt_dilate_cells` 3x3 dilations (0 = none) and rejects every
180 # candidate landing in an occupied cell. The three knobs are separate
181 # from the band grid because the first version (0.5 m cells, one
182 # dilation, one point is enough) reached 1-1.5 m past the pavement edge:
183 # wider than a median strip, which it then masked from both sides, and it
184 # ate the first metres of every verge (75k points on A1 085) while a
185 # single stray asphalt-class point inside the median seeded the guard.
186 # The alternative `"corridor"` rejects everything inside the corridor
187 # polygon, which on a dual carriageway also swallows the median strip and
188 # leaves its grass and hedges to the vehicle sweep.
189 #
190 # `corridor_rail_m` (2 m) is a SECOND, independent guard that only runs
191 # under `"asphalt_column"` and only where a corridor exists: a candidate
192 # inside the corridor polygon is rejected unless a barrier point
193 # (`guardrail`, `guardrail_support`, `guardrail_top_rail`, `wall`) lies
194 # within this distance, measured on the `asphalt_cell_m` raster. WHY: a
195 # road stretch the step-9 surface mesh missed carries no asphalt class at
196 # all (A1 085 matched 77 % of its mesh), so its occupancy cells are empty
197 # and the ghost trails of passing vehicles standing on it passed the
198 # column guard and were painted vegetation -- before the vegetation stage
199 # existed the vehicle sweep claimed them, and the sweep runs after this
200 # stage and only takes unclassified rows. A median is recognisable by the
201 # barriers running down it: on 085 all but 321 of the 50,946 in-corridor
202 # vegetation points sit within 2 m of a rail (p90 = 0.43 m), and 270 of
203 # those 321 were `vehicle` one run earlier. 0 disables the rule.
204 #
205 # `corridor_max_height_m` (0.5 m) is the SECOND half of that rule and the
206 # reason the first half is not enough: the barrier a median is recognised
207 # by stands on the median, so the rail's own green returns -- and the
208 # ghost of whatever brushed past it -- are always within `rail_m` of a
209 # barrier and the exception waves them all through. A median carries mown
210 # grass and nothing else, so an in-corridor candidate more than this above
211 # the DTM is the rail or a road ghost. On A1 085 the in-corridor
212 # `low_vegetation` reaches p95 = 0.40 m while 70 % of the in-corridor
213 # `medium_vegetation` stands above 0.5 m, 0.6-1.5 m up, within 0.05 m of a
214 # guardrail point and in 0.25 m cells holding 80-550 of them: the
215 # lane-parallel teal streaks the QC raster of 085 showed along the median
216 # and its shoulders. Because the cap runs BEFORE the banding it also
217 # un-bands the grass around what it takes: the column p95 that made a
218 # whole cell `medium` drops back to grass height once the rail's returns
219 # are gone. Same scope as `corridor_rail_m` (`"asphalt_column"` only, a
220 # corridor only), and 0 disables it -- which, with a median hedge to keep,
221 # is the setting to reach for.
222 #
223 # `band_mode="column"` (default) bands whole `band_cell_m` XY columns by
224 # the `column_percentile` of the candidate heights in them, so a hedge is
225 # medium from its foot up and a tree is tall down to its trunk;
226 # `"point"` bands each point by its own height and gives every bush a low
227 # skirt. Either way a cell with fewer than `min_cell_points` (10) green
228 # candidates is dropped whole (counted as `vegetation_sparse_rejected`):
229 # such cells are isolated speckle -- 13.5 % of the medium cells on 085
230 # were 1-4-cell blocks of column-p95 noise at the guardrail foot, and
231 # 12 % of the low points on 002 were single green returns on the hard
232 # shoulder. `min_height_m` drops candidates below the ground model (DTM
233 # artefacts).
234 #
235 # `tall_class` decides what the tall band is painted, and its default is
236 # `"medium_vegetation"`: a hedge is tall vegetation that the detector did
237 # NOT call a tree, and hedges and trees are exclusive, so this stage
238 # never creates `tree` on its own -- green residual above `low_max_m` is
239 # medium however tall it grows. `medium_max_m` therefore only bites under
240 # `tall_class="tree"` (trust colour+height with the detector) or
241 # `"unclassified"` (leave the tall residual to a human).
242 #
243 # `low_max_m` is 0.7 m (AI3D-373): at 0.5 m the khaki toe-grass along the
244 # guardrails and the sparse rims of the verge banks were banded medium,
245 # and 0.7 m is the only threshold in the sweep that flips exactly those
246 # and nothing else -- bush and hedge interiors were pixel-identical
247 # between the two settings. `column_percentile` stays 95: p80 under-reads
248 # canopy tops and puts low skirts back inside the hedges.
249 #
250 # `tree_min_height_m` > 0 is a safety net for the detector: a
251 # verticalsigns `tree` instance whose points are all shorter than this is
252 # repainted `medium_vegetation` (a bush the detector called a tree). 0 is
253 # off, which leaves every detector tree exactly as the mask painted it.
254 vegetation_enabled: bool = True
255 vegetation_from_ground: bool = True
256 vegetation_asphalt_rule: str = "asphalt_column"
257 vegetation_asphalt_cell_m: float = 0.25
258 vegetation_asphalt_dilate_cells: int = 0
259 vegetation_asphalt_min_points: int = 5
260 vegetation_corridor_rail_m: float = 2.0
261 vegetation_corridor_max_height_m: float = 0.5
262 vegetation_tree_min_height_m: float = 0.0
263 vegetation_low_max_m: float = 0.7
264 vegetation_medium_max_m: float = 2.0
265 vegetation_min_height_m: float = -0.5
266 vegetation_tall_class: str = "medium_vegetation"
267 vegetation_band_mode: str = "column"
268 vegetation_band_cell_m: float = 0.5
269 vegetation_column_percentile: float = 95.0
270 vegetation_min_cell_points: int = 10
271 vegetation_green_exg_min: float = 0.10
272 vegetation_green_rg_ratio: float = 0.90
273 vegetation_green_min_brightness: float = 0.0
274 vegetation_ground_cell_m: float = 1.0
275 vegetation_ground_percentile: float = 10.0
276 vegetation_min_ground_points: int = 1000
277
278 # Class priority tiers (high wins) used by the voxel representative pick.
279 priority_unclassified: int = 0
280 priority_ground: int = 1
281 priority_asphalt: int = 2
282 priority_line: int = 3
283 priority_detector: int = 4
284 # Guardrail supports (posts) and the median rail's top rail (box tube)
285 # sit above the other detector classes: such a voxel almost always also
286 # holds rail points and would lose the tier-4 count tie-break, dropping
287 # the decomposition out of the decimated cloud. One knob for the whole of
288 # tier 5 -- post and tube are two halves of the same decomposition.
289 priority_support: int = 5
290
291 # ReCap LAS export RGB: "sensor" keeps source-cloud colors, "class"
292 # bakes the class palette in (fallback for viewers without LAS
293 # classification display).
294 las_rgb_mode: str = "sensor"
295
296 # Extra LAS files for per-object review in ReCap (each imported file is
297 # an isolatable scan in the Project Navigator): "none", "class" (one
298 # file per class) or "instance" (one file per detected object).
299 las_split: str = "none"
300
301 # Write the class-colored PLY next to the npz/LAS. Off by default: the
302 # deliverable is the LAS, and at the 1 cm voxel the PLY is a large file
303 # nobody in the annotation loop opens. CLI `--ply` turns it back on.
304 write_ply: bool = False
305
306 # Add the dataset's run3 geoshift back to the LAS coordinates so the
307 # deliverable is in true world coordinates (the npz/ply stay in the
308 # pipeline frame, which is the hash-join key).
309 las_georeference: bool = True
310
311 # EPSG code embedded as a LAS 1.4 WKT VLR; 0 disables. 25832 is
312 # ETRS89 / UTM zone 32N, the CRS of this project's source scans. Only
313 # written when the exported coordinates are actually world coordinates.
314 las_crs_epsg: int = 25832
315
316 # Reporting: warn when peak RSS exceeds this (GB); recorded in run_summary.
317 memory_budget_gb: float = 16.0
318
319 # Output file naming (see `naming.py`). Every per-segment file is one
320 # base name plus a fixed suffix; the `segment_NNN/` directory itself is
321 # never renamed. Default `name_template` is the production scheme
322 # `naming.PRODUCTION_TEMPLATE` ("{dataset}_{branch}_seg{seg}_{date}",
323 # e.g. A1_b000_seg085_260827); empty placeholders are collapsed (an
324 # untagged run yields seg085_260827). Legacy names are opt-in via
325 # `--name-template segment_{seg}_seg3d`. Placeholders: {seg} {date}
326 # {dataset} {branch}. `date_tag` is empty (use today) or YYMMDD.
327 # CLI: --name-template, --dataset-tag, --branch-tag, --date-tag.
328 name_template: str = PRODUCTION_TEMPLATE
329 dataset_tag: str = ""
330 branch_tag: str = ""
331 date_tag: str = ""
332
333
334class ConfigError(_CommonConfigError):
335 """Raised when the seg3d config contains unsupported keys."""
33642
33743
338def load_default_config_dict() -> dict[str, Any]:44def load_default_config_dict() -> dict[str, Any]:
339 """Returns the package-owned default config as a plain dict."""45 """Returns the package-owned default config as a plain dict."""
340 return load_packaged_json(__package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME)46 return config_loader.load_packaged_json(
47 __package__ or _PACKAGE_NAME, _DEFAULT_CONFIG_NAME
48 )
34149
34250
343def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:51def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig:
344 """Builds a validated `Seg3dConfig` from a raw mapping.52 """Builds a validated `Seg3dConfig` from a raw mapping.
34553
346 Unknown keys are rejected and each raw value is coerced to its54 Unknown keys are rejected and each raw value is coerced to its field's
347 dataclass field's declared type by55 declared type by `iolabs.common.config_loader.ConfigModel`, which is
348 `iolabs.common.config_loader.dataclass_from_mapping`, which is strict:56 strict: a bool typo (`"flase"`), a bool given as an int other than 0/1
349 a bool typo (`"flase"`), a bool given as an int other than 0/1 and a57 and a non-integral value for an int field are errors, not silent
350 non-integral value for an int field are errors, not silent58 misconfigurations. Missing keys fall back to the field defaults, which
351 misconfigurations. After construction, tier priorities are checked59 are the packaged JSON's.
352 and any non-strictly-increasing tier ordering is logged as a warning
353 (not an error, since single-tier boosts are legitimate) -- see the
354 inline note below for why this matters.
35560
356 Args:61 Args:
357 raw: Flat mapping of `Seg3dConfig` field names to raw values62 raw: Flat mapping of `Seg3dConfig` field names to raw values
358 (e.g. parsed JSON or `--set KEY=VALUE` overrides).63 (e.g. parsed JSON or `--set KEY=VALUE` overrides).
Importance #45: src/iolabs_point_cloud_segmentation_3d/config.py @@ -360,228 +65,16 @@
360 Returns:65 Returns:
361 The validated `Seg3dConfig`.66 The validated `Seg3dConfig`.
36267
363 Raises:68 Raises:
364 ConfigError: `raw` contains an unknown key or a value that is not69 ConfigError: `raw` contains an unknown key, or a value that is not
365 valid for its field's declared type,70 valid for its field's declared type or outside its declared
366 `hash_round_units_per_m` is not finite and positive,71 range (see the `pydantic.Field` bounds and the model
367 `las_rgb_mode` is not `"sensor"`/`"class"`, `las_split` is not72 validators on `Seg3dConfig`, including the naming knobs).
368 `"none"`/`"class"`/`"instance"`, a vegetation enum
369 (`vegetation_tall_class`, `vegetation_band_mode`,
370 `vegetation_asphalt_rule`) is not one of its allowed values, a
371 vegetation limiter is out of range
372 (`vegetation_green_rg_ratio`, `vegetation_asphalt_cell_m`,
373 `vegetation_ground_cell_m`, `vegetation_band_cell_m`,
374 `vegetation_low_max_m` and `vegetation_medium_max_m` must be
375 finite and positive, with `low_max_m <= medium_max_m`;
376 `vegetation_asphalt_dilate_cells`,
377 `vegetation_asphalt_min_points`,
378 `vegetation_min_cell_points` and
379 `vegetation_corridor_rail_m` and
380 `vegetation_corridor_max_height_m` non-negative, with
381 `vegetation_asphalt_dilate_cells` at most
382 `_MAX_DILATE_CELLS`;
383 `vegetation_min_ground_points` at least 1;
384 `vegetation_column_percentile` and
385 `vegetation_ground_percentile` in `[0, 100]`;
386 `vegetation_min_height_m`, `vegetation_green_exg_min`,
387 `vegetation_green_min_brightness` and
388 `vegetation_tree_min_height_m` finite),
389 `las_crs_epsg` is negative, or a naming knob (`name_template`,
390 `dataset_tag`, `branch_tag`, `date_tag`) does not resolve to a
391 usable base name.
392 """73 """
393 config = dataclass_from_mapping(74 return config_loader.validate_config(
394 Seg3dConfig, raw, context="seg3d config", error_cls=ConfigError75 Seg3dConfig, raw, context=_CONFIG_CONTEXT, error_cls=ConfigError
395 )76 )
396 units = config.hash_round_units_per_m
397 if not math.isfinite(units) or units <= 0.0:
398 # The hash join is a real config parameter now (threaded into
399 # `iolabs.common.point_hash` and recorded in the run stats), but a
400 # non-positive resolution is never a valid one -- catch it at load
401 # time rather than mid-run on the first join.
402 raise ConfigError(
403 f"hash_round_units_per_m={units!r} is not supported: expected a "
404 f"finite, strictly positive number of quantization units per "
405 f"metre (1000 = 1 mm, the fleet-wide contract value)."
406 )
407 if config.las_rgb_mode not in LAS_RGB_MODES:
408 raise ConfigError(
409 f"las_rgb_mode={config.las_rgb_mode!r} is not supported: "
410 f"expected 'sensor' or 'class'."
411 )
412 if config.las_split not in LAS_SPLIT_CONFIG_MODES:
413 raise ConfigError(
414 f"las_split={config.las_split!r} is not supported: expected "
415 f"'none', 'class' or 'instance'."
416 )
417 # The three vegetation enums are checked here rather than in
418 # `vegetation.py`: a typo in `--set vegetation_band_mode=colum` must fail
419 # at load time, not silently fall through to the other branch on a 3.5
420 # min fusion run.
421 for name, allowed in (
422 (
423 "vegetation_tall_class",
424 ("medium_vegetation", "tree", "unclassified"),
425 ),
426 ("vegetation_band_mode", ("column", "point")),
427 ("vegetation_asphalt_rule", ("asphalt_column", "corridor")),
428 ):
429 value = getattr(config, name)
430 if value not in allowed:
431 quoted = [repr(a) for a in allowed]
432 options = " or ".join([", ".join(quoted[:-1]), quoted[-1]])
433 raise ConfigError(
434 f"{name}={value!r} is not supported: expected {options}."
435 )
436 # The vegetation limiters, same argument: a cell size of 0 divides by
437 # zero deep inside the rasteriser and a negative count silently means
438 # "no floor", both of which must fail at load time.
439 # Every cell size divides deep inside a rasteriser, and every one of
440 # these has been observed to fail LATE rather than loudly: a
441 # `band_cell_m` of 0 raised OverflowError 3.5 minutes into a fuse, and
442 # a `min_ground_points` of 0 reached numpy as a zero-size reduction.
443 for name in (
444 "vegetation_green_rg_ratio",
445 "vegetation_asphalt_cell_m",
446 "vegetation_ground_cell_m",
447 "vegetation_band_cell_m",
448 "vegetation_low_max_m",
449 "vegetation_medium_max_m",
450 ):
451 value = float(getattr(config, name))
452 if not math.isfinite(value) or value <= 0.0:
453 raise ConfigError(
454 f"{name}={value!r} is not supported: expected a finite, "
455 f"strictly positive number."
456 )
457 if config.vegetation_low_max_m > config.vegetation_medium_max_m:
458 raise ConfigError(
459 f"vegetation_low_max_m={config.vegetation_low_max_m!r} is not "
460 f"supported: it must not exceed "
461 f"vegetation_medium_max_m="
462 f"{config.vegetation_medium_max_m!r} -- the low band ends "
463 f"where the medium band starts."
464 )
465 for name in (
466 "vegetation_asphalt_dilate_cells",
467 "vegetation_asphalt_min_points",
468 "vegetation_min_cell_points",
469 ):
470 value = int(getattr(config, name))
471 if value < 0:
472 raise ConfigError(
473 f"{name}={value!r} is not supported: expected a "
474 f"non-negative count."
475 )
476 # The one knob with an upper bound too: every iteration is a full 3x3
477 # `binary_dilation` over the asphalt raster, so a mistyped 500 (metres
478 # meant as cells, a stray zero) grinds for minutes inside a fusion run
479 # rather than failing at load time. 64 cells is 16 m of reach at the
480 # shipped 0.25 m cell, i.e. far past any real verge.
481 if config.vegetation_asphalt_dilate_cells > _MAX_DILATE_CELLS:
482 raise ConfigError(
483 f"vegetation_asphalt_dilate_cells="
484 f"{config.vegetation_asphalt_dilate_cells!r} is not supported: "
485 f"expected at most {_MAX_DILATE_CELLS} cells (each one is a "
486 f"full 3x3 dilation of the asphalt raster)."
487 )
488 if config.vegetation_min_ground_points < 1:
489 raise ConfigError(
490 f"vegetation_min_ground_points="
491 f"{config.vegetation_min_ground_points!r} is not supported: "
492 f"expected at least 1 (a DTM needs a point to be built from; "
493 f"raise the floor to skip the stage instead)."
494 )
495 for name in (
496 "vegetation_column_percentile",
497 "vegetation_ground_percentile",
498 ):
499 value = float(getattr(config, name))
500 if not math.isfinite(value) or not 0.0 <= value <= 100.0:
501 raise ConfigError(
502 f"{name}={value!r} is not supported: expected a "
503 f"percentile in [0, 100]."
504 )
505 # Not bounded: -0.5 m (well under the model) is the shipped value and
506 # a large negative is a legitimate "off". Only nan/inf is nonsense --
507 # every comparison against it is False, so the filter would silently
508 # drop the whole candidate set.
509 min_height = float(config.vegetation_min_height_m)
510 if not math.isfinite(min_height):
511 raise ConfigError(
512 f"vegetation_min_height_m={min_height!r} is not supported: "
513 f"expected a finite height in metres."
514 )
515 # Finite-only, same argument as `vegetation_min_height_m` and with
516 # the same sharp edge: every comparison against a nan is False, so a
517 # nan here does not raise, it silently turns the knob OFF (nothing is
518 # green enough / bright enough; no detector tree is short enough to
519 # re-band). No range: a negative `exg_min` is a legitimate "take
520 # everything that is not red", and 0 is the documented "off" for the
521 # other two.
522 for name in (
523 "vegetation_green_exg_min",
524 "vegetation_green_min_brightness",
525 "vegetation_tree_min_height_m",
526 ):
527 value = float(getattr(config, name))
528 if not math.isfinite(value):
529 raise ConfigError(
530 f"{name}={value!r} is not supported: expected a finite "
531 f"number."
532 )
533 # A negative reach would dilate by a negative count deep inside the
534 # rasteriser; 0 is the documented "rule off" setting.
535 rail_m = float(config.vegetation_corridor_rail_m)
536 if not math.isfinite(rail_m) or rail_m < 0.0:
537 raise ConfigError(
538 f"vegetation_corridor_rail_m={rail_m!r} is not supported: "
539 f"expected a finite, non-negative distance in metres "
540 f"(0 disables the rule)."
541 )
542 # Same shape as `rail_m`: 0 is the documented "cap off", a negative
543 # would reject every in-corridor candidate including the median grass,
544 # and a nan compares False against every height, i.e. silently off.
545 max_h = float(config.vegetation_corridor_max_height_m)
546 if not math.isfinite(max_h) or max_h < 0.0:
547 raise ConfigError(
548 f"vegetation_corridor_max_height_m={max_h!r} is not supported: "
549 f"expected a finite, non-negative height in metres "
550 f"(0 disables the cap)."
551 )
552 if config.las_crs_epsg < 0:
553 raise ConfigError(
554 f"las_crs_epsg={config.las_crs_epsg!r} is not supported: "
555 f"expected a non-negative EPSG code (0 disables the CRS VLR)."
556 )
557 try:
558 validate_naming_config(config)
559 except NamingError as exc:
560 # Surfaced as a ConfigError so `--set dataset_tag=...` fails like any
561 # other bad config value, at load time rather than on the first write.
562 raise ConfigError(str(exc)) from exc
563 # Warn (not error: single-tier boosts are legitimate) when the tiers are
564 # not strictly increasing. The sharp edge is pre-ground overlay configs
565 # that pin the old numbers (asphalt=1, line=2, detector=3): the new
566 # priority_ground=1 default then ties asphalt, and ground would win
567 # voxels over asphalt.
568 tiers = [
569 ("priority_unclassified", config.priority_unclassified),
570 ("priority_ground", config.priority_ground),
571 ("priority_asphalt", config.priority_asphalt),
572 ("priority_line", config.priority_line),
573 ("priority_detector", config.priority_detector),
574 ("priority_support", config.priority_support),
575 ]
576 for (lo_name, lo), (hi_name, hi) in zip(tiers, tiers[1:], strict=False):
577 if lo >= hi:
578 logger.warning(
579 "%s=%d >= %s=%d: priority tiers are not strictly increasing; "
580 "the voxel representative pick will not follow the default "
581 "class ordering", lo_name, lo, hi_name, hi,
582 )
583 return config
58477
58578
586def load_config(79def load_config(
587 config_path: Path | None = None,80 config_path: Path | None = None,
Importance #46: src/iolabs_point_cloud_segmentation_3d/config.py @@ -599,16 +92,17 @@
59992
600 Raises:93 Raises:
601 ConfigError: An override key or value is not valid.94 ConfigError: An override key or value is not valid.
602 """95 """
603 merged = copy.deepcopy(load_default_config_dict())96 config = config_loader.load_config(
604 if config_path is not None:97 Seg3dConfig,
605 with Path(config_path).open("r", encoding="utf-8") as handle:98 package=__package__ or _PACKAGE_NAME,
606 file_cfg = json.load(handle)99 filename=_DEFAULT_CONFIG_NAME,
607 merged.update(file_cfg)100 overrides=overrides,
608 for key, value in (overrides or {}).items():101 config_path=config_path,
609 merged[key] = value102 context=_CONFIG_CONTEXT,
610 config = config_from_dict(merged)103 error_cls=ConfigError,
104 )
611 if config_path is not None:105 if config_path is not None:
612 logger.info("Config file applied: %s", config_path)106 logger.info("Config file applied: %s", config_path)
613 if overrides:107 if overrides:
614 logger.info(108 logger.info(
Importance #47: src/iolabs_point_cloud_segmentation_3d/config.py @@ -633,5 +127,7 @@
633127
634 Raises:128 Raises:
635 ConfigError: An override is missing its `=`.129 ConfigError: An override is missing its `=`.
636 """130 """
637 return _parse_set_overrides(raw_overrides, error_cls=ConfigError)131 return config_loader.parse_set_overrides(
132 raw_overrides, error_cls=ConfigError
133 )
Importance #48: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -5,9 +5,9 @@
5Produces a `FuseResult` for the writers and renderer.5Produces a `FuseResult` for the writers and renderer.
6"""6"""
77
8import logging8import logging
9from dataclasses import asdict, dataclass, field, fields9from dataclasses import dataclass, field
10from pathlib import Path10from pathlib import Path
1111
12import numpy as np12import numpy as np
13from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps13from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps
Importance #49: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -39,13 +39,11 @@
39logger = logging.getLogger(__name__)39logger = logging.getLogger(__name__)
4040
41# Default voxel size for CLI help / smoke scripts; the authoritative default41# Default voxel size for CLI help / smoke scripts; the authoritative default
42# lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off42# lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off
43# the dataclass field rather than by building a `Seg3dConfig()` at import43# the model field rather than by building a `Seg3dConfig()` at import
44# time -- importing this module must not construct (and validate) a config.44# time -- importing this module must not construct (and validate) a config.
45DEFAULT_VOXEL: float = next(45DEFAULT_VOXEL: float = Seg3dConfig.model_fields["voxel_size_m"].default
46 f.default for f in fields(Seg3dConfig) if f.name == "voxel_size_m"
47)
4846
4947
50LINES_SOURCES = ("auto", "clusters", "xml")48LINES_SOURCES = ("auto", "clusters", "xml")
5149
Importance #50: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -1653,9 +1651,9 @@
16531651
1654def _stats_params(config: Seg3dConfig, voxel: float) -> dict:1652def _stats_params(config: Seg3dConfig, voxel: float) -> dict:
1655 """Returns the per-segment record of the knobs the run used.1653 """Returns the per-segment record of the knobs the run used.
16561654
1657 Built from the config dataclass itself rather than a hand-written1655 Built from the config model itself rather than a hand-written
1658 list, so a knob added to `Seg3dConfig` is recorded automatically; see1656 list, so a knob added to `Seg3dConfig` is recorded automatically; see
1659 `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is1657 `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is
1660 the one runtime override `fuse_segment` is told about, and it is laid1658 the one runtime override `fuse_segment` is told about, and it is laid
1661 over `config.voxel_size_m` here; the knobs the CLI overrides behind1659 over `config.voxel_size_m` here; the knobs the CLI overrides behind
Importance #51: src/iolabs_point_cloud_segmentation_3d/fuse.py @@ -1669,9 +1667,9 @@
1669 A plain JSON-serialisable dict of parameter name -> value.1667 A plain JSON-serialisable dict of parameter name -> value.
1670 """1668 """
1671 params = {1669 params = {
1672 name: value1670 name: value
1673 for name, value in asdict(config).items()1671 for name, value in config.model_dump().items()
1674 if name not in _PARAMS_EXCLUDED_FIELDS1672 if name not in _PARAMS_EXCLUDED_FIELDS
1675 }1673 }
1676 params["voxel_size_m"] = float(voxel)1674 params["voxel_size_m"] = float(voxel)
1677 return params1675 return params
Importance #52: src/iolabs_point_cloud_segmentation_3d/las_modes.py @@ -3,9 +3,11 @@
3`config` validates them at load time, `cli` offers them as argparse3`config` validates them at load time, `cli` offers them as argparse
4`choices=` and `writer` re-checks them at its own entry points. They live in4`choices=` and `writer` re-checks them at its own entry points. They live in
5their own module so neither the config loader nor the CLI parser has to5their own module so neither the config loader nor the CLI parser has to
6import the writer (and with it laspy/open3d) just to know what a valid mode6import the writer (and with it laspy/open3d) just to know what a valid mode
7string is; adding a mode is then a one-line change here.7string is. `config` states the same whitelists as `Literal` types on the
8config model (`LasRgbMode` / `LasSplitMode`), pinned to these tuples by
9`tests/test_config.py`, so adding a mode is one edit here and one there.
8"""10"""
911
10# Valid values for the ``las_rgb_mode`` config knob.12# Valid values for the ``las_rgb_mode`` config knob.
11LAS_RGB_MODES = ("sensor", "class")13LAS_RGB_MODES = ("sensor", "class")
Importance #53: tests/test_config.py @@ -1,23 +1,41 @@
1"""Config parity tests.1"""Config parity tests.
22
3Every dataclass default must equal the packaged JSON default, and the3Every model field default must equal the packaged JSON default, and the
4priority/hash-rounding values must match the code that consumes them.4priority/hash-rounding values must match the code that consumes them.
5"""5"""
66
7import typing
8
7import pytest9import pytest
810
9from iolabs_point_cloud_segmentation_3d import classes11from iolabs_point_cloud_segmentation_3d import classes, las_modes
10from iolabs_point_cloud_segmentation_3d.config import (12from iolabs_point_cloud_segmentation_3d.config import (
11 ConfigError,13 ConfigError,
14 LasRgbMode,
15 LasSplitMode,
12 Seg3dConfig,16 Seg3dConfig,
13 config_from_dict,17 config_from_dict,
14 load_config,18 load_config,
15 load_default_config_dict,19 load_default_config_dict,
16 parse_set_overrides,20 parse_set_overrides,
17)21)
1822
1923
24def test_model_defaults_match_the_packaged_json():
25 assert Seg3dConfig().model_dump() == config_from_dict(
26 load_default_config_dict()
27 ).model_dump()
28 assert set(load_default_config_dict()) == set(Seg3dConfig.model_fields)
29
30
31def test_las_mode_literals_match_las_modes():
32 # The CLI offers `las_modes` as argparse choices and the writer
33 # re-checks them; the config model validates its own Literals.
34 assert typing.get_args(LasRgbMode) == las_modes.LAS_RGB_MODES
35 assert typing.get_args(LasSplitMode) == las_modes.LAS_SPLIT_CONFIG_MODES
36
37
20def test_priority_lut_override_changes_lut():38def test_priority_lut_override_changes_lut():
21 cfg = config_from_dict({**load_default_config_dict(), "priority_line": 9})39 cfg = config_from_dict({**load_default_config_dict(), "priority_line": 9})
22 lut = classes.priority_lut(cfg)40 lut = classes.priority_lut(cfg)
23 assert lut[classes.BY_NAME["solid_line"].las_code] == 941 assert lut[classes.BY_NAME["solid_line"].las_code] == 9
Importance #54: tests/test_config.py @@ -29,9 +47,9 @@
29 # the loader must warn so ground can't silently win voxels over asphalt.47 # the loader must warn so ground can't silently win voxels over asphalt.
30 import logging48 import logging
3149
32 with caplog.at_level(50 with caplog.at_level(
33 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.config"51 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d._config_model"
34 ):52 ):
35 config_from_dict({53 config_from_dict({
36 **load_default_config_dict(),54 **load_default_config_dict(),
37 "priority_asphalt": 1, "priority_line": 2, "priority_detector": 3,55 "priority_asphalt": 1, "priority_line": 2, "priority_detector": 3,
Importance #55: tests/test_config.py @@ -42,14 +60,14 @@
42def test_strictly_increasing_tiers_do_not_warn(caplog):60def test_strictly_increasing_tiers_do_not_warn(caplog):
43 import logging61 import logging
4462
45 with caplog.at_level(63 with caplog.at_level(
46 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d.config"64 logging.WARNING, logger="iolabs_point_cloud_segmentation_3d._config_model"
47 ):65 ):
48 config_from_dict(load_default_config_dict())66 config_from_dict(load_default_config_dict())
49 assert not [67 assert not [
50 r for r in caplog.records68 r for r in caplog.records
51 if r.name == "iolabs_point_cloud_segmentation_3d.config"69 if r.name == "iolabs_point_cloud_segmentation_3d._config_model"
52 ]70 ]
5371
5472
55def test_hash_rounding_must_be_positive():73def test_hash_rounding_must_be_positive():
Importance #56: tests/test_config.py @@ -368,8 +386,35 @@
368 {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0}386 {"vegetation_low_max_m": 3.0, "vegetation_medium_max_m": 2.0}
369 )387 )
370388
371389
390def test_cross_field_rule_reports_only_its_own_message():
391 # A cross-field rule is a whole-model validator, so it carries no field
392 # location; the message must stay the rule's own text and not grow a
393 # dump of every config key (which is what an unlocated value error
394 # would otherwise echo back).
395 with pytest.raises(ConfigError) as excinfo:
396 config_from_dict(
397 {**load_default_config_dict(), "vegetation_low_max_m": 3.0}
398 )
399 assert str(excinfo.value) == (
400 "vegetation_low_max_m=3.0 is not supported: it must not exceed "
401 "vegetation_medium_max_m=2.0 -- the low band ends where the medium "
402 "band starts."
403 )
404
405
406def test_naming_rule_reports_only_its_own_message():
407 with pytest.raises(ConfigError) as excinfo:
408 config_from_dict(
409 {**load_default_config_dict(), "date_tag": "notadate"}
410 )
411 assert str(excinfo.value) == (
412 "date_tag='notadate' is not supported: expected empty or a "
413 "six-digit YYMMDD tag."
414 )
415
416
372def test_vegetation_limiters_validated_through_set_overrides():417def test_vegetation_limiters_validated_through_set_overrides():
373 # `--set` is the path these values actually arrive on in a sweep.418 # `--set` is the path these values actually arrive on in a sweep.
374 for override in (419 for override in (
375 "vegetation_band_cell_m=0",420 "vegetation_band_cell_m=0",
Importance #57: tests/test_vegetation.py @@ -498,9 +498,11 @@
498 assert extra["over_asphalt"] in result.medium_rows498 assert extra["over_asphalt"] in result.medium_rows
499499
500500
501def test_classify_vegetation_rejects_unknown_asphalt_rule():501def test_classify_vegetation_rejects_unknown_asphalt_rule():
502 with pytest.raises(ValueError, match="asphalt rule"):502 # The rule is a Literal on the config model, so an unknown value dies
503 # at config construction -- before vegetation.py's defensive branch.
504 with pytest.raises(ValueError, match="vegetation_asphalt_rule"):
503 _run(_config(vegetation_asphalt_rule="nonsense"))505 _run(_config(vegetation_asphalt_rule="nonsense"))
504506
505507
506def test_classify_vegetation_rebands_short_detector_trees():508def test_classify_vegetation_rebands_short_detector_trees():