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(-)
| 1 | """The `Seg3dConfig` schema: every knob, its default and its range. | ||
| 2 | |||
| 3 | The model mirrors `seg3d.default.json` key for key -- adding a knob is a | ||
| 4 | field here plus the same key with the same default there. Unknown keys, | ||
| 5 | value coercion and the error messages come from | ||
| 6 | `iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field` | ||
| 7 | bounds, cross-field rules are model validators. `config.py` is the public | ||
| 8 | entry point (loading, merging, `--set` parsing) and re-exports both names. | ||
| 9 | |||
| 10 | Distances are in metres unless the field name says otherwise. | ||
| 11 | """ | ||
| 12 | |||
| 13 | import logging | ||
| 14 | from typing import Literal | ||
| 15 | |||
| 16 | import pydantic | ||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | from . import naming | ||
| 20 | |||
| 21 | logger = 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. | ||
| 32 | LasRgbMode = Literal["sensor", "class"] | ||
| 33 | LasSplitMode = Literal["none", "class", "instance"] | ||
| 34 | |||
| 35 | |||
| 36 | class ConfigError(config_loader.ConfigError): | ||
| 37 | """Raised when the seg3d config contains unsupported keys.""" | ||
| 38 | |||
| 39 | |||
| 40 | class 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 |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the sibling iolabs point-cloud | 3 | Mirrors the config convention used by the sibling iolabs point-cloud |
| 4 | packages (`guardrails` / `verticalsigns`): the package owns a | 4 | packages (`guardrails` / `verticalsigns`): the package owns a |
| 5 | `seg3d.default.json` algorithm config, and a frozen typed params object | 5 | `seg3d.default.json` algorithm config, and a frozen typed params object |
| 6 | (`Seg3dConfig`) is loaded from it at CLI start. Every dataclass field | 6 | (`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it |
| 7 | default is kept identical to `seg3d.default.json` (guarded by | 7 | at 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()` |
| 9 | Runtime overrides are applied through repeatable `--set KEY=VALUE` flags | 9 | and `load_config` agree. Runtime overrides are applied through repeatable |
| 10 | or 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 |
| 11 | 11 | the packaged default. | |
| 12 | Distances are in metres unless the field name says otherwise. | 12 | |
| 13 | Adding a knob is two edits: a field on `Seg3dConfig` (with its range | ||
| 14 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the | ||
| 15 | same key with the same default in `seg3d.default.json`. | ||
| 13 | """ | 16 | """ |
| 14 | 17 | ||
| 15 | import copy | ||
| 16 | import json | ||
| 17 | import logging | 18 | import logging |
| 18 | import math | ||
| 19 | from dataclasses import dataclass | ||
| 20 | from pathlib import Path | 19 | from pathlib import Path |
| 21 | from typing import Any | 20 | from typing import Any |
| 22 | 21 | ||
| 23 | from iolabs.common.config_loader import ConfigError as _CommonConfigError | 22 | from iolabs.common import config_loader |
| 24 | from iolabs.common.config_loader import dataclass_from_mapping, load_packaged_json | 23 | |
| 25 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | 24 | from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig |
| 26 | 25 | ||
| 27 | from .las_modes import LAS_RGB_MODES, LAS_SPLIT_CONFIG_MODES | 26 | __all__ = [ |
| 28 | from .naming import PRODUCTION_TEMPLATE, NamingError, validate_naming_config | 27 | "ConfigError", |
| 28 | "LasRgbMode", | ||
| 29 | "LasSplitMode", | ||
| 30 | "Seg3dConfig", | ||
| 31 | "config_from_dict", | ||
| 32 | "load_config", | ||
| 33 | "load_default_config_dict", | ||
| 34 | "parse_set_overrides", | ||
| 35 | ] | ||
| 29 | 36 | ||
| 30 | logger = logging.getLogger(__name__) | 37 | logger = logging.getLogger(__name__) |
| 31 | 38 | ||
| 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" |
| 34 | 41 | _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) | ||
| 41 | class 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 | |||
| 334 | class ConfigError(_CommonConfigError): | ||
| 335 | """Raised when the seg3d config contains unsupported keys.""" | ||
| 336 | 42 | ||
| 337 | 43 | ||
| 338 | def load_default_config_dict() -> dict[str, Any]: | 44 | def 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 | ) | ||
| 341 | 49 | ||
| 342 | 50 | ||
| 343 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: | 51 | def 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. |
| 345 | 53 | ||
| 346 | Unknown keys are rejected and each raw value is coerced to its | 54 | Unknown keys are rejected and each raw value is coerced to its field's |
| 347 | dataclass field's declared type by | 55 | 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 a | 57 | and a non-integral value for an int field are errors, not silent |
| 350 | non-integral value for an int field are errors, not silent | 58 | misconfigurations. Missing keys fall back to the field defaults, which |
| 351 | misconfigurations. After construction, tier priorities are checked | 59 | 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. | ||
| 355 | 60 | ||
| 356 | Args: | 61 | Args: |
| 357 | raw: Flat mapping of `Seg3dConfig` field names to raw values | 62 | 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). |
| 360 | Returns: | 65 | Returns: |
| 361 | The validated `Seg3dConfig`. | 66 | The validated `Seg3dConfig`. |
| 362 | 67 | ||
| 363 | Raises: | 68 | Raises: |
| 364 | ConfigError: `raw` contains an unknown key or a value that is not | 69 | 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 not | 72 | 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=ConfigError | 75 | 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 | ||
| 584 | 77 | ||
| 585 | 78 | ||
| 586 | def load_config( | 79 | def load_config( |
| 587 | config_path: Path | None = None, | 80 | config_path: Path | None = None, |
| 599 | 92 | ||
| 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] = value | 102 | 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( |
| 633 | 127 | ||
| 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 | ) |
| 64 | import json | 64 | import json |
| 65 | import logging | 65 | import logging |
| 66 | import sys | 66 | import sys |
| 67 | import time | 67 | import time |
| 68 | from dataclasses import asdict, dataclass, field | 68 | from dataclasses import dataclass, field |
| 69 | from pathlib import Path | 69 | from pathlib import Path |
| 70 | 70 | ||
| 71 | import numpy as np | 71 | import numpy as np |
| 72 | from iolabs.common.crs import looks_georeferenced | 72 | from iolabs.common.crs import looks_georeferenced |
| 286 | Raises: | 286 | Raises: |
| 287 | SystemExit: Via `ConfigError` on a malformed spec, an unknown or | 287 | 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( |
| 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) |
| 393 | 396 | ||
| 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, |
| 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.instances | 453 | for inst in result.instances |
| 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 now | 502 | 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 | ) |
| 646 | 651 | ||
| 647 | def veg_params(config: Seg3dConfig) -> dict: | 652 | def 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 | } |
| 652 | 658 | ||
| 653 | 659 | ||
| 654 | def column_height_histogram( | 660 | def column_height_histogram( |
| 774 | 780 | ||
| 775 | 781 | ||
| 776 | def _default_distance(combo: dict) -> int: | 782 | def _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) |
| 780 | 786 | ||
| 781 | 787 | ||
| 782 | def _cached_geoshift( | 788 | def _cached_geoshift( |
| 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, |
| 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 (see | 453 | # `--voxel` overrides config.voxel_size_m per-segment (see |
| 454 | # fuse_segment); reflect that in the resolved config reported here so | 454 | # 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. |
| 5 | Produces a `FuseResult` for the writers and renderer. | 5 | Produces a `FuseResult` for the writers and renderer. |
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | import logging | 8 | import logging |
| 9 | from dataclasses import asdict, dataclass, field, fields | 9 | from dataclasses import dataclass, field |
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | 11 | ||
| 12 | import numpy as np | 12 | import numpy as np |
| 13 | from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps | 13 | from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps |
| 39 | logger = logging.getLogger(__name__) | 39 | logger = logging.getLogger(__name__) |
| 40 | 40 | ||
| 41 | # Default voxel size for CLI help / smoke scripts; the authoritative default | 41 | # Default voxel size for CLI help / smoke scripts; the authoritative default |
| 42 | # lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off | 42 | # lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off |
| 43 | # the dataclass field rather than by building a `Seg3dConfig()` at import | 43 | # 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. |
| 45 | DEFAULT_VOXEL: float = next( | 45 | DEFAULT_VOXEL: float = Seg3dConfig.model_fields["voxel_size_m"].default |
| 46 | f.default for f in fields(Seg3dConfig) if f.name == "voxel_size_m" | ||
| 47 | ) | ||
| 48 | 46 | ||
| 49 | 47 | ||
| 50 | LINES_SOURCES = ("auto", "clusters", "xml") | 48 | LINES_SOURCES = ("auto", "clusters", "xml") |
| 51 | 49 |
| 1653 | 1651 | ||
| 1654 | def _stats_params(config: Seg3dConfig, voxel: float) -> dict: | 1652 | def _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. |
| 1656 | 1654 | ||
| 1657 | Built from the config dataclass itself rather than a hand-written | 1655 | Built from the config model itself rather than a hand-written |
| 1658 | list, so a knob added to `Seg3dConfig` is recorded automatically; see | 1656 | list, so a knob added to `Seg3dConfig` is recorded automatically; see |
| 1659 | `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is | 1657 | `_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 laid | 1658 | 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 behind | 1659 | over `config.voxel_size_m` here; the knobs the CLI overrides behind |
| 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: value | 1670 | 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_FIELDS | 1672 | 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 params | 1675 | return params |
| 3 | `config` validates them at load time, `cli` offers them as argparse | 3 | `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 in | 4 | `choices=` and `writer` re-checks them at its own entry points. They live in |
| 5 | their own module so neither the config loader nor the CLI parser has to | 5 | their own module so neither the config loader nor the CLI parser has to |
| 6 | import the writer (and with it laspy/open3d) just to know what a valid mode | 6 | import the writer (and with it laspy/open3d) just to know what a valid mode |
| 7 | string is; adding a mode is then a one-line change here. | 7 | string is. `config` states the same whitelists as `Literal` types on the |
| 8 | config 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 | """ |
| 9 | 11 | ||
| 10 | # Valid values for the ``las_rgb_mode`` config knob. | 12 | # Valid values for the ``las_rgb_mode`` config knob. |
| 11 | LAS_RGB_MODES = ("sensor", "class") | 13 | LAS_RGB_MODES = ("sensor", "class") |
| 1 | """Config parity tests. | 1 | """Config parity tests. |
| 2 | 2 | ||
| 3 | Every dataclass default must equal the packaged JSON default, and the | 3 | Every model field default must equal the packaged JSON default, and the |
| 4 | priority/hash-rounding values must match the code that consumes them. | 4 | priority/hash-rounding values must match the code that consumes them. |
| 5 | """ | 5 | """ |
| 6 | 6 | ||
| 7 | import typing | ||
| 8 | |||
| 7 | import pytest | 9 | import pytest |
| 8 | 10 | ||
| 9 | from iolabs_point_cloud_segmentation_3d import classes | 11 | from iolabs_point_cloud_segmentation_3d import classes, las_modes |
| 10 | from iolabs_point_cloud_segmentation_3d.config import ( | 12 | from 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 | ) |
| 18 | 22 | ||
| 19 | 23 | ||
| 24 | def 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 | |||
| 31 | def 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 | |||
| 20 | def test_priority_lut_override_changes_lut(): | 38 | def 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] == 9 | 41 | assert lut[classes.BY_NAME["solid_line"].las_code] == 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 logging | 48 | import logging |
| 31 | 49 | ||
| 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, |
| 42 | def test_strictly_increasing_tiers_do_not_warn(caplog): | 60 | def test_strictly_increasing_tiers_do_not_warn(caplog): |
| 43 | import logging | 61 | import logging |
| 44 | 62 | ||
| 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.records | 68 | 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 | ] |
| 53 | 71 | ||
| 54 | 72 | ||
| 55 | def test_hash_rounding_must_be_positive(): | 73 | def test_hash_rounding_must_be_positive(): |
| 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 | ) |
| 370 | 388 | ||
| 371 | 389 | ||
| 390 | def 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 | |||
| 406 | def 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 | |||
| 372 | def test_vegetation_limiters_validated_through_set_overrides(): | 417 | def 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", |
| 498 | assert extra["over_asphalt"] in result.medium_rows | 498 | assert extra["over_asphalt"] in result.medium_rows |
| 499 | 499 | ||
| 500 | 500 | ||
| 501 | def test_classify_vegetation_rejects_unknown_asphalt_rule(): | 501 | def 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")) |
| 504 | 506 | ||
| 505 | 507 | ||
| 506 | def test_classify_vegetation_rebands_short_detector_trees(): | 508 | def test_classify_vegetation_rebands_short_detector_trees(): |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-segmentation-3d" | 2 | name = "iolabs-point-cloud-segmentation-3d" |
| 3 | version = "0.3.1" | 3 | version = "0.3.2" |
| 4 | description = "3D segmentation of MLS LiDAR point clouds" | 4 | description = "3D segmentation of MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11,<3.13" | 6 | requires-python = ">=3.11,<3.13" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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 | ] |
| 16 | 17 |
| 142 | 142 | ||
| 143 | ## Config | 143 | ## Config |
| 144 | 144 | ||
| 145 | All numeric thresholds live in the package-owned `seg3d.default.json`, loaded | 145 | All numeric thresholds live in the package-owned `seg3d.default.json`, loaded |
| 146 | into a frozen `Seg3dConfig` dataclass (`config.py`). Every dataclass field | 146 | into a frozen `Seg3dConfig` pydantic model (`_config_model.py`, derived from |
| 147 | default is kept identical to the JSON, asserted by `tests/test_config.py`, so | 147 | `iolabs.common.config_loader.ConfigModel`; `config.py` is the loading entry |
| 148 | `Seg3dConfig()` and `load_config()` always agree. | 148 | point). Every model field default is kept identical to the JSON, asserted by |
| 149 | `tests/test_config.py`, so `Seg3dConfig()` and `load_config()` always agree. | ||
| 150 | Adding 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 | ||
| 152 | same default in `seg3d.default.json` -- unknown-key rejection, value coercion | ||
| 153 | and the error messages come from the shared layer. | ||
| 149 | 154 | ||
| 150 | Override without editing the packaged default: | 155 | Override without editing the packaged default: |
| 151 | 156 | ||
| 152 | ```bash | 157 | ```bash |
| 544 | 544 | ||
| 545 | Match `verticalsigns` / `asphaltedge` / `guardrails` style: | 545 | Match `verticalsigns` / `asphaltedge` / `guardrails` style: |
| 546 | 546 | ||
| 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 to | 548 | + `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`, |
| 554 | (guardrails/asphaltedge pattern). | 555 | (guardrails/asphaltedge pattern). |
| 555 | - `pyproject.toml`: hatch `force-include` of the default JSON into the wheel | 556 | - `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 section | 558 | - 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. |
| 560 | 561 | ||
| 561 | ## Dependencies | 562 | ## Dependencies |
| 562 | 563 |
| 544 | 544 | ||
| 545 | Match `verticalsigns` / `asphaltedge` / `guardrails` style: | 545 | Match `verticalsigns` / `asphaltedge` / `guardrails` style: |
| 546 | 546 | ||
| 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 to | 548 | + `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`, |
| 554 | (guardrails/asphaltedge pattern). | 555 | (guardrails/asphaltedge pattern). |
| 555 | - `pyproject.toml`: hatch `force-include` of the default JSON into the wheel | 556 | - `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 section | 558 | - 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. |
| 560 | 561 | ||
| 561 | ## Dependencies | 562 | ## Dependencies |
| 562 | 563 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-segmentation-3d" | 2 | name = "iolabs-point-cloud-segmentation-3d" |
| 3 | version = "0.3.1" | 3 | version = "0.3.2" |
| 4 | description = "3D segmentation of MLS LiDAR point clouds" | 4 | description = "3D segmentation of MLS LiDAR point clouds" |
| 5 | readme = "README.md" | 5 | readme = "README.md" |
| 6 | requires-python = ">=3.11,<3.13" | 6 | requires-python = ">=3.11,<3.13" |
| 7 | dependencies = [ | 7 | dependencies = [ |
| 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 | ] |
| 16 | 17 |
| 64 | import json | 64 | import json |
| 65 | import logging | 65 | import logging |
| 66 | import sys | 66 | import sys |
| 67 | import time | 67 | import time |
| 68 | from dataclasses import asdict, dataclass, field | 68 | from dataclasses import dataclass, field |
| 69 | from pathlib import Path | 69 | from pathlib import Path |
| 70 | 70 | ||
| 71 | import numpy as np | 71 | import numpy as np |
| 72 | from iolabs.common.crs import looks_georeferenced | 72 | from iolabs.common.crs import looks_georeferenced |
| 286 | Raises: | 286 | Raises: |
| 287 | SystemExit: Via `ConfigError` on a malformed spec, an unknown or | 287 | 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( |
| 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) |
| 393 | 396 | ||
| 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, |
| 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.instances | 453 | for inst in result.instances |
| 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 now | 502 | 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 | ) |
| 646 | 651 | ||
| 647 | def veg_params(config: Seg3dConfig) -> dict: | 652 | def 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 | } |
| 652 | 658 | ||
| 653 | 659 | ||
| 654 | def column_height_histogram( | 660 | def column_height_histogram( |
| 774 | 780 | ||
| 775 | 781 | ||
| 776 | def _default_distance(combo: dict) -> int: | 782 | def _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) |
| 780 | 786 | ||
| 781 | 787 | ||
| 782 | def _cached_geoshift( | 788 | def _cached_geoshift( |
| 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, |
| 1 | """The `Seg3dConfig` schema: every knob, its default and its range. | ||
| 2 | |||
| 3 | The model mirrors `seg3d.default.json` key for key -- adding a knob is a | ||
| 4 | field here plus the same key with the same default there. Unknown keys, | ||
| 5 | value coercion and the error messages come from | ||
| 6 | `iolabs.common.config_loader.ConfigModel`; ranges are `pydantic.Field` | ||
| 7 | bounds, cross-field rules are model validators. `config.py` is the public | ||
| 8 | entry point (loading, merging, `--set` parsing) and re-exports both names. | ||
| 9 | |||
| 10 | Distances are in metres unless the field name says otherwise. | ||
| 11 | """ | ||
| 12 | |||
| 13 | import logging | ||
| 14 | from typing import Literal | ||
| 15 | |||
| 16 | import pydantic | ||
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | from . import naming | ||
| 20 | |||
| 21 | logger = 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. | ||
| 32 | LasRgbMode = Literal["sensor", "class"] | ||
| 33 | LasSplitMode = Literal["none", "class", "instance"] | ||
| 34 | |||
| 35 | |||
| 36 | class ConfigError(config_loader.ConfigError): | ||
| 37 | """Raised when the seg3d config contains unsupported keys.""" | ||
| 38 | |||
| 39 | |||
| 40 | class 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 |
| 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 (see | 453 | # `--voxel` overrides config.voxel_size_m per-segment (see |
| 454 | # fuse_segment); reflect that in the resolved config reported here so | 454 | # 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. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the sibling iolabs point-cloud | 3 | Mirrors the config convention used by the sibling iolabs point-cloud |
| 4 | packages (`guardrails` / `verticalsigns`): the package owns a | 4 | packages (`guardrails` / `verticalsigns`): the package owns a |
| 5 | `seg3d.default.json` algorithm config, and a frozen typed params object | 5 | `seg3d.default.json` algorithm config, and a frozen typed params object |
| 6 | (`Seg3dConfig`) is loaded from it at CLI start. Every dataclass field | 6 | (`Seg3dConfig`, the pydantic model in `_config_model.py`) is loaded from it |
| 7 | default is kept identical to `seg3d.default.json` (guarded by | 7 | at 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()` |
| 9 | Runtime overrides are applied through repeatable `--set KEY=VALUE` flags | 9 | and `load_config` agree. Runtime overrides are applied through repeatable |
| 10 | or 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 |
| 11 | 11 | the packaged default. | |
| 12 | Distances are in metres unless the field name says otherwise. | 12 | |
| 13 | Adding a knob is two edits: a field on `Seg3dConfig` (with its range | ||
| 14 | expressed as `pydantic.Field(...)` bounds or a `model_validator`) and the | ||
| 15 | same key with the same default in `seg3d.default.json`. | ||
| 13 | """ | 16 | """ |
| 14 | 17 | ||
| 15 | import copy | ||
| 16 | import json | ||
| 17 | import logging | 18 | import logging |
| 18 | import math | ||
| 19 | from dataclasses import dataclass | ||
| 20 | from pathlib import Path | 19 | from pathlib import Path |
| 21 | from typing import Any | 20 | from typing import Any |
| 22 | 21 | ||
| 23 | from iolabs.common.config_loader import ConfigError as _CommonConfigError | 22 | from iolabs.common import config_loader |
| 24 | from iolabs.common.config_loader import dataclass_from_mapping, load_packaged_json | 23 | |
| 25 | from iolabs.common.config_loader import parse_set_overrides as _parse_set_overrides | 24 | from ._config_model import ConfigError, LasRgbMode, LasSplitMode, Seg3dConfig |
| 26 | 25 | ||
| 27 | from .las_modes import LAS_RGB_MODES, LAS_SPLIT_CONFIG_MODES | 26 | __all__ = [ |
| 28 | from .naming import PRODUCTION_TEMPLATE, NamingError, validate_naming_config | 27 | "ConfigError", |
| 28 | "LasRgbMode", | ||
| 29 | "LasSplitMode", | ||
| 30 | "Seg3dConfig", | ||
| 31 | "config_from_dict", | ||
| 32 | "load_config", | ||
| 33 | "load_default_config_dict", | ||
| 34 | "parse_set_overrides", | ||
| 35 | ] | ||
| 29 | 36 | ||
| 30 | logger = logging.getLogger(__name__) | 37 | logger = logging.getLogger(__name__) |
| 31 | 38 | ||
| 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" |
| 34 | 41 | _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) | ||
| 41 | class 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 | |||
| 334 | class ConfigError(_CommonConfigError): | ||
| 335 | """Raised when the seg3d config contains unsupported keys.""" | ||
| 336 | 42 | ||
| 337 | 43 | ||
| 338 | def load_default_config_dict() -> dict[str, Any]: | 44 | def 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 | ) | ||
| 341 | 49 | ||
| 342 | 50 | ||
| 343 | def config_from_dict(raw: dict[str, Any]) -> Seg3dConfig: | 51 | def 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. |
| 345 | 53 | ||
| 346 | Unknown keys are rejected and each raw value is coerced to its | 54 | Unknown keys are rejected and each raw value is coerced to its field's |
| 347 | dataclass field's declared type by | 55 | 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 a | 57 | and a non-integral value for an int field are errors, not silent |
| 350 | non-integral value for an int field are errors, not silent | 58 | misconfigurations. Missing keys fall back to the field defaults, which |
| 351 | misconfigurations. After construction, tier priorities are checked | 59 | 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. | ||
| 355 | 60 | ||
| 356 | Args: | 61 | Args: |
| 357 | raw: Flat mapping of `Seg3dConfig` field names to raw values | 62 | 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). |
| 360 | Returns: | 65 | Returns: |
| 361 | The validated `Seg3dConfig`. | 66 | The validated `Seg3dConfig`. |
| 362 | 67 | ||
| 363 | Raises: | 68 | Raises: |
| 364 | ConfigError: `raw` contains an unknown key or a value that is not | 69 | 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 not | 72 | 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=ConfigError | 75 | 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 | ||
| 584 | 77 | ||
| 585 | 78 | ||
| 586 | def load_config( | 79 | def load_config( |
| 587 | config_path: Path | None = None, | 80 | config_path: Path | None = None, |
| 599 | 92 | ||
| 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] = value | 102 | 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( |
| 633 | 127 | ||
| 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 | ) |
| 5 | Produces a `FuseResult` for the writers and renderer. | 5 | Produces a `FuseResult` for the writers and renderer. |
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | import logging | 8 | import logging |
| 9 | from dataclasses import asdict, dataclass, field, fields | 9 | from dataclasses import dataclass, field |
| 10 | from pathlib import Path | 10 | from pathlib import Path |
| 11 | 11 | ||
| 12 | import numpy as np | 12 | import numpy as np |
| 13 | from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps | 13 | from iolabs_geometry_geometry.polyline_hygiene import bbox_mask, bbox_overlaps |
| 39 | logger = logging.getLogger(__name__) | 39 | logger = logging.getLogger(__name__) |
| 40 | 40 | ||
| 41 | # Default voxel size for CLI help / smoke scripts; the authoritative default | 41 | # Default voxel size for CLI help / smoke scripts; the authoritative default |
| 42 | # lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off | 42 | # lives in `seg3d.default.json` via `Seg3dConfig` (see config.py). Read off |
| 43 | # the dataclass field rather than by building a `Seg3dConfig()` at import | 43 | # 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. |
| 45 | DEFAULT_VOXEL: float = next( | 45 | DEFAULT_VOXEL: float = Seg3dConfig.model_fields["voxel_size_m"].default |
| 46 | f.default for f in fields(Seg3dConfig) if f.name == "voxel_size_m" | ||
| 47 | ) | ||
| 48 | 46 | ||
| 49 | 47 | ||
| 50 | LINES_SOURCES = ("auto", "clusters", "xml") | 48 | LINES_SOURCES = ("auto", "clusters", "xml") |
| 51 | 49 |
| 1653 | 1651 | ||
| 1654 | def _stats_params(config: Seg3dConfig, voxel: float) -> dict: | 1652 | def _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. |
| 1656 | 1654 | ||
| 1657 | Built from the config dataclass itself rather than a hand-written | 1655 | Built from the config model itself rather than a hand-written |
| 1658 | list, so a knob added to `Seg3dConfig` is recorded automatically; see | 1656 | list, so a knob added to `Seg3dConfig` is recorded automatically; see |
| 1659 | `_PARAMS_EXCLUDED_FIELDS` for what is left out and why. `--voxel` is | 1657 | `_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 laid | 1658 | 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 behind | 1659 | over `config.voxel_size_m` here; the knobs the CLI overrides behind |
| 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: value | 1670 | 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_FIELDS | 1672 | 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 params | 1675 | return params |
| 3 | `config` validates them at load time, `cli` offers them as argparse | 3 | `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 in | 4 | `choices=` and `writer` re-checks them at its own entry points. They live in |
| 5 | their own module so neither the config loader nor the CLI parser has to | 5 | their own module so neither the config loader nor the CLI parser has to |
| 6 | import the writer (and with it laspy/open3d) just to know what a valid mode | 6 | import the writer (and with it laspy/open3d) just to know what a valid mode |
| 7 | string is; adding a mode is then a one-line change here. | 7 | string is. `config` states the same whitelists as `Literal` types on the |
| 8 | config 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 | """ |
| 9 | 11 | ||
| 10 | # Valid values for the ``las_rgb_mode`` config knob. | 12 | # Valid values for the ``las_rgb_mode`` config knob. |
| 11 | LAS_RGB_MODES = ("sensor", "class") | 13 | LAS_RGB_MODES = ("sensor", "class") |
| 1 | """Config parity tests. | 1 | """Config parity tests. |
| 2 | 2 | ||
| 3 | Every dataclass default must equal the packaged JSON default, and the | 3 | Every model field default must equal the packaged JSON default, and the |
| 4 | priority/hash-rounding values must match the code that consumes them. | 4 | priority/hash-rounding values must match the code that consumes them. |
| 5 | """ | 5 | """ |
| 6 | 6 | ||
| 7 | import typing | ||
| 8 | |||
| 7 | import pytest | 9 | import pytest |
| 8 | 10 | ||
| 9 | from iolabs_point_cloud_segmentation_3d import classes | 11 | from iolabs_point_cloud_segmentation_3d import classes, las_modes |
| 10 | from iolabs_point_cloud_segmentation_3d.config import ( | 12 | from 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 | ) |
| 18 | 22 | ||
| 19 | 23 | ||
| 24 | def 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 | |||
| 31 | def 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 | |||
| 20 | def test_priority_lut_override_changes_lut(): | 38 | def 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] == 9 | 41 | assert lut[classes.BY_NAME["solid_line"].las_code] == 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 logging | 48 | import logging |
| 31 | 49 | ||
| 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, |
| 42 | def test_strictly_increasing_tiers_do_not_warn(caplog): | 60 | def test_strictly_increasing_tiers_do_not_warn(caplog): |
| 43 | import logging | 61 | import logging |
| 44 | 62 | ||
| 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.records | 68 | 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 | ] |
| 53 | 71 | ||
| 54 | 72 | ||
| 55 | def test_hash_rounding_must_be_positive(): | 73 | def test_hash_rounding_must_be_positive(): |
| 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 | ) |
| 370 | 388 | ||
| 371 | 389 | ||
| 390 | def 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 | |||
| 406 | def 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 | |||
| 372 | def test_vegetation_limiters_validated_through_set_overrides(): | 417 | def 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", |
| 498 | assert extra["over_asphalt"] in result.medium_rows | 498 | assert extra["over_asphalt"] in result.medium_rows |
| 499 | 499 | ||
| 500 | 500 | ||
| 501 | def test_classify_vegetation_rejects_unknown_asphalt_rule(): | 501 | def 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")) |
| 504 | 506 | ||
| 505 | 507 | ||
| 506 | def test_classify_vegetation_rebands_short_detector_trees(): | 508 | def test_classify_vegetation_rebands_short_detector_trees(): |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.