"""High-level convenience: read inputs, compute, write all artifacts. The pipeline now accepts three optional companion inputs in addition to the scan point cloud: - ``cad_path``: CAD reference geometry (PLY/XYZ/STL/OBJ) used as the baseline coordinate frame; the scan is rigidly aligned to it. - ``roi_path``: JSON file holding an axis-aligned bbox (in the CAD frame) that replaces the automatic ROI selection. - ``--skip-excel`` / ``skip_excel``: opt out of the Excel artifact. When all of these are absent the pipeline behaves like the legacy single-cloud workflow. """ from __future__ import annotations import json import logging from pathlib import Path from typing import Any import numpy as np from .api import DeflectionResult, compute_deflection, load_point_cloud from .artifacts import ( export_overlay_plys, render_plots, write_beam_tracks_csv, write_curves_csv, write_report_json, ) from .cad_io import read_cad_reference from .config import load_config, resolve_numeric_parameters, cloud_geometry from .io import ensure_output_dir from .preprocess import estimate_spacing, preprocess_point_cloud from .registration import RegistrationResult, apply_transform, register_to_cad from .roi import load_roi_aabb log = logging.getLogger(__name__) def _swap_y_z(points: np.ndarray) -> np.ndarray: """Reorder columns x,y,z -> x,z,y. Bridges between the CAD/scan convention (``y`` = height) and the algorithm's canonical frame (``z`` = height). Pure column permutation; no values are altered. """ return points[:, [0, 2, 1]] def _swap_roi_y_z(roi: dict) -> dict: """Swap the y/z bounds of an AABB to match :func:`_swap_y_z`.""" return { "x_min": roi["x_min"], "x_max": roi["x_max"], "y_min": roi["z_min"], "y_max": roi["z_max"], "z_min": roi["y_min"], "z_max": roi["y_max"], } def run_measurement_pipeline( input_cloud: str | Path, out_dir: str | Path, config_path: str | None = None, profile: str = "auto", *, cad_path: str | Path | None = None, roi_path: str | Path | None = None, skip_registration: bool = False, enable_bilateral: bool = True, skip_excel: bool = False, input_axis: str = "y_up", ) -> dict: """Read inputs, run the algorithm, and write every artifact. Returns the same dict as ``DeflectionResult.to_dict()``; it is also persisted to ``/report.json``. CSV files land in ``out_dir`` directly, plots in ``out_dir/images/``, PLY overlays in ``out_dir/ply/`` and (unless suppressed) the Excel error report in ``out_dir/report.xlsx``. """ out_dir_path = ensure_output_dir(out_dir) scan_raw = load_point_cloud(input_cloud) user_config: dict[str, Any] = {} if config_path: user_config = json.loads(Path(config_path).read_text(encoding="utf-8")) roi_aabb: dict[str, float] | None = None if roi_path is not None: roi_aabb = load_roi_aabb(roi_path) if not enable_bilateral: user_config.setdefault("preprocess", {}).setdefault("bilateral", {})["enabled"] = False cad_points: np.ndarray | None = None if cad_path is not None: cad_points = read_cad_reference(cad_path) if input_axis == "y_up": # Bridge from the CAD/scan convention (y = height) into the # algorithm's canonical frame (z = height). The core algorithm is # unchanged; this is a pure input adapter at the I/O boundary. scan_raw = _swap_y_z(scan_raw) if cad_points is not None: cad_points = _swap_y_z(cad_points) if roi_aabb is not None: roi_aabb = _swap_roi_y_z(roi_aabb) elif input_axis != "z_up": raise ValueError(f"input_axis must be 'z_up' or 'y_up', got {input_axis!r}") registration_meta: dict[str, Any] = { "enabled": cad_points is not None and not skip_registration, "skipped": cad_points is not None and skip_registration, "performed": False, } registration_result: RegistrationResult | None = None scan_for_compute = scan_raw if cad_points is not None and not skip_registration: registration_result, scan_for_compute = _register_scan( scan_raw, cad_points, profile, user_config, roi_aabb, ) registration_meta.update( performed=True, rms_mm=registration_result.rms_mm, initial_rms_mm=registration_result.initial_rms_mm, iterations=registration_result.iterations, inlier_ratio=registration_result.inlier_ratio, converged=registration_result.converged, R=registration_result.R.tolist(), t=registration_result.t.tolist(), ) result = compute_deflection( scan_for_compute, config=user_config, profile=profile, roi_aabb=roi_aabb, ) _augment_report( result, input_cloud=input_cloud, cad_path=cad_path, roi_path=roi_path, roi_aabb=roi_aabb, registration_meta=registration_meta, ) _apply_registration_quality_flags(result, registration_result) write_report_json(result, out_dir_path / "report.json") write_beam_tracks_csv(result, out_dir_path / "beam_tracks.csv") write_curves_csv(result, out_dir_path / "deflection_curves.csv") ply_exports = export_overlay_plys(result, out_dir_path / "ply") result.report["ply_exports"] = ply_exports write_report_json(result, out_dir_path / "report.json") render_plots(result, out_dir_path / "images") if not skip_excel: _write_excel_safely( result, out_dir_path / "report.xlsx", inputs={ "scan_path": str(Path(input_cloud).resolve()), "cad_path": str(Path(cad_path).resolve()) if cad_path else "", "roi_path": str(Path(roi_path).resolve()) if roi_path else "", "config_path": str(Path(config_path).resolve()) if config_path else "", "profile": profile, }, registration_info=registration_meta, ) return result.report # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- def _register_scan( scan_raw: np.ndarray, cad: np.ndarray, profile: str, user_config: dict[str, Any], roi_aabb: dict[str, float] | None, ) -> tuple[RegistrationResult, np.ndarray]: """Run a lightweight preprocess + ICP and apply the transform.""" from .config import deep_merge base_config = load_config(None, profile) if user_config: base_config = deep_merge(base_config, user_config) if roi_aabb is not None: # Avoid letting the passthrough crop the cloud before registration # (the AABB is expressed in the *post-registration* frame). base_config["preprocess"]["passthrough"] = {"enabled": False, "aabb": None} base_config["preprocess"]["bilateral"] = dict( base_config["preprocess"].get("bilateral", {}) ) base_config["preprocess"]["bilateral"]["enabled"] = False geometry = cloud_geometry(scan_raw) spacing = estimate_spacing( scan_raw, sample_size=base_config["preprocess"]["spacing_sample_size"], ) resolved = resolve_numeric_parameters(base_config, geometry, spacing) work = preprocess_point_cloud(scan_raw, resolved) reg = register_to_cad(work.work_points, cad, resolved) scan_aligned = apply_transform(scan_raw, reg.R, reg.t) return reg, scan_aligned def _augment_report( result: DeflectionResult, *, input_cloud: str | Path, cad_path: str | Path | None, roi_path: str | Path | None, roi_aabb: dict[str, float] | None, registration_meta: dict[str, Any], ) -> None: result.report["input_cloud"] = str(Path(input_cloud).resolve()) result.report["cad_reference"] = str(Path(cad_path).resolve()) if cad_path else None result.report["roi_source"] = str(Path(roi_path).resolve()) if roi_path else None result.report["roi_aabb"] = roi_aabb result.report["registration"] = registration_meta def _apply_registration_quality_flags( result: DeflectionResult, registration: RegistrationResult | None, ) -> None: if registration is None: return if not registration.converged: result.quality_flags.append("registration_not_converged") # Use spacing-relative threshold: NN distance between sparse clouds is # bounded below by half the spacing, so an absolute 10 mm gate # misfires on coarsely-sampled scans. Treat >2x spacing as poor. spacing_mm = float( (result.report.get("preprocess") or {}).get("spacing_mm", 0.0) or 0.0 ) poor_threshold = max(10.0, 2.0 * spacing_mm) if registration.rms_mm > poor_threshold: result.quality_flags.append("registration_poor_quality") if registration.inlier_ratio < 0.5: result.quality_flags.append("registration_low_inlier_ratio") result.report["quality_flags"] = list(result.quality_flags) def _write_excel_safely( result: DeflectionResult, path: Path, *, inputs: dict[str, Any], registration_info: dict[str, Any], ) -> None: try: from .excel_report import write_excel_report except ImportError as exc: log.warning("Skipping Excel report — %s", exc) return try: write_excel_report( result, path, inputs=inputs, registration_info=registration_info, ) except ImportError as exc: log.warning("Skipping Excel report — openpyxl missing (%s)", exc) except Exception as exc: # pragma: no cover - defensive log.warning("Skipping Excel report — %s", exc) __all__ = ["run_measurement_pipeline"]