"""End-to-end orchestration for *research / validation* runs. Two entry points: - :func:`run_coupler_measurement_pipeline` — the integration path: cloud (in CAD frame) + optional CAD STP + ROI → ROI clip → denoise → measure. **No registration.** - :func:`run_from_cad_pipeline` — the research path: STP → synthesise → denoise → 4-hole Kabsch (against oracle deflected GT) → measure → diff GT. Saves every intermediate artifact under a structured run directory. """ from __future__ import annotations import math from pathlib import Path from typing import Any import numpy as np from .api import compute_coupler_measurement, ProgressCallback from .config import load_config, resolve_auto from .data_prep import ( PreparedCloud, denoise_cloud, parse_cad, prepare_cloud_from_cad, prepare_cloud_from_scan, register_cloud, synthesize_cloud, ) from .exceptions import InsufficientPointsError from .io_utils import ( Artifacts, crop_ply_bbox, load_point_cloud, write_json, write_point_cloud, write_yaml, ) from .metrics import compute_deflection_metrics from .detection.autotune import detection_overlay, relax_denoise_steps from .detection.layer_select import select_panel_layer from .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json from .structures import CouplerMeasurement # --------------------------------------------------------------------------- # Integration entry point # --------------------------------------------------------------------------- def run_coupler_measurement_pipeline( input_cloud: str | Path, out_dir: str | Path, *, config_path: str | Path | None = None, profile: str = "auto", cad_stp_path: str | Path | None = None, roi_spec: ROISpec | None = None, roi_json: str | Path | None = None, progress: ProgressCallback | None = None, ) -> CouplerMeasurement: """Main measurement entry: cloud (in CAD frame) → ROI → denoise → measure. **No registration step.** The input cloud is assumed to already live in the CAD / world frame (scanner extrinsics calibrated upstream). If it doesn't, 4-hole Kabsch against nominal CAD holes would also cancel the panel-deflection signal we want to measure — so this pipeline deliberately does not register. For the research path that *does* register (using oracle deflected GT), see :func:`run_from_cad_pipeline`. Inputs: - ``cad_stp_path`` — optional STEP file. Parsed for the analytical panel-normal seed and, when no profile/config is given, to auto-tune the scale/density-dependent denoise + detection knobs (see below). The parsed GT is written as ``01_gt.json`` for audit. - ``roi_spec`` / ``roi_json`` — axis-aligned-bbox ROI applied to the cloud *before* denoising. If both omitted, the full cloud is kept. ``roi_spec`` wins over ``roi_json``. Writes every intermediate to ``out_dir``. Returns a :class:`CouplerMeasurement`. """ art = Artifacts(out_dir) cfg = load_config(config_path, profile=profile) cfg = resolve_auto(cfg) write_yaml(cfg, art.resolved_config) # ---- Load + ROI clip -------------------------------------------------- # The ROI JSON is the cropping hyper-parameter. When it is a finite # bounding box and the input is a (possibly multi-GB) binary PLY, crop # it by streaming the file so we never hold the whole vehicle cloud in # memory. Otherwise fall back to load-then-filter. if roi_spec is None: roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec() finite_bbox = all( math.isfinite(v) for v in ( roi_spec.x_min, roi_spec.x_max, roi_spec.y_min, roi_spec.y_max, roi_spec.z_min, roi_spec.z_max, ) ) is_ply = str(input_cloud).lower().endswith(".ply") if finite_bbox and is_ply: pts, roi_diag = crop_ply_bbox( input_cloud, x_min=roi_spec.x_min, x_max=roi_spec.x_max, y_min=roi_spec.y_min, y_max=roi_spec.y_max, z_min=roi_spec.z_min, z_max=roi_spec.z_max, ) roi_diag["kind"] = roi_spec.kind else: raw_pts = load_point_cloud(input_cloud) pts, roi_diag = apply_roi(raw_pts, roi_spec) if pts.shape[0] == 0: raise InsufficientPointsError( f"ROI bbox kept 0 points from {input_cloud}; check the ROI JSON " f"bounds match the cloud's coordinate frame" ) write_point_cloud(pts, art.path("sampled", ext="ply")) write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json") # ---- Optional normal-direction layer isolation ----------------------- # For a ROI cut from a full-vehicle scan, the box can slice through # several structures stacked along the panel normal (base plate + # raised mounting plate + roof truss). Detection and plane fit assume a # single thin plate, so we keep only one layer when the active config # turns this on (layer_select.enabled). Default config leaves it off, # so single-part clouds are unaffected. ls_cfg = cfg.get("layer_select", {}) if ls_cfg.get("enabled", False): layer = select_panel_layer( pts, normal_seed=ls_cfg.get("normal_seed", "pca"), half_width_mm=float(ls_cfg.get("half_width_mm", 6.0)), bins=int(ls_cfg.get("bins", 240)), pick=str(ls_cfg.get("pick", "frontmost")), ) pts = layer.points write_point_cloud(pts, art.path("transformed", ext="ply")) write_json(layer.diagnostics, art.root / "layer.json") # Auto-tune the scale/density-dependent knobs from CAD + cloud when the # caller relies on the generic default config (no explicit config file # and no hand-tuned profile) but provides a CAD STP. This makes the # "CAD + cloud + ROI" contract self-sufficient for an adequately-sampled # scan of a panel of any scale, instead of (a) the default radius filter # deleting a sparse cloud or (b) 4-hole detection failing with the # small-panel defaults. A specific profile (e.g. lu1_af4) or an explicit # config is respected as-is (autotune skipped), so existing setups are # byte-for-byte unchanged. autotune = ( cad_stp_path is not None and config_path is None and str(profile) in ("auto", "default", "none", "None") ) # ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ---------- panel_seed: np.ndarray | None = None parsed = None if cad_stp_path is not None: if autotune: # The default locating-hole band ([4,12] mm) selects the small # inner holes on a large panel, from which autotune would derive # too fine a grid. Widen it so parse_cad picks the large corner # locating holes (the reliably-scannable rectangle) and excludes # the big central pass-through hole. cfg["cad"] = {**cfg["cad"], "locating_hole_radius_mm": [6.0, 30.0]} parsed = parse_cad(cad_stp_path, cfg["cad"]) write_json(parsed.gt.to_dict(), art.path("gt", ext="json")) panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64) # ---- Denoise (no Kabsch, no prepare_cloud_from_scan) ----------------- # Stage 1 of autotune (before denoise): drop the density-fragile radius # filter so a sparse cloud is not deleted. if autotune: cfg["denoise"] = relax_denoise_steps(cfg.get("denoise", {})) denoised = denoise_cloud(pts, cfg.get("denoise", {})) write_point_cloud(denoised.points, art.path("denoised", ext="ply")) # Stage 2 of autotune (after denoise): size the detection grid / fill / # area band from the CAD locating-hole radius + the DENOISED point # spacing (the cloud detection actually sees). if autotune and parsed is not None: loc_r = float(np.median([h.radius_mm for h in parsed.gt.holes.locating])) cfg["detection"] = { **cfg.get("detection", {}), **detection_overlay(loc_r, denoised.points), } write_yaml(cfg, art.resolved_config) # ---- Measurement ------------------------------------------------------ result = compute_coupler_measurement( denoised.points, config=cfg, profile="custom", panel_normal_seed=panel_seed, progress=progress, ) write_json(result.to_dict(), art.report_json) write_json(result.to_dict()["report"], art.path("metrics", ext="json")) return result # --------------------------------------------------------------------------- # CAD-driven research entry point # --------------------------------------------------------------------------- def run_from_cad_pipeline( stp_path: str | Path, out_dir: str | Path, *, config_path: str | Path | None = None, profile: str = "auto", progress: ProgressCallback | None = None, ) -> dict[str, Any]: """End-to-end research run: STP → synthesise → prepare → measure → diff GT. Returns a summary dict containing the measurement, GT metrics, and measured-minus-GT differences. Writes every intermediate cloud and JSON to ``out_dir``. """ art = Artifacts(out_dir) cfg = load_config(config_path, profile=profile) cfg = resolve_auto(cfg) write_yaml(cfg, art.resolved_config) # ---- Phase A.1 — parse + GT ------------------------------------------- parsed = parse_cad(stp_path, cfg["cad"]) write_point_cloud(parsed.mesh.vertices, art.path("cad_mesh", ext="ply")) write_json(parsed.gt.to_dict(), art.path("gt", ext="json")) # ---- Phase A.2 — synthesise -------------------------------------------- synth = synthesize_cloud( parsed, sampling_cfg=cfg["sampling"], noise_cfg=cfg.get("noise", {}), transform_cfg=cfg.get("transform", {}), scan_cfg=cfg.get("scan_simulator", {}), deflection_cfg=cfg.get("panel_deflection", {}), ) write_point_cloud(synth.sampled, art.path("sampled", ext="ply")) write_point_cloud(synth.noisy, art.path("noisy", ext="ply")) write_point_cloud(synth.transformed, art.path("transformed", ext="ply")) # ---- Phase A.3 — denoise ---------------------------------------------- denoised = denoise_cloud(synth.transformed, cfg.get("denoise", {})) write_point_cloud(denoised.points, art.path("denoised", ext="ply")) # ---- Phase A.4 — register --------------------------------------------- registered = register_cloud( denoised.points, target_holes=synth.gt_holes_after_deflection, detection_cfg=cfg["detection"], cad_cfg=cfg["cad"], registration_cfg=cfg["registration"], panel_normal_seed=None, ) write_point_cloud(registered.points, art.path("registered", ext="ply")) # ---- Phase B — measurement algorithm ---------------------------------- result = compute_coupler_measurement( registered.points, config=cfg, profile="custom", panel_normal_seed=np.asarray(parsed.gt.plane.normal, dtype=np.float64), progress=progress, ) # ---- GT metrics -------------------------------------------------------- gt_normal = synth.gt_normal_after_deflection centers = synth.gt_holes_after_deflection.locating_centers() gt_metrics = compute_deflection_metrics( normal=gt_normal, center_C=tuple(centers.mean(axis=0).tolist()), panel_points=centers, right_axis_sign=int(cfg["metrics"].get("right_axis_sign", 1)), angle_clamp_deg=float(cfg["metrics"].get("angle_clamp_deg", 0.0)), ) diff = { "theta_right_deg": result.theta_right_deg - gt_metrics.theta_right_deg, "theta_ref_deg": result.theta_ref_deg - gt_metrics.theta_ref_deg, "D_y_max_mm": result.D_y_max_mm - gt_metrics.D_y_max_mm, "D_z_max_mm": result.D_z_max_mm - gt_metrics.D_z_max_mm, "dx_y_max_mm": result.dx_y_max_mm - gt_metrics.dx_y_max_mm, "dx_z_max_mm": result.dx_z_max_mm - gt_metrics.dx_z_max_mm, } summary = { "stp": str(stp_path), "panel_deflection_injected": { "theta_right_deg": synth.panel_deflection_theta_right_deg, "theta_ref_deg": synth.panel_deflection_theta_ref_deg, "R": synth.panel_deflection_R.tolist(), }, "scan_pose_perturbation": synth.gt_transform_4x4.tolist(), "registration": { "matrix": registered.transform_4x4.tolist(), "rmse_mm": registered.rmse_mm, "max_residual_mm": registered.max_residual_mm, }, "measured": result.to_dict(), "gt_cad": parsed.gt.to_dict(), "gt_after_deflection": { "normal": gt_normal.tolist(), "locating_centers": centers.tolist(), }, "gt_metrics": gt_metrics.to_dict(), "diff": diff, "config": cfg, } write_json(summary, art.path("metrics", ext="json")) write_json(summary, art.report_json) return summary # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _load_holeset_from_json(path: str | Path): """Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``.""" import json from .structures import Hole, HoleSet with Path(path).open("r", encoding="utf-8") as fh: d = json.load(fh) holes = d["holes"] locating = tuple( Hole( center=tuple(h["center"]), radius_mm=float(h["radius_mm"]), fit_residual_rms_mm=float(h.get("fit_residual_rms_mm", 0.0)), role=h.get("role", "locating"), ) for h in holes["locating"] ) central = None if holes.get("central") is not None: c = holes["central"] central = Hole( center=tuple(c["center"]), radius_mm=float(c["radius_mm"]), fit_residual_rms_mm=float(c.get("fit_residual_rms_mm", 0.0)), role="central", ) return HoleSet(locating=locating, central=central) __all__ = [ "run_coupler_measurement_pipeline", "run_from_cad_pipeline", ]