"""train-measurement 算法适配器 (v2.2)。 读 input.json → 调 TaskManager(来自 alg/train_measurement_system_v2.2)→ 写统一 result.json。 v2.2 变更: - 算法路径从 alg/点云测距v1.1 → alg/train_measurement_system_v2.2 - 新增孔中心拟合(hole_center_fitter)、测量日志、性能日志 - 内置配置文件改为 config/measurements.yaml(v3.2) - TRAIN_MEAS_ROOT 指向 train_measurement_system_v3.2_0629/(用于定位 config/) - 算法内置 _read_binary_ply,直接支持二进制 PLY,无需 ASCII 转换 约定: - 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中; - 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之; 否则使用算法仓库自带的 config/measurements.yaml(v3)作为模板。 - 所有任务结果合成成 measurements[] 列表,附带可视化 PLY 路径到 extra。 """ from __future__ import annotations import os import re import sys import traceback from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from _plugin_io import ( emit_progress, first_pointcloud, load_input, write_error_result, write_result, ) def _task_no_from_name(name: object) -> str | None: match = re.match(r"^任务\s*(\d+)", str(name or "")) return match.group(1) if match else None def _selected_task_nos(config: dict) -> set[str] | None: raw = config.get("demoTaskNos") if not isinstance(raw, list): return None return {str(int(value)) for value in raw if isinstance(value, (int, float)) and float(value).is_integer()} def _to_float(value): if value is None: return None if isinstance(value, (int, float)): return float(value) try: return float(str(value).strip()) except (TypeError, ValueError): return None def _load_excel_measurement_rows(out_dir: Path) -> dict[str, dict]: """读取算法导出的 Excel 结论,作为前端 measurements 的权威来源。""" try: from openpyxl import load_workbook excel_files = sorted( out_dir.glob("measurement_results_*.xlsx"), key=lambda p: p.stat().st_mtime, ) if not excel_files: return {} wb = load_workbook(excel_files[-1], data_only=True, read_only=True) ws = wb.active rows: dict[str, dict] = {} for row in ws.iter_rows(min_row=2, values_only=True): name = str(row[0] or "").strip() if not name: continue conclusion = str(row[6] or "").strip() status = None if conclusion == "通过": status = "pass" elif conclusion in ("不通过", "失败"): status = "fail" elif conclusion == "未配置": status = "info" rows[name] = { "value": _to_float(row[3]), "toleranceLabel": str(row[2]) if row[2] is not None else None, "status": status, } return rows except Exception as exc: print(f"读取 Excel 结论失败,继续使用适配器判定:{exc}", file=sys.stderr) return {} 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 v3.2 算法包") # TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system_v3.2_0629/ train_root = Path(os.environ.get("TRAIN_MEAS_ROOT") or "") if not train_root.exists(): alg_root = Path(os.environ.get("ALG_ROOT") or "/alg") train_root = alg_root / "train_measurement_system_v3.2_0629" 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)}") # ── 准备 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(v3) 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() tm.pointcloud_path_override = pc_path tm.output_dir_override = str(out_dir) emit_progress(0.25, f"读取点云({os.path.basename(pc_path)},可能需要 1~2 分钟)") if not tm.load_config(str(yaml_path)): raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}") # 注入点云路径(覆盖 YAML 中的 pointcloud_file) tm.config["pointcloud_file"] = pc_path # 过滤 enabled=False 的任务(v3 TaskManager 不处理 enabled 字段) import yaml as _yaml_check _raw = _yaml_check.safe_load(yaml_path.read_text(encoding="utf-8")) _enabled_names = { t["name"] for t in (_raw.get("tasks") or _raw.get("measurements") or []) if t.get("enabled", True) } tm.tasks = [t for t in tm.tasks if t.name in _enabled_names] selected_task_nos = _selected_task_nos(config) if selected_task_nos is not None: available_task_nos = {_task_no_from_name(task.name) for task in tm.tasks} missing_task_nos = selected_task_nos - available_task_nos if missing_task_nos: raise RuntimeError(f"测距配置中找不到所选任务:{','.join(sorted(missing_task_nos, key=int))}") tm.tasks = [task for task in tm.tasks if _task_no_from_name(task.name) in selected_task_nos] # 注入可视化输出目录 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.40, f"点云已加载,开始执行 {len(tm.tasks)} 个测量任务") # ── 从 YAML 构建公差映射(name → {theoretical, upper, lower}) ───────── import yaml as _yaml try: raw_yaml = _yaml.safe_load(yaml_path.read_text(encoding="utf-8")) # v3 用 'tasks' key,v2 用 'measurements' key,兼容两者 task_cfgs = raw_yaml.get("tasks") or raw_yaml.get("measurements") or [] tol_map: dict[str, dict] = {} for tc in task_cfgs: tname = tc.get("name") if not tname: continue tol = tc.get("tolerance") theoretical = tc.get("theoretical_dimension") if isinstance(tol, dict): tol_map[tname] = { "theoretical": theoretical, "upper": float(tol.get("upper", 0)), "lower": float(tol.get("lower", 0)), } elif tol is not None: tol_map[tname] = { "theoretical": theoretical, "upper": float(tol), "lower": float(tol), } except Exception: tol_map = {} results = tm.run_all_tasks() report_rows = _load_excel_measurement_rows(out_dir) 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") fval = float(value) if isinstance(value, (int, float)) else None # v3.2 data 里直接有 is_qualified 判定结果(line_to_plane/line_to_line 也支持) data_pass = clean_data.get("is_qualified") if data_pass is None: data_pass = clean_data.get("pass") # 兼容旧字段名 tol_cfg = tol_map.get(name) tolerance = None meas_status = "info" if data_pass is not None: # 算法已判定,直接用 meas_status = "pass" if data_pass else "fail" if tol_cfg: tolerance = tol_cfg.get("upper") elif tol_cfg and fval is not None: theoretical = tol_cfg.get("theoretical") upper = tol_cfg["upper"] lower = tol_cfg["lower"] tolerance = upper if theoretical is not None: error = fval - float(theoretical) meas_status = "pass" if (-lower <= error <= upper) else "fail" measurements.append( { "name": name, "value": fval if fval is not None else float("nan"), "unit": "mm", "tolerance": tolerance, "status": meas_status, "passLabel": "通过", "failLabel": "不通过", } ) else: measurements.append( { "name": name, "value": float("nan"), "unit": "mm", "status": "fail", } ) for measurement in measurements: report_row = report_rows.get(measurement["name"]) if not report_row: continue if report_row.get("value") is not None: measurement["value"] = report_row["value"] if report_row.get("toleranceLabel"): measurement["toleranceLabel"] = report_row["toleranceLabel"] if report_row.get("status"): measurement["status"] = report_row["status"] 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())