算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""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())
|