from __future__ import annotations import copy import json from pathlib import Path from typing import Any import numpy as np DEFAULT_CONFIG: dict[str, Any] = { "preprocess": { "stat_k": 18, "stat_std_ratio": 2.8, "radius_spacing_multiplier": 2.8, "radius_min_mm": 12.0, "radius_min_neighbors": 4, "voxel_spacing_multiplier": 1.5, "voxel_min_mm": 8.0, "normal_k": 20, "spacing_sample_size": 4000, # Optional passthrough crop performed before any other filter. # ``aabb`` is populated from the external ROI JSON when supplied. "passthrough": { "enabled": False, "aabb": None, }, # Point-cloud bilateral filter applied after radius filtering. "bilateral": { "enabled": True, "k": 24, "sigma_d_spacing_multiplier": 2.0, "sigma_d_min_mm": 2.0, "sigma_n_deg": 15.0, "iterations": 1, }, }, "frame": { "use_input_axes": True, "coarse_slice_count": 28, "coarse_y_bins": 30, "bottom_quantile": 0.14, "low_band_margin_spacing_multiplier": 6.0, "candidate_bin_min_points": 12, "normal_alignment_min": 0.55, "flatness_spacing_multiplier": 4.0, "flatness_min_mm": 8.0, "symmetry_weight": 1.25, "bottom_weight": 1.3, "flatness_weight": 1.0, "hole_weight": 0.55, }, "roi": { "x_trim_quantile": 0.004, "bottom_height_ratio": 0.24, "bottom_quantile": 0.18, "z_margin_spacing_multiplier": 3.0, "min_z_ceiling_mm": 1500.0, # External axis-aligned bbox supplied via JSON. # Fields: x_min, x_max, y_min, y_max, z_min, z_max (mm, CAD frame). "aabb": None, }, "beam": { "slice_thickness_length_ratio": 0.012, "slice_thickness_spacing_multiplier": 22.0, "slice_step_ratio": 0.5, "slice_min_points": 80, "y_bin_width_width_ratio": 0.03, "y_bin_width_spacing_multiplier": 3.5, "low_quantile": 0.18, "low_core_quantile": 0.28, "min_bin_points": 10, "width_min_ratio": 0.018, "width_max_ratio": 0.18, "width_preferred_ratio": 0.065, "flatness_spacing_multiplier": 4.5, "flatness_min_mm": 10.0, "normal_alignment_min": 0.6, "low_band_margin_spacing_multiplier": 8.0, "midline_trim_quantile": 0.02, "edge_preference_weight": 3.2, "strong_edge_ratio": 0.58, "inner_reject_ratio": 0.32, "edge_keep_min_ratio": 0.72, "relative_edge_window": 0.18, "drop_weak_edge_slices": True, }, "tracking": { "top_k": 3, "beam_width": 24, "min_coverage_ratio": 0.28, "weights": { "coverage_reward": 2.2, "node_reward": 1.0, "y_penalty": 1.6, "z_penalty": 1.4, "width_penalty": 0.7, "normal_penalty": 0.8, "gap_penalty": 2.0, "gap_growth_penalty": 0.7, "jitter_penalty": 0.9, }, }, "representative": { "band_y_margin_spacing_multiplier": 4.0, "band_z_margin_spacing_multiplier": 10.0, "core_quantile": 0.28, "core_margin_spacing_multiplier": 5.0, "underside_profile_bin_count": 14, "underside_quantile": 0.2, "underside_outer_fraction": 0.4, "underside_rise_spacing_multiplier": 0.55, "underside_core_spacing_multiplier": 0.7, "underside_profile_margin_spacing_multiplier": 1.0, "underside_min_bin_points": 18, "max_iter": 5, "spline_smoothing_y_spacing_multiplier": 3.0, "spline_smoothing_z_spacing_multiplier": 8.0, "max_gap_slices": 4, "grid_step_slice_ratio": 1.0, # Robust rejection of representative points contaminated by gussets / # cross-members before the spline fit. Window is in units of beam # slice steps; sigma is the robust (MAD-based) rejection threshold. "outlier_rejection_enabled": True, "outlier_window_slices": 2.5, "outlier_sigma": 2.5, }, "reference": { "mode": "line_endpoints", "stable_region_fraction": 0.14, "search_extension_spacing_multiplier": 12.0, "search_extension_min_mm": 650.0, "lateral_margin_width_ratio": 0.11, "vertical_margin_height_ratio": 0.22, "underside_lower_margin_spacing_multiplier": 1.2, "underside_upper_margin_spacing_multiplier": 3.8, "circle_trials": 360, "circle_sample_size": 700, "normal_axis_max_alignment": 0.65, "preferred_axes": ["y"], "radius_min_mm": 35.0, "radius_max_width_ratio": 0.04, "fit_tolerance_spacing_multiplier": 0.38, "coverage_bin_count": 20, "min_inliers": 18, "cluster_eps_spacing_multiplier": 2.5, "cluster_min_samples": 2, "x_window_count": 6, "x_window_overlap_ratio": 0.35, "axis_bonus": { "y": 0.42, "z": 0.18, "x": -0.85 }, "extremity_reward": 2.2, "underside_penalty_weight": 1.2, "vertical_center_penalty_weight": 0.25, "lateral_penalty_weight": 0.55, "low_confidence_threshold": 0.6, }, "deflection": { "evaluation_grid_factor": 1.0, }, "registration": { "enabled": True, "skip": False, "icp_max_iter": 60, "icp_tol_mm": 0.05, "icp_tol_rel": 1.0e-4, "icp_max_corr_spacing_multiplier": 6.0, "icp_max_corr_min_mm": 25.0, "icp_downsample_target": 30000, "trim_ratio": 0.2, "pca_sign_disambiguation": True, # If the recovered transform is within these tolerances of identity # (and snapping does not worsen correspondence), return exact identity # so an already-aligned scan is left untouched. "identity_rot_tol_deg": 0.5, "identity_trans_tol_mm": 5.0, }, "cad": { "path": None, "downsample_voxel_mm": 4.0, }, "visualization": { "max_scatter_points": 40000, "dpi": 180, }, } PROFILE_OVERRIDES: dict[str, dict[str, Any]] = { "auto": {}, "M": { "beam": { "slice_thickness_length_ratio": 0.011, "width_preferred_ratio": 0.06, } }, "TC": { "beam": { "slice_thickness_length_ratio": 0.014, "width_preferred_ratio": 0.07, }, "reference": {"stable_region_fraction": 0.16}, }, "TP": { "beam": { "slice_thickness_length_ratio": 0.013, "width_preferred_ratio": 0.072, }, "reference": {"vertical_margin_height_ratio": 0.24}, }, "custom": {}, } def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: merged = copy.deepcopy(base) for key, value in override.items(): if isinstance(value, dict) and isinstance(merged.get(key), dict): merged[key] = deep_merge(merged[key], value) else: merged[key] = copy.deepcopy(value) return merged def load_config(config_path: str | None, profile: str) -> dict[str, Any]: profile_name = profile if profile in PROFILE_OVERRIDES else "auto" config = deep_merge(DEFAULT_CONFIG, PROFILE_OVERRIDES[profile_name]) if config_path: user_config = json.loads(Path(config_path).read_text(encoding="utf-8")) config = deep_merge(config, user_config) return config def cloud_geometry(points: np.ndarray) -> dict[str, float]: mins = points.min(axis=0) maxs = points.max(axis=0) spans = maxs - mins return { "x_min": float(mins[0]), "x_max": float(maxs[0]), "y_min": float(mins[1]), "y_max": float(maxs[1]), "z_min": float(mins[2]), "z_max": float(maxs[2]), "length": float(spans[0]), "width": float(spans[1]), "height": float(spans[2]), } def resolve_numeric_parameters( config: dict[str, Any], geometry: dict[str, float], spacing: float, ) -> dict[str, Any]: resolved = copy.deepcopy(config) length = geometry["length"] width = geometry["width"] height = geometry["height"] preprocess = resolved["preprocess"] preprocess["radius_mm"] = max( preprocess["radius_min_mm"], preprocess["radius_spacing_multiplier"] * spacing, ) preprocess["voxel_mm"] = max( preprocess["voxel_min_mm"], preprocess["voxel_spacing_multiplier"] * spacing, ) bilateral = preprocess.get("bilateral") if isinstance(bilateral, dict): bilateral["sigma_d_mm"] = max( float(bilateral.get("sigma_d_min_mm", 2.0)), float(bilateral.get("sigma_d_spacing_multiplier", 2.0)) * spacing, ) bilateral["sigma_n_rad"] = float( np.deg2rad(float(bilateral.get("sigma_n_deg", 15.0))) ) frame = resolved["frame"] frame["flatness_mm"] = max( frame["flatness_min_mm"], frame["flatness_spacing_multiplier"] * spacing, ) frame["low_band_margin_mm"] = ( frame["low_band_margin_spacing_multiplier"] * spacing ) roi = resolved["roi"] roi["z_margin_mm"] = roi["z_margin_spacing_multiplier"] * spacing beam = resolved["beam"] beam["slice_thickness_mm"] = max( beam["slice_thickness_length_ratio"] * length, beam["slice_thickness_spacing_multiplier"] * spacing, ) beam["slice_step_mm"] = beam["slice_thickness_mm"] * beam["slice_step_ratio"] beam["y_bin_width_mm"] = max( beam["y_bin_width_width_ratio"] * width, beam["y_bin_width_spacing_multiplier"] * spacing, ) beam["width_min_mm"] = beam["width_min_ratio"] * width beam["width_max_mm"] = beam["width_max_ratio"] * width beam["width_preferred_mm"] = beam["width_preferred_ratio"] * width beam["flatness_mm"] = max( beam["flatness_min_mm"], beam["flatness_spacing_multiplier"] * spacing, ) beam["low_band_margin_mm"] = ( beam["low_band_margin_spacing_multiplier"] * spacing ) representative = resolved["representative"] representative["band_y_margin_mm"] = ( representative["band_y_margin_spacing_multiplier"] * spacing ) representative["band_z_margin_mm"] = ( representative["band_z_margin_spacing_multiplier"] * spacing ) representative["core_margin_mm"] = ( representative["core_margin_spacing_multiplier"] * spacing ) representative["grid_step_mm"] = ( representative["grid_step_slice_ratio"] * beam["slice_step_mm"] ) reference = resolved["reference"] reference["lateral_margin_mm"] = ( reference["lateral_margin_width_ratio"] * width ) reference["vertical_margin_mm"] = ( reference["vertical_margin_height_ratio"] * height ) reference["search_extension_mm"] = max( reference["search_extension_min_mm"], reference["search_extension_spacing_multiplier"] * spacing, ) reference["underside_lower_margin_mm"] = ( reference["underside_lower_margin_spacing_multiplier"] * spacing ) reference["underside_upper_margin_mm"] = ( reference["underside_upper_margin_spacing_multiplier"] * spacing ) reference["radius_max_mm"] = max( reference["radius_min_mm"] * 1.5, reference["radius_max_width_ratio"] * width, ) reference["fit_tolerance_mm"] = ( reference["fit_tolerance_spacing_multiplier"] * spacing ) reference["cluster_eps_mm"] = ( reference["cluster_eps_spacing_multiplier"] * spacing ) registration = resolved.get("registration") if isinstance(registration, dict): registration["icp_max_corr_mm"] = max( float(registration.get("icp_max_corr_min_mm", 25.0)), float(registration.get("icp_max_corr_spacing_multiplier", 6.0)) * spacing, ) resolved["runtime"] = { "spacing_mm": float(spacing), "geometry": geometry, } return resolved