"""End-to-end single-beam measurement: stream-crop a cloud by ROI, measure, write artifacts. This is the DEFAULT pipeline for the "ROI already isolates one beam" workflow. Each call handles ONE beam (one ROI -> one result). For left+right, call it twice with the two ROI files; outputs land in separate sub-directories. """ from __future__ import annotations import logging from pathlib import Path from typing import Any import numpy as np from .io import ensure_output_dir, read_point_cloud_cropped, write_csv, write_json from .roi import load_roi_aabb, normalize_aabb from .single_beam import SingleBeamResult, measure_single_beam log = logging.getLogger(__name__) def run_single_beam_pipeline( input_cloud: str | Path, roi_path: str | Path, out_dir: str | Path, *, side: str | None = None, config: dict | None = None, cad_path: str | Path | None = None, enable_registration: bool = False, skip_excel: bool = False, skip_plot: bool = False, _registration: dict | None = None, ) -> dict: """Measure one beam and write report.json/csv (+ xlsx/png if available). Args: input_cloud: PLY/XYZ path. Large binary PLYs are stream-cropped. roi_path: JSON AABB isolating the beam (native frame x=len, y=height, z=width). out_dir: output directory for this beam's artifacts. side: label for the report; defaults to the ROI file stem. cad_path: theoretical CAD reference. Recorded for provenance; parsed and used only when ``enable_registration`` is True. enable_registration: if True, the cloud is rigidly aligned to the CAD before measurement (see :func:`_maybe_register`). Default False — the supplied cloud is assumed already registered. _registration: internal; precomputed registration metadata (used by :func:`run_both_beams` to register once and reuse). """ out_dir_path = ensure_output_dir(out_dir) roi_aabb = load_roi_aabb(roi_path) aabb = normalize_aabb(roi_aabb) if side is None: stem = Path(roi_path).stem.lower() side = "left" if "left" in stem else "right" if "right" in stem else stem reg_meta = _registration transform = None if reg_meta is None: reg_meta, transform = _maybe_register(input_cloud, cad_path, enable_registration, config) elif reg_meta.get("performed"): transform = (np.asarray(reg_meta["R"], dtype=np.float64), np.asarray(reg_meta["t"], dtype=np.float64)) points = read_point_cloud_cropped(input_cloud, aabb) if transform is not None: from .registration import apply_transform # ROI is defined in the registered frame; crop already-registered # points would be wrong, so when registering we crop a slightly # padded box, transform, then re-crop. Handled in _maybe_register's # contract: here transform is applied then re-cropped. points = apply_transform(points, transform[0], transform[1]) from .io import _crop_points points = _crop_points(points, ( aabb["x_min"], aabb["x_max"], aabb["y_min"], aabb["y_max"], aabb["z_min"], aabb["z_max"])) result = measure_single_beam(points, aabb, side=side, config=config) result.report["input_cloud"] = str(Path(input_cloud).resolve()) result.report["roi_source"] = str(Path(roi_path).resolve()) result.report["cad_reference"] = str(Path(cad_path).resolve()) if cad_path else None result.report["registration"] = reg_meta write_json(out_dir_path / "report.json", result.report) _write_curve_csv(result, out_dir_path / "deflection_curve.csv") if not skip_plot: _plot_safely(result, out_dir_path / "deflection_curve.png") if not skip_excel: _excel_safely(result, out_dir_path / "report.xlsx", input_cloud, roi_path, cad_path, reg_meta) return result.report def run_both_beams( input_cloud: str | Path, left_roi: str | Path, right_roi: str | Path, out_dir: str | Path, *, config: dict | None = None, cad_path: str | Path | None = None, enable_registration: bool = False, skip_excel: bool = False, skip_plot: bool = False, ) -> dict: """Measure left and right beams into ``out_dir/{left,right}``. Each side is a fully independent measurement (one call, one Excel each) — per the integration contract. When ``enable_registration`` is True the whole-train cloud is registered to the CAD once and the transform reused for both sides. """ out_dir_path = ensure_output_dir(out_dir) reg_meta, _ = _maybe_register(input_cloud, cad_path, enable_registration, config) left = run_single_beam_pipeline( input_cloud, left_roi, out_dir_path / "left", side="left", config=config, cad_path=cad_path, enable_registration=enable_registration, skip_excel=skip_excel, skip_plot=skip_plot, _registration=reg_meta, ) right = run_single_beam_pipeline( input_cloud, right_roi, out_dir_path / "right", side="right", config=config, cad_path=cad_path, enable_registration=enable_registration, skip_excel=skip_excel, skip_plot=skip_plot, _registration=reg_meta, ) summary = {"left": left, "right": right} write_json(out_dir_path / "summary.json", summary) return summary def _maybe_register(input_cloud, cad_path, enable_registration, config): """Return (registration_meta, transform_or_None). When ``enable_registration`` is False (default), no transform is applied and the meta records that the cloud is assumed pre-registered. When True, the whole-train cloud (downsampled) is rigidly aligned to the CAD; the CAD may be a point cloud / mesh (.ply/.xyz/.stl/.obj). STEP (.stp) is recorded but must be converted to a sampled cloud first (see tools/cad_sampling/). """ if not enable_registration: return {"performed": False, "reason": "disabled (cloud assumed pre-registered)", "cad_reference": str(Path(cad_path).resolve()) if cad_path else None}, None if cad_path is None: return {"performed": False, "reason": "no cad_path supplied"}, None suffix = Path(cad_path).suffix.lower() if suffix in {".stp", ".step", ".igs", ".iges"}: return {"performed": False, "reason": f"STEP not parsed; convert to sampled .ply/.stl first ({suffix})", "cad_reference": str(Path(cad_path).resolve())}, None from .cad_io import read_cad_reference from .config import load_config, resolve_numeric_parameters, cloud_geometry from .preprocess import estimate_spacing, voxel_downsample from .registration import register_to_cad cad = read_cad_reference(cad_path) # Downsample the (possibly huge) train cloud for registration. scan_full = read_point_cloud_cropped(input_cloud, { "x_min": -1e12, "x_max": 1e12, "y_min": -1e12, "y_max": 1e12, "z_min": -1e12, "z_max": 1e12}) base = load_config(None, "auto") if config: from .config import deep_merge base = deep_merge(base, config) spacing = estimate_spacing(scan_full, base["preprocess"]["spacing_sample_size"]) rc = resolve_numeric_parameters(base, cloud_geometry(scan_full), spacing) scan_ds = voxel_downsample(scan_full, max(rc["preprocess"]["voxel_mm"], 10.0)) reg = register_to_cad(scan_ds, cad, rc) meta = { "performed": True, "rms_mm": reg.rms_mm, "iterations": reg.iterations, "inlier_ratio": reg.inlier_ratio, "converged": reg.converged, "R": reg.R.tolist(), "t": reg.t.tolist(), "cad_reference": str(Path(cad_path).resolve()), } return meta, (reg.R, reg.t) # --------------------------------------------------------------------------- # Writers # --------------------------------------------------------------------------- def _write_curve_csv(result: SingleBeamResult, path: Path) -> None: rows = [] for i in range(len(result.x_mm)): rows.append({ "x_mm": float(result.x_mm[i]), "underside_height_mm": float(result.underside_height_mm[i]), "reference_height_mm": float(result.reference_height_mm[i]), "deflection_mm": float(result.deflection_mm[i]), "kept": bool(result.kept_mask[i]) if len(result.kept_mask) else False, }) write_csv( path, fieldnames=["x_mm", "underside_height_mm", "reference_height_mm", "deflection_mm", "kept"], rows=rows, ) def _plot_safely(result: SingleBeamResult, path: Path) -> None: if not result.available or len(result.x_mm) == 0: return try: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt except ImportError: log.warning("matplotlib not installed; skipping single-beam plot") return x = result.x_mm fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 6), sharex=True) ax1.scatter(x, result.underside_height_mm, s=8, c="tab:blue", label="underside") ax1.plot(x, result.reference_height_mm, "r-", lw=1.5, label="reference line") ax1.set_ylabel("height (mm)") ax1.legend(loc="best") ax1.set_title(f"{result.side} beam — underside vs straight reference") ax2.axhline(0.0, color="k", lw=0.8) ax2.plot(x, result.deflection_mm, "tab:green", lw=1.2) if result.selected_x_mm is not None: ax2.plot([result.selected_x_mm], [result.selected_signed_mm], "ro", label=f"max |dev| = {result.selected_absolute_mm:.2f} mm") ax2.legend(loc="best") ax2.set_xlabel("x — longitudinal (mm)") ax2.set_ylabel("deflection (mm)") fig.tight_layout() fig.savefig(path, dpi=150) plt.close(fig) def _excel_safely(result: SingleBeamResult, path: Path, input_cloud, roi_path, cad_path=None, reg_meta=None) -> None: try: from openpyxl import Workbook except ImportError: log.warning("openpyxl not installed; skipping single-beam xlsx") return reg_meta = reg_meta or {} wb = Workbook() ws = wb.active ws.title = "Summary" d = result.report.get("deflection", {}) rows = [ ("mode", "single_beam"), ("side", result.side), ("available", result.available), ("input_cloud", str(Path(input_cloud).resolve())), ("roi_source", str(Path(roi_path).resolve())), ("cad_reference", str(Path(cad_path).resolve()) if cad_path else ""), ("registration_performed", reg_meta.get("performed", False)), ("registration_rms_mm", reg_meta.get("rms_mm", "")), ("registration_note", reg_meta.get("reason", "")), ("roi_point_count", result.roi_point_count), ("n_slices", result.n_slices), ("n_kept", result.n_kept), ("reference_slope_mm_per_m", result.reference_slope_mm_per_m), ("selected_x_mm", d.get("selected_x_mm")), ("selected_signed_deflection_mm", d.get("selected_signed_mm")), ("selected_absolute_deflection_mm", d.get("selected_absolute_mm")), ("max_downward_mm", d.get("max_downward_mm")), ("max_downward_x_mm", d.get("max_downward_x_mm")), ("max_upward_mm", d.get("max_upward_mm")), ("max_upward_x_mm", d.get("max_upward_x_mm")), ("rms_mm", d.get("rms_mm")), ("quality_flags", "; ".join(result.quality_flags)), ] ws.append(["key", "value"]) for k, v in rows: ws.append([k, "" if v is None else v]) ws.column_dimensions["A"].width = 28 ws.column_dimensions["B"].width = 40 ws2 = wb.create_sheet("Deflection_Curve") ws2.append(["x_mm", "underside_height_mm", "reference_height_mm", "deflection_mm", "kept"]) for i in range(len(result.x_mm)): ws2.append([ float(result.x_mm[i]), float(result.underside_height_mm[i]), float(result.reference_height_mm[i]), float(result.deflection_mm[i]), bool(result.kept_mask[i]) if len(result.kept_mask) else False, ]) wb.save(path) __all__ = ["run_single_beam_pipeline", "run_both_beams"]