308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""End-to-end orchestration for *research / validation* runs.
|
|
|
|
Composition::
|
|
|
|
data_prep.prepare_cloud_from_cad / from_scan (Phase A)
|
|
api.compute_coupler_measurement (Phase B)
|
|
|
|
Phase A is the validation / data-preparation part: STP → mesh → sample
|
|
→ noise → transform → denoise → register. Phase B is the public
|
|
measurement algorithm. This module saves every intermediate artifact
|
|
under a structured run directory so engineers can debug and so the
|
|
sweep runner can compare runs.
|
|
|
|
Production callers should *not* use this module — they should call
|
|
:func:`coupler_panel.api.compute_coupler_measurement` directly with
|
|
their own clean point cloud.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 .io_utils import (
|
|
Artifacts,
|
|
load_point_cloud,
|
|
write_json,
|
|
write_point_cloud,
|
|
write_yaml,
|
|
)
|
|
from .metrics import compute_deflection_metrics
|
|
from .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json
|
|
from .structures import CouplerMeasurement
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cloud-only research 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 (speeds up + stabilises hole detection's
|
|
in-plane projection); the parsed GT is written as ``01_gt.json``
|
|
to ``out_dir`` for auditing.
|
|
- ``roi_spec`` / ``roi_json`` — ROI selector applied to the cloud
|
|
*before* denoising. If both omitted, the default ``mode='all'``
|
|
spec (pass-through) is used. ``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 --------------------------------------------------
|
|
raw_pts = load_point_cloud(input_cloud)
|
|
if roi_spec is None:
|
|
roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec()
|
|
pts, roi_diag = apply_roi(raw_pts, roi_spec)
|
|
write_point_cloud(pts, art.path("sampled", ext="ply"))
|
|
write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json")
|
|
|
|
# ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ----------
|
|
panel_seed: np.ndarray | None = None
|
|
if cad_stp_path is not None:
|
|
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) -----------------
|
|
denoised = denoise_cloud(pts, cfg.get("denoise", {}))
|
|
write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
|
|
|
|
# ---- 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 ---------------------------------------------
|
|
# Use PCA on the cloud itself for the projection seed (the cloud is
|
|
# still in the pre-Kabsch frame here; using the CAD normal would
|
|
# cause foreshortening bias). Register against the *deflected* GT
|
|
# holes so Kabsch only cancels scanner-pose jitter, not the panel
|
|
# deflection signal we want to measure.
|
|
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 for the metrics is the *deflected* panel: angles equal the
|
|
# injection, and the maximum-distance / maximum-x-deviation values are
|
|
# computed over the deflected 4-hole rectangle (the same primitive the
|
|
# algorithm later sees through the noise + jitter chain).
|
|
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 _try_load_normal_from_gt_json(path: str | Path) -> np.ndarray | None:
|
|
"""Read ``plane.normal`` from a GT JSON if present. Returns ``None`` otherwise."""
|
|
import json
|
|
|
|
try:
|
|
with Path(path).open("r", encoding="utf-8") as fh:
|
|
d = json.load(fh)
|
|
except Exception:
|
|
return None
|
|
n = d.get("plane", {}).get("normal")
|
|
if n is None or len(n) != 3:
|
|
return None
|
|
arr = np.asarray(n, dtype=np.float64)
|
|
norm = float(np.linalg.norm(arr))
|
|
if norm < 1e-9:
|
|
return None
|
|
return arr / norm
|
|
|
|
|
|
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",
|
|
]
|