"""flatness-suite 算法适配器 (v1.5)。 v1.5 变更: - 算法目录从 alg/point_porj → alg/平整度、平面度、轮廓测量v1.5 - 新增 car_part_v1/ 多车型 STEP CAD 模板 - run_contour 新增 Excel 报表输出(excel_output_path) - pipeline 新增 result_template_path / combined_context_pointcloud_path 参数 约定: - module=all:plane + curvature(contour 需要 CAD STL,缺失时跳过) - module=plane:只跑平面拟合 - module=curvature:只跑曲率估计 - module=contour:需要 CAD STL,缺失时报错 """ from __future__ import annotations import copy import os import sys import traceback from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).parent)) from _plugin_io import ( emit_progress, first_pointcloud, load_input, write_error_result, write_result, ) def _safe_float(v: object) -> float | None: try: return float(v) # type: ignore[arg-type] except (TypeError, ValueError): return None def _find_alg_dir(alg_root: str) -> Path: """定位平整度算法目录,支持多个历史版本路径,优先使用最新版。""" candidates = [ Path(alg_root) / "平整度、平面度、轮廓测量v1.5", Path(alg_root) / "point_porj", Path(alg_root) / "平整度、平面度、轮廓测量v1.2" / "point_porj", Path(alg_root) / "平整度、平面度、轮廓测量" / "point_porj", ] for c in candidates: if c.is_dir() and (c / "pipeline.py").exists(): return c raise RuntimeError( f"找不到平整度算法目录,已尝试:\n" + "\n".join(f" {c}" for c in candidates) + "\n请确认 alg/ 下已解压 平整度、平面度、轮廓测量v1.5/。" ) def _build_pipeline(alg_dir: Path, pc_path: str, out_dir: Path, params: dict) -> Any: """初始化 MeasurementPipeline,注入点云路径和输出路径。""" # 把 alg_dir 加到 sys.path,让 pipeline.py 能 import 同级模块 alg_dir_str = str(alg_dir) if alg_dir_str not in sys.path: sys.path.insert(0, alg_dir_str) from pipeline import MeasurementPipeline # type: ignore # 加载内置 config.yaml,然后覆盖关键路径 pipeline = MeasurementPipeline(config_path=None) # 注入点云路径 pipeline.config["paths"]["pointcloud"] = pc_path # 注入输出路径(curvature_output 写到 outDir) pipeline.config["paths"]["curvature_output"] = str(out_dir / "curvature.ply") # 关闭可视化(无显示器) pipeline.config["visualization"]["enabled"] = False # 用户参数覆盖 voxel_size = _safe_float(params.get("voxelSizeMm")) if voxel_size is not None: pipeline.config["filters"]["voxel_size"] = voxel_size / 1000.0 # mm → m threshold_mm = _safe_float(params.get("thresholdMm")) if threshold_mm is not None: pipeline.config["plane"]["threshold_mm"] = threshold_mm return pipeline def main() -> int: payload = load_input(sys.argv) inputs = payload.get("inputs") or {} config = inputs.get("config") or {} out_dir = Path(payload["outDir"]) out_dir.mkdir(parents=True, exist_ok=True) try: alg_root = os.environ.get("ALG_ROOT") or os.environ.get("ALG_ROOT_HOST") or "/alg" emit_progress(0.05, "定位 point_porj 算法目录") alg_dir = _find_alg_dir(alg_root) pc_path_raw = first_pointcloud(payload) emit_progress(0.10, f"读取点云:{os.path.basename(pc_path_raw)}") # ── ROI 裁剪(整车点云场景下必须填写,否则算法极慢)────────────────────── def _f(key: str) -> float | None: v = config.get(key) try: return float(v) if v is not None and str(v).strip() != "" else None except (TypeError, ValueError): return None x_min, x_max = _f("roiXMin"), _f("roiXMax") y_min, y_max = _f("roiYMin"), _f("roiYMax") z_min, z_max = _f("roiZMin"), _f("roiZMax") has_roi = any(v is not None for v in (x_min, x_max, y_min, y_max, z_min, z_max)) if has_roi: emit_progress(0.12, "ROI 裁剪中…") import numpy as np import open3d as o3d # type: ignore pcd = o3d.io.read_point_cloud(pc_path_raw) pts = np.asarray(pcd.points) mask = np.ones(len(pts), dtype=bool) if x_min is not None: mask &= pts[:, 0] >= x_min if x_max is not None: mask &= pts[:, 0] <= x_max if y_min is not None: mask &= pts[:, 1] >= y_min if y_max is not None: mask &= pts[:, 1] <= y_max if z_min is not None: mask &= pts[:, 2] >= z_min if z_max is not None: mask &= pts[:, 2] <= z_max n_total, n_crop = len(pts), int(mask.sum()) if n_crop == 0: raise RuntimeError( f"ROI 裁剪后无点(原始 {n_total} 点),请检查坐标范围是否正确。" ) cropped = pcd.select_by_index(np.where(mask)[0]) roi_ply = out_dir / "_roi_cropped.ply" # 二进制写出,算法用 o3d 读所以支持二进制,比 ASCII 快很多 o3d.io.write_point_cloud(str(roi_ply), cropped, write_ascii=False) pc_path_raw = str(roi_ply) emit_progress(0.14, f"ROI 裁剪完成:{n_total} → {n_crop} 点") # v1.5 用 o3d.io.read_point_cloud 读取,直接支持二进制 PLY,无需转 ASCII pc_path = pc_path_raw module = str(config.get("module") or "all") emit_progress(0.15, f"初始化 MeasurementPipeline(module={module})") pipeline = _build_pipeline(alg_dir, pc_path, out_dir, config) measurements: list[dict] = [] extra: dict[str, Any] = {"module": module} skipped: list[str] = [] # ── plane ────────────────────────────────────────────────────────────── if module in ("all", "plane"): emit_progress(0.30, "平面拟合 (RANSAC)") try: res = pipeline.run_plane( pointcloud_path=pc_path, threshold_mm=_safe_float(config.get("thresholdMm")), no_visualize=True, ) pr = res.get("plane_result") if pr is not None: rmse_mm = float(pr.rmse) * 1000.0 # m → mm measurements += [ { "name": "平面 RMSE", "value": round(rmse_mm, 4), "unit": "mm", "status": "pass" if rmse_mm <= 1.0 else "warning" if rmse_mm <= 2.0 else "fail", }, { "name": "内点率", "value": round(float(pr.inlier_ratio), 2), "unit": "%", "status": "pass" if pr.inlier_ratio >= 80 else "warning" if pr.inlier_ratio >= 60 else "fail", }, { "name": "法向角误差", "value": round(float(pr.normal_angle_deg), 4), "unit": "deg", "status": "info", }, ] extra["planeModel"] = list(pr.model) if pr.model else None except Exception as exc: skipped.append(f"plane: {exc}") # ── curvature ────────────────────────────────────────────────────────── if module in ("all", "curvature"): emit_progress(0.55, "曲率估计") try: res = pipeline.run_curvature( pointcloud_path=pc_path, no_visualize=True, ) curvatures = res.get("curvatures") if curvatures is not None: import numpy as np measurements += [ { "name": "曲率均值", "value": round(float(np.mean(curvatures)), 6), "unit": "1/mm", "status": "info", }, { "name": "曲率最大值", "value": round(float(np.max(curvatures)), 6), "unit": "1/mm", "status": "info", }, ] # 保存曲率点云路径 curv_ply = out_dir / "curvature.ply" if curv_ply.exists(): extra["curvaturePly"] = "curvature.ply" except Exception as exc: skipped.append(f"curvature: {exc}") # ── contour ──────────────────────────────────────────────────────────── if module in ("all", "contour"): emit_progress(0.75, "轮廓-平整度(需 CAD STL)") cad_path = inputs.get("cadPath") or None mesh_path = inputs.get("meshPath") or None if not cad_path and not mesh_path: skipped.append("contour: 未提供 CAD STL/STEP,跳过") else: try: save_path = str(out_dir / "contour.ply") excel_path = str(out_dir / "contour.xlsx") res = pipeline.run_contour( pointcloud_path=pc_path, cad_path=cad_path, mesh_path=mesh_path, save_path=save_path, excel_output_path=excel_path, no_visualize=True, ) stats = res.get("distance_stats") for key, label, unit in [ ("mean", "轮廓偏差均值", "mm"), ("max", "轮廓偏差最大值", "mm"), ("std", "轮廓偏差标准差", "mm"), ]: v = _safe_float(getattr(stats, key, None) if stats is not None else None) if v is not None: measurements.append({ "name": label, "value": round(v, 4), "unit": unit, "status": "info", }) if Path(save_path).exists(): extra["contourPly"] = "contour.ply" except Exception as exc: skipped.append(f"contour: {exc}") emit_progress(0.95, "整理结果") # 3D 叠加层:优先 contour PLY,其次 curvature PLY overlay3d: dict = {"kind": "none"} for ply_key in ("contourPly", "curvaturePly"): if ply_key in extra: overlay3d = {"kind": "vertexColors", "url": extra[ply_key]} break if skipped: extra["skipped"] = skipped status_vals = [m.get("status") for m in measurements] overall = ( "fail" if "fail" in status_vals else "warning" if "warning" in status_vals else "completed" ) payload_out = { "schemaVersion": 1, "status": overall, "message": ( f"完成 {len(measurements)} 项测量。" + (f" 跳过:{'; '.join(skipped)}" if skipped else "") ), "measurements": measurements, "overlay3d": overlay3d, "extra": extra, } write_result(out_dir, payload_out) emit_progress(1.0, "完成") return 0 except Exception as exc: traceback.print_exc() write_error_result( out_dir, f"flatness-suite 计算失败:{exc}", extra={"traceback": traceback.format_exc()}, ) return 1 if __name__ == "__main__": sys.exit(main())