feat: preserve flatness suite measurement outputs

This commit is contained in:
2026-07-22 11:06:44 +08:00
parent 8555aa2f2e
commit 752ea3f944
9 changed files with 813 additions and 22 deletions

View File

@@ -0,0 +1,246 @@
"""flatness-suite 批量测量结果的模板关联与旧报表解析。"""
from __future__ import annotations
import argparse
import json
import math
import posixpath
from pathlib import Path
from typing import Any
from zipfile import BadZipFile, ZipFile
from xml.etree import ElementTree as ET
_SHEET_NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
def _column_index(reference: str) -> int:
letters = "".join(character for character in reference if character.isalpha())
if not letters:
raise ValueError(f"无效的 Excel 单元格引用: {reference}")
result = 0
for character in letters.upper():
result = result * 26 + ord(character) - ord("A") + 1
return result
def _cell_value(cell: ET.Element, shared_strings: list[str]) -> Any:
cell_type = cell.attrib.get("t")
if cell_type == "inlineStr":
return "".join(text.text or "" for text in cell.findall(".//a:t", _SHEET_NS))
value_node = cell.find("a:v", _SHEET_NS)
if value_node is None or value_node.text is None:
return ""
raw = value_node.text
if cell_type == "s":
try:
return shared_strings[int(raw)]
except (IndexError, ValueError) as exc:
raise ValueError("Excel 共享字符串索引无效") from exc
if cell_type in {"str", "e"}:
return raw
if cell_type == "b":
return raw == "1"
try:
number = float(raw)
return int(number) if number.is_integer() else number
except ValueError:
return raw
def read_xlsx_rows(path: str | Path) -> tuple[str, list[list[Any]]]:
"""只用标准库读取 xlsx 的第一个工作表。"""
workbook_path = Path(path)
if not workbook_path.is_file():
raise FileNotFoundError(f"Excel 文件不存在: {workbook_path}")
try:
with ZipFile(workbook_path) as xlsx:
names = set(xlsx.namelist())
shared_strings: list[str] = []
if "xl/sharedStrings.xml" in names:
shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml"))
shared_strings = [
"".join(text.text or "" for text in item.findall(".//a:t", _SHEET_NS))
for item in shared_root.findall("a:si", _SHEET_NS)
]
workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml"))
first_sheet = workbook_root.find("a:sheets/a:sheet", _SHEET_NS)
if first_sheet is None:
raise ValueError(f"Excel 中没有工作表: {workbook_path}")
sheet_name = first_sheet.attrib.get("name", "Sheet1")
relation_id = first_sheet.attrib.get(f"{{{_REL_NS}}}id")
if not relation_id:
raise ValueError(f"Excel 工作表关系缺失: {workbook_path}")
rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels"))
relation_targets = {
relation.attrib["Id"]: relation.attrib["Target"] for relation in rels_root
}
target = relation_targets.get(relation_id)
if not target:
raise ValueError(f"Excel 工作表文件关系缺失: {workbook_path}")
sheet_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join("xl", target))
if sheet_path not in names:
raise ValueError(f"Excel 工作表文件不存在: {sheet_path}")
sheet_root = ET.fromstring(xlsx.read(sheet_path))
sparse_rows: list[dict[int, Any]] = []
max_column = 0
for row in sheet_root.findall("a:sheetData/a:row", _SHEET_NS):
values: dict[int, Any] = {}
for cell in row.findall("a:c", _SHEET_NS):
column = _column_index(cell.attrib.get("r", ""))
values[column] = _cell_value(cell, shared_strings)
max_column = max(max_column, column)
if values:
sparse_rows.append(values)
except (BadZipFile, KeyError, ET.ParseError) as exc:
raise ValueError(f"无法解析 Excel 文件 {workbook_path}: {exc}") from exc
rows = [
[row.get(column, "") for column in range(1, max_column + 1)]
for row in sparse_rows
]
if not rows:
raise ValueError(f"Excel 工作表为空: {workbook_path}")
return sheet_name, rows
def _header_index(headers: list[Any], candidates: tuple[str, ...]) -> int:
normalized = [str(header).strip() for header in headers]
for candidate in candidates:
if candidate in normalized:
return normalized.index(candidate)
raise ValueError(f"Excel 缺少列: {'/'.join(candidates)}")
def normalize_task_id(value: Any) -> str:
if isinstance(value, float) and value.is_integer():
return str(int(value))
text = str(value).strip()
return text[:-2] if text.endswith(".0") and text[:-2].isdigit() else text
def _label(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def read_measurement_template(path: str | Path) -> dict[str, dict[str, str]]:
"""读取任务号对应的项点名称与原始公差文本。"""
_, rows = read_xlsx_rows(path)
headers = rows[0]
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
name_column = _header_index(headers, ("项点名称", "项目名称", "项目"))
tolerance_column = _header_index(headers, ("公差要求", "公差"))
result: dict[str, dict[str, str]] = {}
for row in rows[1:]:
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
if not task_id:
continue
item_name = _label(row[name_column] if name_column < len(row) else "")
if not item_name:
raise ValueError(f"模板任务 {task_id} 缺少项点名称")
result[task_id] = {
"item_name": item_name,
"tolerance_label": _label(row[tolerance_column] if tolerance_column < len(row) else ""),
}
if not result:
raise ValueError(f"模板中没有测量任务: {path}")
return result
def read_legacy_batch_rows(path: str | Path) -> list[dict[str, Any]]:
"""读取旧版技术汇总表中的任务号、平均值和状态。"""
_, rows = read_xlsx_rows(path)
headers = rows[0]
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
mean_column = _header_index(headers, ("平均值", "mean", "mean_dist", "average_distance"))
status_column = _header_index(headers, ("状态", "测量结论", "结论"))
result: list[dict[str, Any]] = []
for row in rows[1:]:
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
if not task_id:
continue
result.append({
"task_id": task_id,
"mean": row[mean_column] if mean_column < len(row) else None,
"status": row[status_column] if status_column < len(row) else "",
})
if not result:
raise ValueError(f"旧版 Excel 中没有批量结果: {path}")
return result
def _finite_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def build_measurements(
rows: list[dict[str, Any]],
template_path: str | Path,
) -> list[dict[str, Any]]:
template = read_measurement_template(template_path)
status_fields = {
"成功": ("pass", "成功"),
"部分成功": ("warning", "部分成功"),
"失败": ("fail", "失败"),
}
measurements: list[dict[str, Any]] = []
for row in rows:
task_id = normalize_task_id(row.get("task_id", ""))
if not task_id:
raise ValueError("批量结果包含空任务号")
template_row = template.get(task_id)
if template_row is None:
raise ValueError(f"模板中找不到任务 {task_id}")
raw_status = str(row.get("status", "")).strip()
if raw_status not in status_fields:
raise ValueError(f"任务 {task_id} 的状态无效: {raw_status or '<空>'}")
status, _ = status_fields[raw_status]
measurements.append({
"name": f"任务{task_id} · {template_row['item_name']}",
"value": _finite_float(row.get("mean")),
"unit": "mm",
"toleranceLabel": template_row["tolerance_label"],
"status": status,
"passLabel": "成功",
"warningLabel": "部分成功",
"failLabel": "失败",
})
return measurements
def measurements_from_batch_result(result: dict[str, Any], template_path: str | Path) -> list[dict[str, Any]]:
rows = result.get("rows")
if not isinstance(rows, list):
raise ValueError("run_contour_batch 返回值缺少 rows")
return build_measurements(rows, template_path)
def main() -> int:
parser = argparse.ArgumentParser(description="从旧版 flatness-suite Excel 构造通用 measurements")
parser.add_argument("--excel", required=True)
parser.add_argument("--template", required=True)
args = parser.parse_args()
measurements = build_measurements(read_legacy_batch_rows(args.excel), args.template)
print(json.dumps(measurements, ensure_ascii=False, allow_nan=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -31,6 +31,7 @@ from _plugin_io import (
write_error_result,
write_result,
)
from flatness_suite_measurements import measurements_from_batch_result
def _safe_float(v: object) -> float | None:
@@ -40,6 +41,25 @@ def _safe_float(v: object) -> float | None:
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 路径 + 文件指纹 + 三角化精度生成缓存路径。
@@ -191,12 +211,21 @@ def main() -> int:
if use_batch_mode and module == "contour":
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
data_root = alg_dir / "datasets"
# 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定
context_pc = (inputs.get("contextPointCloudPath")
or pc_path_raw
or None)
# 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),
@@ -205,20 +234,13 @@ def main() -> int:
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, "整理批量结果")
batch_stats = res.get("batch_stats") or {}
measurements: list[dict] = []
for task_id, task_res in (res.get("results") or {}).items():
s = task_res.get("distance_stats") or {}
if s:
measurements.append({
"name": f"任务{task_id} 轮廓偏差均值",
"value": round(float(s.get("mean", 0)), 4),
"unit": "mm", "status": "info",
})
measurements = measurements_from_batch_result(res, result_template_path)
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
panel_plys = sorted([
f"contour_batch/{p.name}"
@@ -229,12 +251,19 @@ def main() -> int:
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('results', {}))} 个面板测量",
"message": f"批量完成 {len(res.get('rows') or [])} 个面板测量",
"measurements": measurements,
"overlay3d": overlay3d,
"extra": {"module": "contour_batch", "excelPath": "contour.xlsx"},
"files": result_files,
"extra": {
"module": "contour_batch",
"excelPath": "contour.xlsx",
"combinedContourKept": bool(result_files),
},
})
return 0
@@ -430,6 +459,8 @@ def main() -> int:
extra={"traceback": traceback.format_exc()},
)
return 1
finally:
_cleanup_temporary_artifacts(out_dir)
if __name__ == "__main__":

View File

@@ -0,0 +1,96 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from flatness_suite_run import _cleanup_temporary_artifacts, _config_bool
from flatness_suite_measurements import (
measurements_from_batch_result,
read_legacy_batch_rows,
)
TEMPLATE = (
Path(__file__).parents[3]
/ "alg"
/ "平整度、平面度、轮廓测量v1.5"
/ "excel"
/ "测量结果.xlsx"
)
class FlatnessSuiteRetentionConfigTest(TestCase):
def test_combined_contour_defaults_to_disabled(self) -> None:
self.assertFalse(_config_bool({}, "keepCombinedContour", False))
def test_explicit_boolean_or_string_can_enable_retention(self) -> None:
self.assertTrue(_config_bool({"keepCombinedContour": True}, "keepCombinedContour"))
self.assertTrue(_config_bool({"keepCombinedContour": "true"}, "keepCombinedContour"))
self.assertFalse(_config_bool({"keepCombinedContour": "false"}, "keepCombinedContour"))
def test_cleanup_removes_only_known_temporary_artifacts(self) -> None:
with TemporaryDirectory() as temp:
out_dir = Path(temp)
temporary = out_dir / "_roi_cropped.ply"
result = out_dir / "contour.ply"
temporary.touch()
result.touch()
_cleanup_temporary_artifacts(out_dir)
self.assertFalse(temporary.exists())
self.assertTrue(result.exists())
class FlatnessSuiteBatchMeasurementTest(TestCase):
def test_uses_top_level_rows_instead_of_nested_sample_results(self) -> None:
result = {
"rows": [{"task_id": "11", "mean": 1.25, "status": "成功"}],
"results": {
"11": {
"11": {"distance_stats": {"mean": 999}},
},
},
}
measurements = measurements_from_batch_result(result, TEMPLATE)
self.assertEqual(len(measurements), 1)
self.assertEqual(measurements[0]["name"], "任务11 · 车钩面板的平整度")
self.assertEqual(measurements[0]["value"], 1.25)
self.assertEqual(measurements[0]["toleranceLabel"], "0.5mm")
def test_maps_success_partial_success_failure_and_empty_value(self) -> None:
result = {
"rows": [
{"task_id": 11, "mean": 0.1, "status": "成功"},
{"task_id": 13, "mean": 0.2, "status": "部分成功"},
{"task_id": 17, "mean": None, "status": "失败"},
],
"results": {},
}
measurements = measurements_from_batch_result(result, TEMPLATE)
self.assertEqual([item["status"] for item in measurements], ["pass", "warning", "fail"])
self.assertEqual(measurements[0]["passLabel"], "成功")
self.assertEqual(measurements[1]["warningLabel"], "部分成功")
self.assertEqual(measurements[2]["failLabel"], "失败")
self.assertIsNone(measurements[2]["value"])
def test_missing_template_has_explicit_error(self) -> None:
with self.assertRaisesRegex(FileNotFoundError, "Excel 文件不存在"):
measurements_from_batch_result(
{"rows": [{"task_id": 11, "mean": 0.1, "status": "成功"}]},
Path("missing-template.xlsx"),
)
def test_new_template_is_not_misread_as_legacy_summary(self) -> None:
with self.assertRaisesRegex(ValueError, "平均值"):
read_legacy_batch_rows(TEMPLATE)
def test_malformed_excel_has_explicit_error(self) -> None:
with TemporaryDirectory() as temp:
invalid = Path(temp) / "contour.xlsx"
invalid.write_text("not an xlsx", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "无法解析 Excel"):
read_legacy_batch_rows(invalid)

View File

@@ -3,7 +3,7 @@
"name": "平整度 / 平面度 / 轮廓测量",
"shortName": "flatness-suite",
"description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
"version": "1.5.0",
"version": "1.5.1",
"schemaVersion": 1,
"category": "几何测量",
"requiresPointCloud": false,
@@ -15,6 +15,13 @@
"type": "boolean",
"default": true
},
{
"name": "keepCombinedContour",
"label": "保留组合轮廓点云(可能占用十几 GB)",
"type": "boolean",
"default": false,
"description": "默认关闭:页面使用各面板热力点云,不需要组合文件。仅在需要离线下载包含整车背景的 combined_contour.ply 时开启。"
},
{
"name": "module",
"label": "测量模块",
@@ -47,10 +54,10 @@
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
{
"name": "contextPointCloudPath",
"label": "整车背景点云路径(可视化合并用)",
"label": "整车背景点云路径(仅保留组合轮廓点云时使用)",
"type": "string",
"default": null,
"description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。"
"description": "仅开启“保留组合轮廓点云”时生效。可填写容器内完整点云路径;留空时使用本次选择的点云作为背景。"
}
],
"runtime": {

View File

@@ -106,11 +106,17 @@ export interface PluginProgressEvent {
/** 测量项(GenericResultView 渲染) */
export interface PluginMeasurement {
name: string;
value: number;
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
value: number | null;
unit?: string;
tolerance?: number;
/** 无法安全数值化的原始公差表达式。 */
toleranceLabel?: string;
/** pass / warning / fail / info */
status?: 'pass' | 'warning' | 'fail' | 'info';
passLabel?: string;
warningLabel?: string;
failLabel?: string;
}
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */