Files
massive-cloud/server/plugins/adapters/flatness_suite_run.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

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())