Compare commits
23 Commits
main
...
e3dc7ebe21
| Author | SHA1 | Date | |
|---|---|---|---|
| e3dc7ebe21 | |||
| f879328562 | |||
| 4b9a2f4cd9 | |||
| 185dd36bf3 | |||
| ac1387debc | |||
| d33b280a93 | |||
| 9f4482bea1 | |||
| 29a69793db | |||
| 3db84a7fb2 | |||
| 66c7dd1b65 | |||
| 9262390037 | |||
| 33c5fb9b9c | |||
| 752ea3f944 | |||
| 8555aa2f2e | |||
| 683e4d6c07 | |||
| c1d4c1e4ce | |||
| 6076063be0 | |||
| e4915f327c | |||
| b87aa3f990 | |||
| aa2bd80418 | |||
| dc760de7a2 | |||
| b5440fa1c3 | |||
| 7e2e56a50d |
39
.agents/skills/package-source-sha1/SKILL.md
Normal file
39
.agents/skills/package-source-sha1/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: package-source-sha1
|
||||||
|
description: 为 massive-cloud-v3 生成可复现的纯源码 ZIP 包、内容清单和 40 位 SHA-1。用户要求打包整个项目、交付源码包、排除点云/CAD/数据库/构建产物/密钥,或计算项目 ZIP 哈希时使用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 打包源码并计算 SHA-1
|
||||||
|
|
||||||
|
使用 `scripts/package_source.py`,不要临时拼装 `find`、`tar` 或 `zip` 命令。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
1. 根据项目 `AGENTS.md` 查看开发服务器 `/home/lipan/massive-cloud-v3` 的实时工作树和 `git status`。
|
||||||
|
2. 先运行 `--dry-run` 检查纳入、排除数量和排除原因;发现异常大文件或新的数据格式时,先更新脚本规则。
|
||||||
|
3. 运行正式打包。脚本只从 `git ls-files --cached --others --exclude-standard` 取候选文件,因此包含已跟踪文件和未忽略的新文件,也包含尚未提交的实际代码改动。
|
||||||
|
4. 将 ZIP、`.sha1` 和 `.manifest.json` 一起交付。明确说明包是否包含未提交改动,并报告基线提交。
|
||||||
|
5. 用 `unzip -t` 验证 ZIP;再独立运行 `sha1sum` 或 `shasum -a 1`,确认与 `.sha1` 一致且为 40 位小写十六进制。
|
||||||
|
|
||||||
|
## 固定规则
|
||||||
|
|
||||||
|
- 保留源码、测试、SQL、配置模板、依赖清单/锁文件、容器配置、脚本和文档。
|
||||||
|
- 排除 Git 历史、被 `.gitignore` 忽略的内容、真实 `.env`、密钥、日志、缓存、依赖目录和构建产物。
|
||||||
|
- 排除点云、CAD/网格、数据库、表格结果、归档包、模型权重等非源码文件。
|
||||||
|
- 不要使用 `git archive`:它会遗漏工作区中尚未提交的实际代码。
|
||||||
|
- ZIP 使用固定时间戳、固定顺序和规范权限;相同文件内容应生成相同哈希。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/package_source.py \
|
||||||
|
--source /home/lipan/massive-cloud-v3 \
|
||||||
|
--output /tmp/massive-cloud-v3-source.zip \
|
||||||
|
--dry-run
|
||||||
|
|
||||||
|
python3 scripts/package_source.py \
|
||||||
|
--source /home/lipan/massive-cloud-v3 \
|
||||||
|
--output /tmp/massive-cloud-v3-source.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把产物写进被打包的源码目录;优先使用 `/tmp`,再复制到交付位置。
|
||||||
4
.agents/skills/package-source-sha1/agents/openai.yaml
Normal file
4
.agents/skills/package-source-sha1/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "源码包 SHA-1"
|
||||||
|
short_description: "可复现地打包项目纯源码并自动生成 SHA-1 校验值"
|
||||||
|
default_prompt: "使用 $package-source-sha1 打包当前项目纯源码并返回 ZIP 的 SHA-1。"
|
||||||
176
.agents/skills/package-source-sha1/scripts/package_source.py
Normal file
176
.agents/skills/package-source-sha1/scripts/package_source.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a reproducible source-only ZIP and its SHA-1/manifest files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
EXCLUDED_SUFFIXES = {
|
||||||
|
# Point clouds, CAD and meshes
|
||||||
|
".ply", ".pcd", ".las", ".laz", ".e57", ".xyz", ".pts",
|
||||||
|
".stp", ".step", ".iges", ".igs", ".obj", ".stl", ".glb", ".gltf",
|
||||||
|
# Databases and generated tabular results
|
||||||
|
".db", ".sqlite", ".sqlite3", ".mdb", ".accdb", ".xlsx", ".xls",
|
||||||
|
# Logs, caches, backups and generated archives
|
||||||
|
".log", ".pyc", ".pyo", ".bak", ".tmp", ".zip", ".7z", ".rar",
|
||||||
|
".tar", ".gz", ".bz2", ".xz",
|
||||||
|
# Model weights / binary artifacts
|
||||||
|
".pt", ".pth", ".onnx", ".ckpt", ".safetensors", ".bin",
|
||||||
|
# Private-key/container formats
|
||||||
|
".pem", ".key", ".p12", ".pfx", ".jks",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXCLUDED_DIRS = {
|
||||||
|
".git", ".manus-logs", ".pnpm-store", "node_modules", "dist", "build",
|
||||||
|
"coverage", "results", "result", "outputs", "output", "__pycache__",
|
||||||
|
".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache", "data", "datasets",
|
||||||
|
}
|
||||||
|
|
||||||
|
EXCLUDED_NAMES = {".env", "credentials.json", "secrets.json", "=1.10", "=3.1"}
|
||||||
|
FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0)
|
||||||
|
ZIP_ROOT = "massive-cloud-v3-source"
|
||||||
|
|
||||||
|
|
||||||
|
def git_output(root: Path, *args: str) -> bytes:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(root), *args], check=True, stdout=subprocess.PIPE
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def candidates(root: Path) -> list[str]:
|
||||||
|
raw = git_output(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z")
|
||||||
|
return sorted(os.fsdecode(item) for item in raw.split(b"\0") if item)
|
||||||
|
|
||||||
|
|
||||||
|
def exclusion_reason(relative: str) -> str | None:
|
||||||
|
path = PurePosixPath(relative)
|
||||||
|
lowered_parts = tuple(part.lower() for part in path.parts)
|
||||||
|
name = path.name.lower()
|
||||||
|
|
||||||
|
if any(part in EXCLUDED_DIRS for part in lowered_parts[:-1]):
|
||||||
|
return "excluded-directory"
|
||||||
|
if name in EXCLUDED_NAMES:
|
||||||
|
return "secret-or-artifact-name"
|
||||||
|
if name.startswith(".env.") and name not in {".env.example", ".env.template", ".env.sample"}:
|
||||||
|
return "environment-secret"
|
||||||
|
if path.suffix.lower() in EXCLUDED_SUFFIXES:
|
||||||
|
return "non-source-suffix"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sha1_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha1()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def git_text(root: Path, *args: str) -> str:
|
||||||
|
return git_output(root, *args).decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(root: Path, included: list[str], excluded: list[dict[str, str]]) -> dict:
|
||||||
|
return {
|
||||||
|
"format": "massive-cloud-source-package-v1",
|
||||||
|
"source_root": str(root),
|
||||||
|
"git_branch": git_text(root, "branch", "--show-current"),
|
||||||
|
"git_commit": git_text(root, "rev-parse", "HEAD"),
|
||||||
|
"git_status": git_text(root, "status", "--short"),
|
||||||
|
"included_count": len(included),
|
||||||
|
"included_files": included,
|
||||||
|
"excluded_count": len(excluded),
|
||||||
|
"excluded_files": excluded,
|
||||||
|
"rules": {
|
||||||
|
"candidate_source": "git ls-files --cached --others --exclude-standard",
|
||||||
|
"excluded_directories": sorted(EXCLUDED_DIRS),
|
||||||
|
"excluded_names": sorted(EXCLUDED_NAMES),
|
||||||
|
"excluded_suffixes": sorted(EXCLUDED_SUFFIXES),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_zip(root: Path, output: Path, included: list[str]) -> None:
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||||
|
for relative in included:
|
||||||
|
source = root / relative
|
||||||
|
if not source.is_file() or source.is_symlink():
|
||||||
|
continue
|
||||||
|
info = zipfile.ZipInfo(f"{ZIP_ROOT}/{PurePosixPath(relative)}", FIXED_ZIP_TIME)
|
||||||
|
mode = source.stat().st_mode
|
||||||
|
normalized_mode = 0o755 if mode & stat.S_IXUSR else 0o644
|
||||||
|
info.create_system = 3
|
||||||
|
info.external_attr = (stat.S_IFREG | normalized_mode) << 16
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
with source.open("rb") as handle:
|
||||||
|
archive.writestr(info, handle.read(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", type=Path, required=True, help="Git working tree")
|
||||||
|
parser.add_argument("--output", type=Path, required=True, help="Output .zip path")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Print selection summary only")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = args.source.resolve()
|
||||||
|
output = args.output.resolve()
|
||||||
|
if output.suffix.lower() != ".zip":
|
||||||
|
parser.error("--output must end with .zip")
|
||||||
|
if not (root / ".git").exists():
|
||||||
|
parser.error(f"not a Git working tree: {root}")
|
||||||
|
try:
|
||||||
|
output.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
parser.error("write the package outside the source tree")
|
||||||
|
|
||||||
|
selected: list[str] = []
|
||||||
|
excluded: list[dict[str, str]] = []
|
||||||
|
for relative in candidates(root):
|
||||||
|
reason = exclusion_reason(relative)
|
||||||
|
if reason:
|
||||||
|
excluded.append({"path": relative, "reason": reason})
|
||||||
|
elif (root / relative).is_file() and not (root / relative).is_symlink():
|
||||||
|
selected.append(relative)
|
||||||
|
|
||||||
|
reasons = Counter(item["reason"] for item in excluded)
|
||||||
|
print(f"source: {root}")
|
||||||
|
print(f"included: {len(selected)} files")
|
||||||
|
print(f"excluded: {len(excluded)} files ({dict(sorted(reasons.items()))})")
|
||||||
|
print(f"git commit: {git_text(root, 'rev-parse', 'HEAD')}")
|
||||||
|
status = git_text(root, "status", "--short")
|
||||||
|
print("working tree: dirty" if status else "working tree: clean")
|
||||||
|
if args.dry_run:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
manifest = build_manifest(root, selected, excluded)
|
||||||
|
write_zip(root, output, selected)
|
||||||
|
digest = sha1_file(output)
|
||||||
|
manifest.update({"zip_file": output.name, "zip_size": output.stat().st_size, "sha1": digest})
|
||||||
|
|
||||||
|
sha_path = output.with_suffix(output.suffix + ".sha1")
|
||||||
|
manifest_path = output.with_suffix(output.suffix + ".manifest.json")
|
||||||
|
sha_path.write_text(f"{digest} {output.name}\n", encoding="ascii")
|
||||||
|
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"zip: {output}")
|
||||||
|
print(f"sha1: {digest}")
|
||||||
|
print(f"manifest: {manifest_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -162,3 +162,7 @@ alg/train_measurement_system_v2.2/**/visualizations*/
|
|||||||
alg/整车配准v1.3/**/*.ply
|
alg/整车配准v1.3/**/*.ply
|
||||||
alg/整车配准v1.3/datasets/
|
alg/整车配准v1.3/datasets/
|
||||||
alg/整车配准v1.3/results/
|
alg/整车配准v1.3/results/
|
||||||
|
|
||||||
|
# ─── train_measurement_system_v3.2(运行产物排除)──────────────────────────────
|
||||||
|
alg/train_measurement_system_v3.2_0629/results/
|
||||||
|
alg/train_measurement_system_v3.2_0629/visualizations*/
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ MINIO_ROOT_USER=minioadmin
|
|||||||
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
||||||
|
|
||||||
# 应用配置
|
# 应用配置
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# 或者手动编辑
|
# 或者手动编辑
|
||||||
|
|||||||
@@ -33,12 +33,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 设置构建时环境变量
|
# 设置构建时环境变量
|
||||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
ARG VITE_APP_TITLE="宏量点云处理软件"
|
||||||
ARG VITE_OAUTH_PORTAL_URL="disabled"
|
|
||||||
ARG VITE_APP_ID=""
|
|
||||||
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
||||||
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
|
|
||||||
ENV VITE_APP_ID=$VITE_APP_ID
|
|
||||||
|
|
||||||
# 构建应用
|
# 构建应用
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
259
alg/00_算法交付与输出规范/README.md
Normal file
259
alg/00_算法交付与输出规范/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal file
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
115
alg/00_算法交付与输出规范/result.schema.json
Normal file
115
alg/00_算法交付与输出规范/result.schema.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
alg/00_算法交付与输出规范/validate_result.py
Executable file
136
alg/00_算法交付与输出规范/validate_result.py
Executable 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())
|
||||||
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal file
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal 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`;测量结果字段发生冲突时,以新规范为准。
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
# 列车点云测量系统
|
|
||||||
|
|
||||||
基于配置驱动的点云测量系统,支持多种测量任务类型。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、距离计算,并生成Excel报告和可视化点云文件。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
train_measurement_system/
|
|
||||||
├── config/
|
|
||||||
│ └── measurements_0610.yaml # 测量任务配置文件
|
|
||||||
├── src/
|
|
||||||
│ ├── __init__.py # 包初始化文件
|
|
||||||
│ ├── measurements/
|
|
||||||
│ │ ├── __init__.py # 子包初始化文件
|
|
||||||
│ │ ├── base.py # 测量任务基类
|
|
||||||
│ │ ├── plane_distance.py # 平面到平面距离测量(含宽度差值检查)
|
|
||||||
│ │ ├── point_to_plane.py # 点到平面距离测量
|
|
||||||
│ │ ├── point_to_point.py # 点到点距离测量
|
|
||||||
│ │ ├── plane_with_holes.py # 带孔平面测量
|
|
||||||
│ │ ├── point_to_plane_with_holes.py
|
|
||||||
│ │ └── point_to_point_with_holes.py
|
|
||||||
│ ├── hole_detect/ # 孔检测模块
|
|
||||||
│ │ ├── hole_detector.py
|
|
||||||
│ │ └── detect.py
|
|
||||||
│ ├── config_loader.py # 配置加载器
|
|
||||||
│ ├── task_manager.py # 任务管理器(含任务19自动生成)
|
|
||||||
│ ├── roi_extractor.py # ROI提取器
|
|
||||||
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
|
|
||||||
│ ├── hole_center_fitter.py # 孔中心拟合器
|
|
||||||
│ ├── pointcloud_loader.py # 点云加载器
|
|
||||||
│ ├── excel_exporter.py # Excel结果导出器
|
|
||||||
│ ├── performance_logger.py # 性能日志记录器
|
|
||||||
│ ├── measurement_logger.py # 详细测量日志记录器
|
|
||||||
│ └── visualizer.py # 可视化工具
|
|
||||||
├── results/ # 测量结果输出目录
|
|
||||||
│ ├── measurement_results_*.xlsx # Excel测量结果
|
|
||||||
│ ├── measurement_detail_*.log # 详细测量日志
|
|
||||||
│ └── performance_*.log # 性能分析报告
|
|
||||||
├── visualizations/ # 可视化点云输出目录(PLY文件)
|
|
||||||
├── main.py # 主程序入口
|
|
||||||
├── setup.py # 安装脚本
|
|
||||||
├── requirements.txt # 依赖清单
|
|
||||||
├── USAGE.md # 使用说明
|
|
||||||
└── README.md # 本文件
|
|
||||||
```
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
|
|
||||||
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔平面测量
|
|
||||||
- **宽度差值检查**: 任务18自动生成任务19,检查门口上、中、下位置的宽度差值(±2mm公差)
|
|
||||||
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
|
|
||||||
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
|
|
||||||
- **结果导出**: 自动生成格式化的Excel报告和详细日志
|
|
||||||
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声,支持稀疏点云(可调阈值)
|
|
||||||
- **性能优化**: 点云缓存、详细性能分析报告
|
|
||||||
- **错误处理**: 单个任务失败不影响其他任务执行
|
|
||||||
- **可扩展**: 易于添加新的测量类型
|
|
||||||
|
|
||||||
## 环境要求
|
|
||||||
|
|
||||||
- Python 3.9+
|
|
||||||
- 推荐使用 conda 环境管理
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 创建并激活环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建 conda 环境
|
|
||||||
conda create -n pcc-experiment python=3.9
|
|
||||||
conda activate pcc-experiment
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装依赖
|
|
||||||
|
|
||||||
**方式一:使用 requirements.txt**
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:安装项目**
|
|
||||||
```bash
|
|
||||||
# 正常安装
|
|
||||||
pip install .
|
|
||||||
|
|
||||||
# 或开发模式安装(代码修改立即生效)
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
安装项目会自动安装所有依赖包,并将项目注册到 Python 环境中。
|
|
||||||
|
|
||||||
### 2. 准备点云文件
|
|
||||||
|
|
||||||
确保你有完整的列车点云文件(PLY格式),例如:
|
|
||||||
```
|
|
||||||
D:\lab\PCC\PCC\head_train_pointcloud\train_2_100M.ply
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 配置测量任务
|
|
||||||
|
|
||||||
编辑 `config/measurements_0610.yaml`,设置点云文件路径和测量任务。
|
|
||||||
|
|
||||||
**支持相对路径(相对于配置文件)或绝对路径:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# 点云文件路径
|
|
||||||
pointcloud_file: "点云存放路径/train_2_1T.ply"
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: "任务18_门口宽度"
|
|
||||||
type: "plane_to_plane"
|
|
||||||
enabled: true
|
|
||||||
description: "乘客门口宽度"
|
|
||||||
theoretical_dimension: 1070
|
|
||||||
tolerance:
|
|
||||||
upper: 3
|
|
||||||
lower: 0
|
|
||||||
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [-2552, 1390, 1402]
|
|
||||||
bbox_max: [-2551, 3080, 1577]
|
|
||||||
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [-1482, 1400, 1513]
|
|
||||||
bbox_max: [-1481, 3120, 1591]
|
|
||||||
|
|
||||||
params:
|
|
||||||
sample_points: 100
|
|
||||||
ransac_threshold: 0.1
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
check_width_variation: true # 启用宽度差值检查(任务19)
|
|
||||||
width_variation_tolerance: 2.0 # 差值公差 ±2mm
|
|
||||||
|
|
||||||
- name: "任务43_窗口下沿高度"
|
|
||||||
type: "plane_to_plane"
|
|
||||||
enabled: true
|
|
||||||
description: "测量窗口下沿到地面的距离"
|
|
||||||
theoretical_dimension: 803
|
|
||||||
tolerance:
|
|
||||||
upper: 6.5
|
|
||||||
lower: -4
|
|
||||||
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [5562, 1981, 1672]
|
|
||||||
bbox_max: [6772, 1983, 1677]
|
|
||||||
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [-10222, 1179, -302]
|
|
||||||
bbox_max: [12428, 1181, 302]
|
|
||||||
|
|
||||||
params:
|
|
||||||
sample_points: 100
|
|
||||||
ransac_threshold: 0.5 # 稀疏点云使用较大阈值
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 运行测量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用默认配置文件
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# 指定配置文件
|
|
||||||
python main.py -c config/measurements_0610.yaml
|
|
||||||
|
|
||||||
# 指定点云文件(覆盖配置文件中的路径)
|
|
||||||
python main.py -p /path/to/your/pointcloud.ply
|
|
||||||
|
|
||||||
# 指定输出目录
|
|
||||||
python main.py -o /path/to/output
|
|
||||||
|
|
||||||
# 组合使用
|
|
||||||
python main.py -c config/measurements_0610.yaml -o results/
|
|
||||||
```
|
|
||||||
|
|
||||||
**长时间任务建议在tmux中运行:**
|
|
||||||
```bash
|
|
||||||
tmux new-session -s measurement 'python main.py -c config/measurements_0610.yaml'
|
|
||||||
# 分离会话: Ctrl+B, D
|
|
||||||
# 重新附加: tmux attach -t measurement
|
|
||||||
```
|
|
||||||
|
|
||||||
**命令行参数:**
|
|
||||||
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
|
|
||||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
|
||||||
- `-o, --output`: 结果输出目录(默认: results/)
|
|
||||||
|
|
||||||
### 5. 查看结果
|
|
||||||
|
|
||||||
**Excel报告:** 保存在 `results/` 目录下
|
|
||||||
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
|
||||||
- 包含每个任务的详细统计信息:
|
|
||||||
- 项点名称、理论尺寸、公差要求
|
|
||||||
- 测量结果(平均距离)、精度(标准差)
|
|
||||||
- 误差范围、测量结论(通过/不通过)
|
|
||||||
- 执行时间、错误信息(如有)
|
|
||||||
|
|
||||||
**任务19特殊说明:**
|
|
||||||
- 当任务18启用 `check_width_variation: true` 时,自动生成任务19
|
|
||||||
- 任务19在Excel中单独一行,显示宽度差值检查结果
|
|
||||||
- 理论尺寸: 0,公差: ±2.0mm
|
|
||||||
- 测量结果: 最大差值
|
|
||||||
- 精度: 显示三个差值(上-中、中-下、上-下)
|
|
||||||
- 误差范围: 差值的最小和最大值
|
|
||||||
|
|
||||||
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
|
|
||||||
- 每个任务的详细执行过程
|
|
||||||
- ROI提取信息、RANSAC拟合详情
|
|
||||||
- 宽度差值检查详情(如适用)
|
|
||||||
|
|
||||||
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
|
|
||||||
- 各任务执行时间
|
|
||||||
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
|
|
||||||
- 平均耗时统计
|
|
||||||
- 点位参数(ROI 范围、目标点坐标等)
|
|
||||||
- 测量数据(平均距离、标准差、最小/最大距离)
|
|
||||||
- 采样点数、ROI 点数、内点率
|
|
||||||
- 错误信息(如果失败)
|
|
||||||
|
|
||||||
**可视化点云:** 保存在 `visualizations/` 目录下
|
|
||||||
- 文件格式:PLY
|
|
||||||
- 可使用CloudCompare或Open3D查看
|
|
||||||
- 用于验证ROI提取和测量点位置是否正确
|
|
||||||
|
|
||||||
## 测量类型详解
|
|
||||||
|
|
||||||
### 1. plane_to_plane(平面到平面距离)
|
|
||||||
|
|
||||||
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
|
|
||||||
|
|
||||||
**工作流程:**
|
|
||||||
1. 从source_roi包围盒提取点云
|
|
||||||
2. 使用RANSAC拟合源平面
|
|
||||||
3. 从拟合的内点中随机采样指定数量的点
|
|
||||||
4. 从target_roi包围盒提取点云
|
|
||||||
5. 使用RANSAC拟合目标平面
|
|
||||||
6. 计算采样点到目标平面的有向距离
|
|
||||||
7. 统计平均值、标准差、最小/最大距离
|
|
||||||
|
|
||||||
**配置参数:**
|
|
||||||
```yaml
|
|
||||||
type: "plane_to_plane"
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
params:
|
|
||||||
sample_points: 100 # 采样点数
|
|
||||||
ransac_threshold: 2.0 # RANSAC距离阈值(mm)
|
|
||||||
ransac_max_iterations: 1000 # RANSAC最大迭代次数
|
|
||||||
min_inlier_ratio: 0.6 # 最小内点比例
|
|
||||||
```
|
|
||||||
|
|
||||||
**输出结果:**
|
|
||||||
- 平均距离(mm)
|
|
||||||
- 标准差(mm)
|
|
||||||
- 最小/最大距离(mm)
|
|
||||||
- 采样点数
|
|
||||||
- Source ROI点数
|
|
||||||
- Target ROI点数
|
|
||||||
- 平面拟合内点率
|
|
||||||
|
|
||||||
**可视化文件:**
|
|
||||||
- `{任务名}_source_roi.ply`:源平面ROI点云
|
|
||||||
- `{任务名}_target_roi.ply`:目标平面ROI点云
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. point_to_plane(点到平面距离)
|
|
||||||
|
|
||||||
测量指定点到参考平面的距离。支持两种模式:精确坐标查找和包围盒筛选。
|
|
||||||
|
|
||||||
**工作流程:**
|
|
||||||
1. 根据模式获取测量点:
|
|
||||||
- `exact_points`模式:在点云中查找最近点
|
|
||||||
- `bbox_filtered`模式:从包围盒提取点云并筛选
|
|
||||||
2. 从target_roi提取点云并拟合参考平面(目前的实现中,直接用z=0平面,不做拟合)
|
|
||||||
3. 计算每个点到平面的距离
|
|
||||||
4. 统计平均值、标准差等
|
|
||||||
|
|
||||||
**配置参数(exact_points模式):**
|
|
||||||
```yaml
|
|
||||||
type: "point_to_plane"
|
|
||||||
mode: "exact_points"
|
|
||||||
points:
|
|
||||||
- [-753.5, 1052.5, 1445]
|
|
||||||
- [3446.5, 1053, 1445]
|
|
||||||
- [-753.5, 1052.5, -1445]
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
params:
|
|
||||||
search_radius: 50.0 # 查找最近点的搜索半径(mm)
|
|
||||||
ransac_threshold: 2.0
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
use_absolute_distance: true # 是否使用绝对距离
|
|
||||||
```
|
|
||||||
|
|
||||||
**配置参数(bbox_filtered模式):**
|
|
||||||
```yaml
|
|
||||||
type: "point_to_plane"
|
|
||||||
mode: "bbox_filtered"
|
|
||||||
source_rois:
|
|
||||||
- bbox_min: [x1_min, y1_min, z1_min]
|
|
||||||
bbox_max: [x1_max, y1_max, z1_max]
|
|
||||||
- bbox_min: [x2_min, y2_min, z2_min]
|
|
||||||
bbox_max: [x2_max, y2_max, z2_max]
|
|
||||||
filter:
|
|
||||||
type: "max_abs_coordinate" # 筛选类型
|
|
||||||
axis: "z" # 筛选坐标轴
|
|
||||||
top_n: 10 # 每个ROI选择前N个点
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
params:
|
|
||||||
ransac_threshold: 2.0
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
use_absolute_distance: true
|
|
||||||
```
|
|
||||||
|
|
||||||
**输出结果:**
|
|
||||||
- 平均距离(mm)
|
|
||||||
- 标准差(mm)
|
|
||||||
- 最小/最大距离(mm)
|
|
||||||
- 测量点数
|
|
||||||
- Target ROI点数
|
|
||||||
- 平面拟合内点率
|
|
||||||
|
|
||||||
**可视化文件:**
|
|
||||||
- `{任务名}_found_points.ply`:测量点点云
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. point_to_point(点到点距离)
|
|
||||||
|
|
||||||
测量点对之间的欧氏距离。支持对称点自动配对(例如测量关于z=0对称的点对)。
|
|
||||||
|
|
||||||
**工作流程:**
|
|
||||||
1. 获取测量点:在点云中查找最近点
|
|
||||||
2. 根据z坐标符号自动配对(z>0和z<0的点配对)
|
|
||||||
3. 计算每对点之间的欧氏距离
|
|
||||||
4. 统计平均值、标准差等
|
|
||||||
|
|
||||||
**配置参数:**
|
|
||||||
``` yaml
|
|
||||||
type: "point_to_point"
|
|
||||||
|
|
||||||
# 点对配置(使用任务3的6个点,配对测量)
|
|
||||||
point_pairs:
|
|
||||||
- name: "前端点对"
|
|
||||||
point1: [-753.5, 1052.5, -1445]
|
|
||||||
point2: [-753.5, 1052.5, 1445]
|
|
||||||
- name: "中间点对"
|
|
||||||
point1: [3446.5, 1053, -1445]
|
|
||||||
point2: [3446.5, 1053, 1445]
|
|
||||||
- name: "后端点对"
|
|
||||||
point1: [8146.5, 1053, -1445]
|
|
||||||
point2: [8146.5, 1053, 1445]
|
|
||||||
|
|
||||||
# 最近点搜索半径(mm)
|
|
||||||
search_radius: 10.0
|
|
||||||
```
|
|
||||||
|
|
||||||
**输出结果:**
|
|
||||||
- 平均距离(mm)
|
|
||||||
- 标准差(mm)
|
|
||||||
- 最小/最大距离(mm)
|
|
||||||
- 点对数量
|
|
||||||
|
|
||||||
**可视化文件:**
|
|
||||||
- `{任务名}_found_points.ply`:包含测量点的点云
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置文件说明
|
|
||||||
|
|
||||||
### 基本结构
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# 点云文件路径(必填)
|
|
||||||
pointcloud_file: "D:\\path\\to\\pointcloud.ply"
|
|
||||||
|
|
||||||
# 测量任务列表
|
|
||||||
tasks:
|
|
||||||
- name: "任务名称"
|
|
||||||
type: "测量类型"
|
|
||||||
enabled: true/false
|
|
||||||
description: "任务描述"
|
|
||||||
visualization_dir: "visualizations"
|
|
||||||
# ... 其他参数
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通用参数
|
|
||||||
|
|
||||||
- `name`:任务名称(必填),用于标识任务和命名输出文件
|
|
||||||
- `type`:测量类型(必填),可选值:`plane_to_plane`、`point_to_plane`、`point_to_point`
|
|
||||||
- `enabled`:是否启用任务(必填),设为`false`可跳过该任务
|
|
||||||
- `description`:任务描述(可选),用于文档说明
|
|
||||||
- `visualization_dir`:可视化输出目录(可选),设置后会保存ROI点云文件
|
|
||||||
|
|
||||||
### 包围盒格式
|
|
||||||
|
|
||||||
所有包围盒使用以下格式,坐标单位为毫米(mm):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
```
|
|
||||||
|
|
||||||
**坐标系说明:**
|
|
||||||
- X轴:列车横向(车头到车尾)
|
|
||||||
- Y轴:列车高度(底部到顶部)
|
|
||||||
- Z轴:列车纵向(左侧到右侧)
|
|
||||||
|
|
||||||
### RANSAC参数调优
|
|
||||||
|
|
||||||
RANSAC用于平面拟合,以下参数影响拟合质量:
|
|
||||||
|
|
||||||
- `ransac_threshold`:内点距离阈值(mm)
|
|
||||||
- 较小值(1.0-2.0):严格拟合,适合高质量点云
|
|
||||||
- 较大值(3.0-5.0):宽松拟合,适合噪声较多的点云
|
|
||||||
|
|
||||||
- `ransac_max_iterations`:最大迭代次数
|
|
||||||
- 默认1000次,通常足够
|
|
||||||
- 如果内点率很低,可增加到2000-5000
|
|
||||||
|
|
||||||
- `min_inlier_ratio`:最小内点比例
|
|
||||||
- 默认0.6(60%),表示至少60%的点在平面上
|
|
||||||
- 如果点云包含多个平面或噪声多,可降低到0.4-0.5
|
|
||||||
|
|
||||||
**调优建议:**
|
|
||||||
1. 先运行一次,查看内点率
|
|
||||||
2. 如果内点率低于min_inlier_ratio,任务会失败
|
|
||||||
3. 根据实际内点率调整参数:
|
|
||||||
- 内点率50%-60%:降低min_inlier_ratio或增大ransac_threshold
|
|
||||||
- 内点率>90%:可以提高要求,减小ransac_threshold
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 添加新任务
|
|
||||||
|
|
||||||
### 步骤1:确定测量类型
|
|
||||||
|
|
||||||
根据测量需求选择合适的类型:
|
|
||||||
- **平面到平面距离**:车门高度、车体长度、窗口高度等
|
|
||||||
- **点到平面距离**:特定点到参考平面的高度
|
|
||||||
- **点到点距离**:对称点对之间的距离
|
|
||||||
|
|
||||||
### 步骤2:确定ROI包围盒
|
|
||||||
|
|
||||||
**方法1:使用点云浏览器工具**
|
|
||||||
|
|
||||||
|
|
||||||
**方法2:使用CloudCompare或Open3D或Freecad手动查看**
|
|
||||||
- 打开点云文件
|
|
||||||
- 使用测量工具确定目标区域的坐标范围
|
|
||||||
- 记录包围盒的最小和最大坐标
|
|
||||||
|
|
||||||
### 步骤3:编辑配置文件
|
|
||||||
|
|
||||||
在 `config/measurements.yaml` 中添加新任务:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: "新任务名称"
|
|
||||||
type: "plane_to_plane" # 或其他类型
|
|
||||||
enabled: true
|
|
||||||
description: "任务描述"
|
|
||||||
visualization_dir: "visualizations"
|
|
||||||
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
|
|
||||||
params:
|
|
||||||
sample_points: 100
|
|
||||||
ransac_threshold: 2.0
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤4:运行测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤5:验证结果
|
|
||||||
|
|
||||||
1. **查看Excel报告**:检查测量结果是否合理
|
|
||||||
2. **查看可视化点云**:使用CloudCompare或Open3D打开PLY文件
|
|
||||||
- 确认ROI提取的位置正确
|
|
||||||
- 确认平面拟合质量(内点率)
|
|
||||||
3. **调整参数**:如果结果不理想,调整包围盒或RANSAC参数
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 平面拟合算法
|
|
||||||
|
|
||||||
系统使用Open3D的RANSAC算法进行平面拟合:
|
|
||||||
|
|
||||||
1. **随机采样**:从点云中随机选择3个点
|
|
||||||
2. **拟合平面**:计算平面方程 ax + by + cz + d = 0
|
|
||||||
3. **统计内点**:计算所有点到平面的距离,距离小于阈值的为内点
|
|
||||||
4. **迭代优化**:重复上述步骤,选择内点最多的平面
|
|
||||||
5. **最终拟合**:使用所有内点进行最小二乘拟合(SVD),得到最优平面
|
|
||||||
|
|
||||||
**平面方程:** ax + by + cz + d = 0
|
|
||||||
- (a, b, c):平面法向量(单位向量)
|
|
||||||
- d:平面到原点的有向距离
|
|
||||||
|
|
||||||
### 距离计算
|
|
||||||
|
|
||||||
**点到平面距离:**
|
|
||||||
```
|
|
||||||
distance = |ax + by + cz + d| / sqrt(a² + b² + c²)
|
|
||||||
```
|
|
||||||
|
|
||||||
由于法向量已归一化(sqrt(a² + b² + c²) = 1),简化为:
|
|
||||||
```
|
|
||||||
distance = |ax + by + cz + d|
|
|
||||||
```
|
|
||||||
|
|
||||||
**有向距离:**
|
|
||||||
- 正值:点在法向量指向的一侧
|
|
||||||
- 负值:点在法向量相反的一侧
|
|
||||||
- 绝对距离:取绝对值,不考虑方向
|
|
||||||
|
|
||||||
**点到点距离(欧氏距离):**
|
|
||||||
```
|
|
||||||
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
|
|
||||||
- **点云缓存**:点云文件只加载一次,所有任务共享
|
|
||||||
- **自动降采样**:ROI超过10000点时自动降采样到10000点进行SVD拟合
|
|
||||||
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务
|
|
||||||
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# 使用说明
|
|
||||||
|
|
||||||
## 环境安装
|
|
||||||
|
|
||||||
### 1. 创建 Conda 环境
|
|
||||||
```bash
|
|
||||||
conda create -n pcc-experiment python=3.9
|
|
||||||
conda activate pcc-experiment
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装依赖包
|
|
||||||
|
|
||||||
**方式一:使用 requirements.txt**
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式二:安装项目**
|
|
||||||
```bash
|
|
||||||
# 正常安装
|
|
||||||
pip install .
|
|
||||||
|
|
||||||
# 或开发模式安装(代码修改立即生效)
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
安装项目会自动安装所有依赖包,并将项目添加到 Python 环境中。
|
|
||||||
|
|
||||||
## 配置测量任务
|
|
||||||
|
|
||||||
编辑 `config/measurements_0610.yaml` 文件:
|
|
||||||
|
|
||||||
### 1. 设置点云文件路径
|
|
||||||
```yaml
|
|
||||||
# 支持相对路径(相对于配置文件)或绝对路径
|
|
||||||
pointcloud_file: "点云路径/train_2_1T.ply"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 配置测量任务
|
|
||||||
```yaml
|
|
||||||
tasks:
|
|
||||||
- name: "任务名称"
|
|
||||||
type: "plane_to_plane" # 任务类型: plane_to_plane, point_to_plane, point_to_point
|
|
||||||
enabled: true # 是否启用
|
|
||||||
description: "任务描述"
|
|
||||||
visualization_dir: "visualizations" # 可视化输出目录(可选)
|
|
||||||
|
|
||||||
# 根据任务类型配置相应参数
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
|
|
||||||
target_roi:
|
|
||||||
bbox_min: [x_min, y_min, z_min]
|
|
||||||
bbox_max: [x_max, y_max, z_max]
|
|
||||||
|
|
||||||
params:
|
|
||||||
sample_points: 100
|
|
||||||
ransac_threshold: 2.0
|
|
||||||
ransac_max_iterations: 1000
|
|
||||||
min_inlier_ratio: 0.6
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行测量
|
|
||||||
|
|
||||||
### 基本用法
|
|
||||||
```bash
|
|
||||||
# 使用默认配置文件
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# 指定配置文件
|
|
||||||
python main.py -c config/custom.yaml
|
|
||||||
|
|
||||||
# 指定点云文件(覆盖配置文件中的路径)
|
|
||||||
python main.py -p data/train.ply
|
|
||||||
|
|
||||||
# 指定输出目录
|
|
||||||
python main.py -o results/output
|
|
||||||
|
|
||||||
# 组合使用
|
|
||||||
python main.py -c config/custom.yaml -p data/train.ply -o results/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 命令行参数
|
|
||||||
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
|
|
||||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
|
||||||
- `-o, --output`: 结果输出目录(默认: results/)
|
|
||||||
|
|
||||||
## 查看结果
|
|
||||||
|
|
||||||
### Excel 结果文件
|
|
||||||
位置: `results/measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
|
||||||
|
|
||||||
包含内容:
|
|
||||||
- 任务名称、类型、描述
|
|
||||||
- 点位参数(ROI 范围、目标点坐标等)
|
|
||||||
- 测量数据(平均距离、标准差、最小/最大距离)
|
|
||||||
- 采样点数、ROI 点数、内点率
|
|
||||||
- 错误信息(如果失败)
|
|
||||||
|
|
||||||
### 可视化文件
|
|
||||||
位置: `visualizations/*.ply`
|
|
||||||
|
|
||||||
使用 CloudCompare 或 MeshLab 打开查看:
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 任务没有执行
|
|
||||||
检查配置文件中 `enabled: true` 是否设置
|
|
||||||
|
|
||||||
### 点云文件找不到
|
|
||||||
- 使用绝对路径,或
|
|
||||||
- 使用相对于配置文件的相对路径,或
|
|
||||||
- 使用 `-p` 参数指定点云文件
|
|
||||||
|
|
||||||
### 测量结果异常
|
|
||||||
- 检查 ROI 包围盒是否正确
|
|
||||||
- 查看 Excel 中的内点率(建议 >70%)
|
|
||||||
- 打开 PLY 文件检查 ROI 提取是否准确
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""
|
|
||||||
单文件快速检测脚本
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from hole_detector import HoleDetector
|
|
||||||
|
|
||||||
|
|
||||||
def quick_detect(ply_path, output_dir="results", axis_idx=None):
|
|
||||||
"""
|
|
||||||
快速检测单个点云文件的孔中心
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ply_path: 点云文件路径
|
|
||||||
output_dir: 输出目录
|
|
||||||
axis_idx: 圆柱轴向 (0=X, 1=Y, 2=Z),None则自动检测
|
|
||||||
"""
|
|
||||||
ply_path = Path(ply_path)
|
|
||||||
output_dir = Path(output_dir)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f"快速检测: {ply_path.name}")
|
|
||||||
print(f"{'='*70}\n")
|
|
||||||
|
|
||||||
# 创建检测器
|
|
||||||
detector = HoleDetector(axis_idx=axis_idx)
|
|
||||||
|
|
||||||
# 加载点云
|
|
||||||
points = detector.load_pointcloud(ply_path)
|
|
||||||
|
|
||||||
# 检测孔
|
|
||||||
result = detector.detect_hole(points)
|
|
||||||
|
|
||||||
# 获取3D圆心
|
|
||||||
center_3d = detector.get_3d_center(points)
|
|
||||||
|
|
||||||
# 生成可视化
|
|
||||||
vis_path = output_dir / f"{ply_path.stem}_detection.png"
|
|
||||||
detector.visualize(vis_path)
|
|
||||||
|
|
||||||
# 输出结果
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f"[OK] 检测完成!")
|
|
||||||
print(f"{'='*70}")
|
|
||||||
print(f"3D 圆心坐标:")
|
|
||||||
print(f" X = {center_3d[0]:.3f}")
|
|
||||||
print(f" Y = {center_3d[1]:.3f}")
|
|
||||||
print(f" Z = {center_3d[2]:.3f}")
|
|
||||||
print(f"\n半径: {result['radius']:.3f}")
|
|
||||||
print(f"轴向: {result['axis_name']}")
|
|
||||||
print(f"\n统计:")
|
|
||||||
print(f" 圆内点数: {result['stats']['inside_count']} ({result['stats']['inside_ratio']*100:.1f}%)")
|
|
||||||
print(f" 圆环点数: {result['stats']['ring_count']} ({result['stats']['ring_ratio']*100:.1f}%)")
|
|
||||||
print(f"\n可视化图已保存: {vis_path}")
|
|
||||||
print(f"{'='*70}\n")
|
|
||||||
|
|
||||||
return center_3d, result['radius']
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("用法: python detect.py <ply文件路径> [输出目录] [轴向:X|Y|Z]")
|
|
||||||
print("\n示例:")
|
|
||||||
print(" python detect.py train_2_1T_noisy_cropped.ply")
|
|
||||||
print(" python detect.py hole.ply results Z")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
ply_path = sys.argv[1]
|
|
||||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else "results"
|
|
||||||
|
|
||||||
axis_idx = None
|
|
||||||
if len(sys.argv) > 3:
|
|
||||||
axis_map = {'X': 0, 'Y': 1, 'Z': 2}
|
|
||||||
axis_idx = axis_map.get(sys.argv[3].upper())
|
|
||||||
|
|
||||||
quick_detect(ply_path, output_dir, axis_idx)
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
"""
|
|
||||||
圆柱孔检测工具
|
|
||||||
用于检测点云中圆柱孔的中心位置和半径
|
|
||||||
"""
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import open3d as o3d
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
from scipy.optimize import minimize, least_squares
|
|
||||||
from pathlib import Path
|
|
||||||
import warnings
|
|
||||||
warnings.filterwarnings('ignore')
|
|
||||||
|
|
||||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial']
|
|
||||||
plt.rcParams['axes.unicode_minus'] = False
|
|
||||||
|
|
||||||
|
|
||||||
class HoleDetector:
|
|
||||||
"""圆柱孔检测器"""
|
|
||||||
|
|
||||||
def __init__(self, axis_idx=None, search_range=0.3, max_radius_ratio=0.4):
|
|
||||||
"""
|
|
||||||
初始化检测器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
axis_idx: 圆柱轴向索引 (0=X, 1=Y, 2=Z),None则自动检测
|
|
||||||
search_range: 搜索范围(相对于边界框的比例)
|
|
||||||
max_radius_ratio: 最大半径(相对于边界框的比例)
|
|
||||||
"""
|
|
||||||
self.axis_idx = axis_idx
|
|
||||||
self.search_range = search_range
|
|
||||||
self.max_radius_ratio = max_radius_ratio
|
|
||||||
self.result = None
|
|
||||||
|
|
||||||
def load_pointcloud(self, ply_path):
|
|
||||||
"""加载点云文件"""
|
|
||||||
pcd = o3d.io.read_point_cloud(str(ply_path))
|
|
||||||
points = np.asarray(pcd.points)
|
|
||||||
print(f"[OK] 加载点云: {points.shape[0]} 个点")
|
|
||||||
print(f" 边界: X[{points[:, 0].min():.2f}, {points[:, 0].max():.2f}] "
|
|
||||||
f"Y[{points[:, 1].min():.2f}, {points[:, 1].max():.2f}] "
|
|
||||||
f"Z[{points[:, 2].min():.2f}, {points[:, 2].max():.2f}]")
|
|
||||||
return points
|
|
||||||
|
|
||||||
def auto_detect_axis(self, points):
|
|
||||||
"""
|
|
||||||
自动检测圆柱轴向
|
|
||||||
|
|
||||||
策略:圆柱孔投影后形成圆环,点到中心距离的变异系数最小
|
|
||||||
"""
|
|
||||||
axes_names = ['X', 'Y', 'Z']
|
|
||||||
n_points = len(points)
|
|
||||||
|
|
||||||
# 计算各轴范围
|
|
||||||
ranges = []
|
|
||||||
for i in range(3):
|
|
||||||
axis_range = points[:, i].max() - points[:, i].min()
|
|
||||||
ranges.append(axis_range)
|
|
||||||
|
|
||||||
# 对于所有点云:使用投影点距离中心的变异系数
|
|
||||||
coef_vars = []
|
|
||||||
for i in range(3):
|
|
||||||
other_axes = [0, 1, 2]
|
|
||||||
other_axes.remove(i)
|
|
||||||
proj = points[:, other_axes]
|
|
||||||
|
|
||||||
# 计算投影点到中心的距离
|
|
||||||
center = proj.mean(axis=0)
|
|
||||||
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
|
|
||||||
|
|
||||||
# 变异系数 = 标准差 / 平均值
|
|
||||||
# 圆环:所有点距离相近,变异系数小
|
|
||||||
# 圆盘:点距离分散,变异系数大
|
|
||||||
mean_dist = distances.mean()
|
|
||||||
std_dist = distances.std()
|
|
||||||
cv = std_dist / mean_dist if mean_dist > 0 else float('inf')
|
|
||||||
coef_vars.append(cv)
|
|
||||||
|
|
||||||
axis_idx = np.argmin(coef_vars) # 选择变异系数最小的
|
|
||||||
print(f"[OK] 检测到圆柱轴向: {axes_names[axis_idx]}")
|
|
||||||
print(f" 各轴范围: X={ranges[0]:.2f}, Y={ranges[1]:.2f}, Z={ranges[2]:.2f}")
|
|
||||||
print(f" 变异系数: X={coef_vars[0]:.3f}, Y={coef_vars[1]:.3f}, Z={coef_vars[2]:.3f}")
|
|
||||||
|
|
||||||
return axis_idx
|
|
||||||
|
|
||||||
def _fit_circle_least_squares(self, points_2d):
|
|
||||||
"""
|
|
||||||
使用最小二乘法拟合圆
|
|
||||||
|
|
||||||
Args:
|
|
||||||
points_2d: Nx2 数组
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
center: [cx, cy]
|
|
||||||
radius: float
|
|
||||||
residuals: 残差数组
|
|
||||||
"""
|
|
||||||
n = len(points_2d)
|
|
||||||
x = points_2d[:, 0]
|
|
||||||
y = points_2d[:, 1]
|
|
||||||
|
|
||||||
# 代数拟合(初始估计)
|
|
||||||
A = np.column_stack([x, y, np.ones(n)])
|
|
||||||
b = -(x**2 + y**2)
|
|
||||||
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
|
||||||
a, b_param, c = params
|
|
||||||
|
|
||||||
cx = -a / 2
|
|
||||||
cy = -b_param / 2
|
|
||||||
r = np.sqrt(cx**2 + cy**2 - c)
|
|
||||||
|
|
||||||
# 非线性优化
|
|
||||||
def residual_func(p):
|
|
||||||
cx, cy, r = p
|
|
||||||
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
|
|
||||||
return distances - r
|
|
||||||
|
|
||||||
result = least_squares(residual_func, [cx, cy, r], loss='soft_l1')
|
|
||||||
cx_opt, cy_opt, r_opt = result.x
|
|
||||||
|
|
||||||
# 计算残差
|
|
||||||
distances = np.sqrt((x - cx_opt)**2 + (y - cy_opt)**2)
|
|
||||||
residuals = np.abs(distances - r_opt)
|
|
||||||
|
|
||||||
return np.array([cx_opt, cy_opt]), r_opt, residuals
|
|
||||||
|
|
||||||
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
|
|
||||||
"""
|
|
||||||
使用RANSAC拟合圆
|
|
||||||
|
|
||||||
Args:
|
|
||||||
points_2d: Nx2 数组
|
|
||||||
threshold: 内点阈值(自动调整)
|
|
||||||
max_iterations: 最大迭代次数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
center: [cx, cy]
|
|
||||||
radius: float
|
|
||||||
inlier_mask: bool数组
|
|
||||||
"""
|
|
||||||
n = len(points_2d)
|
|
||||||
|
|
||||||
# 自动确定阈值
|
|
||||||
if threshold is None:
|
|
||||||
center_approx = points_2d.mean(axis=0)
|
|
||||||
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
|
|
||||||
median_dist = np.median(distances)
|
|
||||||
# 阈值为半径的8-12%,适应不同点云密度
|
|
||||||
threshold = max(0.3, median_dist * 0.1)
|
|
||||||
|
|
||||||
print(f" RANSAC阈值: {threshold:.3f}")
|
|
||||||
|
|
||||||
best_inliers = None
|
|
||||||
best_center = None
|
|
||||||
best_radius = None
|
|
||||||
best_n_inliers = 0
|
|
||||||
|
|
||||||
# RANSAC迭代
|
|
||||||
iterations = min(max_iterations, max(100, n * 10))
|
|
||||||
for i in range(iterations):
|
|
||||||
if n < 3:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 随机选3个点
|
|
||||||
indices = np.random.choice(n, 3, replace=False)
|
|
||||||
sample = points_2d[indices]
|
|
||||||
|
|
||||||
try:
|
|
||||||
center, radius, _ = self._fit_circle_least_squares(sample)
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 计算内点
|
|
||||||
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
|
|
||||||
residuals = np.abs(distances - radius)
|
|
||||||
inliers = residuals < threshold
|
|
||||||
n_inliers = np.sum(inliers)
|
|
||||||
|
|
||||||
if n_inliers > best_n_inliers:
|
|
||||||
best_n_inliers = n_inliers
|
|
||||||
best_inliers = inliers
|
|
||||||
best_center = center
|
|
||||||
best_radius = radius
|
|
||||||
|
|
||||||
# 用所有内点重新拟合
|
|
||||||
if best_inliers is not None and np.sum(best_inliers) >= 3:
|
|
||||||
inlier_points = points_2d[best_inliers]
|
|
||||||
best_center, best_radius, _ = self._fit_circle_least_squares(inlier_points)
|
|
||||||
|
|
||||||
# 重新计算内点
|
|
||||||
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
|
||||||
residuals = np.abs(distances - best_radius)
|
|
||||||
best_inliers = residuals < threshold
|
|
||||||
else:
|
|
||||||
# RANSAC失败,用所有点拟合
|
|
||||||
print(" [WARNING] RANSAC未找到足够内点,使用所有点拟合")
|
|
||||||
best_center, best_radius, residuals = self._fit_circle_least_squares(points_2d)
|
|
||||||
best_inliers = residuals < threshold
|
|
||||||
|
|
||||||
inlier_ratio = np.sum(best_inliers) / n
|
|
||||||
print(f" 内点比例: {inlier_ratio*100:.1f}% ({np.sum(best_inliers)}/{n})")
|
|
||||||
|
|
||||||
return best_center, best_radius, best_inliers
|
|
||||||
|
|
||||||
def detect_hole(self, points):
|
|
||||||
"""
|
|
||||||
检测圆柱孔的中心和半径
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {
|
|
||||||
'center_2d': [x, y], # 投影平面上的圆心
|
|
||||||
'radius': float, # 半径
|
|
||||||
'axis_idx': int, # 轴向索引
|
|
||||||
'axis_name': str, # 轴名称
|
|
||||||
'projection': ndarray,# 投影点云
|
|
||||||
'distances': ndarray, # 点到圆心的距离
|
|
||||||
'stats': dict # 统计信息
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# 自动检测轴向(如果未指定)
|
|
||||||
if self.axis_idx is None:
|
|
||||||
axis_idx = self.auto_detect_axis(points)
|
|
||||||
else:
|
|
||||||
axis_idx = self.axis_idx
|
|
||||||
|
|
||||||
axis_names = ['X', 'Y', 'Z']
|
|
||||||
other_axes = [0, 1, 2]
|
|
||||||
other_axes.remove(axis_idx)
|
|
||||||
|
|
||||||
# 投影到垂直平面
|
|
||||||
proj = points[:, other_axes]
|
|
||||||
x_min, x_max = proj[:, 0].min(), proj[:, 0].max()
|
|
||||||
y_min, y_max = proj[:, 1].min(), proj[:, 1].max()
|
|
||||||
x_range = x_max - x_min
|
|
||||||
y_range = y_max - y_min
|
|
||||||
|
|
||||||
# 初始中心(边界框中心)
|
|
||||||
x_init = (x_min + x_max) / 2
|
|
||||||
y_init = (y_min + y_max) / 2
|
|
||||||
|
|
||||||
print(f"[OK] 投影到垂直于 {axis_names[axis_idx]} 的平面")
|
|
||||||
|
|
||||||
# 使用RANSAC + 最小二乘法拟合圆
|
|
||||||
center_2d, radius, inlier_mask = self._fit_circle_ransac(proj)
|
|
||||||
|
|
||||||
print(f"[OK] 圆拟合完成:")
|
|
||||||
print(f" 圆心: ({center_2d[0]:.3f}, {center_2d[1]:.3f})")
|
|
||||||
print(f" 半径: {radius:.3f}")
|
|
||||||
|
|
||||||
# 计算统计信息
|
|
||||||
distances = np.sqrt((proj[:, 0] - center_2d[0])**2 +
|
|
||||||
(proj[:, 1] - center_2d[1])**2)
|
|
||||||
|
|
||||||
# 使用自适应阈值定义圆内和圆环
|
|
||||||
# 圆内:距离 < 半径 * 0.8
|
|
||||||
# 圆环:半径 * 0.8 <= 距离 <= 半径 * 1.2
|
|
||||||
inside_threshold = radius * 0.8
|
|
||||||
ring_outer_threshold = radius * 1.2
|
|
||||||
|
|
||||||
inside_count = np.sum(distances < inside_threshold)
|
|
||||||
ring_count = np.sum((distances >= inside_threshold) & (distances <= ring_outer_threshold))
|
|
||||||
outside_count = len(distances) - inside_count - ring_count
|
|
||||||
|
|
||||||
print(f" 圆内点: {inside_count} ({100*inside_count/len(proj):.1f}%)")
|
|
||||||
print(f" 圆环点: {ring_count} ({100*ring_count/len(proj):.1f}%)")
|
|
||||||
print(f" 圆外点: {outside_count} ({100*outside_count/len(proj):.1f}%)")
|
|
||||||
|
|
||||||
# 保存结果
|
|
||||||
self.result = {
|
|
||||||
'center_2d': center_2d,
|
|
||||||
'radius': radius,
|
|
||||||
'axis_idx': axis_idx,
|
|
||||||
'axis_name': axis_names[axis_idx],
|
|
||||||
'other_axes': other_axes,
|
|
||||||
'other_axes_names': [axis_names[i] for i in other_axes],
|
|
||||||
'projection': proj,
|
|
||||||
'distances': distances,
|
|
||||||
'stats': {
|
|
||||||
'inside_count': inside_count,
|
|
||||||
'ring_count': ring_count,
|
|
||||||
'outside_count': outside_count,
|
|
||||||
'inside_ratio': inside_count / len(proj),
|
|
||||||
'ring_ratio': ring_count / len(proj),
|
|
||||||
'total_points': len(proj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.result
|
|
||||||
|
|
||||||
def visualize(self, output_path):
|
|
||||||
"""
|
|
||||||
生成轴视角示意图
|
|
||||||
显示:圆心位置、拟合的圆、投影点的分布
|
|
||||||
"""
|
|
||||||
if self.result is None:
|
|
||||||
raise ValueError("请先调用 detect_hole() 进行检测")
|
|
||||||
|
|
||||||
r = self.result
|
|
||||||
proj = r['projection']
|
|
||||||
center = r['center_2d']
|
|
||||||
radius = r['radius']
|
|
||||||
distances = r['distances']
|
|
||||||
|
|
||||||
# 分类点(使用与统计一致的标准)
|
|
||||||
inside_threshold = radius * 0.8
|
|
||||||
ring_outer_threshold = radius * 1.2
|
|
||||||
|
|
||||||
inside_mask = distances < inside_threshold
|
|
||||||
ring_mask = (distances >= inside_threshold) & (distances <= ring_outer_threshold)
|
|
||||||
outside_mask = distances > ring_outer_threshold
|
|
||||||
|
|
||||||
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
|
||||||
|
|
||||||
# === 子图1: 全局视图 ===
|
|
||||||
ax1 = axes[0]
|
|
||||||
ax1.scatter(proj[outside_mask, 0], proj[outside_mask, 1],
|
|
||||||
s=1, alpha=0.3, c='gray', label='外部点')
|
|
||||||
ax1.scatter(proj[ring_mask, 0], proj[ring_mask, 1],
|
|
||||||
s=3, alpha=0.6, c='blue', label=f'圆环点 ({r["stats"]["ring_count"]})')
|
|
||||||
ax1.scatter(proj[inside_mask, 0], proj[inside_mask, 1],
|
|
||||||
s=5, alpha=0.8, c='red', label=f'圆内点 ({r["stats"]["inside_count"]})')
|
|
||||||
|
|
||||||
# 画圆心
|
|
||||||
ax1.plot(center[0], center[1], 'g*', markersize=20,
|
|
||||||
label=f'圆心 ({center[0]:.2f}, {center[1]:.2f})', zorder=10)
|
|
||||||
|
|
||||||
# 画拟合的圆
|
|
||||||
circle = plt.Circle(center, radius, color='green', fill=False,
|
|
||||||
linewidth=2.5, label=f'半径 {radius:.2f}')
|
|
||||||
ax1.add_patch(circle)
|
|
||||||
|
|
||||||
ax1.set_xlabel(r['other_axes_names'][0], fontsize=12)
|
|
||||||
ax1.set_ylabel(r['other_axes_names'][1], fontsize=12)
|
|
||||||
ax1.set_title(f'垂直于 {r["axis_name"]} 轴的投影 - 全局视图', fontsize=13, fontweight='bold')
|
|
||||||
ax1.set_aspect('equal')
|
|
||||||
ax1.legend(loc='upper right', fontsize=9)
|
|
||||||
ax1.grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# === 子图2: 局部放大视图 ===
|
|
||||||
ax2 = axes[1]
|
|
||||||
zoom_range = radius * 2.5
|
|
||||||
|
|
||||||
# 只显示放大区域内的点
|
|
||||||
zoom_mask = ((proj[:, 0] >= center[0] - zoom_range) &
|
|
||||||
(proj[:, 0] <= center[0] + zoom_range) &
|
|
||||||
(proj[:, 1] >= center[1] - zoom_range) &
|
|
||||||
(proj[:, 1] <= center[1] + zoom_range))
|
|
||||||
|
|
||||||
proj_zoom = proj[zoom_mask]
|
|
||||||
dist_zoom = distances[zoom_mask]
|
|
||||||
|
|
||||||
inside_threshold = radius * 0.8
|
|
||||||
ring_outer_threshold = radius * 1.2
|
|
||||||
|
|
||||||
inside_z = dist_zoom < inside_threshold
|
|
||||||
ring_z = (dist_zoom >= inside_threshold) & (dist_zoom <= ring_outer_threshold)
|
|
||||||
outside_z = dist_zoom > ring_outer_threshold
|
|
||||||
|
|
||||||
ax2.scatter(proj_zoom[outside_z, 0], proj_zoom[outside_z, 1],
|
|
||||||
s=3, alpha=0.4, c='gray')
|
|
||||||
ax2.scatter(proj_zoom[ring_z, 0], proj_zoom[ring_z, 1],
|
|
||||||
s=6, alpha=0.7, c='blue')
|
|
||||||
ax2.scatter(proj_zoom[inside_z, 0], proj_zoom[inside_z, 1],
|
|
||||||
s=10, alpha=0.9, c='red')
|
|
||||||
|
|
||||||
ax2.plot(center[0], center[1], 'g*', markersize=25, zorder=10)
|
|
||||||
|
|
||||||
circle2 = plt.Circle(center, radius, color='green', fill=False, linewidth=3)
|
|
||||||
ax2.add_patch(circle2)
|
|
||||||
|
|
||||||
ax2.set_xlim(center[0] - zoom_range, center[0] + zoom_range)
|
|
||||||
ax2.set_ylim(center[1] - zoom_range, center[1] + zoom_range)
|
|
||||||
ax2.set_xlabel(r['other_axes_names'][0], fontsize=12)
|
|
||||||
ax2.set_ylabel(r['other_axes_names'][1], fontsize=12)
|
|
||||||
ax2.set_title(f'局部放大 - 验证圆内是否为空', fontsize=13, fontweight='bold')
|
|
||||||
ax2.set_aspect('equal')
|
|
||||||
ax2.grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
plt.tight_layout()
|
|
||||||
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
|
||||||
plt.close()
|
|
||||||
print(f"[OK] 保存可视化结果: {output_path}")
|
|
||||||
|
|
||||||
def get_3d_center(self, points):
|
|
||||||
"""
|
|
||||||
获取3D空间中的圆心坐标(沿轴向取中点)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ndarray: [x, y, z] 3D圆心坐标
|
|
||||||
"""
|
|
||||||
if self.result is None:
|
|
||||||
raise ValueError("请先调用 detect_hole() 进行检测")
|
|
||||||
|
|
||||||
r = self.result
|
|
||||||
axis_idx = r['axis_idx']
|
|
||||||
other_axes = r['other_axes']
|
|
||||||
|
|
||||||
# 沿轴向取中点
|
|
||||||
axis_center = (points[:, axis_idx].min() + points[:, axis_idx].max()) / 2
|
|
||||||
|
|
||||||
# 构建3D坐标
|
|
||||||
center_3d = np.zeros(3)
|
|
||||||
center_3d[other_axes[0]] = r['center_2d'][0]
|
|
||||||
center_3d[other_axes[1]] = r['center_2d'][1]
|
|
||||||
center_3d[axis_idx] = axis_center
|
|
||||||
|
|
||||||
return center_3d
|
|
||||||
286
alg/train_measurement_system_v3.2_0629/README.md
Normal file
286
alg/train_measurement_system_v3.2_0629/README.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# 列车点云测量系统 V3
|
||||||
|
|
||||||
|
基于配置驱动的点云测量系统,支持多种测量任务类型和ROI提取方法。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、孔中心检测、距离计算,并生成Excel报告和可视化点云文件。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
train_measurement_system_v3_0626/
|
||||||
|
├── main.py # 主程序入口
|
||||||
|
├── requirements.txt # 依赖清单
|
||||||
|
├── setup.py # 安装脚本
|
||||||
|
├── pyproject.toml # 项目配置
|
||||||
|
├── README.md # 本文件
|
||||||
|
├── config/ # 配置文件目录
|
||||||
|
│ ├── measurements.yaml # 主配置文件
|
||||||
|
├── src/ # 源代码目录
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config_loader.py # 配置加载器
|
||||||
|
│ ├── task_manager.py # 任务管理器
|
||||||
|
│ ├── pointcloud_loader.py # 点云加载器
|
||||||
|
│ ├── roi_extractor.py # ROI提取器
|
||||||
|
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
|
||||||
|
│ ├── hole_detector.py # 高精度孔检测器 V2
|
||||||
|
│ ├── hole_center_fitter.py # 孔中心拟合适配器
|
||||||
|
│ ├── excel_exporter.py # Excel结果导出器
|
||||||
|
│ ├── visualizer.py # 可视化工具
|
||||||
|
│ ├── performance_logger.py # 性能日志记录器
|
||||||
|
│ ├── measurement_logger.py # 详细测量日志记录器
|
||||||
|
│ ├── measurements/ # 测量类型模块
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base.py # 测量任务基类
|
||||||
|
│ │ ├── plane_distance.py # 平面到平面距离测量
|
||||||
|
│ │ ├── point_to_plane.py # 点到平面距离测量
|
||||||
|
│ │ ├── point_to_point.py # 点到点距离测量
|
||||||
|
│ │ ├── plane_with_holes.py # 带孔平面测量
|
||||||
|
│ │ ├── point_to_plane_with_holes.py
|
||||||
|
│ │ └── point_to_point_with_holes.py
|
||||||
|
│ └── spatial_index/ # 空间索引模块(ROI提取优化)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── base.py # 索引基类
|
||||||
|
│ ├── linear_scan.py # 线性扫描(无索引)
|
||||||
|
│ ├── grid_index.py # Grid网格索引
|
||||||
|
│ ├── grid_index_optimized.py # Grid网格索引(优化版)
|
||||||
|
│ ├── octree_index.py # Octree八叉树索引
|
||||||
|
│ └── kdtree_index.py # KDTree索引
|
||||||
|
├── results/ # 测量结果输出目录(自动创建)
|
||||||
|
└── visualizations/ # 可视化点云输出目录(自动创建)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
|
||||||
|
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔中心检测的测量
|
||||||
|
- **多种ROI提取方法**: Linear、Grid、Grid优化版、Octree、KDTree,性能和精度可调
|
||||||
|
- **高精度孔检测**: 基于RANSAC和几何优化的孔中心检测算法(V2),精度<0.1mm
|
||||||
|
- **宽度差值检查**: 支持门口宽度差值自动检查(任务19)
|
||||||
|
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
|
||||||
|
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
|
||||||
|
- **结果导出**: 自动生成格式化的Excel报告和详细日志
|
||||||
|
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声
|
||||||
|
- **性能优化**: 点云缓存、空间索引加速、详细性能分析报告
|
||||||
|
- **错误处理**: 单个任务失败不影响其他任务执行
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- 推荐使用 conda 环境管理
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 创建并激活环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建 conda 环境
|
||||||
|
conda create -n pcc-measurement python=3.9
|
||||||
|
conda activate pcc-measurement
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 安装依赖
|
||||||
|
|
||||||
|
**方式一:使用 requirements.txt**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**方式二:安装项目**
|
||||||
|
```bash
|
||||||
|
# 正常安装
|
||||||
|
pip install .
|
||||||
|
|
||||||
|
# 或开发模式安装(代码修改立即生效)
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 准备点云文件
|
||||||
|
|
||||||
|
确保你有完整的列车点云文件(PLY格式),例如:
|
||||||
|
```
|
||||||
|
/path/to/your/train_pointcloud.ply
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置测量任务
|
||||||
|
|
||||||
|
编辑 `config/measurements.yaml`,设置点云文件路径和测量任务。
|
||||||
|
|
||||||
|
**基本配置示例:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 点云文件路径(支持相对路径或绝对路径)
|
||||||
|
pointcloud_file: "/path/to/your/train_pointcloud.ply"
|
||||||
|
|
||||||
|
# ROI提取方法配置
|
||||||
|
roi_extraction:
|
||||||
|
method: "grid" # 可选: linear, grid, octree, kdtree
|
||||||
|
grid_size: 100.0 # Grid方法的网格大小(mm)
|
||||||
|
use_optimized: true # 使用优化版Grid(numpy向量化)
|
||||||
|
|
||||||
|
# 测量任务列表
|
||||||
|
tasks:
|
||||||
|
- name: "任务43_窗口下沿高度"
|
||||||
|
type: "plane_to_plane"
|
||||||
|
enabled: true
|
||||||
|
description: "测量窗口下沿到地面的距离"
|
||||||
|
theoretical_dimension: 803
|
||||||
|
tolerance:
|
||||||
|
upper: 6.5
|
||||||
|
lower: -4
|
||||||
|
|
||||||
|
source_roi:
|
||||||
|
bbox_min: [5562, 1981, 1672]
|
||||||
|
bbox_max: [6772, 1983, 1677]
|
||||||
|
|
||||||
|
target_roi:
|
||||||
|
bbox_min: [-10222, 1179, -302]
|
||||||
|
bbox_max: [12428, 1181, 302]
|
||||||
|
|
||||||
|
params:
|
||||||
|
sample_points: 100
|
||||||
|
ransac_threshold: 0.5
|
||||||
|
ransac_max_iterations: 1000
|
||||||
|
min_inlier_ratio: 0.6
|
||||||
|
|
||||||
|
visualization: false # 是否保存可视化点云
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 运行测量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用默认配置文件
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# 指定配置文件
|
||||||
|
python main.py -c config/measurements.yaml
|
||||||
|
|
||||||
|
# 指定点云文件(覆盖配置文件中的路径)
|
||||||
|
python main.py -p /path/to/your/pointcloud.ply
|
||||||
|
|
||||||
|
# 指定输出目录
|
||||||
|
python main.py -o /path/to/output
|
||||||
|
|
||||||
|
# 组合使用
|
||||||
|
python main.py -c config/measurements.yaml -o results/
|
||||||
|
```
|
||||||
|
|
||||||
|
**命令行参数:**
|
||||||
|
- `-c, --config`: 配置文件路径
|
||||||
|
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
||||||
|
- `-o, --output`: 结果输出目录(默认: results/)
|
||||||
|
|
||||||
|
### 6. 查看结果
|
||||||
|
|
||||||
|
**Excel报告:** 保存在 `results/` 目录下
|
||||||
|
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||||
|
- 包含每个任务的详细统计信息:
|
||||||
|
- 项目名称、理论尺寸、公差要求
|
||||||
|
- 测量结果(平均距离)、精度(标准差)
|
||||||
|
- 误差范围、测量结论(通过/不通过)
|
||||||
|
- 执行时间、错误信息(如有)
|
||||||
|
|
||||||
|
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
|
||||||
|
- 每个任务的详细执行过程
|
||||||
|
- ROI提取信息、RANSAC拟合详情
|
||||||
|
- 宽度差值检查详情(如适用)
|
||||||
|
|
||||||
|
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
|
||||||
|
- 各任务执行时间
|
||||||
|
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
|
||||||
|
- 空间索引构建时间和方法
|
||||||
|
|
||||||
|
**可视化点云:** 保存在 `visualizations/` 目录下
|
||||||
|
- 文件格式:PLY
|
||||||
|
- 可使用CloudCompare或Open3D查看
|
||||||
|
- 用于验证ROI提取和测量点位置
|
||||||
|
|
||||||
|
## 测量类型
|
||||||
|
|
||||||
|
### 1. plane_to_plane(平面到平面距离)
|
||||||
|
|
||||||
|
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
|
||||||
|
|
||||||
|
### 2. point_to_plane(点到平面距离)
|
||||||
|
|
||||||
|
测量指定点到参考平面的距离。支持精确坐标查找和包围盒筛选模式。
|
||||||
|
|
||||||
|
### 3. point_to_point(点到点距离)
|
||||||
|
|
||||||
|
测量点对之间的欧氏距离。支持对称点自动配对。
|
||||||
|
|
||||||
|
### 4. 带孔中心检测的测量
|
||||||
|
|
||||||
|
支持基于孔中心的平面拟合和距离测量(`plane_with_holes`, `point_to_plane_with_holes`, `point_to_point_with_holes`)。
|
||||||
|
|
||||||
|
## ROI提取方法
|
||||||
|
|
||||||
|
系统支持多种空间索引方法,可在配置文件中选择:
|
||||||
|
|
||||||
|
- **linear**: 线性扫描(无索引)- 最简单,适合小点云
|
||||||
|
- **grid**: Grid网格索引 - 平衡性能和精度
|
||||||
|
- **grid (optimized)**: Grid网格索引优化版 - 使用numpy向量化,性能提升显著
|
||||||
|
- **octree**: Octree八叉树索引 - 适合大规模点云
|
||||||
|
- **kdtree**: KDTree索引 - 高效的空间查询
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 平面拟合算法
|
||||||
|
|
||||||
|
系统使用Open3D的RANSAC算法进行平面拟合:
|
||||||
|
1. 随机采样3个点拟合平面
|
||||||
|
2. 统计内点(距离小于阈值)
|
||||||
|
3. 迭代优化选择最佳平面
|
||||||
|
4. 使用所有内点进行最小二乘拟合(SVD)
|
||||||
|
|
||||||
|
**平面方程:** ax + by + cz + d = 0
|
||||||
|
|
||||||
|
### 孔检测算法
|
||||||
|
|
||||||
|
高精度孔检测器V2特点:
|
||||||
|
- 自动检测圆柱轴向
|
||||||
|
- 多次RANSAC粗拟合
|
||||||
|
- 高质量点筛选
|
||||||
|
- 几何拟合精化
|
||||||
|
- 圆心精度 < 0.1mm
|
||||||
|
|
||||||
|
### 距离计算
|
||||||
|
|
||||||
|
**点到平面距离:**
|
||||||
|
```
|
||||||
|
distance = |ax + by + cz + d|
|
||||||
|
```
|
||||||
|
|
||||||
|
**点到点距离(欧氏距离):**
|
||||||
|
```
|
||||||
|
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- **点云缓存**: 点云文件只加载一次,所有任务共享
|
||||||
|
- **空间索引**: 使用Grid/Octree/KDTree加速ROI提取
|
||||||
|
- **自动降采样**: ROI超过10000点时自动降采样
|
||||||
|
- **并行安全**: 多个任务顺序执行,单个任务失败不影响其他任务
|
||||||
|
|
||||||
|
## 依赖包
|
||||||
|
|
||||||
|
- `numpy>=2.0.0` - 数值计算
|
||||||
|
- `open3d>=0.18.0` - 点云处理
|
||||||
|
- `scipy>=1.10.0` - 科学计算
|
||||||
|
- `pandas>=2.0.0` - 数据处理
|
||||||
|
- `openpyxl>=3.1.0` - Excel导出
|
||||||
|
- `PyYAML>=6.0.0` - 配置文件解析
|
||||||
|
- `matplotlib>=3.7.0` - 可视化(可选)
|
||||||
|
- `plotly>=5.0.0` - 可视化(可选)
|
||||||
|
|
||||||
|
## 版本更新
|
||||||
|
|
||||||
|
### V3 (2024-06-26)
|
||||||
|
- 重构代码结构,模块化更清晰
|
||||||
|
- 集成高精度孔检测器V2到src目录
|
||||||
|
- 优化导入路径,移除路径hack
|
||||||
|
- 精简配置文件
|
||||||
|
- 更新文档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开发环境:** Python 3.9+
|
||||||
|
**推荐工具:** CloudCompare(点云查看)、VS Code(代码编辑)
|
||||||
100
alg/train_measurement_system_v3.2_0629/SUMMARY.md
Normal file
100
alg/train_measurement_system_v3.2_0629/SUMMARY.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 重构完成总结
|
||||||
|
|
||||||
|
## 重构时间
|
||||||
|
2024-06-26
|
||||||
|
|
||||||
|
## 重构内容
|
||||||
|
|
||||||
|
### 1. 代码结构优化
|
||||||
|
- ✅ 将 `detect_hole_v2/hole_detector.py` 集成到 `src/hole_detector.py`
|
||||||
|
- ✅ 更新 `src/hole_center_fitter.py` 的导入路径(移除路径hack)
|
||||||
|
- ✅ 删除旧的 `src/hole_detect/` 目录(未使用)
|
||||||
|
- ✅ 保留核心模块的清晰结构
|
||||||
|
|
||||||
|
### 2. 配置文件精简
|
||||||
|
只保留3个有效配置文件:
|
||||||
|
- `config/measurements_0624.yaml` - 主配置文件
|
||||||
|
- `config/measurements_0624_TODO.yaml` - 待处理任务配置
|
||||||
|
- `config/measurements_0624_origin_pc.yaml` - 原始点云配置
|
||||||
|
|
||||||
|
### 3. 文档更新
|
||||||
|
- ✅ 创建新的 README.md(功能完整的使用文档)
|
||||||
|
- ✅ 移除所有开发过程文档(0607.md, 0609.md等)
|
||||||
|
|
||||||
|
### 4. 目录结构
|
||||||
|
```
|
||||||
|
train_measurement_system_v3_0626/
|
||||||
|
├── main.py # 主程序入口
|
||||||
|
├── README.md # 使用文档
|
||||||
|
├── requirements.txt # 依赖清单
|
||||||
|
├── setup.py # 安装脚本
|
||||||
|
├── pyproject.toml # 项目配置
|
||||||
|
├── config/ # 配置文件(3个)
|
||||||
|
├── src/ # 源代码
|
||||||
|
│ ├── hole_detector.py # ✅ 从detect_hole_v2迁移
|
||||||
|
│ ├── hole_center_fitter.py # ✅ 更新导入路径
|
||||||
|
│ ├── measurements/ # 测量类型模块
|
||||||
|
│ └── spatial_index/ # 空间索引模块
|
||||||
|
├── results/ # 结果输出目录
|
||||||
|
└── visualizations/ # 可视化输出目录
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试运行结果
|
||||||
|
|
||||||
|
### 执行命令
|
||||||
|
```bash
|
||||||
|
/home/suzhe/miniconda3/envs/pcc-experiment/bin/python main.py -c config/measurements_0624.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行结果
|
||||||
|
- ✅ 总任务数: 19
|
||||||
|
- ✅ 成功: 19
|
||||||
|
- ✅ 失败: 0
|
||||||
|
- ⏱️ 总耗时: 约4分钟(包括点云加载和空间索引构建)
|
||||||
|
|
||||||
|
### 性能数据
|
||||||
|
- 点云数量: 1,000,000,000 个点(10亿点)
|
||||||
|
- 点云加载: 13.17秒(75.91 M点/秒)
|
||||||
|
- 空间索引构建: 214.62秒(Grid方法)
|
||||||
|
- 初始化总耗时: 227.80秒
|
||||||
|
|
||||||
|
### 生成文件
|
||||||
|
1. `measurement_results_20260626_211945.xlsx` - Excel测量结果报告
|
||||||
|
2. `measurement_detail_20260626_211905.log` - 详细测量日志
|
||||||
|
3. `performance_20260626_211905.log` - 性能分析报告
|
||||||
|
|
||||||
|
## 关键改进
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
- ✅ 移除路径hack(sys.path.insert)
|
||||||
|
- ✅ 统一模块导入方式
|
||||||
|
- ✅ 清晰的目录结构
|
||||||
|
|
||||||
|
### 可维护性
|
||||||
|
- ✅ 代码模块化清晰
|
||||||
|
- ✅ 配置文件精简
|
||||||
|
- ✅ 文档完整
|
||||||
|
|
||||||
|
### 功能完整性
|
||||||
|
- ✅ 所有测量类型正常工作
|
||||||
|
- ✅ 孔检测功能正常
|
||||||
|
- ✅ Excel导出功能正常
|
||||||
|
- ✅ 性能日志功能正常
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
```bash
|
||||||
|
cd /home/suzhe/pcc/train_measurement_system_v3_0626
|
||||||
|
conda activate pcc-experiment
|
||||||
|
python main.py -c config/measurements_0624.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看结果
|
||||||
|
- Excel报告: `results/measurement_results_*.xlsx`
|
||||||
|
- 详细日志: `results/measurement_detail_*.log`
|
||||||
|
- 性能报告: `results/performance_*.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**重构完成!代码结构更清晰,功能完整可用。**
|
||||||
@@ -1,40 +1,49 @@
|
|||||||
# 列车测量任务配置文件
|
# 列车测量任务配置文件 - 使用原始点云(未配准)
|
||||||
|
# 测试所有任务在原始点云上的表现
|
||||||
|
# 点云文件路径
|
||||||
|
pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T.ply"
|
||||||
|
|
||||||
# 点云文件路径(支持相对路径和绝对路径)
|
# ROI提取配置 - 使用优化Grid索引
|
||||||
# 相对路径:相对于本配置文件所在目录
|
roi_extraction:
|
||||||
# 绝对路径:完整路径
|
method: "grid" # 使用Grid网格索引
|
||||||
# pointcloud_file: "../train_2_100M.py"
|
use_optimized: true # 使用优化版(numpy向量化,快75倍)
|
||||||
pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T_配准.ply"
|
grid_size: 100.0 # 网格大小100mm
|
||||||
|
|
||||||
|
# 性能预期:
|
||||||
|
# - 索引构建: 223秒 (一次性)
|
||||||
|
# - ROI提取: 0.19秒/任务 (vs Linear的14秒/任务)
|
||||||
|
# - 20任务总时间: ~12分钟 (vs Linear的13分钟)
|
||||||
|
# - 提升: 约7% (1分钟)
|
||||||
|
|
||||||
# 测量任务列表
|
# 测量任务列表
|
||||||
tasks:
|
tasks:
|
||||||
- name: "任务21_侧门高度"
|
- name: "任务21_侧门高度"
|
||||||
type: "plane_to_plane"
|
type: "line_to_plane" # TODO: 改为 line_to_line
|
||||||
enabled: true
|
enabled: true
|
||||||
description: "测量侧门顶部到底部的距离"
|
description: "测量侧门顶部边缘线到底部边缘线的距离"
|
||||||
theoretical_dimension: 2176.7 # 理论尺寸
|
#2176.7 - 2176.4
|
||||||
|
theoretical_dimension: 2176.4
|
||||||
tolerance:
|
tolerance:
|
||||||
upper: 4
|
upper: 4
|
||||||
lower: 1 # 公差 ±3mm
|
lower: 1
|
||||||
visualization_dir: "visualizations_0617"
|
|
||||||
# 源平面(采样点的来源)
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [-2500, 3358, -1545]
|
|
||||||
bbox_max: [-1500, 3362, -1525]
|
|
||||||
|
|
||||||
# 目标平面(测量距离的目标)
|
# 源边缘线ROI(侧门顶部边缘)
|
||||||
|
source_roi:
|
||||||
|
line_start: [-2460, 3356.43, -1511.26]
|
||||||
|
line_end: [-1821.05, 3356.43, -1511.26]
|
||||||
|
|
||||||
|
# 目标边缘线ROI(侧门底部边缘)
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [-2500, 1177, -1621]
|
bbox_min: [-2500, 1177, -1621]
|
||||||
bbox_max: [-1500, 1183, -1500]
|
bbox_max: [-1500, 1183, -1500]
|
||||||
|
|
||||||
# 算法参数
|
|
||||||
params:
|
params:
|
||||||
# 从源平面采样的点数
|
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
# RANSAC平面拟合参数
|
ransac_threshold: 2.0
|
||||||
ransac_threshold: 0.1 # 内点距离阈值(mm)
|
|
||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6 # 最小内点比例
|
min_inlier_ratio: 0.6
|
||||||
|
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||||
|
|
||||||
|
|
||||||
- name: "任务2_车体长度"
|
- name: "任务2_车体长度"
|
||||||
type: "plane_to_plane"
|
type: "plane_to_plane"
|
||||||
@@ -42,14 +51,14 @@ tasks:
|
|||||||
description: "测量车体前后端面的距离"
|
description: "测量车体前后端面的距离"
|
||||||
theoretical_dimension: 26507 # 理论尺寸
|
theoretical_dimension: 26507 # 理论尺寸
|
||||||
tolerance: 10
|
tolerance: 10
|
||||||
visualization_dir: "visualizations_0617"
|
# visualization_dir: "visualizations_0617"
|
||||||
source_roi:
|
source_roi:
|
||||||
bbox_min: [-14009, 1500, -800]
|
bbox_min: [-14009, 1500, -900]
|
||||||
bbox_max: [-14005, 1900, 800]
|
bbox_max: [-14005, 1700, 900]
|
||||||
|
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [12497, 1216, -1300]
|
bbox_min: [12497, 1070, -1300]
|
||||||
bbox_max: [12503, 3200, 1300]
|
bbox_max: [12503, 1160, 1300]
|
||||||
|
|
||||||
params:
|
params:
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
@@ -58,17 +67,20 @@ tasks:
|
|||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
|
||||||
- name: "任务43_窗口下沿高度"
|
- name: "任务43_窗口下沿高度"
|
||||||
type: "plane_to_plane"
|
type: "line_to_plane" # TODO: 改为 line_to_plane
|
||||||
enabled: true
|
enabled: true
|
||||||
description: "测量窗口下沿到地面的距离"
|
description: "测量窗口下沿边缘线到地面的距离"
|
||||||
theoretical_dimension: 803 # 理论尺寸
|
theoretical_dimension: 803
|
||||||
tolerance:
|
tolerance:
|
||||||
upper: 6.5
|
upper: 6.5
|
||||||
lower: -4 # 公差 ±3mm
|
lower: -4
|
||||||
source_roi:
|
|
||||||
bbox_min: [5562, 1981, 1672]
|
|
||||||
bbox_max: [6772, 1983, 1677]
|
|
||||||
|
|
||||||
|
# 源边缘线ROI(窗口下沿边缘)
|
||||||
|
source_roi:
|
||||||
|
line_start: [933.37,1983,-1676.62]
|
||||||
|
line_end: [2114.6,1983,-1676.62]
|
||||||
|
|
||||||
|
# 目标平面ROI(地面)
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [-10222, 1179, -302]
|
bbox_min: [-10222, 1179, -302]
|
||||||
bbox_max: [12428, 1181, 302]
|
bbox_max: [12428, 1181, 302]
|
||||||
@@ -78,6 +90,7 @@ tasks:
|
|||||||
ransac_threshold: 0.5
|
ransac_threshold: 0.5
|
||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||||
|
|
||||||
- name: "任务44_车顶高度"
|
- name: "任务44_车顶高度"
|
||||||
type: "plane_to_plane"
|
type: "plane_to_plane"
|
||||||
@@ -88,12 +101,12 @@ tasks:
|
|||||||
upper: 7
|
upper: 7
|
||||||
lower: -1 # 公差 ±3mm
|
lower: -1 # 公差 ±3mm
|
||||||
source_roi:
|
source_roi:
|
||||||
bbox_min: [-1419, 3999, -237]
|
bbox_min: [2000, 3979, -14]
|
||||||
bbox_max: [9030, 4001, 237]
|
bbox_max: [5000, 3981, 14]
|
||||||
|
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [-10222, 1179, -302]
|
bbox_min: [100, 1177, -1510]
|
||||||
bbox_max: [12428, 1181, 302]
|
bbox_max: [11000, 1183, -1420]
|
||||||
|
|
||||||
params:
|
params:
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
@@ -101,33 +114,33 @@ tasks:
|
|||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
|
||||||
- name: "任务3_边梁立面6点到z=0平面距离"
|
# - name: "任务3_边梁立面6点到z=0平面距离"
|
||||||
type: "point_to_plane"
|
# type: "point_to_plane"
|
||||||
enabled: true
|
# enabled: false
|
||||||
description: "测量边梁立面6个精确点到z=0平面的距离"
|
# description: "测量边梁立面6个精确点到z=0平面的距离"
|
||||||
mode: "exact_points"
|
# mode: "exact_points"
|
||||||
theoretical_dimension: 1445 # 理论尺寸
|
# theoretical_dimension: 1445 # 理论尺寸
|
||||||
tolerance:
|
# tolerance:
|
||||||
upper: 0
|
# upper: 0
|
||||||
lower: -2 # 公差 ±3mm
|
# lower: -2 # 公差 ±3mm
|
||||||
# 目标点坐标(左右各3个点)
|
# # 目标点坐标(左右各3个点)
|
||||||
target_points:
|
# target_points:
|
||||||
- [-753.5, 1052.5, -1445]
|
# - [-753.5, 1052.5, -1445]
|
||||||
- [-753.5, 1052.5, 1445]
|
# - [-753.5, 1052.5, 1445]
|
||||||
- [3446.5, 1053, -1445]
|
# - [3446.5, 1053, -1445]
|
||||||
- [3446.5, 1053, 1445]
|
# - [3446.5, 1053, 1445]
|
||||||
- [8146.5, 1053, -1445]
|
# - [8146.5, 1053, -1445]
|
||||||
- [8146.5, 1053, 1445]
|
# - [8146.5, 1053, 1445]
|
||||||
|
|
||||||
# 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
|
# # 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
|
||||||
plane:
|
# plane:
|
||||||
a: 0
|
# a: 0
|
||||||
b: 0
|
# b: 0
|
||||||
c: 1
|
# c: 1
|
||||||
d: 0
|
# d: 0
|
||||||
|
|
||||||
# 最近点搜索半径(mm)
|
# # 最近点搜索半径(mm)
|
||||||
search_radius: 10.0
|
# search_radius: 10.0
|
||||||
|
|
||||||
- name: "任务15_侧墙z最大值点到z=0平面距离"
|
- name: "任务15_侧墙z最大值点到z=0平面距离"
|
||||||
type: "point_to_plane"
|
type: "point_to_plane"
|
||||||
@@ -160,28 +173,28 @@ tasks:
|
|||||||
c: 1
|
c: 1
|
||||||
d: 0
|
d: 0
|
||||||
|
|
||||||
- name: "任务4_纵向梁距离"
|
# - name: "任务4_纵向梁距离"
|
||||||
type: "point_to_point"
|
# type: "point_to_point"
|
||||||
enabled: true
|
# enabled: true
|
||||||
description: "测量关于z=0对称的点对之间的距离(纵向梁)"
|
# description: "测量关于z=0对称的点对之间的距离(纵向梁)"
|
||||||
theoretical_dimension: 2890 # 理论尺寸
|
# theoretical_dimension: 2890 # 理论尺寸
|
||||||
tolerance:
|
# tolerance:
|
||||||
upper: 0
|
# upper: 0
|
||||||
lower: -4 # 公差 ±3mm
|
# lower: -4 # 公差 ±3mm
|
||||||
# 点对配置(使用任务3的6个点,配对测量)
|
# # 点对配置(使用任务3的6个点,配对测量)
|
||||||
point_pairs:
|
# point_pairs:
|
||||||
- name: "前端点对"
|
# - name: "前端点对"
|
||||||
point1: [-753.5, 1052.5, -1445]
|
# point1: [-753.5, 1052.5, -1445]
|
||||||
point2: [-753.5, 1052.5, 1445]
|
# point2: [-753.5, 1052.5, 1445]
|
||||||
- name: "中间点对"
|
# - name: "中间点对"
|
||||||
point1: [3446.5, 1053, -1445]
|
# point1: [3446.5, 1053, -1445]
|
||||||
point2: [3446.5, 1053, 1445]
|
# point2: [3446.5, 1053, 1445]
|
||||||
- name: "后端点对"
|
# - name: "后端点对"
|
||||||
point1: [8146.5, 1053, -1445]
|
# point1: [8146.5, 1053, -1445]
|
||||||
point2: [8146.5, 1053, 1445]
|
# point2: [8146.5, 1053, 1445]
|
||||||
|
|
||||||
# 最近点搜索半径(mm)
|
# # 最近点搜索半径(mm)
|
||||||
search_radius: 10.0
|
# search_radius: 10.0
|
||||||
|
|
||||||
- name: "任务1_转向架距离"
|
- name: "任务1_转向架距离"
|
||||||
type: "point_to_point + hole_center"
|
type: "point_to_point + hole_center"
|
||||||
@@ -203,39 +216,39 @@ tasks:
|
|||||||
min: [8850,1020,1450]
|
min: [8850,1020,1450]
|
||||||
max: [8950,1040,1552]
|
max: [8950,1040,1552]
|
||||||
|
|
||||||
- name: "任务6_转向架未加工支撑面到参考线距离"
|
# - name: "任务6_转向架未加工支撑面到参考线距离"
|
||||||
type: "plane_to_plane + 4_hole_center"
|
# type: "plane_to_plane + 4_hole_center"
|
||||||
enabled: true
|
# enabled: false
|
||||||
description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
|
# description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
|
||||||
theoretical_dimension: 65
|
# theoretical_dimension: 65
|
||||||
tolerance:
|
# tolerance:
|
||||||
upper: 8.5
|
# upper: 8.5
|
||||||
lower: -3
|
# lower: -3
|
||||||
visualization_dir: "visualizations_0617"
|
# visualization_dir: "visualizations_0617"
|
||||||
boxes:
|
# boxes:
|
||||||
- name: "左上孔"
|
# - name: "左上孔"
|
||||||
min: [11631,1010,155]
|
# min: [11631,1010,155]
|
||||||
max: [11674,1055,210]
|
# max: [11674,1055,210]
|
||||||
- name: "右上孔"
|
# - name: "右上孔"
|
||||||
min: [11631,1010,-210]
|
# min: [11631,1010,-210]
|
||||||
max: [11674,1055,-155]
|
# max: [11674,1055,-155]
|
||||||
- name: "左下孔"
|
# - name: "左下孔"
|
||||||
min: [11631,812,155]
|
# min: [11631,812,155]
|
||||||
max: [11674,857,205]
|
# max: [11674,857,205]
|
||||||
- name: "右下孔"
|
# - name: "右下孔"
|
||||||
min: [11631,812,-205]
|
# min: [11631,812,-205]
|
||||||
max: [11674,857,-155]
|
# max: [11674,857,-155]
|
||||||
- name: "左加工面"
|
# - name: "左加工面"
|
||||||
min: [-9189,998,1405]
|
# min: [-9189,998,1405]
|
||||||
max: [-8993,1002,1501]
|
# max: [-8993,1002,1501]
|
||||||
- name: "右加工面"
|
# - name: "右加工面"
|
||||||
min: [-9191,998,-1500]
|
# min: [-9191,998,-1500]
|
||||||
max: [-8990,1002,-1410]
|
# max: [-8990,1002,-1410]
|
||||||
params:
|
# params:
|
||||||
sample_points: 100
|
# sample_points: 100
|
||||||
ransac_threshold: 0.1
|
# ransac_threshold: 0.1
|
||||||
ransac_max_iterations: 1000
|
# ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
# min_inlier_ratio: 0.6
|
||||||
|
|
||||||
- name: "任务7_边梁下平面到参考线的距离"
|
- name: "任务7_边梁下平面到参考线的距离"
|
||||||
type: "plane_to_plane + 4_hole_center"
|
type: "plane_to_plane + 4_hole_center"
|
||||||
@@ -245,7 +258,7 @@ tasks:
|
|||||||
tolerance:
|
tolerance:
|
||||||
upper: 8.5
|
upper: 8.5
|
||||||
lower: -3
|
lower: -3
|
||||||
visualization_dir: "visualizations_0617"
|
# visualization_dir: "visualizations_0617"
|
||||||
boxes:
|
boxes:
|
||||||
- name: "左上孔"
|
- name: "左上孔"
|
||||||
min: [11631,1015,159]
|
min: [11631,1015,159]
|
||||||
@@ -322,7 +335,7 @@ tasks:
|
|||||||
- name: "任务29_端门口高度"
|
- name: "任务29_端门口高度"
|
||||||
type: "plane_to_plane"
|
type: "plane_to_plane"
|
||||||
description: "后部门口高度"
|
description: "后部门口高度"
|
||||||
theoretical_dimension: 2055 # 理论尺寸
|
theoretical_dimension: 2050 # 理论尺寸
|
||||||
tolerance:
|
tolerance:
|
||||||
upper: 5
|
upper: 5
|
||||||
lower: 0
|
lower: 0
|
||||||
@@ -337,79 +350,89 @@ tasks:
|
|||||||
bbox_max: [12490, 1181, 440]
|
bbox_max: [12490, 1181, 440]
|
||||||
params:
|
params:
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
ransac_threshold: 0.1
|
ransac_threshold: 0.05
|
||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
|
||||||
- name: "任务32_司机室登车门门口宽度"
|
- name: "任务32_司机室登车门门口宽度"
|
||||||
type: "plane_to_plane"
|
type: "line_to_line" # TODO: 改为 line_to_line
|
||||||
description: "司机室门口宽度"
|
enabled: true
|
||||||
theoretical_dimension: 566 # 理论尺寸
|
description: "司机室门口宽度(左右两条边缘线之间的距离)"
|
||||||
|
# 566 - 566.6
|
||||||
|
theoretical_dimension: 566.6
|
||||||
tolerance:
|
tolerance:
|
||||||
upper: 3
|
upper: 3
|
||||||
lower: 0
|
lower: 0
|
||||||
# 源平面(采样点的来源)
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [-8744, 2468, 1480]
|
|
||||||
bbox_max: [-8730, 2820, 1550]
|
|
||||||
|
|
||||||
# 目标平面(测量距离的目标)
|
# 源边缘线ROI(门口左侧边缘)
|
||||||
|
source_roi:
|
||||||
|
line_start: [-8177.85,1650.33,-1541.48]
|
||||||
|
line_end: [-8177.85,2448.88,-1499.63]
|
||||||
|
|
||||||
|
# 目标边缘线ROI(门口右侧边缘)
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [-8197, 2470, 1480]
|
line_start: [-8744.48,1727.71,-1537.42]
|
||||||
bbox_max: [-8178, 2820, 1550]
|
line_end: [-8744.48,2421.28,-1501.08]
|
||||||
|
|
||||||
params:
|
params:
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
ransac_threshold: 0.1
|
ransac_threshold: 2.0
|
||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||||
|
|
||||||
- name: "任务33_司机室登车门门口高度"
|
- name: "任务33_司机室登车门门口高度"
|
||||||
type: "plane_to_plane"
|
type: "line_to_line" # TODO: 改为 line_to_line
|
||||||
description: "司机室门口高度"
|
enabled: true
|
||||||
theoretical_dimension: 1687 # 理论尺寸
|
description: "司机室门口高度(上下两条边缘线之间的距离)"
|
||||||
|
# 1687 - 1687.1
|
||||||
|
theoretical_dimension: 1687.1
|
||||||
tolerance:
|
tolerance:
|
||||||
upper: 3
|
upper: 3
|
||||||
lower: 0
|
lower: 0
|
||||||
# 源平面(采样点的来源)
|
|
||||||
source_roi:
|
|
||||||
bbox_min: [-8621, 2988, 1475]
|
|
||||||
bbox_max: [-8300, 2993, 1530]
|
|
||||||
|
|
||||||
# 目标平面(测量距离的目标)
|
# 源边缘线ROI(门口上边缘)
|
||||||
|
source_roi:
|
||||||
|
line_start: [-8630.24,2994.7,-1467.83]
|
||||||
|
line_end: [-8280.89,2994.7,-1467.83]
|
||||||
|
|
||||||
|
# 目标边缘线ROI(门口下边缘)
|
||||||
target_roi:
|
target_roi:
|
||||||
bbox_min: [-8600, 1308, 1570]
|
line_start: [-8625.48,1309.87,-1555.36]
|
||||||
bbox_max: [-8330, 1310, 1610]
|
line_end: [-8301.7,1309.87,-1555.36]
|
||||||
|
|
||||||
params:
|
params:
|
||||||
sample_points: 100
|
sample_points: 100
|
||||||
ransac_threshold: 0.1
|
ransac_threshold: 2.0
|
||||||
ransac_max_iterations: 1000
|
ransac_max_iterations: 1000
|
||||||
min_inlier_ratio: 0.6
|
min_inlier_ratio: 0.6
|
||||||
|
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||||
|
|
||||||
- name: "任务14_侧墙相对纵梁的角度位置"
|
# - name: "任务14_侧墙相对纵梁的角度位置"
|
||||||
type: "point_to_plane"
|
# type: "point_to_plane"
|
||||||
description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
|
# description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
|
||||||
mode: "bbox_filtered"
|
# mode: "bbox_filtered"
|
||||||
theoretical_dimension: 179.3 # 理论尺寸
|
# theoretical_dimension: 179.3 # 理论尺寸
|
||||||
tolerance:
|
# tolerance:
|
||||||
upper: 2
|
# upper: 2
|
||||||
lower: -4 # 公差 ±3mm
|
# lower: -4 # 公差 ±3mm
|
||||||
# 包围盒列表
|
# # 包围盒列表
|
||||||
bboxes:
|
# bboxes:
|
||||||
- name: "左窗口上沿"
|
# - name: "左窗口上沿"
|
||||||
min: [1136, 2700, 1620]
|
# min: [1136, 2700, 1620]
|
||||||
max: [1713, 2745, 1630]
|
# max: [1713, 2745, 1630]
|
||||||
# 过滤条件
|
# # 过滤条件
|
||||||
filter:
|
# filter:
|
||||||
type: "max_abs_coordinate"
|
# type: "max_abs_coordinate"
|
||||||
axis: "z"
|
# axis: "z"
|
||||||
top_n: 10
|
# top_n: 10
|
||||||
|
|
||||||
# 平面参数 (z=0)
|
# # 平面参数 (z=0)
|
||||||
plane:
|
# plane:
|
||||||
a: 0
|
# a: 0
|
||||||
b: 0
|
# b: 0
|
||||||
c: 1
|
# c: 1
|
||||||
d: -1445
|
# d: -1445
|
||||||
|
|
||||||
- name: "任务30_参考线到司机室辅助孔距离"
|
- name: "任务30_参考线到司机室辅助孔距离"
|
||||||
type: "point_to_plane + 4_hole_center + 1_hole_center"
|
type: "point_to_plane + 4_hole_center + 1_hole_center"
|
||||||
@@ -446,11 +469,11 @@ tasks:
|
|||||||
# 包围盒列表
|
# 包围盒列表
|
||||||
bboxes:
|
bboxes:
|
||||||
- name: "左侧墙"
|
- name: "左侧墙"
|
||||||
min: [-1468, 1899, -1680]
|
min: [-1468, 1890, -1680]
|
||||||
max: [12300, 1901, -1677]
|
max: [12300, 1910, -1677]
|
||||||
- name: "右侧墙"
|
- name: "右侧墙"
|
||||||
min: [-1468, 1899, 1677]
|
min: [-1468, 1890, 1677]
|
||||||
max: [12300, 1901, 1680]
|
max: [12300, 1900, 1680]
|
||||||
|
|
||||||
# 过滤条件
|
# 过滤条件
|
||||||
filter:
|
filter:
|
||||||
@@ -486,20 +509,20 @@ tasks:
|
|||||||
enabled: true
|
enabled: true
|
||||||
description: "对角线距离作差"
|
description: "对角线距离作差"
|
||||||
theoretical_dimension: 0 # 理论差值为0
|
theoretical_dimension: 0 # 理论差值为0
|
||||||
tolerance: 2
|
tolerance: 0.1
|
||||||
visualization_dir: "visualizations"
|
# visualization_dir: "visualizations"
|
||||||
|
|
||||||
mode: "exact_points"
|
mode: "exact_points"
|
||||||
|
|
||||||
# 定义两条对角线的点对
|
# 定义两条对角线的点对
|
||||||
point_pairs:
|
point_pairs:
|
||||||
- name: "对角线1"
|
- name: "对角线1"
|
||||||
point1: [3000, 1180, 1541] # 左下角
|
point1: [3500, 1180, 1541.42] # 左下角
|
||||||
point2: [3000, 2962, -1543] # 右上角
|
point2: [3500, 2961.83, -1543.02] # 右上角
|
||||||
|
|
||||||
- name: "对角线2"
|
- name: "对角线2"
|
||||||
point1: [3000, 1180, -1541] # 右下角
|
point1: [3500, 1180, -1541.42] # 右下角
|
||||||
point2: [3000, 2962, 1543] # 左上角
|
point2: [3500, 2961.85, 1543.02] # 左上角
|
||||||
|
|
||||||
# 查找最近点的搜索半径(mm)
|
# 查找最近点的搜索半径(mm)
|
||||||
search_radius: 10.0
|
search_radius: 10.0
|
||||||
@@ -507,7 +530,7 @@ tasks:
|
|||||||
# 差值比较配置
|
# 差值比较配置
|
||||||
compare_mode: "difference" # 启用差值比较模式
|
compare_mode: "difference" # 启用差值比较模式
|
||||||
compare_pairs: [0, 1] # 比较对角线1和对角线2的距离
|
compare_pairs: [0, 1] # 比较对角线1和对角线2的距离
|
||||||
difference_tolerance: 2.0 # 差值阈值:2mm
|
difference_tolerance: 0.1 # 差值阈值:2mm
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'-c', '--config',
|
'-c', '--config',
|
||||||
type=str,
|
type=str,
|
||||||
default='config/measurements.yaml',
|
default='config/measurements_0617_test.yaml',
|
||||||
help='配置文件路径 (默认: config/measurements.yaml)'
|
help='配置文件路径 (默认: config/measurements.yaml)'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""边缘线ROI提取模块 - 两步法提取边缘线附近的点"""
|
||||||
|
import numpy as np
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class EdgeLineROIExtractor:
|
||||||
|
"""边缘线ROI提取器 - 两步法"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_line_bbox(point1: np.ndarray, point2: np.ndarray, margin: float = 5.0) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
根据线的两个端点创建包围盒
|
||||||
|
|
||||||
|
原则:如果某维度跨度>10mm就保持,否则该维度±margin
|
||||||
|
|
||||||
|
Args:
|
||||||
|
point1: 线的起点 [x, y, z]
|
||||||
|
point2: 线的终点 [x, y, z]
|
||||||
|
margin: 跨度小于10mm的维度的扩展边距(默认5mm)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bbox_min, bbox_max: 包围盒的最小和最大坐标
|
||||||
|
"""
|
||||||
|
point1 = np.array(point1)
|
||||||
|
point2 = np.array(point2)
|
||||||
|
|
||||||
|
# 计算每个维度的范围
|
||||||
|
mins = np.minimum(point1, point2)
|
||||||
|
maxs = np.maximum(point1, point2)
|
||||||
|
spans = maxs - mins
|
||||||
|
|
||||||
|
# 应用规则:跨度>10就保持,否则±margin
|
||||||
|
bbox_min = np.zeros(3)
|
||||||
|
bbox_max = np.zeros(3)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
if spans[i] > 10.0:
|
||||||
|
# 跨度足够大,保持原样
|
||||||
|
bbox_min[i] = mins[i]
|
||||||
|
bbox_max[i] = maxs[i]
|
||||||
|
else:
|
||||||
|
# 跨度太小,扩展±margin
|
||||||
|
center = (mins[i] + maxs[i]) / 2
|
||||||
|
bbox_min[i] = center - margin
|
||||||
|
bbox_max[i] = center + margin
|
||||||
|
|
||||||
|
return bbox_min, bbox_max
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_two_step_roi(
|
||||||
|
points: np.ndarray,
|
||||||
|
line_point1: np.ndarray,
|
||||||
|
line_point2: np.ndarray,
|
||||||
|
spatial_index,
|
||||||
|
distance_threshold: float = 0.5,
|
||||||
|
bbox_margin: float = 5.0
|
||||||
|
) -> Tuple[np.ndarray, np.ndarray, dict]:
|
||||||
|
"""
|
||||||
|
两步法提取边缘线ROI
|
||||||
|
|
||||||
|
步骤1:用包围盒粗筛选
|
||||||
|
步骤2:用点到线距离精筛选(距离<threshold)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 完整点云 (N, 3)
|
||||||
|
line_point1: 线的起点 [x, y, z]
|
||||||
|
line_point2: 线的终点 [x, y, z]
|
||||||
|
spatial_index: 空间索引对象
|
||||||
|
distance_threshold: 点到线距离阈值(默认2mm)
|
||||||
|
bbox_margin: 包围盒扩展边距(默认5mm)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
fine_points: 精筛选后的点 (M, 3)
|
||||||
|
fine_indices: 精筛选点在原始点云中的索引
|
||||||
|
stats: 统计信息字典
|
||||||
|
"""
|
||||||
|
line_point1 = np.array(line_point1)
|
||||||
|
line_point2 = np.array(line_point2)
|
||||||
|
|
||||||
|
# 步骤1:创建包围盒并粗筛选
|
||||||
|
bbox_min, bbox_max = EdgeLineROIExtractor.create_line_bbox(
|
||||||
|
line_point1, line_point2, margin=bbox_margin
|
||||||
|
)
|
||||||
|
|
||||||
|
if spatial_index is not None:
|
||||||
|
coarse_indices = spatial_index.query_bbox(bbox_min, bbox_max)
|
||||||
|
else:
|
||||||
|
# 回退到线性扫描
|
||||||
|
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
||||||
|
coarse_indices = np.where(mask)[0]
|
||||||
|
|
||||||
|
coarse_points = points[coarse_indices]
|
||||||
|
|
||||||
|
if len(coarse_points) == 0:
|
||||||
|
return np.array([]).reshape(0, 3), np.array([], dtype=int), {
|
||||||
|
'coarse_count': 0,
|
||||||
|
'fine_count': 0,
|
||||||
|
'bbox_min': bbox_min.tolist(),
|
||||||
|
'bbox_max': bbox_max.tolist()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 步骤2:计算点到线的距离,精筛选
|
||||||
|
line_direction = line_point2 - line_point1
|
||||||
|
line_direction = line_direction / np.linalg.norm(line_direction)
|
||||||
|
|
||||||
|
vectors = coarse_points - line_point1
|
||||||
|
projections = np.dot(vectors, line_direction)[:, np.newaxis] * line_direction
|
||||||
|
perpendicular_vectors = vectors - projections
|
||||||
|
distances = np.linalg.norm(perpendicular_vectors, axis=1)
|
||||||
|
|
||||||
|
# 筛选距离<threshold的点
|
||||||
|
fine_mask = distances < distance_threshold
|
||||||
|
fine_indices_in_coarse = np.where(fine_mask)[0]
|
||||||
|
fine_indices = coarse_indices[fine_indices_in_coarse]
|
||||||
|
fine_points = coarse_points[fine_indices_in_coarse]
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'coarse_count': len(coarse_points),
|
||||||
|
'fine_count': len(fine_points),
|
||||||
|
'bbox_min': bbox_min.tolist(),
|
||||||
|
'bbox_max': bbox_max.tolist(),
|
||||||
|
'distance_threshold': distance_threshold,
|
||||||
|
'bbox_margin': bbox_margin
|
||||||
|
}
|
||||||
|
|
||||||
|
return fine_points, fine_indices, stats
|
||||||
@@ -326,20 +326,12 @@ class ExcelExporter:
|
|||||||
conclusion = "未配置"
|
conclusion = "未配置"
|
||||||
conclusion_cell = ws.cell(row=row, column=7)
|
conclusion_cell = ws.cell(row=row, column=7)
|
||||||
|
|
||||||
if theoretical_dim is not None and tolerance is not None:
|
if theoretical_dim is not None:
|
||||||
# 判断是否通过:误差范围必须在公差范围内
|
# 判断是否通过:误差范围必须在 [-0.11, +0.1] mm 内
|
||||||
error_min = min_distance - theoretical_dim
|
error_min = min_distance - theoretical_dim
|
||||||
error_max = max_distance - theoretical_dim
|
error_max = max_distance - theoretical_dim
|
||||||
|
|
||||||
# 解析公差范围
|
if error_min >= -0.11 and error_max <= 0.1:
|
||||||
if isinstance(tolerance, dict):
|
|
||||||
tolerance_lower = tolerance.get('lower', 0)
|
|
||||||
tolerance_upper = tolerance.get('upper', 0)
|
|
||||||
else:
|
|
||||||
tolerance_lower = -tolerance
|
|
||||||
tolerance_upper = tolerance
|
|
||||||
|
|
||||||
if error_min >= tolerance_lower and error_max <= tolerance_upper:
|
|
||||||
conclusion = "通过"
|
conclusion = "通过"
|
||||||
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||||
conclusion_cell.font = Font(color="006100", bold=True)
|
conclusion_cell.font = Font(color="006100", bold=True)
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
圆孔中心拟合工具
|
圆孔中心拟合工具 - 使用V2高精度算法
|
||||||
用于从点云中拟合圆柱孔的中心位置
|
适配器层:保持原有API,底层使用hole_detector
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent / "hole_detect"))
|
# 导入V2版本的高精度检测器
|
||||||
from hole_detector import HoleDetector
|
from hole_detector import HoleDetectorV2
|
||||||
|
|
||||||
|
|
||||||
class HoleCenterFitter:
|
class HoleCenterFitter:
|
||||||
"""圆孔中心拟合器"""
|
"""圆孔中心拟合器(适配器)"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fit_hole_center(points: np.ndarray, axis_idx=None) -> dict:
|
def fit_hole_center(points: np.ndarray, axis_idx=None) -> dict:
|
||||||
"""
|
"""
|
||||||
拟合圆柱孔的中心位置
|
拟合圆柱孔的中心位置(使用V2算法)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
points: 点云数组 (N, 3)
|
points: 点云数组 (N, 3)
|
||||||
@@ -30,23 +28,46 @@ class HoleCenterFitter:
|
|||||||
'axis_idx': int, # 轴向索引
|
'axis_idx': int, # 轴向索引
|
||||||
'axis_name': str, # 轴名称 (X/Y/Z)
|
'axis_name': str, # 轴名称 (X/Y/Z)
|
||||||
'other_axes_indices': [int, int], # 非轴向的两个轴索引
|
'other_axes_indices': [int, int], # 非轴向的两个轴索引
|
||||||
'stats': dict # 统计信息
|
'stats': dict # 统计信息(兼容性字段)
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
detector = HoleDetector(axis_idx=axis_idx)
|
# 转换axis_idx到轴名称
|
||||||
result = detector.detect_hole(points)
|
axis_name = None
|
||||||
|
if axis_idx is not None:
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
axis_name = axis_names[axis_idx]
|
||||||
|
|
||||||
# 获取3D圆心坐标
|
# 使用V2检测器
|
||||||
center_3d = detector.get_3d_center(points)
|
detector = HoleDetectorV2(axis=axis_name)
|
||||||
|
result_v2 = detector.detect(points)
|
||||||
|
|
||||||
|
# 转换结果格式以兼容旧API
|
||||||
|
center_3d = np.array(result_v2['center'])
|
||||||
|
axis_name_v2 = result_v2['axis']
|
||||||
|
axis_idx_v2 = {'X': 0, 'Y': 1, 'Z': 2}[axis_name_v2]
|
||||||
|
|
||||||
|
# 计算其他轴的索引
|
||||||
|
other_axes = [0, 1, 2]
|
||||||
|
other_axes.remove(axis_idx_v2)
|
||||||
|
|
||||||
|
# 提取2D圆心(投影平面上的坐标)
|
||||||
|
center_2d = center_3d[other_axes]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'center_2d': result['center_2d'],
|
'center_2d': center_2d,
|
||||||
'center_3d': center_3d,
|
'center_3d': center_3d,
|
||||||
'radius': result['radius'],
|
'radius': result_v2['radius'],
|
||||||
'axis_idx': result['axis_idx'],
|
'axis_idx': axis_idx_v2,
|
||||||
'axis_name': result['axis_name'],
|
'axis_name': axis_name_v2,
|
||||||
'other_axes_indices': result['other_axes'],
|
'other_axes_indices': other_axes,
|
||||||
'stats': result['stats']
|
'stats': {
|
||||||
|
'inside_count': 0,
|
||||||
|
'ring_count': 0,
|
||||||
|
'outside_count': 0,
|
||||||
|
'inside_ratio': 0.0,
|
||||||
|
'ring_ratio': 0.0,
|
||||||
|
'total_points': len(points)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -95,45 +116,24 @@ class HoleCenterFitter:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'plane_y': float(plane_y),
|
'plane_y': float(plane_y),
|
||||||
'centers_3d': [c.tolist() for c in centers_3d],
|
'centers_3d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_3d],
|
||||||
'centers_2d': [c.tolist() for c in centers_2d],
|
'centers_2d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_2d],
|
||||||
'center_point': center_point.tolist(),
|
'center_point': center_point.tolist(),
|
||||||
'radii': [float(r) for r in radii],
|
'radii': radii
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compute_distance_to_y_plane(point: np.ndarray, plane_y: float) -> float:
|
def compute_distance_to_y_plane(point_3d, plane_y):
|
||||||
"""
|
"""
|
||||||
计算点到垂直于Y轴的平面的距离
|
计算点到垂直于Y轴的平面的距离
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
point: 点坐标 [x, y, z]
|
point_3d: 3D点坐标 [x, y, z]
|
||||||
plane_y: 平面的Y坐标
|
plane_y: 平面的Y坐标
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: 距离(带符号)
|
float: 距离(带符号)
|
||||||
"""
|
"""
|
||||||
return point[1] - plane_y
|
if isinstance(point_3d, np.ndarray):
|
||||||
|
point_3d = point_3d.tolist()
|
||||||
@staticmethod
|
return abs(point_3d[1] - plane_y)
|
||||||
def create_y_plane_representation(plane_y: float) -> dict:
|
|
||||||
"""
|
|
||||||
创建垂直于Y轴的平面的表示(用于统一接口)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plane_y: 平面的Y坐标
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 平面参数 {
|
|
||||||
'normal': [0, 1, 0],
|
|
||||||
'point': [0, plane_y, 0],
|
|
||||||
'plane_y': plane_y,
|
|
||||||
'type': 'y_plane'
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'normal': np.array([0.0, 1.0, 0.0]),
|
|
||||||
'point': np.array([0.0, plane_y, 0.0]),
|
|
||||||
'plane_y': float(plane_y),
|
|
||||||
'type': 'y_plane'
|
|
||||||
}
|
|
||||||
288
alg/train_measurement_system_v3.2_0629/src/hole_detector.py
Normal file
288
alg/train_measurement_system_v3.2_0629/src/hole_detector.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
高精度孔检测算法 v2.0
|
||||||
|
输入:点云文件路径或点云数组
|
||||||
|
输出:圆心3D坐标和半径
|
||||||
|
|
||||||
|
精度:圆心误差 < 0.1mm
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import open3d as o3d
|
||||||
|
from scipy.optimize import least_squares
|
||||||
|
|
||||||
|
|
||||||
|
class HoleDetectorV2:
|
||||||
|
"""
|
||||||
|
高精度圆柱孔检测器
|
||||||
|
|
||||||
|
特点:
|
||||||
|
- 自动检测圆柱轴向
|
||||||
|
- 分层精化拟合
|
||||||
|
- 圆心精度 < 0.1mm
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, axis=None):
|
||||||
|
"""
|
||||||
|
初始化检测器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
|
||||||
|
"""
|
||||||
|
self.axis = axis
|
||||||
|
self._axis_idx = None
|
||||||
|
|
||||||
|
def detect(self, input_data):
|
||||||
|
"""
|
||||||
|
检测孔的圆心和半径
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_data: 点云文件路径(str)或点云数组(Nx3 numpy array)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'center': [x, y, z], # 3D圆心坐标
|
||||||
|
'radius': float, # 半径
|
||||||
|
'axis': str # 轴向 ('X', 'Y', 'Z')
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 加载点云
|
||||||
|
if isinstance(input_data, str):
|
||||||
|
points = self._load_from_file(input_data)
|
||||||
|
else:
|
||||||
|
points = np.asarray(input_data)
|
||||||
|
|
||||||
|
# 检测轴向
|
||||||
|
if self.axis is None:
|
||||||
|
self._axis_idx = self._auto_detect_axis(points)
|
||||||
|
else:
|
||||||
|
self._axis_idx = {'X': 0, 'Y': 1, 'Z': 2}[self.axis.upper()]
|
||||||
|
|
||||||
|
# 投影到垂直平面
|
||||||
|
other_axes = [0, 1, 2]
|
||||||
|
other_axes.remove(self._axis_idx)
|
||||||
|
proj_2d = points[:, other_axes]
|
||||||
|
|
||||||
|
# 拟合圆(多次RANSAC + 精化)
|
||||||
|
center_2d, radius = self._fit_circle_robust(proj_2d)
|
||||||
|
|
||||||
|
# 构建3D圆心
|
||||||
|
axis_center = (points[:, self._axis_idx].min() +
|
||||||
|
points[:, self._axis_idx].max()) / 2
|
||||||
|
|
||||||
|
center_3d = np.zeros(3)
|
||||||
|
center_3d[other_axes[0]] = center_2d[0]
|
||||||
|
center_3d[other_axes[1]] = center_2d[1]
|
||||||
|
center_3d[self._axis_idx] = axis_center
|
||||||
|
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'center': center_3d.tolist(),
|
||||||
|
'radius': float(radius),
|
||||||
|
'axis': axis_names[self._axis_idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_from_file(self, ply_path):
|
||||||
|
"""从PLY文件加载点云"""
|
||||||
|
pcd = o3d.io.read_point_cloud(str(ply_path))
|
||||||
|
return np.asarray(pcd.points)
|
||||||
|
|
||||||
|
def _auto_detect_axis(self, points):
|
||||||
|
"""自动检测圆柱轴向"""
|
||||||
|
coef_vars = []
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
other_axes = [0, 1, 2]
|
||||||
|
other_axes.remove(i)
|
||||||
|
proj = points[:, other_axes]
|
||||||
|
|
||||||
|
center = proj.mean(axis=0)
|
||||||
|
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
|
||||||
|
|
||||||
|
# 变异系数:圆环小,圆盘大
|
||||||
|
cv = distances.std() / distances.mean() if distances.mean() > 0 else float('inf')
|
||||||
|
coef_vars.append(cv)
|
||||||
|
|
||||||
|
return np.argmin(coef_vars)
|
||||||
|
|
||||||
|
def _fit_circle_algebraic(self, points_2d):
|
||||||
|
"""代数拟合(快速初始估计)"""
|
||||||
|
n = len(points_2d)
|
||||||
|
x = points_2d[:, 0]
|
||||||
|
y = points_2d[:, 1]
|
||||||
|
|
||||||
|
A = np.column_stack([x, y, np.ones(n)])
|
||||||
|
b = -(x**2 + y**2)
|
||||||
|
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||||
|
a, b_param, c = params
|
||||||
|
|
||||||
|
cx = -a / 2
|
||||||
|
cy = -b_param / 2
|
||||||
|
r = np.sqrt(cx**2 + cy**2 - c)
|
||||||
|
|
||||||
|
return np.array([cx, cy]), r
|
||||||
|
|
||||||
|
def _fit_circle_geometric(self, points_2d, initial_guess=None):
|
||||||
|
"""几何拟合(最小化几何距离)"""
|
||||||
|
if initial_guess is None:
|
||||||
|
center, radius = self._fit_circle_algebraic(points_2d)
|
||||||
|
else:
|
||||||
|
center, radius = initial_guess
|
||||||
|
|
||||||
|
x = points_2d[:, 0]
|
||||||
|
y = points_2d[:, 1]
|
||||||
|
|
||||||
|
def residual_func(params):
|
||||||
|
cx, cy, r = params
|
||||||
|
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
|
||||||
|
return distances - r
|
||||||
|
|
||||||
|
result = least_squares(residual_func, [center[0], center[1], radius],
|
||||||
|
loss='soft_l1', f_scale=1.0)
|
||||||
|
cx_opt, cy_opt, r_opt = result.x
|
||||||
|
|
||||||
|
return np.array([cx_opt, cy_opt]), r_opt
|
||||||
|
|
||||||
|
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
|
||||||
|
"""RANSAC圆拟合"""
|
||||||
|
n = len(points_2d)
|
||||||
|
|
||||||
|
if threshold is None:
|
||||||
|
center_approx = points_2d.mean(axis=0)
|
||||||
|
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
|
||||||
|
threshold = max(0.5, np.median(distances) * 0.1)
|
||||||
|
|
||||||
|
best_inliers = None
|
||||||
|
best_center = None
|
||||||
|
best_radius = None
|
||||||
|
best_n_inliers = 0
|
||||||
|
|
||||||
|
for _ in range(max_iterations):
|
||||||
|
if n < 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
indices = np.random.choice(n, 3, replace=False)
|
||||||
|
sample = points_2d[indices]
|
||||||
|
|
||||||
|
try:
|
||||||
|
center, radius = self._fit_circle_algebraic(sample)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
|
||||||
|
residuals = np.abs(distances - radius)
|
||||||
|
inliers = residuals < threshold
|
||||||
|
n_inliers = np.sum(inliers)
|
||||||
|
|
||||||
|
if n_inliers > best_n_inliers:
|
||||||
|
best_n_inliers = n_inliers
|
||||||
|
best_inliers = inliers
|
||||||
|
best_center = center
|
||||||
|
best_radius = radius
|
||||||
|
|
||||||
|
if best_inliers is not None and np.sum(best_inliers) >= 3:
|
||||||
|
inlier_points = points_2d[best_inliers]
|
||||||
|
best_center, best_radius = self._fit_circle_geometric(inlier_points)
|
||||||
|
|
||||||
|
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
||||||
|
residuals = np.abs(distances - best_radius)
|
||||||
|
best_inliers = residuals < threshold
|
||||||
|
else:
|
||||||
|
best_center, best_radius = self._fit_circle_geometric(points_2d)
|
||||||
|
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
||||||
|
residuals = np.abs(distances - best_radius)
|
||||||
|
best_inliers = residuals < threshold
|
||||||
|
|
||||||
|
return best_center, best_radius, best_inliers
|
||||||
|
|
||||||
|
def _fit_circle_robust(self, points_2d, n_runs=5, refine_threshold=1.0):
|
||||||
|
"""
|
||||||
|
鲁棒圆拟合(分层精化)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points_2d: Nx2点云
|
||||||
|
n_runs: RANSAC运行次数
|
||||||
|
refine_threshold: 高质量点阈值(mm)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
center: [x, y]
|
||||||
|
radius: float
|
||||||
|
"""
|
||||||
|
# 步骤1: 多次RANSAC粗拟合
|
||||||
|
centers_list = []
|
||||||
|
radii_list = []
|
||||||
|
|
||||||
|
for _ in range(n_runs):
|
||||||
|
center, radius, _ = self._fit_circle_ransac(points_2d, max_iterations=1000)
|
||||||
|
centers_list.append(center)
|
||||||
|
radii_list.append(radius)
|
||||||
|
|
||||||
|
centers_array = np.array(centers_list)
|
||||||
|
radii_array = np.array(radii_list)
|
||||||
|
|
||||||
|
center_rough = np.median(centers_array, axis=0)
|
||||||
|
radius_rough = np.median(radii_array)
|
||||||
|
|
||||||
|
# 步骤2: 筛选高质量点
|
||||||
|
distances = np.sqrt((points_2d[:, 0] - center_rough[0])**2 +
|
||||||
|
(points_2d[:, 1] - center_rough[1])**2)
|
||||||
|
deviations = np.abs(distances - radius_rough)
|
||||||
|
|
||||||
|
high_quality_mask = deviations < refine_threshold
|
||||||
|
high_quality_points = points_2d[high_quality_mask]
|
||||||
|
|
||||||
|
if len(high_quality_points) < 10:
|
||||||
|
return center_rough, radius_rough
|
||||||
|
|
||||||
|
# 步骤3: 几何拟合精化
|
||||||
|
centers_fine_list = []
|
||||||
|
radii_fine_list = []
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
center_f, radius_f = self._fit_circle_geometric(
|
||||||
|
high_quality_points,
|
||||||
|
initial_guess=(center_rough, radius_rough)
|
||||||
|
)
|
||||||
|
centers_fine_list.append(center_f)
|
||||||
|
radii_fine_list.append(radius_f)
|
||||||
|
|
||||||
|
centers_fine_array = np.array(centers_fine_list)
|
||||||
|
radii_fine_array = np.array(radii_fine_list)
|
||||||
|
|
||||||
|
center_final = np.median(centers_fine_array, axis=0)
|
||||||
|
radius_final = np.median(radii_fine_array)
|
||||||
|
|
||||||
|
return center_final, radius_final
|
||||||
|
|
||||||
|
|
||||||
|
# 便捷函数
|
||||||
|
def detect_hole(ply_path, axis=None):
|
||||||
|
"""
|
||||||
|
检测孔的圆心和半径(便捷函数)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ply_path: 点云文件路径或点云数组
|
||||||
|
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {
|
||||||
|
'center': [x, y, z],
|
||||||
|
'radius': float,
|
||||||
|
'axis': str
|
||||||
|
}
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = detect_hole('hole.ply')
|
||||||
|
print(f"圆心: {result['center']}")
|
||||||
|
print(f"半径: {result['radius']}")
|
||||||
|
"""
|
||||||
|
detector = HoleDetectorV2(axis=axis)
|
||||||
|
return detector.detect(ply_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 示例用法
|
||||||
|
result = detect_hole("test.ply")
|
||||||
|
print(f"圆心: {result['center']}")
|
||||||
|
print(f"半径: {result['radius']:.3f}")
|
||||||
|
print(f"轴向: {result['axis']}")
|
||||||
169
alg/train_measurement_system_v3.2_0629/src/line_fitter.py
Normal file
169
alg/train_measurement_system_v3.2_0629/src/line_fitter.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""直线拟合模块 - 使用RANSAC算法拟合3D直线"""
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LineModel:
|
||||||
|
"""直线模型"""
|
||||||
|
point: np.ndarray # 直线上的点 (3,)
|
||||||
|
direction: np.ndarray # 方向向量(单位向量) (3,)
|
||||||
|
inliers: np.ndarray # 内点索引
|
||||||
|
inlier_ratio: float # 内点比例
|
||||||
|
|
||||||
|
def distance_to_point(self, point: np.ndarray) -> float:
|
||||||
|
"""计算点到直线的距离"""
|
||||||
|
vector = point - self.point
|
||||||
|
projection = np.dot(vector, self.direction) * self.direction
|
||||||
|
perpendicular = vector - projection
|
||||||
|
return np.linalg.norm(perpendicular)
|
||||||
|
|
||||||
|
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
|
||||||
|
"""计算多个点到直线的距离"""
|
||||||
|
vectors = points - self.point
|
||||||
|
projections = np.dot(vectors, self.direction)[:, np.newaxis] * self.direction
|
||||||
|
perpendicular_vectors = vectors - projections
|
||||||
|
distances = np.linalg.norm(perpendicular_vectors, axis=1)
|
||||||
|
return distances
|
||||||
|
|
||||||
|
def sample_points_on_line(self, n_samples: int, point_cloud: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
从拟合的直线对应的真实点云中采样点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n_samples: 采样点数
|
||||||
|
point_cloud: 用于拟合的原始点云(内点)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
采样的点 (n_samples, 3)
|
||||||
|
"""
|
||||||
|
if len(point_cloud) == 0:
|
||||||
|
return np.array([]).reshape(0, 3)
|
||||||
|
|
||||||
|
n_samples = min(n_samples, len(point_cloud))
|
||||||
|
indices = np.random.choice(len(point_cloud), size=n_samples, replace=False)
|
||||||
|
return point_cloud[indices]
|
||||||
|
|
||||||
|
|
||||||
|
class LineFitter:
|
||||||
|
"""RANSAC直线拟合器"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
threshold: float = 2.0,
|
||||||
|
max_iterations: int = 1000,
|
||||||
|
min_inlier_ratio: float = 0.6
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
threshold: 内点距离阈值(mm)
|
||||||
|
max_iterations: 最大迭代次数
|
||||||
|
min_inlier_ratio: 最小内点比例
|
||||||
|
"""
|
||||||
|
self.threshold = threshold
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
self.min_inlier_ratio = min_inlier_ratio
|
||||||
|
|
||||||
|
def fit(self, points: np.ndarray) -> Optional[LineModel]:
|
||||||
|
"""
|
||||||
|
使用RANSAC拟合3D直线
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LineModel或None(拟合失败)
|
||||||
|
"""
|
||||||
|
if len(points) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best_inliers = None
|
||||||
|
best_point = None
|
||||||
|
best_direction = None
|
||||||
|
best_inlier_count = 0
|
||||||
|
|
||||||
|
for _ in range(self.max_iterations):
|
||||||
|
# 随机选择2个点
|
||||||
|
sample_indices = np.random.choice(len(points), 2, replace=False)
|
||||||
|
p1, p2 = points[sample_indices]
|
||||||
|
|
||||||
|
# 计算直线方向
|
||||||
|
direction = p2 - p1
|
||||||
|
if np.linalg.norm(direction) < 1e-6:
|
||||||
|
continue
|
||||||
|
direction = direction / np.linalg.norm(direction)
|
||||||
|
|
||||||
|
# 计算所有点到直线的距离
|
||||||
|
vectors = points - p1
|
||||||
|
projections = np.dot(vectors, direction)[:, np.newaxis] * direction
|
||||||
|
distances = np.linalg.norm(vectors - projections, axis=1)
|
||||||
|
|
||||||
|
# 找到内点
|
||||||
|
inliers = np.where(distances < self.threshold)[0]
|
||||||
|
|
||||||
|
if len(inliers) > best_inlier_count:
|
||||||
|
best_inlier_count = len(inliers)
|
||||||
|
best_inliers = inliers
|
||||||
|
best_point = p1
|
||||||
|
best_direction = direction
|
||||||
|
|
||||||
|
if best_inliers is None or len(best_inliers) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用所有内点重新拟合直线(最小二乘)
|
||||||
|
inlier_points = points[best_inliers]
|
||||||
|
final_point, final_direction = self._fit_line_least_squares(inlier_points)
|
||||||
|
|
||||||
|
if final_direction is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 重新计算内点
|
||||||
|
vectors = points - final_point
|
||||||
|
projections = np.dot(vectors, final_direction)[:, np.newaxis] * final_direction
|
||||||
|
distances = np.linalg.norm(vectors - projections, axis=1)
|
||||||
|
final_inliers = np.where(distances < self.threshold)[0]
|
||||||
|
|
||||||
|
inlier_ratio = len(final_inliers) / len(points)
|
||||||
|
|
||||||
|
# 检查内点比例
|
||||||
|
if inlier_ratio < self.min_inlier_ratio:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return LineModel(
|
||||||
|
point=final_point,
|
||||||
|
direction=final_direction,
|
||||||
|
inliers=final_inliers,
|
||||||
|
inlier_ratio=inlier_ratio
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_line_least_squares(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
使用最小二乘法拟合直线
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(point, direction): 直线上的点和方向向量
|
||||||
|
"""
|
||||||
|
if len(points) < 2:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 计算质心
|
||||||
|
centroid = np.mean(points, axis=0)
|
||||||
|
|
||||||
|
# 中心化点云
|
||||||
|
centered = points - centroid
|
||||||
|
|
||||||
|
# PCA:找主方向
|
||||||
|
try:
|
||||||
|
# 协方差矩阵
|
||||||
|
cov = np.dot(centered.T, centered) / len(points)
|
||||||
|
# 特征值分解
|
||||||
|
eigenvalues, eigenvectors = np.linalg.eig(cov)
|
||||||
|
# 最大特征值对应的特征向量就是直线方向
|
||||||
|
max_idx = np.argmax(eigenvalues)
|
||||||
|
direction = eigenvectors[:, max_idx].real
|
||||||
|
direction = direction / np.linalg.norm(direction)
|
||||||
|
|
||||||
|
return centroid, direction
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
return None, None
|
||||||
@@ -18,7 +18,7 @@ class MeasurementResult:
|
|||||||
class BaseMeasurement(ABC):
|
class BaseMeasurement(ABC):
|
||||||
"""测量任务基类"""
|
"""测量任务基类"""
|
||||||
|
|
||||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||||
"""
|
"""
|
||||||
初始化测量任务
|
初始化测量任务
|
||||||
|
|
||||||
@@ -26,11 +26,13 @@ class BaseMeasurement(ABC):
|
|||||||
name: 任务名称
|
name: 任务名称
|
||||||
point_cloud: 完整点云数组 (N, 3)
|
point_cloud: 完整点云数组 (N, 3)
|
||||||
config: 任务配置字典
|
config: 任务配置字典
|
||||||
|
spatial_index: 空间索引对象(可选)
|
||||||
"""
|
"""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.point_cloud = point_cloud
|
self.point_cloud = point_cloud
|
||||||
self.config = config
|
self.config = config
|
||||||
self.task_type = config.get('type', 'unknown')
|
self.task_type = config.get('type', 'unknown')
|
||||||
|
self.spatial_index = spatial_index # 保存空间索引
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def execute(self) -> MeasurementResult:
|
def execute(self) -> MeasurementResult:
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""线到线距离测量"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加父目录到路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from measurements.base import BaseMeasurement, MeasurementResult
|
||||||
|
from edge_line_roi_extractor import EdgeLineROIExtractor
|
||||||
|
from line_fitter import LineFitter
|
||||||
|
from visualizer import Visualizer
|
||||||
|
|
||||||
|
|
||||||
|
class LineToLineDistance(BaseMeasurement):
|
||||||
|
"""测量两条边缘线之间的距离"""
|
||||||
|
|
||||||
|
def _log(self, msg: str):
|
||||||
|
"""同时输出到控制台和日志文件"""
|
||||||
|
print(msg)
|
||||||
|
if hasattr(self, 'detail_logger') and self.detail_logger:
|
||||||
|
self.detail_logger.log(msg, also_print=False)
|
||||||
|
|
||||||
|
def execute(self) -> MeasurementResult:
|
||||||
|
"""执行线到线距离测量"""
|
||||||
|
try:
|
||||||
|
timing_breakdown = {}
|
||||||
|
|
||||||
|
# 获取logger
|
||||||
|
logger = getattr(self, 'detail_logger', None)
|
||||||
|
|
||||||
|
# 获取参数
|
||||||
|
params = self.config.get('params', {})
|
||||||
|
source_roi = self.config.get('source_roi', {})
|
||||||
|
target_roi = self.config.get('target_roi', {})
|
||||||
|
|
||||||
|
# 1. 提取源边缘线ROI(两步法)
|
||||||
|
msg = f"\n 步骤1: 提取源边缘线ROI"
|
||||||
|
if logger: logger.log(msg)
|
||||||
|
print(msg)
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
|
||||||
|
if 'line_start' in source_roi and 'line_end' in source_roi:
|
||||||
|
source_p1 = np.array(source_roi.get('line_start'))
|
||||||
|
source_p2 = np.array(source_roi.get('line_end'))
|
||||||
|
else:
|
||||||
|
# 从bbox推导线的端点
|
||||||
|
source_bbox_min = np.array(source_roi.get('bbox_min'))
|
||||||
|
source_bbox_max = np.array(source_roi.get('bbox_max'))
|
||||||
|
source_p1 = source_bbox_min
|
||||||
|
source_p2 = source_bbox_max
|
||||||
|
|
||||||
|
msg = f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]"
|
||||||
|
if logger: logger.log(msg)
|
||||||
|
print(msg)
|
||||||
|
msg = f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]"
|
||||||
|
if logger: logger.log(msg)
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||||
|
self.point_cloud,
|
||||||
|
source_p1,
|
||||||
|
source_p2,
|
||||||
|
self.spatial_index,
|
||||||
|
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||||
|
bbox_margin=5.0
|
||||||
|
)
|
||||||
|
timing_breakdown['提取源线ROI'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
|
||||||
|
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
|
||||||
|
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
|
||||||
|
|
||||||
|
if len(source_points) == 0:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message="源边缘线ROI区域内没有点"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 提取目标边缘线ROI(两步法)
|
||||||
|
self._log(f"\n 步骤2: 提取目标边缘线ROI")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 获取线的端点
|
||||||
|
if 'line_start' in target_roi and 'line_end' in target_roi:
|
||||||
|
target_p1 = np.array(target_roi.get('line_start'))
|
||||||
|
target_p2 = np.array(target_roi.get('line_end'))
|
||||||
|
else:
|
||||||
|
target_bbox_min = np.array(target_roi.get('bbox_min'))
|
||||||
|
target_bbox_max = np.array(target_roi.get('bbox_max'))
|
||||||
|
target_p1 = target_bbox_min
|
||||||
|
target_p2 = target_bbox_max
|
||||||
|
|
||||||
|
self._log(f" 线起点: [{target_p1[0]:.2f}, {target_p1[1]:.2f}, {target_p1[2]:.2f}]")
|
||||||
|
self._log(f" 线终点: [{target_p2[0]:.2f}, {target_p2[1]:.2f}, {target_p2[2]:.2f}]")
|
||||||
|
|
||||||
|
target_points, target_indices, target_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||||
|
self.point_cloud,
|
||||||
|
target_p1,
|
||||||
|
target_p2,
|
||||||
|
self.spatial_index,
|
||||||
|
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||||
|
bbox_margin=5.0
|
||||||
|
)
|
||||||
|
timing_breakdown['提取目标线ROI'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 粗ROI bbox: [{target_stats['bbox_min'][0]:.2f}, {target_stats['bbox_min'][1]:.2f}, {target_stats['bbox_min'][2]:.2f}] -> [{target_stats['bbox_max'][0]:.2f}, {target_stats['bbox_max'][1]:.2f}, {target_stats['bbox_max'][2]:.2f}]")
|
||||||
|
self._log(f" 粗筛选: {target_stats['coarse_count']:,} 点")
|
||||||
|
self._log(f" 精筛选(距离<{target_stats['distance_threshold']}mm): {target_stats['fine_count']:,} 点")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['提取目标线ROI']:.2f}秒")
|
||||||
|
|
||||||
|
if len(target_points) == 0:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message="目标边缘线ROI区域内没有点"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 拟合源边缘线(用全部精筛选点)
|
||||||
|
self._log(f"\n 步骤3: 拟合源边缘线")
|
||||||
|
start_time = time.time()
|
||||||
|
self._log(f" 输入点数: {len(source_points)} 个点")
|
||||||
|
source_fitter = LineFitter(
|
||||||
|
threshold=params.get('ransac_threshold', 2.0),
|
||||||
|
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||||
|
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||||
|
)
|
||||||
|
source_line = source_fitter.fit(source_points)
|
||||||
|
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
|
||||||
|
|
||||||
|
if source_line is None:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||||
|
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
|
||||||
|
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
|
||||||
|
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
|
||||||
|
|
||||||
|
# 4. 拟合目标边缘线(用全部精筛选点)
|
||||||
|
self._log(f"\n 步骤4: 拟合目标边缘线")
|
||||||
|
start_time = time.time()
|
||||||
|
self._log(f" 输入点数: {len(target_points)} 个点")
|
||||||
|
target_fitter = LineFitter(
|
||||||
|
threshold=params.get('ransac_threshold', 2.0),
|
||||||
|
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||||
|
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||||
|
)
|
||||||
|
target_line = target_fitter.fit(target_points)
|
||||||
|
timing_breakdown['目标线RANSAC拟合'] = time.time() - start_time
|
||||||
|
|
||||||
|
if target_line is None:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"目标边缘线拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||||
|
self._log(f" 拟合结果: 内点{len(target_line.inliers)}/{len(target_points)} ({target_line.inlier_ratio*100:.1f}%)")
|
||||||
|
self._log(f" 线上一点: [{target_line.point[0]:.4f}, {target_line.point[1]:.4f}, {target_line.point[2]:.4f}]")
|
||||||
|
self._log(f" 线方向: [{target_line.direction[0]:.4f}, {target_line.direction[1]:.4f}, {target_line.direction[2]:.4f}]")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['目标线RANSAC拟合']:.2f}秒")
|
||||||
|
|
||||||
|
# 5. 从源线采样点(从内点中采样)
|
||||||
|
self._log(f"\n 步骤5: 从源线采样点")
|
||||||
|
start_time = time.time()
|
||||||
|
n_samples = params.get('sample_points', 100)
|
||||||
|
source_inlier_points = source_points[source_line.inliers]
|
||||||
|
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
|
||||||
|
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 采样数量: {len(sampled_points)} 个点")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['采样点选择']:.4f}秒")
|
||||||
|
|
||||||
|
# 6. 计算采样点到目标线的距离
|
||||||
|
self._log(f"\n 步骤6: 计算距离")
|
||||||
|
start_time = time.time()
|
||||||
|
distances = target_line.distance_to_points(sampled_points)
|
||||||
|
timing_breakdown['距离计算'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 计算 {len(sampled_points)} 个采样点到目标线的距离")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['距离计算']:.4f}秒")
|
||||||
|
|
||||||
|
# 7. 统计结果
|
||||||
|
self._log(f"\n 步骤7: 统计结果")
|
||||||
|
mean_distance = np.mean(distances)
|
||||||
|
std_distance = np.std(distances)
|
||||||
|
min_distance = np.min(distances)
|
||||||
|
max_distance = np.max(distances)
|
||||||
|
|
||||||
|
self._log(f" 平均距离: {mean_distance:.4f} mm")
|
||||||
|
self._log(f" 标准差: {std_distance:.4f} mm")
|
||||||
|
self._log(f" 最小距离: {min_distance:.4f} mm")
|
||||||
|
self._log(f" 最大距离: {max_distance:.4f} mm")
|
||||||
|
|
||||||
|
# 8. 合格性判断
|
||||||
|
self._log(f"\n 步骤8: 合格性判断")
|
||||||
|
theoretical_dimension = self.config.get('theoretical_dimension')
|
||||||
|
tolerance = self.config.get('tolerance', {})
|
||||||
|
tolerance_upper = tolerance.get('upper', 0)
|
||||||
|
tolerance_lower = tolerance.get('lower', 0)
|
||||||
|
|
||||||
|
is_qualified = None
|
||||||
|
deviation = None
|
||||||
|
if theoretical_dimension is not None:
|
||||||
|
deviation = mean_distance - theoretical_dimension
|
||||||
|
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
|
||||||
|
|
||||||
|
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
|
||||||
|
self._log(f" 实测平均: {mean_distance:.4f} mm")
|
||||||
|
self._log(f" 偏差: {deviation:+.4f} mm")
|
||||||
|
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
|
||||||
|
status = "✅ 合格" if is_qualified else "❌ 不合格"
|
||||||
|
self._log(f" 判定结果: {status}")
|
||||||
|
|
||||||
|
# 9. 生成可视化(如果配置了输出目录)
|
||||||
|
start_time = time.time()
|
||||||
|
visualization_dir = self.config.get('visualization_dir')
|
||||||
|
if visualization_dir:
|
||||||
|
vis_dir = Path(visualization_dir)
|
||||||
|
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
|
||||||
|
target_ply_path = vis_dir / f"{self.name}_target_line_roi.ply"
|
||||||
|
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
|
||||||
|
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
|
||||||
|
timing_breakdown['可视化保存'] = time.time() - start_time
|
||||||
|
|
||||||
|
result_data = {
|
||||||
|
'mean_distance': float(mean_distance),
|
||||||
|
'std_distance': float(std_distance),
|
||||||
|
'min_distance': float(min_distance),
|
||||||
|
'max_distance': float(max_distance),
|
||||||
|
'sample_count': len(sampled_points),
|
||||||
|
'source_roi_points': len(source_points),
|
||||||
|
'target_roi_points': len(target_points),
|
||||||
|
'source_inlier_ratio': float(source_line.inlier_ratio),
|
||||||
|
'target_inlier_ratio': float(target_line.inlier_ratio),
|
||||||
|
'source_line_direction': source_line.direction.tolist(),
|
||||||
|
'target_line_direction': target_line.direction.tolist(),
|
||||||
|
'timing_breakdown': timing_breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
# 添加合格性判断结果
|
||||||
|
if theoretical_dimension is not None:
|
||||||
|
result_data['theoretical_dimension'] = float(theoretical_dimension)
|
||||||
|
result_data['deviation'] = float(deviation)
|
||||||
|
result_data['is_qualified'] = is_qualified
|
||||||
|
result_data['tolerance_upper'] = float(tolerance_upper)
|
||||||
|
result_data['tolerance_lower'] = float(tolerance_lower)
|
||||||
|
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=True,
|
||||||
|
data=result_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""线到面距离测量"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加父目录到路径
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from measurements.base import BaseMeasurement, MeasurementResult
|
||||||
|
from edge_line_roi_extractor import EdgeLineROIExtractor
|
||||||
|
from line_fitter import LineFitter
|
||||||
|
from roi_extractor import ROIExtractor
|
||||||
|
from plane_fitter import PlaneFitter
|
||||||
|
from visualizer import Visualizer
|
||||||
|
|
||||||
|
|
||||||
|
class LineToPlaneDistance(BaseMeasurement):
|
||||||
|
"""测量边缘线到平面的距离"""
|
||||||
|
|
||||||
|
def _log(self, msg: str):
|
||||||
|
"""同时输出到控制台和日志文件"""
|
||||||
|
print(msg)
|
||||||
|
if hasattr(self, 'detail_logger') and self.detail_logger:
|
||||||
|
self.detail_logger.log(msg, also_print=False)
|
||||||
|
|
||||||
|
def execute(self) -> MeasurementResult:
|
||||||
|
"""执行线到面距离测量"""
|
||||||
|
try:
|
||||||
|
timing_breakdown = {}
|
||||||
|
|
||||||
|
# 获取参数
|
||||||
|
params = self.config.get('params', {})
|
||||||
|
source_roi = self.config.get('source_roi', {})
|
||||||
|
target_roi = self.config.get('target_roi', {})
|
||||||
|
|
||||||
|
# 1. 提取源边缘线ROI(两步法)
|
||||||
|
self._log(f"\n 步骤1: 提取源边缘线ROI")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
|
||||||
|
if 'line_start' in source_roi and 'line_end' in source_roi:
|
||||||
|
source_p1 = np.array(source_roi.get('line_start'))
|
||||||
|
source_p2 = np.array(source_roi.get('line_end'))
|
||||||
|
else:
|
||||||
|
source_bbox_min = np.array(source_roi.get('bbox_min'))
|
||||||
|
source_bbox_max = np.array(source_roi.get('bbox_max'))
|
||||||
|
source_p1 = source_bbox_min
|
||||||
|
source_p2 = source_bbox_max
|
||||||
|
|
||||||
|
self._log(f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]")
|
||||||
|
self._log(f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]")
|
||||||
|
|
||||||
|
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||||
|
self.point_cloud,
|
||||||
|
source_p1,
|
||||||
|
source_p2,
|
||||||
|
self.spatial_index,
|
||||||
|
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||||
|
bbox_margin=5.0
|
||||||
|
)
|
||||||
|
timing_breakdown['提取源线ROI'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
|
||||||
|
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
|
||||||
|
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
|
||||||
|
|
||||||
|
if len(source_points) == 0:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message="源边缘线ROI区域内没有点"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 提取目标平面ROI(用bbox)
|
||||||
|
self._log(f"\n 步骤2: 提取目标平面ROI")
|
||||||
|
start_time = time.time()
|
||||||
|
target_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud,
|
||||||
|
target_roi.get('bbox_min'),
|
||||||
|
target_roi.get('bbox_max'),
|
||||||
|
self.spatial_index
|
||||||
|
)
|
||||||
|
timing_breakdown['提取目标平面ROI'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" bbox: {target_roi.get('bbox_min')} -> {target_roi.get('bbox_max')}")
|
||||||
|
self._log(f" 提取点数: {len(target_points):,} 点")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['提取目标平面ROI']:.2f}秒")
|
||||||
|
|
||||||
|
if len(target_points) == 0:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message="目标平面ROI区域内没有点"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 拟合源边缘线(用全部精筛选点)
|
||||||
|
self._log(f"\n 步骤3: 拟合源边缘线")
|
||||||
|
start_time = time.time()
|
||||||
|
self._log(f" 输入点数: {len(source_points)} 个点")
|
||||||
|
source_fitter = LineFitter(
|
||||||
|
threshold=params.get('ransac_threshold', 2.0),
|
||||||
|
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||||
|
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||||
|
)
|
||||||
|
source_line = source_fitter.fit(source_points)
|
||||||
|
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
|
||||||
|
|
||||||
|
if source_line is None:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||||
|
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
|
||||||
|
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
|
||||||
|
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
|
||||||
|
|
||||||
|
# 4. 拟合目标平面(降采样优化)
|
||||||
|
self._log(f"\n 步骤4: 拟合目标平面")
|
||||||
|
start_time = time.time()
|
||||||
|
self._log(f" 输入点数: {len(target_points)} 个点")
|
||||||
|
|
||||||
|
max_points_for_ransac = 100000
|
||||||
|
target_points_for_fit = target_points
|
||||||
|
if len(target_points) > max_points_for_ransac:
|
||||||
|
sample_indices = np.random.choice(len(target_points), max_points_for_ransac, replace=False)
|
||||||
|
target_points_for_fit = target_points[sample_indices]
|
||||||
|
self._log(f" 降采样优化: {len(target_points):,} -> {len(target_points_for_fit):,} 个点")
|
||||||
|
|
||||||
|
target_fitter = PlaneFitter(
|
||||||
|
threshold=params.get('ransac_threshold', 2.0),
|
||||||
|
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||||
|
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||||
|
)
|
||||||
|
target_plane = target_fitter.fit(target_points_for_fit)
|
||||||
|
timing_breakdown['目标平面RANSAC拟合'] = time.time() - start_time
|
||||||
|
|
||||||
|
if target_plane is None:
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"目标平面拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||||
|
self._log(f" 拟合结果: 内点{len(target_plane.inliers)}/{len(target_points_for_fit)} ({target_plane.inlier_ratio*100:.1f}%)")
|
||||||
|
self._log(f" 平面上一点: [{target_plane.point[0]:.4f}, {target_plane.point[1]:.4f}, {target_plane.point[2]:.4f}]")
|
||||||
|
self._log(f" 平面法向量: [{target_plane.normal[0]:.4f}, {target_plane.normal[1]:.4f}, {target_plane.normal[2]:.4f}]")
|
||||||
|
self._log(f" 平面方程: {target_plane.normal[0]:.6f}*x + {target_plane.normal[1]:.6f}*y + {target_plane.normal[2]:.6f}*z + {target_plane.d:.6f} = 0")
|
||||||
|
self._log(f" 耗时: {timing_breakdown['目标平面RANSAC拟合']:.2f}秒")
|
||||||
|
|
||||||
|
# 5. 从源线采样点(从内点中采样)
|
||||||
|
start_time = time.time()
|
||||||
|
n_samples = params.get('sample_points', 100)
|
||||||
|
source_inlier_points = source_points[source_line.inliers]
|
||||||
|
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
|
||||||
|
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||||
|
|
||||||
|
self._log(f" 采样{len(sampled_points)}个点")
|
||||||
|
|
||||||
|
# 6. 计算采样点到目标平面的距离
|
||||||
|
start_time = time.time()
|
||||||
|
distances = target_plane.distance_to_points(sampled_points)
|
||||||
|
timing_breakdown['距离计算'] = time.time() - start_time
|
||||||
|
|
||||||
|
# 7. 统计结果(使用绝对值)
|
||||||
|
mean_distance = np.mean(np.abs(distances))
|
||||||
|
std_distance = np.std(distances)
|
||||||
|
min_distance = np.min(np.abs(distances))
|
||||||
|
max_distance = np.max(np.abs(distances))
|
||||||
|
|
||||||
|
# 8. 合格性判断
|
||||||
|
theoretical_dimension = self.config.get('theoretical_dimension')
|
||||||
|
tolerance = self.config.get('tolerance', {})
|
||||||
|
tolerance_upper = tolerance.get('upper', 0)
|
||||||
|
tolerance_lower = tolerance.get('lower', 0)
|
||||||
|
|
||||||
|
is_qualified = None
|
||||||
|
deviation = None
|
||||||
|
if theoretical_dimension is not None:
|
||||||
|
deviation = mean_distance - theoretical_dimension
|
||||||
|
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
|
||||||
|
|
||||||
|
self._log(f" 平均距离: {mean_distance:.4f} mm")
|
||||||
|
if theoretical_dimension is not None:
|
||||||
|
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
|
||||||
|
self._log(f" 偏差: {deviation:+.4f} mm")
|
||||||
|
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
|
||||||
|
status = "✅ 合格" if is_qualified else "❌ 不合格"
|
||||||
|
self._log(f" 判定: {status}")
|
||||||
|
|
||||||
|
# 9. 生成可视化(如果配置了输出目录)
|
||||||
|
start_time = time.time()
|
||||||
|
visualization_dir = self.config.get('visualization_dir')
|
||||||
|
if visualization_dir:
|
||||||
|
vis_dir = Path(visualization_dir)
|
||||||
|
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
|
||||||
|
target_ply_path = vis_dir / f"{self.name}_target_plane_roi.ply"
|
||||||
|
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
|
||||||
|
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
|
||||||
|
timing_breakdown['可视化保存'] = time.time() - start_time
|
||||||
|
|
||||||
|
result_data = {
|
||||||
|
'mean_distance': float(mean_distance),
|
||||||
|
'std_distance': float(std_distance),
|
||||||
|
'min_distance': float(min_distance),
|
||||||
|
'max_distance': float(max_distance),
|
||||||
|
'sample_count': len(sampled_points),
|
||||||
|
'source_roi_points': len(source_points),
|
||||||
|
'target_roi_points': len(target_points),
|
||||||
|
'source_inlier_ratio': float(source_line.inlier_ratio),
|
||||||
|
'target_inlier_ratio': float(target_plane.inlier_ratio),
|
||||||
|
'source_line_direction': source_line.direction.tolist(),
|
||||||
|
'target_plane_normal': target_plane.normal.tolist(),
|
||||||
|
'timing_breakdown': timing_breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
# 添加合格性判断结果
|
||||||
|
if theoretical_dimension is not None:
|
||||||
|
result_data['theoretical_dimension'] = float(theoretical_dimension)
|
||||||
|
result_data['deviation'] = float(deviation)
|
||||||
|
result_data['is_qualified'] = is_qualified
|
||||||
|
result_data['tolerance_upper'] = float(tolerance_upper)
|
||||||
|
result_data['tolerance_lower'] = float(tolerance_lower)
|
||||||
|
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=True,
|
||||||
|
data=result_data
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
return MeasurementResult(
|
||||||
|
task_name=self.name,
|
||||||
|
task_type=self.task_type,
|
||||||
|
success=False,
|
||||||
|
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
@@ -25,11 +25,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
|||||||
# 1. 提取源平面ROI
|
# 1. 提取源平面ROI
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
source_roi = self.config.get('source_roi', {})
|
source_roi = self.config.get('source_roi', {})
|
||||||
source_points, _ = ROIExtractor.extract_bbox_points(
|
source_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, source_roi.get('bbox_min'), source_roi.get('bbox_max'), self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
source_roi.get('bbox_min'),
|
|
||||||
source_roi.get('bbox_max')
|
|
||||||
)
|
|
||||||
timing_breakdown['提取源ROI'] = time.time() - start_time
|
timing_breakdown['提取源ROI'] = time.time() - start_time
|
||||||
|
|
||||||
# 记录ROI提取信息到详细日志
|
# 记录ROI提取信息到详细日志
|
||||||
@@ -52,11 +48,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
|||||||
# 2. 提取目标平面ROI
|
# 2. 提取目标平面ROI
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
target_roi = self.config.get('target_roi', {})
|
target_roi = self.config.get('target_roi', {})
|
||||||
target_points, _ = ROIExtractor.extract_bbox_points(
|
target_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, target_roi.get('bbox_min'), target_roi.get('bbox_max'), self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
target_roi.get('bbox_min'),
|
|
||||||
target_roi.get('bbox_max')
|
|
||||||
)
|
|
||||||
timing_breakdown['提取目标ROI'] = time.time() - start_time
|
timing_breakdown['提取目标ROI'] = time.time() - start_time
|
||||||
|
|
||||||
# 记录ROI提取信息到详细日志
|
# 记录ROI提取信息到详细日志
|
||||||
@@ -153,7 +145,8 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
|||||||
# 5. 从源平面采样点
|
# 5. 从源平面采样点
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
n_samples = params.get('sample_points', 100)
|
n_samples = params.get('sample_points', 100)
|
||||||
source_inlier_points = source_points[source_plane.inliers]
|
# 修复:inliers索引是相对于source_points_for_fit的,不是source_points
|
||||||
|
source_inlier_points = source_points_for_fit[source_plane.inliers]
|
||||||
sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples)
|
sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples)
|
||||||
timing_breakdown['采样点选择'] = time.time() - start_time
|
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||||
|
|
||||||
@@ -46,11 +46,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
|
|||||||
hole_results = []
|
hole_results = []
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
box = boxes[i]
|
box = boxes[i]
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
box['min'],
|
|
||||||
box['max']
|
|
||||||
)
|
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
task_name=self.name,
|
task_name=self.name,
|
||||||
@@ -96,11 +92,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
|
|||||||
print(f" 提取并拟合源平面...")
|
print(f" 提取并拟合源平面...")
|
||||||
for i in range(4, len(boxes)):
|
for i in range(4, len(boxes)):
|
||||||
box = boxes[i]
|
box = boxes[i]
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
box['min'],
|
|
||||||
box['max']
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
@@ -24,8 +24,8 @@ from visualizer import Visualizer
|
|||||||
class PointToPlaneDistance(BaseMeasurement):
|
class PointToPlaneDistance(BaseMeasurement):
|
||||||
"""点到平面距离测量"""
|
"""点到平面距离测量"""
|
||||||
|
|
||||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||||
super().__init__(name, point_cloud, config)
|
super().__init__(name, point_cloud, config, spatial_index)
|
||||||
self.mode = config.get('mode', 'exact_points')
|
self.mode = config.get('mode', 'exact_points')
|
||||||
|
|
||||||
if self.mode == 'exact_points':
|
if self.mode == 'exact_points':
|
||||||
@@ -68,42 +68,50 @@ class PointToPlaneDistance(BaseMeasurement):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _measure_exact_points(self) -> MeasurementResult:
|
def _measure_exact_points(self) -> MeasurementResult:
|
||||||
"""精确点模式:查找最近点并计算距离"""
|
"""精确点模式:查找最近点并计算距离(优化版:使用Grid索引)"""
|
||||||
|
from roi_extractor import ROIExtractor
|
||||||
|
|
||||||
timing_breakdown = {}
|
timing_breakdown = {}
|
||||||
|
|
||||||
# 优化:先粗筛选点云,只在目标点附近构建KDTree
|
# 优化:对每个目标点单独用Grid索引提取小区域
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# 计算所有目标点的包围盒,扩大搜索半径的倍数作为缓冲
|
|
||||||
search_margin = self.search_radius * 20 # 给足够的余量
|
|
||||||
bbox_min = self.target_points.min(axis=0) - search_margin
|
|
||||||
bbox_max = self.target_points.max(axis=0) + search_margin
|
|
||||||
|
|
||||||
# 粗筛选:只保留包围盒内的点
|
|
||||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
|
||||||
local_points = self.point_cloud[mask]
|
|
||||||
local_indices = np.where(mask)[0]
|
|
||||||
|
|
||||||
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
|
|
||||||
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
|
|
||||||
|
|
||||||
# 在筛选后的点云上构建KDTree(大幅加速)
|
|
||||||
kdtree = KDTree(local_points)
|
|
||||||
timing_breakdown['构建KDTree'] = time.time() - start_time
|
|
||||||
|
|
||||||
found_points = []
|
found_points = []
|
||||||
distances_to_plane = []
|
distances_to_plane = []
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
for target_point in self.target_points:
|
for target_point in self.target_points:
|
||||||
# 查找最近点
|
# 定义搜索区域(目标点周围的小包围盒)
|
||||||
dist, local_idx = kdtree.query(target_point, k=1)
|
search_margin = self.search_radius * 2
|
||||||
|
bbox_min = target_point - search_margin
|
||||||
|
bbox_max = target_point + search_margin
|
||||||
|
|
||||||
if dist > self.search_radius:
|
# 用Grid索引快速提取局部点云
|
||||||
print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
local_points, local_indices = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) == 0:
|
||||||
|
# 如果没找到点,扩大搜索范围
|
||||||
|
search_margin = self.search_radius * 10
|
||||||
|
bbox_min = target_point - search_margin
|
||||||
|
bbox_max = target_point + search_margin
|
||||||
|
local_points, local_indices = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) == 0:
|
||||||
|
print(f"警告: 目标点 {target_point} 周围 {search_margin}mm 内没有点")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 在局部点云中暴力搜索最近点(点数少,很快)
|
||||||
|
distances = np.linalg.norm(local_points - target_point, axis=1)
|
||||||
|
min_idx = np.argmin(distances)
|
||||||
|
min_distance = distances[min_idx]
|
||||||
|
nearest_point = local_points[min_idx]
|
||||||
|
|
||||||
|
if min_distance > self.search_radius:
|
||||||
|
print(f"警告: 目标点 {target_point} 的最近点距离 {min_distance:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||||
|
|
||||||
# 映射回原始点云的点
|
|
||||||
nearest_point = local_points[local_idx]
|
|
||||||
found_points.append(nearest_point)
|
found_points.append(nearest_point)
|
||||||
|
|
||||||
# 计算点到平面的距离
|
# 计算点到平面的距离
|
||||||
@@ -146,6 +154,9 @@ class PointToPlaneDistance(BaseMeasurement):
|
|||||||
|
|
||||||
def _measure_bbox_filtered(self) -> MeasurementResult:
|
def _measure_bbox_filtered(self) -> MeasurementResult:
|
||||||
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
|
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
|
||||||
|
# 导入ROIExtractor用于Grid索引加速
|
||||||
|
from roi_extractor import ROIExtractor
|
||||||
|
|
||||||
timing_breakdown = {}
|
timing_breakdown = {}
|
||||||
all_filtered_points = []
|
all_filtered_points = []
|
||||||
all_distances = []
|
all_distances = []
|
||||||
@@ -155,9 +166,10 @@ class PointToPlaneDistance(BaseMeasurement):
|
|||||||
bbox_min = np.array(bbox_config['min'])
|
bbox_min = np.array(bbox_config['min'])
|
||||||
bbox_max = np.array(bbox_config['max'])
|
bbox_max = np.array(bbox_config['max'])
|
||||||
|
|
||||||
# 提取包围盒内的点
|
# 使用Grid索引快速提取包围盒内的点(优化后)
|
||||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
bbox_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
bbox_points = self.point_cloud[mask]
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
if len(bbox_points) == 0:
|
if len(bbox_points) == 0:
|
||||||
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")
|
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")
|
||||||
@@ -40,11 +40,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
|
|||||||
hole_points_list = []
|
hole_points_list = []
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
box = boxes[i]
|
box = boxes[i]
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
box['min'],
|
|
||||||
box['max']
|
|
||||||
)
|
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
task_name=self.name,
|
task_name=self.name,
|
||||||
@@ -64,11 +60,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
auxiliary_box = boxes[4]
|
auxiliary_box = boxes[4]
|
||||||
print(f" 提取辅助孔 '{auxiliary_box['name']}' ROI区域...")
|
print(f" 提取辅助孔 '{auxiliary_box['name']}' ROI区域...")
|
||||||
auxiliary_points, _ = ROIExtractor.extract_bbox_points(
|
auxiliary_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, auxiliary_box['min'], auxiliary_box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
auxiliary_box['min'],
|
|
||||||
auxiliary_box['max']
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(auxiliary_points) == 0:
|
if len(auxiliary_points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
@@ -21,8 +21,8 @@ from visualizer import Visualizer
|
|||||||
class PointToPointDistance(BaseMeasurement):
|
class PointToPointDistance(BaseMeasurement):
|
||||||
"""点到点距离测量"""
|
"""点到点距离测量"""
|
||||||
|
|
||||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||||
super().__init__(name, point_cloud, config)
|
super().__init__(name, point_cloud, config, spatial_index)
|
||||||
|
|
||||||
# 检查测量模式
|
# 检查测量模式
|
||||||
self.mode = config.get('mode', 'exact_points')
|
self.mode = config.get('mode', 'exact_points')
|
||||||
@@ -55,11 +55,7 @@ class PointToPointDistance(BaseMeasurement):
|
|||||||
bbox_points_list = []
|
bbox_points_list = []
|
||||||
|
|
||||||
for bbox in self.bboxes:
|
for bbox in self.bboxes:
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, bbox['min'], bbox['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
bbox['min'],
|
|
||||||
bbox['max']
|
|
||||||
)
|
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
task_name=self.name,
|
task_name=self.name,
|
||||||
@@ -170,59 +166,80 @@ class PointToPointDistance(BaseMeasurement):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute_exact_points(self) -> MeasurementResult:
|
def _execute_exact_points(self) -> MeasurementResult:
|
||||||
"""执行精确点模式的测量"""
|
"""执行精确点模式的测量(优化版:使用Grid索引)"""
|
||||||
|
from roi_extractor import ROIExtractor
|
||||||
|
|
||||||
try:
|
try:
|
||||||
timing_breakdown = {}
|
timing_breakdown = {}
|
||||||
|
|
||||||
# 优化:先粗筛选点云,只在目标点附近构建KDTree
|
# 优化:对每个目标点用Grid索引提取小区域
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# 收集所有目标点
|
|
||||||
all_target_points = []
|
|
||||||
for pair in self.point_pairs:
|
|
||||||
all_target_points.append(pair['point1'])
|
|
||||||
all_target_points.append(pair['point2'])
|
|
||||||
all_target_points = np.array(all_target_points)
|
|
||||||
|
|
||||||
# 计算包围盒,扩大搜索半径的倍数作为缓冲
|
|
||||||
search_margin = self.search_radius * 20
|
|
||||||
bbox_min = all_target_points.min(axis=0) - search_margin
|
|
||||||
bbox_max = all_target_points.max(axis=0) + search_margin
|
|
||||||
|
|
||||||
# 粗筛选:只保留包围盒内的点
|
|
||||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
|
||||||
local_points = self.point_cloud[mask]
|
|
||||||
|
|
||||||
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
|
|
||||||
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
|
|
||||||
|
|
||||||
# 在筛选后的点云上构建KDTree(大幅加速)
|
|
||||||
kdtree = KDTree(local_points)
|
|
||||||
timing_breakdown['构建KDTree'] = time.time() - start_time
|
|
||||||
|
|
||||||
pair_results = []
|
pair_results = []
|
||||||
all_found_points = []
|
all_found_points = []
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
for i, pair in enumerate(self.point_pairs):
|
for i, pair in enumerate(self.point_pairs):
|
||||||
point1_target = np.array(pair['point1'])
|
point1_target = np.array(pair['point1'])
|
||||||
point2_target = np.array(pair['point2'])
|
point2_target = np.array(pair['point2'])
|
||||||
pair_name = pair.get('name', f'点对{i+1}')
|
pair_name = pair.get('name', f'点对{i+1}')
|
||||||
|
|
||||||
# 查找point1的最近点
|
# 为point1找最近点
|
||||||
dist1, local_idx1 = kdtree.query(point1_target, k=1)
|
search_margin = self.search_radius * 2
|
||||||
|
bbox_min = point1_target - search_margin
|
||||||
|
bbox_max = point1_target + search_margin
|
||||||
|
|
||||||
|
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) == 0:
|
||||||
|
search_margin = self.search_radius * 10
|
||||||
|
bbox_min = point1_target - search_margin
|
||||||
|
bbox_max = point1_target + search_margin
|
||||||
|
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) > 0:
|
||||||
|
distances_to_p1 = np.linalg.norm(local_points - point1_target, axis=1)
|
||||||
|
local_idx1 = np.argmin(distances_to_p1)
|
||||||
|
dist1 = distances_to_p1[local_idx1]
|
||||||
|
point1_found = local_points[local_idx1]
|
||||||
|
else:
|
||||||
|
print(f"警告: {pair_name} - 点1 {point1_target} 附近没有点")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 为point2找最近点
|
||||||
|
search_margin = self.search_radius * 2
|
||||||
|
bbox_min = point2_target - search_margin
|
||||||
|
bbox_max = point2_target + search_margin
|
||||||
|
|
||||||
|
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) == 0:
|
||||||
|
search_margin = self.search_radius * 10
|
||||||
|
bbox_min = point2_target - search_margin
|
||||||
|
bbox_max = point2_target + search_margin
|
||||||
|
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||||
|
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(local_points) > 0:
|
||||||
|
distances_to_p2 = np.linalg.norm(local_points - point2_target, axis=1)
|
||||||
|
local_idx2 = np.argmin(distances_to_p2)
|
||||||
|
dist2 = distances_to_p2[local_idx2]
|
||||||
|
point2_found = local_points[local_idx2]
|
||||||
|
else:
|
||||||
|
print(f"警告: {pair_name} - 点2 {point2_target} 附近没有点")
|
||||||
|
continue
|
||||||
|
|
||||||
if dist1 > self.search_radius:
|
if dist1 > self.search_radius:
|
||||||
print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||||
|
|
||||||
point1_found = local_points[local_idx1]
|
|
||||||
|
|
||||||
# 查找point2的最近点
|
|
||||||
dist2, local_idx2 = kdtree.query(point2_target, k=1)
|
|
||||||
if dist2 > self.search_radius:
|
if dist2 > self.search_radius:
|
||||||
print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||||
|
|
||||||
point2_found = local_points[local_idx2]
|
|
||||||
|
|
||||||
# 计算两点之间的欧氏距离
|
# 计算两点之间的欧氏距离
|
||||||
distance = np.linalg.norm(point2_found - point1_found)
|
distance = np.linalg.norm(point2_found - point1_found)
|
||||||
|
|
||||||
@@ -47,11 +47,7 @@ class PointToPointWithHoles(BaseMeasurement):
|
|||||||
hole_results = [] # 保存完整的拟合结果
|
hole_results = [] # 保存完整的拟合结果
|
||||||
|
|
||||||
for i, box in enumerate(boxes):
|
for i, box in enumerate(boxes):
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
box['min'],
|
|
||||||
box['max']
|
|
||||||
)
|
|
||||||
if len(points) == 0:
|
if len(points) == 0:
|
||||||
return MeasurementResult(
|
return MeasurementResult(
|
||||||
task_name=self.name,
|
task_name=self.name,
|
||||||
@@ -131,11 +127,7 @@ class PointToPointWithHoles(BaseMeasurement):
|
|||||||
|
|
||||||
# 保存每个孔的点云
|
# 保存每个孔的点云
|
||||||
for i, box in enumerate(boxes):
|
for i, box in enumerate(boxes):
|
||||||
points, _ = ROIExtractor.extract_bbox_points(
|
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||||
self.point_cloud,
|
|
||||||
box['min'],
|
|
||||||
box['max']
|
|
||||||
)
|
|
||||||
ply_path = vis_dir / f"{self.name}_{box['name']}.ply"
|
ply_path = vis_dir / f"{self.name}_{box['name']}.ply"
|
||||||
Visualizer.save_roi_pointcloud(points, str(ply_path))
|
Visualizer.save_roi_pointcloud(points, str(ply_path))
|
||||||
|
|
||||||
@@ -25,6 +25,12 @@ class PerformanceLogger:
|
|||||||
# 任务性能记录
|
# 任务性能记录
|
||||||
self.task_records = []
|
self.task_records = []
|
||||||
|
|
||||||
|
# 初始化阶段性能记录
|
||||||
|
self.init_phases = {
|
||||||
|
'pointcloud_loading': {'time': 0, 'points': 0, 'file': ''},
|
||||||
|
'spatial_index': {'time': 0, 'method': '', 'info': {}}
|
||||||
|
}
|
||||||
|
|
||||||
def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]):
|
def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]):
|
||||||
"""
|
"""
|
||||||
记录单个任务的性能数据
|
记录单个任务的性能数据
|
||||||
@@ -41,6 +47,36 @@ class PerformanceLogger:
|
|||||||
}
|
}
|
||||||
self.task_records.append(record)
|
self.task_records.append(record)
|
||||||
|
|
||||||
|
def log_pointcloud_loading(self, time_seconds: float, point_count: int, filename: str):
|
||||||
|
"""
|
||||||
|
记录点云加载性能
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_seconds: 加载耗时(秒)
|
||||||
|
point_count: 点云数量
|
||||||
|
filename: 文件名
|
||||||
|
"""
|
||||||
|
self.init_phases['pointcloud_loading'] = {
|
||||||
|
'time': time_seconds,
|
||||||
|
'points': point_count,
|
||||||
|
'file': filename
|
||||||
|
}
|
||||||
|
|
||||||
|
def log_spatial_index(self, time_seconds: float, method: str, info: Dict):
|
||||||
|
"""
|
||||||
|
记录空间索引构建性能
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_seconds: 构建耗时(秒)
|
||||||
|
method: 索引方法名称
|
||||||
|
info: 索引详细信息
|
||||||
|
"""
|
||||||
|
self.init_phases['spatial_index'] = {
|
||||||
|
'time': time_seconds,
|
||||||
|
'method': method,
|
||||||
|
'info': info
|
||||||
|
}
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""保存性能日志到文件"""
|
"""保存性能日志到文件"""
|
||||||
with open(self.log_file, 'w', encoding='utf-8') as f:
|
with open(self.log_file, 'w', encoding='utf-8') as f:
|
||||||
@@ -48,6 +84,51 @@ class PerformanceLogger:
|
|||||||
f.write("点云测量系统 - 性能分析报告\n")
|
f.write("点云测量系统 - 性能分析报告\n")
|
||||||
f.write("=" * 80 + "\n")
|
f.write("=" * 80 + "\n")
|
||||||
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
# 输出初始化阶段性能
|
||||||
|
f.write("=" * 80 + "\n")
|
||||||
|
f.write("初始化阶段性能\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
# 点云加载
|
||||||
|
pc_loading = self.init_phases['pointcloud_loading']
|
||||||
|
if pc_loading['time'] > 0:
|
||||||
|
f.write("阶段1: 点云加载\n")
|
||||||
|
f.write("-" * 80 + "\n")
|
||||||
|
f.write(f" 文件名: {pc_loading['file']}\n")
|
||||||
|
f.write(f" 点云数量: {pc_loading['points']:,} 个点\n")
|
||||||
|
f.write(f" 加载耗时: {pc_loading['time']:.2f} 秒\n")
|
||||||
|
if pc_loading['points'] > 0:
|
||||||
|
f.write(f" 加载速度: {pc_loading['points']/pc_loading['time']/1000000:.2f} M点/秒\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# 空间索引构建
|
||||||
|
spatial_idx = self.init_phases['spatial_index']
|
||||||
|
if spatial_idx['time'] > 0:
|
||||||
|
f.write("阶段2: 空间索引构建\n")
|
||||||
|
f.write("-" * 80 + "\n")
|
||||||
|
f.write(f" 方法: {spatial_idx['method']}\n")
|
||||||
|
f.write(f" 构建耗时: {spatial_idx['time']:.2f} 秒\n")
|
||||||
|
info = spatial_idx['info']
|
||||||
|
if 'grid_count' in info:
|
||||||
|
f.write(f" 网格数量: {info['grid_count']:,}\n")
|
||||||
|
f.write(f" 平均点/格: {info['avg_points_per_grid']:.1f}\n")
|
||||||
|
elif 'node_count' in info:
|
||||||
|
f.write(f" 节点数量: {info['node_count']:,}\n")
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
# 初始化总耗时
|
||||||
|
init_total = pc_loading['time'] + spatial_idx['time']
|
||||||
|
if init_total > 0:
|
||||||
|
f.write(f"初始化阶段总耗时: {init_total:.2f} 秒\n\n")
|
||||||
|
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
|
# 任务执行性能
|
||||||
|
f.write("=" * 80 + "\n")
|
||||||
|
f.write("任务执行性能\n")
|
||||||
|
f.write("=" * 80 + "\n")
|
||||||
f.write(f"总任务数: {len(self.task_records)}\n")
|
f.write(f"总任务数: {len(self.task_records)}\n")
|
||||||
f.write("=" * 80 + "\n\n")
|
f.write("=" * 80 + "\n\n")
|
||||||
|
|
||||||
@@ -106,6 +187,19 @@ class PerformanceLogger:
|
|||||||
count = step_counts[step_name]
|
count = step_counts[step_name]
|
||||||
f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n")
|
f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n")
|
||||||
|
|
||||||
|
f.write("\n" + "=" * 80 + "\n")
|
||||||
|
|
||||||
|
# 总体性能摘要
|
||||||
|
grand_total = init_total + total_all_tasks
|
||||||
|
f.write("\n" + "=" * 80 + "\n")
|
||||||
|
f.write("总体性能摘要\n")
|
||||||
|
f.write("=" * 80 + "\n\n")
|
||||||
|
f.write(f" 初始化阶段: {init_total:.2f} 秒 ({init_total/grand_total*100:.1f}%)\n")
|
||||||
|
f.write(f" - 点云加载: {pc_loading['time']:.2f} 秒\n")
|
||||||
|
f.write(f" - 空间索引: {spatial_idx['time']:.2f} 秒\n")
|
||||||
|
f.write(f" 任务执行阶段: {total_all_tasks:.2f} 秒 ({total_all_tasks/grand_total*100:.1f}%)\n")
|
||||||
|
f.write(f" 总耗时: {grand_total:.2f} 秒\n")
|
||||||
|
|
||||||
f.write("\n" + "=" * 80 + "\n")
|
f.write("\n" + "=" * 80 + "\n")
|
||||||
f.write(f"日志已保存到: {self.log_file}\n")
|
f.write(f"日志已保存到: {self.log_file}\n")
|
||||||
f.write("=" * 80 + "\n")
|
f.write("=" * 80 + "\n")
|
||||||
@@ -66,38 +66,46 @@ class PointCloudLoader:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
|
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
|
||||||
"""读取二进制PLY数据"""
|
"""读取二进制PLY数据(优化版:批量读取)"""
|
||||||
# 构建struct格式字符串
|
# 构建numpy dtype
|
||||||
type_map = {
|
type_map = {
|
||||||
'float': 'f',
|
'float': np.float32,
|
||||||
'double': 'd',
|
'double': np.float64,
|
||||||
'uchar': 'B',
|
'uchar': np.uint8,
|
||||||
'uint': 'I',
|
'uint': np.uint32,
|
||||||
'int': 'i'
|
'int': np.int32
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt = '<' # 小端序
|
# 构建结构化数组的dtype
|
||||||
|
dtype_list = []
|
||||||
for prop in properties:
|
for prop in properties:
|
||||||
fmt += type_map.get(prop['type'], 'f')
|
dtype_list.append((prop['name'], type_map.get(prop['type'], np.float32)))
|
||||||
|
|
||||||
struct_size = struct.calcsize(fmt)
|
dtype = np.dtype(dtype_list)
|
||||||
points = []
|
|
||||||
|
|
||||||
for _ in range(vertex_count):
|
# 一次性读取所有数据
|
||||||
data = struct.unpack(fmt, f.read(struct_size))
|
raw_data = np.fromfile(f, dtype=dtype, count=vertex_count)
|
||||||
# 只取前3个值作为xyz坐标
|
|
||||||
points.append(data[:3])
|
|
||||||
|
|
||||||
return np.array(points, dtype=np.float64)
|
# 提取xyz坐标(假设前3个属性是x, y, z)
|
||||||
|
# 如果属性名不是x,y,z,可以通过索引访问
|
||||||
|
if 'x' in raw_data.dtype.names and 'y' in raw_data.dtype.names and 'z' in raw_data.dtype.names:
|
||||||
|
points = np.column_stack([raw_data['x'], raw_data['y'], raw_data['z']])
|
||||||
|
else:
|
||||||
|
# 假设前3个字段是xyz
|
||||||
|
points = np.column_stack([raw_data[raw_data.dtype.names[0]],
|
||||||
|
raw_data[raw_data.dtype.names[1]],
|
||||||
|
raw_data[raw_data.dtype.names[2]]])
|
||||||
|
|
||||||
|
return points.astype(np.float64)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _read_ascii_ply(f, vertex_count: int) -> np.ndarray:
|
def _read_ascii_ply(f, vertex_count: int) -> np.ndarray:
|
||||||
"""读取ASCII PLY数据"""
|
"""读取ASCII PLY数据(优化版:批量读取)"""
|
||||||
points = []
|
# 读取剩余所有行
|
||||||
for _ in range(vertex_count):
|
remaining = f.read().decode('ascii')
|
||||||
line = f.readline().decode('ascii').strip()
|
|
||||||
values = line.split()
|
|
||||||
# 只取前3个值作为xyz坐标
|
|
||||||
points.append([float(values[0]), float(values[1]), float(values[2])])
|
|
||||||
|
|
||||||
return np.array(points, dtype=np.float64)
|
# 使用numpy的快速文本解析
|
||||||
|
from io import StringIO
|
||||||
|
points = np.loadtxt(StringIO(remaining), dtype=np.float64, max_rows=vertex_count, usecols=(0, 1, 2))
|
||||||
|
|
||||||
|
return points
|
||||||
@@ -10,7 +10,8 @@ class ROIExtractor:
|
|||||||
def extract_bbox_points(
|
def extract_bbox_points(
|
||||||
points: np.ndarray,
|
points: np.ndarray,
|
||||||
bbox_min: list,
|
bbox_min: list,
|
||||||
bbox_max: list
|
bbox_max: list,
|
||||||
|
spatial_index=None
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""
|
"""
|
||||||
提取包围盒内的点
|
提取包围盒内的点
|
||||||
@@ -19,6 +20,7 @@ class ROIExtractor:
|
|||||||
points: 点云数组 (N, 3)
|
points: 点云数组 (N, 3)
|
||||||
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
|
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
|
||||||
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
|
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
|
||||||
|
spatial_index: 空间索引对象(可选)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
roi_points: 提取的点 (M, 3)
|
roi_points: 提取的点 (M, 3)
|
||||||
@@ -27,11 +29,15 @@ class ROIExtractor:
|
|||||||
bbox_min = np.array(bbox_min)
|
bbox_min = np.array(bbox_min)
|
||||||
bbox_max = np.array(bbox_max)
|
bbox_max = np.array(bbox_max)
|
||||||
|
|
||||||
# 找到在包围盒内的点
|
if spatial_index is not None:
|
||||||
|
# 使用空间索引快速查询
|
||||||
|
indices = spatial_index.query_bbox(bbox_min, bbox_max)
|
||||||
|
else:
|
||||||
|
# 回退到线性扫描
|
||||||
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
||||||
indices = np.where(mask)[0]
|
indices = np.where(mask)[0]
|
||||||
roi_points = points[indices]
|
|
||||||
|
|
||||||
|
roi_points = points[indices]
|
||||||
return roi_points, indices
|
return roi_points, indices
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""空间索引模块 - 提供多种ROI提取方法"""
|
||||||
|
|
||||||
|
from .base import SpatialIndex
|
||||||
|
from .linear_scan import LinearScanIndex
|
||||||
|
from .grid_index import GridIndex
|
||||||
|
from .grid_index_optimized import GridIndexOptimized
|
||||||
|
from .octree_index import OctreeIndex
|
||||||
|
from .kdtree_index import KDTreeIndex
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'SpatialIndex',
|
||||||
|
'LinearScanIndex',
|
||||||
|
'GridIndex',
|
||||||
|
'GridIndexOptimized',
|
||||||
|
'OctreeIndex',
|
||||||
|
'KDTreeIndex',
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""空间索引基类"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class SpatialIndex(ABC):
|
||||||
|
"""空间索引抽象基类"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray):
|
||||||
|
"""
|
||||||
|
初始化索引
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
"""
|
||||||
|
self.points = points
|
||||||
|
self.build_time = 0.0 # 构建时间(秒)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
查询包围盒内的点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bbox_min: [x_min, y_min, z_min]
|
||||||
|
bbox_max: [x_max, y_max, z_max]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包围盒内的点的索引数组
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
"""返回索引信息(用于日志)"""
|
||||||
|
return {
|
||||||
|
'type': self.__class__.__name__,
|
||||||
|
'point_count': len(self.points),
|
||||||
|
'build_time': self.build_time
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Grid/Voxel 网格索引"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from .base import SpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
class GridIndex(SpatialIndex):
|
||||||
|
"""Grid/Voxel 空间索引 - O(N)构建, O(1)查询"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
|
||||||
|
"""
|
||||||
|
构建网格索引
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
grid_size: 网格大小(毫米)
|
||||||
|
"""
|
||||||
|
super().__init__(points)
|
||||||
|
self.grid_size = grid_size
|
||||||
|
|
||||||
|
print(f" 构建Grid索引 (grid_size={grid_size}mm)...")
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 计算点云边界
|
||||||
|
self.mins = points.min(axis=0)
|
||||||
|
self.maxs = points.max(axis=0)
|
||||||
|
|
||||||
|
# 将每个点映射到网格坐标
|
||||||
|
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
|
||||||
|
|
||||||
|
# 构建索引:grid_key → point_indices
|
||||||
|
self.grid = defaultdict(list)
|
||||||
|
for idx, (gx, gy, gz) in enumerate(grid_coords):
|
||||||
|
grid_key = (gx, gy, gz)
|
||||||
|
self.grid[grid_key].append(idx)
|
||||||
|
|
||||||
|
# 转换为numpy数组以加速查询
|
||||||
|
for key in self.grid:
|
||||||
|
self.grid[key] = np.array(self.grid[key], dtype=np.int32)
|
||||||
|
|
||||||
|
self.build_time = time.time() - start
|
||||||
|
print(f" 网格数量: {len(self.grid):,}")
|
||||||
|
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
|
||||||
|
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||||
|
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Grid索引查询 - O(1 + K)复杂度
|
||||||
|
|
||||||
|
直接定位到相关网格,只检查这些网格中的点
|
||||||
|
"""
|
||||||
|
# 将bbox转换为网格坐标范围
|
||||||
|
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
|
||||||
|
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
|
||||||
|
|
||||||
|
# 收集相关网格中的所有点索引
|
||||||
|
candidate_indices = []
|
||||||
|
for gx in range(grid_min[0], grid_max[0] + 1):
|
||||||
|
for gy in range(grid_min[1], grid_max[1] + 1):
|
||||||
|
for gz in range(grid_min[2], grid_max[2] + 1):
|
||||||
|
grid_key = (gx, gy, gz)
|
||||||
|
if grid_key in self.grid:
|
||||||
|
candidate_indices.append(self.grid[grid_key])
|
||||||
|
|
||||||
|
if len(candidate_indices) == 0:
|
||||||
|
return np.array([], dtype=np.int32)
|
||||||
|
|
||||||
|
# 合并所有候选点
|
||||||
|
candidate_indices = np.concatenate(candidate_indices)
|
||||||
|
candidate_points = self.points[candidate_indices]
|
||||||
|
|
||||||
|
# 精确过滤(网格是粗略的,需要精确检查)
|
||||||
|
mask = (
|
||||||
|
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||||
|
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||||
|
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||||
|
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||||
|
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||||
|
(candidate_points[:, 2] <= bbox_max[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidate_indices[mask]
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info['method'] = 'Grid网格索引'
|
||||||
|
info['complexity'] = 'O(1 + K)'
|
||||||
|
info['grid_size'] = self.grid_size
|
||||||
|
info['grid_count'] = len(self.grid)
|
||||||
|
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
|
||||||
|
return info
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Grid/Voxel 网格索引 - 优化版(numpy向量化)"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from .base import SpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
class GridIndexOptimized(SpatialIndex):
|
||||||
|
"""Grid/Voxel 空间索引 - 优化版,使用numpy向量化避免Python循环"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
|
||||||
|
"""
|
||||||
|
构建网格索引(优化版)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
grid_size: 网格大小(毫米)
|
||||||
|
"""
|
||||||
|
super().__init__(points)
|
||||||
|
self.grid_size = grid_size
|
||||||
|
|
||||||
|
print(f" 构建Grid索引(优化版 - numpy向量化)(grid_size={grid_size}mm)...")
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 计算点云边界
|
||||||
|
self.mins = points.min(axis=0)
|
||||||
|
self.maxs = points.max(axis=0)
|
||||||
|
|
||||||
|
# 将每个点映射到网格坐标
|
||||||
|
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
|
||||||
|
|
||||||
|
# ===== 关键优化:使用numpy向量化替代Python循环 =====
|
||||||
|
|
||||||
|
# 1. 将3D网格坐标编码为1D哈希值(避免tuple开销)
|
||||||
|
# 使用大素数来避免冲突
|
||||||
|
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
|
||||||
|
grid_hash = (grid_coords * hash_multiplier).sum(axis=1)
|
||||||
|
|
||||||
|
# 2. 使用argsort排序(numpy C实现,非常快)
|
||||||
|
sort_idx = np.argsort(grid_hash)
|
||||||
|
sorted_hash = grid_hash[sort_idx]
|
||||||
|
|
||||||
|
# 3. 找到每个唯一网格的边界
|
||||||
|
# 只需要遍历唯一网格数(约500万),而不是10亿点!
|
||||||
|
unique_mask = np.concatenate(([True], sorted_hash[1:] != sorted_hash[:-1]))
|
||||||
|
unique_indices = np.where(unique_mask)[0]
|
||||||
|
|
||||||
|
# 4. 构建索引字典
|
||||||
|
self.grid = {}
|
||||||
|
self.grid_hash_to_coords = {} # 保存hash到3D坐标的映射
|
||||||
|
|
||||||
|
n_unique = len(unique_indices)
|
||||||
|
for i in range(n_unique):
|
||||||
|
start_idx = unique_indices[i]
|
||||||
|
end_idx = unique_indices[i + 1] if i + 1 < n_unique else len(sorted_hash)
|
||||||
|
|
||||||
|
hash_val = sorted_hash[start_idx]
|
||||||
|
indices = sort_idx[start_idx:end_idx]
|
||||||
|
|
||||||
|
# 使用hash作为key(比tuple快)
|
||||||
|
self.grid[hash_val] = indices
|
||||||
|
|
||||||
|
# 保存hash到原始3D坐标的映射(用于查询)
|
||||||
|
self.grid_hash_to_coords[hash_val] = tuple(grid_coords[indices[0]])
|
||||||
|
|
||||||
|
self.build_time = time.time() - start
|
||||||
|
print(f" 网格数量: {len(self.grid):,}")
|
||||||
|
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
|
||||||
|
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||||
|
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Grid索引查询 - O(1 + K)复杂度
|
||||||
|
"""
|
||||||
|
# 将bbox转换为网格坐标范围
|
||||||
|
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
|
||||||
|
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
|
||||||
|
|
||||||
|
# 收集相关网格中的所有点索引
|
||||||
|
candidate_indices = []
|
||||||
|
|
||||||
|
# 遍历bbox覆盖的网格(通常很少,比如10×10×10=1000个)
|
||||||
|
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
|
||||||
|
for gx in range(grid_min[0], grid_max[0] + 1):
|
||||||
|
for gy in range(grid_min[1], grid_max[1] + 1):
|
||||||
|
for gz in range(grid_min[2], grid_max[2] + 1):
|
||||||
|
grid_coord = np.array([gx, gy, gz], dtype=np.int32)
|
||||||
|
grid_hash_val = (grid_coord * hash_multiplier).sum()
|
||||||
|
|
||||||
|
if grid_hash_val in self.grid:
|
||||||
|
candidate_indices.append(self.grid[grid_hash_val])
|
||||||
|
|
||||||
|
if len(candidate_indices) == 0:
|
||||||
|
return np.array([], dtype=np.int32)
|
||||||
|
|
||||||
|
# 合并所有候选点
|
||||||
|
candidate_indices = np.concatenate(candidate_indices)
|
||||||
|
candidate_points = self.points[candidate_indices]
|
||||||
|
|
||||||
|
# 精确过滤
|
||||||
|
mask = (
|
||||||
|
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||||
|
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||||
|
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||||
|
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||||
|
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||||
|
(candidate_points[:, 2] <= bbox_max[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidate_indices[mask]
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info['method'] = 'Grid网格索引(优化版)'
|
||||||
|
info['complexity'] = 'O(1 + K)'
|
||||||
|
info['grid_size'] = self.grid_size
|
||||||
|
info['grid_count'] = len(self.grid)
|
||||||
|
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
|
||||||
|
info['optimization'] = 'numpy向量化'
|
||||||
|
return info
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""KDTree 索引(使用scipy)"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
from .base import SpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
class KDTreeIndex(SpatialIndex):
|
||||||
|
"""KDTree 空间索引 - O(N log N)构建, O(log N + K)查询"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray):
|
||||||
|
"""
|
||||||
|
构建KD树索引
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
"""
|
||||||
|
super().__init__(points)
|
||||||
|
|
||||||
|
print(f" 构建KDTree索引...")
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 使用scipy的cKDTree(C实现,比纯Python快)
|
||||||
|
self.tree = cKDTree(points)
|
||||||
|
|
||||||
|
self.build_time = time.time() - start
|
||||||
|
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||||
|
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
KDTree查询 - O(log N + K)复杂度
|
||||||
|
|
||||||
|
使用球形查询近似bbox,然后精确过滤
|
||||||
|
"""
|
||||||
|
# KDTree没有直接的bbox查询,使用球形查询近似
|
||||||
|
center = (bbox_min + bbox_max) / 2
|
||||||
|
# 使用外接球半径,确保包含整个bbox
|
||||||
|
radius = np.linalg.norm(bbox_max - bbox_min) / 2
|
||||||
|
|
||||||
|
# 球形查询
|
||||||
|
candidate_indices = self.tree.query_ball_point(center, radius)
|
||||||
|
|
||||||
|
if len(candidate_indices) == 0:
|
||||||
|
return np.array([], dtype=np.int32)
|
||||||
|
|
||||||
|
candidate_indices = np.array(candidate_indices, dtype=np.int32)
|
||||||
|
candidate_points = self.points[candidate_indices]
|
||||||
|
|
||||||
|
# 精确过滤(因为球形查询是近似的)
|
||||||
|
mask = (
|
||||||
|
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||||
|
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||||
|
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||||
|
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||||
|
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||||
|
(candidate_points[:, 2] <= bbox_max[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidate_indices[mask]
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info['method'] = 'KDTree索引(scipy)'
|
||||||
|
info['complexity'] = 'O(log N + K)'
|
||||||
|
info['note'] = '构建慢,适合小数据集'
|
||||||
|
return info
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""线性扫描(无索引)- 基准方法"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from .base import SpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
class LinearScanIndex(SpatialIndex):
|
||||||
|
"""线性扫描索引(实际上就是无索引)"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray):
|
||||||
|
super().__init__(points)
|
||||||
|
# 无需构建索引
|
||||||
|
self.build_time = 0.0
|
||||||
|
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
线性扫描查询 - O(N)复杂度
|
||||||
|
|
||||||
|
遍历所有点,检查是否在bbox内
|
||||||
|
"""
|
||||||
|
mask = (
|
||||||
|
(self.points[:, 0] >= bbox_min[0]) & (self.points[:, 0] <= bbox_max[0]) &
|
||||||
|
(self.points[:, 1] >= bbox_min[1]) & (self.points[:, 1] <= bbox_max[1]) &
|
||||||
|
(self.points[:, 2] >= bbox_min[2]) & (self.points[:, 2] <= bbox_max[2])
|
||||||
|
)
|
||||||
|
return np.where(mask)[0]
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info['method'] = '线性扫描(无索引)'
|
||||||
|
info['complexity'] = 'O(N)'
|
||||||
|
return info
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Octree 八叉树索引"""
|
||||||
|
import numpy as np
|
||||||
|
import time
|
||||||
|
from .base import SpatialIndex
|
||||||
|
|
||||||
|
|
||||||
|
class OctreeNode:
|
||||||
|
"""八叉树节点"""
|
||||||
|
|
||||||
|
def __init__(self, center: np.ndarray, size: float):
|
||||||
|
self.center = center
|
||||||
|
self.size = size
|
||||||
|
self.children = [None] * 8 # 8个子节点
|
||||||
|
self.point_indices = [] # 叶节点存储点索引
|
||||||
|
self.is_leaf = True
|
||||||
|
|
||||||
|
|
||||||
|
class OctreeIndex(SpatialIndex):
|
||||||
|
"""Octree 空间索引 - O(N)构建, O(log N + K)查询"""
|
||||||
|
|
||||||
|
def __init__(self, points: np.ndarray, max_depth: int = 8, max_points_per_node: int = 1000):
|
||||||
|
"""
|
||||||
|
构建八叉树索引
|
||||||
|
|
||||||
|
Args:
|
||||||
|
points: 点云数组 (N, 3)
|
||||||
|
max_depth: 最大树深度
|
||||||
|
max_points_per_node: 每个叶节点最多存储的点数
|
||||||
|
"""
|
||||||
|
super().__init__(points)
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.max_points_per_node = max_points_per_node
|
||||||
|
|
||||||
|
print(f" 构建Octree索引 (max_depth={max_depth}, max_points={max_points_per_node})...")
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# 计算点云边界
|
||||||
|
mins = points.min(axis=0)
|
||||||
|
maxs = points.max(axis=0)
|
||||||
|
center = (mins + maxs) / 2
|
||||||
|
size = np.max(maxs - mins) / 2
|
||||||
|
|
||||||
|
# 构建树
|
||||||
|
all_indices = np.arange(len(points), dtype=np.int32)
|
||||||
|
self.root = self._build_tree(center, size, all_indices, depth=0)
|
||||||
|
|
||||||
|
self.build_time = time.time() - start
|
||||||
|
self.node_count = self._count_nodes(self.root)
|
||||||
|
print(f" 节点数量: {self.node_count:,}")
|
||||||
|
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||||
|
|
||||||
|
def _build_tree(self, center: np.ndarray, size: float, indices: np.ndarray, depth: int) -> OctreeNode:
|
||||||
|
"""递归构建八叉树"""
|
||||||
|
node = OctreeNode(center, size)
|
||||||
|
|
||||||
|
# 终止条件:达到最大深度或点数足够少
|
||||||
|
if depth >= self.max_depth or len(indices) <= self.max_points_per_node:
|
||||||
|
node.point_indices = indices
|
||||||
|
node.is_leaf = True
|
||||||
|
return node
|
||||||
|
|
||||||
|
# 分割成8个子空间
|
||||||
|
node.is_leaf = False
|
||||||
|
half_size = size / 2
|
||||||
|
points_subset = self.points[indices]
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
# 计算子节点中心
|
||||||
|
child_center = center.copy()
|
||||||
|
child_center[0] += half_size if (i & 1) else -half_size
|
||||||
|
child_center[1] += half_size if (i & 2) else -half_size
|
||||||
|
child_center[2] += half_size if (i & 4) else -half_size
|
||||||
|
|
||||||
|
# 找到属于这个子空间的点
|
||||||
|
mask = (
|
||||||
|
((points_subset[:, 0] >= child_center[0] - half_size) &
|
||||||
|
(points_subset[:, 0] < child_center[0] + half_size)) &
|
||||||
|
((points_subset[:, 1] >= child_center[1] - half_size) &
|
||||||
|
(points_subset[:, 1] < child_center[1] + half_size)) &
|
||||||
|
((points_subset[:, 2] >= child_center[2] - half_size) &
|
||||||
|
(points_subset[:, 2] < child_center[2] + half_size))
|
||||||
|
)
|
||||||
|
|
||||||
|
child_indices = indices[mask]
|
||||||
|
|
||||||
|
if len(child_indices) > 0:
|
||||||
|
node.children[i] = self._build_tree(child_center, half_size, child_indices, depth + 1)
|
||||||
|
|
||||||
|
return node
|
||||||
|
|
||||||
|
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Octree查询 - O(log N + K)复杂度
|
||||||
|
|
||||||
|
递归查询与bbox相交的节点
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
self._query_recursive(self.root, bbox_min, bbox_max, result)
|
||||||
|
|
||||||
|
if len(result) == 0:
|
||||||
|
return np.array([], dtype=np.int32)
|
||||||
|
|
||||||
|
# 合并所有候选点
|
||||||
|
candidate_indices = np.concatenate(result)
|
||||||
|
candidate_points = self.points[candidate_indices]
|
||||||
|
|
||||||
|
# 精确过滤
|
||||||
|
mask = (
|
||||||
|
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||||
|
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||||
|
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||||
|
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||||
|
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||||
|
(candidate_points[:, 2] <= bbox_max[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidate_indices[mask]
|
||||||
|
|
||||||
|
def _query_recursive(self, node: OctreeNode, bbox_min: np.ndarray, bbox_max: np.ndarray, result: list):
|
||||||
|
"""递归查询节点"""
|
||||||
|
if node is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查节点边界框是否与查询bbox相交
|
||||||
|
node_min = node.center - node.size
|
||||||
|
node_max = node.center + node.size
|
||||||
|
|
||||||
|
if not self._bbox_intersects(bbox_min, bbox_max, node_min, node_max):
|
||||||
|
return
|
||||||
|
|
||||||
|
if node.is_leaf:
|
||||||
|
# 叶节点:添加所有点索引
|
||||||
|
if len(node.point_indices) > 0:
|
||||||
|
result.append(node.point_indices)
|
||||||
|
else:
|
||||||
|
# 内部节点:递归查询子节点
|
||||||
|
for child in node.children:
|
||||||
|
if child is not None:
|
||||||
|
self._query_recursive(child, bbox_min, bbox_max, result)
|
||||||
|
|
||||||
|
def _bbox_intersects(self, bbox1_min: np.ndarray, bbox1_max: np.ndarray,
|
||||||
|
bbox2_min: np.ndarray, bbox2_max: np.ndarray) -> bool:
|
||||||
|
"""检查两个bbox是否相交"""
|
||||||
|
return not (
|
||||||
|
bbox1_max[0] < bbox2_min[0] or bbox1_min[0] > bbox2_max[0] or
|
||||||
|
bbox1_max[1] < bbox2_min[1] or bbox1_min[1] > bbox2_max[1] or
|
||||||
|
bbox1_max[2] < bbox2_min[2] or bbox1_min[2] > bbox2_max[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _count_nodes(self, node: OctreeNode) -> int:
|
||||||
|
"""统计节点数量"""
|
||||||
|
if node is None:
|
||||||
|
return 0
|
||||||
|
count = 1
|
||||||
|
if not node.is_leaf:
|
||||||
|
for child in node.children:
|
||||||
|
count += self._count_nodes(child)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_info(self) -> dict:
|
||||||
|
info = super().get_info()
|
||||||
|
info['method'] = 'Octree八叉树索引'
|
||||||
|
info['complexity'] = 'O(log N + K)'
|
||||||
|
info['max_depth'] = self.max_depth
|
||||||
|
info['node_count'] = self.node_count
|
||||||
|
return info
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""任务管理器 - 负责加载配置、调度任务、汇总结果"""
|
"""任务管理器 - 负责加载配置、调度任务、汇总结果"""
|
||||||
import yaml
|
import yaml
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -13,9 +14,12 @@ from measurements.point_to_point import PointToPointDistance
|
|||||||
from measurements.plane_with_holes import PlaneToPlaneWithHoles
|
from measurements.plane_with_holes import PlaneToPlaneWithHoles
|
||||||
from measurements.point_to_plane_with_holes import PointToPlaneWithHoles
|
from measurements.point_to_plane_with_holes import PointToPlaneWithHoles
|
||||||
from measurements.point_to_point_with_holes import PointToPointWithHoles
|
from measurements.point_to_point_with_holes import PointToPointWithHoles
|
||||||
|
from measurements.line_to_line import LineToLineDistance
|
||||||
|
from measurements.line_to_plane import LineToPlaneDistance
|
||||||
from excel_exporter import ExcelExporter
|
from excel_exporter import ExcelExporter
|
||||||
from performance_logger import PerformanceLogger
|
from performance_logger import PerformanceLogger
|
||||||
from measurement_logger import MeasurementLogger
|
from measurement_logger import MeasurementLogger
|
||||||
|
from spatial_index import LinearScanIndex, GridIndex, GridIndexOptimized, OctreeIndex, KDTreeIndex
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
@@ -25,6 +29,7 @@ class TaskManager:
|
|||||||
"""初始化任务管理器"""
|
"""初始化任务管理器"""
|
||||||
self.config = None
|
self.config = None
|
||||||
self.point_cloud = None
|
self.point_cloud = None
|
||||||
|
self.spatial_index = None # 空间索引
|
||||||
self.tasks = []
|
self.tasks = []
|
||||||
self.results = []
|
self.results = []
|
||||||
self.pointcloud_path_override = None
|
self.pointcloud_path_override = None
|
||||||
@@ -53,11 +58,35 @@ class TaskManager:
|
|||||||
# 处理相对路径
|
# 处理相对路径
|
||||||
pointcloud_path = self._resolve_path(pointcloud_path, config_path)
|
pointcloud_path = self._resolve_path(pointcloud_path, config_path)
|
||||||
|
|
||||||
print(f"\n正在加载点云文件: {pointcloud_path}")
|
# 加载点云
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("阶段1: 加载点云")
|
||||||
|
print("="*70)
|
||||||
|
print(f" 文件: {Path(pointcloud_path).name}")
|
||||||
|
|
||||||
|
load_start = time.time()
|
||||||
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
|
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
|
||||||
print(f"点云加载完成: {len(self.point_cloud)} 个点")
|
load_time = time.time() - load_start
|
||||||
|
|
||||||
|
print(f" 点云数量: {len(self.point_cloud):,} 个点")
|
||||||
|
print(f" 加载耗时: {load_time:.2f} 秒")
|
||||||
|
|
||||||
|
# 保存点云加载信息供性能日志使用
|
||||||
|
self.pointcloud_load_time = load_time
|
||||||
|
self.pointcloud_filename = Path(pointcloud_path).name
|
||||||
|
|
||||||
|
# 构建空间索引
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("阶段2: 构建空间索引")
|
||||||
|
print("="*70)
|
||||||
|
index_start = time.time()
|
||||||
|
self._build_spatial_index()
|
||||||
|
self.index_build_time = time.time() - index_start
|
||||||
|
|
||||||
# 解析任务
|
# 解析任务
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("阶段3: 解析测量任务")
|
||||||
|
print("="*70)
|
||||||
tasks_config = self.config.get('tasks', [])
|
tasks_config = self.config.get('tasks', [])
|
||||||
if not tasks_config:
|
if not tasks_config:
|
||||||
print("警告: 配置文件中没有定义任务")
|
print("警告: 配置文件中没有定义任务")
|
||||||
@@ -139,37 +168,57 @@ class TaskManager:
|
|||||||
return PlaneToPlaneWithHoles(
|
return PlaneToPlaneWithHoles(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
elif 'point_to_plane' in task_type:
|
elif 'point_to_plane' in task_type:
|
||||||
return PointToPlaneWithHoles(
|
return PointToPlaneWithHoles(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
elif 'hole_center' in task_type and 'point_to_point' in task_type:
|
elif 'hole_center' in task_type and 'point_to_point' in task_type:
|
||||||
return PointToPointWithHoles(
|
return PointToPointWithHoles(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
elif task_type == 'plane_to_plane':
|
elif task_type == 'plane_to_plane':
|
||||||
return PlaneToPlaneDistance(
|
return PlaneToPlaneDistance(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
elif task_type == 'point_to_plane':
|
elif task_type == 'point_to_plane':
|
||||||
return PointToPlaneDistance(
|
return PointToPlaneDistance(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
elif task_type == 'point_to_point':
|
elif task_type == 'point_to_point':
|
||||||
return PointToPointDistance(
|
return PointToPointDistance(
|
||||||
name=task_name,
|
name=task_name,
|
||||||
point_cloud=self.point_cloud,
|
point_cloud=self.point_cloud,
|
||||||
config=task_config
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
|
)
|
||||||
|
elif task_type == 'line_to_line':
|
||||||
|
return LineToLineDistance(
|
||||||
|
name=task_name,
|
||||||
|
point_cloud=self.point_cloud,
|
||||||
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
|
)
|
||||||
|
elif task_type == 'line_to_plane':
|
||||||
|
return LineToPlaneDistance(
|
||||||
|
name=task_name,
|
||||||
|
point_cloud=self.point_cloud,
|
||||||
|
config=task_config,
|
||||||
|
spatial_index=self.spatial_index
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(f"警告: 未知的任务类型: {task_type}")
|
print(f"警告: 未知的任务类型: {task_type}")
|
||||||
@@ -194,6 +243,22 @@ class TaskManager:
|
|||||||
|
|
||||||
perf_logger = PerformanceLogger(str(output_dir))
|
perf_logger = PerformanceLogger(str(output_dir))
|
||||||
|
|
||||||
|
# 记录初始化阶段性能
|
||||||
|
if hasattr(self, 'pointcloud_load_time'):
|
||||||
|
perf_logger.log_pointcloud_loading(
|
||||||
|
self.pointcloud_load_time,
|
||||||
|
len(self.point_cloud),
|
||||||
|
self.pointcloud_filename
|
||||||
|
)
|
||||||
|
if hasattr(self, 'index_build_time'):
|
||||||
|
index_info = self.spatial_index.get_info() if self.spatial_index else {}
|
||||||
|
index_method = self.config.get('roi_extraction', {}).get('method', 'unknown')
|
||||||
|
perf_logger.log_spatial_index(
|
||||||
|
self.index_build_time,
|
||||||
|
index_method,
|
||||||
|
index_info
|
||||||
|
)
|
||||||
|
|
||||||
# 创建详细测量日志记录器
|
# 创建详细测量日志记录器
|
||||||
detail_logger = MeasurementLogger(str(output_dir))
|
detail_logger = MeasurementLogger(str(output_dir))
|
||||||
|
|
||||||
@@ -402,3 +467,44 @@ class TaskManager:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n导出Excel失败: {e}")
|
print(f"\n导出Excel失败: {e}")
|
||||||
|
|
||||||
|
def _build_spatial_index(self):
|
||||||
|
"""根据配置构建空间索引"""
|
||||||
|
index_config = self.config.get('roi_extraction', {})
|
||||||
|
index_method = index_config.get('method', 'linear').lower()
|
||||||
|
|
||||||
|
if index_method == 'linear':
|
||||||
|
print(" 方法: 线性扫描(无索引)")
|
||||||
|
self.spatial_index = LinearScanIndex(self.point_cloud)
|
||||||
|
|
||||||
|
elif index_method == 'grid':
|
||||||
|
grid_size = index_config.get('grid_size', 100.0)
|
||||||
|
use_optimized = index_config.get('use_optimized', True) # 默认使用优化版
|
||||||
|
|
||||||
|
if use_optimized:
|
||||||
|
print(f" 方法: Grid网格索引(优化版 - numpy向量化)")
|
||||||
|
self.spatial_index = GridIndexOptimized(self.point_cloud, grid_size=grid_size)
|
||||||
|
else:
|
||||||
|
print(f" 方法: Grid网格索引(原始版 - Python循环)")
|
||||||
|
self.spatial_index = GridIndex(self.point_cloud, grid_size=grid_size)
|
||||||
|
|
||||||
|
elif index_method == 'octree':
|
||||||
|
max_depth = index_config.get('max_depth', 8)
|
||||||
|
max_points = index_config.get('max_points_per_node', 1000)
|
||||||
|
print(f" 方法: Octree八叉树索引")
|
||||||
|
self.spatial_index = OctreeIndex(self.point_cloud, max_depth=max_depth, max_points_per_node=max_points)
|
||||||
|
|
||||||
|
elif index_method == 'kdtree':
|
||||||
|
print(f" 方法: KDTree索引")
|
||||||
|
self.spatial_index = KDTreeIndex(self.point_cloud)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f" 警告: 未知的索引方法 '{index_method}',使用线性扫描")
|
||||||
|
self.spatial_index = LinearScanIndex(self.point_cloud)
|
||||||
|
|
||||||
|
# 显示索引信息
|
||||||
|
info = self.spatial_index.get_info()
|
||||||
|
if 'grid_count' in info:
|
||||||
|
print(f" 详细信息: {info['grid_count']:,} 个网格, 平均 {info['avg_points_per_grid']:.1f} 点/格")
|
||||||
|
elif 'node_count' in info:
|
||||||
|
print(f" 详细信息: {info['node_count']:,} 个节点")
|
||||||
@@ -39,8 +39,8 @@ registration:
|
|||||||
random_seed:
|
random_seed:
|
||||||
|
|
||||||
evaluation:
|
evaluation:
|
||||||
chunk_size: 2048
|
chunk_size: 8192
|
||||||
triangle_chunk_size: 4096
|
triangle_chunk_size: 512
|
||||||
cad_mesh_deflection: 0.1
|
cad_mesh_deflection: 0.1
|
||||||
|
|
||||||
curvature:
|
curvature:
|
||||||
|
|||||||
@@ -74,15 +74,23 @@ class Evaluator:
|
|||||||
) -> tuple[np.ndarray, DistanceStats]:
|
) -> tuple[np.ndarray, DistanceStats]:
|
||||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
dtype = torch.float64
|
dtype = torch.float64
|
||||||
|
|
||||||
|
# vertices/triangles 上 GPU(mesh 通常几百MB,可以接受)
|
||||||
vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device)
|
vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device)
|
||||||
faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device)
|
faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device)
|
||||||
triangles = vertices_t[faces_t]
|
triangles = vertices_t[faces_t]
|
||||||
points_t = torch.as_tensor(points, dtype=dtype, device=device)
|
del vertices_t, faces_t # 释放原始 vertices/faces,只保留 triangles
|
||||||
|
|
||||||
|
# points 保留在 CPU,按 point_chunk 逐批传上 GPU,避免 10 亿点全量上 GPU OOM
|
||||||
|
points_np = np.asarray(points, dtype=np.float64)
|
||||||
|
n_points = points_np.shape[0]
|
||||||
|
|
||||||
distances = []
|
distances = []
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
for start in range(0, points_t.shape[0], point_chunk_size):
|
for start in range(0, n_points, point_chunk_size):
|
||||||
chunk = points_t[start:start + point_chunk_size]
|
chunk = torch.as_tensor(
|
||||||
|
points_np[start:start + point_chunk_size], dtype=dtype, device=device
|
||||||
|
)
|
||||||
if triangle_chunk_size is None or triangle_chunk_size <= 0:
|
if triangle_chunk_size is None or triangle_chunk_size <= 0:
|
||||||
dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
|
dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
|
||||||
min_dist2 = dist2.min(dim=1).values
|
min_dist2 = dist2.min(dim=1).values
|
||||||
@@ -90,7 +98,7 @@ class Evaluator:
|
|||||||
min_dist2 = torch.full(
|
min_dist2 = torch.full(
|
||||||
(chunk.shape[0],),
|
(chunk.shape[0],),
|
||||||
torch.inf,
|
torch.inf,
|
||||||
dtype=points_t.dtype,
|
dtype=dtype,
|
||||||
device=device,
|
device=device,
|
||||||
)
|
)
|
||||||
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):
|
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ class MeasurementPipeline:
|
|||||||
excel_output_path: str | None = None,
|
excel_output_path: str | None = None,
|
||||||
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
|
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
|
||||||
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
|
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
|
||||||
|
save_combined_visualization: bool = True,
|
||||||
no_visualize: bool = False,
|
no_visualize: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
batch_start_time = time.perf_counter()
|
batch_start_time = time.perf_counter()
|
||||||
@@ -443,7 +444,7 @@ class MeasurementPipeline:
|
|||||||
|
|
||||||
combined_visualization_output = None
|
combined_visualization_output = None
|
||||||
combined_pointclouds = []
|
combined_pointclouds = []
|
||||||
if combined_visualizations:
|
if save_combined_visualization and combined_visualizations:
|
||||||
combined_visualization_output = self._contour_combined_save_path(save_path)
|
combined_visualization_output = self._contour_combined_save_path(save_path)
|
||||||
self._save_contour_combined_pointcloud_stream(
|
self._save_contour_combined_pointcloud_stream(
|
||||||
combined_visualizations,
|
combined_visualizations,
|
||||||
@@ -627,6 +628,7 @@ class MeasurementPipeline:
|
|||||||
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
|
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
|
||||||
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
|
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
|
||||||
result_col = self._find_header_index(headers, ["测量结果"])
|
result_col = self._find_header_index(headers, ["测量结果"])
|
||||||
|
error_col = self._find_header_index(headers, ["误差结果"])
|
||||||
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
|
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
|
||||||
|
|
||||||
result_by_task = {
|
result_by_task = {
|
||||||
@@ -648,6 +650,7 @@ class MeasurementPipeline:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||||
|
output_row[error_col] = result_row.get("max")
|
||||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||||
table.append(output_row)
|
table.append(output_row)
|
||||||
used_task_ids.add(task_id)
|
used_task_ids.add(task_id)
|
||||||
@@ -659,6 +662,7 @@ class MeasurementPipeline:
|
|||||||
output_row = [""] * len(headers)
|
output_row = [""] * len(headers)
|
||||||
output_row[task_col] = task_id
|
output_row[task_col] = task_id
|
||||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||||
|
output_row[error_col] = result_row.get("max")
|
||||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||||
table.append(output_row)
|
table.append(output_row)
|
||||||
|
|
||||||
@@ -729,7 +733,9 @@ class MeasurementPipeline:
|
|||||||
|
|
||||||
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
|
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
|
||||||
status = str(row.get("status", "")).strip()
|
status = str(row.get("status", "")).strip()
|
||||||
if status and status != "成功":
|
if status in {"成功", "部分成功", "失败"}:
|
||||||
|
return status
|
||||||
|
if status:
|
||||||
return "失败"
|
return "失败"
|
||||||
|
|
||||||
value = self._row_measurement_mean(row)
|
value = self._row_measurement_mean(row)
|
||||||
|
|||||||
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from pipeline import MeasurementPipeline
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedContourRetentionTest(TestCase):
|
||||||
|
def make_pipeline(self, root: Path) -> MeasurementPipeline:
|
||||||
|
mesh = root / "mesh.ply"
|
||||||
|
pointcloud = root / "points.ply"
|
||||||
|
mesh.touch()
|
||||||
|
pointcloud.touch()
|
||||||
|
|
||||||
|
pipeline = MeasurementPipeline.__new__(MeasurementPipeline)
|
||||||
|
pipeline._resolve_contour_task_inputs = Mock(return_value=[{
|
||||||
|
"task_id": "1",
|
||||||
|
"sample_id": "sample",
|
||||||
|
"cad_path": None,
|
||||||
|
"mesh_path": mesh,
|
||||||
|
"pointcloud_path": pointcloud,
|
||||||
|
}])
|
||||||
|
pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply")
|
||||||
|
pipeline.run_contour = Mock(return_value={
|
||||||
|
"distance_stats": SimpleNamespace(max=0.5),
|
||||||
|
"distances": np.array([0.1, 0.2]),
|
||||||
|
"pointcloud": object(),
|
||||||
|
})
|
||||||
|
pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply")
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream = Mock()
|
||||||
|
pipeline._should_visualize = Mock(return_value=False)
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
def test_skips_combined_file_when_disabled(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
pipeline = self.make_pipeline(Path(temp))
|
||||||
|
result = pipeline.run_contour_batch(
|
||||||
|
all_tasks=True,
|
||||||
|
save_path=temp,
|
||||||
|
save_combined_visualization=False,
|
||||||
|
no_visualize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result["combined_visualization_output"])
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream.assert_not_called()
|
||||||
|
|
||||||
|
def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
pipeline = self.make_pipeline(Path(temp))
|
||||||
|
result = pipeline.run_contour_batch(
|
||||||
|
all_tasks=True,
|
||||||
|
save_path=temp,
|
||||||
|
save_combined_visualization=True,
|
||||||
|
no_visualize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply")
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream.assert_called_once()
|
||||||
@@ -52,7 +52,20 @@ def build_cad_sample_cache_path(cad_path, output_dir, mesh_deflection, cad_sampl
|
|||||||
])
|
])
|
||||||
digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16]
|
digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16]
|
||||||
cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad"
|
cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad"
|
||||||
return os.path.join(output_dir, "cad_cache", f"{cad_name}_{digest}.ply")
|
# 优先存在 CAD 文件同目录下的 .cad_sample_cache/,跨任务复用;
|
||||||
|
# 目录不可写时回退到 output_dir/cad_cache/
|
||||||
|
cad_dir = os.path.dirname(os.path.abspath(cad_path))
|
||||||
|
shared_cache_dir = os.path.join(cad_dir, ".cad_sample_cache")
|
||||||
|
try:
|
||||||
|
os.makedirs(shared_cache_dir, exist_ok=True)
|
||||||
|
# 写权限探测
|
||||||
|
test_path = os.path.join(shared_cache_dir, ".write_test")
|
||||||
|
open(test_path, "w").close()
|
||||||
|
os.remove(test_path)
|
||||||
|
cache_dir = shared_cache_dir
|
||||||
|
except OSError:
|
||||||
|
cache_dir = os.path.join(output_dir, "cad_cache")
|
||||||
|
return os.path.join(cache_dir, f"{cad_name}_{digest}.ply")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -160,6 +173,33 @@ def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
|
|||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def load_cad_mesh(cad_path, mesh_deflection=0.1):
|
||||||
|
"""根据后缀加载 CAD mesh:STEP 走 OCC 离散化,PLY 直接读取三角网格。"""
|
||||||
|
ext = os.path.splitext(cad_path)[1].lower()
|
||||||
|
|
||||||
|
if ext in (".step", ".stp"):
|
||||||
|
return step_to_open3d_mesh(cad_path, mesh_deflection=mesh_deflection)
|
||||||
|
|
||||||
|
if ext == ".ply":
|
||||||
|
print(f"[INFO] Loading CAD mesh from PLY: {cad_path}")
|
||||||
|
mesh = o3d.io.read_triangle_mesh(cad_path)
|
||||||
|
if len(mesh.vertices) == 0 or len(mesh.triangles) == 0:
|
||||||
|
raise RuntimeError(f"PLY CAD mesh 为空或读取失败: {cad_path}")
|
||||||
|
|
||||||
|
mesh.remove_duplicated_vertices()
|
||||||
|
mesh.remove_duplicated_triangles()
|
||||||
|
mesh.remove_degenerate_triangles()
|
||||||
|
mesh.compute_vertex_normals()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[OK] PLY CAD mesh loaded: {len(mesh.vertices)} vertices, "
|
||||||
|
f"{len(mesh.triangles)} triangles"
|
||||||
|
)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
raise RuntimeError(f"不支持的 CAD 格式: {cad_path}(仅支持 .step/.stp/.ply)")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 工具函数
|
# 工具函数
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -516,7 +556,7 @@ def translate_align_by_front_tail(
|
|||||||
cad_pcd = None
|
cad_pcd = None
|
||||||
|
|
||||||
if cad_pcd is None:
|
if cad_pcd is None:
|
||||||
cad_mesh = step_to_open3d_mesh(
|
cad_mesh = load_cad_mesh(
|
||||||
cad_path,
|
cad_path,
|
||||||
mesh_deflection=mesh_deflection
|
mesh_deflection=mesh_deflection
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import { Route, Switch } from "wouter";
|
import { Redirect, Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import ChangePassword from "./pages/ChangePassword";
|
||||||
|
import Users from "./pages/Users";
|
||||||
|
import { useAuth } from "./_core/hooks/useAuth";
|
||||||
|
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||||
|
|
||||||
|
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <DashboardLayoutSkeleton />;
|
||||||
|
if (!user) return <Redirect to="/login" />;
|
||||||
|
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
|
||||||
|
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
// make sure to consider if you need authentication for certain routes
|
// make sure to consider if you need authentication for certain routes
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path="/login" component={Login} />
|
||||||
|
<Route path="/change-password" component={ChangePassword} />
|
||||||
|
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
|
||||||
|
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
|
|
||||||
const logoutMutation = trpc.auth.logout.useMutation({
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
await utils.auth.me.invalidate();
|
await utils.auth.me.invalidate();
|
||||||
}
|
}
|
||||||
}, [logoutMutation, utils]);
|
}, [logoutMutation, utils]);
|
||||||
|
|
||||||
const state = useMemo(() => {
|
const state = useMemo(() => {
|
||||||
localStorage.setItem(
|
|
||||||
"manus-runtime-user-info",
|
|
||||||
JSON.stringify(meQuery.data)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
user: meQuery.data ?? null,
|
user: meQuery.data ?? null,
|
||||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||||
isAuthenticated: Boolean(meQuery.data),
|
isAuthenticated: Boolean(meQuery.data),
|
||||||
|
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
|
||||||
|
isSuperAdmin: meQuery.data?.role === "super_admin",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
meQuery.data,
|
meQuery.data,
|
||||||
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname === redirectPath) return;
|
if (window.location.pathname === redirectPath) return;
|
||||||
|
|
||||||
window.location.href = redirectPath
|
window.location.href = redirectPath;
|
||||||
}, [
|
}, [
|
||||||
redirectOnUnauthenticated,
|
redirectOnUnauthenticated,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
|||||||
@@ -9,11 +9,19 @@
|
|||||||
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
|
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
|
||||||
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
|
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, ListChecks, Play, RefreshCw, Scan, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
||||||
|
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
|
||||||
|
|
||||||
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
|
'train-measurement',
|
||||||
|
'coupler-panel',
|
||||||
|
'flatness-suite',
|
||||||
|
'underframe-deflection',
|
||||||
|
];
|
||||||
|
|
||||||
interface PluginInputField {
|
interface PluginInputField {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,12 +50,24 @@ interface PluginSummary {
|
|||||||
runtimeKind: 'process' | 'mock';
|
runtimeKind: 'process' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PointcloudFileSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
source?: string;
|
||||||
|
sourceResultId?: number | null;
|
||||||
|
sourcePointcloudFileId?: number | null;
|
||||||
|
createdAt?: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
|
modelId: number | null;
|
||||||
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
||||||
pointCloudLoaded: boolean;
|
pointCloudLoaded: boolean;
|
||||||
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
||||||
taskPcFileCount?: number;
|
taskPcFileCount?: number;
|
||||||
|
/** 当前任务下的点云文件元数据(用于配准输入校验与重复配准确认) */
|
||||||
|
taskPointcloudFiles?: PointcloudFileSummary[];
|
||||||
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
|
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
|
||||||
selectedPcFileIds?: number[];
|
selectedPcFileIds?: number[];
|
||||||
/** 当前任务中选中的点云中是否包含配准后的点云 */
|
/** 当前任务中选中的点云中是否包含配准后的点云 */
|
||||||
@@ -60,6 +80,13 @@ interface Props {
|
|||||||
activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null;
|
activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | Date) {
|
||||||
|
if (!value) return '未知时间';
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '未知时间';
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const field of plugin.inputs) {
|
for (const field of plugin.inputs) {
|
||||||
@@ -68,7 +95,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
|
export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, taskPointcloudFiles = [], selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
@@ -78,17 +105,75 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`);
|
toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`);
|
||||||
if (taskId != null) {
|
if (taskId != null) {
|
||||||
void utils.analysisResult.list.invalidate({ taskId });
|
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 || '提交失败'),
|
onError: (e) => toast.error(e.message || '提交失败'),
|
||||||
});
|
});
|
||||||
|
const runDemoAll = trpc.algorithm.runDemoAll.useMutation({
|
||||||
|
onSuccess: ({ jobs, registrationJob, queuedMeasurementCount }) => {
|
||||||
|
const submittedCount = jobs.length + (registrationJob ? 1 : 0);
|
||||||
|
if (registrationJob) {
|
||||||
|
toast.success(`已提交整车配准;完成后将自动提交 ${queuedMeasurementCount} 个测量算法`);
|
||||||
|
} else {
|
||||||
|
toast.success(`已提交 ${submittedCount} 个算法到并发池`);
|
||||||
|
}
|
||||||
|
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 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} 个运行任务` : '当前没有运行中的全流程任务');
|
||||||
|
if (taskId != null) {
|
||||||
|
void utils.analysisResult.list.invalidate({ taskId });
|
||||||
|
void utils.pointcloudFile.list.invalidate({ taskId });
|
||||||
|
}
|
||||||
|
if (modelId != null) {
|
||||||
|
void utils.inspectionTask.listByModel.invalidate({ modelId });
|
||||||
|
}
|
||||||
|
void utils.algorithm.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message || '停止全流程失败'),
|
||||||
|
});
|
||||||
|
|
||||||
const plugins = data?.plugins ?? [];
|
const plugins = data?.plugins ?? [];
|
||||||
const scheduler = data?.scheduler;
|
const scheduler = data?.scheduler;
|
||||||
|
const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery(
|
||||||
|
{ 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 [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
||||||
|
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
|
||||||
|
|
||||||
// 首次拉到 plugins 后用默认值初始化每个表单
|
// 首次拉到 plugins 后用默认值初始化每个表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,14 +210,122 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const canRun = useMemo(
|
// 区分配准插件 vs 测量插件
|
||||||
// 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载
|
const regPlugins = plugins.filter((p) => p.category === '配准');
|
||||||
() => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending,
|
const measPlugins = plugins.filter((p) => p.category !== '配准');
|
||||||
[taskId, taskPcFileCount, selected, runBatch.isPending],
|
const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
|
||||||
);
|
const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
|
||||||
|
const demoMeasurementPluginIds = DEMO_MEASUREMENT_PLUGIN_IDS.filter((id) => measPlugins.some((p) => p.id === id));
|
||||||
|
const hasDemoRegistrationPlugin = regPlugins.some((p) => p.id === 'vehicle-registration');
|
||||||
|
const demoAllPluginIds = [
|
||||||
|
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
||||||
|
...demoMeasurementPluginIds,
|
||||||
|
];
|
||||||
|
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 || runDemoSelected.isPending;
|
||||||
|
|
||||||
const handleRun = (ids: string[]) => {
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
if (!canRun || taskId == null) return;
|
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||||
|
const selectedRegistrationPointcloud =
|
||||||
|
selectedPcCount === 1
|
||||||
|
? taskPointcloudFiles.find((f) => f.id === selectedPcFileIds?.[0])
|
||||||
|
: undefined;
|
||||||
|
const latestRegistrationChild = selectedRegistrationPointcloud?.source !== 'registered'
|
||||||
|
? taskPointcloudFiles
|
||||||
|
.filter((f) => (
|
||||||
|
f.source === 'registered'
|
||||||
|
&& f.sourcePointcloudFileId === selectedRegistrationPointcloud?.id
|
||||||
|
))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||||
|
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
})[0]
|
||||||
|
: undefined;
|
||||||
|
const baseRunBlockReason =
|
||||||
|
taskId == null ? '请先选择检测任务'
|
||||||
|
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
||||||
|
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
||||||
|
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
||||||
|
: null;
|
||||||
|
const registrationBlockReason =
|
||||||
|
baseRunBlockReason
|
||||||
|
?? (selectedRegIds.length === 0 ? '请勾选配准算法'
|
||||||
|
: selectedPcCount === 0 ? '整车配准必须选择 1 个点云文件'
|
||||||
|
: selectedPcCount > 1 ? '整车配准一次只能选择 1 个点云文件'
|
||||||
|
: selectedRegistrationPointcloud?.source === 'registered' ? '请选择原始点云进行配准,已配准点云不能再次配准'
|
||||||
|
: selectedCadCount === 0 ? '整车配准必须选择 1 个 CAD 文件'
|
||||||
|
: selectedCadCount > 1 ? '整车配准一次只能选择 1 个 CAD 文件'
|
||||||
|
: null);
|
||||||
|
const measurementBlockReason =
|
||||||
|
baseRunBlockReason
|
||||||
|
?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null);
|
||||||
|
const canRunRegistration = registrationBlockReason == null;
|
||||||
|
const canRunMeasurement = measurementBlockReason == null;
|
||||||
|
const demoBlockReason =
|
||||||
|
baseRunBlockReason
|
||||||
|
?? (selectedPcCount === 0 ? '一键全流程演示必须选择 1 个原始点云'
|
||||||
|
: selectedPcCount > 1 ? '一键全流程演示一次只能选择 1 个原始点云'
|
||||||
|
: selectedRegistrationPointcloud?.source === 'registered' ? '请选择原始点云,系统会自动找到对应配准产物'
|
||||||
|
: selectedCadCount === 0 ? '一键全流程演示必须选择 1 个 CAD 文件'
|
||||||
|
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
||||||
|
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
||||||
|
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
||||||
|
: demoCatalogLoading ? '正在加载任务目录'
|
||||||
|
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
|
||||||
|
: null);
|
||||||
|
const canRunDemoAll = demoBlockReason == null;
|
||||||
|
const demoRouteHint =
|
||||||
|
selectedPcCount === 1 && selectedRegistrationPointcloud?.source === 'registered'
|
||||||
|
? '请选择原始点云,系统会自动找到对应配准产物。'
|
||||||
|
: selectedPcCount === 1 && selectedRegistrationPointcloud && selectedRegistrationPointcloud.source !== 'registered' && selectedCadCount === 1
|
||||||
|
? `将先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`
|
||||||
|
: null;
|
||||||
|
const duplicateRegistrationWarning =
|
||||||
|
latestRegistrationChild && selectedRegistrationPointcloud
|
||||||
|
? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelectAllMeasurement = () => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const plugin of measPlugins) next.add(plugin.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRun = (ids: string[], mode: 'registration' | 'measurement') => {
|
||||||
|
const blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason;
|
||||||
|
if (blockReason) {
|
||||||
|
toast.error(blockReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (taskId == null) return;
|
||||||
|
const allowDuplicateRegistration =
|
||||||
|
mode === 'registration' && duplicateRegistrationWarning
|
||||||
|
? window.confirm(`${duplicateRegistrationWarning}\n\n是否确认重新配准?`)
|
||||||
|
: false;
|
||||||
|
if (mode === 'registration' && duplicateRegistrationWarning && !allowDuplicateRegistration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointCloudFileIds =
|
||||||
|
mode === 'registration'
|
||||||
|
? selectedPcFileIds
|
||||||
|
: selectedPcFileIds && selectedPcFileIds.length > 0
|
||||||
|
? selectedPcFileIds
|
||||||
|
: undefined;
|
||||||
|
const cadFileIds =
|
||||||
|
mode === 'registration'
|
||||||
|
? selectedCadFileIds
|
||||||
|
: selectedCadFileIds && selectedCadFileIds.length > 0
|
||||||
|
? selectedCadFileIds
|
||||||
|
: undefined;
|
||||||
const jobs = ids.map((pluginId) => ({
|
const jobs = ids.map((pluginId) => ({
|
||||||
pluginId,
|
pluginId,
|
||||||
params: paramsById[pluginId] ?? {},
|
params: paramsById[pluginId] ?? {},
|
||||||
@@ -140,25 +333,118 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
runBatch.mutate({
|
runBatch.mutate({
|
||||||
taskId,
|
taskId,
|
||||||
jobs,
|
jobs,
|
||||||
...(selectedPcFileIds && selectedPcFileIds.length > 0
|
...(pointCloudFileIds ? { pointCloudFileIds } : {}),
|
||||||
? { pointCloudFileIds: selectedPcFileIds }
|
...(cadFileIds ? { cadFileIds } : {}),
|
||||||
: {}),
|
...(allowDuplicateRegistration ? { allowDuplicateRegistration } : {}),
|
||||||
...(selectedCadFileIds && selectedCadFileIds.length > 0
|
|
||||||
? { cadFileIds: selectedCadFileIds }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 区分配准插件 vs 测量插件
|
const handleRunDemoAll = () => {
|
||||||
const regPlugins = plugins.filter((p) => p.category === '配准');
|
if (demoBlockReason) {
|
||||||
const measPlugins = plugins.filter((p) => p.category !== '配准');
|
toast.error(demoBlockReason);
|
||||||
const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
|
return;
|
||||||
const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
|
}
|
||||||
|
if (taskId == null || !selectedPcFileIds?.[0] || !selectedCadFileIds?.[0]) return;
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const pluginId of demoAllPluginIds) next.add(pluginId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
runDemoAll.mutate({
|
||||||
|
taskId,
|
||||||
|
sourcePointCloudFileId: selectedPcFileIds[0],
|
||||||
|
cadFileId: selectedCadFileIds[0],
|
||||||
|
measurementPluginIds: demoMeasurementPluginIds,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
|
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* ── 一键演示:整车配准 → 测量算法 ───────────────────────────── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="industrial-label flex items-center gap-1.5 text-emerald-300">
|
||||||
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
|
一键全流程演示
|
||||||
|
</span>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{demoFlowRunning ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 text-amber-300 ${
|
||||||
|
cancelDemoAll.isPending ? 'opacity-60 cursor-wait' : ''
|
||||||
|
}`}
|
||||||
|
disabled={cancelDemoAll.isPending}
|
||||||
|
onClick={handleCancelDemoAll}
|
||||||
|
title="停止整条一键全流程链路"
|
||||||
|
>
|
||||||
|
{cancelDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Square className="w-3 h-3" />}
|
||||||
|
停止全流程
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
|
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
disabled={!canRunDemoAll}
|
||||||
|
onClick={handleRunDemoAll}
|
||||||
|
title={demoBlockReason ?? demoRunTitle}
|
||||||
|
>
|
||||||
|
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
|
运行全部任务({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>
|
||||||
|
{demoRouteHint && (
|
||||||
|
<div className="flex items-start gap-1.5 bg-blue-900/20 border border-blue-700/40 rounded p-2">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-blue-300 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-[10px] text-blue-200">{demoRouteHint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 步骤 1:整车配准 ────────────────────────────────── */}
|
{/* ── 步骤 1:整车配准 ────────────────────────────────── */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
@@ -169,11 +455,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
canRun && selectedRegIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
|
canRunRegistration ? '' : 'opacity-50 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
disabled={!canRun || selectedRegIds.length === 0}
|
disabled={!canRunRegistration}
|
||||||
onClick={() => handleRun(selectedRegIds)}
|
onClick={() => handleRun(selectedRegIds, 'registration')}
|
||||||
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
|
title={registrationBlockReason ?? '执行配准'}
|
||||||
>
|
>
|
||||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
执行({selectedRegIds.length})
|
执行({selectedRegIds.length})
|
||||||
@@ -182,11 +468,23 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
{regPlugins.length === 0 && !isLoading && (
|
{regPlugins.length === 0 && !isLoading && (
|
||||||
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
|
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedRegIds.length > 0 && registrationBlockReason && !baseRunBlockReason && (
|
||||||
|
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-[10px] text-amber-300">{registrationBlockReason}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedRegIds.length > 0 && !registrationBlockReason && duplicateRegistrationWarning && (
|
||||||
|
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-[10px] text-amber-300">{duplicateRegistrationWarning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{regPlugins.map((p) => (
|
{regPlugins.map((p) => (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
p={p}
|
p={p}
|
||||||
isSelected={selected.has(p.id)}
|
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
|
||||||
isExpanded={expanded.has(p.id)}
|
isExpanded={expanded.has(p.id)}
|
||||||
params={paramsById[p.id] ?? {}}
|
params={paramsById[p.id] ?? {}}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
@@ -207,7 +505,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<Cpu className="w-3.5 h-3.5" />
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
第 2 步 · 测量分析(多选并发)
|
第 2 步 · 测量分析(多选并发)
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 flex-wrap justify-end">
|
||||||
{scheduler && (
|
{scheduler && (
|
||||||
<span
|
<span
|
||||||
className="text-[9px] text-muted-foreground tabular-nums"
|
className="text-[9px] text-muted-foreground tabular-nums"
|
||||||
@@ -216,19 +514,24 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
|
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
|
onClick={handleSelectAllMeasurement}
|
||||||
|
disabled={measPlugins.length === 0}
|
||||||
|
title="全选测量算法,不包含整车配准"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
全选测量算法
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
canRun && selectedMeasIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
|
canRunMeasurement ? '' : 'opacity-50 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
disabled={!canRun || selectedMeasIds.length === 0}
|
disabled={!canRunMeasurement}
|
||||||
onClick={() => handleRun(selectedMeasIds)}
|
onClick={() => handleRun(selectedMeasIds, 'measurement')}
|
||||||
title={
|
title={measurementBlockReason ?? '并发执行已勾选算法'}
|
||||||
taskId == null ? '请先选择检测任务'
|
|
||||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
|
||||||
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
|
|
||||||
: '并发执行已勾选算法'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
执行({selectedMeasIds.length})
|
执行({selectedMeasIds.length})
|
||||||
@@ -237,7 +540,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
|
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
|
||||||
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && (
|
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && !demoFlowActive && (
|
||||||
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-[10px] text-amber-300">
|
<span className="text-[10px] text-amber-300">
|
||||||
@@ -256,7 +559,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<PluginCard
|
<PluginCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
p={p}
|
p={p}
|
||||||
isSelected={selected.has(p.id)}
|
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
|
||||||
isExpanded={expanded.has(p.id)}
|
isExpanded={expanded.has(p.id)}
|
||||||
params={paramsById[p.id] ?? {}}
|
params={paramsById[p.id] ?? {}}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
@@ -269,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DemoTaskSelectionDialog
|
||||||
|
open={demoTaskDialogOpen}
|
||||||
|
onOpenChange={setDemoTaskDialogOpen}
|
||||||
|
items={demoTaskCatalog?.items ?? []}
|
||||||
|
total={demoTaskCount}
|
||||||
|
loading={demoCatalogLoading}
|
||||||
|
pending={runDemoSelected.isPending}
|
||||||
|
runBlockReason={demoBlockReason}
|
||||||
|
onRun={handleRunDemoSelected}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
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, 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';
|
||||||
|
|
||||||
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
||||||
function fmtDuration(ms: number): string {
|
function fmtDuration(ms: number): string {
|
||||||
@@ -53,10 +57,28 @@ interface Props {
|
|||||||
/** 外部分析任务是否正在提交中 */
|
/** 外部分析任务是否正在提交中 */
|
||||||
externalPending?: boolean;
|
externalPending?: boolean;
|
||||||
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
||||||
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
onViewPluginOverlay?: (args: {
|
||||||
|
taskId: number;
|
||||||
|
resultId: number;
|
||||||
|
overlay: Overlay3D;
|
||||||
|
inputPointCloudFileIds?: number[];
|
||||||
|
inputCadFileIds?: number[];
|
||||||
|
}) => 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 };
|
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
|
||||||
|
|
||||||
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
||||||
@@ -100,23 +122,41 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeasurementTable({ items }: { items: Measurement[] }) {
|
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
||||||
|
const showDetails = displayMode === 'maximized';
|
||||||
|
const sortedItems = sortMeasurements(items);
|
||||||
return (
|
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>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<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>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
<th className="text-right py-1 font-normal">结果</th>
|
<th className="text-right py-1 font-normal">结果</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((m, i) => (
|
{sortedItems.map((m, i) => (
|
||||||
<tr key={i} className="border-t border-border/40">
|
<tr key={i} className="border-t border-border/40">
|
||||||
<td className="py-1">{m.name}</td>
|
<td className="py-1 pr-2 whitespace-nowrap">
|
||||||
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
|
{m.legacyUnmapped || !m.taskNo ? (
|
||||||
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
|
<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">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
||||||
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
||||||
@@ -130,6 +170,7 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +203,196 @@ type AssemblyMeta = SmoothHeatmapMeta & {
|
|||||||
parts?: AssemblyPartRow[];
|
parts?: AssemblyPartRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getPluginDisplayName(pluginId: string | null | undefined, pluginNameById: Map<string, string>, fallback: string) {
|
||||||
|
if (!pluginId) return fallback;
|
||||||
|
return pluginNameById.get(pluginId) ?? pluginId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTitle(r: { type: string; pluginId?: string | null }, pluginNameById: Map<string, string>) {
|
||||||
|
if (r.type === 'error_analysis') return '误差分析';
|
||||||
|
if (r.type === 'measurement') return '尺寸测量';
|
||||||
|
if (r.type === 'smooth_heatmap') return '平滑热力';
|
||||||
|
if (r.type === 'assembly_inspection') return '装配检测';
|
||||||
|
if (r.type === 'obj_deviation') return '偏差热力';
|
||||||
|
if (r.type === 'plugin') return `算法 · ${getPluginDisplayName(r.pluginId, pluginNameById, '插件')}`;
|
||||||
|
return '压缩';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string) {
|
||||||
|
if (status === 'completed') return '已完成';
|
||||||
|
if (status === 'processing') return '运行中';
|
||||||
|
if (status === 'pending') return '排队中';
|
||||||
|
if (status === 'cancelled') return '已停止';
|
||||||
|
if (status === 'error') return '失败';
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status: string) {
|
||||||
|
if (status === 'completed') return 'bg-green-900/40 text-green-400';
|
||||||
|
if (status === 'processing') return 'bg-blue-900/40 text-blue-400 animate-pulse';
|
||||||
|
if (status === 'pending') return 'bg-sky-900/40 text-sky-300';
|
||||||
|
if (status === 'cancelled') return 'bg-amber-900/40 text-amber-300';
|
||||||
|
if (status === 'error') return 'bg-red-900/40 text-red-400';
|
||||||
|
return 'bg-secondary text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStartTime(value: string | Date) {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCancellableStatus(status: string) {
|
||||||
|
return status === 'processing' || status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisResultRow = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
schemaVersion?: number | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
errorStats: unknown;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DemoFlowMeta = {
|
||||||
|
runId: string;
|
||||||
|
role?: string;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
mode?: 'all' | 'selected';
|
||||||
|
selectedTaskNos?: number[];
|
||||||
|
selectedTaskCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||||
|
const SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
|
||||||
|
|
||||||
|
type AnalysisListItem =
|
||||||
|
| { kind: 'result'; result: AnalysisResultRow }
|
||||||
|
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
|
||||||
|
|
||||||
|
type ResultListVariant = 'panel' | 'maximized';
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
|
||||||
|
const data = asRecord(result.resultData);
|
||||||
|
if (!data) return null;
|
||||||
|
const runId = data?.demoFlowRunId;
|
||||||
|
if (typeof runId !== 'string' || runId.length === 0) return null;
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultTimeMs(result: AnalysisResultRow, field: 'createdAt' | 'updatedAt' = 'createdAt') {
|
||||||
|
return new Date(result[field]).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisListItems(results: AnalysisResultRow[]): AnalysisListItem[] {
|
||||||
|
const items: AnalysisListItem[] = [];
|
||||||
|
const flowItems = new Map<string, Extract<AnalysisListItem, { kind: 'demoFlow' }>>();
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const meta = readDemoFlowMeta(result);
|
||||||
|
if (!meta) {
|
||||||
|
items.push({ kind: 'result', result });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = flowItems.get(meta.runId);
|
||||||
|
if (!group) {
|
||||||
|
group = { kind: 'demoFlow', runId: meta.runId, results: [] };
|
||||||
|
flowItems.set(meta.runId, group);
|
||||||
|
items.push(group);
|
||||||
|
}
|
||||||
|
group.results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(flowItems.values()).forEach((group) => {
|
||||||
|
group.results.sort((a, b) => {
|
||||||
|
const am = readDemoFlowMeta(a);
|
||||||
|
const bm = readDemoFlowMeta(b);
|
||||||
|
const ai = am?.index ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = bm?.index ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return resultTimeMs(a) - resultTimeMs(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDemoFlowStatus(results: AnalysisResultRow[]) {
|
||||||
|
if (results.some((r) => r.status === 'processing')) return 'processing';
|
||||||
|
if (results.some((r) => r.status === 'pending')) return 'pending';
|
||||||
|
if (results.length > 0 && results.every((r) => r.status === 'completed')) return 'completed';
|
||||||
|
if (results.some((r) => r.status === 'error')) return 'error';
|
||||||
|
if (results.length > 0 && results.every((r) => r.status === 'cancelled')) return 'cancelled';
|
||||||
|
if (results.some((r) => r.status === 'cancelled')) return 'cancelled';
|
||||||
|
return results[0]?.status ?? 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDemoFlowTotal(results: AnalysisResultRow[]) {
|
||||||
|
const totals = results.map((r) => readDemoFlowMeta(r)?.total ?? 0);
|
||||||
|
return Math.max(results.length, ...totals);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
||||||
|
const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState<Set<string>>(new Set());
|
||||||
|
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
||||||
|
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
||||||
|
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
|
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
|
||||||
{ taskId },
|
{ taskId },
|
||||||
{ refetchInterval: 3000 },
|
{ refetchInterval: 3000 },
|
||||||
);
|
);
|
||||||
|
const { data: algorithmList } = trpc.algorithm.list.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const pluginNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const plugin of algorithmList?.plugins ?? []) {
|
||||||
|
if (plugin.name) map.set(plugin.id, plugin.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [algorithmList?.plugins]);
|
||||||
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
|
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
|
||||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
});
|
});
|
||||||
const deleteResult = trpc.analysisResult.delete.useMutation({
|
const deleteResult = trpc.analysisResult.delete.useMutation({
|
||||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
});
|
});
|
||||||
|
const cancelResult = trpc.analysisResult.cancel.useMutation({
|
||||||
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
|
});
|
||||||
|
|
||||||
function toggleExpand(id: number) {
|
function toggleExpand(id: number) {
|
||||||
setExpandedIds(prev => {
|
setExpandedIds(prev => {
|
||||||
@@ -186,72 +403,98 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const running = runAnalysis.isPending;
|
const running = runAnalysis.isPending;
|
||||||
|
const analysisResults = results as AnalysisResultRow[];
|
||||||
|
const listItems = buildAnalysisListItems(analysisResults);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="space-y-3">
|
const flowIdsToExpand: string[] = [];
|
||||||
<div className="flex items-center justify-between gap-1 flex-wrap">
|
const resultIdsToExpand: number[] = [];
|
||||||
<span className="industrial-label flex items-center gap-1.5">
|
|
||||||
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
||||||
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
|
||||||
disabled={running}
|
|
||||||
title="误差统计分析"
|
|
||||||
>
|
|
||||||
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
|
||||||
{running ? '分析中…' : '误差分析'}
|
|
||||||
</button>
|
|
||||||
{onRunAssembly && (
|
|
||||||
<button
|
|
||||||
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
|
||||||
onClick={onRunAssembly}
|
|
||||||
disabled={externalPending}
|
|
||||||
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
|
|
||||||
>
|
|
||||||
<Layers className="w-3 h-3" />
|
|
||||||
装配检测
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onRunDeviation && (
|
|
||||||
<button
|
|
||||||
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
|
||||||
onClick={onRunDeviation}
|
|
||||||
disabled={externalPending}
|
|
||||||
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
|
|
||||||
>
|
|
||||||
<Crosshair className="w-3 h-3" />
|
|
||||||
偏差热力
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && (
|
for (const result of results as AnalysisResultRow[]) {
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
|
const meta = readDemoFlowMeta(result);
|
||||||
)}
|
if (!meta) continue;
|
||||||
|
if (!initializedDemoFlowIds.current.has(meta.runId)) {
|
||||||
|
initializedDemoFlowIds.current.add(meta.runId);
|
||||||
|
flowIdsToExpand.push(meta.runId);
|
||||||
|
}
|
||||||
|
if (!initializedDemoResultIds.current.has(result.id)) {
|
||||||
|
initializedDemoResultIds.current.add(result.id);
|
||||||
|
resultIdsToExpand.push(result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{!isLoading && results.length === 0 && (
|
if (flowIdsToExpand.length > 0) {
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
setExpandedFlowIds((prev) => {
|
||||||
暂无分析结果,点击运行分析
|
const next = new Set(prev);
|
||||||
</div>
|
for (const id of flowIdsToExpand) next.add(id);
|
||||||
)}
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
if (resultIdsToExpand.length > 0) {
|
||||||
{results.map(r => {
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of resultIdsToExpand) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
function toggleFlowExpand(runId: string) {
|
||||||
|
setExpandedFlowIds(prev => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.has(runId) ? n.delete(runId) : n.add(runId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowResultCard(key: string) {
|
||||||
|
setCollapsedFlowResultCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactCard(key: string) {
|
||||||
|
setExpandedFlowArtifactCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactGroup(key: string) {
|
||||||
|
setExpandedFlowArtifactGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactExtra(key: string) {
|
||||||
|
setExpandedFlowArtifactExtras((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultCard(r: AnalysisResultRow, variant: ResultListVariant, extraClassName = '') {
|
||||||
const expanded = expandedIds.has(r.id);
|
const expanded = expandedIds.has(r.id);
|
||||||
const errorStats = r.errorStats as ErrorStats | null;
|
const errorStats = r.errorStats as ErrorStats | null;
|
||||||
const measurements = r.measurements as Measurement[] | null;
|
const measurements = filterVisibleMeasurements((r.measurements as Measurement[] | null) ?? []);
|
||||||
|
const resultTitle = getResultTitle(r, pluginNameById);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className="industrial-card rounded overflow-hidden">
|
<div key={r.id} className={`industrial-card rounded overflow-hidden min-w-0 ${extraClassName}`}>
|
||||||
{/* 标题行 */}
|
{/* 标题行 */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
|
className="px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
|
||||||
onClick={() => toggleExpand(r.id)}
|
onClick={() => toggleExpand(r.id)}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />}
|
||||||
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
|
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
|
||||||
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
|
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
|
||||||
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
|
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
|
||||||
@@ -259,42 +502,19 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
|
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
|
||||||
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
|
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
|
||||||
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
||||||
<span className="text-xs flex-1 truncate">
|
<Tooltip>
|
||||||
{r.type === 'error_analysis'
|
<TooltipTrigger asChild>
|
||||||
? '误差分析'
|
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
|
||||||
: r.type === 'measurement'
|
</TooltipTrigger>
|
||||||
? '尺寸测量'
|
<TooltipContent
|
||||||
: r.type === 'smooth_heatmap'
|
side="left"
|
||||||
? '平滑热力'
|
align="start"
|
||||||
: r.type === 'assembly_inspection'
|
className="max-w-[min(28rem,calc(100vw-2rem))] break-all"
|
||||||
? '装配检测'
|
>
|
||||||
: r.type === 'obj_deviation'
|
{resultTitle}
|
||||||
? '偏差热力'
|
</TooltipContent>
|
||||||
: r.type === 'plugin'
|
</Tooltip>
|
||||||
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
|
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
: '压缩'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
|
||||||
r.status === 'completed' ? 'bg-green-900/40 text-green-400' :
|
|
||||||
r.status === 'processing' ? 'bg-blue-900/40 text-blue-400 animate-pulse' :
|
|
||||||
r.status === 'error' ? 'bg-red-900/40 text-red-400' :
|
|
||||||
'bg-secondary text-muted-foreground'
|
|
||||||
}`}>{r.status}</span>
|
|
||||||
<ElapsedTime status={r.status} createdAt={r.createdAt} updatedAt={r.updatedAt} />
|
|
||||||
{r.status === 'processing' &&
|
|
||||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
|
||||||
(() => {
|
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
|
||||||
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
|
|
||||||
return pct != null ? (
|
|
||||||
<span className="text-[9px] font-mono text-primary tabular-nums">{pct}%</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5">
|
|
||||||
{onReplay && r.status === 'completed' && (
|
{onReplay && r.status === 'completed' && (
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary"
|
className="p-0.5 rounded hover:bg-secondary"
|
||||||
@@ -304,24 +524,51 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<Play className="w-3 h-3 text-primary" />
|
<Play className="w-3 h-3 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canWrite && isCancellableStatus(r.status) && (
|
||||||
<button
|
<button
|
||||||
|
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
||||||
|
title="停止"
|
||||||
|
disabled={cancelResult.isPending}
|
||||||
|
onClick={e => { e.stopPropagation(); cancelResult.mutate({ id: r.id }); }}
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canWrite && <button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatStartTime(r.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${getStatusClass(r.status)}`}>{getStatusLabel(r.status)}</span>
|
||||||
|
<ElapsedTime status={r.status} createdAt={r.createdAt} updatedAt={r.updatedAt} />
|
||||||
|
{(r.status === 'processing' || r.status === 'pending') &&
|
||||||
|
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||||
|
(() => {
|
||||||
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
|
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
|
||||||
|
return pct != null ? (
|
||||||
|
<span className="text-[9px] font-mono text-primary tabular-nums flex-shrink-0">{pct}%</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 展开内容 */}
|
{/* 展开内容 */}
|
||||||
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||||
{measurements && measurements.length > 0 && (
|
{measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
||||||
<MeasurementTable items={measurements} />
|
<MeasurementTable items={measurements} displayMode={variant} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +576,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
|
|
||||||
{expanded &&
|
{expanded &&
|
||||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||||
r.status === 'processing' &&
|
(r.status === 'processing' || r.status === 'pending') &&
|
||||||
(() => {
|
(() => {
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
const p = hm?.progress ?? 0;
|
const p = hm?.progress ?? 0;
|
||||||
@@ -501,22 +748,402 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error') && (
|
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') && (
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||||
<GenericResultView
|
<GenericResultView
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
resultId={r.id}
|
resultId={r.id}
|
||||||
pluginId={(r as { pluginId?: string }).pluginId ?? undefined}
|
pluginId={r.pluginId ?? undefined}
|
||||||
pluginName={(r as { pluginId?: string }).pluginId ?? '算法插件'}
|
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
||||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||||
|
displayMode={variant}
|
||||||
onViewOverlay={onViewPluginOverlay}
|
onViewOverlay={onViewPluginOverlay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
function renderFlowStatusNotice(notice: DemoFlowStatusNotice) {
|
||||||
|
const progress = notice.progress != null ? ` ${Math.round(notice.progress * 100)}%` : '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notice.resultId}
|
||||||
|
className={`rounded px-2 py-1 text-[10px] ${notice.status === 'error' ? 'bg-red-950/30 text-red-300' : notice.status === 'cancelled' ? 'bg-amber-950/30 text-amber-300' : 'bg-secondary/50 text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{notice.algorithmName}</span>
|
||||||
|
<span> · {getStatusLabel(notice.status)}{progress}</span>
|
||||||
|
{notice.detail && <span> · {notice.detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowArtifactGroup(artifact: DemoFlowArtifactGroup, cardKey: string) {
|
||||||
|
const overlay = normalizeOverlay3D(artifact.overlay);
|
||||||
|
const inputPointCloudFileIds = numberArrayFromUnknown(artifact.inputPointCloudFileIds);
|
||||||
|
const inputCadFileIds = numberArrayFromUnknown(artifact.inputCadFileIds);
|
||||||
|
const groupKey = `${cardKey}:${artifact.resultId}`;
|
||||||
|
const groupExpanded = expandedFlowArtifactGroups.has(groupKey);
|
||||||
|
const extraExpanded = expandedFlowArtifactExtras.has(groupKey);
|
||||||
|
const artifactCount = artifact.files.length + artifact.images.length
|
||||||
|
+ (overlay.kind !== 'none' ? 1 : 0)
|
||||||
|
+ (artifact.extra && Object.keys(artifact.extra).length > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={artifact.resultId} className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactGroup(groupKey)}
|
||||||
|
>
|
||||||
|
{groupExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-violet-400" />
|
||||||
|
<span className="text-[11px] font-medium text-foreground flex-1">{artifact.algorithmName}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{artifactCount} 项</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{groupExpanded && <div className="border-t border-border/40 p-2 space-y-2">
|
||||||
|
|
||||||
|
{artifact.files.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">报表下载</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{artifact.files.map((file) => (
|
||||||
|
<a
|
||||||
|
key={file.path}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(file.path)}`}
|
||||||
|
download
|
||||||
|
className="industrial-card p-1.5 flex items-center gap-1.5 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={file.path}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-3 h-3 flex-shrink-0 text-green-500" />
|
||||||
|
<span className="truncate">{file.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.images.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{artifact.images.map((image) => (
|
||||||
|
<a
|
||||||
|
key={image}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(image)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="industrial-card p-1 flex items-center gap-1 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={image}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{image}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overlay.kind !== 'none' && onViewPluginOverlay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
|
onClick={() => onViewPluginOverlay({
|
||||||
|
taskId,
|
||||||
|
resultId: artifact.resultId,
|
||||||
|
overlay,
|
||||||
|
inputPointCloudFileIds,
|
||||||
|
inputCadFileIds,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
在 3D 中查看叠加层
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.extra && Object.keys(artifact.extra).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => toggleFlowArtifactExtra(groupKey)}
|
||||||
|
>
|
||||||
|
{extraExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
原始字段
|
||||||
|
</button>
|
||||||
|
{extraExpanded && (
|
||||||
|
<pre className="industrial-card p-2 mt-1 text-[9px] font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(artifact.extra, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
||||||
|
const expanded = expandedFlowIds.has(group.runId);
|
||||||
|
const status = getDemoFlowStatus(group.results);
|
||||||
|
const total = getDemoFlowTotal(group.results);
|
||||||
|
const completed = group.results.filter((r) => r.status === 'completed').length;
|
||||||
|
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
||||||
|
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, 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 (
|
||||||
|
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 min-w-0"
|
||||||
|
onClick={() => toggleFlowExpand(group.runId)}
|
||||||
|
>
|
||||||
|
{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">{flowTitle}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
|
{completed}/{total} 完成
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
|
{Number.isFinite(createdAt) ? new Date(createdAt).toLocaleDateString('zh-CN') : ''}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${getStatusClass(status)}`}>{getStatusLabel(status)}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowResultCard(cardKey)}
|
||||||
|
>
|
||||||
|
{resultsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Ruler className="w-3.5 h-3.5 text-green-400" />
|
||||||
|
<span className="text-xs flex-1">算法结果</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.measurementRows.length} 项</span>
|
||||||
|
</button>
|
||||||
|
{resultsExpanded && (
|
||||||
|
<div className="border-t border-border/40">
|
||||||
|
{aggregated.statusNotices.length > 0 && (
|
||||||
|
<div className="space-y-1 p-2 pb-1">
|
||||||
|
{aggregated.statusNotices.map(renderFlowStatusNotice)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aggregated.tableRows.length === 0 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-center py-4">暂无测量结果</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<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>}
|
||||||
|
<th className="text-right px-2 py-1.5 font-normal">结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
|
||||||
|
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
||||||
|
<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 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`
|
||||||
|
: `${formatValue(row.measurement.value)} ${row.measurement.unit ?? ''}`}
|
||||||
|
</td>}
|
||||||
|
{showMeasurementDetails && <td className="px-2 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{row.measurement.toleranceLabel != null
|
||||||
|
? row.measurement.toleranceLabel
|
||||||
|
: row.measurement.tolerance != null
|
||||||
|
? row.measurement.baseline != null
|
||||||
|
? `${row.measurement.tolerance}/${row.measurement.baseline}mm`
|
||||||
|
: `±${row.measurement.tolerance}`
|
||||||
|
: '—'}
|
||||||
|
</td>}
|
||||||
|
<td className="px-2 text-right whitespace-nowrap">
|
||||||
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
||||||
|
{demoFlowStatusLabel(row.measurement)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactCard(cardKey)}
|
||||||
|
>
|
||||||
|
{artifactsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-xs flex-1">算法产物</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.artifactGroups.length} 组</span>
|
||||||
|
</button>
|
||||||
|
{artifactsExpanded && (
|
||||||
|
<div className="border-t border-border/40 p-2 space-y-3">
|
||||||
|
{aggregated.artifactGroups.length > 0
|
||||||
|
? aggregated.artifactGroups.map((artifact) => renderFlowArtifactGroup(artifact, cardKey))
|
||||||
|
: <div className="text-[10px] text-muted-foreground text-center py-3">暂无算法产物</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultList(variant: ResultListVariant) {
|
||||||
|
return (
|
||||||
|
<div className={variant === 'maximized' ? 'grid grid-cols-1 xl:grid-cols-2 gap-3' : 'space-y-1.5'}>
|
||||||
|
{listItems.map((item) => (
|
||||||
|
item.kind === 'demoFlow'
|
||||||
|
? renderDemoFlowCard(item, variant)
|
||||||
|
: renderResultCard(item.result, variant)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-1 flex-wrap">
|
||||||
|
<span className="industrial-label flex items-center gap-1.5">
|
||||||
|
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn py-0.5 px-1 text-[10px] flex items-center justify-center"
|
||||||
|
onClick={() => setMaximizedOpen(true)}
|
||||||
|
title="最大化查看分析结果"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
|
||||||
|
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
||||||
|
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
||||||
|
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
||||||
|
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
|
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
||||||
|
disabled={running}
|
||||||
|
title="误差统计分析"
|
||||||
|
>
|
||||||
|
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
|
{running ? '分析中…' : '误差分析'}
|
||||||
|
</button>
|
||||||
|
{onRunAssembly && (
|
||||||
|
<button
|
||||||
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
||||||
|
onClick={onRunAssembly}
|
||||||
|
disabled={externalPending}
|
||||||
|
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
|
||||||
|
>
|
||||||
|
<Layers className="w-3 h-3" />
|
||||||
|
装配检测
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onRunDeviation && (
|
||||||
|
<button
|
||||||
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
||||||
|
onClick={onRunDeviation}
|
||||||
|
disabled={externalPending}
|
||||||
|
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
|
||||||
|
>
|
||||||
|
<Crosshair className="w-3 h-3" />
|
||||||
|
偏差热力
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && results.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
|
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && results.length > 0 && renderResultList('panel')}
|
||||||
|
|
||||||
|
<Dialog open={maximizedOpen} onOpenChange={setMaximizedOpen}>
|
||||||
|
<DialogContent className="w-[calc(100vw-2rem)] h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-2rem)] p-0 overflow-hidden gap-0">
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||||
|
<DialogTitle className="text-sm flex items-center gap-2">
|
||||||
|
<BarChart2 className="w-4 h-4" />
|
||||||
|
分析结果
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-0 overflow-y-auto p-4">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8">加载中…</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && results.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8">暂无分析结果</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && results.length > 0 && renderResultList('maximized')}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
@@ -21,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
import { LayoutDashboard, PanelLeft, Users } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -106,7 +98,6 @@ function DashboardLayoutContent({
|
|||||||
children,
|
children,
|
||||||
setSidebarWidth,
|
setSidebarWidth,
|
||||||
}: DashboardLayoutContentProps) {
|
}: DashboardLayoutContentProps) {
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const { state, toggleSidebar } = useSidebar();
|
const { state, toggleSidebar } = useSidebar();
|
||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
@@ -201,36 +192,6 @@ function DashboardLayoutContent({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="p-3">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
<Avatar className="h-9 w-9 border shrink-0">
|
|
||||||
<AvatarFallback className="text-xs font-medium">
|
|
||||||
{user?.name?.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
||||||
<p className="text-sm font-medium truncate leading-none">
|
|
||||||
{user?.name || "-"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
||||||
{user?.email || "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={logout}
|
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Sign out</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
export function DashboardLayoutSkeleton() {
|
export function DashboardLayoutSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
|
|||||||
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<div className="flex items-center gap-3 px-2">
|
<div className="flex items-center gap-3 px-2">
|
||||||
<Skeleton className="h-8 w-8 rounded-md" />
|
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu items */}
|
{/* Menu items */}
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User profile area at bottom */}
|
{/* User profile area at bottom */}
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
<div className="absolute bottom-4 left-4 right-4">
|
||||||
<div className="flex items-center gap-3 px-1">
|
<div className="flex items-center gap-3 px-1">
|
||||||
<Skeleton className="h-9 w-9 rounded-full" />
|
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="bg-muted h-3 w-20" />
|
||||||
<Skeleton className="h-2 w-32" />
|
<Skeleton className="bg-muted h-2 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
|
|||||||
{/* Main content skeleton */}
|
{/* Main content skeleton */}
|
||||||
<div className="flex-1 p-4 space-y-4">
|
<div className="flex-1 p-4 space-y-4">
|
||||||
{/* Content blocks */}
|
{/* Content blocks */}
|
||||||
<Skeleton className="h-12 w-48 rounded-lg" />
|
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-64 rounded-xl" />
|
<Skeleton className="bg-muted h-64 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
211
client/src/components/DemoFlowOverlay.tsx
Normal file
211
client/src/components/DemoFlowOverlay.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
formatElapsedTime,
|
||||||
|
type DemoFlowState,
|
||||||
|
type DemoFlowStep,
|
||||||
|
type DemoFlowStepStatus,
|
||||||
|
} from '@/hooks/useDemoFlowState';
|
||||||
|
|
||||||
|
interface DemoFlowOverlayProps {
|
||||||
|
state: DemoFlowState;
|
||||||
|
pluginNameById?: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'processing':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
</div>);
|
||||||
|
case 'pending':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-slate-500/20 text-slate-400 flex items-center justify-center">
|
||||||
|
<Circle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'error':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'cancelled':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBorder(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'border-green-500/30 bg-green-500/5';
|
||||||
|
case 'processing': return 'border-blue-500/40 bg-blue-500/10 animate-pulse';
|
||||||
|
case 'pending': return 'border-slate-500/20 bg-slate-500/5';
|
||||||
|
case 'error': return 'border-red-500/30 bg-red-500/5';
|
||||||
|
case 'cancelled': return 'border-amber-500/30 bg-amber-500/5';
|
||||||
|
default: return 'border-border/40 bg-card/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLabel(step: DemoFlowStep, pluginNameById?: Map<string, string>): string {
|
||||||
|
if (step.role === 'registration') return '整车配准';
|
||||||
|
const name = step.pluginId ? (pluginNameById?.get(step.pluginId) ?? step.pluginId) : '测量算法';
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing': return '运行中';
|
||||||
|
case 'pending': return '排队中';
|
||||||
|
case 'completed': return '已完成';
|
||||||
|
case 'error': return '失败';
|
||||||
|
case 'cancelled': return '已停止';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
|
||||||
|
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const title = useMemo(
|
||||||
|
() => state.completedPointCount == null
|
||||||
|
? '点云数量暂不可用'
|
||||||
|
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
|
||||||
|
[state.completedPointCount],
|
||||||
|
);
|
||||||
|
const elapsedTime = formatElapsedTime(state.elapsedMs);
|
||||||
|
|
||||||
|
if (!state.active && state.overallStatus === 'idle') return null;
|
||||||
|
|
||||||
|
const isCollapsed = state.runId != null && collapsedRunIds.has(state.runId);
|
||||||
|
const setCurrentRunCollapsed = (collapsed: boolean) => {
|
||||||
|
if (state.runId == null) return;
|
||||||
|
setCollapsedRunIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (collapsed) next.add(state.runId!);
|
||||||
|
else next.delete(state.runId!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-72 max-w-[calc(100%-1.5rem)] items-center gap-3 border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-3 py-2.5 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[10px] font-medium" title={title}>{title}</div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
|
||||||
|
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
|
||||||
|
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="展开进度"
|
||||||
|
aria-label="展开进度"
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(false)}
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto mx-4 w-full max-w-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-5 shadow-2xl backdrop-blur-sm sm:px-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
|
{state.overallStatus === 'processing' ? (
|
||||||
|
<Scan className="w-4 h-4 text-primary animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<Cpu className="w-4 h-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="break-words text-sm font-medium">{title}</h3>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="收起进度"
|
||||||
|
aria-label="收起进度"
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(true)}
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总进度条 */}
|
||||||
|
<div className="h-1.5 w-full bg-secondary rounded-full overflow-hidden mb-5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤条 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{state.steps.map((step, idx) => {
|
||||||
|
const isCurrent = state.currentStep?.id === step.id;
|
||||||
|
const label = stepLabel(step, pluginNameById);
|
||||||
|
const showProgress = step.status === 'processing' && step.progress != null;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="relative flex items-start gap-3">
|
||||||
|
{idx !== state.steps.length - 1 && (
|
||||||
|
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 mt-0.5">{statusIcon(step.status)}</div>
|
||||||
|
<div className={`min-w-0 flex-1 rounded border p-2.5 ${statusBorder(step.status)} ${isCurrent ? 'ring-1 ring-primary/20' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground flex-shrink-0">
|
||||||
|
{step.status === 'processing' ? '运行中'
|
||||||
|
: step.status === 'pending' ? '排队中'
|
||||||
|
: step.status === 'completed' ? '已完成'
|
||||||
|
: step.status === 'error' ? '失败'
|
||||||
|
: step.status === 'cancelled' ? '已停止'
|
||||||
|
: step.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showProgress && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-500"
|
||||||
|
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
|
||||||
|
{Math.round((step.progress ?? 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.overallStatus === 'processing' && state.currentStep && (
|
||||||
|
<div className="mt-4 text-[10px] text-muted-foreground text-center">
|
||||||
|
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal file
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,24 @@ import { useState } from 'react';
|
|||||||
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface Measurement {
|
interface Measurement {
|
||||||
|
taskNo?: string;
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
position?: string;
|
||||||
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
|
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
||||||
|
toleranceLabel?: string;
|
||||||
|
/** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */
|
||||||
|
baseline?: number;
|
||||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||||
|
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
||||||
|
passLabel?: string;
|
||||||
|
warningLabel?: string;
|
||||||
|
failLabel?: string;
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorStats {
|
interface ErrorStats {
|
||||||
@@ -31,7 +44,11 @@ interface ErrorStats {
|
|||||||
type Overlay3D =
|
type Overlay3D =
|
||||||
| { kind: 'none' }
|
| { kind: 'none' }
|
||||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||||
| { kind: 'ply'; file: string };
|
| { kind: 'ply'; file: string; files?: string[] };
|
||||||
|
|
||||||
|
type RawOverlay3D =
|
||||||
|
| Overlay3D
|
||||||
|
| { kind: 'vertexColors'; file?: string; url?: string; vertexCount?: number; dispMin?: number; dispMax?: number };
|
||||||
|
|
||||||
interface PluginResultLike {
|
interface PluginResultLike {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
@@ -39,10 +56,12 @@ interface PluginResultLike {
|
|||||||
message?: string;
|
message?: string;
|
||||||
measurements?: Measurement[];
|
measurements?: Measurement[];
|
||||||
errorStats?: ErrorStats | null;
|
errorStats?: ErrorStats | null;
|
||||||
overlay3d?: Overlay3D;
|
overlay3d?: RawOverlay3D;
|
||||||
|
inputPointCloudFileIds?: unknown;
|
||||||
|
inputCadFileIds?: unknown;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
|
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
|
||||||
files?: string[];
|
files?: (string | { name?: string; path?: string })[];
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -53,11 +72,19 @@ interface Props {
|
|||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
pluginName?: string;
|
pluginName?: string;
|
||||||
data: PluginResultLike;
|
data: PluginResultLike;
|
||||||
|
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
|
||||||
|
displayMode?: 'panel' | 'maximized';
|
||||||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||||||
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
onViewOverlay?: (overlay: {
|
||||||
|
taskId: number;
|
||||||
|
resultId: number;
|
||||||
|
overlay: Overlay3D;
|
||||||
|
inputPointCloudFileIds?: number[];
|
||||||
|
inputCadFileIds?: number[];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(s?: Measurement['status']) {
|
export function statusClass(s?: Measurement['status']) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return 'bg-green-900/40 text-green-400';
|
return 'bg-green-900/40 text-green-400';
|
||||||
@@ -70,46 +97,122 @@ function statusClass(s?: Measurement['status']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(s?: Measurement['status']) {
|
export function statusLabel(m: Measurement) {
|
||||||
switch (s) {
|
switch (m.status) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return '合格';
|
return m.passLabel ?? '合格';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return '警告';
|
return m.warningLabel ?? '警告';
|
||||||
case 'fail':
|
case 'fail':
|
||||||
return '超差';
|
return m.failLabel ?? '超差';
|
||||||
default:
|
default:
|
||||||
return '—';
|
return '参考';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(v: number): string {
|
export function isReferenceMeasurement(measurement: { status?: unknown }): boolean {
|
||||||
|
return measurement.status === 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVisibleMeasurements<T extends { status?: unknown }>(measurements: readonly T[]): T[] {
|
||||||
|
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 '—';
|
if (!Number.isFinite(v)) return '—';
|
||||||
const abs = Math.abs(v);
|
const abs = Math.abs(v);
|
||||||
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
||||||
return v.toFixed(3);
|
return v.toFixed(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
||||||
|
return ids.length > 0 ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||||
|
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
||||||
|
if (overlay.kind === 'ply') {
|
||||||
|
const files = Array.isArray(overlay.files)
|
||||||
|
? overlay.files.filter((f): f is string => typeof f === 'string' && f.length > 0)
|
||||||
|
: undefined;
|
||||||
|
return files && files.length > 0
|
||||||
|
? { kind: 'ply', file: overlay.file, files }
|
||||||
|
: { kind: 'ply', file: overlay.file };
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyUrl = 'url' in overlay ? overlay.url : undefined;
|
||||||
|
const file = typeof overlay.file === 'string' && overlay.file.length > 0
|
||||||
|
? overlay.file
|
||||||
|
: typeof legacyUrl === 'string' && legacyUrl.length > 0
|
||||||
|
? legacyUrl
|
||||||
|
: undefined;
|
||||||
|
if (!file) return { kind: 'none' };
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof overlay.vertexCount === 'number' &&
|
||||||
|
typeof overlay.dispMin === 'number' &&
|
||||||
|
typeof overlay.dispMax === 'number'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'vertexColors',
|
||||||
|
file,
|
||||||
|
vertexCount: overlay.vertexCount,
|
||||||
|
dispMin: overlay.dispMin,
|
||||||
|
dispMax: overlay.dispMax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ply', file };
|
||||||
|
}
|
||||||
|
|
||||||
export default function GenericResultView({
|
export default function GenericResultView({
|
||||||
taskId,
|
taskId,
|
||||||
resultId,
|
resultId,
|
||||||
|
pluginId,
|
||||||
pluginName,
|
pluginName,
|
||||||
data,
|
data,
|
||||||
|
displayMode = 'panel',
|
||||||
onViewOverlay,
|
onViewOverlay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
const errorStats = data.errorStats ?? null;
|
const errorStats = data.errorStats ?? null;
|
||||||
const measurements = data.measurements ?? [];
|
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
|
||||||
|
const showMeasurementDetails = displayMode === 'maximized';
|
||||||
const images = data.images ?? [];
|
const images = data.images ?? [];
|
||||||
const files = data.files ?? [];
|
const files = (data.files ?? []).map((f) =>
|
||||||
const overlay = data.overlay3d ?? { kind: 'none' };
|
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
||||||
|
);
|
||||||
|
const overlay = normalizeOverlay3D(data.overlay3d);
|
||||||
|
const inputPointCloudFileIds = numberArrayFromUnknown(data.inputPointCloudFileIds);
|
||||||
|
const inputCadFileIds = numberArrayFromUnknown(data.inputCadFileIds);
|
||||||
|
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pluginName && (
|
{displayPluginName && (
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{pluginName}
|
{displayPluginName}
|
||||||
{data.pluginId ? ` · ${data.pluginId}` : ''}
|
|
||||||
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
|
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -136,30 +239,55 @@ export default function GenericResultView({
|
|||||||
{measurements.length > 0 && (
|
{measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">测量值</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>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<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>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
<th className="text-right py-1 font-normal">结果</th>
|
<th className="text-right py-1 font-normal">结果</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{measurements.map((m, i) => (
|
{measurements.map((m, i) => (
|
||||||
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
|
<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}>
|
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||||||
{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>
|
</td>
|
||||||
<td className="text-right font-mono">
|
{showMeasurementDetails && <td className="text-right font-mono">
|
||||||
{formatValue(m.value)} {m.unit ?? ''}
|
{m.baseline != null
|
||||||
</td>
|
? `${formatValue(m.value)}/${m.baseline}mm`
|
||||||
<td className="text-right text-muted-foreground">
|
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
||||||
{m.tolerance != null ? `±${m.tolerance}` : '—'}
|
</td>}
|
||||||
</td>
|
{showMeasurementDetails && <td className="text-right text-muted-foreground">
|
||||||
|
{m.toleranceLabel != null
|
||||||
|
? m.toleranceLabel
|
||||||
|
: m.tolerance != null
|
||||||
|
? m.baseline != null
|
||||||
|
? `${m.tolerance}/${m.baseline}mm`
|
||||||
|
: `±${m.tolerance}`
|
||||||
|
: '—'}
|
||||||
|
</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
||||||
{m.status === 'info' ? '参考' : statusLabel(m.status)}
|
{m.status === 'info' ? '参考' : statusLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -167,13 +295,14 @@ export default function GenericResultView({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{overlay.kind !== 'none' && onViewOverlay && (
|
{overlay.kind !== 'none' && onViewOverlay && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
onClick={() => onViewOverlay({ taskId, resultId, overlay })}
|
onClick={() => onViewOverlay({ taskId, resultId, overlay, inputPointCloudFileIds, inputCadFileIds })}
|
||||||
>
|
>
|
||||||
<Box className="w-3 h-3" />
|
<Box className="w-3 h-3" />
|
||||||
在 3D 中查看叠加层
|
在 3D 中查看叠加层
|
||||||
|
|||||||
176
client/src/components/InspectionObjectConfig.tsx
Normal file
176
client/src/components/InspectionObjectConfig.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from './ui/alert-dialog';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type Props = { modelId: number };
|
||||||
|
|
||||||
|
const stateLabels = {
|
||||||
|
missing_pointcloud: '未配置点云',
|
||||||
|
missing_cad: '未配置 CAD',
|
||||||
|
ready_draft: '配置完整,未启用',
|
||||||
|
active: '已启用',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ReferenceCounts = {
|
||||||
|
taskReferenceCount: number;
|
||||||
|
pointcloudReferenceCount: number;
|
||||||
|
cadReferenceCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function referenceReason(object: ReferenceCounts) {
|
||||||
|
return [
|
||||||
|
object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null,
|
||||||
|
object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null,
|
||||||
|
object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null,
|
||||||
|
].filter(Boolean).join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionObjectConfig({ modelId }: Props) {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [pointcloudId, setPointcloudId] = useState<number | null>(null);
|
||||||
|
const [cadId, setCadId] = useState<number | null>(null);
|
||||||
|
const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open });
|
||||||
|
const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open });
|
||||||
|
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open });
|
||||||
|
const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected && objects.length > 0) setSelectedId(objects[0].id);
|
||||||
|
}, [objects, selected]);
|
||||||
|
useEffect(() => {
|
||||||
|
setPointcloudId(selected?.defaultModelPointcloudId ?? null);
|
||||||
|
setCadId(selected?.defaultModelCadFileId ?? null);
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.inspectionObject.list.invalidate({ modelId }),
|
||||||
|
utils.modelPointcloudFile.list.invalidate({ modelId }),
|
||||||
|
utils.cadFile.list.invalidate({ modelId }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
const create = trpc.inspectionObject.create.useMutation({
|
||||||
|
onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); },
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({
|
||||||
|
onSuccess: async (_file, variables) => {
|
||||||
|
await invalidate();
|
||||||
|
const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name;
|
||||||
|
toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新');
|
||||||
|
},
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({
|
||||||
|
onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); },
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const configure = trpc.inspectionObject.configureDefaults.useMutation({
|
||||||
|
onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); },
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const setStatus = trpc.inspectionObject.setStatus.useMutation({
|
||||||
|
onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); },
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const deleteObject = trpc.inspectionObject.delete.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedId(null);
|
||||||
|
await invalidate();
|
||||||
|
toast.success('检测对象已删除');
|
||||||
|
},
|
||||||
|
onError: error => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available');
|
||||||
|
const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available');
|
||||||
|
|
||||||
|
return <Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button className="industrial-btn flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-0.5 text-[10px]"><Settings2 className="h-3 w-3" /> 检测对象配置</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex max-h-[92vh] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-4 sm:max-w-4xl sm:p-6">
|
||||||
|
<DialogHeader className="shrink-0">
|
||||||
|
<DialogTitle>检测对象配置</DialogTitle>
|
||||||
|
<DialogDescription>资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="objects" className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<TabsList className="grid w-full shrink-0 grid-cols-2 sm:w-80">
|
||||||
|
<TabsTrigger value="objects">对象配置</TabsTrigger>
|
||||||
|
<TabsTrigger value="resources">资源归属</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="objects" className="min-h-0 overflow-y-auto pr-1">
|
||||||
|
<div className="grid min-w-0 gap-4 md:grid-cols-[15rem_minmax(0,1fr)]">
|
||||||
|
<aside className="min-w-0 space-y-3 rounded-lg border bg-secondary/20 p-3">
|
||||||
|
<div className="flex min-w-0 gap-1">
|
||||||
|
<input aria-label="新检测对象名称" className="min-w-0 flex-1 rounded border bg-background px-2 py-1.5 text-xs" placeholder="如:头车" value={name} onChange={event => setName(event.target.value)} />
|
||||||
|
<Button aria-label="创建检测对象" size="icon" className="h-7 w-7 shrink-0" disabled={!name.trim() || create.isPending} onClick={() => create.mutate({ modelId, name: name.trim() })}><Plus className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto md:max-h-[25rem]">
|
||||||
|
{objects.map(object => <button key={object.id} onClick={() => setSelectedId(object.id)} className={`w-full rounded border px-2 py-2 text-left text-xs ${selectedId === object.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-secondary'}`}>
|
||||||
|
<span className="flex items-center justify-between gap-2"><strong className="min-w-0 truncate" title={object.name}>{object.name}</strong>{object.configurationState === 'active' ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}</span>
|
||||||
|
<span className="mt-1 block text-[10px] text-muted-foreground">{stateLabels[object.configurationState]}</span>
|
||||||
|
</button>)}
|
||||||
|
{objects.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">尚未创建检测对象</p>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{selected ? <main className="min-w-0 space-y-4">
|
||||||
|
<section className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0"><h3 className="truncate text-sm font-medium" title={selected.name}>{selected.name}</h3><p className="text-xs text-muted-foreground">{stateLabels[selected.configurationState]}</p></div>
|
||||||
|
<span className={`rounded-full px-2 py-1 text-[10px] ${selected.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-amber-500/15 text-amber-400'}`}>{selected.status === 'active' ? '已启用' : '草稿'}</span>
|
||||||
|
</div>
|
||||||
|
<div><h3 className="text-sm font-medium">默认输入方案</h3><p className="text-xs text-muted-foreground">新建“{selected.name}”任务时自动关联并勾选以下资源。</p></div>
|
||||||
|
<label className="grid min-w-0 gap-1 text-xs"><span>预置点云</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={pointcloudId ?? ''} onChange={event => setPointcloudId(Number(event.target.value) || null)}><option value="">请选择</option>{compatiblePointclouds.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||||
|
<label className="grid min-w-0 gap-1 text-xs"><span>默认 CAD</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={cadId ?? ''} onChange={event => setCadId(Number(event.target.value) || null)}><option value="">请选择</option>{compatibleCadFiles.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||||
|
<div className="flex flex-wrap gap-2"><Button size="sm" disabled={!pointcloudId || !cadId || configure.isPending} onClick={() => configure.mutate({ id: selected.id, defaultModelPointcloudId: pointcloudId!, defaultModelCadFileId: cadId!, active: true })}>保存并启用</Button><Button size="sm" variant="outline" disabled={selected.status === 'draft' || setStatus.isPending} onClick={() => setStatus.mutate({ id: selected.id, status: 'draft' })}>停用</Button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-lg border border-destructive/30 p-4">
|
||||||
|
<div><h3 className="text-sm font-medium text-destructive">删除检测对象</h3><p className="text-xs text-muted-foreground">仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。</p></div>
|
||||||
|
{selected.canDelete ? <AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild><Button size="sm" variant="destructive"><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button></AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader><AlertDialogTitle>确认删除“{selected.name}”?</AlertDialogTitle><AlertDialogDescription>删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。</AlertDialogDescription></AlertDialogHeader>
|
||||||
|
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90" disabled={deleteObject.isPending} onClick={() => deleteObject.mutate({ id: selected.id })}>确认删除</AlertDialogAction></AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog> : <div className="space-y-1"><Button size="sm" variant="destructive" disabled><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button><p className="text-xs text-amber-500">需先处理:{referenceReason(selected)}。</p></div>}
|
||||||
|
</section>
|
||||||
|
</main> : <div className="grid min-h-48 place-items-center rounded-lg border border-dashed text-sm text-muted-foreground">请先创建检测对象</div>}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="resources" className="min-h-0 overflow-y-auto pr-1">
|
||||||
|
<section className="min-w-0 space-y-3">
|
||||||
|
<div><h3 className="text-sm font-medium">车型级资源归属</h3><p className="text-xs text-muted-foreground">每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。</p></div>
|
||||||
|
{objects.length === 0 && <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">请先在“对象配置”中创建检测对象</div>}
|
||||||
|
{objects.length > 0 && <div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||||
|
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库点云归属</h4><div className="max-h-[50vh] space-y-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
{pointclouds.map(file => <div key={file.id} className="flex min-w-0 flex-col gap-1 rounded px-1.5 py-1.5 text-xs hover:bg-secondary sm:flex-row sm:items-center sm:gap-2"><span className="min-w-0 flex-1 truncate" title={file.name}>{file.name}</span><select className={`min-w-0 rounded border bg-background px-1.5 py-1 text-[10px] outline-none focus:border-primary sm:w-32 sm:shrink-0 ${file.inspectionObjectId == null ? 'border-amber-500/50 text-amber-400' : 'border-border'}`} aria-label={`${file.name} 的归属检测对象`} value={file.inspectionObjectId ?? ''} disabled={assignPointcloud.isPending} onChange={event => { const inspectionObjectId = Number(event.target.value); if (inspectionObjectId && inspectionObjectId !== file.inspectionObjectId) assignPointcloud.mutate({ id: file.id, inspectionObjectId }); }}><option value="" disabled>未分类</option>{objects.map(object => <option key={object.id} value={object.id}>{object.name}</option>)}</select></div>)}
|
||||||
|
{pointclouds.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无点云</p>}
|
||||||
|
</div></div>
|
||||||
|
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库 CAD · 适用对象</h4><div className="max-h-[50vh] space-y-2 overflow-y-auto overflow-x-hidden">
|
||||||
|
{cadFiles.map(file => <fieldset key={file.id} className="min-w-0 rounded border p-2"><legend className="max-w-full truncate px-1 text-xs font-medium" title={file.name}>{file.name}</legend><div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return <label key={object.id} className="flex min-w-0 items-center gap-1.5 text-xs"><input type="checkbox" checked={checked} disabled={setCadObjects.isPending} onChange={() => { const next = checked ? file.inspectionObjectIds.filter(id => id !== object.id) : [...file.inspectionObjectIds, object.id]; if (next.length === 0) return toast.error('CAD 至少需要一个适用检测对象'); setCadObjects.mutate({ id: file.id, inspectionObjectIds: next }); }} /><span className="max-w-32 truncate" title={object.name}>{object.name}</span></label>; })}</div></fieldset>)}
|
||||||
|
{cadFiles.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无 CAD</p>}
|
||||||
|
</div></div>
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
|
|||||||
function getHeatmapLegendBounds(
|
function getHeatmapLegendBounds(
|
||||||
points: Point[],
|
points: Point[],
|
||||||
mode: 'points' | 'smoothHeatmap',
|
mode: 'points' | 'smoothHeatmap',
|
||||||
): { min: number; max: number; midTick: string } {
|
): { min: number; max: number; midTick: string } | null {
|
||||||
if (mode === 'smoothHeatmap') {
|
if (mode === 'smoothHeatmap') {
|
||||||
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
|
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
|
||||||
|
if (pointsWithError.length === 0) return null;
|
||||||
|
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
const midTick =
|
const midTick =
|
||||||
minError < 0 && maxError > 0
|
minError < 0 && maxError > 0
|
||||||
? '0'
|
? '0'
|
||||||
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
|
|||||||
let minE = Infinity;
|
let minE = Infinity;
|
||||||
let maxE = -Infinity;
|
let maxE = -Infinity;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
const e = points[i].error ?? 0;
|
const e = points[i].error;
|
||||||
|
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
|
||||||
if (e < minE) minE = e;
|
if (e < minE) minE = e;
|
||||||
if (e > maxE) maxE = e;
|
if (e > maxE) maxE = e;
|
||||||
}
|
}
|
||||||
|
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
|
||||||
if (!(maxE > minE)) {
|
if (!(maxE > minE)) {
|
||||||
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
||||||
}
|
}
|
||||||
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
|
|
||||||
const positions = new Float32Array(data.points.length * 3);
|
const positions = new Float32Array(data.points.length * 3);
|
||||||
const colors = new Float32Array(data.points.length * 3);
|
const colors = new Float32Array(data.points.length * 3);
|
||||||
|
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
|
||||||
|
const hasErrorValues = pointsWithError.length > 0;
|
||||||
|
|
||||||
let minError: number;
|
let minError: number;
|
||||||
let maxError: number;
|
let maxError: number;
|
||||||
if (errorRangeMode === 'percentile') {
|
if (errorRangeMode === 'percentile' && hasErrorValues) {
|
||||||
const r = displayErrorRangeSmoothHeatmap(data.points);
|
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
minError = r.minError;
|
minError = r.minError;
|
||||||
maxError = r.maxError;
|
maxError = r.maxError;
|
||||||
} else {
|
} else {
|
||||||
minError = Infinity;
|
minError = Infinity;
|
||||||
maxError = -Infinity;
|
maxError = -Infinity;
|
||||||
for (let i = 0; i < data.points.length; i++) {
|
for (let i = 0; i < data.points.length; i++) {
|
||||||
const err = data.points[i].error ?? 0;
|
const err = data.points[i].error;
|
||||||
|
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
|
||||||
if (err < minError) minError = err;
|
if (err < minError) minError = err;
|
||||||
if (err > maxError) maxError = err;
|
if (err > maxError) maxError = err;
|
||||||
}
|
}
|
||||||
@@ -415,8 +422,13 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
positions[i3 + 1] = point.y;
|
positions[i3 + 1] = point.y;
|
||||||
positions[i3 + 2] = point.z;
|
positions[i3 + 2] = point.z;
|
||||||
|
|
||||||
if (showHeatmap) {
|
if (point.r !== undefined && point.g !== undefined && point.b !== undefined) {
|
||||||
const color = errorToColor(point.error ?? 0, minError, maxError);
|
// 点云自带颜色(如平整度热力图)直接使用,不走 errorToColor
|
||||||
|
colors[i3] = point.r;
|
||||||
|
colors[i3 + 1] = point.g;
|
||||||
|
colors[i3 + 2] = point.b;
|
||||||
|
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
|
||||||
|
const color = errorToColor(point.error!, minError, maxError);
|
||||||
colors[i3] = color.r;
|
colors[i3] = color.r;
|
||||||
colors[i3 + 1] = color.g;
|
colors[i3 + 1] = color.g;
|
||||||
colors[i3 + 2] = color.b;
|
colors[i3 + 2] = color.b;
|
||||||
@@ -1018,7 +1030,7 @@ const CADModel = memo(function CADModel({
|
|||||||
}
|
}
|
||||||
}, [useVertexColors, geometry]);
|
}, [useVertexColors, geometry]);
|
||||||
|
|
||||||
if (!visible && !useVertexColors) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
if (!geometry && !loading) return null;
|
if (!geometry && !loading) return null;
|
||||||
|
|
||||||
@@ -1160,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一键全流程演示扫描动画
|
||||||
|
interface DemoFlowScannerProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
|
||||||
|
const ringRef = useRef<THREE.Mesh>(null);
|
||||||
|
const sweepRef = useRef<THREE.Mesh>(null);
|
||||||
|
const particlesRef = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
|
||||||
|
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
|
||||||
|
|
||||||
|
const { positions, colors } = useMemo(() => {
|
||||||
|
const count = 120;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
|
||||||
|
positions[i * 3] = Math.cos(angle) * radius;
|
||||||
|
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
|
||||||
|
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
colors[i * 3] = 0.2;
|
||||||
|
colors[i * 3 + 1] = 0.75;
|
||||||
|
colors[i * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
|
return { positions, colors };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!active) return;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
if (ringRef.current) {
|
||||||
|
ringRef.current.rotation.z = -t * 0.3;
|
||||||
|
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
|
||||||
|
}
|
||||||
|
if (sweepRef.current) {
|
||||||
|
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
|
||||||
|
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
|
||||||
|
}
|
||||||
|
if (particlesRef.current) {
|
||||||
|
particlesRef.current.rotation.y = t * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* 旋转圆环 */}
|
||||||
|
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<ringGeometry args={[3.5, 3.7, 96]} />
|
||||||
|
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 扫描平面 */}
|
||||||
|
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<planeGeometry args={[7.6, 7.6, 48, 48]} />
|
||||||
|
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 粒子环 */}
|
||||||
|
<points ref={particlesRef}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||||
|
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
|
||||||
|
</points>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
||||||
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
||||||
|
|
||||||
@@ -1173,6 +1261,7 @@ interface SceneContentProps {
|
|||||||
pointCloudData?: PointCloudData | null;
|
pointCloudData?: PointCloudData | null;
|
||||||
pointSize: number;
|
pointSize: number;
|
||||||
showHeatmap: boolean;
|
showHeatmap: boolean;
|
||||||
|
showPointCloud: boolean;
|
||||||
pointCloudRenderMode: PointCloudRenderMode;
|
pointCloudRenderMode: PointCloudRenderMode;
|
||||||
enablePointPicking: boolean;
|
enablePointPicking: boolean;
|
||||||
showCADModel: boolean;
|
showCADModel: boolean;
|
||||||
@@ -1196,12 +1285,15 @@ interface SceneContentProps {
|
|||||||
/** ROI 坐标拾取模式 */
|
/** ROI 坐标拾取模式 */
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SceneContent = memo(function SceneContent({
|
const SceneContent = memo(function SceneContent({
|
||||||
pointCloudData,
|
pointCloudData,
|
||||||
pointSize,
|
pointSize,
|
||||||
showHeatmap,
|
showHeatmap,
|
||||||
|
showPointCloud,
|
||||||
pointCloudRenderMode,
|
pointCloudRenderMode,
|
||||||
enablePointPicking,
|
enablePointPicking,
|
||||||
showCADModel,
|
showCADModel,
|
||||||
@@ -1221,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
plyOverlayData = null,
|
plyOverlayData = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: SceneContentProps) {
|
}: SceneContentProps) {
|
||||||
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
||||||
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||||
@@ -1248,9 +1341,15 @@ const SceneContent = memo(function SceneContent({
|
|||||||
const cadShellHeatmapOn =
|
const cadShellHeatmapOn =
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
Boolean(pointCloudData && cadSurfaceGeometry) &&
|
Boolean(pointCloudData && cadSurfaceGeometry) &&
|
||||||
|
showCADModel &&
|
||||||
!cadHeatmapSuppressClient &&
|
!cadHeatmapSuppressClient &&
|
||||||
!useDirectColoring;
|
!useDirectColoring;
|
||||||
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring;
|
const showCadReferenceMesh = showCADModel && !cadShellHeatmapOn;
|
||||||
|
const showSmoothPointFallback =
|
||||||
|
pointCloudData &&
|
||||||
|
showPointCloud &&
|
||||||
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
|
(!cadSurfaceGeometry || cadHeatmapSuppressClient || !showCADModel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1287,7 +1386,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{!lightweightScene && <AmbientParticles />}
|
{!lightweightScene && <AmbientParticles />}
|
||||||
|
|
||||||
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
|
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
|
||||||
{pointCloudData && pointCloudRenderMode === 'points' && (
|
{pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
|
||||||
<PointCloudMesh
|
<PointCloudMesh
|
||||||
data={pointCloudData}
|
data={pointCloudData}
|
||||||
pointSize={pointSize}
|
pointSize={pointSize}
|
||||||
@@ -1300,6 +1399,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{pointCloudData &&
|
{pointCloudData &&
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
cadSurfaceGeometry &&
|
cadSurfaceGeometry &&
|
||||||
|
showCADModel &&
|
||||||
!cadHeatmapSuppressClient &&
|
!cadHeatmapSuppressClient &&
|
||||||
!useDirectColoring && (
|
!useDirectColoring && (
|
||||||
<CadSurfaceHeatmapMesh
|
<CadSurfaceHeatmapMesh
|
||||||
@@ -1309,34 +1409,23 @@ const SceneContent = memo(function SceneContent({
|
|||||||
showWireframe={showWireframe}
|
showWireframe={showWireframe}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pointCloudData &&
|
{showSmoothPointFallback && (
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
|
||||||
cadSurfaceGeometry &&
|
|
||||||
cadHeatmapSuppressClient && (
|
|
||||||
<PointCloudMesh
|
|
||||||
data={pointCloudData}
|
|
||||||
pointSize={Math.min(pointSize * 2.8, 0.12)}
|
|
||||||
showHeatmap={showHeatmap}
|
|
||||||
enablePointPicking={false}
|
|
||||||
errorRangeMode="percentile"
|
|
||||||
pointOpacity={showHeatmap ? 0.38 : 0.55}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && !cadSurfaceGeometry && (
|
|
||||||
<>
|
<>
|
||||||
<PointCloudMesh
|
<PointCloudMesh
|
||||||
data={pointCloudData}
|
data={pointCloudData}
|
||||||
pointSize={Math.min(pointSize * 3.2, 0.14)}
|
pointSize={Math.min(pointSize * (cadSurfaceGeometry ? 2.8 : 3.2), cadSurfaceGeometry ? 0.12 : 0.14)}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
enablePointPicking={false}
|
enablePointPicking={false}
|
||||||
errorRangeMode="percentile"
|
errorRangeMode="percentile"
|
||||||
pointOpacity={showHeatmap ? 0.42 : 0.6}
|
pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
|
||||||
/>
|
/>
|
||||||
|
{!cadSurfaceGeometry && (
|
||||||
<SmoothHeatmapMesh
|
<SmoothHeatmapMesh
|
||||||
data={pointCloudData}
|
data={pointCloudData}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
showWireframe={showWireframe}
|
showWireframe={showWireframe}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1375,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<IndustrialGrid />
|
<IndustrialGrid />
|
||||||
|
|
||||||
|
{/* 一键全流程演示扫描动画 */}
|
||||||
|
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
|
||||||
|
|
||||||
{/* PLY 叠加层(kind=ply 插件结果) */}
|
{/* PLY 叠加层(kind=ply 插件结果) */}
|
||||||
{plyOverlayData && (
|
{plyOverlayData && (
|
||||||
<points>
|
<points>
|
||||||
@@ -1389,7 +1481,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
/>
|
/>
|
||||||
</bufferGeometry>
|
</bufferGeometry>
|
||||||
<pointsMaterial
|
<pointsMaterial
|
||||||
size={0.04}
|
size={pointSize ?? 0.03}
|
||||||
vertexColors
|
vertexColors
|
||||||
sizeAttenuation
|
sizeAttenuation
|
||||||
transparent
|
transparent
|
||||||
@@ -1403,6 +1495,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
return prevProps.pointCloudData === nextProps.pointCloudData &&
|
return prevProps.pointCloudData === nextProps.pointCloudData &&
|
||||||
prevProps.pointSize === nextProps.pointSize &&
|
prevProps.pointSize === nextProps.pointSize &&
|
||||||
prevProps.showHeatmap === nextProps.showHeatmap &&
|
prevProps.showHeatmap === nextProps.showHeatmap &&
|
||||||
|
prevProps.showPointCloud === nextProps.showPointCloud &&
|
||||||
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
|
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
|
||||||
prevProps.enablePointPicking === nextProps.enablePointPicking &&
|
prevProps.enablePointPicking === nextProps.enablePointPicking &&
|
||||||
prevProps.showCADModel === nextProps.showCADModel &&
|
prevProps.showCADModel === nextProps.showCADModel &&
|
||||||
@@ -1414,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
|
|||||||
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
||||||
prevProps.dispRange === nextProps.dispRange &&
|
prevProps.dispRange === nextProps.dispRange &&
|
||||||
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
||||||
prevProps.roiPickMode === nextProps.roiPickMode;
|
prevProps.roiPickMode === nextProps.roiPickMode &&
|
||||||
|
prevProps.demoFlowActive === nextProps.demoFlowActive;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
||||||
@@ -1529,11 +1623,14 @@ async function fetchBatchSampled(
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 接收二进制,直接解析为 Float32Array,无 JSON 开销
|
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
||||||
|
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||||
|
const hasError = resp.headers.get('X-Has-Error') === '1';
|
||||||
|
const stride = hasColor ? 7 : 4;
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
const floats = new Float32Array(arrayBuffer);
|
const floats = new Float32Array(arrayBuffer);
|
||||||
const count = Math.floor(floats.length / 4);
|
const count = Math.floor(floats.length / stride);
|
||||||
console.log(`[PointCloudViewer] Binary received: ${count} pts, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
|
console.log(`[PointCloudViewer] Binary received: ${count} pts, hasColor=${hasColor}, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
|
||||||
|
|
||||||
if (count === 0) return { points: [] };
|
if (count === 0) return { points: [] };
|
||||||
|
|
||||||
@@ -1543,9 +1640,16 @@ async function fetchBatchSampled(
|
|||||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const base = i * 4;
|
const base = i * stride;
|
||||||
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
||||||
pts[i] = { x, y, z, error };
|
const pt: Point = { x, y, z };
|
||||||
|
if (hasError && Number.isFinite(error)) pt.error = error;
|
||||||
|
if (hasColor) {
|
||||||
|
pt.r = floats[base + 4];
|
||||||
|
pt.g = floats[base + 5];
|
||||||
|
pt.b = floats[base + 6];
|
||||||
|
}
|
||||||
|
pts[i] = pt;
|
||||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||||
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
||||||
@@ -1565,6 +1669,7 @@ interface PointCloudViewerProps {
|
|||||||
*/
|
*/
|
||||||
loadKey?: number;
|
loadKey?: number;
|
||||||
showHeatmap?: boolean;
|
showHeatmap?: boolean;
|
||||||
|
showPointCloud?: boolean;
|
||||||
showCADModel?: boolean;
|
showCADModel?: boolean;
|
||||||
showWireframe?: boolean;
|
showWireframe?: boolean;
|
||||||
pointSize?: number;
|
pointSize?: number;
|
||||||
@@ -1601,7 +1706,7 @@ interface PointCloudViewerProps {
|
|||||||
overlay:
|
overlay:
|
||||||
| { kind: 'none' }
|
| { kind: 'none' }
|
||||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||||
| { kind: 'ply'; file: string };
|
| { kind: 'ply'; file: string; files?: string[] };
|
||||||
} | null;
|
} | null;
|
||||||
/**
|
/**
|
||||||
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
|
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
|
||||||
@@ -1609,6 +1714,8 @@ interface PointCloudViewerProps {
|
|||||||
*/
|
*/
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PointCloudViewer({
|
export default function PointCloudViewer({
|
||||||
@@ -1617,6 +1724,7 @@ export default function PointCloudViewer({
|
|||||||
batchTaskId,
|
batchTaskId,
|
||||||
loadKey,
|
loadKey,
|
||||||
showHeatmap = true,
|
showHeatmap = true,
|
||||||
|
showPointCloud = true,
|
||||||
showCADModel = true,
|
showCADModel = true,
|
||||||
showWireframe = false,
|
showWireframe = false,
|
||||||
pointSize = 0.03,
|
pointSize = 0.03,
|
||||||
@@ -1637,6 +1745,7 @@ export default function PointCloudViewer({
|
|||||||
pluginOverlay = null,
|
pluginOverlay = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: PointCloudViewerProps) {
|
}: PointCloudViewerProps) {
|
||||||
// 点云数据状态
|
// 点云数据状态
|
||||||
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
||||||
@@ -1700,6 +1809,9 @@ export default function PointCloudViewer({
|
|||||||
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
|
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
|
||||||
const scale = maxDim > 0 ? 10 / maxDim : 1;
|
const scale = maxDim > 0 ? 10 / maxDim : 1;
|
||||||
|
|
||||||
|
// 保存变换参数,供 ply 叠加层对齐用
|
||||||
|
pcTransformRef.current = { cx: centerX, cy: centerY, cz: centerZ, scale };
|
||||||
|
|
||||||
for (let i = 0; i < pts.length; i++) {
|
for (let i = 0; i < pts.length; i++) {
|
||||||
const p = pts[i];
|
const p = pts[i];
|
||||||
p.x = (p.x - centerX) * scale;
|
p.x = (p.x - centerX) * scale;
|
||||||
@@ -1753,30 +1865,110 @@ export default function PointCloudViewer({
|
|||||||
|
|
||||||
// PLY 叠加层状态(kind=ply)
|
// PLY 叠加层状态(kind=ply)
|
||||||
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
|
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
|
||||||
|
const [plyOverlayStatus, setPlyOverlayStatus] = useState<{ status: 'idle' | 'loading' | 'ready' | 'error'; message?: string }>({ status: 'idle' });
|
||||||
|
// 主点云的坐标变换参数,供 ply 叠加层对齐用
|
||||||
|
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
|
||||||
|
|
||||||
// 加载 PLY 叠加层
|
// 加载 PLY 叠加层:走采样接口,避免浏览器加载 15GB 文件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
|
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
|
||||||
setPlyOverlayData(null);
|
setPlyOverlayData(null);
|
||||||
|
setPlyOverlayStatus({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = pcTransformRef.current;
|
||||||
|
if (!t || !pointCloudData?.points?.length) {
|
||||||
|
setPlyOverlayData(null);
|
||||||
|
setPlyOverlayStatus({
|
||||||
|
status: 'error',
|
||||||
|
message: '请先加载本算法结果对应的点云文件,再查看 PLY 叠加层',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { taskId, resultId, overlay } = pluginOverlay;
|
const { taskId, resultId, overlay } = pluginOverlay;
|
||||||
const url = `/api/files/read/analysis/${taskId}/${resultId}/${overlay.file}`;
|
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { signal: ac.signal });
|
// 通过受限的分析结果接口加载相对 PLY 路径,支持多文件合并采样
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
|
||||||
const text = await resp.text();
|
? overlay.files
|
||||||
const parsed = parsePlyText(text);
|
: [overlay.file];
|
||||||
if (parsed) setPlyOverlayData(parsed);
|
const resp = await fetch('/api/files/sample/analysis', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: ac.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskId,
|
||||||
|
resultId,
|
||||||
|
files: plyFiles,
|
||||||
|
totalPoints: 300_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const payload = await resp.json().catch(() => null) as { error?: string } | null;
|
||||||
|
const detail = payload?.error;
|
||||||
|
if (resp.status === 404) {
|
||||||
|
throw new Error(detail ?? '分析结果或 PLY 文件不存在');
|
||||||
|
}
|
||||||
|
if (resp.status === 400 || resp.status === 403) {
|
||||||
|
throw new Error(detail ?? 'PLY 文件参数或访问权限无效');
|
||||||
|
}
|
||||||
|
throw new Error(detail ?? `服务端采样失败(HTTP ${resp.status})`);
|
||||||
|
}
|
||||||
|
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||||
|
const stride = hasColor ? 7 : 4;
|
||||||
|
const buf = await resp.arrayBuffer();
|
||||||
|
const floats = new Float32Array(buf);
|
||||||
|
const count = Math.floor(floats.length / stride);
|
||||||
|
if (count === 0) {
|
||||||
|
setPlyOverlayStatus({ status: 'error', message: 'PLY 叠加层没有可渲染点' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
if (hasColor) {
|
||||||
|
// stride=7: [x,y,z,error,r,g,b] — 用批量提取代替逐点循环
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const s = i * 7;
|
||||||
|
positions[i * 3] = floats[s];
|
||||||
|
positions[i * 3 + 1] = floats[s + 1];
|
||||||
|
positions[i * 3 + 2] = floats[s + 2];
|
||||||
|
colors[i * 3] = floats[s + 4];
|
||||||
|
colors[i * 3 + 1] = floats[s + 5];
|
||||||
|
colors[i * 3 + 2] = floats[s + 6];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// stride=4: [x,y,z,error] — 只提取 xyz,颜色统一蓝色
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const s = i * 4;
|
||||||
|
positions[i * 3] = floats[s];
|
||||||
|
positions[i * 3 + 1] = floats[s + 1];
|
||||||
|
positions[i * 3 + 2] = floats[s + 2];
|
||||||
|
colors[i * 3] = 0.4; colors[i * 3 + 1] = 0.8; colors[i * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 用主点云的坐标变换对齐叠加层坐标
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
|
||||||
|
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
|
||||||
|
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
|
||||||
|
}
|
||||||
|
setPlyOverlayData({ positions, colors });
|
||||||
|
setPlyOverlayStatus({ status: 'ready' });
|
||||||
|
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
console.warn('[PlyOverlay] load failed:', e);
|
console.warn('[PlyOverlay] load failed:', e);
|
||||||
|
setPlyOverlayData(null);
|
||||||
|
setPlyOverlayStatus({
|
||||||
|
status: 'error',
|
||||||
|
message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [pluginOverlay]);
|
}, [pluginOverlay, pointCloudData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
|
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
|
||||||
@@ -1881,16 +2073,22 @@ export default function PointCloudViewer({
|
|||||||
|
|
||||||
const heatmapLegendBounds = useMemo(() => {
|
const heatmapLegendBounds = useMemo(() => {
|
||||||
if (!showHeatmap) return null;
|
if (!showHeatmap) return null;
|
||||||
|
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
|
||||||
|
const hasVisibleCadHeatmap =
|
||||||
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
|
serverCadHeatmap.status === 'ready' &&
|
||||||
|
showCADModel;
|
||||||
const pts = pointCloudData?.points;
|
const pts = pointCloudData?.points;
|
||||||
if (!pts?.length) return null;
|
if (!hasVisibleCadHeatmap && (!showPointCloud || !pts?.length)) return null;
|
||||||
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
|
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
|
||||||
const lo = serverCadHeatmap.dispMin;
|
const lo = serverCadHeatmap.dispMin;
|
||||||
const hi = serverCadHeatmap.dispMax;
|
const hi = serverCadHeatmap.dispMax;
|
||||||
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
|
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
|
||||||
return { min: lo, max: hi, midTick };
|
return { min: lo, max: hi, midTick };
|
||||||
}
|
}
|
||||||
|
if (!pts?.length) return null;
|
||||||
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
|
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
|
||||||
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
}, [showHeatmap, showPointCloud, showCADModel, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
|
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
|
||||||
@@ -1944,6 +2142,29 @@ export default function PointCloudViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pluginOverlay?.overlay.kind === 'ply' && plyOverlayStatus.status !== 'idle' && plyOverlayStatus.status !== 'ready' && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-3 shadow-lg backdrop-blur-sm flex items-center gap-3 max-w-sm">
|
||||||
|
{plyOverlayStatus.status === 'loading' && (
|
||||||
|
<svg className="animate-spin h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-foreground/90 font-medium">
|
||||||
|
{plyOverlayStatus.status === 'error' ? 'PLY 叠加层不可用' : '正在加载 PLY 叠加层…'}
|
||||||
|
</div>
|
||||||
|
{plyOverlayStatus.message && (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{plyOverlayStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 热力图点击拾取信息 */}
|
{/* 热力图点击拾取信息 */}
|
||||||
{heatmapPick && (
|
{heatmapPick && (
|
||||||
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
|
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
|
||||||
@@ -1989,6 +2210,7 @@ export default function PointCloudViewer({
|
|||||||
pointCloudData={pointCloudData}
|
pointCloudData={pointCloudData}
|
||||||
pointSize={pointSize}
|
pointSize={pointSize}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
|
showPointCloud={showPointCloud}
|
||||||
pointCloudRenderMode={pointCloudRenderMode}
|
pointCloudRenderMode={pointCloudRenderMode}
|
||||||
enablePointPicking={enableErrorPicking}
|
enablePointPicking={enableErrorPicking}
|
||||||
showCADModel={showCADModel}
|
showCADModel={showCADModel}
|
||||||
@@ -2008,6 +2230,7 @@ export default function PointCloudViewer({
|
|||||||
plyOverlayData={plyOverlayData}
|
plyOverlayData={plyOverlayData}
|
||||||
roiPickMode={roiPickMode}
|
roiPickMode={roiPickMode}
|
||||||
onRoiPointPicked={onRoiPointPicked}
|
onRoiPointPicked={onRoiPointPicked}
|
||||||
|
demoFlowActive={demoFlowActive}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
|
|
||||||
export type TreeSelection = {
|
export type TreeSelection = {
|
||||||
seriesId: number | null;
|
seriesId: number | null;
|
||||||
@@ -25,9 +26,11 @@ interface Props {
|
|||||||
function EditableText({
|
function EditableText({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
|
canEdit: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
@@ -35,7 +38,7 @@ function EditableText({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
||||||
onDoubleClick={() => { setDraft(value); setEditing(true); }}
|
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,6 +63,7 @@ function EditableText({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleTree({ selection, onSelect }: Props) {
|
export default function VehicleTree({ selection, onSelect }: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
||||||
const [newSeriesName, setNewSeriesName] = useState('');
|
const [newSeriesName, setNewSeriesName] = useState('');
|
||||||
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<div className="industrial-panel p-2 space-y-1">
|
<div className="industrial-panel p-2 space-y-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="industrial-label">车系 / 车型 / 任务</span>
|
<span className="industrial-label">车系 / 车型 / 任务</span>
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => setAddingSeries(true)}
|
onClick={() => setAddingSeries(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" /> 车系
|
<Plus className="w-3 h-3" /> 车系
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addingSeries && (
|
{canWrite && addingSeries && (
|
||||||
<div className="flex items-center gap-1 pl-1">
|
<div className="flex items-center gap-1 pl-1">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -167,18 +171,16 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
|
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'}`}
|
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
|
||||||
onClick={() => {
|
onClick={() => toggleSeries(series.id)}
|
||||||
toggleSeries(series.id);
|
|
||||||
onSelect({ seriesId: series.id, modelId: null, taskId: null });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{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" />}
|
{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" />
|
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={series.name}
|
value={series.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary"
|
className="p-0.5 rounded hover:bg-secondary"
|
||||||
title="新增车型"
|
title="新增车型"
|
||||||
@@ -196,11 +198,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新增车型输入 */}
|
{/* 新增车型输入 */}
|
||||||
{addingModelFor === series.id && (
|
{canWrite && addingModelFor === series.id && (
|
||||||
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -258,9 +260,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={model.name}
|
value={model.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateModel.mutate({ id: model.id, name })}
|
onSave={name => updateModel.mutate({ id: model.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除车型"
|
title="删除车型"
|
||||||
@@ -271,7 +274,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +286,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
|
|
||||||
{seriesList.length === 0 && (
|
{seriesList.length === 0 && (
|
||||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||||
暂无车系,点击右上角新增
|
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { type ElementType } from 'react';
|
|
||||||
import { CheckCircle2, Loader2, FolderOpen, Cloud, Box, BarChart3, Save } from 'lucide-react';
|
|
||||||
|
|
||||||
export type WorkflowStepId = 'cad' | 'pointcloud' | 'render' | 'analysis' | 'save';
|
|
||||||
|
|
||||||
export type StepStatus = 'done' | 'active' | 'pending';
|
|
||||||
|
|
||||||
export interface WorkflowState {
|
|
||||||
cad: StepStatus;
|
|
||||||
pointcloud: StepStatus;
|
|
||||||
render: StepStatus;
|
|
||||||
analysis: StepStatus;
|
|
||||||
save: StepStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepDef {
|
|
||||||
id: WorkflowStepId;
|
|
||||||
label: string;
|
|
||||||
icon: ElementType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEPS: StepDef[] = [
|
|
||||||
{ id: 'cad', label: 'CAD 模型', icon: Box },
|
|
||||||
{ id: 'pointcloud', label: '点云采集', icon: Cloud },
|
|
||||||
{ id: 'render', label: '渲染完成', icon: FolderOpen },
|
|
||||||
{ id: 'analysis', label: '分析完成', icon: BarChart3 },
|
|
||||||
{ id: 'save', label: '保存完成', icon: Save },
|
|
||||||
];
|
|
||||||
|
|
||||||
function StepNode({
|
|
||||||
step,
|
|
||||||
status,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
step: StepDef;
|
|
||||||
status: StepStatus;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
const Icon = step.icon;
|
|
||||||
|
|
||||||
const circleClass =
|
|
||||||
status === 'done'
|
|
||||||
? 'bg-[oklch(0.55_0.18_145)] border-[oklch(0.55_0.18_145)] text-white'
|
|
||||||
: status === 'active'
|
|
||||||
? 'bg-primary/20 border-primary text-primary'
|
|
||||||
: 'bg-transparent border-border text-muted-foreground/40';
|
|
||||||
|
|
||||||
const labelClass =
|
|
||||||
status === 'done'
|
|
||||||
? 'text-[oklch(0.75_0.14_145)]'
|
|
||||||
: status === 'active'
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-muted-foreground/40';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={!onClick || status === 'pending'}
|
|
||||||
className={`flex flex-col items-center gap-1 transition-opacity
|
|
||||||
${status === 'pending' ? 'cursor-default' : 'cursor-pointer hover:opacity-80'}`}
|
|
||||||
>
|
|
||||||
<div className={`w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all ${circleClass}`}>
|
|
||||||
{status === 'done' ? (
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
) : status === 'active' ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] leading-tight whitespace-nowrap transition-colors ${labelClass}`}>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Connector({ done }: { done: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center pb-4 px-1 min-w-[24px]">
|
|
||||||
<div className={`h-px w-full transition-colors duration-500 ${done ? 'bg-[oklch(0.55_0.18_145)]' : 'bg-border'}`} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
state: WorkflowState;
|
|
||||||
onStepClick?: (id: WorkflowStepId) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkflowProgress({ state, onStepClick }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="h-14 border-b border-border bg-card/60 flex items-center px-6 flex-shrink-0">
|
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider mr-4 flex-shrink-0">
|
|
||||||
工作流程
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
|
||||||
{STEPS.map((step, i) => (
|
|
||||||
<div key={step.id} className="flex items-center flex-1 min-w-0">
|
|
||||||
<StepNode
|
|
||||||
step={step}
|
|
||||||
status={state[step.id]}
|
|
||||||
onClick={onStepClick ? () => onStepClick(step.id) : undefined}
|
|
||||||
/>
|
|
||||||
{i < STEPS.length - 1 && <Connector done={state[step.id] === 'done'} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
173
client/src/components/demoFlowAggregation.test.ts
Normal file
173
client/src/components/demoFlowAggregation.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||||
|
|
||||||
|
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
|
||||||
|
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameOf = (item: DemoFlowSourceResult) => `算法${item.id}`;
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowResults', () => {
|
||||||
|
it('按全流程顺序合并测量项,并保留来源和算法内顺序', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B1', value: 2 }, { name: 'B2', value: 3 }] }),
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A1', value: 1 }] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => [row.measurement.name, row.resultId, row.flowIndex, row.measurementIndex])).toEqual([
|
||||||
|
['A1', 10, 1, 0],
|
||||||
|
['B1', 20, 2, 0],
|
||||||
|
['B2', 20, 2, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原样保留空值、公差文本、基准长度和自定义结论文案,并忽略空测量', () => {
|
||||||
|
const measurement = {
|
||||||
|
name: '斜率', value: null, unit: 'mm', tolerance: 0.5, toleranceLabel: '+8.5/-3', baseline: 1000,
|
||||||
|
status: 'warning' as const, passLabel: '达标', warningLabel: '复核', failLabel: '未达标',
|
||||||
|
};
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [measurement] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows).toHaveLength(1);
|
||||||
|
expect(model.measurementRows[0].measurement).toEqual(measurement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认不生成算法分组行,开启后按算法生成分组', () => {
|
||||||
|
const inputs = [
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A', value: 1 }] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B', value: 2 }] }),
|
||||||
|
];
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf).tableRows.every((row) => row.kind === 'measurement')).toBe(true);
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf, { includeAlgorithmGroups: true }).tableRows.map((row) => row.kind)).toEqual([
|
||||||
|
'algorithm', 'measurement', 'algorithm', 'measurement',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过滤参考项,同时保留其他状态和未设置状态的测量项', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '参考项', value: 0.1, status: 'info' },
|
||||||
|
{ name: '合格项', value: 1, status: 'pass' },
|
||||||
|
{ name: '警告项', value: 2, status: 'warning' },
|
||||||
|
{ name: '失败项', value: 3, status: 'fail' },
|
||||||
|
{ name: '无状态项', value: 4 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||||
|
'合格项', '警告项', '失败项', '无状态项',
|
||||||
|
]);
|
||||||
|
expect(model.tableRows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过滤全流程指定的中间指标,同时保持其他有效测量项的原顺序', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '车体长度', value: 100, status: 'pass' },
|
||||||
|
{ name: '前后端平移差', value: 0.1, status: 'pass' },
|
||||||
|
{ name: '车体宽度', value: 20, status: 'warning' },
|
||||||
|
{ name: 'ICP fitness', value: 0.98, status: 'pass' },
|
||||||
|
{ name: '配准 RMSE', value: 0.02, status: 'pass' },
|
||||||
|
{ name: '车体高度', value: 30, status: 'fail' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||||
|
'车体长度', '车体宽度', '车体高度',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('警告');
|
||||||
|
expect(demoFlowStatusLabel({ status: 'fail' })).toBe('超差');
|
||||||
|
expect(demoFlowStatusLabel({ status: 'info' })).toBe('参考');
|
||||||
|
expect(demoFlowStatusLabel({})).toBe('参考');
|
||||||
|
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '通过项', value: 1, status: 'pass', passLabel: '通过' },
|
||||||
|
{ name: '警告项', value: 2, status: 'warning', warningLabel: '部分成功' },
|
||||||
|
{ name: '失败项', value: 3, status: 'fail', failLabel: '失败' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => demoFlowStatusLabel(row.measurement))).toEqual([
|
||||||
|
'合格', '警告', '超差',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '配准误差 p95', value: 0.1, status: 'info' },
|
||||||
|
{ name: '配准误差均值', value: 0.05, status: 'info' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf, { includeAlgorithmGroups: true });
|
||||||
|
|
||||||
|
expect(model.measurementRows).toEqual([]);
|
||||||
|
expect(model.tableRows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
|
||||||
|
}),
|
||||||
|
result(20, 2, 'completed', {
|
||||||
|
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.artifactGroups).toMatchObject([
|
||||||
|
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
|
||||||
|
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
|
||||||
|
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
|
||||||
|
result(30, 3, 'pending', { stage: '等待资源' }),
|
||||||
|
result(40, 4, 'error', { error: '输入无效' }),
|
||||||
|
result(50, 5, 'cancelled', {}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
|
||||||
|
expect(model.statusNotices).toMatchObject([
|
||||||
|
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
|
||||||
|
{ resultId: 30, status: 'pending', detail: '等待资源' },
|
||||||
|
{ resultId: 40, status: 'error', detail: '输入无效' },
|
||||||
|
{ resultId: 50, status: 'cancelled' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
197
client/src/components/demoFlowAggregation.ts
Normal file
197
client/src/components/demoFlowAggregation.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
||||||
|
|
||||||
|
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
|
||||||
|
'前后端平移差',
|
||||||
|
'ICP fitness',
|
||||||
|
'配准 RMSE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DemoFlowSourceResult = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
measurementIndex: number;
|
||||||
|
measurement: Measurement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementGroupRow = {
|
||||||
|
kind: 'algorithm';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowTableRow = DemoFlowMeasurementRow | DemoFlowMeasurementGroupRow;
|
||||||
|
|
||||||
|
export type DemoFlowArtifactGroup = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
files: { label: string; path: string }[];
|
||||||
|
images: string[];
|
||||||
|
overlay: PluginResultLike['overlay3d'];
|
||||||
|
inputPointCloudFileIds: unknown;
|
||||||
|
inputCadFileIds: unknown;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowStatusNotice = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowAggregation = {
|
||||||
|
tableRows: DemoFlowTableRow[];
|
||||||
|
measurementRows: DemoFlowMeasurementRow[];
|
||||||
|
artifactGroups: DemoFlowArtifactGroup[];
|
||||||
|
statusNotices: DemoFlowStatusNotice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowIndex(result: DemoFlowSourceResult, fallback: number) {
|
||||||
|
return finiteNumber(asRecord(result.resultData)?.demoFlowIndex) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFiles(value: unknown): { label: string; path: string }[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
if (typeof item === 'string' && item.length > 0) return [{ label: item, path: item }];
|
||||||
|
const record = asRecord(item);
|
||||||
|
if (!record) return [];
|
||||||
|
const path = typeof record.path === 'string'
|
||||||
|
? record.path
|
||||||
|
: typeof record.name === 'string'
|
||||||
|
? record.name
|
||||||
|
: undefined;
|
||||||
|
if (!path) return [];
|
||||||
|
const label = typeof record.name === 'string' ? record.name : path;
|
||||||
|
return [{ label, path }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImages(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOverlay(value: PluginResultLike['overlay3d']) {
|
||||||
|
return Boolean(value && value.kind !== 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDemoFlowMeasurements(measurements: readonly Measurement[]): Measurement[] {
|
||||||
|
return filterVisibleMeasurements(measurements)
|
||||||
|
.filter((measurement) => !DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES.has(measurement.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): string {
|
||||||
|
switch (measurement.status) {
|
||||||
|
case 'pass':
|
||||||
|
return '合格';
|
||||||
|
case 'warning':
|
||||||
|
return '警告';
|
||||||
|
case 'fail':
|
||||||
|
return '超差';
|
||||||
|
default:
|
||||||
|
return '参考';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowResults(
|
||||||
|
results: DemoFlowSourceResult[],
|
||||||
|
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||||
|
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
|
||||||
|
): DemoFlowAggregation {
|
||||||
|
const sorted = results
|
||||||
|
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
||||||
|
.sort((a, b) => a.index - b.index || a.originalIndex - b.originalIndex);
|
||||||
|
const measurementRows: DemoFlowMeasurementRow[] = [];
|
||||||
|
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 : [])
|
||||||
|
.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 });
|
||||||
|
}
|
||||||
|
measurements.forEach((measurement, measurementIndex) => {
|
||||||
|
const row: DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement',
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
measurementIndex,
|
||||||
|
measurement,
|
||||||
|
};
|
||||||
|
measurementRows.push(row);
|
||||||
|
tableRows.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = normalizeFiles(data.files);
|
||||||
|
const images = normalizeImages(data.images);
|
||||||
|
const extra = asRecord(data.extra) ?? undefined;
|
||||||
|
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
|
||||||
|
artifactGroups.push({
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
files,
|
||||||
|
images,
|
||||||
|
overlay: data.overlay3d,
|
||||||
|
inputPointCloudFileIds: data.inputPointCloudFileIds,
|
||||||
|
inputCadFileIds: data.inputCadFileIds,
|
||||||
|
extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'completed') {
|
||||||
|
const progress = finiteNumber(data.progress);
|
||||||
|
const detail = result.status === 'error'
|
||||||
|
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
|
||||||
|
: typeof data.stage === 'string'
|
||||||
|
? data.stage
|
||||||
|
: typeof data.error === 'string'
|
||||||
|
? data.error
|
||||||
|
: undefined;
|
||||||
|
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tableRows, measurementRows, artifactGroups, statusNotices };
|
||||||
|
}
|
||||||
@@ -1,23 +1,2 @@
|
|||||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||||
|
export const getLoginUrl = () => "/login";
|
||||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
|
||||||
export const getLoginUrl = () => {
|
|
||||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
|
||||||
const appId = import.meta.env.VITE_APP_ID;
|
|
||||||
|
|
||||||
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
|
|
||||||
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
|
||||||
const state = btoa(redirectUri);
|
|
||||||
|
|
||||||
const url = new URL(`${oauthPortalUrl}/app-auth`);
|
|
||||||
url.searchParams.set("appId", appId || '');
|
|
||||||
url.searchParams.set("redirectUri", redirectUri);
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
url.searchParams.set("type", "signIn");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|||||||
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
aggregateDemoFlowState,
|
||||||
|
formatElapsedTime,
|
||||||
|
type AnalysisResultRow,
|
||||||
|
} from './useDemoFlowState';
|
||||||
|
|
||||||
|
const STARTED_AT = '2026-07-24T00:00:10.000Z';
|
||||||
|
|
||||||
|
function result(
|
||||||
|
overrides: Partial<AnalysisResultRow> & Pick<AnalysisResultRow, 'id' | 'status'>,
|
||||||
|
): AnalysisResultRow {
|
||||||
|
return {
|
||||||
|
taskId: 1,
|
||||||
|
type: 'plugin',
|
||||||
|
pluginId: 'vehicle-registration',
|
||||||
|
createdAt: STARTED_AT,
|
||||||
|
updatedAt: STARTED_AT,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 0.7,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowState', () => {
|
||||||
|
it('按源点云总点数和整体进度估算已完成点云量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'ready', pointCount: 1_000_000_000 }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallProgress).toBe(35);
|
||||||
|
expect(state.completedPointCount).toBe(350_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('元数据缺失时不生成虚假的点云数量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'pending', pointCount: null }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.sourcePointCount).toBeNull();
|
||||||
|
expect(state.completedPointCount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('运行中使用当前时间计时,结束后冻结在最后更新时间', () => {
|
||||||
|
const running = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
const completed = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: '2026-07-24T00:00:25.000Z',
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 1,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(running.elapsedMs).toBe(30_000);
|
||||||
|
expect(completed.elapsedMs).toBe(15_000);
|
||||||
|
expect(completed.endedAt).toBe(new Date('2026-07-24T00:00:25.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配准失败后把未创建步骤标为停止,不再聚合成排队中', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'error', updatedAt: '2026-07-24T00:00:25.000Z' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.active).toBe(false);
|
||||||
|
expect(state.overallStatus).toBe('error');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('停止标记可让已完成配准后的占位步骤和计时正确冻结', () => {
|
||||||
|
const cancelledAt = '2026-07-24T00:00:28.000Z';
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: cancelledAt,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
demoFlowCancelledAt: cancelledAt,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallStatus).toBe('cancelled');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
expect(state.elapsedMs).toBe(18_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatElapsedTime', () => {
|
||||||
|
it('小时超过 24 后继续累计', () => {
|
||||||
|
expect(formatElapsedTime((27 * 3600 + 2 * 60 + 3) * 1000)).toBe('27:02:03');
|
||||||
|
});
|
||||||
|
});
|
||||||
273
client/src/hooks/useDemoFlowState.ts
Normal file
273
client/src/hooks/useDemoFlowState.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
|
export type AnalysisResultRow = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number | null;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointcloudFileRow = {
|
||||||
|
id: number;
|
||||||
|
metadataStatus?: string | null;
|
||||||
|
pointCount?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampFromUnknown(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'string' && !(value instanceof Date)) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedTime(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface DemoFlowStep {
|
||||||
|
id: number;
|
||||||
|
index: number;
|
||||||
|
role: 'registration' | 'measurement';
|
||||||
|
pluginId: string | null;
|
||||||
|
status: DemoFlowStepStatus;
|
||||||
|
progress?: number;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoFlowState {
|
||||||
|
active: boolean;
|
||||||
|
runId: string | null;
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
steps: DemoFlowStep[];
|
||||||
|
overallStatus: DemoFlowStepStatus | 'idle';
|
||||||
|
currentStep: DemoFlowStep | null;
|
||||||
|
overallProgress: number;
|
||||||
|
sourcePointCloudFileId: number | null;
|
||||||
|
sourcePointCount: number | null;
|
||||||
|
completedPointCount: number | null;
|
||||||
|
startedAt: number | null;
|
||||||
|
endedAt: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATE: DemoFlowState = {
|
||||||
|
active: false,
|
||||||
|
runId: null,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
steps: [],
|
||||||
|
overallStatus: 'idle',
|
||||||
|
currentStep: null,
|
||||||
|
overallProgress: 0,
|
||||||
|
sourcePointCloudFileId: null,
|
||||||
|
sourcePointCount: null,
|
||||||
|
completedPointCount: null,
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: null,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isActiveStatus(step: DemoFlowStep): boolean {
|
||||||
|
return step.status === 'processing' || step.status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowState(
|
||||||
|
taskId: number | null,
|
||||||
|
results: AnalysisResultRow[],
|
||||||
|
pointcloudFiles: PointcloudFileRow[],
|
||||||
|
nowMs: number,
|
||||||
|
): DemoFlowState {
|
||||||
|
if (taskId == null || results.length === 0) return EMPTY_STATE;
|
||||||
|
|
||||||
|
const steps: DemoFlowStep[] = [];
|
||||||
|
let activeRunId: string | null = null;
|
||||||
|
let declaredTotal = 0;
|
||||||
|
let sourcePointCloudFileId: number | null = null;
|
||||||
|
let cancelledAt: number | null = null;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const data = asRecord(r.resultData);
|
||||||
|
if (!data) continue;
|
||||||
|
const runId = data.demoFlowRunId;
|
||||||
|
if (typeof runId !== 'string' || runId.length === 0) continue;
|
||||||
|
|
||||||
|
if (activeRunId == null) {
|
||||||
|
activeRunId = runId;
|
||||||
|
} else if (activeRunId !== runId) {
|
||||||
|
// 分析结果按 createdAt 倒序返回,只聚合最新的一次全流程。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = numberFromUnknown(data.demoFlowIndex) ?? steps.length + 1;
|
||||||
|
const roleRaw = data.demoFlowRole;
|
||||||
|
const role = roleRaw === 'registration' ? 'registration' : 'measurement';
|
||||||
|
const progress = numberFromUnknown(data.progress);
|
||||||
|
const stepTotal = numberFromUnknown(data.demoFlowTotal) ?? 0;
|
||||||
|
const sourceId = numberFromUnknown(data.demoFlowSourcePointCloudFileId);
|
||||||
|
const rowCancelledAt = timestampFromUnknown(data.demoFlowCancelledAt);
|
||||||
|
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
|
||||||
|
if (sourcePointCloudFileId == null && sourceId != null) sourcePointCloudFileId = sourceId;
|
||||||
|
if (rowCancelledAt != null && (cancelledAt == null || rowCancelledAt > cancelledAt)) {
|
||||||
|
cancelledAt = rowCancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
id: r.id,
|
||||||
|
index,
|
||||||
|
role,
|
||||||
|
pluginId: r.pluginId ?? null,
|
||||||
|
status: r.status as DemoFlowStepStatus,
|
||||||
|
progress,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0 || activeRunId == null) return EMPTY_STATE;
|
||||||
|
|
||||||
|
steps.sort((a, b) => a.index - b.index);
|
||||||
|
const actualSteps = [...steps];
|
||||||
|
const total = Math.max(actualSteps.length, declaredTotal);
|
||||||
|
const hasActiveActualStep = actualSteps.some(isActiveStatus);
|
||||||
|
const hasError = actualSteps.some((step) => step.status === 'error');
|
||||||
|
const hasCancelled = cancelledAt != null || actualSteps.some((step) => step.status === 'cancelled');
|
||||||
|
const terminalStatus: 'error' | 'cancelled' | null = !hasActiveActualStep
|
||||||
|
? hasError
|
||||||
|
? 'error'
|
||||||
|
: hasCancelled
|
||||||
|
? 'cancelled'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 链路已失败或停止时,尚未创建的步骤不会再执行,不能继续显示为“排队中”。
|
||||||
|
for (let index = actualSteps.length + 1; index <= total; index += 1) {
|
||||||
|
steps.push({
|
||||||
|
id: -index,
|
||||||
|
index,
|
||||||
|
role: index === 1 ? 'registration' : 'measurement',
|
||||||
|
pluginId: null,
|
||||||
|
status: terminalStatus == null ? 'pending' : 'cancelled',
|
||||||
|
createdAt: steps[0]?.createdAt ?? new Date(nowMs),
|
||||||
|
updatedAt: steps[0]?.updatedAt ?? new Date(nowMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = steps.filter((step) => step.status === 'completed').length;
|
||||||
|
let overallStatus: DemoFlowState['overallStatus'];
|
||||||
|
if (actualSteps.some((step) => step.status === 'processing')) overallStatus = 'processing';
|
||||||
|
else if (actualSteps.some((step) => step.status === 'pending')) overallStatus = 'pending';
|
||||||
|
else if (terminalStatus != null) overallStatus = terminalStatus;
|
||||||
|
else if (actualSteps.length === total && actualSteps.every((step) => step.status === 'completed')) {
|
||||||
|
overallStatus = 'completed';
|
||||||
|
} else {
|
||||||
|
overallStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = overallStatus === 'processing' || overallStatus === 'pending';
|
||||||
|
const currentStep =
|
||||||
|
steps.find((step) => step.status === 'processing') ??
|
||||||
|
steps.find((step) => step.status === 'pending') ??
|
||||||
|
actualSteps[actualSteps.length - 1] ??
|
||||||
|
null;
|
||||||
|
const doneProgress = total > 0 ? completed / total : 0;
|
||||||
|
const currentProgress = currentStep && isActiveStatus(currentStep) && total > 0
|
||||||
|
? Math.min(1, Math.max(0, currentStep.progress ?? 0)) / total
|
||||||
|
: 0;
|
||||||
|
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
|
||||||
|
|
||||||
|
const sourcePointcloud = sourcePointCloudFileId == null
|
||||||
|
? undefined
|
||||||
|
: pointcloudFiles.find((file) => file.id === sourcePointCloudFileId);
|
||||||
|
const sourcePointCount =
|
||||||
|
sourcePointcloud?.metadataStatus === 'ready'
|
||||||
|
&& typeof sourcePointcloud.pointCount === 'number'
|
||||||
|
&& Number.isFinite(sourcePointcloud.pointCount)
|
||||||
|
&& sourcePointcloud.pointCount >= 0
|
||||||
|
? sourcePointcloud.pointCount
|
||||||
|
: null;
|
||||||
|
const completedPointCount =
|
||||||
|
sourcePointCount == null ? null : sourcePointCount * (overallProgress / 100);
|
||||||
|
|
||||||
|
const startTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.createdAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const updateTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.updatedAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const startedAt = startTimestamps.length > 0 ? Math.min(...startTimestamps) : null;
|
||||||
|
const endedAt = active
|
||||||
|
? null
|
||||||
|
: cancelledAt ?? (updateTimestamps.length > 0 ? Math.max(...updateTimestamps) : startedAt);
|
||||||
|
const elapsedMs = startedAt == null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, (endedAt ?? nowMs) - startedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
runId: activeRunId,
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
steps,
|
||||||
|
overallStatus,
|
||||||
|
currentStep,
|
||||||
|
overallProgress,
|
||||||
|
sourcePointCloudFileId,
|
||||||
|
sourcePointCount,
|
||||||
|
completedPointCount,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoFlowState(taskId: number | null): DemoFlowState {
|
||||||
|
const { data: results = [] } = trpc.analysisResult.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null, refetchInterval: 3000 },
|
||||||
|
);
|
||||||
|
const { data: pointcloudFiles = [] } = trpc.pointcloudFile.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null },
|
||||||
|
);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const state = useMemo(
|
||||||
|
() => aggregateDemoFlowState(
|
||||||
|
taskId,
|
||||||
|
results as AnalysisResultRow[],
|
||||||
|
pointcloudFiles as PointcloudFileRow[],
|
||||||
|
nowMs,
|
||||||
|
),
|
||||||
|
[nowMs, pointcloudFiles, results, taskId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
if (!state.active) return;
|
||||||
|
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [state.active, state.runId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { getLoginUrl } from "./const";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
||||||
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
|
|||||||
|
|
||||||
if (!isUnauthorized) return;
|
if (!isUnauthorized) return;
|
||||||
|
|
||||||
window.location.href = getLoginUrl();
|
if (window.location.pathname !== "/login") window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
queryClient.getQueryCache().subscribe(event => {
|
queryClient.getQueryCache().subscribe(event => {
|
||||||
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
|
|||||||
if (event.type === "updated" && event.action.type === "error") {
|
if (event.type === "updated" && event.action.type === "error") {
|
||||||
const error = event.mutation.state.error;
|
const error = event.mutation.state.error;
|
||||||
redirectToLoginIfUnauthorized(error);
|
redirectToLoginIfUnauthorized(error);
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
|
||||||
|
toast.error(error.message || "无权执行此操作");
|
||||||
|
}
|
||||||
console.error("[API Mutation Error]", error);
|
console.error("[API Mutation Error]", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -69,7 +72,12 @@ const trpcLinkOptions = {
|
|||||||
const sep = input.includes('?') ? '&' : '?';
|
const sep = input.includes('?') ? '&' : '?';
|
||||||
url = `${input}${sep}_t=${Date.now()}`;
|
url = `${input}${sep}_t=${Date.now()}`;
|
||||||
} else {
|
} else {
|
||||||
url = input as string;
|
const requestUrl = new URL(input.url);
|
||||||
|
requestUrl.searchParams.set('_t', Date.now().toString());
|
||||||
|
return globalThis.fetch(new Request(requestUrl.toString(), input), {
|
||||||
|
...(init ?? {}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return globalThis.fetch(url, {
|
return globalThis.fetch(url, {
|
||||||
...(init ?? {}),
|
...(init ?? {}),
|
||||||
|
|||||||
48
client/src/pages/ChangePassword.tsx
Normal file
48
client/src/pages/ChangePassword.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Redirect, useLocation } from "wouter";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function ChangePassword() {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const change = trpc.auth.changePassword.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.auth.me.invalidate();
|
||||||
|
setLocation("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loading && !user) return <Redirect to="/login" />;
|
||||||
|
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!mismatch) change.mutate({ currentPassword, newPassword });
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">修改密码</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
|
||||||
|
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
|
||||||
|
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
|
||||||
|
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
|
||||||
|
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,45 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import { getLoginUrl } from '@/const';
|
|
||||||
import PointCloudViewer from '@/components/PointCloudViewer';
|
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||||
|
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
||||||
|
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
|
||||||
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
||||||
import TaskManager from '@/components/TaskManager';
|
import TaskManager from '@/components/TaskManager';
|
||||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||||
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
||||||
import type { Overlay3D } from '@/components/GenericResultView';
|
import type { Overlay3D } from '@/components/GenericResultView';
|
||||||
import WorkflowProgress, { type WorkflowState, type WorkflowStepId, type StepStatus } from '@/components/WorkflowProgress';
|
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
LogIn,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Box,
|
Users,
|
||||||
|
Cloud,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Settings,
|
Settings,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Crosshair,
|
|
||||||
Train,
|
Train,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading, isAuthenticated, logout } = useAuth();
|
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
|
||||||
|
|
||||||
|
// 本次使用时间只属于当前页面会话;刷新或重新登录后从零开始。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setSessionElapsedMs(Date.now() - startedAt);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
||||||
const [selection, setSelection] = useState<TreeSelection>({
|
const [selection, setSelection] = useState<TreeSelection>({
|
||||||
@@ -68,9 +82,19 @@ export default function Home() {
|
|||||||
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||||
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
||||||
|
|
||||||
|
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
|
||||||
|
const demoFlowState = useDemoFlowState(selection.taskId);
|
||||||
|
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
const pluginNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
|
||||||
|
return map;
|
||||||
|
}, [pluginList]);
|
||||||
|
|
||||||
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
||||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
const [showCADModel, setShowCADModel] = useState(true);
|
const [showPointCloud, setShowPointCloud] = useState(true);
|
||||||
|
const [showCADModel] = useState(true);
|
||||||
const [showWireframe, setShowWireframe] = useState(false);
|
const [showWireframe, setShowWireframe] = useState(false);
|
||||||
const [pointSize, setPointSize] = useState(0.03);
|
const [pointSize, setPointSize] = useState(0.03);
|
||||||
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
|
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
|
||||||
@@ -89,12 +113,7 @@ export default function Home() {
|
|||||||
resultId: number;
|
resultId: number;
|
||||||
overlay: Overlay3D;
|
overlay: Overlay3D;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
|
const [errorPickEnabled] = useState(false);
|
||||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
|
||||||
// 触发分析时的 completed 结果基线数(key=taskId, value=触发时已有的 completed 数)
|
|
||||||
const [analysisBaselines, setAnalysisBaselines] = useState<Map<number, number>>(new Map());
|
|
||||||
// 用 ref 在 onSuccess 闭包中读取最新 analysisResults,避免过时闭包
|
|
||||||
const analysisResultsRef = useRef<any[]>([]);
|
|
||||||
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
||||||
const [showRightPanel, setShowRightPanel] = useState(true);
|
const [showRightPanel, setShowRightPanel] = useState(true);
|
||||||
|
|
||||||
@@ -122,13 +141,6 @@ export default function Home() {
|
|||||||
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
|
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
|
||||||
}
|
}
|
||||||
void utils.analysisResult.list.invalidate({ taskId: variables.taskId });
|
void utils.analysisResult.list.invalidate({ taskId: variables.taskId });
|
||||||
// 记录此时已有的 completed 数作为基线,只有新增结果才算本次分析完成
|
|
||||||
const baselineCount = analysisResultsRef.current.filter((r: any) => r.status === 'completed').length;
|
|
||||||
setAnalysisBaselines(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
if (!next.has(variables.taskId)) next.set(variables.taskId, baselineCount);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '提交失败'),
|
onError: (e) => toast.error(e.message || '提交失败'),
|
||||||
});
|
});
|
||||||
@@ -146,7 +158,7 @@ export default function Home() {
|
|||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
setIsCreatingTask(false);
|
setShowHeatmap(false);
|
||||||
setSelectedPcFileIds([]);
|
setSelectedPcFileIds([]);
|
||||||
setSelectedTaskCadFileIds([]);
|
setSelectedTaskCadFileIds([]);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
@@ -231,6 +243,10 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
||||||
|
setHeatmapViewResultId(null);
|
||||||
|
setPluginOverlay(null);
|
||||||
|
setPointCloudRenderMode('points');
|
||||||
|
setShowHeatmap(false);
|
||||||
setPointCloudTaskId(taskId);
|
setPointCloudTaskId(taskId);
|
||||||
setPointCloudUrls([`__task:${taskId}`]);
|
setPointCloudUrls([`__task:${taskId}`]);
|
||||||
setPcLoading(true);
|
setPcLoading(true);
|
||||||
@@ -253,57 +269,6 @@ export default function Home() {
|
|||||||
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
|
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
|
||||||
const batchTaskId = pointCloudTaskId;
|
const batchTaskId = pointCloudTaskId;
|
||||||
|
|
||||||
// ── 工作流进度条状态推导 ───────────────────────────────────────────────────
|
|
||||||
const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery(
|
|
||||||
{ taskId: selection.taskId! },
|
|
||||||
{ enabled: selection.taskId != null, refetchInterval: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => { analysisResultsRef.current = analysisResults; }, [analysisResults]);
|
|
||||||
|
|
||||||
const workflowState = useMemo<WorkflowState>(() => {
|
|
||||||
const hasCad = !!cadModelUrl;
|
|
||||||
const hasPc = pointCloudTaskId != null;
|
|
||||||
const rendered = hasPc && !pcLoading && pcProgress >= 100;
|
|
||||||
const baseline = (!isCreatingTask && selection.taskId != null)
|
|
||||||
? (analysisBaselines.get(selection.taskId) ?? -1)
|
|
||||||
: -1;
|
|
||||||
const triggered = baseline >= 0;
|
|
||||||
const currentCompleted = (analysisResults as any[]).filter((r: any) => r.status === 'completed').length;
|
|
||||||
const analyzed = triggered && currentCompleted > baseline;
|
|
||||||
|
|
||||||
function step(cond: boolean, prev: boolean): 'done' | 'active' | 'pending' {
|
|
||||||
if (cond) return 'done';
|
|
||||||
if (prev) return 'active';
|
|
||||||
return 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分析步骤:只在 触发后未完成 时转圈;保存步骤:分析完成即完成,无中间态
|
|
||||||
const analysisStatus: StepStatus = analyzed ? 'done' : triggered ? 'active' : 'pending';
|
|
||||||
const saveStatus: StepStatus = analyzed ? 'done' : 'pending';
|
|
||||||
|
|
||||||
return {
|
|
||||||
cad: step(hasCad, true),
|
|
||||||
pointcloud: step(hasPc, hasCad),
|
|
||||||
render: step(rendered, hasPc),
|
|
||||||
analysis: analysisStatus,
|
|
||||||
save: saveStatus,
|
|
||||||
};
|
|
||||||
}, [cadModelUrl, pointCloudTaskId, pcLoading, pcProgress, analysisResults, isCreatingTask, analysisBaselines, selection.taskId]);
|
|
||||||
|
|
||||||
const rightPanelRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const handleWorkflowStepClick = useCallback((id: WorkflowStepId) => {
|
|
||||||
if (id === 'cad' || id === 'pointcloud') {
|
|
||||||
setShowLeftPanel(true);
|
|
||||||
} else if (id === 'render') {
|
|
||||||
if (selection.taskId != null && pointCloudTaskId !== selection.taskId) {
|
|
||||||
handleLoadPointClouds(selection.taskId, []);
|
|
||||||
}
|
|
||||||
} else if (id === 'analysis' || id === 'save') {
|
|
||||||
setShowRightPanel(true);
|
|
||||||
}
|
|
||||||
}, [selection.taskId, pointCloudTaskId, handleLoadPointClouds]);
|
|
||||||
// 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求
|
// 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求
|
||||||
const pointCloudUrlsStable = useMemo(
|
const pointCloudUrlsStable = useMemo(
|
||||||
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
|
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
|
||||||
@@ -312,14 +277,11 @@ export default function Home() {
|
|||||||
|
|
||||||
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
|
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (selection.modelId == null) {
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
<VehicleTree selection={selection} onSelect={setSelection} />
|
||||||
);
|
{selection.modelId != null && (
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<TaskManager
|
<TaskManager
|
||||||
modelId={selection.modelId}
|
modelId={selection.modelId}
|
||||||
@@ -329,10 +291,10 @@ export default function Home() {
|
|||||||
onPcFilesSelectionChange={setSelectedPcFileIds}
|
onPcFilesSelectionChange={setSelectedPcFileIds}
|
||||||
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
||||||
onSelectTaskCad={handleSelectCad}
|
onSelectTaskCad={handleSelectCad}
|
||||||
onStartCreate={() => setIsCreatingTask(true)}
|
|
||||||
onEndCreate={() => setIsCreatingTask(false)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -348,10 +310,12 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AlgorithmRunPanel
|
{canWrite && <AlgorithmRunPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
|
modelId={selection.modelId}
|
||||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||||
taskPcFileCount={taskPcFiles.length}
|
taskPcFileCount={taskPcFiles.length}
|
||||||
|
taskPointcloudFiles={taskPcFiles}
|
||||||
selectedPcFileIds={selectedPcFileIds}
|
selectedPcFileIds={selectedPcFileIds}
|
||||||
hasRegisteredCloud={hasRegisteredCloud}
|
hasRegisteredCloud={hasRegisteredCloud}
|
||||||
selectedCadFileIds={selectedTaskCadFileIds}
|
selectedCadFileIds={selectedTaskCadFileIds}
|
||||||
@@ -362,8 +326,8 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(null);
|
setRoiPickedPoint(null);
|
||||||
}}
|
}}
|
||||||
activeRoiPickTarget={activeRoiPickTarget}
|
activeRoiPickTarget={activeRoiPickTarget}
|
||||||
/>
|
/>}
|
||||||
<div className="h-px bg-border" />
|
{canWrite && <div className="h-px bg-border" />}
|
||||||
<AnalysisPanel
|
<AnalysisPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
||||||
@@ -371,13 +335,50 @@ export default function Home() {
|
|||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setHeatmapViewResultId(resultId);
|
setHeatmapViewResultId(resultId);
|
||||||
setPointCloudRenderMode('smoothHeatmap');
|
setPointCloudRenderMode('smoothHeatmap');
|
||||||
|
setShowHeatmap(true);
|
||||||
}}
|
}}
|
||||||
onViewPluginOverlay={({ taskId, resultId, overlay }) => {
|
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
|
||||||
|
const expectedIds = inputPointCloudFileIds ?? [];
|
||||||
|
const expectedFiles = expectedIds
|
||||||
|
.map((id) => taskPcFiles.find((f) => f.id === id))
|
||||||
|
.filter((f): f is NonNullable<typeof f> => f != null);
|
||||||
|
const expectedNames = expectedIds
|
||||||
|
.map((id) => taskPcFiles.find((f) => f.id === id)?.name ?? `#${id}`)
|
||||||
|
.join('、');
|
||||||
|
const expectedLabel = expectedFiles.length > 0 && expectedFiles.every((f) => (f as { source?: string }).source === 'registered')
|
||||||
|
? '配准后的点云'
|
||||||
|
: '本算法结果对应的点云文件';
|
||||||
|
const expectedHint = expectedNames ? `${expectedLabel}:${expectedNames}` : expectedLabel;
|
||||||
|
|
||||||
|
if (pointCloudTaskId == null || pointCloudUrls.length === 0) {
|
||||||
|
toast.error(`请先在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pcLoading) {
|
||||||
|
toast.warning('点云仍在加载中,请等待加载完成后再查看叠加层');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pointCloudTaskId !== taskId) {
|
||||||
|
toast.error(`当前 3D 视口加载的点云不属于该算法结果,请先在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expectedIds.length === 0) {
|
||||||
|
toast.warning('无法确认匹配关系,请加载该结果运行时使用的点云');
|
||||||
|
} else {
|
||||||
|
const loadedIds = selectedPcFileIds.length > 0
|
||||||
|
? selectedPcFileIds
|
||||||
|
: taskPcFiles.map((f) => f.id);
|
||||||
|
const missingIds = expectedIds.filter((id) => !loadedIds.includes(id));
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
toast.error(`当前加载的点云与该结果不匹配,请在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay({ taskId, resultId, overlay });
|
setPluginOverlay({ taskId, resultId, overlay });
|
||||||
if (overlay.kind === 'vertexColors') {
|
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
||||||
setPointCloudRenderMode('smoothHeatmap');
|
setShowHeatmap(overlay.kind === 'vertexColors');
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
externalPending={runAnalysisJob.isPending}
|
externalPending={runAnalysisJob.isPending}
|
||||||
onRunAssembly={() => {
|
onRunAssembly={() => {
|
||||||
@@ -398,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">
|
<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-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
|
<h1 className="text-sm font-semibold">宏量点云处理软件</h1>
|
||||||
<Box className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeModel && (
|
{activeModel && (
|
||||||
@@ -439,33 +437,32 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(pcLoading || cadLoading) && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
|
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
|
||||||
<span className={`status-led ${
|
<span className="status-led status-led-processing" />
|
||||||
pcLoading ? 'status-led-processing' :
|
|
||||||
cadLoading ? 'status-led-processing' :
|
|
||||||
'status-led-idle'
|
|
||||||
}`} />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{pcLoading ? `加载点云 ${pcProgress}%` :
|
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
|
||||||
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
|
|
||||||
'待机'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||||||
) : isAuthenticated ? (
|
) : user ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-xs text-muted-foreground">{user?.name || '用户'}</span>
|
<div className="text-right leading-tight">
|
||||||
<button className="industrial-btn py-1.5" onClick={() => logout()}>
|
<div>{user.username}</div>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<div className="whitespace-nowrap text-[10px] text-muted-foreground">
|
||||||
</button>
|
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
本次使用时间 <span className="font-mono tabular-nums">{formatElapsedTime(sessionElapsedMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
|
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
||||||
<LogIn className="w-3.5 h-3.5" />
|
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
|
||||||
</a>
|
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
|
||||||
)}
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -473,20 +470,13 @@ export default function Home() {
|
|||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* 左侧面板 */}
|
{/* 左侧面板 */}
|
||||||
{showLeftPanel && (
|
{showLeftPanel && (
|
||||||
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
|
<aside className="flex w-72 flex-shrink-0 flex-col overflow-hidden border-r border-border bg-card/30 p-3">
|
||||||
{renderLeftPanel()}
|
{renderLeftPanel()}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 中央 3D 视图 */}
|
{/* 中央 3D 视图 */}
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
{/* 工作流进度条(仅在选中任务时显示) */}
|
|
||||||
{selection.taskId != null && (
|
|
||||||
<WorkflowProgress
|
|
||||||
state={workflowState}
|
|
||||||
onStepClick={handleWorkflowStepClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<div className="absolute inset-2">
|
<div className="absolute inset-2">
|
||||||
<div className="viewer-container w-full h-full relative">
|
<div className="viewer-container w-full h-full relative">
|
||||||
@@ -511,6 +501,7 @@ export default function Home() {
|
|||||||
pointCloudUrls={pointCloudUrlsStable}
|
pointCloudUrls={pointCloudUrlsStable}
|
||||||
batchTaskId={batchTaskId ?? undefined}
|
batchTaskId={batchTaskId ?? undefined}
|
||||||
loadKey={pcLoadKey}
|
loadKey={pcLoadKey}
|
||||||
|
showPointCloud={showPointCloud}
|
||||||
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
|
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
|
||||||
onCadProgress={handleCadProgress}
|
onCadProgress={handleCadProgress}
|
||||||
onCadLoaded={handleCadLoaded}
|
onCadLoaded={handleCadLoaded}
|
||||||
@@ -528,6 +519,7 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(pt);
|
setRoiPickedPoint(pt);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
}}
|
}}
|
||||||
|
demoFlowActive={demoFlowState.active}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -542,17 +534,15 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
{/* 操作提示 */}
|
||||||
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
|
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
|
||||||
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && (
|
<span>左键旋转</span>
|
||||||
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span>
|
<span>右键平移</span>
|
||||||
)}
|
<span>滚轮缩放</span>
|
||||||
{errorPickEnabled && pointCloudRenderMode === 'points'
|
|
||||||
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
|
|
||||||
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 选中点信息 */}
|
{/* 选中点信息 */}
|
||||||
@@ -594,43 +584,38 @@ export default function Home() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`}
|
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showPointCloud ? 'industrial-btn-primary' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (pointCloudRenderMode !== 'points') {
|
||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
|
setShowHeatmap(false);
|
||||||
|
setShowPointCloud(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowPointCloud(v => !v);
|
||||||
}}
|
}}
|
||||||
title="逐点显示(支持误差拾取)"
|
title={showPointCloud ? '隐藏点云' : '显示点云'}
|
||||||
>
|
>
|
||||||
<span className="text-[10px]">点云</span>
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
|
<span>点云</span>
|
||||||
</button>
|
</button>
|
||||||
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
|
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
|
||||||
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
|
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
|
||||||
正在显示分析结果 #{heatmapViewResultId}
|
正在显示分析结果 #{heatmapViewResultId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{pointCloudRenderMode === 'smoothHeatmap' && (
|
||||||
<button
|
<button
|
||||||
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
type="button"
|
||||||
|
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
||||||
onClick={() => setShowHeatmap(v => !v)}
|
onClick={() => setShowHeatmap(v => !v)}
|
||||||
>
|
>
|
||||||
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
<span className="ml-1">热力图</span>
|
<span>热力图</span>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`industrial-btn py-1 ${showCADModel ? 'industrial-btn-primary' : ''}`}
|
|
||||||
onClick={() => setShowCADModel(v => !v)}
|
|
||||||
>
|
|
||||||
<Box className="w-3.5 h-3.5" />
|
|
||||||
<span className="ml-1">CAD</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`industrial-btn py-1 ${errorPickEnabled ? 'industrial-btn-primary' : ''}`}
|
|
||||||
onClick={() => setErrorPickEnabled(v => !v)}
|
|
||||||
title="开启后左键点击显示该点误差"
|
|
||||||
>
|
|
||||||
<Crosshair className="w-3.5 h-3.5" />
|
|
||||||
<span className="ml-1">误差拾取</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-5 w-px bg-border" />
|
<div className="h-5 w-px bg-border" />
|
||||||
@@ -656,7 +641,7 @@ export default function Home() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={100_000}
|
min={100_000}
|
||||||
max={5_000_000}
|
max={2_000_000}
|
||||||
step={100_000}
|
step={100_000}
|
||||||
value={renderPointBudget}
|
value={renderPointBudget}
|
||||||
onChange={e => setRenderPointBudget(Number(e.target.value))}
|
onChange={e => setRenderPointBudget(Number(e.target.value))}
|
||||||
@@ -669,7 +654,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* 右侧面板 - 分析结果 */}
|
{/* 右侧面板 - 分析结果 */}
|
||||||
{showRightPanel && (
|
{showRightPanel && (
|
||||||
<aside ref={rightPanelRef} className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
|
<aside className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
|
||||||
{renderRightPanel()}
|
{renderRightPanel()}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
53
client/src/pages/Login.tsx
Normal file
53
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Redirect, useLocation } from "wouter";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const login = trpc.auth.login.useMutation({
|
||||||
|
onSuccess: async (signedInUser) => {
|
||||||
|
utils.auth.me.setData(undefined, signedInUser);
|
||||||
|
await utils.auth.me.invalidate();
|
||||||
|
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm industrial-panel p-8 space-y-5"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
login.mutate({ username, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">用户名</Label>
|
||||||
|
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
|
||||||
|
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
|
||||||
|
{login.isPending ? "正在登录…" : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/src/pages/Users.tsx
Normal file
68
client/src/pages/Users.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { ArrowLeft, Search, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
|
||||||
|
type Role = keyof typeof roleNames;
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [draftSearch, setDraftSearch] = useState("");
|
||||||
|
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
|
||||||
|
const list = trpc.users.list.useQuery(search ? { search } : {});
|
||||||
|
const refresh = () => utils.users.list.invalidate();
|
||||||
|
const onError = (error: { message: string }) => toast.error(error.message);
|
||||||
|
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
|
||||||
|
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
|
||||||
|
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
|
||||||
|
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
|
||||||
|
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6 space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="industrial-panel p-5">
|
||||||
|
<h2 className="font-medium mb-4">新建用户</h2>
|
||||||
|
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
|
||||||
|
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
|
||||||
|
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
|
||||||
|
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
|
||||||
|
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="industrial-panel p-5 space-y-4">
|
||||||
|
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
|
||||||
|
<tbody>{list.data?.map(user => (
|
||||||
|
<tr key={user.id} className="border-b border-border/60">
|
||||||
|
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
|
||||||
|
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
|
||||||
|
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
|
||||||
|
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
|
||||||
|
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
|
||||||
|
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
|
||||||
|
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,22 +38,21 @@ services:
|
|||||||
container_name: massive-point-cloud-app-dev
|
container_name: massive-point-cloud-app-dev
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
network_mode: host
|
network_mode: host
|
||||||
runtime: nvidia # docker-compose V1 用
|
# runtime: nvidia # docker-compose V1 用
|
||||||
# gpus: all # docker compose V2 用这行,同时注释上面的 runtime
|
# gpus: all # docker compose V2 用这行,同时注释上面的 runtime
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
||||||
- S3_BUCKET=pointcloud-data
|
- S3_BUCKET=pointcloud-data
|
||||||
- S3_REGION=us-east-1
|
- S3_REGION=us-east-1
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
- VITE_OAUTH_PORTAL_URL=disabled
|
|
||||||
- VITE_APP_ID=
|
|
||||||
# 数据目录路径
|
# 数据目录路径
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
@@ -75,10 +74,13 @@ services:
|
|||||||
- ${LOCAL_DATA_DIR:-./data}:/data
|
- ${LOCAL_DATA_DIR:-./data}:/data
|
||||||
# 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载)
|
# 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载)
|
||||||
- ${LOCAL_ALG_DIR:-./alg}:/alg:ro
|
- ${LOCAL_ALG_DIR:-./alg}:/alg:ro
|
||||||
command: sh -c "
|
command:
|
||||||
LIBSTDCPP=\$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1) &&
|
- bash
|
||||||
[ -n \"\$LIBSTDCPP\" ] && ln -sf \"\$LIBSTDCPP\" /usr/lib/x86_64-linux-gnu/libstdc++.so.6 &&
|
- -c
|
||||||
corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0"
|
- |
|
||||||
|
LIBSTDCPP=$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1)
|
||||||
|
[ -n "$LIBSTDCPP" ] && ln -sf "$LIBSTDCPP" /usr/lib/x86_64-linux-gnu/libstdc++.so.6
|
||||||
|
corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
|
||||||
# 内网部署禁用OAuth,使用本地认证
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
|
||||||
# MinIO S3兼容存储配置
|
# MinIO S3兼容存储配置
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
- ALG_ROOT=/alg
|
- ALG_ROOT=/alg
|
||||||
# 应用配置
|
# 应用配置
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ MINIO_ROOT_PASSWORD=minioadmin123
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 应用标题
|
# 应用标题
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 可选配置
|
## 可选配置
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ USE pointcloud_db;
|
|||||||
-- 用户表
|
-- 用户表
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
openId VARCHAR(64) NOT NULL UNIQUE,
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
passwordHash VARCHAR(255),
|
||||||
name TEXT,
|
name TEXT,
|
||||||
email VARCHAR(320),
|
email VARCHAR(320),
|
||||||
loginMethod VARCHAR(64),
|
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||||
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
|
isActive INT NOT NULL DEFAULT 1,
|
||||||
|
mustChangePassword INT NOT NULL DEFAULT 1,
|
||||||
|
sessionVersion INT NOT NULL DEFAULT 0,
|
||||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
lastLoginAt TIMESTAMP NULL,
|
||||||
INDEX idx_openId (openId),
|
|
||||||
INDEX idx_email (email(255))
|
INDEX idx_email (email(255))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -124,16 +126,33 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` (
|
|||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`modelId` INT NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`fileSize` BIGINT NOT NULL,
|
||||||
|
`filePath` VARCHAR(512) NOT NULL,
|
||||||
|
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
`pointCount` BIGINT NULL,
|
||||||
|
`pointAttributes` JSON NULL,
|
||||||
|
`pointDataFormat` VARCHAR(32) NULL,
|
||||||
|
`bytesPerPoint` INT NULL,
|
||||||
|
`metadataError` TEXT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX `idx_model_pointcloud_modelId` (`modelId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`modelId` INT NOT NULL,
|
`modelId` INT NOT NULL,
|
||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
`inspector` VARCHAR(100),
|
`inspector` VARCHAR(100),
|
||||||
`notes` TEXT,
|
`notes` TEXT,
|
||||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
`status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created',
|
||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`completedAt` TIMESTAMP NULL
|
`completedAt` TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
||||||
@@ -142,14 +161,24 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
|||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
`fileSize` BIGINT NOT NULL,
|
`fileSize` BIGINT NOT NULL,
|
||||||
`filePath` VARCHAR(512) NOT NULL,
|
`filePath` VARCHAR(512) NOT NULL,
|
||||||
|
`source` VARCHAR(32) NOT NULL DEFAULT 'uploaded',
|
||||||
|
`sourceResultId` INT NULL,
|
||||||
|
`sourcePointcloudFileId` INT NULL,
|
||||||
|
`sourceModelPointcloudId` INT NULL,
|
||||||
|
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
`pointCount` BIGINT NULL,
|
||||||
|
`pointAttributes` JSON NULL,
|
||||||
|
`pointDataFormat` VARCHAR(32) NULL,
|
||||||
|
`bytesPerPoint` INT NULL,
|
||||||
|
`metadataError` TEXT NULL,
|
||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`taskId` INT NOT NULL,
|
`taskId` INT NOT NULL,
|
||||||
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation') NOT NULL,
|
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation','plugin') NOT NULL,
|
||||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
`status` ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending',
|
||||||
`errorStats` JSON,
|
`errorStats` JSON,
|
||||||
`measurements` JSON,
|
`measurements` JSON,
|
||||||
`resultData` JSON,
|
`resultData` JSON,
|
||||||
@@ -157,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
|||||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 创建默认管理员用户 (内网部署使用)
|
|
||||||
INSERT INTO users (openId, name, email, role) VALUES
|
|
||||||
('admin-local', '系统管理员', 'admin@localhost', 'admin')
|
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
|
||||||
|
|
||||||
-- 输出初始化完成信息
|
-- 输出初始化完成信息
|
||||||
SELECT 'Database initialization completed successfully!' AS message;
|
SELECT 'Database initialization completed successfully!' AS message;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 算法集成规范(Route B · 通用接口接入)
|
# 算法集成规范(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`
|
## 4. 输出契约 — `result.json`
|
||||||
|
|
||||||
|
以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合
|
||||||
|
[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md),
|
||||||
|
并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入
|
||||||
|
`measurements`。
|
||||||
|
|
||||||
算法在 `outDir/result.json` 写最终结果:
|
算法在 `outDir/result.json` 写最终结果:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
110
docs/开发环境与协作方式.md
Normal file
110
docs/开发环境与协作方式.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 当前源码与开发环境说明
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前最新版本源码不在本地 `massive-cloud-export` 目录中,而是在开发服务器 `2680v4`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh 2680v4
|
||||||
|
cd /home/lipan/massive-cloud-v3
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 `massive-cloud-export` 更接近旧版离线导出包或历史交付包,可作为历史留档参考;日常开发、联调和问题排查应以 `2680v4:/home/lipan/massive-cloud-v3` 为准。
|
||||||
|
|
||||||
|
## 协作方式
|
||||||
|
|
||||||
|
- 当前项目由两名开发人员共同维护。
|
||||||
|
- 日常习惯是直接 SSH 到 `2680v4` 上修改代码。
|
||||||
|
- 修改目录为 `/home/lipan/massive-cloud-v3`。
|
||||||
|
- 该目录是当前实际运行服务挂载的源码目录,代码改动会被开发模式服务读取。
|
||||||
|
- 服务器工作区可能存在其他开发人员的未提交改动,修改前应先查看状态,避免覆盖他人工作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
## 外网调试入口
|
||||||
|
|
||||||
|
`http://npsx.i-llusion.cn:3002` 不是正式生产环境地址,而是开发环境的公网穿透访问地址。
|
||||||
|
|
||||||
|
访问链路为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
外网浏览器
|
||||||
|
-> http://npsx.i-llusion.cn:3002
|
||||||
|
-> 内网穿透
|
||||||
|
-> 2680v4:3002
|
||||||
|
-> massive-point-cloud-app-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
服务器上存在 `npc` 穿透客户端进程,连接到 `47.106.245.28:8024`,用于把 `2680v4` 上的开发服务暴露到公网,方便外网访问和调试。
|
||||||
|
|
||||||
|
## 当前运行方式
|
||||||
|
|
||||||
|
当前服务使用开发模式 Docker 编排文件运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/lipan/massive-cloud-v3
|
||||||
|
sudo docker compose -f docker-compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
关键运行信息:
|
||||||
|
|
||||||
|
- 应用容器:`massive-point-cloud-app-dev`
|
||||||
|
- 编排文件:`/home/lipan/massive-cloud-v3/docker-compose.dev.yml`
|
||||||
|
- 网络模式:`network_mode: host`
|
||||||
|
- 应用端口:`3002`
|
||||||
|
- 源码映射:`/home/lipan/massive-cloud-v3:/app`
|
||||||
|
- 依赖卷:`pointcloud-app-node-modules-dev:/app/node_modules`
|
||||||
|
- 数据目录映射:`/home/lipan/massive-cloud-v3/data:/data`
|
||||||
|
- 算法目录映射:`/home/lipan/massive-cloud-v3/alg:/alg:ro`
|
||||||
|
|
||||||
|
容器启动后会在 `/app` 中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install && pnpm dev --host 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,开发人员在宿主机 `/home/lipan/massive-cloud-v3` 中修改的源码,会通过 volume 映射进入容器 `/app`,由容器内的 Node/tsx 开发服务运行。
|
||||||
|
|
||||||
|
## Docker 权限
|
||||||
|
|
||||||
|
`lipan` 用户当前直接访问 Docker daemon 权限不足,管理容器时需要使用 `sudo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker ps
|
||||||
|
sudo docker logs -f massive-point-cloud-app-dev
|
||||||
|
sudo docker compose -f docker-compose.dev.yml ps
|
||||||
|
sudo docker compose -f docker-compose.dev.yml restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用检查命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 登录开发服务器
|
||||||
|
ssh 2680v4
|
||||||
|
|
||||||
|
# 进入当前源码目录
|
||||||
|
cd /home/lipan/massive-cloud-v3
|
||||||
|
|
||||||
|
# 查看代码状态,注意是否有他人改动
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
# 查看当前容器
|
||||||
|
sudo docker ps
|
||||||
|
|
||||||
|
# 查看应用日志
|
||||||
|
sudo docker logs -f massive-point-cloud-app-dev
|
||||||
|
|
||||||
|
# 查看 3002 端口监听
|
||||||
|
sudo ss -lntp sport = :3002
|
||||||
|
|
||||||
|
# 查看穿透进程
|
||||||
|
pgrep -af "npc|nps|frp"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `massive-cloud-export` 不是当前最新开发源码目录,后续维护不要以它为准。
|
||||||
|
- 如果需要保留旧版交付材料,可以继续保留 `massive-cloud-export`;如果要清理,应先确认其中截图、部署包说明、历史交付内容是否还需要留档。
|
||||||
|
- 直接在服务器改代码时,务必先确认工作区状态,避免覆盖另一名开发人员正在修改的文件。
|
||||||
22
drizzle/0010_model_library_task_links.sql
Normal file
22
drizzle/0010_model_library_task_links.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 车型点云库,以及任务输入对车型库文件的轻量关联
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`modelId` INT NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`fileSize` BIGINT NOT NULL,
|
||||||
|
`filePath` VARCHAR(512) NOT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_model_pointcloud_files_modelId` (`modelId`)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE `task_pointcloud_files`
|
||||||
|
ADD COLUMN `sourceModelPointcloudId` INT NULL
|
||||||
|
AFTER `sourceResultId`;
|
||||||
|
|
||||||
|
ALTER TABLE `task_cad_files`
|
||||||
|
ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT 'uploaded'
|
||||||
|
AFTER `filePath`,
|
||||||
|
ADD COLUMN `sourceModelCadFileId` INT NULL
|
||||||
|
AFTER `source`;
|
||||||
5
drizzle/0011_registration_source_pointcloud.sql
Normal file
5
drizzle/0011_registration_source_pointcloud.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 记录配准产物来自哪个任务点云文件,用于阻止链式配准并检测重复配准
|
||||||
|
|
||||||
|
ALTER TABLE `task_pointcloud_files`
|
||||||
|
ADD COLUMN `sourcePointcloudFileId` INT NULL
|
||||||
|
AFTER `sourceResultId`;
|
||||||
11
drizzle/0012_inspection_task_created_status.sql
Normal file
11
drizzle/0012_inspection_task_created_status.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- 检测任务新增 created 初始状态;task_analysis_results.status 仍保留原有语义
|
||||||
|
|
||||||
|
ALTER TABLE `inspection_tasks`
|
||||||
|
MODIFY COLUMN `status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created';
|
||||||
|
|
||||||
|
UPDATE `inspection_tasks` AS t
|
||||||
|
LEFT JOIN `task_analysis_results` AS r
|
||||||
|
ON r.`taskId` = t.`id`
|
||||||
|
SET t.`status` = 'created'
|
||||||
|
WHERE t.`status` = 'pending'
|
||||||
|
AND r.`id` IS NULL;
|
||||||
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 任务分析结果新增 cancelled:停止后保留记录与进度,不按 error 展示。
|
||||||
|
ALTER TABLE task_analysis_results
|
||||||
|
MODIFY COLUMN status ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending';
|
||||||
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `inspection_tasks`
|
||||||
|
ADD UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`);
|
||||||
22
drizzle/0015_local_users_and_roles.sql
Normal file
22
drizzle/0015_local_users_and_roles.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||||
|
ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`,
|
||||||
|
ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`,
|
||||||
|
ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`,
|
||||||
|
ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`,
|
||||||
|
ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`,
|
||||||
|
ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`;
|
||||||
|
|
||||||
|
UPDATE `users`
|
||||||
|
SET `username` = CONCAT('legacy_user_', `id`),
|
||||||
|
`isActive` = 0,
|
||||||
|
`mustChangePassword` = 1,
|
||||||
|
`sessionVersion` = `sessionVersion` + 1;
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY COLUMN `username` VARCHAR(64) NOT NULL,
|
||||||
|
ADD UNIQUE INDEX `users_username_unique` (`username`),
|
||||||
|
DROP INDEX `users_openId_unique`,
|
||||||
|
DROP COLUMN `openId`,
|
||||||
|
DROP COLUMN `loginMethod`,
|
||||||
|
DROP COLUMN `lastSignedIn`;
|
||||||
15
drizzle/0016_pointcloud_metadata.sql
Normal file
15
drizzle/0016_pointcloud_metadata.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
ALTER TABLE `model_pointcloud_files`
|
||||||
|
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN `pointCount` bigint NULL,
|
||||||
|
ADD COLUMN `pointAttributes` json NULL,
|
||||||
|
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||||
|
ADD COLUMN `bytesPerPoint` int NULL,
|
||||||
|
ADD COLUMN `metadataError` text NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `task_pointcloud_files`
|
||||||
|
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN `pointCount` bigint NULL,
|
||||||
|
ADD COLUMN `pointAttributes` json NULL,
|
||||||
|
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||||
|
ADD COLUMN `bytesPerPoint` int NULL,
|
||||||
|
ADD COLUMN `metadataError` text NULL;
|
||||||
33
drizzle/0017_inspection_objects.sql
Normal file
33
drizzle/0017_inspection_objects.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `inspection_objects` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`modelId` INT NOT NULL,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`status` ENUM('draft','active') NOT NULL DEFAULT 'draft',
|
||||||
|
`defaultModelPointcloudId` INT NULL,
|
||||||
|
`defaultModelCadFileId` INT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY `uk_inspection_objects_model_name` (`modelId`,`name`),
|
||||||
|
KEY `idx_inspection_objects_model` (`modelId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `model_pointcloud_files`
|
||||||
|
ADD COLUMN `inspectionObjectId` INT NULL AFTER `filePath`,
|
||||||
|
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `inspectionObjectId`,
|
||||||
|
ADD KEY `idx_model_pointcloud_object` (`inspectionObjectId`);
|
||||||
|
|
||||||
|
ALTER TABLE `model_cad_files`
|
||||||
|
ADD COLUMN `contentScope` ENUM('whole_vehicle') NOT NULL DEFAULT 'whole_vehicle' AFTER `filePath`,
|
||||||
|
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `contentScope`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `model_cad_inspection_objects` (
|
||||||
|
`cadFileId` INT NOT NULL,
|
||||||
|
`inspectionObjectId` INT NOT NULL,
|
||||||
|
UNIQUE KEY `uk_model_cad_inspection_object` (`cadFileId`,`inspectionObjectId`),
|
||||||
|
KEY `idx_model_cad_object` (`inspectionObjectId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `inspection_tasks`
|
||||||
|
ADD COLUMN `inspectionObjectId` INT NULL AFTER `modelId`,
|
||||||
|
ADD KEY `idx_inspection_tasks_object` (`inspectionObjectId`);
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json } from "drizzle-orm/mysql-core";
|
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||||
|
import type { PointAttribute } from "../shared/pointcloudMetadata";
|
||||||
|
|
||||||
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
// ─── 本地用户 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const users = mysqlTable("users", {
|
export const users = mysqlTable("users", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
username: varchar("username", { length: 64 }).notNull().unique(),
|
||||||
|
passwordHash: varchar("passwordHash", { length: 255 }),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
email: varchar("email", { length: 320 }),
|
email: varchar("email", { length: 320 }),
|
||||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
|
||||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
isActive: int("isActive").default(1).notNull(),
|
||||||
|
mustChangePassword: int("mustChangePassword").default(1).notNull(),
|
||||||
|
sessionVersion: int("sessionVersion").default(0).notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
lastLoginAt: timestamp("lastLoginAt"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -46,6 +50,25 @@ export const vehicleModels = mysqlTable("vehicle_models", {
|
|||||||
export type VehicleModel = typeof vehicleModels.$inferSelect;
|
export type VehicleModel = typeof vehicleModels.$inferSelect;
|
||||||
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
|
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
|
||||||
|
|
||||||
|
// ─── 检测对象 ────────────────────────────────────────────────────────────────
|
||||||
|
// 车型下真正执行检测的对象,例如头车、中间车。默认资源只影响新建任务。
|
||||||
|
export const inspectionObjects = mysqlTable("inspection_objects", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
modelId: int("modelId").notNull(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: mysqlEnum("status", ["draft", "active"]).default("draft").notNull(),
|
||||||
|
defaultModelPointcloudId: int("defaultModelPointcloudId"),
|
||||||
|
defaultModelCadFileId: int("defaultModelCadFileId"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
modelNameUnique: uniqueIndex("uk_inspection_objects_model_name").on(table.modelId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type InspectionObject = typeof inspectionObjects.$inferSelect;
|
||||||
|
export type InsertInspectionObject = typeof inspectionObjects.$inferInsert;
|
||||||
|
|
||||||
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
|
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
|
||||||
|
|
||||||
@@ -56,12 +79,44 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
|
|||||||
format: varchar("format", { length: 32 }).notNull(),
|
format: varchar("format", { length: 32 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||||
|
contentScope: mysqlEnum("contentScope", ["whole_vehicle"]).default("whole_vehicle").notNull(),
|
||||||
|
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
||||||
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
|
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
|
||||||
|
|
||||||
|
export const modelCadInspectionObjects = mysqlTable("model_cad_inspection_objects", {
|
||||||
|
cadFileId: int("cadFileId").notNull(),
|
||||||
|
inspectionObjectId: int("inspectionObjectId").notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
cadObjectUnique: uniqueIndex("uk_model_cad_inspection_object").on(table.cadFileId, table.inspectionObjectId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||||
|
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
||||||
|
|
||||||
|
export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
modelId: int("modelId").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
|
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||||
|
inspectionObjectId: int("inspectionObjectId"),
|
||||||
|
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
|
||||||
|
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||||
|
pointCount: bigint("pointCount", { mode: "number" }),
|
||||||
|
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||||
|
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
|
||||||
|
bytesPerPoint: int("bytesPerPoint"),
|
||||||
|
metadataError: text("metadataError"),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModelPointcloudFile = typeof modelPointcloudFiles.$inferSelect;
|
||||||
|
export type InsertModelPointcloudFile = typeof modelPointcloudFiles.$inferInsert;
|
||||||
|
|
||||||
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// 每个任务可关联任意个 CAD 文件(与车型级 CAD 独立,用于本次检测的实测参考)
|
// 每个任务可关联任意个 CAD 文件(与车型级 CAD 独立,用于本次检测的实测参考)
|
||||||
|
|
||||||
@@ -72,6 +127,10 @@ export const taskCadFiles = mysqlTable("task_cad_files", {
|
|||||||
format: varchar("format", { length: 32 }).notNull(),
|
format: varchar("format", { length: 32 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||||
|
/** 来源:uploaded = 用户上传;model_library = 车型 CAD 库关联 */
|
||||||
|
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
|
||||||
|
/** 来源车型 CAD 文件 ID(仅 model_library 时有值) */
|
||||||
|
sourceModelCadFileId: int("sourceModelCadFileId"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,13 +143,18 @@ export type InsertTaskCadFile = typeof taskCadFiles.$inferInsert;
|
|||||||
export const inspectionTasks = mysqlTable("inspection_tasks", {
|
export const inspectionTasks = mysqlTable("inspection_tasks", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
modelId: int("modelId").notNull(),
|
modelId: int("modelId").notNull(),
|
||||||
|
inspectionObjectId: int("inspectionObjectId"),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
inspector: varchar("inspector", { length: 100 }),
|
inspector: varchar("inspector", { length: 100 }),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
status: mysqlEnum("status", ["created", "pending", "processing", "completed", "error"]).default("created").notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
completedAt: timestamp("completedAt"),
|
completedAt: timestamp("completedAt"),
|
||||||
|
}, (table) => {
|
||||||
|
return {
|
||||||
|
modelNameUnique: uniqueIndex("uk_inspection_tasks_model_name").on(table.modelId, table.name),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export type InspectionTask = typeof inspectionTasks.$inferSelect;
|
export type InspectionTask = typeof inspectionTasks.$inferSelect;
|
||||||
@@ -105,10 +169,20 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||||
/** 来源:uploaded = 用户上传;registered = 配准算法产出 */
|
/** 来源:uploaded = 用户上传;registered = 配准算法产出;model_library = 车型点云库关联 */
|
||||||
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
|
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
|
||||||
/** 产出该点云的分析结果 ID(仅 registered 时有值) */
|
/** 产出该点云的分析结果 ID(仅 registered 时有值) */
|
||||||
sourceResultId: int("sourceResultId"),
|
sourceResultId: int("sourceResultId"),
|
||||||
|
/** 生成该配准产物的原始任务点云 ID(仅 registered 时有值) */
|
||||||
|
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
||||||
|
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
||||||
|
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
||||||
|
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||||
|
pointCount: bigint("pointCount", { mode: "number" }),
|
||||||
|
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||||
|
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
|
||||||
|
bytesPerPoint: int("bytesPerPoint"),
|
||||||
|
metadataError: text("metadataError"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,7 +209,7 @@ export const taskAnalysisResults = mysqlTable("task_analysis_results", {
|
|||||||
pluginId: varchar("pluginId", { length: 64 }),
|
pluginId: varchar("pluginId", { length: 64 }),
|
||||||
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
|
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
|
||||||
schemaVersion: int("schemaVersion"),
|
schemaVersion: int("schemaVersion"),
|
||||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
status: mysqlEnum("status", ["pending", "processing", "completed", "error", "cancelled"]).default("pending").notNull(),
|
||||||
errorStats: json("errorStats"), // { min, max, mean, stdDev, totalPoints, withinTolerance, outOfTolerance }
|
errorStats: json("errorStats"), // { min, max, mean, stdDev, totalPoints, withinTolerance, outOfTolerance }
|
||||||
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
|
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
|
||||||
resultData: json("resultData"), // 其他分析数据(如压缩比等)
|
resultData: json("resultData"), // 其他分析数据(如压缩比等)
|
||||||
|
|||||||
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise";
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
|
export type Measurement = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface CandidateRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
taskId: number;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackfillPlan {
|
||||||
|
row: CandidateRow;
|
||||||
|
resultJsonPath: string;
|
||||||
|
originalResultJson: Record<string, unknown>;
|
||||||
|
measurements: Measurement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionSummary {
|
||||||
|
planned: number;
|
||||||
|
applied: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMeasurements<T extends Record<string, unknown>>(
|
||||||
|
value: T | null,
|
||||||
|
measurements: Measurement[],
|
||||||
|
): T & { measurements: Measurement[] } {
|
||||||
|
return { ...(value ?? ({} as T)), measurements };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executePlans(
|
||||||
|
plans: BackfillPlan[],
|
||||||
|
apply: boolean,
|
||||||
|
persist: (plan: BackfillPlan) => Promise<boolean>,
|
||||||
|
): Promise<ExecutionSummary> {
|
||||||
|
const summary: ExecutionSummary = {
|
||||||
|
planned: plans.length,
|
||||||
|
applied: 0,
|
||||||
|
skipped: apply ? 0 : plans.length,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
if (!apply) return summary;
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
try {
|
||||||
|
if (await persist(plan)) summary.applied += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.failed += 1;
|
||||||
|
console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function argumentValue(name: string): string | undefined {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseMeasurements(
|
||||||
|
excelPath: string,
|
||||||
|
templatePath: string,
|
||||||
|
helperPath: string,
|
||||||
|
): Promise<Measurement[]> {
|
||||||
|
const python = process.env.PYTHON3_PATH || "/usr/bin/python3";
|
||||||
|
const { stdout } = await execFile(
|
||||||
|
python,
|
||||||
|
[helperPath, "--excel", excelPath, "--template", templatePath],
|
||||||
|
{ maxBuffer: 10 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(stdout) as unknown;
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
throw new Error("解析结果为空")
|
||||||
|
}
|
||||||
|
return parsed.filter((item): item is Measurement => !!item && typeof item === "object");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCandidates(pool: Pool, resultId?: number): Promise<CandidateRow[]> {
|
||||||
|
const filters = [
|
||||||
|
"pluginId = 'flatness-suite'",
|
||||||
|
"status = 'completed'",
|
||||||
|
"(measurements IS NULL OR JSON_LENGTH(measurements) = 0)",
|
||||||
|
];
|
||||||
|
const params: number[] = [];
|
||||||
|
if (resultId !== undefined) {
|
||||||
|
filters.push("id = ?");
|
||||||
|
params.push(resultId);
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query<CandidateRow[]>(
|
||||||
|
`SELECT id, taskId, measurements, resultData
|
||||||
|
FROM task_analysis_results
|
||||||
|
WHERE ${filters.join(" AND ")}
|
||||||
|
ORDER BY id`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPlans(
|
||||||
|
rows: CandidateRow[],
|
||||||
|
dataDir: string,
|
||||||
|
templatePath: string,
|
||||||
|
helperPath: string,
|
||||||
|
): Promise<{ plans: BackfillPlan[]; skipped: number }> {
|
||||||
|
const plans: BackfillPlan[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id));
|
||||||
|
const resultJsonPath = path.join(resultDir, "result.json");
|
||||||
|
const excelPath = path.join(resultDir, "contour.xlsx");
|
||||||
|
try {
|
||||||
|
const [resultText] = await Promise.all([
|
||||||
|
fs.readFile(resultJsonPath, "utf8"),
|
||||||
|
fs.access(excelPath),
|
||||||
|
]);
|
||||||
|
const resultJson = JSON.parse(resultText) as Record<string, unknown>;
|
||||||
|
const extra = resultJson.extra as Record<string, unknown> | undefined;
|
||||||
|
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | undefined;
|
||||||
|
if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") {
|
||||||
|
throw new Error("不是 contour_batch 批量结果")
|
||||||
|
}
|
||||||
|
const measurements = await parseMeasurements(excelPath, templatePath, helperPath);
|
||||||
|
plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements });
|
||||||
|
console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`);
|
||||||
|
} catch (error) {
|
||||||
|
skipped += 1;
|
||||||
|
console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { plans, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
|
||||||
|
const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`;
|
||||||
|
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
await fs.rename(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise<string> {
|
||||||
|
const backupDir = path.join(dataDir, "maintenance-backups");
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`);
|
||||||
|
await writeJsonAtomically(backupPath, {
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
entries: plans.map((plan) => ({
|
||||||
|
id: plan.row.id,
|
||||||
|
taskId: plan.row.taskId,
|
||||||
|
resultJsonPath: plan.resultJsonPath,
|
||||||
|
measurements: plan.row.measurements,
|
||||||
|
resultData: plan.row.resultData,
|
||||||
|
resultJson: plan.originalResultJson,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistPlan(pool: Pool, plan: BackfillPlan): Promise<boolean> {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements);
|
||||||
|
const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements);
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, updatedResultJson);
|
||||||
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
|
`UPDATE task_analysis_results
|
||||||
|
SET measurements = ?, resultData = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND pluginId = 'flatness-suite'
|
||||||
|
AND status = 'completed'
|
||||||
|
AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`,
|
||||||
|
[JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id],
|
||||||
|
);
|
||||||
|
if (result.affectedRows !== 1) {
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson);
|
||||||
|
await connection.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupManifest {
|
||||||
|
entries: Array<{
|
||||||
|
id: number;
|
||||||
|
resultJsonPath: string;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: unknown;
|
||||||
|
resultJson: unknown;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup(pool: Pool, backupPath: string): Promise<void> {
|
||||||
|
const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest;
|
||||||
|
if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效")
|
||||||
|
for (const entry of manifest.entries) {
|
||||||
|
await writeJsonAtomically(entry.resultJsonPath, entry.resultJson);
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'",
|
||||||
|
[
|
||||||
|
entry.measurements == null ? null : JSON.stringify(entry.measurements),
|
||||||
|
entry.resultData == null ? null : JSON.stringify(entry.resultData),
|
||||||
|
entry.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
console.log(`[backfill] 已恢复结果 ${entry.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) throw new Error("缺少 DATABASE_URL")
|
||||||
|
const dataDir = process.env.DATA_DIR || "/data";
|
||||||
|
const algRoot = process.env.ALG_ROOT || "/alg";
|
||||||
|
const resultIdRaw = argumentValue("--result-id");
|
||||||
|
const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw);
|
||||||
|
if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数")
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
const restorePath = argumentValue("--restore");
|
||||||
|
const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx");
|
||||||
|
const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py");
|
||||||
|
const pool = mysql.createPool(databaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (restorePath) {
|
||||||
|
await restoreBackup(pool, restorePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = await listCandidates(pool, resultId);
|
||||||
|
const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath);
|
||||||
|
let backupPath: string | undefined;
|
||||||
|
if (apply && plans.length > 0) {
|
||||||
|
backupPath = await writeBackup(plans, dataDir);
|
||||||
|
console.log(`[backfill] 写入前备份: ${backupPath}`);
|
||||||
|
}
|
||||||
|
const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan));
|
||||||
|
summary.skipped += skipped;
|
||||||
|
console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`);
|
||||||
|
if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入");
|
||||||
|
if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||||
|
if (isMain) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[backfill] 失败:", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
60
scripts/migrate-inspection-objects.ts
Normal file
60
scripts/migrate-inspection-objects.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
type Mapping = { modelId: number; objectName: string; pointclouds: string[]; defaultPointcloud: string; cadFiles: string[]; defaultCad: string };
|
||||||
|
const mappings: Mapping[] = [
|
||||||
|
{ modelId: 1, objectName: '中间车', pointclouds: ['1_10.ply', '1_11.ply', '1_12.ply'], defaultPointcloud: '1_10.ply', cadFiles: ['train_1_mesh.ply'], defaultCad: 'train_1_mesh.ply' },
|
||||||
|
{ modelId: 2, objectName: '头车', pointclouds: ['2_10.ply', '2_11.ply', '2_12.ply', 'train_2_10M.ply', 'train_2_1T.ply'], defaultPointcloud: '2_10.ply', cadFiles: ['m000004802418tc01_asm.stp', 'train_2_mesh.ply'], defaultCad: 'm000004802418tc01_asm.stp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw new Error('DATABASE_URL is required');
|
||||||
|
const connection = await mysql.createConnection(connectionString);
|
||||||
|
let createdObjects = 0;
|
||||||
|
let assignedPointclouds = 0;
|
||||||
|
let assignedCads = 0;
|
||||||
|
let classifiedTasks = 0;
|
||||||
|
const warnings: Array<{ taskId: number; reason: string }> = [];
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const [existing] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await connection.execute('INSERT INTO inspection_objects (modelId,name,status) VALUES (?,?,\'draft\')', [mapping.modelId, mapping.objectName]);
|
||||||
|
createdObjects += 1;
|
||||||
|
}
|
||||||
|
const [objectRows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
|
||||||
|
const objectId = Number(objectRows[0].id);
|
||||||
|
for (const name of mapping.pointclouds) {
|
||||||
|
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id,inspectionObjectId FROM model_pointcloud_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
|
||||||
|
if (rows.length !== 1) { conflicts.push(`点云 model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
|
||||||
|
if (rows[0].inspectionObjectId && Number(rows[0].inspectionObjectId) !== objectId) { conflicts.push(`点云 ${name} 已属于其他检测对象`); continue; }
|
||||||
|
if (!rows[0].inspectionObjectId) { await connection.execute('UPDATE model_pointcloud_files SET inspectionObjectId=?,status=\'available\' WHERE id=?', [objectId, rows[0].id]); assignedPointclouds += 1; }
|
||||||
|
}
|
||||||
|
for (const name of mapping.cadFiles) {
|
||||||
|
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_cad_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
|
||||||
|
if (rows.length !== 1) { conflicts.push(`CAD model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
|
||||||
|
const [result] = await connection.execute<mysql.ResultSetHeader>('INSERT IGNORE INTO model_cad_inspection_objects (cadFileId,inspectionObjectId) VALUES (?,?)', [rows[0].id, objectId]);
|
||||||
|
assignedCads += result.affectedRows;
|
||||||
|
}
|
||||||
|
const [[defaultPc]] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_pointcloud_files WHERE modelId=? AND name=? AND inspectionObjectId=?', [mapping.modelId, mapping.defaultPointcloud, objectId]);
|
||||||
|
const [[defaultCad]] = await connection.query<mysql.RowDataPacket[]>('SELECT c.id FROM model_cad_files c JOIN model_cad_inspection_objects r ON r.cadFileId=c.id WHERE c.modelId=? AND c.name=? AND r.inspectionObjectId=?', [mapping.modelId, mapping.defaultCad, objectId]);
|
||||||
|
if (defaultPc && defaultCad) await connection.execute('UPDATE inspection_objects SET defaultModelPointcloudId=?,defaultModelCadFileId=?,status=\'active\' WHERE id=?', [defaultPc.id, defaultCad.id, objectId]);
|
||||||
|
else conflicts.push(`${mapping.objectName} 默认输入方案资源不完整`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tasks] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_tasks WHERE inspectionObjectId IS NULL ORDER BY id');
|
||||||
|
for (const task of tasks) {
|
||||||
|
const [sources] = await connection.query<mysql.RowDataPacket[]>('SELECT DISTINCT mp.inspectionObjectId FROM task_pointcloud_files tp JOIN model_pointcloud_files mp ON mp.id=tp.sourceModelPointcloudId WHERE tp.taskId=? AND mp.inspectionObjectId IS NOT NULL', [task.id]);
|
||||||
|
if (sources.length === 1) { await connection.execute('UPDATE inspection_tasks SET inspectionObjectId=? WHERE id=? AND inspectionObjectId IS NULL', [sources[0].inspectionObjectId, task.id]); classifiedTasks += 1; }
|
||||||
|
else warnings.push({ taskId: Number(task.id), reason: sources.length > 1 ? '包含不同检测对象的车型库点云' : '无可推断的车型库来源点云' });
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
console.log(JSON.stringify({ createdObjects, assignedPointclouds, assignedCads, classifiedTasks, unclassifiedTasks: warnings, conflicts }, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user