Back to report index

Step 6 filteringclusters 883db80: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:34:34+02:00

Commit #33 ยท 11 snippets

 README.md                                          |   8 +-
 pyproject.toml                                     |   5 +-
 .../_config.py                                     | 365 ++++++++-------------
 tests/test_clustering_gpu_config.py                |  66 ++--
 4 files changed, 197 insertions(+), 247 deletions(-)
Importance #1: src/iolabs_point_cloud_filtering_clusters/_config.py @@ -1,254 +1,171 @@
1"""Packaged GPU Step 6 clustering config: pydantic model tree plus load helpers."""
2
1from __future__ import annotations3from __future__ import annotations
24
3import json5import logging
4from importlib import resources
5from pathlib import Path6from pathlib import Path
6from typing import Any7from typing import Any
78
9import pydantic
10from iolabs.common import config_loader
11
12logger = logging.getLogger(__name__)
13
8GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan"14GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan"
915
10ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS = frozenset(16_PACKAGE = "iolabs_point_cloud_filtering_clusters"
11 {17_DEFAULT_FILENAME = "clustering_gpu.default.json"
12 "device",18_CONTEXT = "step6 gpu config"
13 "initial_outlier_removal",
14 "voxelization",
15 "features",
16 "clustering",
17 "cluster_density_filtering",
18 "cluster_intensity_filtering",
19 "postprocess",
20 "file_naming",
21 }
22)
23
24ALLOWED_INITIAL_OUTLIER_REMOVAL_KEYS = frozenset({"nb_neighbors", "std_ratio"})
25ALLOWED_VOXELIZATION_KEYS = frozenset({"enabled", "voxel_size"})
26ALLOWED_FEATURE_KEYS = frozenset(
27 {"include_z_residual", "include_intensity", "normalize_intensity"}
28)
29ALLOWED_CLUSTERING_KEYS = frozenset(
30 {
31 "enable_density_filtering",
32 "enable_intensity_trimming",
33 "enable_intensity_peak_split",
34 "dbscan_eps",
35 "dbscan_guard_threshold_points",
36 "min_cluster_size",
37 "max_mbytes_per_batch",
38 "calc_core_sample_indices",
39 "safe_raw_points",
40 "safe_post_outlier_points",
41 "safe_engine_rows",
42 "dbscan_min_points",
43 "min_samples",
44 "include_noise_in_second_pass",
45 "skip_gpu_second_pass",
46 }
47)
48ALLOWED_CLUSTER_DENSITY_FILTERING_KEYS = frozenset(
49 {"density_cutoffs", "cutoff_portions"}
50)
51ALLOWED_CLUSTER_INTENSITY_FILTERING_KEYS = frozenset(
52 {
53 "n_bins",
54 "intensity_range",
55 "peak_distance",
56 "peak_prominence",
57 "sigma_estimate",
58 "sigma_estimate_peak_distance_fraction",
59 "n_sigma_intensity_cutoff",
60 "min_sigma",
61 }
62)
63ALLOWED_POSTPROCESS_KEYS = frozenset(
64 {"enable_geometry_filtering", "min_points", "min_length", "max_width", "max_height"}
65)
66ALLOWED_FILE_NAMING_KEYS = frozenset({"bright_filtered_suffix", "cluster_prefix"})
67
68
69class ClusterFinderGPUConfigError(ValueError):
70 """Raised when Step 6 GPU config contains unsupported keys."""
71
72
73def _default_gpu_config_path() -> Path:
74 if __package__ in {None, ""}:
75 return Path(__file__).resolve().with_name("clustering_gpu.default.json")
76 return Path(str(resources.files(__package__).joinpath("clustering_gpu.default.json")))
77
78
79def _deep_merge_dicts(
80 base: dict[str, Any],
81 overrides: dict[str, Any],
82) -> dict[str, Any]:
83 for key, value in overrides.items():
84 if isinstance(value, dict) and isinstance(base.get(key), dict):
85 base[key] = _deep_merge_dicts(dict(base[key]), value)
86 else:
87 base[key] = value
88 return base
8919
9020
91def _validate_allowed_keys(21class ClusterFinderGPUConfigError(config_loader.ConfigError):
92 config: dict[str, Any],22 """Raised when Step 6 GPU config contains unsupported keys or values."""
93 allowed_keys: frozenset[str],
94 *,
95 context: str,
96) -> None:
97 unknown_keys = sorted(set(config) - allowed_keys)
98 if not unknown_keys:
99 return
100
101 allowed = ", ".join(sorted(allowed_keys))
102 raise ClusterFinderGPUConfigError(
103 f"Unknown {context} key(s): {', '.join(unknown_keys)}. Allowed keys: {allowed}"
104 )
10523
10624
107def _normalize_section(25class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel):
108 raw_section: Any,26 """Statistical outlier-removal section of the GPU clustering config."""
109 *,
110 allowed_keys: frozenset[str],
111 context: str,
112) -> dict[str, Any]:
113 if raw_section is None:
114 section: dict[str, Any] = {}
115 elif isinstance(raw_section, dict):
116 section = dict(raw_section)
117 else:
118 raise ClusterFinderGPUConfigError(f"{context} must be a mapping")
11927
120 _validate_allowed_keys(section, allowed_keys, context=context)28 nb_neighbors: int = 20
121 return section29 std_ratio: float = 2.75
12230
12331
124def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]:32class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel):
125 config = dict(raw_config)33 """Voxel-downsample section of the GPU clustering config."""
126 if "engine" in config:
127 raise ClusterFinderGPUConfigError(
128 "Unknown step6 gpu config key(s): engine. Allowed keys: "
129 + ", ".join(sorted(ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS))
130 )
131 _validate_allowed_keys(
132 config,
133 ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS,
134 context="step6 gpu config",
135 )
13634
137 config.setdefault("device", "CUDA:0")35 enabled: bool = False
36 voxel_size: float = 0.03
13837
139 initial_outlier_removal = _normalize_section(38
140 config.get("initial_outlier_removal"),39class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel):
141 allowed_keys=ALLOWED_INITIAL_OUTLIER_REMOVAL_KEYS,40 """Feature-vector section of the GPU clustering config."""
142 context="step6 gpu initial_outlier_removal",41
143 )42 include_z_residual: bool = True
144 initial_outlier_removal.setdefault("nb_neighbors", 20)43 include_intensity: bool = False
145 initial_outlier_removal.setdefault("std_ratio", 2.75)44 normalize_intensity: bool = True
146 config["initial_outlier_removal"] = initial_outlier_removal45
14746
148 voxelization = _normalize_section(47class ClusterFinderGPUClusteringConfig(config_loader.ConfigModel):
149 config.get("voxelization"),48 """DBSCAN and pass-control section of the GPU clustering config."""
150 allowed_keys=ALLOWED_VOXELIZATION_KEYS,49
151 context="step6 gpu voxelization",50 enable_density_filtering: bool = True
152 )51 enable_intensity_trimming: bool = False
153 voxelization.setdefault("enabled", True)52 enable_intensity_peak_split: bool = False
154 voxelization.setdefault("voxel_size", 0.03)53 dbscan_eps: float = 1.2
155 config["voxelization"] = voxelization54 dbscan_guard_threshold_points: int = 15000
15655 min_cluster_size: int = 200
157 features = _normalize_section(56 dbscan_min_points: int = 50
158 config.get("features"),57 include_noise_in_second_pass: bool = False
159 allowed_keys=ALLOWED_FEATURE_KEYS,58 skip_gpu_second_pass: bool = False
160 context="step6 gpu features",59 max_mbytes_per_batch: int = 256
161 )60 calc_core_sample_indices: bool = False
162 features.setdefault("include_z_residual", True)61 safe_raw_points: int = 1000000
163 features.setdefault("include_intensity", False)62 safe_post_outlier_points: int = 900000
164 features.setdefault("normalize_intensity", True)63 safe_engine_rows: int = 400000
165 config["features"] = features64
16665 @pydantic.model_validator(mode="before")
167 clustering = _normalize_section(66 @classmethod
168 config.get("clustering"),67 def _map_min_samples_alias(cls, value: Any) -> Any:
169 allowed_keys=ALLOWED_CLUSTERING_KEYS,68 """Map the legacy ``min_samples`` clustering key onto ``dbscan_min_points``."""
170 context="step6 gpu clustering",69 if not isinstance(value, dict) or "min_samples" not in value:
171 )70 return value
172 clustering.setdefault("enable_density_filtering", True)71 data = dict(value)
173 clustering.setdefault("enable_intensity_trimming", False)72 min_samples = data.pop("min_samples")
174 clustering.setdefault("enable_intensity_peak_split", False)73 if min_samples is not None:
175 clustering.setdefault("dbscan_eps", 1.2)74 data["dbscan_min_points"] = min_samples
176 clustering.setdefault("dbscan_guard_threshold_points", 15000)75 return data
177 clustering.setdefault("min_cluster_size", 200)76
178 clustering.setdefault("max_mbytes_per_batch", 256)77
179 clustering.setdefault("calc_core_sample_indices", False)78class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel):
180 clustering.setdefault("safe_raw_points", 1000000)79 """Per-cluster density cutoff section of the GPU clustering config."""
181 clustering.setdefault("safe_post_outlier_points", 900000)80
182 clustering.setdefault("safe_engine_rows", 400000)81 density_cutoffs: list[float] = [0.0008, 0.0015, 0.003, 0.006]
183 min_samples = clustering.pop("min_samples", None)82 cutoff_portions: list[float] = [0.8, 0.8, 0.2, 0.02]
184 if min_samples is not None:83
185 clustering["dbscan_min_points"] = int(min_samples)84
186 clustering.setdefault("dbscan_min_points", 50)85class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel):
187 clustering.setdefault("include_noise_in_second_pass", False)86 """Per-cluster intensity histogram section of the GPU clustering config."""
188 clustering.setdefault("skip_gpu_second_pass", False)87
189 config["clustering"] = clustering88 n_bins: int = 50
19089 intensity_range: list[int] = [0, 255]
191 cluster_density_filtering = _normalize_section(90 peak_distance: int = 5
192 config.get("cluster_density_filtering"),91 peak_prominence: int = 10
193 allowed_keys=ALLOWED_CLUSTER_DENSITY_FILTERING_KEYS,92 sigma_estimate: float = 3.0
194 context="step6 gpu cluster_density_filtering",93 sigma_estimate_peak_distance_fraction: float = 0.125
195 )94 n_sigma_intensity_cutoff: float = 5.0
196 cluster_density_filtering.setdefault("density_cutoffs", [0.0008, 0.0015, 0.003, 0.006])95 min_sigma: float = 0.5
197 cluster_density_filtering.setdefault("cutoff_portions", [0.8, 0.8, 0.2, 0.02])96
198 config["cluster_density_filtering"] = cluster_density_filtering97
19998class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel):
200 cluster_intensity_filtering = _normalize_section(99 """Geometry-filter section of the GPU clustering config."""
201 config.get("cluster_intensity_filtering"),100
202 allowed_keys=ALLOWED_CLUSTER_INTENSITY_FILTERING_KEYS,101 enable_geometry_filtering: bool = False
203 context="step6 gpu cluster_intensity_filtering",102 min_points: int = 100
103 min_length: float = 1.0
104 max_width: float = 1.6
105 max_height: float = 0.2
106
107
108class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel):
109 """Input/output file-name section of the GPU clustering config."""
110
111 bright_filtered_suffix: str = "_run5_bright_filtered"
112 cluster_prefix: str = "run6_cluster_"
113
114
115class ClusterFinderGPUConfig(config_loader.ConfigModel):
116 """Root GPU Step 6 clustering config; field names match the packaged JSON."""
117
118 device: str = "CUDA:0"
119 voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig()
120 features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig()
121 initial_outlier_removal: ClusterFinderGPUInitialOutlierRemovalConfig = (
122 ClusterFinderGPUInitialOutlierRemovalConfig()
204 )123 )
205 cluster_intensity_filtering.setdefault("n_bins", 50)124 clustering: ClusterFinderGPUClusteringConfig = ClusterFinderGPUClusteringConfig()
206 cluster_intensity_filtering.setdefault("intensity_range", [0, 255])125 cluster_density_filtering: ClusterFinderGPUDensityFilteringConfig = (
207 cluster_intensity_filtering.setdefault("peak_distance", 5)126 ClusterFinderGPUDensityFilteringConfig()
208 cluster_intensity_filtering.setdefault("peak_prominence", 10)
209 cluster_intensity_filtering.setdefault("sigma_estimate", 3.0)
210 cluster_intensity_filtering.setdefault("sigma_estimate_peak_distance_fraction", 0.125)
211 cluster_intensity_filtering.setdefault("n_sigma_intensity_cutoff", 5.0)
212 cluster_intensity_filtering.setdefault("min_sigma", 0.5)
213 config["cluster_intensity_filtering"] = cluster_intensity_filtering
214
215 postprocess = _normalize_section(
216 config.get("postprocess"),
217 allowed_keys=ALLOWED_POSTPROCESS_KEYS,
218 context="step6 gpu postprocess",
219 )127 )
220 postprocess.setdefault("enable_geometry_filtering", False)128 cluster_intensity_filtering: ClusterFinderGPUIntensityFilteringConfig = (
221 postprocess.setdefault("min_points", 100)129 ClusterFinderGPUIntensityFilteringConfig()
222 postprocess.setdefault("min_length", 1.0)
223 postprocess.setdefault("max_width", 1.6)
224 postprocess.setdefault("max_height", 0.2)
225 config["postprocess"] = postprocess
226
227 file_naming = _normalize_section(
228 config.get("file_naming"),
229 allowed_keys=ALLOWED_FILE_NAMING_KEYS,
230 context="step6 gpu file_naming",
231 )130 )
232 file_naming.setdefault("bright_filtered_suffix", "_run5_bright_filtered")131 postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig()
233 file_naming.setdefault("cluster_prefix", "run6_cluster_")132 file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig()
234 config["file_naming"] = file_naming
235133
236 return config134
135def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]:
136 """Validate *raw_config* against the model tree and return a plain dict."""
137 return config_loader.validate_config(
138 ClusterFinderGPUConfig,
139 raw_config,
140 context=_CONTEXT,
141 error_cls=ClusterFinderGPUConfigError,
142 ).model_dump()
237143
238144
239def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]:145def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]:
240 resolved_path = Path(config_path) if config_path is not None else _default_gpu_config_path()146 """Load packaged (or *config_path*) defaults, validate, and return a plain dict."""
241 with resolved_path.open("r", encoding="utf-8") as handle:147 return config_loader.load_config(
242 raw_config: dict[str, Any] = json.load(handle)148 ClusterFinderGPUConfig,
243 return normalize_cluster_finder_gpu_config(raw_config)149 package=_PACKAGE,
150 filename=_DEFAULT_FILENAME,
151 config_path=config_path,
152 context=_CONTEXT,
153 error_cls=ClusterFinderGPUConfigError,
154 ).model_dump()
244155
245156
246def build_cluster_finder_gpu_config(157def build_cluster_finder_gpu_config(
247 *,158 *,
248 overrides: dict[str, Any] | None = None,159 overrides: dict[str, Any] | None = None,
249 config_path: str | Path | None = None,160 config_path: str | Path | None = None,
250) -> dict[str, Any]:161) -> dict[str, Any]:
251 config = load_cluster_finder_gpu_config(config_path)162 """Load defaults, deep-merge *overrides*, validate, and return a plain dict."""
252 if overrides:163 return config_loader.load_config(
253 config = _deep_merge_dicts(config, dict(overrides))164 ClusterFinderGPUConfig,
254 return normalize_cluster_finder_gpu_config(config)165 package=_PACKAGE,
166 filename=_DEFAULT_FILENAME,
167 overrides=overrides,
168 config_path=config_path,
169 context=_CONTEXT,
170 error_cls=ClusterFinderGPUConfigError,
171 ).model_dump()
Importance #2: tests/test_clustering_gpu_config.py @@ -1,42 +1,66 @@
1import importlib.util1import json
2from pathlib import Path
32
4import pytest3import pytest
54from iolabs.common import config_loader
65from iolabs_point_cloud_filtering_clusters import _config
7MODULE_PATH = (
8 Path(__file__)
9 .resolve()
10 .parents[1]
11 / "src"
12 / "iolabs_point_cloud_filtering_clusters"
13 / "_config.py"
14)
15
16SPEC = importlib.util.spec_from_file_location("step6_config", MODULE_PATH)
17assert SPEC is not None and SPEC.loader is not None
18MODULE = importlib.util.module_from_spec(SPEC)
19SPEC.loader.exec_module(MODULE)
206
217
22def test_load_cluster_finder_gpu_defaults() -> None:8def test_load_cluster_finder_gpu_defaults() -> None:
23 config = MODULE.load_cluster_finder_gpu_config()9 config = _config.load_cluster_finder_gpu_config()
2410
25 assert config["device"] == "CUDA:0"11 assert config["device"] == "CUDA:0"
26 assert config["voxelization"]["enabled"] is False12 assert config["voxelization"]["enabled"] is False
27 assert config["clustering"]["dbscan_min_points"] == 5013 assert config["clustering"]["dbscan_min_points"] == 50
28 assert config["file_naming"]["cluster_prefix"] == "run6_cluster_"14 assert config["file_naming"]["cluster_prefix"] == "run6_cluster_"
2915
3016
31def test_unknown_step6_gpu_key_is_rejected() -> None:17def test_unknown_step6_gpu_key_is_rejected() -> None:
32 with pytest.raises(MODULE.ClusterFinderGPUConfigError, match="random_seed"):18 with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"):
33 MODULE.build_cluster_finder_gpu_config(overrides={"random_seed": 1})19 _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1})
3420
3521
36def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None:22def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None:
37 config = MODULE.build_cluster_finder_gpu_config(23 config = _config.build_cluster_finder_gpu_config(
38 overrides={"clustering": {"min_samples": 12}}24 overrides={"clustering": {"min_samples": 12}}
39 )25 )
4026
41 assert config["clustering"]["dbscan_min_points"] == 1227 assert config["clustering"]["dbscan_min_points"] == 12
42 assert "min_samples" not in config["clustering"]28 assert "min_samples" not in config["clustering"]
29
30
31def test_cluster_finder_gpu_config_error_is_config_error() -> None:
32 assert issubclass(_config.ClusterFinderGPUConfigError, config_loader.ConfigError)
33 assert issubclass(_config.ClusterFinderGPUConfigError, ValueError)
34
35
36def test_defaults_round_trip_packaged_json() -> None:
37 packaged = json.loads(
38 config_loader.default_config_path(
39 "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json"
40 ).read_text(encoding="utf-8")
41 )
42
43 assert _config.load_cluster_finder_gpu_config() == packaged
44
45
46def test_bad_value_type_is_rejected() -> None:
47 with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"):
48 _config.build_cluster_finder_gpu_config(
49 overrides={"clustering": {"dbscan_eps": "not-a-number"}}
50 )
51
52
53def test_nested_unknown_key_names_its_section() -> None:
54 with pytest.raises(_config.ClusterFinderGPUConfigError, match="postprocess"):
55 _config.build_cluster_finder_gpu_config(overrides={"postprocess": {"bogus": 1}})
56
57
58def test_model_defaults_match_packaged_json() -> None:
59 """Model defaults must mirror the packaged JSON, so a partial config file agrees."""
60 packaged = json.loads(
61 config_loader.default_config_path(
62 "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json"
63 ).read_text(encoding="utf-8")
64 )
65
66 assert _config.ClusterFinderGPUConfig().model_dump() == packaged
Importance #3: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-filtering-clusters"2name = "iolabs-point-cloud-filtering-clusters"
3version = "0.6.3"3version = "0.6.4"
4description = "GPU Step 6 clustering plus cluster metadata helpers for lane modelling"4description = "GPU Step 6 clustering plus cluster metadata helpers for lane modelling"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
Importance #4: pyproject.toml @@ -9,9 +9,11 @@
9 "torch>=2.2.0",9 "torch>=2.2.0",
10 "scipy>=1.7.0",10 "scipy>=1.7.0",
11 "matplotlib>=3.4.0",11 "matplotlib>=3.4.0",
12 "scikit-learn>=1.0.0",12 "scikit-learn>=1.0.0",
13 "pydantic>=2.7",
13 "iolabs-logstash>=0.4.0",14 "iolabs-logstash>=0.4.0",
15 "iolabs-common>=0.8.0",
14 "iolabs-geometry-geometry",16 "iolabs-geometry-geometry",
15 "iolabs-geometry-visualization",17 "iolabs-geometry-visualization",
16 "iolabs-point-cloud-filtering-intensity>=0.5.1",18 "iolabs-point-cloud-filtering-intensity>=0.5.1",
17]19]
Importance #5: pyproject.toml @@ -35,8 +37,9 @@
35authenticate = "always"37authenticate = "always"
3638
37[tool.uv.sources]39[tool.uv.sources]
38iolabs-logstash = { index = "nexus" }40iolabs-logstash = { index = "nexus" }
41iolabs-common = { index = "nexus" }
39iolabs-geometry-geometry = { index = "nexus" }42iolabs-geometry-geometry = { index = "nexus" }
40iolabs-geometry-visualization = { index = "nexus" }43iolabs-geometry-visualization = { index = "nexus" }
41iolabs-point-cloud-filtering-intensity = { index = "nexus" }44iolabs-point-cloud-filtering-intensity = { index = "nexus" }
4245
Importance #6: README.md @@ -22,15 +22,21 @@
2222
23## Requirements23## Requirements
2424
25- Python โ‰ฅ3.11, <3.1325- Python โ‰ฅ3.11, <3.13
26- numpy, open3d, torch, scipy, matplotlib, scikit-learn, iolabs-geometry-geometry, iolabs-geometry-visualization, iolabs-point-cloud-filtering-intensity26- numpy, open3d, torch, scipy, matplotlib, scikit-learn, pydantic, iolabs-common, iolabs-geometry-geometry, iolabs-geometry-visualization, iolabs-point-cloud-filtering-intensity
27- optional GPU runtime: cupy-cuda12x, rmm-cu12, cuml-cu1227- optional GPU runtime: cupy-cuda12x, rmm-cu12, cuml-cu12
2828
29## Usage29## Usage
3030
31Runs the GPU Step 6 clustering pipeline over Step 5 bright-filtered points, emits per-cluster NPZ artifacts, and provides cluster metadata helpers used by the modelling-lines package.31Runs the GPU Step 6 clustering pipeline over Step 5 bright-filtered points, emits per-cluster NPZ artifacts, and provides cluster metadata helpers used by the modelling-lines package.
3232
33## Configuration
34
35GPU Step 6 defaults live in `src/iolabs_point_cloud_filtering_clusters/clustering_gpu.default.json` and are mirrored by the pydantic model tree in `_config.py` (`ClusterFinderGPUConfig`, nested sections as nested models).
36
37To add a config key: add a field to the matching `config_loader.ConfigModel` and the same key to the packaged JSON default. Nothing else. Unknown keys are rejected; overrides deep-merge onto the packaged defaults.
38
33## Develop locally (Nexus)39## Develop locally (Nexus)
3440
35Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps โ€” e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc โ€” then:41Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps โ€” e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc โ€” then:
3642
Importance #7: pyproject.toml @@ -1,7 +1,7 @@
1[project]1[project]
2name = "iolabs-point-cloud-filtering-clusters"2name = "iolabs-point-cloud-filtering-clusters"
3version = "0.6.3"3version = "0.6.4"
4description = "GPU Step 6 clustering plus cluster metadata helpers for lane modelling"4description = "GPU Step 6 clustering plus cluster metadata helpers for lane modelling"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
Importance #8: pyproject.toml @@ -9,9 +9,11 @@
9 "torch>=2.2.0",9 "torch>=2.2.0",
10 "scipy>=1.7.0",10 "scipy>=1.7.0",
11 "matplotlib>=3.4.0",11 "matplotlib>=3.4.0",
12 "scikit-learn>=1.0.0",12 "scikit-learn>=1.0.0",
13 "pydantic>=2.7",
13 "iolabs-logstash>=0.4.0",14 "iolabs-logstash>=0.4.0",
15 "iolabs-common>=0.8.0",
14 "iolabs-geometry-geometry",16 "iolabs-geometry-geometry",
15 "iolabs-geometry-visualization",17 "iolabs-geometry-visualization",
16 "iolabs-point-cloud-filtering-intensity>=0.5.1",18 "iolabs-point-cloud-filtering-intensity>=0.5.1",
17]19]
Importance #9: pyproject.toml @@ -35,8 +37,9 @@
35authenticate = "always"37authenticate = "always"
3638
37[tool.uv.sources]39[tool.uv.sources]
38iolabs-logstash = { index = "nexus" }40iolabs-logstash = { index = "nexus" }
41iolabs-common = { index = "nexus" }
39iolabs-geometry-geometry = { index = "nexus" }42iolabs-geometry-geometry = { index = "nexus" }
40iolabs-geometry-visualization = { index = "nexus" }43iolabs-geometry-visualization = { index = "nexus" }
41iolabs-point-cloud-filtering-intensity = { index = "nexus" }44iolabs-point-cloud-filtering-intensity = { index = "nexus" }
4245
Importance #10: src/iolabs_point_cloud_filtering_clusters/_config.py @@ -1,254 +1,171 @@
1"""Packaged GPU Step 6 clustering config: pydantic model tree plus load helpers."""
2
1from __future__ import annotations3from __future__ import annotations
24
3import json5import logging
4from importlib import resources
5from pathlib import Path6from pathlib import Path
6from typing import Any7from typing import Any
78
9import pydantic
10from iolabs.common import config_loader
11
12logger = logging.getLogger(__name__)
13
8GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan"14GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan"
915
10ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS = frozenset(16_PACKAGE = "iolabs_point_cloud_filtering_clusters"
11 {17_DEFAULT_FILENAME = "clustering_gpu.default.json"
12 "device",18_CONTEXT = "step6 gpu config"
13 "initial_outlier_removal",
14 "voxelization",
15 "features",
16 "clustering",
17 "cluster_density_filtering",
18 "cluster_intensity_filtering",
19 "postprocess",
20 "file_naming",
21 }
22)
23
24ALLOWED_INITIAL_OUTLIER_REMOVAL_KEYS = frozenset({"nb_neighbors", "std_ratio"})
25ALLOWED_VOXELIZATION_KEYS = frozenset({"enabled", "voxel_size"})
26ALLOWED_FEATURE_KEYS = frozenset(
27 {"include_z_residual", "include_intensity", "normalize_intensity"}
28)
29ALLOWED_CLUSTERING_KEYS = frozenset(
30 {
31 "enable_density_filtering",
32 "enable_intensity_trimming",
33 "enable_intensity_peak_split",
34 "dbscan_eps",
35 "dbscan_guard_threshold_points",
36 "min_cluster_size",
37 "max_mbytes_per_batch",
38 "calc_core_sample_indices",
39 "safe_raw_points",
40 "safe_post_outlier_points",
41 "safe_engine_rows",
42 "dbscan_min_points",
43 "min_samples",
44 "include_noise_in_second_pass",
45 "skip_gpu_second_pass",
46 }
47)
48ALLOWED_CLUSTER_DENSITY_FILTERING_KEYS = frozenset(
49 {"density_cutoffs", "cutoff_portions"}
50)
51ALLOWED_CLUSTER_INTENSITY_FILTERING_KEYS = frozenset(
52 {
53 "n_bins",
54 "intensity_range",
55 "peak_distance",
56 "peak_prominence",
57 "sigma_estimate",
58 "sigma_estimate_peak_distance_fraction",
59 "n_sigma_intensity_cutoff",
60 "min_sigma",
61 }
62)
63ALLOWED_POSTPROCESS_KEYS = frozenset(
64 {"enable_geometry_filtering", "min_points", "min_length", "max_width", "max_height"}
65)
66ALLOWED_FILE_NAMING_KEYS = frozenset({"bright_filtered_suffix", "cluster_prefix"})
67
68
69class ClusterFinderGPUConfigError(ValueError):
70 """Raised when Step 6 GPU config contains unsupported keys."""
71
72
73def _default_gpu_config_path() -> Path:
74 if __package__ in {None, ""}:
75 return Path(__file__).resolve().with_name("clustering_gpu.default.json")
76 return Path(str(resources.files(__package__).joinpath("clustering_gpu.default.json")))
77
78
79def _deep_merge_dicts(
80 base: dict[str, Any],
81 overrides: dict[str, Any],
82) -> dict[str, Any]:
83 for key, value in overrides.items():
84 if isinstance(value, dict) and isinstance(base.get(key), dict):
85 base[key] = _deep_merge_dicts(dict(base[key]), value)
86 else:
87 base[key] = value
88 return base
8919
9020
91def _validate_allowed_keys(21class ClusterFinderGPUConfigError(config_loader.ConfigError):
92 config: dict[str, Any],22 """Raised when Step 6 GPU config contains unsupported keys or values."""
93 allowed_keys: frozenset[str],
94 *,
95 context: str,
96) -> None:
97 unknown_keys = sorted(set(config) - allowed_keys)
98 if not unknown_keys:
99 return
100
101 allowed = ", ".join(sorted(allowed_keys))
102 raise ClusterFinderGPUConfigError(
103 f"Unknown {context} key(s): {', '.join(unknown_keys)}. Allowed keys: {allowed}"
104 )
10523
10624
107def _normalize_section(25class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel):
108 raw_section: Any,26 """Statistical outlier-removal section of the GPU clustering config."""
109 *,
110 allowed_keys: frozenset[str],
111 context: str,
112) -> dict[str, Any]:
113 if raw_section is None:
114 section: dict[str, Any] = {}
115 elif isinstance(raw_section, dict):
116 section = dict(raw_section)
117 else:
118 raise ClusterFinderGPUConfigError(f"{context} must be a mapping")
11927
120 _validate_allowed_keys(section, allowed_keys, context=context)28 nb_neighbors: int = 20
121 return section29 std_ratio: float = 2.75
12230
12331
124def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]:32class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel):
125 config = dict(raw_config)33 """Voxel-downsample section of the GPU clustering config."""
126 if "engine" in config:
127 raise ClusterFinderGPUConfigError(
128 "Unknown step6 gpu config key(s): engine. Allowed keys: "
129 + ", ".join(sorted(ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS))
130 )
131 _validate_allowed_keys(
132 config,
133 ALLOWED_CLUSTER_FINDER_GPU_CONFIG_KEYS,
134 context="step6 gpu config",
135 )
13634
137 config.setdefault("device", "CUDA:0")35 enabled: bool = False
36 voxel_size: float = 0.03
13837
139 initial_outlier_removal = _normalize_section(38
140 config.get("initial_outlier_removal"),39class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel):
141 allowed_keys=ALLOWED_INITIAL_OUTLIER_REMOVAL_KEYS,40 """Feature-vector section of the GPU clustering config."""
142 context="step6 gpu initial_outlier_removal",41
143 )42 include_z_residual: bool = True
144 initial_outlier_removal.setdefault("nb_neighbors", 20)43 include_intensity: bool = False
145 initial_outlier_removal.setdefault("std_ratio", 2.75)44 normalize_intensity: bool = True
146 config["initial_outlier_removal"] = initial_outlier_removal45
14746
148 voxelization = _normalize_section(47class ClusterFinderGPUClusteringConfig(config_loader.ConfigModel):
149 config.get("voxelization"),48 """DBSCAN and pass-control section of the GPU clustering config."""
150 allowed_keys=ALLOWED_VOXELIZATION_KEYS,49
151 context="step6 gpu voxelization",50 enable_density_filtering: bool = True
152 )51 enable_intensity_trimming: bool = False
153 voxelization.setdefault("enabled", True)52 enable_intensity_peak_split: bool = False
154 voxelization.setdefault("voxel_size", 0.03)53 dbscan_eps: float = 1.2
155 config["voxelization"] = voxelization54 dbscan_guard_threshold_points: int = 15000
15655 min_cluster_size: int = 200
157 features = _normalize_section(56 dbscan_min_points: int = 50
158 config.get("features"),57 include_noise_in_second_pass: bool = False
159 allowed_keys=ALLOWED_FEATURE_KEYS,58 skip_gpu_second_pass: bool = False
160 context="step6 gpu features",59 max_mbytes_per_batch: int = 256
161 )60 calc_core_sample_indices: bool = False
162 features.setdefault("include_z_residual", True)61 safe_raw_points: int = 1000000
163 features.setdefault("include_intensity", False)62 safe_post_outlier_points: int = 900000
164 features.setdefault("normalize_intensity", True)63 safe_engine_rows: int = 400000
165 config["features"] = features64
16665 @pydantic.model_validator(mode="before")
167 clustering = _normalize_section(66 @classmethod
168 config.get("clustering"),67 def _map_min_samples_alias(cls, value: Any) -> Any:
169 allowed_keys=ALLOWED_CLUSTERING_KEYS,68 """Map the legacy ``min_samples`` clustering key onto ``dbscan_min_points``."""
170 context="step6 gpu clustering",69 if not isinstance(value, dict) or "min_samples" not in value:
171 )70 return value
172 clustering.setdefault("enable_density_filtering", True)71 data = dict(value)
173 clustering.setdefault("enable_intensity_trimming", False)72 min_samples = data.pop("min_samples")
174 clustering.setdefault("enable_intensity_peak_split", False)73 if min_samples is not None:
175 clustering.setdefault("dbscan_eps", 1.2)74 data["dbscan_min_points"] = min_samples
176 clustering.setdefault("dbscan_guard_threshold_points", 15000)75 return data
177 clustering.setdefault("min_cluster_size", 200)76
178 clustering.setdefault("max_mbytes_per_batch", 256)77
179 clustering.setdefault("calc_core_sample_indices", False)78class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel):
180 clustering.setdefault("safe_raw_points", 1000000)79 """Per-cluster density cutoff section of the GPU clustering config."""
181 clustering.setdefault("safe_post_outlier_points", 900000)80
182 clustering.setdefault("safe_engine_rows", 400000)81 density_cutoffs: list[float] = [0.0008, 0.0015, 0.003, 0.006]
183 min_samples = clustering.pop("min_samples", None)82 cutoff_portions: list[float] = [0.8, 0.8, 0.2, 0.02]
184 if min_samples is not None:83
185 clustering["dbscan_min_points"] = int(min_samples)84
186 clustering.setdefault("dbscan_min_points", 50)85class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel):
187 clustering.setdefault("include_noise_in_second_pass", False)86 """Per-cluster intensity histogram section of the GPU clustering config."""
188 clustering.setdefault("skip_gpu_second_pass", False)87
189 config["clustering"] = clustering88 n_bins: int = 50
19089 intensity_range: list[int] = [0, 255]
191 cluster_density_filtering = _normalize_section(90 peak_distance: int = 5
192 config.get("cluster_density_filtering"),91 peak_prominence: int = 10
193 allowed_keys=ALLOWED_CLUSTER_DENSITY_FILTERING_KEYS,92 sigma_estimate: float = 3.0
194 context="step6 gpu cluster_density_filtering",93 sigma_estimate_peak_distance_fraction: float = 0.125
195 )94 n_sigma_intensity_cutoff: float = 5.0
196 cluster_density_filtering.setdefault("density_cutoffs", [0.0008, 0.0015, 0.003, 0.006])95 min_sigma: float = 0.5
197 cluster_density_filtering.setdefault("cutoff_portions", [0.8, 0.8, 0.2, 0.02])96
198 config["cluster_density_filtering"] = cluster_density_filtering97
19998class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel):
200 cluster_intensity_filtering = _normalize_section(99 """Geometry-filter section of the GPU clustering config."""
201 config.get("cluster_intensity_filtering"),100
202 allowed_keys=ALLOWED_CLUSTER_INTENSITY_FILTERING_KEYS,101 enable_geometry_filtering: bool = False
203 context="step6 gpu cluster_intensity_filtering",102 min_points: int = 100
103 min_length: float = 1.0
104 max_width: float = 1.6
105 max_height: float = 0.2
106
107
108class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel):
109 """Input/output file-name section of the GPU clustering config."""
110
111 bright_filtered_suffix: str = "_run5_bright_filtered"
112 cluster_prefix: str = "run6_cluster_"
113
114
115class ClusterFinderGPUConfig(config_loader.ConfigModel):
116 """Root GPU Step 6 clustering config; field names match the packaged JSON."""
117
118 device: str = "CUDA:0"
119 voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig()
120 features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig()
121 initial_outlier_removal: ClusterFinderGPUInitialOutlierRemovalConfig = (
122 ClusterFinderGPUInitialOutlierRemovalConfig()
204 )123 )
205 cluster_intensity_filtering.setdefault("n_bins", 50)124 clustering: ClusterFinderGPUClusteringConfig = ClusterFinderGPUClusteringConfig()
206 cluster_intensity_filtering.setdefault("intensity_range", [0, 255])125 cluster_density_filtering: ClusterFinderGPUDensityFilteringConfig = (
207 cluster_intensity_filtering.setdefault("peak_distance", 5)126 ClusterFinderGPUDensityFilteringConfig()
208 cluster_intensity_filtering.setdefault("peak_prominence", 10)
209 cluster_intensity_filtering.setdefault("sigma_estimate", 3.0)
210 cluster_intensity_filtering.setdefault("sigma_estimate_peak_distance_fraction", 0.125)
211 cluster_intensity_filtering.setdefault("n_sigma_intensity_cutoff", 5.0)
212 cluster_intensity_filtering.setdefault("min_sigma", 0.5)
213 config["cluster_intensity_filtering"] = cluster_intensity_filtering
214
215 postprocess = _normalize_section(
216 config.get("postprocess"),
217 allowed_keys=ALLOWED_POSTPROCESS_KEYS,
218 context="step6 gpu postprocess",
219 )127 )
220 postprocess.setdefault("enable_geometry_filtering", False)128 cluster_intensity_filtering: ClusterFinderGPUIntensityFilteringConfig = (
221 postprocess.setdefault("min_points", 100)129 ClusterFinderGPUIntensityFilteringConfig()
222 postprocess.setdefault("min_length", 1.0)
223 postprocess.setdefault("max_width", 1.6)
224 postprocess.setdefault("max_height", 0.2)
225 config["postprocess"] = postprocess
226
227 file_naming = _normalize_section(
228 config.get("file_naming"),
229 allowed_keys=ALLOWED_FILE_NAMING_KEYS,
230 context="step6 gpu file_naming",
231 )130 )
232 file_naming.setdefault("bright_filtered_suffix", "_run5_bright_filtered")131 postprocess: ClusterFinderGPUPostprocessConfig = ClusterFinderGPUPostprocessConfig()
233 file_naming.setdefault("cluster_prefix", "run6_cluster_")132 file_naming: ClusterFinderGPUFileNamingConfig = ClusterFinderGPUFileNamingConfig()
234 config["file_naming"] = file_naming
235133
236 return config134
135def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]:
136 """Validate *raw_config* against the model tree and return a plain dict."""
137 return config_loader.validate_config(
138 ClusterFinderGPUConfig,
139 raw_config,
140 context=_CONTEXT,
141 error_cls=ClusterFinderGPUConfigError,
142 ).model_dump()
237143
238144
239def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]:145def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]:
240 resolved_path = Path(config_path) if config_path is not None else _default_gpu_config_path()146 """Load packaged (or *config_path*) defaults, validate, and return a plain dict."""
241 with resolved_path.open("r", encoding="utf-8") as handle:147 return config_loader.load_config(
242 raw_config: dict[str, Any] = json.load(handle)148 ClusterFinderGPUConfig,
243 return normalize_cluster_finder_gpu_config(raw_config)149 package=_PACKAGE,
150 filename=_DEFAULT_FILENAME,
151 config_path=config_path,
152 context=_CONTEXT,
153 error_cls=ClusterFinderGPUConfigError,
154 ).model_dump()
244155
245156
246def build_cluster_finder_gpu_config(157def build_cluster_finder_gpu_config(
247 *,158 *,
248 overrides: dict[str, Any] | None = None,159 overrides: dict[str, Any] | None = None,
249 config_path: str | Path | None = None,160 config_path: str | Path | None = None,
250) -> dict[str, Any]:161) -> dict[str, Any]:
251 config = load_cluster_finder_gpu_config(config_path)162 """Load defaults, deep-merge *overrides*, validate, and return a plain dict."""
252 if overrides:163 return config_loader.load_config(
253 config = _deep_merge_dicts(config, dict(overrides))164 ClusterFinderGPUConfig,
254 return normalize_cluster_finder_gpu_config(config)165 package=_PACKAGE,
166 filename=_DEFAULT_FILENAME,
167 overrides=overrides,
168 config_path=config_path,
169 context=_CONTEXT,
170 error_cls=ClusterFinderGPUConfigError,
171 ).model_dump()
Importance #11: tests/test_clustering_gpu_config.py @@ -1,42 +1,66 @@
1import importlib.util1import json
2from pathlib import Path
32
4import pytest3import pytest
54from iolabs.common import config_loader
65from iolabs_point_cloud_filtering_clusters import _config
7MODULE_PATH = (
8 Path(__file__)
9 .resolve()
10 .parents[1]
11 / "src"
12 / "iolabs_point_cloud_filtering_clusters"
13 / "_config.py"
14)
15
16SPEC = importlib.util.spec_from_file_location("step6_config", MODULE_PATH)
17assert SPEC is not None and SPEC.loader is not None
18MODULE = importlib.util.module_from_spec(SPEC)
19SPEC.loader.exec_module(MODULE)
206
217
22def test_load_cluster_finder_gpu_defaults() -> None:8def test_load_cluster_finder_gpu_defaults() -> None:
23 config = MODULE.load_cluster_finder_gpu_config()9 config = _config.load_cluster_finder_gpu_config()
2410
25 assert config["device"] == "CUDA:0"11 assert config["device"] == "CUDA:0"
26 assert config["voxelization"]["enabled"] is False12 assert config["voxelization"]["enabled"] is False
27 assert config["clustering"]["dbscan_min_points"] == 5013 assert config["clustering"]["dbscan_min_points"] == 50
28 assert config["file_naming"]["cluster_prefix"] == "run6_cluster_"14 assert config["file_naming"]["cluster_prefix"] == "run6_cluster_"
2915
3016
31def test_unknown_step6_gpu_key_is_rejected() -> None:17def test_unknown_step6_gpu_key_is_rejected() -> None:
32 with pytest.raises(MODULE.ClusterFinderGPUConfigError, match="random_seed"):18 with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"):
33 MODULE.build_cluster_finder_gpu_config(overrides={"random_seed": 1})19 _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1})
3420
3521
36def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None:22def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None:
37 config = MODULE.build_cluster_finder_gpu_config(23 config = _config.build_cluster_finder_gpu_config(
38 overrides={"clustering": {"min_samples": 12}}24 overrides={"clustering": {"min_samples": 12}}
39 )25 )
4026
41 assert config["clustering"]["dbscan_min_points"] == 1227 assert config["clustering"]["dbscan_min_points"] == 12
42 assert "min_samples" not in config["clustering"]28 assert "min_samples" not in config["clustering"]
29
30
31def test_cluster_finder_gpu_config_error_is_config_error() -> None:
32 assert issubclass(_config.ClusterFinderGPUConfigError, config_loader.ConfigError)
33 assert issubclass(_config.ClusterFinderGPUConfigError, ValueError)
34
35
36def test_defaults_round_trip_packaged_json() -> None:
37 packaged = json.loads(
38 config_loader.default_config_path(
39 "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json"
40 ).read_text(encoding="utf-8")
41 )
42
43 assert _config.load_cluster_finder_gpu_config() == packaged
44
45
46def test_bad_value_type_is_rejected() -> None:
47 with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"):
48 _config.build_cluster_finder_gpu_config(
49 overrides={"clustering": {"dbscan_eps": "not-a-number"}}
50 )
51
52
53def test_nested_unknown_key_names_its_section() -> None:
54 with pytest.raises(_config.ClusterFinderGPUConfigError, match="postprocess"):
55 _config.build_cluster_finder_gpu_config(overrides={"postprocess": {"bogus": 1}})
56
57
58def test_model_defaults_match_packaged_json() -> None:
59 """Model defaults must mirror the packaged JSON, so a partial config file agrees."""
60 packaged = json.loads(
61 config_loader.default_config_path(
62 "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json"
63 ).read_text(encoding="utf-8")
64 )
65
66 assert _config.ClusterFinderGPUConfig().model_dump() == packaged