Files
massive-cloud/alg/delivery_v1.2.1/underframe_deflection/pipeline.py
lipan 29f1d45d66 feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
2026-06-25 08:22:20 +00:00

289 lines
9.7 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 _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 ``<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)
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"]