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

283 lines
9.6 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.
"""算法插件适配器共用 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。
自动发现 outDir 下的 Excel 产物(*.xlsx / *.xls),以相对路径写入
payload["files"],前端据此展示下载入口(适配器也可自行预置 files)。
"""
import math
def _sanitize(obj: Any) -> Any:
# numpy 标量 → Python 原生类型
try:
import numpy as np # type: ignore
if isinstance(obj, np.ndarray):
return _sanitize(obj.tolist())
if isinstance(obj, (np.floating,)):
f = float(obj)
return None if (math.isnan(f) or math.isinf(f)) else f
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.bool_,)):
return bool(obj)
except ImportError:
pass
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]
if isinstance(obj, tuple):
return [_sanitize(v) for v in obj]
return obj
out_dir = Path(out_dir)
excel_files: list[str] = list(payload.get("files") or [])
for pattern in ("*.xlsx", "*.xls"):
for f in sorted(out_dir.rglob(pattern)):
rel = str(f.relative_to(out_dir))
if rel not in excel_files:
excel_files.append(rel)
if excel_files:
payload["files"] = excel_files
out_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)