"""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=contour:轮廓/平整度,需要 CAD STEP(推荐,算法方基准 3min25s) - module=plane:只跑平面拟合 - module=curvature:只跑曲率估计(大点云极慢,慎用) - module=all:plane + curvature + contour(已从前端选项移除,大点云不可用) """ from __future__ import annotations import copy import hashlib 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, ) from flatness_suite_measurements import measurements_from_batch_result def _safe_float(v: object) -> float | None: try: return float(v) # type: ignore[arg-type] except (TypeError, ValueError): return None def _config_bool(config: dict, key: str, default: bool = False) -> bool: value = config.get(key, default) if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) def _cleanup_temporary_artifacts(out_dir: Path) -> None: # 仅删除适配器自己创建、且不会写入 result.json 的固定临时文件。 # 不使用通配符,避免误删算法结果或用户要求保留的产物。 for name in ("_roi_cropped.ply",): try: (out_dir / name).unlink(missing_ok=True) except OSError as exc: print(f"[flatness-suite] 清理临时文件失败 {name}: {exc}", file=sys.stderr) def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path: """根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。 优先返回配准阶段已生成的 mesh 缓存(_mesh.ply), 其次返回 flatness 自己的缓存路径,均放在 CAD 同目录的 .cad_sample_cache/ 下。 """ stat = os.stat(cad_path) cache_key = "|".join([ os.path.abspath(cad_path), str(stat.st_size), str(stat.st_mtime_ns), f"{mesh_deflection:.12g}", ]) digest = hashlib.sha1(cache_key.encode()).hexdigest()[:16] cad_stem = Path(cad_path).stem shared_dir = Path(cad_path).parent / ".cad_sample_cache" # 配准阶段已缓存的 mesh(与配准采样缓存同目录,命名规则:__mesh.ply) registration_mesh = shared_dir / f"{cad_stem}_{digest}_mesh.ply" if registration_mesh.exists(): return registration_mesh # flatness 自己写缓存的目标路径 try: shared_dir.mkdir(parents=True, exist_ok=True) test = shared_dir / ".write_test" test.touch() test.unlink() return shared_dir / f"{cad_stem}_{digest}_mesh.ply" except OSError: return Path(cad_path).parent / f"{cad_stem}_{digest}_mesh.ply" 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, "定位算法目录") alg_dir = _find_alg_dir(alg_root) # ── 判断走单点云模式还是 datasets 批量模式 ────────────────────────────── pc_path_raw = first_pointcloud(payload) use_batch_mode = bool(config.get("useBatchDatasets", True)) # ── 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 "contour") emit_progress(0.15, f"初始化 MeasurementPipeline(module={module})") pipeline = _build_pipeline(alg_dir, pc_path if not use_batch_mode else str(alg_dir), out_dir, config) # ── datasets 批量模式(useBatchDatasets=true 时) ────────────────────── if use_batch_mode and module == "contour": emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云") data_root = alg_dir / "datasets" # combined_contour.ply 可能包含完整整车点云,单次可达十几 GB。 # 平台页面只使用各面板 *_contour.ply,因此默认从源头跳过组合文件; # 仅在用户显式开启时才生成,并作为可下载产物登记。 keep_combined_contour = _config_bool(config, "keepCombinedContour", False) context_pc = None if keep_combined_contour: context_pc = (config.get("contextPointCloudPath") or inputs.get("contextPointCloudPath") or pc_path_raw or None) save_dir = str(out_dir / "contour_batch") excel_path = str(out_dir / "contour.xlsx") result_template_path = alg_dir / "excel" / "测量结果.xlsx" if not result_template_path.is_file(): raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}") selected_task_nos = config.get("demoTaskNos") selected_task_ids = ( [str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()] if isinstance(selected_task_nos, list) else None ) if selected_task_ids == []: raise RuntimeError("请至少选择一个轮廓测量任务") res = pipeline.run_contour_batch( task_ids=selected_task_ids, all_tasks=selected_task_ids is None, data_root=str(data_root), pointcloud_kind="noise", point_chunk_size=8192, triangle_chunk_size=512, save_path=save_dir, excel_output_path=excel_path, result_template_path=str(result_template_path), combined_context_pointcloud_path=context_pc, save_combined_visualization=keep_combined_contour, no_visualize=True, ) emit_progress(0.95, "整理批量结果") measurements = measurements_from_batch_result(res, result_template_path) # 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色) panel_plys = sorted([ f"contour_batch/{p.name}" for p in (out_dir / "contour_batch").glob("*_contour.ply") if p.name != "combined_contour.ply" ]) if panel_plys: overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys} else: overlay3d = {"kind": "none"} combined_rel = "contour_batch/combined_contour.ply" result_files = [combined_rel] if keep_combined_contour and (out_dir / combined_rel).is_file() else [] write_result(out_dir, { "status": "completed", "message": f"批量完成 {len(res.get('rows') or [])} 个面板测量", "measurements": measurements, "overlay3d": overlay3d, "files": result_files, "extra": { "module": "contour_batch", "excelPath": "contour.xlsx", "combinedContourKept": bool(result_files), }, }) return 0 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 STEP)") 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") # STEP mesh 缓存:避免每次重新三角化 442 MB+ 整车 STEP cached_mesh_ply: str | None = None if cad_path and not mesh_path: _deflection = float( _safe_float(config.get("meshDeflection")) or 0.1 ) _cache_ply = _mesh_cache_path(cad_path, _deflection) if _cache_ply.exists(): emit_progress(0.76, "使用已缓存 CAD mesh,跳过 STEP 三角化") mesh_path = str(_cache_ply) cad_path = None else: # 先单独做三角化,完成后立即缓存,再跑点距计算 # 这样即使点距阶段 OOM,下次也不用重新三角化 emit_progress(0.76, "首次三角化 CAD STEP,结果将缓存供后续复用…") try: import open3d as o3d # type: ignore _orig_cad = cad_path sys.path.insert(0, str(alg_dir)) from cad_converter import CADConverter # type: ignore _converter = CADConverter() _verts, _faces = _converter.step_to_numpy_mesh( _orig_cad, mesh_deflection=_deflection ) _mesh_o3d = o3d.geometry.TriangleMesh() _mesh_o3d.vertices = o3d.utility.Vector3dVector(_verts) _mesh_o3d.triangles = o3d.utility.Vector3iVector(_faces.astype(int)) o3d.io.write_triangle_mesh(str(_cache_ply), _mesh_o3d) emit_progress(0.80, f"CAD mesh 已缓存:{_cache_ply.name},开始点距计算…") mesh_path = str(_cache_ply) cad_path = None except Exception as _tri_exc: emit_progress(0.76, f"三角化缓存写入失败({_tri_exc}),继续直接跑…") context_pc_path = (inputs.get("contextPointCloudPath") or None) res = pipeline.run_contour( pointcloud_path=pc_path, cad_path=cad_path, mesh_path=mesh_path, point_chunk_size=8192, triangle_chunk_size=512, save_path=save_path, excel_output_path=excel_path, combined_context_pointcloud_path=context_pc_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": "ply", "file": 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 finally: _cleanup_temporary_artifacts(out_dir) if __name__ == "__main__": sys.exit(main())