257 lines
8.6 KiB
Python
257 lines
8.6 KiB
Python
"""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 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,
|
|
) -> dict:
|
|
"""Read inputs, run the algorithm, and write every artifact.
|
|
|
|
Returns the same dict as ``DeflectionResult.to_dict()``; it is also
|
|
persisted to ``<out_dir>/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)
|
|
|
|
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"]
|