feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
This commit is contained in:
288
alg/delivery_v1.2.1/underframe_deflection/pipeline.py
Normal file
288
alg/delivery_v1.2.1/underframe_deflection/pipeline.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user