468 lines
20 KiB
Python
468 lines
20 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=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(与配准采样缓存同目录,命名规则:<stem>_<digest>_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}")
|
||
res = pipeline.run_contour_batch(
|
||
all_tasks=True,
|
||
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())
|