Compare commits

...

4 Commits

38 changed files with 2166 additions and 159 deletions

View File

@@ -59,7 +59,7 @@ MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
# 应用配置
VITE_APP_TITLE=宏量点云处理系统
VITE_APP_TITLE=宏量点云处理软件
EOF
# 或者手动编辑

View File

@@ -33,7 +33,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 设置构建时环境变量
ARG VITE_APP_TITLE="宏量点云处理系统"
ARG VITE_APP_TITLE="宏量点云处理软件"
ENV VITE_APP_TITLE=$VITE_APP_TITLE
# 构建应用

View File

@@ -0,0 +1,259 @@
# 算法交付与测量结果输出规范
版本:1.0.0
状态:自 2026-08-13 起,新交付算法必须遵循;存量算法逐步迁移。
适用范围:`alg/` 下所有向宏量点云处理平台输出测量结果的算法。
## 1. 目的
本规范统一算法 `result.json` 中正式测量结果的表达,解决以下问题:
- 任务号被写在名称字符串里,例如 `任务46_车体横断面对角线差值`;
- 同一任务的多个测量位置无法分组,例如任务 5 的左梁和右梁;
- 头车、中车使用不同任务号,但结果中没有检测对象上下文;
- `dx_y`、`dx_z` 等计算中间量混入正式测量结果;
- 前端只能解析中文名称,无法稳定排序、筛选、统计和生成报告。
平台进程、进度事件、产物文件等接入方式仍参考
[`docs/algorithm-integration-spec.md`](../../docs/algorithm-integration-spec.md)。涉及测量结果字段时,**以本规范为准**。
## 2. 基本原则
1. **任务号结构化**:正式测量项必须提供 `taskNo`,不得只写进 `name`。
2. **名称不编码**:`name` 只写项目名称,不包含 `任务XX_`、左右位置或车型前缀。
3. **指标编码稳定**:每项结果必须有稳定的 `metricCode`,程序关联以它为准,不能依赖中文名称。
4. **一个测量值一条记录**:同一任务存在左/右、前/后等多个结果时,保留多条记录并使用 `position` 区分,`taskNo` 相同。
5. **正式结果与中间量隔离**:只有可交付、可判定、可进入报告的值才能进入 `measurements`;中间量放入 `extra.intermediateMetrics`。
6. **不猜任务号**:任务号必须来自经确认的业务任务清单。无法确定时应阻止正式交付并请求确认,不能按数组序号或名称相似度推断。
7. **值和单位分离**:`value` 必须是 JSON 数值或 `null`;禁止输出 `"0.073/700mm"` 这样的混合字符串。
8. **JSON 必须标准合法**:禁止 `NaN`、`Infinity`;失败且无有效值时使用 `null`。
## 3. 职责边界
### 3.1 业务/产品负责人
- 提供并确认正式任务清单:`taskNo`、项目名称、适用检测对象、理论值、公差、单位;
- 审批任务号变更,算法工程师不得自行重新编号。
### 3.2 平台/集成人员
- 在算法输入或适配层提供 `inspectionObject`,例如 `head`(头车)、`middle`(中车);
- 将经确认的业务任务清单配置为映射表;
- 校验最终 `result.json`,不让缺少任务号的正式结果进入业务表格。
### 3.3 算法工程师
- 为算法指标定义稳定的 `metricCode` 和准确的 `name`;
- 输出 `position` 等算法能够确定的测量位置;
- 在已获得检测对象和任务映射时输出正确的 `taskNo`;
- 只把最终可交付结果放进 `measurements`,中间量放进 `extra.intermediateMetrics`;
- 交付前运行本目录的校验脚本。
> 最终写入平台的 `result.json` 必须满足本规范。若算法核心包不负责业务任务号,平台薄适配层必须在写出 `result.json` 前补齐,不能交给前端猜测。
## 4. 顶层结构
```json
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"message": "测量完成",
"context": {
"inspectionObject": "head",
"inspectionObjectName": "头车"
},
"measurements": [],
"extra": {
"intermediateMetrics": []
}
}
```
字段说明:
| 字段 | 必填 | 说明 |
|---|---:|---|
| `schemaVersion` | 是 | 平台插件结果版本;当前平台存量插件仍使用 `1` |
| `measurementContractVersion` | 是 | 本测量结果规范版本,当前固定为 `"1.0"` |
| `status` | 是 | `completed` 或 `error` |
| `message` | 否 | 面向用户的简短说明 |
| `context` | 有对象差异时必填 | 本次测量的业务上下文 |
| `measurements` | 完成时必填 | 正式测量结果;允许空数组,不允许混入调试指标 |
| `extra` | 否 | 算法报告、质量信息、中间量等非正式结果 |
`schemaVersion` 与 `measurementContractVersion` 含义不同,不得互相替代。
## 5. 正式测量项 `measurements[]`
推荐完整结构:
```json
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "左梁",
"value": 0.265,
"unit": "mm",
"tolerance": 7.0,
"toleranceLabel": "±7 mm",
"status": "pass"
}
```
| 字段 | 必填 | 类型/取值 | 说明 |
|---|---:|---|---|
| `taskNo` | 是 | 字符串 | 正式业务任务号;即使全是数字也必须用字符串 |
| `metricCode` | 是 | 字符串 | 稳定指标编码,建议 `<域>.<部件>.<指标>`,仅用小写字母、数字、点、下划线和短横线 |
| `name` | 是 | 字符串 | 纯项目名称,不带 `任务XX_` 和位置前缀 |
| `position` | 多位置时必填 | 字符串 | 如 `左梁`、`右梁`、`一位端`;不适用时省略 |
| `value` | 是 | 数值或 `null` | 测量值;无有效结果时为 `null` |
| `unit` | 有量纲时必填 | 字符串 | 如 `mm`、`°`、`%`;无量纲使用 `"1"` |
| `theoreticalValue` | 否 | 数值 | 理论值,与 `unit` 相同 |
| `baseline` | 斜率形式时必填 | 正数 | 基准长度,单位为 mm |
| `tolerance` | 简单对称公差时使用 | 非负数 | `±tolerance`,与 `unit` 相同;斜率形式表示分子公差 |
| `toleranceLower` | 非对称公差时使用 | 数值 | 相对理论值的下偏差 |
| `toleranceUpper` | 非对称公差时使用 | 数值 | 相对理论值的上偏差 |
| `toleranceLabel` | 复杂公差时使用 | 字符串 | 报告展示原文,例如 `+8.5/-3 mm` |
| `status` | 是 | `pass` / `warning` / `fail` | 正式结果不得使用 `info` |
| `qualityFlags` | 否 | 字符串数组 | 质量告警代码,不替代 `status` |
约束:
- 同一 `taskNo + metricCode + position` 在一次结果中必须唯一;
- `name` 不得匹配 `^任务\s*\d+`;
- `status=pass|warning|fail` 必须基于已确认的公差规则;
- `value=null` 时不得判为 `pass`;
- 若尚未获得正式任务号,该项不得进入最终 `measurements`。
## 6. 检测对象与任务号映射
当同一指标在不同检测对象上对应不同任务号时,必须先根据 `context.inspectionObject` 选择任务号。
本次已确认的映射如下:
| 检测对象 | 指标 | `taskNo` |
|---|---|---:|
| 任意 | 纵向梁到直线的垂直偏差(左、右挠度) | 5 |
| 头车 `head` | 车钩面板到参考面的右偏转角 | 8 |
| 中车 `middle` | 车钩面板到参考面的右偏转角 | 9 |
| 头车 `head` | 车钩面板到纵向中心的右偏转角 | 10 |
| 中车 `middle` | 车钩面板到纵向中心的右偏转角 | 12 |
任务 5 的左右梁应输出两条记录:两条的 `taskNo` 和 `metricCode` 相同,`position` 分别为 `左梁`、`右梁`。
如果输入中没有头车/中车信息,车钩算法不得自行默认成头车;应报契约错误,或由平台适配层在具备检测对象信息后完成映射。
## 7. 斜率形式与角度值
车钩“右偏转角”目前存在两种可能的交付口径,必须由业务任务清单明确:
### 7.1 以角度交付
```json
{
"value": 0.0063,
"unit": "°",
"tolerance": 0.08
}
```
### 7.2 以偏差值/基准长度交付
```json
{
"value": 0.073,
"unit": "mm",
"baseline": 700,
"tolerance": 1.0,
"toleranceLabel": "1/700 mm"
}
```
禁止把 `value` 写成字符串 `"0.073/700mm"`。角度值和换算后的偏差值不能共用同一个 `metricCode`;如两者都需保留,一个作为正式结果,另一个放入中间量并使用不同编码。
## 8. 中间数据与诊断数据
以下数据通常不属于正式测量项:拟合系数、法向量、点数、残差、`dx_y`、`dx_z`、`D_y`、`D_z`、ICP fitness、RMSE 等。
统一放入:
```json
{
"extra": {
"intermediateMetrics": [
{
"metricCode": "coupler.panel.dx_y_max",
"name": "Y 方向计算用最大 X 偏差",
"value": 0.0101,
"unit": "mm"
}
]
}
}
```
中间量:
- 不需要 `taskNo`;
- 不设置业务 `status` 和公差;
- 不进入“算法结果”项数、合格率和正式报告;
- 可用于日志、排障和算法复核。
## 9. 禁止的旧写法
```json
{
"measurements": [
{ "name": "任务46_车体横断面对角线差值", "value": 0.592 },
{ "name": "左梁 · 纵向梁到直线的垂直偏差(挠度)", "value": 0.265 },
{ "name": "最大 x 偏差 dx_y", "value": 0.010, "status": "pass" }
]
}
```
问题分别是:任务号嵌入名称、位置嵌入名称、中间量冒充正式结果。
正确写法见 [`examples/head-car-coupler-and-deflection.result.json`](examples/head-car-coupler-and-deflection.result.json)。
## 10. 兼容与迁移
- 本规范是**目标输出契约**。当前平台前端尚未使用所有新增字段时,也必须保留这些字段,不能因此退回名称拼接方案;
- 存量 `任务46_xxx` 等结果允许历史回放,但算法下一版本应拆为 `taskNo="46"` 和纯 `name`;
- 平台适配层可以暂时从经确认的旧配置中迁移任务号,但不得长期正则解析中文名称;
- 修改任务号、单位、换算口径、公差或 `metricCode` 均属于接口变更,必须在算法版本说明中记录;
- 核心计算方法未变、只调整输出结构时,也应至少提升算法补丁版本并注明“输出契约迁移”。
## 11. 交付检查清单
算法工程师提交前逐项确认:
- [ ] `result.json` 是 UTF-8 标准 JSON,无 `NaN`/`Infinity`
- [ ] 顶层包含 `measurementContractVersion: "1.0"`
- [ ] 每个正式测量项都有 `taskNo`、`metricCode`、纯 `name`、`value`、`status`
- [ ] 多位置结果使用相同任务号并通过 `position` 区分
- [ ] 头车/中车任务号来自 `context.inspectionObject`,没有默认猜测
- [ ] `measurements` 中没有 `info` 状态和算法中间量
- [ ] 单位、公差、理论值和换算口径与正式任务清单一致
- [ ] `extra.intermediateMetrics` 中的指标不参与合格判定
- [ ] 已执行:`python3 validate_result.py result.json`
- [ ] 版本说明记录了输出字段变化
## 12. 校验方式
本目录提供两种校验:
```bash
# 零第三方依赖,执行本规范的关键业务规则校验
python3 validate_result.py /path/to/result.json
# 如本机安装了 jsonschema,可进行完整 JSON Schema 校验
python3 -m jsonschema -i /path/to/result.json result.schema.json
```
示例自检:
```bash
python3 validate_result.py examples/head-car-coupler-and-deflection.result.json
```

View File

@@ -0,0 +1,72 @@
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"message": "头车车钩偏转与左右梁挠度测量完成",
"context": {
"inspectionObject": "head",
"inspectionObjectName": "头车"
},
"measurements": [
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "左梁",
"value": 0.265,
"unit": "mm",
"tolerance": 7.0,
"toleranceLabel": "±7 mm",
"status": "pass"
},
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "右梁",
"value": 0.270,
"unit": "mm",
"tolerance": 12.0,
"toleranceLabel": "±12 mm",
"status": "pass"
},
{
"taskNo": "8",
"metricCode": "coupler.panel.reference_plane.right_deflection",
"name": "车钩面板到参考面的右偏转角",
"value": 0.022,
"unit": "mm",
"baseline": 375.0,
"tolerance": 0.8,
"toleranceLabel": "0.8/375 mm",
"status": "pass"
},
{
"taskNo": "10",
"metricCode": "coupler.panel.longitudinal_center.right_deflection",
"name": "车钩面板到纵向中心的右偏转角",
"value": 0.073,
"unit": "mm",
"baseline": 700.0,
"tolerance": 1.0,
"toleranceLabel": "1/700 mm",
"status": "pass"
}
],
"extra": {
"intermediateMetrics": [
{
"metricCode": "coupler.panel.dx_y_max",
"name": "Y 方向计算用最大 X 偏差",
"value": 0.010,
"unit": "mm"
},
{
"metricCode": "coupler.panel.dx_z_max",
"name": "Z 方向计算用最大 X 偏差",
"value": 0.010,
"unit": "mm"
}
]
}
}

View File

@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"measurements": [
{
"name": "任务46_车体横断面对角线差值",
"metricCode": "vehicle.diagonal.delta",
"value": 0.592,
"unit": "mm",
"status": "info"
}
],
"extra": {
"intermediateMetrics": [
{
"taskNo": "debug-1",
"metricCode": "coupler.panel.dx_y_max",
"name": "最大 x 偏差 dx_y",
"value": 0.01,
"unit": "mm",
"status": "pass",
"tolerance": 1.0
}
]
}
}

View File

@@ -0,0 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://massive-cloud.local/schemas/algorithm-measurement-result-1.0.json",
"title": "宏量点云算法测量结果输出契约 1.0",
"type": "object",
"required": ["schemaVersion", "measurementContractVersion", "status"],
"properties": {
"schemaVersion": { "type": "integer", "minimum": 1 },
"measurementContractVersion": { "const": "1.0" },
"status": { "enum": ["completed", "error"] },
"message": { "type": "string" },
"context": {
"type": "object",
"properties": {
"inspectionObject": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"inspectionObjectName": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
},
"measurements": {
"type": "array",
"items": { "$ref": "#/$defs/measurement" }
},
"errorStats": { "type": "object" },
"overlay3d": { "type": "object" },
"images": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"files": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"derivedPointClouds": { "type": "array" },
"extra": {
"type": "object",
"properties": {
"intermediateMetrics": {
"type": "array",
"items": { "$ref": "#/$defs/intermediateMetric" }
}
},
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": { "status": { "const": "completed" } },
"required": ["status"]
},
"then": { "required": ["measurements"] }
}
],
"additionalProperties": true,
"$defs": {
"metricCode": {
"type": "string",
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
},
"measurement": {
"type": "object",
"required": ["taskNo", "metricCode", "name", "value", "status"],
"properties": {
"taskNo": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"metricCode": { "$ref": "#/$defs/metricCode" },
"name": {
"type": "string",
"minLength": 1,
"not": { "pattern": "^任务\\s*[0-9]+" }
},
"position": { "type": "string", "minLength": 1 },
"value": { "type": ["number", "null"] },
"unit": { "type": "string", "minLength": 1 },
"theoreticalValue": { "type": "number" },
"baseline": { "type": "number", "exclusiveMinimum": 0 },
"tolerance": { "type": "number", "minimum": 0 },
"toleranceLower": { "type": "number" },
"toleranceUpper": { "type": "number" },
"toleranceLabel": { "type": "string", "minLength": 1 },
"status": { "enum": ["pass", "warning", "fail"] },
"qualityFlags": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": true
},
"intermediateMetric": {
"type": "object",
"required": ["metricCode", "name", "value"],
"properties": {
"metricCode": { "$ref": "#/$defs/metricCode" },
"name": { "type": "string", "minLength": 1 },
"value": { "type": ["number", "null"] },
"unit": { "type": "string", "minLength": 1 }
},
"not": {
"anyOf": [
{ "required": ["taskNo"] },
{ "required": ["status"] },
{ "required": ["tolerance"] }
]
},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""算法测量结果契约 1.0 的零第三方依赖校验器。"""
from __future__ import annotations
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
METRIC_CODE_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
TASK_NO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
TASK_NAME_PREFIX_RE = re.compile(r"^任务\s*\d+")
FINAL_STATUSES = {"pass", "warning", "fail"}
def is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def validate(payload: Any) -> list[str]:
errors: list[str] = []
if not isinstance(payload, dict):
return ["根节点必须是 JSON 对象"]
if not isinstance(payload.get("schemaVersion"), int) or payload["schemaVersion"] < 1:
errors.append("schemaVersion 必须是大于等于 1 的整数")
if payload.get("measurementContractVersion") != "1.0":
errors.append('measurementContractVersion 必须为 "1.0"')
if payload.get("status") not in {"completed", "error"}:
errors.append("status 必须为 completed 或 error")
measurements = payload.get("measurements")
if payload.get("status") == "completed" and not isinstance(measurements, list):
errors.append("completed 结果必须包含 measurements 数组")
measurements = []
elif measurements is None:
measurements = []
elif not isinstance(measurements, list):
errors.append("measurements 必须是数组")
measurements = []
unique_keys: set[tuple[str, str, str]] = set()
for index, item in enumerate(measurements):
path = f"measurements[{index}]"
if not isinstance(item, dict):
errors.append(f"{path} 必须是对象")
continue
task_no = item.get("taskNo")
metric_code = item.get("metricCode")
name = item.get("name")
position = item.get("position", "")
value = item.get("value", ...)
status = item.get("status")
if not isinstance(task_no, str) or not TASK_NO_RE.fullmatch(task_no):
errors.append(f"{path}.taskNo 必须是非空规范字符串,数字任务号也要加引号")
if not isinstance(metric_code, str) or not METRIC_CODE_RE.fullmatch(metric_code):
errors.append(f"{path}.metricCode 格式无效")
if not isinstance(name, str) or not name.strip():
errors.append(f"{path}.name 必须是非空字符串")
elif TASK_NAME_PREFIX_RE.search(name):
errors.append(f"{path}.name 不得包含任务号前缀,请使用 taskNo 字段")
if position and not isinstance(position, str):
errors.append(f"{path}.position 必须是字符串")
if value is ... or (value is not None and not is_number(value)):
errors.append(f"{path}.value 必须是有限数值或 null")
if status not in FINAL_STATUSES:
errors.append(f"{path}.status 必须为 pass、warning 或 fail,正式结果不得使用 info")
if value is None and status == "pass":
errors.append(f"{path} 的 value 为 null 时不能判定为 pass")
if "baseline" in item and (not is_number(item["baseline"]) or item["baseline"] <= 0):
errors.append(f"{path}.baseline 必须是正数")
if "tolerance" in item and (not is_number(item["tolerance"]) or item["tolerance"] < 0):
errors.append(f"{path}.tolerance 必须是非负有限数值")
if isinstance(task_no, str) and isinstance(metric_code, str) and isinstance(position, str):
key = (task_no, metric_code, position)
if key in unique_keys:
errors.append(f"{path} 与其他正式测量项重复:taskNo + metricCode + position 必须唯一")
unique_keys.add(key)
extra = payload.get("extra", {})
if extra is not None and not isinstance(extra, dict):
errors.append("extra 必须是对象")
extra = {}
intermediate = extra.get("intermediateMetrics", []) if isinstance(extra, dict) else []
if not isinstance(intermediate, list):
errors.append("extra.intermediateMetrics 必须是数组")
intermediate = []
for index, item in enumerate(intermediate):
path = f"extra.intermediateMetrics[{index}]"
if not isinstance(item, dict):
errors.append(f"{path} 必须是对象")
continue
if any(field in item for field in ("taskNo", "status", "tolerance")):
errors.append(f"{path} 不得包含 taskNo、status 或 tolerance")
code = item.get("metricCode")
if not isinstance(code, str) or not METRIC_CODE_RE.fullmatch(code):
errors.append(f"{path}.metricCode 格式无效")
if not isinstance(item.get("name"), str) or not item["name"].strip():
errors.append(f"{path}.name 必须是非空字符串")
value = item.get("value", ...)
if value is ... or (value is not None and not is_number(value)):
errors.append(f"{path}.value 必须是有限数值或 null")
return errors
def main() -> int:
if len(sys.argv) != 2:
print(f"用法: {Path(sys.argv[0]).name} /path/to/result.json", file=sys.stderr)
return 2
path = Path(sys.argv[1])
try:
payload = json.loads(path.read_text(encoding="utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"非法数值 {value}")))
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"校验失败: 无法读取标准 JSON:{exc}", file=sys.stderr)
return 1
errors = validate(payload)
if errors:
print(f"校验失败,共 {len(errors)} 个问题:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"校验通过:{path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,45 @@
# 算法输出规范执行通知(转发模板)
各位算法同事:
为统一平台“算法结果”中的任务号、测量位置、单位、公差和中间数据,从现在起请新交付算法以及存量算法的后续版本遵循:
`alg/00_算法交付与输出规范/README.md`
目录内同时提供:
- `result.schema.json`:完整 JSON Schema;
- `validate_result.py`:零第三方依赖校验器;
- `examples/head-car-coupler-and-deflection.result.json`:正确示例;
- `examples/invalid-legacy-result.json`:禁止的旧写法反例。
本次最关键的变化:
1. 正式测量结果必须单独返回 `taskNo`,不再把任务号写在 `name` 中;
2. `name` 只保留纯项目名称;同一任务的左/右等多个结果使用 `position` 区分;
3. 每个指标必须提供稳定的 `metricCode`;
4. 头车/中车必须依据输入的检测对象选择任务号,不得默认猜测;
5. `dx_y`、`dx_z`、拟合残差、法向量等中间量不得进入正式 `measurements`,统一放入 `extra.intermediateMetrics`;
6. 正式结果只允许 `pass`、`warning`、`fail`,不再用 `info` 表示可交付项;
7. 交付前请执行:
```bash
python3 alg/00_算法交付与输出规范/validate_result.py /path/to/result.json
```
请各算法负责人先检查自己的输出,并回复以下信息:
- 指标与正式业务任务号的映射是否完整;
- 哪些输出是正式测量值,哪些只是计算中间量;
- 同一指标是否会因头车/中车等检测对象对应不同任务号;
- 单位、公差、理论值和换算口径是否与正式任务清单一致;
- 预计从哪个算法版本开始按新规范交付。
车钩与挠度算法请重点确认:
- 任务 5 的左梁、右梁作为同一任务的两个 `position` 输出;
- 头车车钩对应任务 8、10,中车对应任务 9、12;
- `dx_y`、`dx_z` 仅保留为中间量;
- “右偏转角”最终交付口径是角度 `°`,还是偏差值/基准长度(例如 `0.073/700 mm`)。
平台插件的运行、输入、进度和产物接入仍参考 `docs/algorithm-integration-spec.md`;测量结果字段发生冲突时,以新规范为准。

View File

@@ -11,9 +11,10 @@
*/
import { useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, ListChecks, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { toast } from 'sonner';
import TrainMeasurementPanel from './TrainMeasurementPanel';
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
@@ -130,6 +131,19 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
},
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
});
const runDemoSelected = trpc.algorithm.runDemoSelected.useMutation({
onSuccess: ({ registrationJob, queuedMeasurementCount, selectedTaskNos }) => {
if (registrationJob) {
toast.success(`已提交自选 ${selectedTaskNos.length} 项;配准完成后将自动提交 ${queuedMeasurementCount} 个测量算法`);
}
if (taskId != null) {
void utils.analysisResult.list.invalidate({ taskId });
void utils.pointcloudFile.list.invalidate({ taskId });
}
if (modelId != null) void utils.inspectionTask.listByModel.invalidate({ modelId });
},
onError: (e) => toast.error(e.message || '选择任务运行提交失败'),
});
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
onSuccess: ({ cancelledIds }) => {
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
@@ -151,10 +165,15 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
const { data: demoTaskCatalog, isLoading: demoCatalogLoading } = trpc.algorithm.demoTaskCatalog.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, staleTime: 60_000 },
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
// 首次拉到 plugins 后用默认值初始化每个表单
useEffect(() => {
@@ -202,13 +221,13 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
...demoMeasurementPluginIds,
];
const demoAlgorithmCount = demoAllPluginIds.length;
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
const demoTaskCount = demoTaskCatalog?.total ?? 0;
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
const demoPluginIds = new Set(demoAllPluginIds);
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
const demoFlowRunning = runningDemoResults.length > 0;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending || runDemoSelected.isPending;
const selectedPcCount = selectedPcFileIds?.length ?? 0;
const selectedCadCount = selectedCadFileIds?.length ?? 0;
@@ -232,7 +251,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: null;
const registrationBlockReason =
baseRunBlockReason
@@ -257,6 +276,8 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
: demoCatalogLoading ? '正在加载任务目录'
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
: null);
const canRunDemoAll = demoBlockReason == null;
const demoRouteHint =
@@ -337,6 +358,20 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
});
};
const handleRunDemoSelected = (taskNos: number[]) => {
if (demoBlockReason) {
toast.error(demoBlockReason);
return;
}
if (taskId == null || !selectedPcFileIds?.[0] || !selectedCadFileIds?.[0]) return;
runDemoSelected.mutate({
taskId,
sourcePointCloudFileId: selectedPcFileIds[0],
cadFileId: selectedCadFileIds[0],
taskNos,
});
};
const handleCancelDemoAll = () => {
if (taskId == null || cancelDemoAll.isPending) return;
cancelDemoAll.mutate({ taskId });
@@ -353,7 +388,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
<Cpu className="w-3.5 h-3.5" />
一键全流程演示
</span>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{demoFlowRunning && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
运行中 {runningDemoResults.length}
@@ -373,6 +408,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
停止全流程
</button>
) : (
<>
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
@@ -383,8 +419,21 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
title={demoBlockReason ?? demoRunTitle}
>
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
运行全部 {demoAlgorithmCount} 个算法
运行全部任务({demoTaskCount}项)
</button>
<button
type="button"
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${
taskId != null && !demoCatalogLoading && demoTaskCount > 0 ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={taskId == null || demoCatalogLoading || demoTaskCount === 0}
onClick={() => setDemoTaskDialogOpen(true)}
title="选择正式任务并运行最小测量算法集合"
>
<ListChecks className="w-3 h-3" />
选择任务运行
</button>
</>
)}
</div>
</div>
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
/>
))}
</div>
<DemoTaskSelectionDialog
open={demoTaskDialogOpen}
onOpenChange={setDemoTaskDialogOpen}
items={demoTaskCatalog?.items ?? []}
total={demoTaskCount}
loading={demoCatalogLoading}
pending={runDemoSelected.isPending}
runBlockReason={demoBlockReason}
onRun={handleRunDemoSelected}
/>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import { useAuth } from '@/_core/hooks/useAuth';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, type Overlay3D } from './GenericResultView';
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, sortMeasurements, statusClass, type Overlay3D } from './GenericResultView';
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -66,7 +66,19 @@ interface Props {
}) => void;
}
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
type Measurement = {
taskNo?: string;
metricCode?: string;
name: string;
position?: string;
value: number;
unit: string;
tolerance: number;
status: string;
baseline?: number;
legacyUnmapped?: boolean;
legacyOriginalName?: string;
};
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
@@ -112,10 +124,13 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
const showDetails = displayMode === 'maximized';
const sortedItems = sortMeasurements(items);
return (
<table className="w-full text-[10px] border-collapse">
<div className="overflow-x-auto">
<table className="w-full min-w-[360px] text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
<th className="text-left py-1 font-normal">项目</th>
{showDetails && <th className="text-right py-1 font-normal">值</th>}
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
@@ -123,9 +138,23 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
</tr>
</thead>
<tbody>
{items.map((m, i) => (
{sortedItems.map((m, i) => (
<tr key={i} className="border-t border-border/40">
<td className="py-1">{m.name}</td>
<td className="py-1 pr-2 whitespace-nowrap">
{m.legacyUnmapped || !m.taskNo ? (
<span className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help" title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}>
待确认
</span>
) : m.taskNo}
</td>
<td className="py-1">
{m.name}
{m.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{m.position}
</span>
)}
</td>
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
<td className="text-right">
@@ -141,6 +170,7 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
))}
</tbody>
</table>
</div>
);
}
@@ -233,6 +263,9 @@ type DemoFlowMeta = {
role?: string;
index?: number;
total?: number;
mode?: 'all' | 'selected';
selectedTaskNos?: number[];
selectedTaskCount?: number;
};
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
@@ -263,6 +296,11 @@ function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
index: numberFromUnknown(data.demoFlowIndex),
total: numberFromUnknown(data.demoFlowTotal),
mode: data.demoFlowMode === 'selected' ? 'selected' : data.demoFlowMode === 'all' ? 'all' : undefined,
selectedTaskNos: Array.isArray(data.demoFlowSelectedTaskNos)
? data.demoFlowSelectedTaskNos.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
: undefined,
selectedTaskCount: numberFromUnknown(data.demoFlowSelectedTaskCount),
};
}
@@ -856,11 +894,24 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
const cardKey = `${variant}:${group.runId}`;
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
const flowMeta = group.results.map(readDemoFlowMeta).find((meta) => meta?.mode) ?? null;
const selectedTaskNos = flowMeta?.mode === 'selected' ? flowMeta.selectedTaskNos : undefined;
const selectedTaskCount = flowMeta?.selectedTaskCount ?? selectedTaskNos?.length ?? 0;
const flowTitle = flowMeta?.mode === 'selected'
? `全流程算法结果 · 自选 ${selectedTaskCount} 项`
: '全流程算法结果 · 全部任务';
const aggregated = aggregateDemoFlowResults(
group.results,
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS, selectedTaskNos },
);
const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS
? aggregated.tableRows
: sortMeasurements(aggregated.measurementRows.map((row) => ({
...row,
taskNo: row.measurement.taskNo,
position: row.measurement.position,
})));
const showMeasurementDetails = variant === 'maximized';
return (
@@ -871,7 +922,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
<span className="text-xs flex-1 min-w-0 truncate">全流程算法结果</span>
<span className="text-xs flex-1 min-w-0 truncate">{flowTitle}</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{completed}/{total} 完成
</span>
@@ -907,6 +958,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground border-b border-border/60">
<th className="text-left px-2 py-1.5 font-normal whitespace-nowrap">任务号</th>
<th className="text-left px-2 py-1.5 font-normal">项目</th>
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
@@ -914,13 +966,30 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</tr>
</thead>
<tbody>
{aggregated.tableRows.map((row) => row.kind === 'algorithm' ? (
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
<td colSpan={showMeasurementDetails ? 4 : 2} className="px-2 py-1 font-medium">{row.algorithmName}</td>
<td colSpan={showMeasurementDetails ? 5 : 3} className="px-2 py-1 font-medium">{row.algorithmName}</td>
</tr>
) : (
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>{row.measurement.name}</td>
<td className="px-2 py-1 whitespace-nowrap">
{row.measurement.legacyUnmapped || !row.measurement.taskNo ? (
<span
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
title={`旧算法项目尚未确认任务号:${row.measurement.legacyOriginalName ?? row.measurement.name}`}
>
待确认
</span>
) : row.measurement.taskNo}
</td>
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>
{row.measurement.name}
{row.measurement.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{row.measurement.position}
</span>
)}
</td>
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
{row.measurement.baseline != null
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from 'react';
import { Layers, Play, Search, XCircle } from 'lucide-react';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
export interface DemoTaskCatalogItem {
taskNo: number;
name: string;
category: string;
pluginId: string;
pluginName: string;
enabled: boolean;
disabledReason?: string;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
items: DemoTaskCatalogItem[];
total: number;
loading: boolean;
pending: boolean;
runBlockReason: string | null;
onRun: (taskNos: number[]) => void;
}
export default function DemoTaskSelectionDialog({
open,
onOpenChange,
items,
total,
loading,
pending,
runBlockReason,
onRun,
}: Props) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<number>>(new Set());
useEffect(() => {
if (!open) return;
setSearch('');
setSelected(new Set());
}, [open]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return items;
return items.filter((item) => (
String(item.taskNo).includes(keyword)
|| `任务${String(item.taskNo).padStart(2, '0')}`.toLocaleLowerCase('zh-CN').includes(keyword)
|| item.name.toLocaleLowerCase('zh-CN').includes(keyword)
));
}, [items, search]);
const selectableResults = filteredItems.filter((item) => item.enabled);
const selectedCountInResults = selectableResults.filter((item) => selected.has(item.taskNo)).length;
const selectAllState = selectableResults.length > 0 && selectedCountInResults === selectableResults.length
? true
: selectedCountInResults > 0
? 'indeterminate'
: false;
const selectedPluginCount = new Set(
items.filter((item) => selected.has(item.taskNo)).map((item) => item.pluginId),
).size;
const startBlockReason = selected.size === 0 ? '请至少选择一个任务' : runBlockReason;
const toggleTask = (taskNo: number) => {
setSelected((previous) => {
const next = new Set(previous);
if (next.has(taskNo)) next.delete(taskNo);
else next.add(taskNo);
return next;
});
};
const toggleAllResults = () => {
setSelected((previous) => {
const next = new Set(previous);
if (selectAllState === true) {
selectableResults.forEach((item) => next.delete(item.taskNo));
} else {
selectableResults.forEach((item) => next.add(item.taskNo));
}
return next;
});
};
const handleRun = () => {
if (startBlockReason) return;
onRun(Array.from(selected).sort((a, b) => a - b));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[min(960px,calc(100vw-1rem))] max-w-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="border-b border-border/60 px-4 py-4 pr-12 sm:px-6">
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Layers className="size-4 text-cyan-400" />
选择任务运行
</DialogTitle>
<DialogDescription>
当前检测对象可运行 {total} 项;系统仍会先执行 1 次整车配准。
</DialogDescription>
<div className="relative pt-2">
<Search className="pointer-events-none absolute left-3 top-[18px] size-4 text-muted-foreground" />
<Input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索任务号或任务名称"
aria-label="搜索任务号或任务名称"
className="pl-9"
/>
</div>
<div className="flex min-h-8 flex-wrap items-center gap-3 pt-1 text-xs">
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 focus-within:ring-2 focus-within:ring-ring">
<Checkbox
checked={selectAllState}
onCheckedChange={toggleAllResults}
disabled={selectableResults.length === 0}
aria-label="全选当前搜索结果"
/>
全选当前结果({selectableResults.length})
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setSelected(new Set())}
disabled={selected.size === 0}
>
<XCircle className="size-3.5" /> 清空
</Button>
</div>
</DialogHeader>
<div className="min-h-0 overflow-y-auto px-3 py-3 sm:px-6 sm:py-4">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">正在加载任务目录…</div>
) : filteredItems.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的任务</div>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2" role="group" aria-label="可选任务">
{filteredItems.map((item) => {
const checkboxId = `demo-task-${item.taskNo}`;
return (
<label
key={item.taskNo}
htmlFor={checkboxId}
className={`flex min-h-14 items-start gap-3 rounded-md border px-3 py-2.5 transition-colors motion-reduce:transition-none ${
item.enabled
? selected.has(item.taskNo)
? 'cursor-pointer border-primary/70 bg-primary/10 hover:bg-primary/15'
: 'cursor-pointer border-border/70 hover:border-primary/40 hover:bg-secondary/50'
: 'cursor-not-allowed border-border/40 bg-muted/20 opacity-55'
}`}
title={item.disabledReason}
>
<Checkbox
id={checkboxId}
checked={selected.has(item.taskNo)}
disabled={!item.enabled}
onCheckedChange={() => toggleTask(item.taskNo)}
aria-describedby={!item.enabled ? `${checkboxId}-reason` : undefined}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="rounded bg-cyan-950/70 px-1.5 py-0.5 font-mono text-[10px] font-medium text-cyan-300">
任务 {String(item.taskNo).padStart(2, '0')}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-muted-foreground">
{item.category}
</span>
</span>
<span className="mt-1 block text-xs leading-5 text-foreground">{item.name}</span>
{!item.enabled && item.disabledReason && (
<span id={`${checkboxId}-reason`} className="mt-0.5 block text-[10px] text-amber-400">
{item.disabledReason}
</span>
)}
</span>
</label>
);
})}
</div>
)}
</div>
<DialogFooter className="border-t border-border/60 bg-background/95 px-4 py-3 backdrop-blur sm:items-center sm:justify-between sm:px-6">
<div className="text-left" aria-live="polite" aria-atomic="true">
<div className="text-xs font-medium">
已选 {selected.size} 项 · 将执行 {selectedPluginCount} 个测量算法 + 1 次整车配准
</div>
{startBlockReason && <div className="mt-0.5 text-[10px] text-amber-400">{startBlockReason}</div>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="button" size="sm" onClick={handleRun} disabled={Boolean(startBlockReason) || pending}>
<Play className="size-3.5" />
{pending ? '提交中…' : '开始运行'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,10 @@ import { useState } from 'react';
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
interface Measurement {
taskNo?: string;
metricCode?: string;
name: string;
position?: string;
value: number | null;
unit?: string;
tolerance?: number;
@@ -24,6 +27,8 @@ interface Measurement {
passLabel?: string;
warningLabel?: string;
failLabel?: string;
legacyUnmapped?: boolean;
legacyOriginalName?: string;
}
interface ErrorStats {
@@ -113,6 +118,24 @@ export function filterVisibleMeasurements<T extends { status?: unknown }>(measur
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
}
export function sortMeasurements<T extends { taskNo?: unknown; position?: unknown }>(measurements: readonly T[]): T[] {
const positionOrder = (position: unknown) => position === '左梁' ? 0 : position === '右梁' ? 1 : 2;
return measurements
.map((measurement, index) => ({ measurement, index }))
.sort((a, b) => {
const aNo = typeof a.measurement.taskNo === 'string' && /^\d+$/.test(a.measurement.taskNo)
? Number(a.measurement.taskNo)
: Number.POSITIVE_INFINITY;
const bNo = typeof b.measurement.taskNo === 'string' && /^\d+$/.test(b.measurement.taskNo)
? Number(b.measurement.taskNo)
: Number.POSITIVE_INFINITY;
if (aNo !== bNo) return aNo - bNo;
const positionDelta = positionOrder(a.measurement.position) - positionOrder(b.measurement.position);
return positionDelta || a.index - b.index;
})
.map(({ measurement }) => measurement);
}
export function formatValue(v: number | null): string {
if (v == null) return '—';
if (!Number.isFinite(v)) return '—';
@@ -174,7 +197,7 @@ export default function GenericResultView({
}: Props) {
const [showExtra, setShowExtra] = useState(false);
const errorStats = data.errorStats ?? null;
const measurements = filterVisibleMeasurements(data.measurements ?? []);
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
const showMeasurementDetails = displayMode === 'maximized';
const images = data.images ?? [];
const files = (data.files ?? []).map((f) =>
@@ -216,9 +239,11 @@ export default function GenericResultView({
{measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">测量值</div>
<table className="w-full text-[10px] border-collapse">
<div className="overflow-x-auto">
<table className="w-full min-w-[360px] text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
<th className="text-left py-1 font-normal">项目</th>
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
@@ -228,8 +253,23 @@ export default function GenericResultView({
<tbody>
{measurements.map((m, i) => (
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
<td className="py-1 pr-2 whitespace-nowrap">
{m.legacyUnmapped || !m.taskNo ? (
<span
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}
>
待确认
</span>
) : m.taskNo}
</td>
<td className="py-1 truncate max-w-[140px]" title={m.name}>
{m.name}
{m.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{m.position}
</span>
)}
</td>
{showMeasurementDetails && <td className="text-right font-mono">
{m.baseline != null
@@ -255,6 +295,7 @@ export default function GenericResultView({
</tbody>
</table>
</div>
</div>
)}
{overlay.kind !== 'none' && onViewOverlay && (

View File

@@ -171,10 +171,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<div
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
onClick={() => {
toggleSeries(series.id);
onSelect({ seriesId: series.id, modelId: null, taskId: null });
}}
onClick={() => toggleSeries(series.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 flex-shrink-0 text-muted-foreground" />}
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />

View File

@@ -84,6 +84,23 @@ describe('aggregateDemoFlowResults', () => {
]);
});
it('自选模式只聚合选中的正式任务,任务 5 保留左右梁两个位置', () => {
const results = [result(1, 1, 'completed', {
measurements: [
{ taskNo: '2', name: '车体长度', value: 1 },
{ taskNo: '5', name: '挠度', position: '左梁', value: 2 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 3 },
{ taskNo: '10', name: '纵向中心右偏转', value: 4 },
],
})];
const model = aggregateDemoFlowResults(results, () => '算法', { selectedTaskNos: [5, 10] });
expect(model.measurementRows.map((row) => [row.measurement.taskNo, row.measurement.position])).toEqual([
['5', '左梁'],
['5', '右梁'],
['10', undefined],
]);
});
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');

View File

@@ -123,7 +123,7 @@ export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): s
export function aggregateDemoFlowResults(
results: DemoFlowSourceResult[],
getAlgorithmName: (result: DemoFlowSourceResult) => string,
options: { includeAlgorithmGroups?: boolean } = {},
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
): DemoFlowAggregation {
const sorted = results
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
@@ -132,12 +132,18 @@ export function aggregateDemoFlowResults(
const tableRows: DemoFlowTableRow[] = [];
const artifactGroups: DemoFlowArtifactGroup[] = [];
const statusNotices: DemoFlowStatusNotice[] = [];
const selectedTaskNos = options.selectedTaskNos
? new Set(options.selectedTaskNos.map(String))
: null;
for (const { result, index } of sorted) {
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
const algorithmName = getAlgorithmName(result);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : [])
.filter((measurement) => !selectedTaskNos || (
typeof measurement.taskNo === 'string' && selectedTaskNos.has(measurement.taskNo)
));
if (measurements.length > 0 && options.includeAlgorithmGroups) {
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });

View File

@@ -12,7 +12,6 @@ import { trpc } from '@/lib/trpc';
import {
LogOut,
Users,
Box,
Cloud,
Eye,
EyeOff,
@@ -278,14 +277,11 @@ export default function Home() {
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
const renderLeftPanel = () => {
if (selection.modelId == null) {
return (
<VehicleTree selection={selection} onSelect={setSelection} />
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<VehicleTree selection={selection} onSelect={setSelection} />
{selection.modelId != null && (
<>
<div className="h-px bg-border my-1" />
<TaskManager
modelId={selection.modelId}
@@ -296,6 +292,8 @@ export default function Home() {
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
/>
</>
)}
</div>
);
};
@@ -401,11 +399,8 @@ export default function Home() {
{/* 顶部工具栏 */}
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
<Box className="w-4 h-4 text-primary" />
</div>
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
<div className="flex items-center">
<h1 className="text-sm font-semibold">宏量点云处理软件</h1>
</div>
{activeModel && (

View File

@@ -32,7 +32,7 @@ export default function Login() {
}}
>
<div>
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
</div>
<div className="space-y-2">

View File

@@ -52,7 +52,7 @@ services:
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
- S3_BUCKET=pointcloud-data
- S3_REGION=us-east-1
- VITE_APP_TITLE=宏量点云处理系统
- VITE_APP_TITLE=宏量点云处理软件
# 数据目录路径
- DATA_DIR=/data
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)

View File

@@ -42,7 +42,7 @@ services:
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
- ALG_ROOT=/alg
# 应用配置
- VITE_APP_TITLE=宏量点云处理系统
- VITE_APP_TITLE=宏量点云处理软件
depends_on:
mysql:
condition: service_healthy

View File

@@ -27,7 +27,7 @@ MINIO_ROOT_PASSWORD=minioadmin123
```bash
# 应用标题
VITE_APP_TITLE=宏量点云处理系统
VITE_APP_TITLE=宏量点云处理软件
```
## 可选配置

View File

@@ -1,5 +1,11 @@
# 算法集成规范(Route B · 通用接口接入)
> **文档分工说明(2026-08-13)**:本文继续负责插件运行、Manifest、输入、进度、
> 产物和平台接入方式。算法 `result.json` 中的正式测量项、结构化任务号、检测对象、
> 多位置结果以及中间数据隔离,已由
> [`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md)
> 统一规定。若本文第 4 节旧示例与该规范冲突,**以算法交付与输出规范为准**。
本文是宏量点云处理平台对**多类算法批量接入**的对外规范,面向算法团队 +
验收方。一句话总结:
@@ -130,6 +136,11 @@ runner 在 workDir 写出 `input.json` 后调用算法 CLI,固定路径:
## 4. 输出契约 — `result.json`
以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合
[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md),
并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入
`measurements`。
算法在 `outDir/result.json` 写最终结果:
```jsonc

View File

@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
import * as db from './db.js';
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
type InspectionObjectContext,
} from './measurementCompatibility.js';
import type {
PluginManifest,
PluginProgressEvent,
@@ -111,7 +117,12 @@ async function gatherInputs(
}
}
return { task, pointCloudRecords, pointCloudPaths, cadPath };
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const inspectionContext = inspectionObjectContextFromName(inspectionObject?.name);
return { task, pointCloudRecords, pointCloudPaths, cadPath, inspectionContext };
}
async function writeInputJson(
@@ -124,6 +135,7 @@ async function writeInputJson(
cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
inspectionContext: InspectionObjectContext,
) {
const inputJson = {
schemaVersion: 1,
@@ -133,6 +145,7 @@ async function writeInputJson(
taskId,
workDir,
outDir: workDir,
context: inspectionContext,
inputs: {
pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
@@ -188,6 +201,10 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
'demoFlowCancelledAt',
'demoFlowMode',
'demoFlowSelectedTaskNos',
'demoFlowSelectedTaskCount',
'demoFlowPluginTaskNos',
] as const;
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
@@ -330,7 +347,7 @@ export async function runAlgorithmJob(
try {
await patchProgress(resultId, pluginId, 0.02, '准备输入');
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
taskId,
manifest,
pointCloudFileIds,
@@ -349,6 +366,7 @@ export async function runAlgorithmJob(
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
inspectionContext,
);
let exitCode: number | null = 0;
@@ -367,6 +385,28 @@ export async function runAlgorithmJob(
: 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const normalized = normalizeMeasurements({
pluginId,
measurements: result?.measurements,
extra: result?.extra,
context: inspectionContext,
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const requestedDemoTaskNos = Array.isArray(params.demoTaskNos)
? new Set(
params.demoTaskNos
.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
.map(String),
)
: null;
const normalizedMeasurements = Array.isArray(result?.measurements)
? requestedDemoTaskNos
? normalized.measurements.filter((measurement) => (
typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo)
))
: normalized.measurements
: undefined;
const resultData: PluginResultData = {
kind: 'plugin',
pluginId,
@@ -378,12 +418,12 @@ export async function runAlgorithmJob(
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message,
measurements: result?.measurements,
measurements: normalizedMeasurements,
errorStats: result?.errorStats,
overlay3d: result?.overlay3d,
images: result?.images,
files: result?.files,
extra: result?.extra,
extra: normalized.extra,
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
@@ -393,7 +433,7 @@ export async function runAlgorithmJob(
await db.updateTaskAnalysisResult(resultId, {
status: finalStatus,
errorStats: result?.errorStats ?? null,
measurements: result?.measurements ?? null,
measurements: normalizedMeasurements ?? null,
resultData,
});

84
server/demoFlow.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from './demoFlow';
import { listPlugins } from './plugins/registry';
import type { PluginManifest } from './plugins/types';
function manifest(id: string, tasks: PluginManifest['demoTasks']): PluginManifest {
return {
id,
name: id,
description: id,
version: '1',
schemaVersion: 1,
demoTasks: tasks,
demoSelection: { kind: 'task-nos', param: 'demoTaskNos' },
runtime: { kind: 'mock', fixture: 'unused.json' },
};
}
const manifests = [
manifest('distance', [
{ taskNo: 2, name: '车体长度', category: '尺寸', inspectionObjects: ['head', 'middle'] },
]),
manifest('contour', [
{ taskNo: 11, name: '车钩面板的平整度', category: '轮廓', inspectionObjects: ['head', 'middle'] },
]),
manifest('coupler', [
{ taskNo: 9, name: '参考面右偏转', category: '车钩', inspectionObjects: ['middle'] },
{ taskNo: 10, name: '纵向中心右偏转', category: '车钩', inspectionObjects: ['head'] },
]),
];
describe('demo task catalog', () => {
it('按数值排序并标记不适用于当前检测对象的任务', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
expect(catalog.items.map((item) => item.taskNo)).toEqual([2, 9, 10, 11]);
expect(catalog.total).toBe(3);
expect(catalog.items.find((item) => item.taskNo === 9)).toMatchObject({ enabled: false });
});
it('跨算法选择转换为最小作业集合并去重', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
const plan = resolveSelectedDemoJobs(manifests, catalog, [11, 2, 10, 2]);
expect(plan.taskNos).toEqual([2, 10, 11]);
expect(plan.jobs.map((job) => job.pluginId)).toEqual(['distance', 'coupler', 'contour']);
expect(plan.jobs).toHaveLength(3);
});
it('拒绝当前检测对象不可运行的任务', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
expect(() => resolveSelectedDemoJobs(manifests, catalog, [9])).toThrow('不适用于当前头车');
});
});
describe('actual plugin demo task catalog', () => {
const actualManifests = listPlugins();
it('返回真实非连续任务号、名称与检测对象动态数量', () => {
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
const middle = buildDemoTaskCatalog(actualManifests, 'middle', '中车');
expect(head.total).toBe(39);
expect(middle.total).toBe(26);
expect(head.items.find((item) => item.taskNo === 11)?.name).toBe('车钩面板的平整度');
expect(head.items.some((item) => item.taskNo === 3)).toBe(false);
expect(head.items.map((item) => item.taskNo)).toEqual(
[...head.items.map((item) => item.taskNo)].sort((a, b) => a - b),
);
});
it('任务 2、11、10 生成三个插件作业,单个车钩任务只生成一次共享作业', () => {
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
const mixed = resolveSelectedDemoJobs(actualManifests, head, [2, 11, 10]);
expect(mixed.jobs.map((job) => job.pluginId)).toEqual([
'train-measurement',
'coupler-panel',
'flatness-suite',
]);
const couplerOnly = resolveSelectedDemoJobs(actualManifests, head, [8]);
expect(couplerOnly.jobs).toEqual([{
pluginId: 'coupler-panel',
taskNos: [8],
params: { demoTaskNos: [8] },
}]);
});
});

120
server/demoFlow.ts Normal file
View File

@@ -0,0 +1,120 @@
import type {
DemoInspectionObject,
PluginDemoTask,
PluginManifest,
} from './plugins/types.js';
export interface DemoTaskCatalogItem extends PluginDemoTask {
pluginId: string;
pluginName: string;
enabled: boolean;
disabledReason?: string;
}
export interface DemoTaskCatalog {
inspectionObject: DemoInspectionObject | 'unknown';
inspectionObjectName?: string;
total: number;
items: DemoTaskCatalogItem[];
}
export interface DemoSelectedPluginJob {
pluginId: string;
taskNos: number[];
params: Record<string, unknown>;
}
function disabledReasonFor(
task: PluginDemoTask,
inspectionObject: DemoInspectionObject | 'unknown',
inspectionObjectName?: string,
): string | undefined {
if (!task.inspectionObjects?.length) return undefined;
if (inspectionObject === 'unknown') {
return inspectionObjectName
? `检测对象“${inspectionObjectName}”尚未配置任务适用规则`
: '当前任务尚未确认头车或中车检测对象';
}
if (!task.inspectionObjects.includes(inspectionObject)) {
return `不适用于当前${inspectionObject === 'head' ? '头车' : '中车'}检测对象`;
}
return undefined;
}
/** 从插件 manifest 构建服务端权威任务目录。 */
export function buildDemoTaskCatalog(
manifests: readonly PluginManifest[],
inspectionObject: DemoInspectionObject | 'unknown',
inspectionObjectName?: string,
): DemoTaskCatalog {
const items: DemoTaskCatalogItem[] = [];
const claimedTaskNos = new Map<number, string>();
for (const manifest of manifests) {
for (const task of manifest.demoTasks ?? []) {
const previousPlugin = claimedTaskNos.get(task.taskNo);
if (previousPlugin) {
throw new Error(`演示任务 ${task.taskNo} 被插件 ${previousPlugin} 与 ${manifest.id} 重复声明`);
}
claimedTaskNos.set(task.taskNo, manifest.id);
const disabledReason = disabledReasonFor(task, inspectionObject, inspectionObjectName);
items.push({
...task,
pluginId: manifest.id,
pluginName: manifest.name,
enabled: disabledReason == null,
...(disabledReason ? { disabledReason } : {}),
});
}
}
items.sort((a, b) => a.taskNo - b.taskNo);
return {
inspectionObject,
...(inspectionObjectName ? { inspectionObjectName } : {}),
total: items.filter((item) => item.enabled).length,
items,
};
}
/** 校验所选任务并转换为最小插件作业集合。 */
export function resolveSelectedDemoJobs(
manifests: readonly PluginManifest[],
catalog: DemoTaskCatalog,
requestedTaskNos: readonly number[],
): { taskNos: number[]; jobs: DemoSelectedPluginJob[] } {
const taskNos = Array.from(new Set(requestedTaskNos)).sort((a, b) => a - b);
if (taskNos.length === 0) throw new Error('请至少选择一个任务');
const itemByTaskNo = new Map(catalog.items.map((item) => [item.taskNo, item]));
for (const taskNo of taskNos) {
const item = itemByTaskNo.get(taskNo);
if (!item) throw new Error(`任务 ${taskNo} 不存在或当前算法未提供`);
if (!item.enabled) throw new Error(`任务 ${taskNo} 当前不可执行:${item.disabledReason ?? '运行条件不满足'}`);
}
const taskNosByPlugin = new Map<string, number[]>();
for (const taskNo of taskNos) {
const pluginId = itemByTaskNo.get(taskNo)!.pluginId;
const pluginTaskNos = taskNosByPlugin.get(pluginId) ?? [];
pluginTaskNos.push(taskNo);
taskNosByPlugin.set(pluginId, pluginTaskNos);
}
const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest]));
const jobs: DemoSelectedPluginJob[] = [];
for (const [pluginId, pluginTaskNos] of Array.from(taskNosByPlugin.entries())) {
const manifest = manifestById.get(pluginId);
if (!manifest) throw new Error(`未注册的算法插件:${pluginId}`);
if (manifest.demoSelection?.kind !== 'task-nos' || !manifest.demoSelection.param) {
throw new Error(`算法插件 ${pluginId} 未配置任务选择执行方式`);
}
jobs.push({
pluginId,
taskNos: pluginTaskNos,
params: { [manifest.demoSelection.param]: pluginTaskNos },
});
}
return { taskNos, jobs };
}

View File

@@ -7,6 +7,11 @@ import { samplePointcloudFiles } from './pointcloudSample.js';
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
import { authenticateRequest } from './_core/auth.js';
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from './measurementCompatibility.js';
const router = Router();
@@ -562,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req,
try {
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
const task = await db.getInspectionTaskById(taskId);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const normalized = normalizeMeasurements({
pluginId: row.pluginId ?? (typeof existingRd.pluginId === 'string' ? existingRd.pluginId : undefined),
measurements: result.measurements ?? existingRd.measurements,
extra: result.extra ?? existingRd.extra,
context: inspectionObjectContextFromName(inspectionObject?.name),
algorithmProtocolVersion: result.schemaVersion ?? row.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const mergedRd = {
...existingRd,
message: result.message ?? existingRd.message,
measurements: result.measurements ?? existingRd.measurements,
measurements: normalized.measurements,
errorStats: result.errorStats ?? existingRd.errorStats,
overlay3d: result.overlay3d ?? existingRd.overlay3d,
images: result.images ?? existingRd.images,
files: result.files ?? existingRd.files,
extra: result.extra ?? existingRd.extra,
extra: normalized.extra,
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
};
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
await db.updateTaskAnalysisResult(resultId, {
measurements: normalized.measurements,
resultData: mergedRd,
});
res.json({ success: true, overlay3d: result.overlay3d });
} catch (e) {
res.status(500).json({ error: String(e) });

View File

@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
inspectionObjectContextFromName,
normalizeMeasurements,
sortNormalizedMeasurements,
} from './measurementCompatibility.js';
const valueFields = {
value: 1.25,
unit: 'mm',
tolerance: 0.8,
toleranceLabel: '+0.8/-0.2',
baseline: 375,
status: 'pass' as const,
passLabel: '通过',
};
afterEach(() => {
delete process.env.MEASUREMENT_COMPAT_ENABLED;
});
describe('normalizeMeasurements', () => {
it.each([
['任务46_车体横断面对角线差值', '46', '车体横断面对角线差值'],
['任务11 · 车钩面板的平整度', '11', '车钩面板的平整度'],
])('拆分通用旧任务名 %s', (originalName, taskNo, name) => {
const output = normalizeMeasurements({
pluginId: 'train-measurement',
measurements: [{ name: originalName, ...valueFields }],
});
expect(output.measurements[0]).toMatchObject({
taskNo,
metricCode: `legacy.train_measurement.task_${taskNo}`,
name,
legacyOriginalName: originalName,
...valueFields,
});
});
it('新协议结果原样保留且重复调用一致', () => {
const measurement = { taskNo: '8', metricCode: 'coupler.reference-offset', name: '参考面右偏转', ...valueFields, customConclusion: '合格' };
const first = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [measurement], extra: { marker: true } });
const second = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: first.measurements, extra: first.extra });
expect(first.measurements).toEqual([measurement]);
expect(second).toEqual(first);
});
it('任务 5 保留左右梁两行和位置顺序', () => {
const output = normalizeMeasurements({
pluginId: 'underframe-deflection',
measurements: [
{ name: '右梁 · 纵向梁到直线的垂直偏差(挠度)', value: 2 },
{ name: '左梁 · 纵向梁到直线的垂直偏差(挠度)', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: '5', name: '挠度', position: '左梁', value: 1 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 2 },
]);
});
it.each([
['头车', '8', '10'],
['中车', '9', '12'],
['中间车', '9', '12'],
])('%s 车钩精确映射为正确任务号', (objectName, referenceTaskNo, longitudinalTaskNo) => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName(objectName),
measurements: [
{ name: '车钩面板距纵向中心的右偏转角', value: 2 },
{ name: '车钩面板距参考面的右偏转角', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: referenceTaskNo, name: '参考面右偏转', value: 1 },
{ taskNo: longitudinalTaskNo, name: '纵向中心右偏转', value: 2 },
]);
});
it('未知检测对象不默认成头车', () => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('拖车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }],
});
expect(output.measurements[0]).toMatchObject({ legacyUnmapped: true });
expect(output.measurements[0].taskNo).toBeUndefined();
expect(output.warnings).toHaveLength(1);
});
it('车钩中间量移入 extra.intermediateMetrics 并保留字段', () => {
const intermediate = ['最大 x 偏差 dx_y', '最大 x 偏差 dx_z', '面板 y 向最大偏差 D_y', '面板 z 向最大偏差 D_z']
.map((name, index) => ({ name, value: index, unit: 'mm', status: 'info' }));
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('头车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }, ...intermediate],
extra: { marker: 'kept' },
});
expect(output.measurements).toHaveLength(1);
expect(output.extra).toEqual({ marker: 'kept', intermediateMetrics: intermediate });
});
it('未知名称不模糊猜测并生成稳定编码', () => {
const source = { name: '类似车钩面板距参考面的右偏转角但不是', ...valueFields, customConclusion: '人工通过' };
const output = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [source] });
expect(output.measurements[0]).toMatchObject({
...source,
legacyUnmapped: true,
legacyOriginalName: source.name,
});
expect(output.measurements[0].metricCode).toMatch(/^legacy\.coupler_panel\.unmapped\.[0-9a-f]{8}$/);
});
it('按数字任务号排序且未映射项最后', () => {
const sorted = sortNormalizedMeasurements([
{ taskNo: '46', name: '46' },
{ name: 'unknown' },
{ taskNo: '5', position: '右梁', name: '5R' },
{ taskNo: '11', name: '11' },
{ taskNo: '5', position: '左梁', name: '5L' },
]);
expect(sorted.map((item) => item.name)).toEqual(['5L', '5R', '11', '46', 'unknown']);
});
it('环境开关关闭时原样返回', () => {
process.env.MEASUREMENT_COMPAT_ENABLED = '0';
const measurements = [{ name: '任务46_原始', value: 1 }];
const output = normalizeMeasurements({ pluginId: 'train-measurement', measurements, extra: { raw: true } });
expect(output).toEqual({ measurements, extra: { raw: true }, warnings: [], converted: false });
});
});

View File

@@ -0,0 +1,261 @@
import type { PluginMeasurement } from './plugins/types.js';
export type InspectionObjectKind = 'head' | 'middle' | 'unknown';
export interface InspectionObjectContext {
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface CompatibilityWarning {
code: 'legacy-unmapped';
pluginId: string;
originalName: string;
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface NormalizeMeasurementsInput {
pluginId?: string | null;
measurements?: unknown;
extra?: unknown;
context?: InspectionObjectContext;
algorithmProtocolVersion?: string | number | null;
}
export interface NormalizeMeasurementsOutput {
measurements: PluginMeasurement[];
extra: Record<string, unknown>;
warnings: CompatibilityWarning[];
converted: boolean;
}
const COUPLER_INTERMEDIATE_NAMES = new Set([
'最大 x 偏差 dx_y',
'最大 x 偏差 dx_z',
'面板 y 向最大偏差 D_y',
'面板 z 向最大偏差 D_z',
]);
const DEFLECTION_NAMES = new Map<string, string>([
['左梁 · 纵向梁到直线的垂直偏差(挠度)', '左梁'],
['右梁 · 纵向梁到直线的垂直偏差(挠度)', '右梁'],
['挠度左梁', '左梁'],
['挠度右梁', '右梁'],
]);
const COUPLER_NAMES = {
longitudinal: new Set([
'车钩面板距纵向中心的右偏转角',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
]),
reference: new Set([
'车钩面板距参考面的右偏转角',
'车钩面板距参考面的右偏转角(头车一位端处车钩)',
]),
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function pluginKey(pluginId: string): string {
return (pluginId || 'unknown').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'unknown';
}
function stableNameHash(value: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function legacyMetricCode(pluginId: string, taskNo: string): string {
return `legacy.${pluginKey(pluginId)}.task_${taskNo}`;
}
function unmappedMetricCode(pluginId: string, name: string): string {
return `legacy.${pluginKey(pluginId)}.unmapped.${stableNameHash(name)}`;
}
export function inspectionObjectContextFromName(name?: string | null): InspectionObjectContext {
const normalized = name?.trim() ?? '';
return {
inspectionObject: normalized === '头车'
? 'head'
: normalized === '中车' || normalized === '中间车'
? 'middle'
: 'unknown',
inspectionObjectName: normalized || undefined,
};
}
export function measurementCompatibilityEnabled(): boolean {
return process.env.MEASUREMENT_COMPAT_ENABLED !== '0';
}
function positionOrder(position: unknown): number {
if (position === '左梁') return 0;
if (position === '右梁') return 1;
return 2;
}
export function sortNormalizedMeasurements<T extends { taskNo?: unknown; position?: unknown }>(items: readonly T[]): T[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aNo = typeof a.item.taskNo === 'string' && /^\d+$/.test(a.item.taskNo) ? Number(a.item.taskNo) : Number.POSITIVE_INFINITY;
const bNo = typeof b.item.taskNo === 'string' && /^\d+$/.test(b.item.taskNo) ? Number(b.item.taskNo) : Number.POSITIVE_INFINITY;
if (aNo !== bNo) return aNo - bNo;
const positionDelta = positionOrder(a.item.position) - positionOrder(b.item.position);
return positionDelta || a.index - b.index;
})
.map(({ item }) => item);
}
export function normalizeMeasurements(input: NormalizeMeasurementsInput): NormalizeMeasurementsOutput {
const pluginId = input.pluginId ?? '';
const context = input.context ?? { inspectionObject: 'unknown' as const };
const sourceMeasurements = Array.isArray(input.measurements)
? input.measurements.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
: [];
const sourceExtra = asRecord(input.extra);
if (!measurementCompatibilityEnabled()) {
return {
measurements: sourceMeasurements as unknown as PluginMeasurement[],
extra: sourceExtra,
warnings: [],
converted: false,
};
}
const measurements: PluginMeasurement[] = [];
const intermediateMetrics = Array.isArray(sourceExtra.intermediateMetrics)
? [...sourceExtra.intermediateMetrics]
: [];
const warnings: CompatibilityWarning[] = [];
let converted = false;
sourceMeasurements.forEach((source) => {
const item = { ...source };
const originalName = typeof item.name === 'string' ? item.name : '';
// 新协议项必须同时具备 taskNo 和 metricCode;重复规范化时保持原样。
if (typeof item.taskNo === 'string' && item.taskNo && typeof item.metricCode === 'string' && item.metricCode) {
measurements.push(item as unknown as PluginMeasurement);
return;
}
if (pluginId === 'coupler-panel' && COUPLER_INTERMEDIATE_NAMES.has(originalName)) {
intermediateMetrics.push(item);
converted = true;
return;
}
const generic = /^任务(\d+)(?:_|\s*·\s*)(.+)$/.exec(originalName);
if (generic) {
const [, taskNo, name] = generic;
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: name.trim(),
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
if (pluginId === 'underframe-deflection') {
const position = DEFLECTION_NAMES.get(originalName);
if (position) {
measurements.push({
...item,
taskNo: '5',
metricCode: legacyMetricCode(pluginId, '5'),
name: '挠度',
position,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
if (pluginId === 'coupler-panel') {
const isLongitudinal = COUPLER_NAMES.longitudinal.has(originalName);
const isReference = COUPLER_NAMES.reference.has(originalName);
if (isLongitudinal || isReference) {
const taskNo = context.inspectionObject === 'head'
? (isLongitudinal ? '10' : '8')
: context.inspectionObject === 'middle'
? (isLongitudinal ? '12' : '9')
: undefined;
if (taskNo) {
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: isLongitudinal ? '纵向中心右偏转' : '参考面右偏转',
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
}
const unmapped = {
...item,
metricCode: unmappedMetricCode(pluginId, originalName),
legacyUnmapped: true,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement;
measurements.push(unmapped);
warnings.push({
code: 'legacy-unmapped',
pluginId,
originalName,
inspectionObject: context.inspectionObject,
inspectionObjectName: context.inspectionObjectName,
});
converted = true;
});
return {
measurements: sortNormalizedMeasurements(measurements),
extra: intermediateMetrics.length > 0 ? { ...sourceExtra, intermediateMetrics } : sourceExtra,
warnings,
converted,
};
}
export function logCompatibilityWarnings(resultId: number, warnings: readonly CompatibilityWarning[]): void {
warnings.forEach((warning) => {
const signature = [
resultId,
warning.pluginId,
warning.originalName,
warning.inspectionObject,
warning.inspectionObjectName ?? '',
].join('\u0000');
if (loggedWarningSignatures.has(signature)) return;
loggedWarningSignatures.add(signature);
console.warn('[MeasurementCompatibility] legacy measurement unmapped', {
resultId,
pluginId: warning.pluginId,
originalName: warning.originalName,
inspectionObject: warning.inspectionObject,
inspectionObjectName: warning.inspectionObjectName,
});
});
}
/** 避免历史结果轮询每次重复打印同一条告警;进程重启后会重新记录一次。 */
const loggedWarningSignatures = new Set<string>();

View File

@@ -155,23 +155,53 @@ def main() -> int:
offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right))
offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref))
measurements = [
{
inspection_object = str((payload.get("context") or {}).get("inspectionObject") or "unknown")
longitudinal_task_no = "10" if inspection_object == "head" else "12" if inspection_object == "middle" else None
reference_task_no = "8" if inspection_object == "head" else "9" if inspection_object == "middle" else None
raw_selected_task_nos = config.get("demoTaskNos")
selected_task_nos = (
{str(int(value)) for value in raw_selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()}
if isinstance(raw_selected_task_nos, list)
else None
)
def _selected(task_no: str | None) -> bool:
return selected_task_nos is None or (task_no is not None and task_no in selected_task_nos)
measurements = []
if _selected(longitudinal_task_no):
longitudinal_measurement = {
"name": "车钩面板距纵向中心的右偏转角",
"value": round(offset_right, 6),
"unit": "mm",
"baseline": 700.0,
"tolerance": 1.0,
"status": _status_for(offset_right, 1.0),
},
{
}
if longitudinal_task_no:
longitudinal_measurement.update({
"taskNo": longitudinal_task_no,
"metricCode": "coupler.longitudinal_right_offset",
"name": "纵向中心右偏转",
})
measurements.append(longitudinal_measurement)
if _selected(reference_task_no):
reference_measurement = {
"name": "车钩面板距参考面的右偏转角",
"value": round(offset_ref, 6),
"unit": "mm",
"baseline": 375.0,
"tolerance": 0.8,
"status": _status_for(offset_ref, 0.8),
},
}
if reference_task_no:
reference_measurement.update({
"taskNo": reference_task_no,
"metricCode": "coupler.reference_right_offset",
"name": "参考面右偏转",
})
measurements.append(reference_measurement)
measurements.extend([
{
"name": "最大 x 偏差 dx_y",
"value": dx_y,
@@ -198,7 +228,7 @@ def main() -> int:
"unit": "mm",
"status": "info",
},
]
])
payload_out = {
"schemaVersion": getattr(result, "schema_version", "1.1"),

View File

@@ -226,8 +226,17 @@ def main() -> int:
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
if not result_template_path.is_file():
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
selected_task_nos = config.get("demoTaskNos")
selected_task_ids = (
[str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()]
if isinstance(selected_task_nos, list)
else None
)
if selected_task_ids == []:
raise RuntimeError("请至少选择一个轮廓测量任务")
res = pipeline.run_contour_batch(
all_tasks=True,
task_ids=selected_task_ids,
all_tasks=selected_task_ids is None,
data_root=str(data_root),
pointcloud_kind="noise",
point_chunk_size=8192,

View File

@@ -0,0 +1,24 @@
import importlib.util
import unittest
from pathlib import Path
def _load_adapter(name: str):
path = Path(__file__).with_name(name)
spec = importlib.util.spec_from_file_location(path.stem, path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class DemoTaskSelectionTest(unittest.TestCase):
def test_train_task_number_parsing_and_selection(self):
adapter = _load_adapter("train_measurement_run.py")
self.assertEqual(adapter._task_no_from_name("任务46_车体横断面对角线差值"), "46")
self.assertEqual(adapter._selected_task_nos({"demoTaskNos": [2, 46, 2]}), {"2", "46"})
self.assertIsNone(adapter._selected_task_nos({}))
if __name__ == "__main__":
unittest.main()

View File

@@ -19,6 +19,7 @@ v2.2 变更:
from __future__ import annotations
import os
import re
import sys
import traceback
from pathlib import Path
@@ -33,6 +34,18 @@ from _plugin_io import (
)
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
@@ -155,6 +168,13 @@ def main() -> int:
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"

View File

@@ -49,6 +49,11 @@ def main() -> int:
try:
emit_progress(0.05, "加载挠度算法包 v1.2.1")
selected_task_nos = config.get("demoTaskNos")
if isinstance(selected_task_nos, list) and 5 not in selected_task_nos:
write_error_result(out_dir, "构架挠度插件仅支持任务 5")
return 1
# ── 输入文件 ────────────────────────────────────────────────────────
pointcloud_path = first_pointcloud(payload)
if not pointcloud_path:

View File

@@ -8,6 +8,13 @@
"category": "几何测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 8, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
{ "taskNo": 9, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["middle"] },
{ "taskNo": 10, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
{ "taskNo": 12, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["middle"] }
],
"inputs": [
{
"name": "profile",

View File

@@ -8,6 +8,29 @@
"category": "几何测量",
"requiresPointCloud": false,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 11, "name": "车钩面板的平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 13, "name": "车长方向平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 17, "name": "车高方向弧形轮廓测量", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 20, "name": "门柱平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 22, "name": "从门口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 23, "name": "从窗口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 25, "name": "内风挡外圈平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 26, "name": "内风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 27, "name": "外风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 31, "name": "车头前墙区域平面度", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 34, "name": "司机室门外弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 35, "name": "前窗框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 36, "name": "前窗框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 37, "name": "顶灯框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 38, "name": "顶灯框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 39, "name": "逃生窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 40, "name": "逃生窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 41, "name": "曲面窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 42, "name": "曲面窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 45, "name": "地板平度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }
],
"inputs": [
{
"name": "useBatchDatasets",

View File

@@ -8,6 +8,25 @@
"category": "尺寸测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 1, "name": "转向架距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 2, "name": "车体长度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 7, "name": "边梁下平面到参考线的距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 15, "name": "侧墙最大点到参考平面距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 16, "name": "车体最大宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 18, "name": "门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 21, "name": "侧门高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 28, "name": "端门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 29, "name": "端门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 30, "name": "参考线到司机室辅助孔距离", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 32, "name": "司机室登车门门口宽度", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 33, "name": "司机室登车门门口高度", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 43, "name": "窗口下沿高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 44, "name": "车顶高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 46, "name": "车体横断面对角线差值", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 47, "name": "内端墙门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }
],
"uiPanel": "train-measurement",
"inputs": [
{

View File

@@ -8,6 +8,10 @@
"category": "几何测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] }
],
"inputs": [
{
"name": "leftRoiPath",

View File

@@ -68,6 +68,22 @@ export interface PluginRuntimeMock {
export type PluginRuntime = PluginRuntimeProcess | PluginRuntimeMock;
export type DemoInspectionObject = 'head' | 'middle';
/** 插件可在“全流程任务选择”中暴露的正式检验任务。 */
export interface PluginDemoTask {
taskNo: number;
name: string;
category: string;
inspectionObjects?: DemoInspectionObject[];
}
/** 任务选择如何转换为插件参数。 */
export interface PluginDemoSelection {
kind: 'task-nos';
param: string;
}
/** 插件 manifest(写在 server/plugins/manifests/*.json) */
export interface PluginManifest {
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
@@ -89,6 +105,10 @@ export interface PluginManifest {
requiresCad?: boolean;
/** 是否需要点云(默认 true) */
requiresPointCloud?: boolean;
/** “选择任务运行”模式的权威任务目录。 */
demoTasks?: PluginDemoTask[];
/** 选中任务传给适配器的方式。 */
demoSelection?: PluginDemoSelection;
runtime: PluginRuntime;
}
@@ -105,18 +125,30 @@ export interface PluginProgressEvent {
/** 测量项(GenericResultView 渲染) */
export interface PluginMeasurement {
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
taskNo?: string;
/** 跨版本稳定的测量项编码。 */
metricCode?: string;
name: string;
/** 同任务内的位置,例如左梁、右梁。 */
position?: string;
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
value: number | null;
unit?: string;
tolerance?: number;
/** 无法安全数值化的原始公差表达式。 */
toleranceLabel?: string;
/** 斜率类测量的基准长度(mm)。 */
baseline?: number;
/** pass / warning / fail / info */
status?: 'pass' | 'warning' | 'fail' | 'info';
passLabel?: string;
warningLabel?: string;
failLabel?: string;
/** 兼容层无法确定任务号时置为 true。 */
legacyUnmapped?: boolean;
/** 兼容转换前的原始项目名,用于追溯。 */
legacyOriginalName?: string;
}
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */

View File

@@ -13,9 +13,15 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js";
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from "./measurementCompatibility.js";
import {
clearLoginFailures,
createSessionToken,
@@ -308,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户
};
}
interface DemoFlowMeasurementJob {
pluginId: string;
params?: Record<string, unknown>;
taskNos?: number[];
}
interface DemoFlowMetadata {
demoFlowMode: "all" | "selected";
demoFlowSelectedTaskNos?: number[];
demoFlowSelectedTaskCount?: number;
}
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
registrationResultId: number;
measurementPluginIds: string[];
measurementJobs: DemoFlowMeasurementJob[];
demoFlowRunId: string;
metadata: DemoFlowMetadata;
}) {
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
if (registrationResult?.status !== "completed") {
@@ -330,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
return;
}
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
const pluginId = input.measurementPluginIds[idx];
for (let idx = 0; idx < input.measurementJobs.length; idx += 1) {
const job = input.measurementJobs[idx];
const pluginId = job.pluginId;
const manifest = getPlugin(pluginId);
await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId,
params: paramsForDemoPlugin(pluginId),
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
? [registered.id]
: [input.sourcePointCloudFileId],
@@ -345,14 +365,86 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
demoFlowRunId: input.demoFlowRunId,
demoFlowRole: "measurement",
demoFlowIndex: idx + 2,
demoFlowTotal: input.measurementPluginIds.length + 1,
demoFlowTotal: input.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
...(job.taskNos ? { demoFlowPluginTaskNos: job.taskNos } : {}),
},
});
}
}
async function startDemoFlow(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
measurementJobs: DemoFlowMeasurementJob[];
metadata: DemoFlowMetadata;
}) {
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const job of input.measurementJobs) {
const manifest = getPlugin(job.pluginId);
if (!manifest) throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${job.pluginId}` });
if (manifest.category === "配准") {
throw new TRPCError({ code: "BAD_REQUEST", message: "全流程的测量算法列表不能包含配准算法。" });
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
if (sourcePointcloud.source === "registered") {
throw new TRPCError({ code: "BAD_REQUEST", message: "请选择原始点云,系统会自动找到对应配准产物。" });
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: input.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[demoFlow] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementJobs: input.measurementJobs,
demoFlowRunId,
metadata: input.metadata,
});
}).catch((err) => console.error("[demoFlow] 配准链式提交失败:", err));
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: input.measurementJobs.length,
scheduler: algorithmJobStats(),
};
}
export const appRouter = router({
system: systemRouter,
@@ -963,7 +1055,43 @@ export const appRouter = router({
list: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
return db.listTaskAnalysisResults(input.taskId);
const [rows, task] = await Promise.all([
db.listTaskAnalysisResults(input.taskId),
db.getInspectionTaskById(input.taskId),
]);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return rows.map((row) => {
const resultData = row.resultData && typeof row.resultData === 'object' && !Array.isArray(row.resultData)
? { ...(row.resultData as Record<string, unknown>) }
: {};
const pluginId = row.pluginId ?? (typeof resultData.pluginId === 'string' ? resultData.pluginId : undefined);
const rawMeasurements = Array.isArray(row.measurements)
? row.measurements
: resultData.measurements;
if (!pluginId || !Array.isArray(rawMeasurements)) return row;
const normalized = normalizeMeasurements({
pluginId,
measurements: rawMeasurements,
extra: resultData.extra,
context,
algorithmProtocolVersion: row.schemaVersion ?? (resultData.schemaVersion as string | number | undefined),
});
logCompatibilityWarnings(row.id, normalized.warnings);
return {
...row,
measurements: normalized.measurements,
resultData: {
...resultData,
measurements: normalized.measurements,
extra: normalized.extra,
},
};
});
}),
runAnalysis: protectedProcedure
@@ -1284,6 +1412,19 @@ export const appRouter = router({
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
}),
/** 当前检测对象可用的正式任务目录。 */
demoTaskCatalog: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return buildDemoTaskCatalog(listPlugins(), context.inspectionObject, context.inspectionObjectName);
}),
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
runDemoAll: protectedProcedure
.input(
@@ -1296,85 +1437,50 @@ export const appRouter = router({
)
.mutation(async ({ input }) => {
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const pluginId of measurementPluginIds) {
const manifest = getPlugin(pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${pluginId}`,
});
}
if (manifest.category === "配准") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "一键全流程演示的测量算法列表不能包含配准算法。",
});
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
}
if (sourcePointcloud.source === "registered") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "请选择原始点云,系统会自动找到对应配准产物。",
});
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise
.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
return startDemoFlow({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementPluginIds,
demoFlowRunId,
});
})
.catch((err) => {
console.error("[runDemoAll] 配准链式提交失败:", err);
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
metadata: { demoFlowMode: "all" },
});
}),
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: measurementPluginIds.length,
scheduler: algorithmJobStats(),
};
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
runDemoSelected: protectedProcedure
.input(z.object({
taskId: z.number(),
sourcePointCloudFileId: z.number(),
cadFileId: z.number(),
taskNos: z.array(z.number().int().positive()).min(1).max(64),
}))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
const manifests = listPlugins();
const catalog = buildDemoTaskCatalog(manifests, context.inspectionObject, context.inspectionObjectName);
let selectedPlan: ReturnType<typeof resolveSelectedDemoJobs>;
try {
selectedPlan = resolveSelectedDemoJobs(manifests, catalog, input.taskNos);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "所选任务无效" });
}
const result = await startDemoFlow({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
measurementJobs: selectedPlan.jobs,
metadata: {
demoFlowMode: "selected",
demoFlowSelectedTaskNos: selectedPlan.taskNos,
demoFlowSelectedTaskCount: selectedPlan.taskNos.length,
},
});
return { ...result, selectedTaskNos: selectedPlan.taskNos };
}),
cancelDemoAll: protectedProcedure