feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
252
server/plugins/adapters/_plugin_io.py
Normal file
252
server/plugins/adapters/_plugin_io.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""算法插件适配器共用 I/O 工具。
|
||||
|
||||
约定(与 server/plugins/types.ts 对齐):
|
||||
- 子进程入口的唯一参数是 ``input.json`` 的绝对路径;
|
||||
- 子进程通过 stdout 单行 JSON 上报进度:``{"type":"progress","percent":42,"message":"..."}``
|
||||
- 子进程在 ``outDir/result.json`` 写最终结果;
|
||||
- stderr 仅做日志(runner 会原样转发)。
|
||||
|
||||
所有适配器都很薄:解析 input.json → 调真实算法 API → 写 result.json。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_input(argv: list[str]) -> dict[str, Any]:
|
||||
"""读取 input.json。"""
|
||||
if len(argv) < 2:
|
||||
emit_error("usage: <adapter>.py <input.json>", exit_code=2)
|
||||
path = Path(argv[1])
|
||||
if not path.exists():
|
||||
emit_error(f"input.json 不存在: {path}", exit_code=2)
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def emit_progress(percent: float, message: str = "") -> None:
|
||||
"""以单行 JSON 上报进度。runner 解析后写入数据库。
|
||||
|
||||
percent 接受 0~1 或 0~100,runner 端会归一化。
|
||||
"""
|
||||
line = {"type": "progress", "percent": float(percent), "message": message}
|
||||
sys.stdout.write(json.dumps(line, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def write_result(out_dir: str | os.PathLike, payload: dict[str, Any]) -> Path:
|
||||
"""写 result.json 到 outDir,并强制 schemaVersion / status 字段存在。
|
||||
NaN/Infinity 会被替换为 null,确保输出合法 JSON。
|
||||
"""
|
||||
import math
|
||||
|
||||
def _sanitize(obj: Any) -> Any:
|
||||
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
|
||||
return None
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
out_path = Path(out_dir) / "result.json"
|
||||
payload.setdefault("schemaVersion", 1)
|
||||
payload.setdefault("status", "completed")
|
||||
out_path.write_text(json.dumps(_sanitize(payload), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def write_error_result(out_dir: str | os.PathLike, message: str, extra: dict | None = None) -> None:
|
||||
"""统一错误写出:result.json 仍然存在,runner 据此把数据库改成 error。"""
|
||||
payload: dict[str, Any] = {
|
||||
"schemaVersion": 1,
|
||||
"status": "error",
|
||||
"message": message,
|
||||
}
|
||||
if extra:
|
||||
payload["extra"] = extra
|
||||
Path(out_dir).mkdir(parents=True, exist_ok=True)
|
||||
(Path(out_dir) / "result.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def emit_error(message: str, exit_code: int = 1) -> None:
|
||||
sys.stderr.write(f"[adapter-error] {message}\n")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def first_pointcloud(input_payload: dict[str, Any]) -> str:
|
||||
"""取 inputs.pointCloudPaths 的第一个;无则报错。"""
|
||||
paths = (input_payload.get("inputs") or {}).get("pointCloudPaths") or []
|
||||
if not paths:
|
||||
raise RuntimeError("inputs.pointCloudPaths 为空,请确认任务已上传点云。")
|
||||
return str(paths[0])
|
||||
|
||||
|
||||
def now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
# ─── Binary PLY → ASCII PLY 转换 ─────────────────────────────────────────────
|
||||
|
||||
def ensure_ascii_ply(src: str | os.PathLike, cache_dir: str | os.PathLike) -> str:
|
||||
"""如果 src 是二进制 PLY,把它转成 ASCII PLY 写到 cache_dir 并返回新路径。
|
||||
如果已经是 ASCII PLY 或其他格式,直接返回原路径。
|
||||
|
||||
不依赖 open3d / plyfile,只用 numpy + struct(标准库)。
|
||||
"""
|
||||
src = Path(src)
|
||||
suffix = src.suffix.lower()
|
||||
if suffix != ".ply":
|
||||
return str(src)
|
||||
|
||||
# 读文件头(最多 16 KB)
|
||||
HEADER_MAX = 16 * 1024
|
||||
with open(src, "rb") as fh:
|
||||
raw = fh.read(HEADER_MAX)
|
||||
|
||||
try:
|
||||
header_text = raw.decode("ascii", errors="replace")
|
||||
except Exception:
|
||||
return str(src) # 无法解析,原样返回
|
||||
|
||||
if not header_text.startswith("ply"):
|
||||
return str(src)
|
||||
|
||||
end_marker = "end_header"
|
||||
end_idx = header_text.find(end_marker)
|
||||
if end_idx == -1:
|
||||
return str(src)
|
||||
|
||||
# 找 end_header 后的换行
|
||||
data_start = end_idx + len(end_marker)
|
||||
if header_text[data_start : data_start + 2] == "\r\n":
|
||||
data_start += 2
|
||||
elif header_text[data_start] in ("\r", "\n"):
|
||||
data_start += 1
|
||||
|
||||
header_lines = header_text[:end_idx].splitlines()
|
||||
|
||||
# 解析 format
|
||||
fmt = "ascii"
|
||||
for line in header_lines:
|
||||
parts = line.strip().split()
|
||||
if parts and parts[0] == "format":
|
||||
fmt = parts[1] if len(parts) > 1 else "ascii"
|
||||
break
|
||||
|
||||
if fmt == "ascii":
|
||||
return str(src) # 已经是 ASCII,无需转换
|
||||
|
||||
# 解析 vertex 属性
|
||||
import struct as _struct
|
||||
import numpy as _np
|
||||
|
||||
_PLY_DTYPE: dict[str, tuple[str, int]] = {
|
||||
"char": ("b", 1), "int8": ("b", 1),
|
||||
"uchar": ("B", 1), "uint8": ("B", 1),
|
||||
"short": ("h", 2), "int16": ("h", 2),
|
||||
"ushort": ("H", 2), "uint16": ("H", 2),
|
||||
"int": ("i", 4), "int32": ("i", 4),
|
||||
"uint": ("I", 4), "uint32": ("I", 4),
|
||||
"float": ("f", 4), "float32": ("f", 4),
|
||||
"double": ("d", 8), "float64": ("d", 8),
|
||||
}
|
||||
|
||||
vertex_count = 0
|
||||
props: list[tuple[str, str, int]] = [] # (name, fmt_char, byte_size)
|
||||
in_vertex = False
|
||||
for line in header_lines:
|
||||
parts = line.strip().split()
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0] == "element":
|
||||
in_vertex = parts[1] == "vertex" if len(parts) > 1 else False
|
||||
if in_vertex and len(parts) > 2:
|
||||
vertex_count = int(parts[2])
|
||||
elif parts[0] == "property" and in_vertex:
|
||||
if parts[1] == "list":
|
||||
in_vertex = False
|
||||
continue
|
||||
ptype = parts[1] if len(parts) > 1 else "float"
|
||||
pname = parts[2] if len(parts) > 2 else ""
|
||||
fc, bs = _PLY_DTYPE.get(ptype, ("f", 4))
|
||||
props.append((pname, fc, bs))
|
||||
|
||||
if vertex_count == 0 or not props:
|
||||
return str(src)
|
||||
|
||||
# Find x, y, z offsets
|
||||
x_off = y_off = z_off = -1
|
||||
x_fc = y_fc = z_fc = "f"
|
||||
byte_off = 0
|
||||
for pname, fc, bs in props:
|
||||
n = pname.lower()
|
||||
if n == "x":
|
||||
x_off, x_fc = byte_off, fc
|
||||
elif n == "y":
|
||||
y_off, y_fc = byte_off, fc
|
||||
elif n == "z":
|
||||
z_off, z_fc = byte_off, fc
|
||||
byte_off += bs
|
||||
|
||||
if x_off < 0 or y_off < 0 or z_off < 0:
|
||||
return str(src) # 找不到 xyz,原样返回
|
||||
|
||||
bytes_per_vertex = sum(bs for _, _, bs in props)
|
||||
little_endian = fmt != "binary_big_endian"
|
||||
endian = "<" if little_endian else ">"
|
||||
|
||||
# 读整个数据区
|
||||
file_size = src.stat().st_size
|
||||
data_bytes = file_size - data_start
|
||||
expected = vertex_count * bytes_per_vertex
|
||||
|
||||
with open(src, "rb") as fh:
|
||||
fh.seek(data_start)
|
||||
data = fh.read(expected)
|
||||
|
||||
if len(data) < expected:
|
||||
return str(src) # 文件截断,原样返回
|
||||
|
||||
# 用 numpy structured array 解析
|
||||
dtype_fields = []
|
||||
for pname, fc, bs in props:
|
||||
np_type = endian + fc
|
||||
dtype_fields.append((pname or f"_f{len(dtype_fields)}", np_type))
|
||||
|
||||
try:
|
||||
arr = _np.frombuffer(data, dtype=_np.dtype(dtype_fields))
|
||||
xs = arr[props[0][0] if props[0][0] else "_f0"] # fallback
|
||||
# 直接按名字取
|
||||
x_name = props[[i for i, (n, _, _) in enumerate(props) if n.lower() == "x"][0]][0]
|
||||
y_name = props[[i for i, (n, _, _) in enumerate(props) if n.lower() == "y"][0]][0]
|
||||
z_name = props[[i for i, (n, _, _) in enumerate(props) if n.lower() == "z"][0]][0]
|
||||
xs = arr[x_name].astype(_np.float64)
|
||||
ys = arr[y_name].astype(_np.float64)
|
||||
zs = arr[z_name].astype(_np.float64)
|
||||
except Exception:
|
||||
return str(src)
|
||||
|
||||
# 写 ASCII PLY
|
||||
cache_dir = Path(cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = cache_dir / (src.stem + "_ascii.ply")
|
||||
|
||||
with open(out_path, "w", encoding="ascii") as fh:
|
||||
fh.write("ply\nformat ascii 1.0\n")
|
||||
fh.write(f"element vertex {vertex_count}\n")
|
||||
fh.write("property double x\nproperty double y\nproperty double z\n")
|
||||
fh.write("end_header\n")
|
||||
for x, y, z in zip(xs, ys, zs):
|
||||
fh.write(f"{x:.10g} {y:.10g} {z:.10g}\n")
|
||||
|
||||
return str(out_path)
|
||||
|
||||
159
server/plugins/adapters/coupler_panel_run.py
Normal file
159
server/plugins/adapters/coupler_panel_run.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""coupler-panel 算法适配器 (v1.1)。
|
||||
|
||||
v1.1 变更:
|
||||
- 入口从 compute_coupler_measurement → run_coupler_measurement_pipeline
|
||||
- 新增 D_y_max_mm / D_z_max_mm 字段
|
||||
- 支持可选 CAD STP 路径(cad_stp_path)和 ROI JSON(roi_json)
|
||||
- PYTHONPATH 指向 delivery_v1.1/coupler_panel_release
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from _plugin_io import (
|
||||
emit_progress,
|
||||
ensure_ascii_ply,
|
||||
first_pointcloud,
|
||||
load_input,
|
||||
write_error_result,
|
||||
write_result,
|
||||
)
|
||||
|
||||
|
||||
def _status_for(value: float, tolerance: float | None) -> str:
|
||||
if tolerance is None:
|
||||
return "info"
|
||||
if abs(value) <= tolerance:
|
||||
return "pass"
|
||||
if abs(value) <= tolerance * 1.5:
|
||||
return "warning"
|
||||
return "fail"
|
||||
|
||||
|
||||
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:
|
||||
emit_progress(0.05, "加载 coupler_panel v1.1 算法包")
|
||||
from coupler_panel.pipeline import run_coupler_measurement_pipeline # type: ignore
|
||||
|
||||
pc_path = first_pointcloud(payload)
|
||||
emit_progress(0.15, f"读取点云:{os.path.basename(pc_path)}")
|
||||
|
||||
# 若是二进制 PLY,先转成 ASCII PLY(算法内置 reader 不依赖 open3d)
|
||||
pc_path = ensure_ascii_ply(pc_path, out_dir / "_ply_cache")
|
||||
|
||||
profile = str(config.get("profile") or "auto")
|
||||
right_axis_sign = int(config.get("rightAxisSign") or 1)
|
||||
|
||||
# 可选 CAD STP 路径(前端传入 cadPath 时使用)
|
||||
cad_stp_path = inputs.get("cadPath") or None
|
||||
|
||||
# 可选 ROI JSON 路径(前端传入 roiJson 时使用)
|
||||
roi_json = inputs.get("roiJson") or None
|
||||
|
||||
emit_progress(0.25, "调用 run_coupler_measurement_pipeline")
|
||||
|
||||
def _on_progress(stage: str, fraction: float) -> None:
|
||||
emit_progress(0.25 + min(1.0, max(0.0, fraction)) * 0.70, stage)
|
||||
|
||||
result = run_coupler_measurement_pipeline(
|
||||
input_cloud=pc_path,
|
||||
out_dir=str(out_dir),
|
||||
profile=profile,
|
||||
cad_stp_path=cad_stp_path,
|
||||
roi_json=roi_json,
|
||||
progress=_on_progress,
|
||||
)
|
||||
|
||||
emit_progress(0.97, "整理结果")
|
||||
|
||||
# right_axis_sign 影响 theta_right_deg 的符号约定
|
||||
theta_right = float(result.theta_right_deg) * right_axis_sign
|
||||
theta_ref = float(result.theta_ref_deg)
|
||||
dx_y = float(result.dx_y_max_mm)
|
||||
dx_z = float(result.dx_z_max_mm)
|
||||
D_y = float(result.D_y_max_mm)
|
||||
D_z = float(result.D_z_max_mm)
|
||||
|
||||
measurements = [
|
||||
{
|
||||
"name": "偏转角 θ_right (右向)",
|
||||
"value": theta_right,
|
||||
"unit": "deg",
|
||||
"tolerance": 0.5,
|
||||
"status": _status_for(theta_right, 0.5),
|
||||
},
|
||||
{
|
||||
"name": "偏转角 θ_ref (参考向)",
|
||||
"value": theta_ref,
|
||||
"unit": "deg",
|
||||
"tolerance": 0.5,
|
||||
"status": _status_for(theta_ref, 0.5),
|
||||
},
|
||||
{
|
||||
"name": "最大 x 偏差 dx_y",
|
||||
"value": dx_y,
|
||||
"unit": "mm",
|
||||
"tolerance": 1.0,
|
||||
"status": _status_for(dx_y, 1.0),
|
||||
},
|
||||
{
|
||||
"name": "最大 x 偏差 dx_z",
|
||||
"value": dx_z,
|
||||
"unit": "mm",
|
||||
"tolerance": 1.0,
|
||||
"status": _status_for(dx_z, 1.0),
|
||||
},
|
||||
{
|
||||
"name": "面板 y 向最大偏差 D_y",
|
||||
"value": D_y,
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
},
|
||||
{
|
||||
"name": "面板 z 向最大偏差 D_z",
|
||||
"value": D_z,
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
},
|
||||
]
|
||||
|
||||
payload_out = {
|
||||
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
||||
"status": "completed",
|
||||
"measurements": measurements,
|
||||
"overlay3d": {"kind": "none"},
|
||||
"extra": {
|
||||
"algorithmVersion": getattr(result, "algorithm_version", None),
|
||||
"qualityFlags": list(result.quality_flags or []),
|
||||
"normal": list(result.normal) if result.normal else None,
|
||||
"centerC": list(result.center_C) if result.center_C else None,
|
||||
"report": result.report if isinstance(result.report, dict) else None,
|
||||
},
|
||||
}
|
||||
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"coupler-panel 计算失败:{exc}",
|
||||
extra={"traceback": traceback.format_exc()},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
271
server/plugins/adapters/flatness_suite_run.py
Normal file
271
server/plugins/adapters/flatness_suite_run.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""flatness-suite 算法适配器 (v1.2)。
|
||||
|
||||
v1.2 变更:
|
||||
- 算法目录从 alg/平整度、平面度、轮廓测量/ → alg/point_porj/
|
||||
- 入口从独立脚本(importlib 加载)→ MeasurementPipeline 类
|
||||
- run_plane / run_curvature / run_contour 统一通过 MeasurementPipeline 调用
|
||||
- 点云路径通过 config["paths"]["pointcloud"] 注入,不再依赖 open3d 直接读取
|
||||
|
||||
约定:
|
||||
- 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,
|
||||
ensure_ascii_ply,
|
||||
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:
|
||||
"""定位 point_porj 目录,支持多种可能的路径。"""
|
||||
candidates = [
|
||||
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"找不到 point_porj 算法目录,已尝试:\n"
|
||||
+ "\n".join(f" {c}" for c in candidates)
|
||||
+ "\n请确认 alg/ 下已解压 point_porj/。"
|
||||
)
|
||||
|
||||
|
||||
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)}")
|
||||
|
||||
# 二进制 PLY → ASCII PLY(算法内置 reader 不依赖 open3d)
|
||||
pc_path = ensure_ascii_ply(pc_path_raw, out_dir / "_ply_cache")
|
||||
|
||||
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())
|
||||
207
server/plugins/adapters/train_measurement_run.py
Normal file
207
server/plugins/adapters/train_measurement_run.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""train-measurement 算法适配器 (v1.1)。
|
||||
|
||||
读 input.json → 调 TaskManager(来自 alg/点云测距v1.1)→ 写统一 result.json。
|
||||
|
||||
v1.1 变更:
|
||||
- 路径从 260505列车点云测距v1 → 点云测距v1.1
|
||||
- 算法包现在是可安装的 Python 包(有 __init__.py),PYTHONPATH 指向 src/
|
||||
- TRAIN_MEAS_ROOT 指向 train_measurement_system/(用于定位 config/measurements.yaml)
|
||||
- TaskManager.run_all_tasks() 返回结构不变,data 字段含 mean_distance / std_distance 等
|
||||
|
||||
约定:
|
||||
- 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中;
|
||||
- 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之;
|
||||
否则使用算法仓库自带的 config/measurements.yaml 作为模板。
|
||||
- 所有任务结果合成成 measurements[] 列表,附带可视化 PLY 路径到 extra。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from _plugin_io import (
|
||||
emit_progress,
|
||||
ensure_ascii_ply,
|
||||
first_pointcloud,
|
||||
load_input,
|
||||
write_error_result,
|
||||
write_result,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
emit_progress(0.05, "加载 train_measurement v1.1 算法包")
|
||||
|
||||
# TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system/
|
||||
train_root = Path(os.environ.get("TRAIN_MEAS_ROOT") or "")
|
||||
if not train_root.exists():
|
||||
# 回退:从 ALG_ROOT 推导
|
||||
alg_root = Path(os.environ.get("ALG_ROOT") or "/alg")
|
||||
train_root = alg_root / "点云测距v1.1" / "train_measurement_system"
|
||||
|
||||
if not train_root.exists():
|
||||
raise RuntimeError(
|
||||
f"找不到 train_measurement_system 目录:{train_root}。"
|
||||
"请确认 TRAIN_MEAS_ROOT 或 ALG_ROOT 环境变量已正确设置。"
|
||||
)
|
||||
|
||||
from task_manager import TaskManager # type: ignore
|
||||
|
||||
pc_path = first_pointcloud(payload)
|
||||
emit_progress(0.15, f"读取点云:{os.path.basename(pc_path)}")
|
||||
|
||||
# 若是二进制 PLY,先转成 ASCII PLY(算法内置 reader 不依赖 open3d)
|
||||
pc_path = ensure_ascii_ply(pc_path, out_dir / "_ply_cache")
|
||||
|
||||
# ── 准备 YAML 配置 ────────────────────────────────────────────────────
|
||||
config_preset = str(config.get("configPreset") or "builtin")
|
||||
custom_yaml_str = str(config.get("customYaml") or "").strip()
|
||||
|
||||
if config_preset == "custom" and custom_yaml_str:
|
||||
# 支持 JSON 格式(前端传来)和 YAML 格式
|
||||
yaml_path = out_dir / "custom_measurements.yaml"
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(custom_yaml_str)
|
||||
# JSON 格式:转成 YAML 写盘
|
||||
import yaml as _yaml
|
||||
yaml_path.write_text(_yaml.dump(parsed, allow_unicode=True, default_flow_style=False), encoding="utf-8")
|
||||
except Exception:
|
||||
# 已经是 YAML 格式,直接写盘
|
||||
yaml_path.write_text(custom_yaml_str, encoding="utf-8")
|
||||
else:
|
||||
# 使用算法仓库内置的 config/measurements.yaml
|
||||
yaml_path = train_root / "config" / "measurements.yaml"
|
||||
if not yaml_path.exists():
|
||||
raise RuntimeError(f"内置配置文件不存在:{yaml_path}")
|
||||
|
||||
emit_progress(0.20, f"加载配置:{yaml_path.name}")
|
||||
|
||||
# ── 初始化 TaskManager ────────────────────────────────────────────────
|
||||
tm = TaskManager()
|
||||
# 先注入点云路径,让 load_config 跳过 YAML 里的 pointcloud_file(开发测试路径)
|
||||
tm.pointcloud_path_override = pc_path
|
||||
if not tm.load_config(str(yaml_path)):
|
||||
raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}")
|
||||
|
||||
# 注入点云路径(覆盖 YAML 中的 pointcloud_file)
|
||||
tm.config["pointcloud_file"] = pc_path
|
||||
|
||||
# 注入可视化输出目录
|
||||
viz_dir = out_dir / "visualizations"
|
||||
viz_dir.mkdir(parents=True, exist_ok=True)
|
||||
for task in tm.tasks:
|
||||
if hasattr(task, "visualization_dir"):
|
||||
task.visualization_dir = str(viz_dir)
|
||||
|
||||
emit_progress(0.30, "加载点云(已在 load_config 中完成)")
|
||||
|
||||
emit_progress(0.40, f"执行 {len(tm.tasks)} 个测量任务")
|
||||
results = tm.run_all_tasks()
|
||||
|
||||
emit_progress(0.92, "整理结果")
|
||||
measurements: list[dict] = []
|
||||
clean_raw: list[dict] = []
|
||||
|
||||
for r in results:
|
||||
name = str(r.get("name") or "未命名任务")
|
||||
status_raw = r.get("status")
|
||||
data = r.get("data") if isinstance(r.get("data"), dict) else {}
|
||||
# 剥掉以 '_' 开头的私有 numpy 数组(不可 JSON 序列化)
|
||||
clean_data = {k: v for k, v in data.items() if not k.startswith("_")} if data else {}
|
||||
clean_raw.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": r.get("type"),
|
||||
"status": status_raw,
|
||||
"data": clean_data,
|
||||
"error": r.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
if status_raw == "success":
|
||||
value = clean_data.get("mean_distance")
|
||||
if value is None:
|
||||
value = clean_data.get("distance")
|
||||
measurements.append(
|
||||
{
|
||||
"name": name,
|
||||
"value": float(value) if isinstance(value, (int, float)) else float("nan"),
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
}
|
||||
)
|
||||
std = clean_data.get("std_distance")
|
||||
if isinstance(std, (int, float)):
|
||||
measurements.append(
|
||||
{
|
||||
"name": f"{name} · 标准差",
|
||||
"value": float(std),
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
}
|
||||
)
|
||||
# v1.1 新增字段:min/max distance
|
||||
for field_name, label in [
|
||||
("min_distance", "最小距离"),
|
||||
("max_distance", "最大距离"),
|
||||
]:
|
||||
v = clean_data.get(field_name)
|
||||
if isinstance(v, (int, float)):
|
||||
measurements.append(
|
||||
{
|
||||
"name": f"{name} · {label}",
|
||||
"value": float(v),
|
||||
"unit": "mm",
|
||||
"status": "info",
|
||||
}
|
||||
)
|
||||
else:
|
||||
measurements.append(
|
||||
{
|
||||
"name": name,
|
||||
"value": float("nan"),
|
||||
"unit": "mm",
|
||||
"status": "fail",
|
||||
}
|
||||
)
|
||||
|
||||
success_count = sum(1 for r in results if r.get("status") == "success")
|
||||
payload_out = {
|
||||
"schemaVersion": 1,
|
||||
"status": "completed",
|
||||
"message": f"共 {len(results)} 个任务,成功 {success_count} 个。",
|
||||
"measurements": measurements,
|
||||
"overlay3d": {"kind": "none"},
|
||||
"extra": {
|
||||
"rawTaskResults": clean_raw,
|
||||
"visualizationDir": "visualizations",
|
||||
},
|
||||
}
|
||||
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"train-measurement 计算失败:{exc}",
|
||||
extra={"traceback": traceback.format_exc()},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
180
server/plugins/adapters/underframe_deflection_run.py
Normal file
180
server/plugins/adapters/underframe_deflection_run.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""underframe-deflection 算法适配器 (v1.1)。
|
||||
|
||||
v1.1 变更:
|
||||
- 入口改为 run_measurement_pipeline(返回 dict,同时落盘 report.json / xlsx / PLY)
|
||||
- 新增 cad_path(CAD 基准 PLY/STL)和 roi_path(ROI JSON)可选输入
|
||||
- 新增 skip_registration / enable_bilateral 配置项
|
||||
- PYTHONPATH 指向 delivery_v1.1
|
||||
- 结果从 report dict 中读取,而非 DeflectionResult 对象字段
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from _plugin_io import (
|
||||
emit_progress,
|
||||
ensure_ascii_ply,
|
||||
first_pointcloud,
|
||||
load_input,
|
||||
write_error_result,
|
||||
write_result,
|
||||
)
|
||||
|
||||
|
||||
def _status_for(value: float | None, tolerance: float | None) -> str:
|
||||
if value is None or tolerance is None:
|
||||
return "info"
|
||||
if abs(value) <= tolerance:
|
||||
return "pass"
|
||||
if abs(value) <= tolerance * 1.5:
|
||||
return "warning"
|
||||
return "fail"
|
||||
|
||||
|
||||
def _safe_float(v: object) -> float | None:
|
||||
try:
|
||||
return float(v) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
emit_progress(0.05, "加载 underframe_deflection v1.1 算法包")
|
||||
from underframe_deflection.pipeline import run_measurement_pipeline # type: ignore
|
||||
|
||||
pc_path = first_pointcloud(payload)
|
||||
emit_progress(0.15, f"读取点云:{os.path.basename(pc_path)}")
|
||||
|
||||
# 若是二进制 PLY,先转成 ASCII PLY(算法内置 reader 不依赖 open3d)
|
||||
pc_path = ensure_ascii_ply(pc_path, out_dir / "_ply_cache")
|
||||
|
||||
profile = str(config.get("profile") or "auto")
|
||||
skip_registration = bool(config.get("skipRegistration") or False)
|
||||
enable_bilateral = bool(config.get("enableBilateral") if "enableBilateral" in config else True)
|
||||
|
||||
# 可选 CAD 基准(PLY/STL)和 ROI JSON
|
||||
cad_path = inputs.get("cadPath") or None
|
||||
roi_path = inputs.get("roiPath") or None
|
||||
|
||||
emit_progress(0.20, "调用 run_measurement_pipeline")
|
||||
|
||||
# run_measurement_pipeline 内部会打印进度到 stdout,
|
||||
# 我们在调用前后各报一次进度即可。
|
||||
report: dict = run_measurement_pipeline(
|
||||
input_cloud=pc_path,
|
||||
out_dir=str(out_dir),
|
||||
profile=profile,
|
||||
cad_path=cad_path,
|
||||
roi_path=roi_path,
|
||||
skip_registration=skip_registration,
|
||||
enable_bilateral=enable_bilateral,
|
||||
skip_excel=False,
|
||||
)
|
||||
|
||||
emit_progress(0.92, "整理结果")
|
||||
|
||||
# ── 从 report dict 中提取核心测量值 ──────────────────────────────────
|
||||
deflection = report.get("deflection") or {}
|
||||
left_sel = (deflection.get("left") or {}).get("selected_measurement") or {}
|
||||
right_sel = (deflection.get("right") or {}).get("selected_measurement") or {}
|
||||
|
||||
left_mm = _safe_float(left_sel.get("signed_deflection_mm"))
|
||||
right_mm = _safe_float(right_sel.get("signed_deflection_mm"))
|
||||
|
||||
registration = report.get("registration") or {}
|
||||
reg_rms = _safe_float(registration.get("rms_mm"))
|
||||
|
||||
quality_flags: list[str] = report.get("quality_flags") or []
|
||||
|
||||
# 容差:高铁底架挠度通常要求 ≤ 3 mm(可在 manifest inputs 中暴露)
|
||||
tol = _safe_float(config.get("toleranceMm")) or 3.0
|
||||
|
||||
measurements = [
|
||||
{
|
||||
"name": "左梁挠度",
|
||||
"value": left_mm if left_mm is not None else float("nan"),
|
||||
"unit": "mm",
|
||||
"tolerance": tol,
|
||||
"status": _status_for(left_mm, tol),
|
||||
},
|
||||
{
|
||||
"name": "右梁挠度",
|
||||
"value": right_mm if right_mm is not None else float("nan"),
|
||||
"unit": "mm",
|
||||
"tolerance": tol,
|
||||
"status": _status_for(right_mm, tol),
|
||||
},
|
||||
]
|
||||
|
||||
if reg_rms is not None:
|
||||
measurements.append(
|
||||
{
|
||||
"name": "配准 RMS",
|
||||
"value": reg_rms,
|
||||
"unit": "mm",
|
||||
"status": "pass" if reg_rms <= 1.0 else "warning" if reg_rms <= 2.0 else "fail",
|
||||
}
|
||||
)
|
||||
|
||||
# ── 3D 叠加层:优先 out_dir/ply/ 子目录,回退到 out_dir 根目录 ──────────
|
||||
overlay3d: dict = {"kind": "none"}
|
||||
ply_files: list = []
|
||||
ply_dir = out_dir / "ply"
|
||||
if ply_dir.exists():
|
||||
ply_files = sorted(ply_dir.glob("*.ply"))
|
||||
if not ply_files:
|
||||
# 算法直接输出到 out_dir 根目录;优先取 full overlay
|
||||
for name in ("roi_measurement_overlay_full.ply", "roi_cropped_cloud.ply"):
|
||||
candidate = out_dir / name
|
||||
if candidate.exists():
|
||||
ply_files = [candidate]
|
||||
break
|
||||
if not ply_files:
|
||||
ply_files = sorted(out_dir.glob("*.ply"))
|
||||
if ply_files:
|
||||
rel = ply_files[0].relative_to(out_dir)
|
||||
overlay3d = {
|
||||
"kind": "ply",
|
||||
"file": str(rel),
|
||||
}
|
||||
|
||||
payload_out = {
|
||||
"schemaVersion": report.get("schema_version", "1.1"),
|
||||
"status": "completed",
|
||||
"measurements": measurements,
|
||||
"overlay3d": overlay3d,
|
||||
"extra": {
|
||||
"algorithmVersion": report.get("algorithm_version"),
|
||||
"qualityFlags": quality_flags,
|
||||
"registration": registration,
|
||||
"deflectionFull": deflection,
|
||||
},
|
||||
}
|
||||
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"underframe-deflection 计算失败:{exc}",
|
||||
extra={"traceback": traceback.format_exc()},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user