"""Region-of-interest helpers driven by an external JSON file. Supports a single axis-aligned bounding box (AABB) in the CAD coordinate frame, units millimetres. The AABB is used in two places: 1. ``preprocess_point_cloud`` applies it as an early passthrough crop (performance + noise reduction). 2. ``frame.build_bottom_roi_mask`` consumes the same AABB to build the canonical ROI mask used by the deflection stages. """ from __future__ import annotations import json from pathlib import Path from typing import Any import numpy as np from .exceptions import InvalidPointCloudError _REQUIRED_KEYS = ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max") def load_roi_aabb(path: str | Path) -> dict[str, float]: """Load an AABB ROI specification from a JSON file. Returns a normalised dict with the six float fields. Raises ``InvalidPointCloudError`` for missing keys, non-numeric values, or inverted ranges (min >= max). """ path = Path(path) try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise InvalidPointCloudError(f"Failed to read ROI JSON {path}: {exc}") from exc if not isinstance(raw, dict): raise InvalidPointCloudError( f"ROI JSON {path} must be a JSON object with AABB fields." ) aabb: dict[str, float] = {} for key in _REQUIRED_KEYS: if key not in raw: raise InvalidPointCloudError( f"ROI JSON {path} missing required key '{key}'." ) try: aabb[key] = float(raw[key]) except (TypeError, ValueError) as exc: raise InvalidPointCloudError( f"ROI JSON {path} key '{key}' is not numeric: {raw[key]!r}" ) from exc _validate_aabb(aabb, source=str(path)) return aabb def normalize_aabb(aabb: dict[str, Any]) -> dict[str, float]: """Validate and coerce an in-memory AABB dict to ``{key: float}``.""" coerced: dict[str, float] = {} for key in _REQUIRED_KEYS: if key not in aabb: raise InvalidPointCloudError(f"AABB dict missing required key '{key}'.") coerced[key] = float(aabb[key]) _validate_aabb(coerced, source="") return coerced def _validate_aabb(aabb: dict[str, float], *, source: str) -> None: for axis in ("x", "y", "z"): lo = aabb[f"{axis}_min"] hi = aabb[f"{axis}_max"] if not (np.isfinite(lo) and np.isfinite(hi)): raise InvalidPointCloudError( f"AABB from {source} has non-finite bound on axis {axis}." ) if lo >= hi: raise InvalidPointCloudError( f"AABB from {source} has inverted range on axis {axis}: " f"{axis}_min={lo} >= {axis}_max={hi}." ) def apply_passthrough(points: np.ndarray, aabb: dict[str, float]) -> np.ndarray: """Return a boolean mask of points inside the AABB (inclusive).""" aabb = normalize_aabb(aabb) return ( (points[:, 0] >= aabb["x_min"]) & (points[:, 0] <= aabb["x_max"]) & (points[:, 1] >= aabb["y_min"]) & (points[:, 1] <= aabb["y_max"]) & (points[:, 2] >= aabb["z_min"]) & (points[:, 2] <= aabb["z_max"]) ) __all__ = ["load_roi_aabb", "normalize_aabb", "apply_passthrough"]