feat: 升级算法包 delivery_v1.1 → v1.2,新增任务级 CAD 与测距面板

- 算法包替换为 delivery_v1.2(coupler_panel 新增 autotune/layer_select,underframe_deflection 新增 single_beam 管线),删除 delivery_v1.1
- manifests/adapters 的 PYTHONPATH 与版本说明同步更新到 v1.2
- 新增任务级 CAD 文件支持(task_cad_files 表、上传/读取路由、TaskManager 全选与加载显示)
- 新增 train-measurement 自定义前端面板(YAML 编辑器 + 可视化 ROI 配置 + 3D 取点)
- PointCloudViewer 支持 PLY 叠加层渲染与 ROI 拾取
- Dockerfile 改用 Miniforge 安装 pythonocc-core,修复 libgomp/libstdc++ 依赖
This commit is contained in:
2026-06-11 02:30:04 +00:00
parent 16811100d1
commit cec0766e67
143 changed files with 3087 additions and 12604 deletions

View File

@@ -0,0 +1,285 @@
"""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"]