Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:38:40+02:00
Commit #35 · 33 snippets
README.md | 6 +- .../__init__.py | 2 +- .../_config.py | 137 +++++++++++++-------- .../cluster_finder.py | 6 +- .../clustering_gpu_engine.py | 7 +- .../clustering_gpu_pipeline_recursive.py | 3 +- .../clustering_gpu_pipeline_stages.py | 3 +- .../clustering_gpu_split.py | 7 +- .../clustering_gpu_types.py | 2 + tests/test_clustering_gpu_config.py | 81 ------------ tests/test_config.py | 126 +++++++++++++++++++ 11 files changed, 230 insertions(+), 150 deletions(-)
| 1 | """Packaged GPU Step 6 clustering config: pydantic model tree plus load helpers.""" | 1 | """Packaged GPU clustering config: pydantic model tree plus load helpers. |
| 2 | |||
| 3 | The schema is `ClusterFinderGPUConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `clustering_gpu.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `clustering_gpu.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return plain JSON-typed dicts (`dict[str, Any]`), because callers | ||
| 10 | pass `--set`-style overrides around as dicts and embed the normalized mapping in | ||
| 11 | run manifests. | ||
| 12 | """ | ||
| 2 | 13 | ||
| 3 | from __future__ import annotations | 14 | from __future__ import annotations |
| 4 | 15 | ||
| 5 | import logging | 16 | import logging |
| 17 | from collections.abc import Mapping | ||
| 6 | from pathlib import Path | 18 | from pathlib import Path |
| 7 | from typing import Any | 19 | from typing import Any |
| 8 | 20 | ||
| 9 | import pydantic | 21 | import pydantic |
| 10 | from iolabs.common import config_loader | 22 | from iolabs.common import config_loader |
| 11 | 23 | ||
| 12 | logger = logging.getLogger(__name__) | 24 | logger = logging.getLogger(__name__) |
| 13 | 25 | ||
| 14 | GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan" | 26 | _PACKAGE_NAME = "iolabs_point_cloud_filtering_clusters" |
| 15 | |||
| 16 | _PACKAGE = "iolabs_point_cloud_filtering_clusters" | ||
| 17 | _DEFAULT_FILENAME = "clustering_gpu.default.json" | 27 | _DEFAULT_FILENAME = "clustering_gpu.default.json" |
| 18 | _CONTEXT = "step6 gpu config" | 28 | _CONTEXT = "clustering GPU config" |
| 19 | 29 | ||
| 20 | 30 | ||
| 21 | class ClusterFinderGPUConfigError(config_loader.ConfigError): | 31 | class ClusterFinderGPUConfigError(config_loader.ConfigError): |
| 22 | """Raised when Step 6 GPU config contains unsupported keys or values.""" | 32 | """Raised when clustering GPU config contains unsupported keys or values.""" |
| 23 | 33 | ||
| 24 | 34 | ||
| 25 | class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel): | 35 | class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel): |
| 26 | """Statistical outlier-removal section of the GPU clustering config.""" | 36 | """Statistical outlier-removal section of the GPU clustering config.""" |
| 27 | 37 | ||
| 28 | nb_neighbors: int = 20 | 38 | nb_neighbors: int = pydantic.Field(default=20, ge=1) |
| 29 | std_ratio: float = 2.75 | 39 | std_ratio: float = pydantic.Field(default=2.75, gt=0.0) |
| 30 | 40 | ||
| 31 | 41 | ||
| 32 | class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel): | 42 | class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel): |
| 33 | """Voxel-downsample section of the GPU clustering config.""" | 43 | """Voxel-downsample section of the GPU clustering config.""" |
| 34 | 44 | ||
| 35 | enabled: bool = False | 45 | enabled: bool = False |
| 36 | voxel_size: float = 0.03 | 46 | voxel_size: float = pydantic.Field(default=0.03, gt=0.0) |
| 37 | 47 | ||
| 38 | 48 | ||
| 39 | class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel): | 49 | class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel): |
| 40 | """Feature-vector section of the GPU clustering config.""" | 50 | """Feature-vector section of the GPU clustering config.""" |
| 77 | 87 | ||
| 78 | class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel): | 88 | class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel): |
| 79 | """Per-cluster density cutoff section of the GPU clustering config.""" | 89 | """Per-cluster density cutoff section of the GPU clustering config.""" |
| 80 | 90 | ||
| 81 | density_cutoffs: list[float] = [0.0008, 0.0015, 0.003, 0.006] | 91 | density_cutoffs: tuple[float, ...] = (0.0008, 0.0015, 0.003, 0.006) |
| 82 | cutoff_portions: list[float] = [0.8, 0.8, 0.2, 0.02] | 92 | cutoff_portions: tuple[float, ...] = (0.8, 0.8, 0.2, 0.02) |
| 93 | |||
| 94 | @pydantic.model_validator(mode="after") | ||
| 95 | def _check_cutoffs(self) -> "ClusterFinderGPUDensityFilteringConfig": | ||
| 96 | """Reject mismatched cutoff lists or portions outside ``(0, 1]``.""" | ||
| 97 | if len(self.density_cutoffs) != len(self.cutoff_portions): | ||
| 98 | raise ValueError("density_cutoffs and cutoff_portions must have the same length") | ||
| 99 | if any(portion <= 0.0 or portion > 1.0 for portion in self.cutoff_portions): | ||
| 100 | raise ValueError("every cutoff_portions entry must satisfy 0 < portion <= 1") | ||
| 101 | return self | ||
| 83 | 102 | ||
| 84 | 103 | ||
| 85 | class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel): | 104 | class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel): |
| 86 | """Per-cluster intensity histogram section of the GPU clustering config.""" | 105 | """Per-cluster intensity histogram section of the GPU clustering config.""" |
| 87 | 106 | ||
| 88 | n_bins: int = 50 | 107 | n_bins: int = pydantic.Field(default=50, ge=1) |
| 89 | intensity_range: list[int] = [0, 255] | 108 | intensity_range: tuple[int, int] = (0, 255) |
| 90 | peak_distance: int = 5 | 109 | peak_distance: int = pydantic.Field(default=5, ge=1) |
| 91 | peak_prominence: int = 10 | 110 | peak_prominence: int = pydantic.Field(default=10, ge=0) |
| 92 | sigma_estimate: float = 3.0 | 111 | sigma_estimate: float = pydantic.Field(default=3.0, gt=0.0) |
| 93 | sigma_estimate_peak_distance_fraction: float = 0.125 | 112 | sigma_estimate_peak_distance_fraction: float = pydantic.Field(default=0.125, gt=0.0, le=1.0) |
| 94 | n_sigma_intensity_cutoff: float = 5.0 | 113 | n_sigma_intensity_cutoff: float = pydantic.Field(default=5.0, gt=0.0) |
| 95 | min_sigma: float = 0.5 | 114 | min_sigma: float = pydantic.Field(default=0.5, gt=0.0) |
| 115 | |||
| 116 | @pydantic.model_validator(mode="after") | ||
| 117 | def _check_intensity_range(self) -> "ClusterFinderGPUIntensityFilteringConfig": | ||
| 118 | """Reject a histogram range whose lower bound is not below its upper bound.""" | ||
| 119 | low, high = self.intensity_range | ||
| 120 | if low >= high: | ||
| 121 | raise ValueError("intensity_range must be [low, high] with low < high") | ||
| 122 | return self | ||
| 96 | 123 | ||
| 97 | 124 | ||
| 98 | class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel): | 125 | class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel): |
| 99 | """Geometry-filter section of the GPU clustering config.""" | 126 | """Geometry-filter section of the GPU clustering config.""" |
| 100 | 127 | ||
| 101 | enable_geometry_filtering: bool = False | 128 | enable_geometry_filtering: bool = False |
| 102 | min_points: int = 100 | 129 | min_points: int = pydantic.Field(default=100, ge=0) |
| 103 | min_length: float = 1.0 | 130 | min_length: float = pydantic.Field(default=1.0, ge=0.0) |
| 104 | max_width: float = 1.6 | 131 | max_width: float = pydantic.Field(default=1.6, gt=0.0) |
| 105 | max_height: float = 0.2 | 132 | max_height: float = pydantic.Field(default=0.2, gt=0.0) |
| 106 | 133 | ||
| 107 | 134 | ||
| 108 | class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel): | 135 | class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel): |
| 109 | """Input/output file-name section of the GPU clustering config.""" | 136 | """Input/output file-name section of the GPU clustering config.""" |
| 112 | cluster_prefix: str = "run6_cluster_" | 139 | cluster_prefix: str = "run6_cluster_" |
| 113 | 140 | ||
| 114 | 141 | ||
| 115 | class ClusterFinderGPUConfig(config_loader.ConfigModel): | 142 | class ClusterFinderGPUConfig(config_loader.ConfigModel): |
| 116 | """Root GPU Step 6 clustering config; field names match the packaged JSON.""" | 143 | """Root GPU clustering config; field names match the packaged JSON.""" |
| 117 | 144 | ||
| 118 | device: str = "CUDA:0" | 145 | device: str = "CUDA:0" |
| 119 | voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig() | 146 | voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig() |
| 120 | features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig() | 147 | features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig() |
| 152 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) | 179 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) |
| 153 | return data | 180 | return data |
| 154 | 181 | ||
| 155 | 182 | ||
| 156 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 183 | def _load_model( |
| 184 | *, | ||
| 185 | overrides: Mapping[str, Any] | None = None, | ||
| 186 | config_path: str | Path | None = None, | ||
| 187 | ) -> ClusterFinderGPUConfig: | ||
| 188 | """Load the packaged (or *config_path*) defaults with *overrides* deep-merged on top.""" | ||
| 189 | if overrides: | ||
| 190 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 191 | if config_path is not None: | ||
| 192 | logger.info("Config file applied: %s", config_path) | ||
| 193 | return config_loader.load_config( | ||
| 194 | ClusterFinderGPUConfig, | ||
| 195 | package=_PACKAGE_NAME, | ||
| 196 | filename=_DEFAULT_FILENAME, | ||
| 197 | overrides=dict(overrides) if overrides is not None else None, | ||
| 198 | config_path=config_path, | ||
| 199 | context=_CONTEXT, | ||
| 200 | error_cls=ClusterFinderGPUConfigError, | ||
| 201 | ) | ||
| 202 | |||
| 203 | |||
| 204 | def normalize_cluster_finder_gpu_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 157 | """Validate *raw_config* against the model tree and return a plain dict.""" | 205 | """Validate *raw_config* against the model tree and return a plain dict.""" |
| 158 | return config_loader.validate_config( | 206 | return config_loader.validate_config( |
| 159 | ClusterFinderGPUConfig, | 207 | ClusterFinderGPUConfig, |
| 160 | raw_config, | 208 | raw_config, |
| 161 | context=_CONTEXT, | 209 | context=_CONTEXT, |
| 162 | error_cls=ClusterFinderGPUConfigError, | 210 | error_cls=ClusterFinderGPUConfigError, |
| 163 | ).model_dump() | 211 | ).model_dump(mode="json") |
| 164 | 212 | ||
| 165 | 213 | ||
| 166 | def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]: | 214 | def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 167 | """Load packaged (or *config_path*) defaults, validate, and return a plain dict.""" | 215 | """Load packaged (or *config_path*) defaults, validate, and return a plain dict.""" |
| 168 | return config_loader.load_config( | 216 | return _load_model(config_path=config_path).model_dump(mode="json") |
| 169 | ClusterFinderGPUConfig, | ||
| 170 | package=_PACKAGE, | ||
| 171 | filename=_DEFAULT_FILENAME, | ||
| 172 | config_path=config_path, | ||
| 173 | context=_CONTEXT, | ||
| 174 | error_cls=ClusterFinderGPUConfigError, | ||
| 175 | ).model_dump() | ||
| 176 | 217 | ||
| 177 | 218 | ||
| 178 | def build_cluster_finder_gpu_config( | 219 | def build_cluster_finder_gpu_config( |
| 179 | *, | 220 | *, |
| 180 | overrides: dict[str, Any] | None = None, | 221 | overrides: Mapping[str, Any] | None = None, |
| 181 | config_path: str | Path | None = None, | 222 | config_path: str | Path | None = None, |
| 182 | ) -> dict[str, Any]: | 223 | ) -> dict[str, Any]: |
| 183 | """Load defaults, deep-merge *overrides*, validate, and return a plain dict.""" | 224 | """Load defaults, deep-merge *overrides*, validate, and return a plain dict.""" |
| 184 | return config_loader.load_config( | 225 | return _load_model(overrides=overrides, config_path=config_path).model_dump(mode="json") |
| 185 | ClusterFinderGPUConfig, | ||
| 186 | package=_PACKAGE, | ||
| 187 | filename=_DEFAULT_FILENAME, | ||
| 188 | overrides=overrides, | ||
| 189 | config_path=config_path, | ||
| 190 | context=_CONTEXT, | ||
| 191 | error_cls=ClusterFinderGPUConfigError, | ||
| 192 | ).model_dump() |
| 49 | 59 | ||
| 50 | enable_density_filtering: bool = True | 60 | enable_density_filtering: bool = True |
| 51 | enable_intensity_trimming: bool = False | 61 | enable_intensity_trimming: bool = False |
| 52 | enable_intensity_peak_split: bool = False | 62 | enable_intensity_peak_split: bool = False |
| 53 | dbscan_eps: float = 1.2 | 63 | dbscan_eps: float = pydantic.Field(default=1.2, gt=0.0) |
| 54 | dbscan_guard_threshold_points: int = 15000 | 64 | dbscan_guard_threshold_points: int = pydantic.Field(default=15000, ge=0) |
| 55 | min_cluster_size: int = 200 | 65 | min_cluster_size: int = pydantic.Field(default=200, ge=1) |
| 56 | dbscan_min_points: int = 50 | 66 | dbscan_min_points: int = pydantic.Field(default=50, ge=1) |
| 57 | include_noise_in_second_pass: bool = False | 67 | include_noise_in_second_pass: bool = False |
| 58 | skip_gpu_second_pass: bool = False | 68 | skip_gpu_second_pass: bool = False |
| 59 | max_mbytes_per_batch: int = 256 | 69 | max_mbytes_per_batch: int = pydantic.Field(default=256, ge=1) |
| 60 | calc_core_sample_indices: bool = False | 70 | calc_core_sample_indices: bool = False |
| 61 | safe_raw_points: int = 1000000 | 71 | safe_raw_points: int = pydantic.Field(default=1000000, ge=1) |
| 62 | safe_post_outlier_points: int = 900000 | 72 | safe_post_outlier_points: int = pydantic.Field(default=900000, ge=1) |
| 63 | safe_engine_rows: int = 400000 | 73 | safe_engine_rows: int = pydantic.Field(default=400000, ge=1) |
| 64 | 74 | ||
| 65 | @pydantic.model_validator(mode="before") | 75 | @pydantic.model_validator(mode="before") |
| 66 | @classmethod | 76 | @classmethod |
| 67 | def _map_min_samples_alias(cls, value: Any) -> Any: | 77 | def _map_min_samples_alias(cls, value: Any) -> Any: |
| 3 | from .cluster_finder import ClusterFinder | 3 | from .cluster_finder import ClusterFinder |
| 4 | from .cluster_metadata import ClusterInfo, get_cluster_info, load_npz_clusters_from_dir, planes_between | 4 | from .cluster_metadata import ClusterInfo, get_cluster_info, load_npz_clusters_from_dir, planes_between |
| 5 | from ._config import ( | 5 | from ._config import ( |
| 6 | ClusterFinderGPUConfigError, | 6 | ClusterFinderGPUConfigError, |
| 7 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 8 | build_cluster_finder_gpu_config, | 7 | build_cluster_finder_gpu_config, |
| 9 | load_cluster_finder_gpu_config, | 8 | load_cluster_finder_gpu_config, |
| 10 | normalize_cluster_finder_gpu_config, | 9 | normalize_cluster_finder_gpu_config, |
| 11 | ) | 10 | ) |
| 11 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME | ||
| 12 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu | 12 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu |
| 13 | from . import ( | 13 | from . import ( |
| 14 | cluster_finder, | 14 | cluster_finder, |
| 15 | cluster_metadata, | 15 | cluster_metadata, |
| 11 | from iolabs.logstash import get_props_logger | 11 | from iolabs.logstash import get_props_logger |
| 12 | from iolabs_geometry_geometry import geometry_tools | 12 | from iolabs_geometry_geometry import geometry_tools |
| 13 | 13 | ||
| 14 | from ._log_props import LOG_PROPS | 14 | from ._log_props import LOG_PROPS |
| 15 | from ._config import ( | 15 | from ._config import normalize_cluster_finder_gpu_config |
| 16 | GPU_CLUSTERING_ENGINE_NAME, | 16 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME |
| 17 | normalize_cluster_finder_gpu_config, | ||
| 18 | ) | ||
| 19 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu_for_segment | 17 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu_for_segment |
| 20 | 18 | ||
| 21 | 19 | ||
| 22 | class ClusterFinder: | 20 | class ClusterFinder: |
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | from iolabs.logstash import get_props_logger | 6 | from iolabs.logstash import get_props_logger |
| 7 | 7 | ||
| 8 | from ._log_props import LOG_PROPS | 8 | from ._log_props import LOG_PROPS |
| 9 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 10 | from .clustering_gpu_runtime import _release_gpu_memory | 9 | from .clustering_gpu_runtime import _release_gpu_memory |
| 11 | from .clustering_gpu_types import EngineLabelResult, PreparedSegmentReplay | 10 | from .clustering_gpu_types import ( |
| 11 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 12 | EngineLabelResult, | ||
| 13 | PreparedSegmentReplay, | ||
| 14 | ) | ||
| 12 | 15 | ||
| 13 | 16 | ||
| 14 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 17 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 15 | 18 |
| 6 | import numpy as np | 6 | import numpy as np |
| 7 | from iolabs.logstash import get_props_logger | 7 | from iolabs.logstash import get_props_logger |
| 8 | 8 | ||
| 9 | from ._log_props import LOG_PROPS | 9 | from ._log_props import LOG_PROPS |
| 10 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 11 | from .clustering_gpu_io import GPUClusterArtifact | 10 | from .clustering_gpu_io import GPUClusterArtifact |
| 12 | from .clustering_gpu_metrics import GPUClusterRunMetrics | 11 | from .clustering_gpu_metrics import GPUClusterRunMetrics |
| 13 | from .clustering_gpu_pipeline_stages import _run_prepared_segment_once | 12 | from .clustering_gpu_pipeline_stages import _run_prepared_segment_once |
| 14 | from .clustering_gpu_runtime import _log_gpu_stage_snapshot, _release_gpu_memory | 13 | from .clustering_gpu_runtime import _log_gpu_stage_snapshot, _release_gpu_memory |
| 17 | _is_gpu_memory_split_error, | 16 | _is_gpu_memory_split_error, |
| 18 | _planned_split_plane_count, | 17 | _planned_split_plane_count, |
| 19 | _subdivide_prepared_segment, | 18 | _subdivide_prepared_segment, |
| 20 | ) | 19 | ) |
| 21 | from .clustering_gpu_types import PreparedSegmentReplay | 20 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME, PreparedSegmentReplay |
| 22 | 21 | ||
| 23 | 22 | ||
| 24 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 23 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 25 | 24 |
| 8 | 8 | ||
| 9 | from iolabs.logstash import get_props_logger | 9 | from iolabs.logstash import get_props_logger |
| 10 | 10 | ||
| 11 | from ._log_props import LOG_PROPS | 11 | from ._log_props import LOG_PROPS |
| 12 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 13 | from .clustering_gpu_engine import _label_points_with_engine | 12 | from .clustering_gpu_engine import _label_points_with_engine |
| 14 | from .clustering_gpu_filters import ( | 13 | from .clustering_gpu_filters import ( |
| 15 | _apply_legacy_density_filter, | 14 | _apply_legacy_density_filter, |
| 16 | _apply_legacy_intensity_peak_split, | 15 | _apply_legacy_intensity_peak_split, |
| 28 | _count_cloud_points, | 27 | _count_cloud_points, |
| 29 | _log_gpu_stage_snapshot, | 28 | _log_gpu_stage_snapshot, |
| 30 | _safe_gpu_split_targets, | 29 | _safe_gpu_split_targets, |
| 31 | ) | 30 | ) |
| 32 | from .clustering_gpu_types import PreparedSegmentReplay | 31 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME, PreparedSegmentReplay |
| 33 | 32 | ||
| 34 | 33 | ||
| 35 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 34 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 36 | 35 |
| 5 | 5 | ||
| 6 | import numpy as np | 6 | import numpy as np |
| 7 | from iolabs_geometry_geometry import geometry_tools | 7 | from iolabs_geometry_geometry import geometry_tools |
| 8 | 8 | ||
| 9 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 10 | from .clustering_gpu_features import segment_frame_from_planes | 9 | from .clustering_gpu_features import segment_frame_from_planes |
| 11 | from .clustering_gpu_io import MergedBrightPoints, load_bright_points_npz | 10 | from .clustering_gpu_io import MergedBrightPoints, load_bright_points_npz |
| 12 | from .clustering_gpu_metrics import GPUClusterRunMetrics | 11 | from .clustering_gpu_metrics import GPUClusterRunMetrics |
| 13 | from .clustering_gpu_prepare import _prepare_replay_from_bright_points | 12 | from .clustering_gpu_prepare import _prepare_replay_from_bright_points |
| 14 | from .clustering_gpu_runtime import _safe_gpu_split_targets | 13 | from .clustering_gpu_runtime import _safe_gpu_split_targets |
| 15 | from .clustering_gpu_types import PreparedSegmentReplay, RawSegmentChunk | 14 | from .clustering_gpu_types import ( |
| 15 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 16 | PreparedSegmentReplay, | ||
| 17 | RawSegmentChunk, | ||
| 18 | ) | ||
| 16 | 19 | ||
| 17 | def _project_points_along_axis( | 20 | def _project_points_along_axis( |
| 18 | points: np.ndarray, | 21 | points: np.ndarray, |
| 19 | *, | 22 | *, |
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | 6 | ||
| 7 | from .clustering_gpu_io import MergedBrightPoints | 7 | from .clustering_gpu_io import MergedBrightPoints |
| 8 | 8 | ||
| 9 | GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan" | ||
| 10 | |||
| 9 | @dataclass | 11 | @dataclass |
| 10 | class PreparedSegmentReplay: | 12 | class PreparedSegmentReplay: |
| 11 | input_points_before_outlier_removal: int | 13 | input_points_before_outlier_removal: int |
| 12 | bright_points: MergedBrightPoints | 14 | bright_points: MergedBrightPoints |
| 1 | import json | ||
| 2 | |||
| 3 | import pytest | ||
| 4 | from iolabs.common import config_loader | ||
| 5 | from iolabs_point_cloud_filtering_clusters import _config | ||
| 6 | |||
| 7 | |||
| 8 | def test_load_cluster_finder_gpu_defaults() -> None: | ||
| 9 | config = _config.load_cluster_finder_gpu_config() | ||
| 10 | |||
| 11 | assert config["device"] == "CUDA:0" | ||
| 12 | assert config["voxelization"]["enabled"] is False | ||
| 13 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 14 | assert config["file_naming"]["cluster_prefix"] == "run6_cluster_" | ||
| 15 | |||
| 16 | |||
| 17 | def test_unknown_step6_gpu_key_is_rejected() -> None: | ||
| 18 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"): | ||
| 19 | _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1}) | ||
| 20 | |||
| 21 | |||
| 22 | def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None: | ||
| 23 | config = _config.build_cluster_finder_gpu_config( | ||
| 24 | overrides={"clustering": {"min_samples": 12}} | ||
| 25 | ) | ||
| 26 | |||
| 27 | assert config["clustering"]["dbscan_min_points"] == 12 | ||
| 28 | assert "min_samples" not in config["clustering"] | ||
| 29 | |||
| 30 | |||
| 31 | def 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 | |||
| 36 | def 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 | |||
| 46 | def 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 | |||
| 53 | def 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 | |||
| 58 | def 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 | ||
| 67 | |||
| 68 | |||
| 69 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 70 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 71 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 72 | {"voxelization": None, "clustering": None} | ||
| 73 | ) | ||
| 74 | |||
| 75 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 76 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 77 | |||
| 78 | |||
| 79 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 80 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 81 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) | ||
| 0 |
| 1 | import json | ||
| 2 | |||
| 3 | import pytest | ||
| 4 | from iolabs.common import config_loader | ||
| 5 | from iolabs_point_cloud_filtering_clusters import _config | ||
| 6 | |||
| 7 | |||
| 8 | def _packaged_defaults() -> dict: | ||
| 9 | path = config_loader.default_config_path( | ||
| 10 | "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json" | ||
| 11 | ) | ||
| 12 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 13 | |||
| 14 | |||
| 15 | def test_model_defaults_match_packaged_json() -> None: | ||
| 16 | """Model defaults must mirror the packaged JSON, so a partial config file agrees.""" | ||
| 17 | assert _config.ClusterFinderGPUConfig().model_dump(mode="json") == _packaged_defaults() | ||
| 18 | |||
| 19 | |||
| 20 | def test_load_cluster_finder_gpu_config_returns_packaged_defaults() -> None: | ||
| 21 | config = _config.load_cluster_finder_gpu_config() | ||
| 22 | |||
| 23 | assert isinstance(config, dict) | ||
| 24 | assert config == _packaged_defaults() | ||
| 25 | assert config["device"] == "CUDA:0" | ||
| 26 | assert config["voxelization"]["enabled"] is False | ||
| 27 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 28 | assert config["file_naming"]["cluster_prefix"] == "run6_cluster_" | ||
| 29 | |||
| 30 | |||
| 31 | def test_error_class_is_config_error() -> None: | ||
| 32 | assert issubclass(_config.ClusterFinderGPUConfigError, config_loader.ConfigError) | ||
| 33 | assert issubclass(_config.ClusterFinderGPUConfigError, ValueError) | ||
| 34 | |||
| 35 | |||
| 36 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 37 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"): | ||
| 38 | _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1}) | ||
| 39 | |||
| 40 | |||
| 41 | def test_unknown_nested_key_is_rejected() -> None: | ||
| 42 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="postprocess"): | ||
| 43 | _config.build_cluster_finder_gpu_config(overrides={"postprocess": {"bogus": 1}}) | ||
| 44 | |||
| 45 | |||
| 46 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 47 | config = _config.build_cluster_finder_gpu_config( | ||
| 48 | overrides={"clustering": {"dbscan_eps": 2.5}} | ||
| 49 | ) | ||
| 50 | |||
| 51 | assert config["clustering"]["dbscan_eps"] == 2.5 | ||
| 52 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 53 | assert config["file_naming"] == _packaged_defaults()["file_naming"] | ||
| 54 | |||
| 55 | |||
| 56 | def test_set_override_coercion_and_rejection() -> None: | ||
| 57 | overrides = config_loader.parse_set_overrides( | ||
| 58 | ["clustering.safe_raw_points=1e3", "clustering.skip_gpu_second_pass=on"], | ||
| 59 | nested=True, | ||
| 60 | error_cls=_config.ClusterFinderGPUConfigError, | ||
| 61 | ) | ||
| 62 | config = _config.build_cluster_finder_gpu_config(overrides=overrides) | ||
| 63 | |||
| 64 | assert config["clustering"]["safe_raw_points"] == 1000 | ||
| 65 | assert config["clustering"]["skip_gpu_second_pass"] is True | ||
| 66 | |||
| 67 | bad = config_loader.parse_set_overrides( | ||
| 68 | ["clustering.skip_gpu_second_pass=flase"], | ||
| 69 | nested=True, | ||
| 70 | error_cls=_config.ClusterFinderGPUConfigError, | ||
| 71 | ) | ||
| 72 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="skip_gpu_second_pass"): | ||
| 73 | _config.build_cluster_finder_gpu_config(overrides=bad) | ||
| 74 | |||
| 75 | |||
| 76 | def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None: | ||
| 77 | config = _config.build_cluster_finder_gpu_config( | ||
| 78 | overrides={"clustering": {"min_samples": 12}} | ||
| 79 | ) | ||
| 80 | |||
| 81 | assert config["clustering"]["dbscan_min_points"] == 12 | ||
| 82 | assert "min_samples" not in config["clustering"] | ||
| 83 | |||
| 84 | |||
| 85 | def test_bad_value_type_is_rejected() -> None: | ||
| 86 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"): | ||
| 87 | _config.build_cluster_finder_gpu_config( | ||
| 88 | overrides={"clustering": {"dbscan_eps": "not-a-number"}} | ||
| 89 | ) | ||
| 90 | |||
| 91 | |||
| 92 | def test_out_of_range_value_is_rejected() -> None: | ||
| 93 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"): | ||
| 94 | _config.build_cluster_finder_gpu_config(overrides={"clustering": {"dbscan_eps": 0.0}}) | ||
| 95 | |||
| 96 | |||
| 97 | def test_mismatched_density_cutoffs_are_rejected() -> None: | ||
| 98 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="cutoff_portions"): | ||
| 99 | _config.build_cluster_finder_gpu_config( | ||
| 100 | overrides={"cluster_density_filtering": {"cutoff_portions": [0.5]}} | ||
| 101 | ) | ||
| 102 | |||
| 103 | |||
| 104 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 105 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 106 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 107 | {"voxelization": None, "clustering": None} | ||
| 108 | ) | ||
| 109 | |||
| 110 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 111 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 112 | |||
| 113 | |||
| 114 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 115 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 116 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) | ||
| 117 | |||
| 118 | |||
| 119 | def test_config_path_replaces_packaged_defaults(tmp_path) -> None: | ||
| 120 | path = tmp_path / "custom.json" | ||
| 121 | path.write_text(json.dumps({"device": "CPU:0"}), encoding="utf-8") | ||
| 122 | |||
| 123 | config = _config.load_cluster_finder_gpu_config(path) | ||
| 124 | |||
| 125 | assert config["device"] == "CPU:0" | ||
| 126 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 0 |
| 31 | Runs 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. | 31 | Runs 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. |
| 32 | 32 | ||
| 33 | ## Configuration | 33 | ## Configuration |
| 34 | 34 | ||
| 35 | GPU 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). | 35 | Defaults live in `src/iolabs_point_cloud_filtering_clusters/clustering_gpu.default.json`. The schema is `ClusterFinderGPUConfig` in `_config.py` (a `config_loader.ConfigModel`); nested JSON sections are nested models and unknown keys are rejected. **To add a config key: add the field (with its type, default and any `Field` range) to the model and the same key with the same default to the JSON — nothing else.** `load_cluster_finder_gpu_config`, `build_cluster_finder_gpu_config` and `normalize_cluster_finder_gpu_config` return a plain `dict`. Runtime overrides come from repeatable `--set KEY=VALUE`, never repo-local JSON. |
| 36 | 36 | ||
| 37 | To 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. | 37 | To add a whole section: declare a new `ClusterFinderGPU<Section>Config` model, add it as a field on `ClusterFinderGPUConfig` with a default instance, and mirror the section in the packaged JSON. A section given as `null` falls back to that section's defaults; a section of any non-mapping type is rejected. |
| 38 | |||
| 39 | To add a whole section: declare a new `config_loader.ConfigModel` subclass, add it as a field on `ClusterFinderGPUConfig` with a default instance, and mirror the section in the packaged JSON. A section given as `null` falls back to that section's defaults; a section of any non-mapping type is rejected. | ||
| 40 | 38 | ||
| 41 | ## Develop locally (Nexus) | 39 | ## Develop locally (Nexus) |
| 42 | 40 | ||
| 43 | Internal `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: | 41 | Internal `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: |
| 3 | from .cluster_finder import ClusterFinder | 3 | from .cluster_finder import ClusterFinder |
| 4 | from .cluster_metadata import ClusterInfo, get_cluster_info, load_npz_clusters_from_dir, planes_between | 4 | from .cluster_metadata import ClusterInfo, get_cluster_info, load_npz_clusters_from_dir, planes_between |
| 5 | from ._config import ( | 5 | from ._config import ( |
| 6 | ClusterFinderGPUConfigError, | 6 | ClusterFinderGPUConfigError, |
| 7 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 8 | build_cluster_finder_gpu_config, | 7 | build_cluster_finder_gpu_config, |
| 9 | load_cluster_finder_gpu_config, | 8 | load_cluster_finder_gpu_config, |
| 10 | normalize_cluster_finder_gpu_config, | 9 | normalize_cluster_finder_gpu_config, |
| 11 | ) | 10 | ) |
| 11 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME | ||
| 12 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu | 12 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu |
| 13 | from . import ( | 13 | from . import ( |
| 14 | cluster_finder, | 14 | cluster_finder, |
| 15 | cluster_metadata, | 15 | cluster_metadata, |
| 1 | """Packaged GPU Step 6 clustering config: pydantic model tree plus load helpers.""" | 1 | """Packaged GPU clustering config: pydantic model tree plus load helpers. |
| 2 | |||
| 3 | The schema is `ClusterFinderGPUConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `clustering_gpu.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `clustering_gpu.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return plain JSON-typed dicts (`dict[str, Any]`), because callers | ||
| 10 | pass `--set`-style overrides around as dicts and embed the normalized mapping in | ||
| 11 | run manifests. | ||
| 12 | """ | ||
| 2 | 13 | ||
| 3 | from __future__ import annotations | 14 | from __future__ import annotations |
| 4 | 15 | ||
| 5 | import logging | 16 | import logging |
| 17 | from collections.abc import Mapping | ||
| 6 | from pathlib import Path | 18 | from pathlib import Path |
| 7 | from typing import Any | 19 | from typing import Any |
| 8 | 20 | ||
| 9 | import pydantic | 21 | import pydantic |
| 10 | from iolabs.common import config_loader | 22 | from iolabs.common import config_loader |
| 11 | 23 | ||
| 12 | logger = logging.getLogger(__name__) | 24 | logger = logging.getLogger(__name__) |
| 13 | 25 | ||
| 14 | GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan" | 26 | _PACKAGE_NAME = "iolabs_point_cloud_filtering_clusters" |
| 15 | |||
| 16 | _PACKAGE = "iolabs_point_cloud_filtering_clusters" | ||
| 17 | _DEFAULT_FILENAME = "clustering_gpu.default.json" | 27 | _DEFAULT_FILENAME = "clustering_gpu.default.json" |
| 18 | _CONTEXT = "step6 gpu config" | 28 | _CONTEXT = "clustering GPU config" |
| 19 | 29 | ||
| 20 | 30 | ||
| 21 | class ClusterFinderGPUConfigError(config_loader.ConfigError): | 31 | class ClusterFinderGPUConfigError(config_loader.ConfigError): |
| 22 | """Raised when Step 6 GPU config contains unsupported keys or values.""" | 32 | """Raised when clustering GPU config contains unsupported keys or values.""" |
| 23 | 33 | ||
| 24 | 34 | ||
| 25 | class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel): | 35 | class ClusterFinderGPUInitialOutlierRemovalConfig(config_loader.ConfigModel): |
| 26 | """Statistical outlier-removal section of the GPU clustering config.""" | 36 | """Statistical outlier-removal section of the GPU clustering config.""" |
| 27 | 37 | ||
| 28 | nb_neighbors: int = 20 | 38 | nb_neighbors: int = pydantic.Field(default=20, ge=1) |
| 29 | std_ratio: float = 2.75 | 39 | std_ratio: float = pydantic.Field(default=2.75, gt=0.0) |
| 30 | 40 | ||
| 31 | 41 | ||
| 32 | class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel): | 42 | class ClusterFinderGPUVoxelizationConfig(config_loader.ConfigModel): |
| 33 | """Voxel-downsample section of the GPU clustering config.""" | 43 | """Voxel-downsample section of the GPU clustering config.""" |
| 34 | 44 | ||
| 35 | enabled: bool = False | 45 | enabled: bool = False |
| 36 | voxel_size: float = 0.03 | 46 | voxel_size: float = pydantic.Field(default=0.03, gt=0.0) |
| 37 | 47 | ||
| 38 | 48 | ||
| 39 | class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel): | 49 | class ClusterFinderGPUFeaturesConfig(config_loader.ConfigModel): |
| 40 | """Feature-vector section of the GPU clustering config.""" | 50 | """Feature-vector section of the GPU clustering config.""" |
| 49 | 59 | ||
| 50 | enable_density_filtering: bool = True | 60 | enable_density_filtering: bool = True |
| 51 | enable_intensity_trimming: bool = False | 61 | enable_intensity_trimming: bool = False |
| 52 | enable_intensity_peak_split: bool = False | 62 | enable_intensity_peak_split: bool = False |
| 53 | dbscan_eps: float = 1.2 | 63 | dbscan_eps: float = pydantic.Field(default=1.2, gt=0.0) |
| 54 | dbscan_guard_threshold_points: int = 15000 | 64 | dbscan_guard_threshold_points: int = pydantic.Field(default=15000, ge=0) |
| 55 | min_cluster_size: int = 200 | 65 | min_cluster_size: int = pydantic.Field(default=200, ge=1) |
| 56 | dbscan_min_points: int = 50 | 66 | dbscan_min_points: int = pydantic.Field(default=50, ge=1) |
| 57 | include_noise_in_second_pass: bool = False | 67 | include_noise_in_second_pass: bool = False |
| 58 | skip_gpu_second_pass: bool = False | 68 | skip_gpu_second_pass: bool = False |
| 59 | max_mbytes_per_batch: int = 256 | 69 | max_mbytes_per_batch: int = pydantic.Field(default=256, ge=1) |
| 60 | calc_core_sample_indices: bool = False | 70 | calc_core_sample_indices: bool = False |
| 61 | safe_raw_points: int = 1000000 | 71 | safe_raw_points: int = pydantic.Field(default=1000000, ge=1) |
| 62 | safe_post_outlier_points: int = 900000 | 72 | safe_post_outlier_points: int = pydantic.Field(default=900000, ge=1) |
| 63 | safe_engine_rows: int = 400000 | 73 | safe_engine_rows: int = pydantic.Field(default=400000, ge=1) |
| 64 | 74 | ||
| 65 | @pydantic.model_validator(mode="before") | 75 | @pydantic.model_validator(mode="before") |
| 66 | @classmethod | 76 | @classmethod |
| 67 | def _map_min_samples_alias(cls, value: Any) -> Any: | 77 | def _map_min_samples_alias(cls, value: Any) -> Any: |
| 77 | 87 | ||
| 78 | class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel): | 88 | class ClusterFinderGPUDensityFilteringConfig(config_loader.ConfigModel): |
| 79 | """Per-cluster density cutoff section of the GPU clustering config.""" | 89 | """Per-cluster density cutoff section of the GPU clustering config.""" |
| 80 | 90 | ||
| 81 | density_cutoffs: list[float] = [0.0008, 0.0015, 0.003, 0.006] | 91 | density_cutoffs: tuple[float, ...] = (0.0008, 0.0015, 0.003, 0.006) |
| 82 | cutoff_portions: list[float] = [0.8, 0.8, 0.2, 0.02] | 92 | cutoff_portions: tuple[float, ...] = (0.8, 0.8, 0.2, 0.02) |
| 93 | |||
| 94 | @pydantic.model_validator(mode="after") | ||
| 95 | def _check_cutoffs(self) -> "ClusterFinderGPUDensityFilteringConfig": | ||
| 96 | """Reject mismatched cutoff lists or portions outside ``(0, 1]``.""" | ||
| 97 | if len(self.density_cutoffs) != len(self.cutoff_portions): | ||
| 98 | raise ValueError("density_cutoffs and cutoff_portions must have the same length") | ||
| 99 | if any(portion <= 0.0 or portion > 1.0 for portion in self.cutoff_portions): | ||
| 100 | raise ValueError("every cutoff_portions entry must satisfy 0 < portion <= 1") | ||
| 101 | return self | ||
| 83 | 102 | ||
| 84 | 103 | ||
| 85 | class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel): | 104 | class ClusterFinderGPUIntensityFilteringConfig(config_loader.ConfigModel): |
| 86 | """Per-cluster intensity histogram section of the GPU clustering config.""" | 105 | """Per-cluster intensity histogram section of the GPU clustering config.""" |
| 87 | 106 | ||
| 88 | n_bins: int = 50 | 107 | n_bins: int = pydantic.Field(default=50, ge=1) |
| 89 | intensity_range: list[int] = [0, 255] | 108 | intensity_range: tuple[int, int] = (0, 255) |
| 90 | peak_distance: int = 5 | 109 | peak_distance: int = pydantic.Field(default=5, ge=1) |
| 91 | peak_prominence: int = 10 | 110 | peak_prominence: int = pydantic.Field(default=10, ge=0) |
| 92 | sigma_estimate: float = 3.0 | 111 | sigma_estimate: float = pydantic.Field(default=3.0, gt=0.0) |
| 93 | sigma_estimate_peak_distance_fraction: float = 0.125 | 112 | sigma_estimate_peak_distance_fraction: float = pydantic.Field(default=0.125, gt=0.0, le=1.0) |
| 94 | n_sigma_intensity_cutoff: float = 5.0 | 113 | n_sigma_intensity_cutoff: float = pydantic.Field(default=5.0, gt=0.0) |
| 95 | min_sigma: float = 0.5 | 114 | min_sigma: float = pydantic.Field(default=0.5, gt=0.0) |
| 115 | |||
| 116 | @pydantic.model_validator(mode="after") | ||
| 117 | def _check_intensity_range(self) -> "ClusterFinderGPUIntensityFilteringConfig": | ||
| 118 | """Reject a histogram range whose lower bound is not below its upper bound.""" | ||
| 119 | low, high = self.intensity_range | ||
| 120 | if low >= high: | ||
| 121 | raise ValueError("intensity_range must be [low, high] with low < high") | ||
| 122 | return self | ||
| 96 | 123 | ||
| 97 | 124 | ||
| 98 | class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel): | 125 | class ClusterFinderGPUPostprocessConfig(config_loader.ConfigModel): |
| 99 | """Geometry-filter section of the GPU clustering config.""" | 126 | """Geometry-filter section of the GPU clustering config.""" |
| 100 | 127 | ||
| 101 | enable_geometry_filtering: bool = False | 128 | enable_geometry_filtering: bool = False |
| 102 | min_points: int = 100 | 129 | min_points: int = pydantic.Field(default=100, ge=0) |
| 103 | min_length: float = 1.0 | 130 | min_length: float = pydantic.Field(default=1.0, ge=0.0) |
| 104 | max_width: float = 1.6 | 131 | max_width: float = pydantic.Field(default=1.6, gt=0.0) |
| 105 | max_height: float = 0.2 | 132 | max_height: float = pydantic.Field(default=0.2, gt=0.0) |
| 106 | 133 | ||
| 107 | 134 | ||
| 108 | class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel): | 135 | class ClusterFinderGPUFileNamingConfig(config_loader.ConfigModel): |
| 109 | """Input/output file-name section of the GPU clustering config.""" | 136 | """Input/output file-name section of the GPU clustering config.""" |
| 112 | cluster_prefix: str = "run6_cluster_" | 139 | cluster_prefix: str = "run6_cluster_" |
| 113 | 140 | ||
| 114 | 141 | ||
| 115 | class ClusterFinderGPUConfig(config_loader.ConfigModel): | 142 | class ClusterFinderGPUConfig(config_loader.ConfigModel): |
| 116 | """Root GPU Step 6 clustering config; field names match the packaged JSON.""" | 143 | """Root GPU clustering config; field names match the packaged JSON.""" |
| 117 | 144 | ||
| 118 | device: str = "CUDA:0" | 145 | device: str = "CUDA:0" |
| 119 | voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig() | 146 | voxelization: ClusterFinderGPUVoxelizationConfig = ClusterFinderGPUVoxelizationConfig() |
| 120 | features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig() | 147 | features: ClusterFinderGPUFeaturesConfig = ClusterFinderGPUFeaturesConfig() |
| 152 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) | 179 | logger.debug("Using defaults for null %s section(s): %s", _CONTEXT, null_sections) |
| 153 | return data | 180 | return data |
| 154 | 181 | ||
| 155 | 182 | ||
| 156 | def normalize_cluster_finder_gpu_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 183 | def _load_model( |
| 184 | *, | ||
| 185 | overrides: Mapping[str, Any] | None = None, | ||
| 186 | config_path: str | Path | None = None, | ||
| 187 | ) -> ClusterFinderGPUConfig: | ||
| 188 | """Load the packaged (or *config_path*) defaults with *overrides* deep-merged on top.""" | ||
| 189 | if overrides: | ||
| 190 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 191 | if config_path is not None: | ||
| 192 | logger.info("Config file applied: %s", config_path) | ||
| 193 | return config_loader.load_config( | ||
| 194 | ClusterFinderGPUConfig, | ||
| 195 | package=_PACKAGE_NAME, | ||
| 196 | filename=_DEFAULT_FILENAME, | ||
| 197 | overrides=dict(overrides) if overrides is not None else None, | ||
| 198 | config_path=config_path, | ||
| 199 | context=_CONTEXT, | ||
| 200 | error_cls=ClusterFinderGPUConfigError, | ||
| 201 | ) | ||
| 202 | |||
| 203 | |||
| 204 | def normalize_cluster_finder_gpu_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 157 | """Validate *raw_config* against the model tree and return a plain dict.""" | 205 | """Validate *raw_config* against the model tree and return a plain dict.""" |
| 158 | return config_loader.validate_config( | 206 | return config_loader.validate_config( |
| 159 | ClusterFinderGPUConfig, | 207 | ClusterFinderGPUConfig, |
| 160 | raw_config, | 208 | raw_config, |
| 161 | context=_CONTEXT, | 209 | context=_CONTEXT, |
| 162 | error_cls=ClusterFinderGPUConfigError, | 210 | error_cls=ClusterFinderGPUConfigError, |
| 163 | ).model_dump() | 211 | ).model_dump(mode="json") |
| 164 | 212 | ||
| 165 | 213 | ||
| 166 | def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]: | 214 | def load_cluster_finder_gpu_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 167 | """Load packaged (or *config_path*) defaults, validate, and return a plain dict.""" | 215 | """Load packaged (or *config_path*) defaults, validate, and return a plain dict.""" |
| 168 | return config_loader.load_config( | 216 | return _load_model(config_path=config_path).model_dump(mode="json") |
| 169 | ClusterFinderGPUConfig, | ||
| 170 | package=_PACKAGE, | ||
| 171 | filename=_DEFAULT_FILENAME, | ||
| 172 | config_path=config_path, | ||
| 173 | context=_CONTEXT, | ||
| 174 | error_cls=ClusterFinderGPUConfigError, | ||
| 175 | ).model_dump() | ||
| 176 | 217 | ||
| 177 | 218 | ||
| 178 | def build_cluster_finder_gpu_config( | 219 | def build_cluster_finder_gpu_config( |
| 179 | *, | 220 | *, |
| 180 | overrides: dict[str, Any] | None = None, | 221 | overrides: Mapping[str, Any] | None = None, |
| 181 | config_path: str | Path | None = None, | 222 | config_path: str | Path | None = None, |
| 182 | ) -> dict[str, Any]: | 223 | ) -> dict[str, Any]: |
| 183 | """Load defaults, deep-merge *overrides*, validate, and return a plain dict.""" | 224 | """Load defaults, deep-merge *overrides*, validate, and return a plain dict.""" |
| 184 | return config_loader.load_config( | 225 | return _load_model(overrides=overrides, config_path=config_path).model_dump(mode="json") |
| 185 | ClusterFinderGPUConfig, | ||
| 186 | package=_PACKAGE, | ||
| 187 | filename=_DEFAULT_FILENAME, | ||
| 188 | overrides=overrides, | ||
| 189 | config_path=config_path, | ||
| 190 | context=_CONTEXT, | ||
| 191 | error_cls=ClusterFinderGPUConfigError, | ||
| 192 | ).model_dump() |
| 11 | from iolabs.logstash import get_props_logger | 11 | from iolabs.logstash import get_props_logger |
| 12 | from iolabs_geometry_geometry import geometry_tools | 12 | from iolabs_geometry_geometry import geometry_tools |
| 13 | 13 | ||
| 14 | from ._log_props import LOG_PROPS | 14 | from ._log_props import LOG_PROPS |
| 15 | from ._config import ( | 15 | from ._config import normalize_cluster_finder_gpu_config |
| 16 | GPU_CLUSTERING_ENGINE_NAME, | 16 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME |
| 17 | normalize_cluster_finder_gpu_config, | ||
| 18 | ) | ||
| 19 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu_for_segment | 17 | from .clustering_gpu_pipeline_entrypoints import find_clusters_gpu_for_segment |
| 20 | 18 | ||
| 21 | 19 | ||
| 22 | class ClusterFinder: | 20 | class ClusterFinder: |
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | from iolabs.logstash import get_props_logger | 6 | from iolabs.logstash import get_props_logger |
| 7 | 7 | ||
| 8 | from ._log_props import LOG_PROPS | 8 | from ._log_props import LOG_PROPS |
| 9 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 10 | from .clustering_gpu_runtime import _release_gpu_memory | 9 | from .clustering_gpu_runtime import _release_gpu_memory |
| 11 | from .clustering_gpu_types import EngineLabelResult, PreparedSegmentReplay | 10 | from .clustering_gpu_types import ( |
| 11 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 12 | EngineLabelResult, | ||
| 13 | PreparedSegmentReplay, | ||
| 14 | ) | ||
| 12 | 15 | ||
| 13 | 16 | ||
| 14 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 17 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 15 | 18 |
| 6 | import numpy as np | 6 | import numpy as np |
| 7 | from iolabs.logstash import get_props_logger | 7 | from iolabs.logstash import get_props_logger |
| 8 | 8 | ||
| 9 | from ._log_props import LOG_PROPS | 9 | from ._log_props import LOG_PROPS |
| 10 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 11 | from .clustering_gpu_io import GPUClusterArtifact | 10 | from .clustering_gpu_io import GPUClusterArtifact |
| 12 | from .clustering_gpu_metrics import GPUClusterRunMetrics | 11 | from .clustering_gpu_metrics import GPUClusterRunMetrics |
| 13 | from .clustering_gpu_pipeline_stages import _run_prepared_segment_once | 12 | from .clustering_gpu_pipeline_stages import _run_prepared_segment_once |
| 14 | from .clustering_gpu_runtime import _log_gpu_stage_snapshot, _release_gpu_memory | 13 | from .clustering_gpu_runtime import _log_gpu_stage_snapshot, _release_gpu_memory |
| 17 | _is_gpu_memory_split_error, | 16 | _is_gpu_memory_split_error, |
| 18 | _planned_split_plane_count, | 17 | _planned_split_plane_count, |
| 19 | _subdivide_prepared_segment, | 18 | _subdivide_prepared_segment, |
| 20 | ) | 19 | ) |
| 21 | from .clustering_gpu_types import PreparedSegmentReplay | 20 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME, PreparedSegmentReplay |
| 22 | 21 | ||
| 23 | 22 | ||
| 24 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 23 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 25 | 24 |
| 8 | 8 | ||
| 9 | from iolabs.logstash import get_props_logger | 9 | from iolabs.logstash import get_props_logger |
| 10 | 10 | ||
| 11 | from ._log_props import LOG_PROPS | 11 | from ._log_props import LOG_PROPS |
| 12 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 13 | from .clustering_gpu_engine import _label_points_with_engine | 12 | from .clustering_gpu_engine import _label_points_with_engine |
| 14 | from .clustering_gpu_filters import ( | 13 | from .clustering_gpu_filters import ( |
| 15 | _apply_legacy_density_filter, | 14 | _apply_legacy_density_filter, |
| 16 | _apply_legacy_intensity_peak_split, | 15 | _apply_legacy_intensity_peak_split, |
| 28 | _count_cloud_points, | 27 | _count_cloud_points, |
| 29 | _log_gpu_stage_snapshot, | 28 | _log_gpu_stage_snapshot, |
| 30 | _safe_gpu_split_targets, | 29 | _safe_gpu_split_targets, |
| 31 | ) | 30 | ) |
| 32 | from .clustering_gpu_types import PreparedSegmentReplay | 31 | from .clustering_gpu_types import GPU_CLUSTERING_ENGINE_NAME, PreparedSegmentReplay |
| 33 | 32 | ||
| 34 | 33 | ||
| 35 | LOGGER = get_props_logger(__name__, LOG_PROPS) | 34 | LOGGER = get_props_logger(__name__, LOG_PROPS) |
| 36 | 35 |
| 5 | 5 | ||
| 6 | import numpy as np | 6 | import numpy as np |
| 7 | from iolabs_geometry_geometry import geometry_tools | 7 | from iolabs_geometry_geometry import geometry_tools |
| 8 | 8 | ||
| 9 | from ._config import GPU_CLUSTERING_ENGINE_NAME | ||
| 10 | from .clustering_gpu_features import segment_frame_from_planes | 9 | from .clustering_gpu_features import segment_frame_from_planes |
| 11 | from .clustering_gpu_io import MergedBrightPoints, load_bright_points_npz | 10 | from .clustering_gpu_io import MergedBrightPoints, load_bright_points_npz |
| 12 | from .clustering_gpu_metrics import GPUClusterRunMetrics | 11 | from .clustering_gpu_metrics import GPUClusterRunMetrics |
| 13 | from .clustering_gpu_prepare import _prepare_replay_from_bright_points | 12 | from .clustering_gpu_prepare import _prepare_replay_from_bright_points |
| 14 | from .clustering_gpu_runtime import _safe_gpu_split_targets | 13 | from .clustering_gpu_runtime import _safe_gpu_split_targets |
| 15 | from .clustering_gpu_types import PreparedSegmentReplay, RawSegmentChunk | 14 | from .clustering_gpu_types import ( |
| 15 | GPU_CLUSTERING_ENGINE_NAME, | ||
| 16 | PreparedSegmentReplay, | ||
| 17 | RawSegmentChunk, | ||
| 18 | ) | ||
| 16 | 19 | ||
| 17 | def _project_points_along_axis( | 20 | def _project_points_along_axis( |
| 18 | points: np.ndarray, | 21 | points: np.ndarray, |
| 19 | *, | 22 | *, |
| 5 | import numpy as np | 5 | import numpy as np |
| 6 | 6 | ||
| 7 | from .clustering_gpu_io import MergedBrightPoints | 7 | from .clustering_gpu_io import MergedBrightPoints |
| 8 | 8 | ||
| 9 | GPU_CLUSTERING_ENGINE_NAME = "cuml_dbscan" | ||
| 10 | |||
| 9 | @dataclass | 11 | @dataclass |
| 10 | class PreparedSegmentReplay: | 12 | class PreparedSegmentReplay: |
| 11 | input_points_before_outlier_removal: int | 13 | input_points_before_outlier_removal: int |
| 12 | bright_points: MergedBrightPoints | 14 | bright_points: MergedBrightPoints |
| 1 | import json | ||
| 2 | |||
| 3 | import pytest | ||
| 4 | from iolabs.common import config_loader | ||
| 5 | from iolabs_point_cloud_filtering_clusters import _config | ||
| 6 | |||
| 7 | |||
| 8 | def test_load_cluster_finder_gpu_defaults() -> None: | ||
| 9 | config = _config.load_cluster_finder_gpu_config() | ||
| 10 | |||
| 11 | assert config["device"] == "CUDA:0" | ||
| 12 | assert config["voxelization"]["enabled"] is False | ||
| 13 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 14 | assert config["file_naming"]["cluster_prefix"] == "run6_cluster_" | ||
| 15 | |||
| 16 | |||
| 17 | def test_unknown_step6_gpu_key_is_rejected() -> None: | ||
| 18 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"): | ||
| 19 | _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1}) | ||
| 20 | |||
| 21 | |||
| 22 | def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None: | ||
| 23 | config = _config.build_cluster_finder_gpu_config( | ||
| 24 | overrides={"clustering": {"min_samples": 12}} | ||
| 25 | ) | ||
| 26 | |||
| 27 | assert config["clustering"]["dbscan_min_points"] == 12 | ||
| 28 | assert "min_samples" not in config["clustering"] | ||
| 29 | |||
| 30 | |||
| 31 | def 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 | |||
| 36 | def 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 | |||
| 46 | def 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 | |||
| 53 | def 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 | |||
| 58 | def 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 | ||
| 67 | |||
| 68 | |||
| 69 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 70 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 71 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 72 | {"voxelization": None, "clustering": None} | ||
| 73 | ) | ||
| 74 | |||
| 75 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 76 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 77 | |||
| 78 | |||
| 79 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 80 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 81 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) | ||
| 0 |
| 1 | import json | ||
| 2 | |||
| 3 | import pytest | ||
| 4 | from iolabs.common import config_loader | ||
| 5 | from iolabs_point_cloud_filtering_clusters import _config | ||
| 6 | |||
| 7 | |||
| 8 | def _packaged_defaults() -> dict: | ||
| 9 | path = config_loader.default_config_path( | ||
| 10 | "iolabs_point_cloud_filtering_clusters", "clustering_gpu.default.json" | ||
| 11 | ) | ||
| 12 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 13 | |||
| 14 | |||
| 15 | def test_model_defaults_match_packaged_json() -> None: | ||
| 16 | """Model defaults must mirror the packaged JSON, so a partial config file agrees.""" | ||
| 17 | assert _config.ClusterFinderGPUConfig().model_dump(mode="json") == _packaged_defaults() | ||
| 18 | |||
| 19 | |||
| 20 | def test_load_cluster_finder_gpu_config_returns_packaged_defaults() -> None: | ||
| 21 | config = _config.load_cluster_finder_gpu_config() | ||
| 22 | |||
| 23 | assert isinstance(config, dict) | ||
| 24 | assert config == _packaged_defaults() | ||
| 25 | assert config["device"] == "CUDA:0" | ||
| 26 | assert config["voxelization"]["enabled"] is False | ||
| 27 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 28 | assert config["file_naming"]["cluster_prefix"] == "run6_cluster_" | ||
| 29 | |||
| 30 | |||
| 31 | def test_error_class_is_config_error() -> None: | ||
| 32 | assert issubclass(_config.ClusterFinderGPUConfigError, config_loader.ConfigError) | ||
| 33 | assert issubclass(_config.ClusterFinderGPUConfigError, ValueError) | ||
| 34 | |||
| 35 | |||
| 36 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 37 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="random_seed"): | ||
| 38 | _config.build_cluster_finder_gpu_config(overrides={"random_seed": 1}) | ||
| 39 | |||
| 40 | |||
| 41 | def test_unknown_nested_key_is_rejected() -> None: | ||
| 42 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="postprocess"): | ||
| 43 | _config.build_cluster_finder_gpu_config(overrides={"postprocess": {"bogus": 1}}) | ||
| 44 | |||
| 45 | |||
| 46 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 47 | config = _config.build_cluster_finder_gpu_config( | ||
| 48 | overrides={"clustering": {"dbscan_eps": 2.5}} | ||
| 49 | ) | ||
| 50 | |||
| 51 | assert config["clustering"]["dbscan_eps"] == 2.5 | ||
| 52 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 53 | assert config["file_naming"] == _packaged_defaults()["file_naming"] | ||
| 54 | |||
| 55 | |||
| 56 | def test_set_override_coercion_and_rejection() -> None: | ||
| 57 | overrides = config_loader.parse_set_overrides( | ||
| 58 | ["clustering.safe_raw_points=1e3", "clustering.skip_gpu_second_pass=on"], | ||
| 59 | nested=True, | ||
| 60 | error_cls=_config.ClusterFinderGPUConfigError, | ||
| 61 | ) | ||
| 62 | config = _config.build_cluster_finder_gpu_config(overrides=overrides) | ||
| 63 | |||
| 64 | assert config["clustering"]["safe_raw_points"] == 1000 | ||
| 65 | assert config["clustering"]["skip_gpu_second_pass"] is True | ||
| 66 | |||
| 67 | bad = config_loader.parse_set_overrides( | ||
| 68 | ["clustering.skip_gpu_second_pass=flase"], | ||
| 69 | nested=True, | ||
| 70 | error_cls=_config.ClusterFinderGPUConfigError, | ||
| 71 | ) | ||
| 72 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="skip_gpu_second_pass"): | ||
| 73 | _config.build_cluster_finder_gpu_config(overrides=bad) | ||
| 74 | |||
| 75 | |||
| 76 | def test_legacy_min_samples_alias_maps_to_dbscan_min_points() -> None: | ||
| 77 | config = _config.build_cluster_finder_gpu_config( | ||
| 78 | overrides={"clustering": {"min_samples": 12}} | ||
| 79 | ) | ||
| 80 | |||
| 81 | assert config["clustering"]["dbscan_min_points"] == 12 | ||
| 82 | assert "min_samples" not in config["clustering"] | ||
| 83 | |||
| 84 | |||
| 85 | def test_bad_value_type_is_rejected() -> None: | ||
| 86 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"): | ||
| 87 | _config.build_cluster_finder_gpu_config( | ||
| 88 | overrides={"clustering": {"dbscan_eps": "not-a-number"}} | ||
| 89 | ) | ||
| 90 | |||
| 91 | |||
| 92 | def test_out_of_range_value_is_rejected() -> None: | ||
| 93 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="dbscan_eps"): | ||
| 94 | _config.build_cluster_finder_gpu_config(overrides={"clustering": {"dbscan_eps": 0.0}}) | ||
| 95 | |||
| 96 | |||
| 97 | def test_mismatched_density_cutoffs_are_rejected() -> None: | ||
| 98 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="cutoff_portions"): | ||
| 99 | _config.build_cluster_finder_gpu_config( | ||
| 100 | overrides={"cluster_density_filtering": {"cutoff_portions": [0.5]}} | ||
| 101 | ) | ||
| 102 | |||
| 103 | |||
| 104 | def test_null_section_falls_back_to_section_defaults() -> None: | ||
| 105 | """An explicit ``null`` section means "use defaults", as before the pydantic move.""" | ||
| 106 | config = _config.normalize_cluster_finder_gpu_config( | ||
| 107 | {"voxelization": None, "clustering": None} | ||
| 108 | ) | ||
| 109 | |||
| 110 | assert config["voxelization"] == {"enabled": False, "voxel_size": 0.03} | ||
| 111 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 112 | |||
| 113 | |||
| 114 | def test_non_mapping_section_is_still_rejected() -> None: | ||
| 115 | with pytest.raises(_config.ClusterFinderGPUConfigError, match="voxelization"): | ||
| 116 | _config.normalize_cluster_finder_gpu_config({"voxelization": 5}) | ||
| 117 | |||
| 118 | |||
| 119 | def test_config_path_replaces_packaged_defaults(tmp_path) -> None: | ||
| 120 | path = tmp_path / "custom.json" | ||
| 121 | path.write_text(json.dumps({"device": "CPU:0"}), encoding="utf-8") | ||
| 122 | |||
| 123 | config = _config.load_cluster_finder_gpu_config(path) | ||
| 124 | |||
| 125 | assert config["device"] == "CPU:0" | ||
| 126 | assert config["clustering"]["dbscan_min_points"] == 50 | ||
| 0 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.