Compare commits

..

9 Commits

88 changed files with 7390 additions and 3589 deletions

4
.gitignore vendored
View File

@@ -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*/

View File

@@ -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拟合
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务

View File

@@ -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 提取是否准确

View File

@@ -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)

View File

@@ -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

View 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(代码编辑)

View 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`
---
**重构完成!代码结构更清晰,功能完整可用。**

View File

@@ -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

View File

@@ -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)'
) )

View File

@@ -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

View File

@@ -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)

View File

@@ -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'
}

View 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']}")

View 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

View File

@@ -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:

View File

@@ -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()}"
)

View File

@@ -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()}"
)

View File

@@ -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

View File

@@ -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(

View File

@@ -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} 内没有点")

View File

@@ -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(

View File

@@ -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)

View File

@@ -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))

View File

@@ -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")

View File

@@ -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

View File

@@ -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:
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1) # 使用空间索引快速查询
indices = np.where(mask)[0] indices = spatial_index.query_bbox(bbox_min, bbox_max)
roi_points = points[indices] else:
# 回退到线性扫描
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
indices = np.where(mask)[0]
roi_points = points[indices]
return roi_points, indices return roi_points, indices
@staticmethod @staticmethod

View File

@@ -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',
]

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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']:,} 个节点")

View File

@@ -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:

View File

@@ -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):

File diff suppressed because it is too large Load Diff

View File

@@ -9,12 +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, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TrainMeasurementPanel from './TrainMeasurementPanel'; import TrainMeasurementPanel from './TrainMeasurementPanel';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
'coupler-panel',
'flatness-suite',
'underframe-deflection',
];
interface PluginInputField { interface PluginInputField {
name: string; name: string;
label: string; label: string;
@@ -42,12 +49,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 +79,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 +94,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,13 +104,53 @@ 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 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 [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());
@@ -125,14 +191,120 @@ 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 demoAlgorithmCount = demoAllPluginIds.length;
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
const demoPluginIds = new Set(demoAllPluginIds);
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
const demoFlowRunning = runningDemoResults.length > 0;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
const 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 || 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 ? '暂无可执行的测量算法'
: 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 +312,90 @@ 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 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 items-center 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" />}
运行全部 {demoAlgorithmCount} 个算法
</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 +406,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 +419,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 +456,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 +465,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 +491,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 +510,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}

View File

@@ -1,7 +1,9 @@
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 { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView'; import GenericResultView, { type Overlay3D } from './GenericResultView';
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,7 +55,13 @@ 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 = { name: string; value: number; unit: string; tolerance: number; status: string };
@@ -162,20 +170,182 @@ 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;
};
const SHOW_LEGACY_ANALYSIS_ACTIONS = 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),
};
}
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 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 [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,6 +356,394 @@ 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);
useEffect(() => {
const flowIdsToExpand: string[] = [];
const resultIdsToExpand: number[] = [];
for (const result of results as AnalysisResultRow[]) {
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);
}
}
if (flowIdsToExpand.length > 0) {
setExpandedFlowIds((prev) => {
const next = new Set(prev);
for (const id of flowIdsToExpand) next.add(id);
return next;
});
}
if (resultIdsToExpand.length > 0) {
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 renderResultCard(r: AnalysisResultRow, extraClassName = '') {
const expanded = expandedIds.has(r.id);
const errorStats = r.errorStats as ErrorStats | null;
const measurements = r.measurements as Measurement[] | null;
const resultTitle = getResultTitle(r, pluginNameById);
return (
<div key={r.id} className={`industrial-card rounded overflow-hidden min-w-0 ${extraClassName}`}>
{/* 标题行 */}
<div
className="px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
onClick={() => toggleExpand(r.id)}
>
<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 === '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 === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-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 === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
</TooltipTrigger>
<TooltipContent
side="left"
align="start"
className="max-w-[min(28rem,calc(100vw-2rem))] break-all"
>
{resultTitle}
</TooltipContent>
</Tooltip>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
{onReplay && r.status === 'completed' && (
<button
className="p-0.5 rounded hover:bg-secondary"
title="回放此结果"
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
>
<Play className="w-3 h-3 text-primary" />
</button>
)}
{isCancellableStatus(r.status) && (
<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>
)}
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</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' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
{errorStats && <ErrorStatCard stats={errorStats} />}
{measurements && measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
<MeasurementTable items={measurements} />
</div>
)}
</div>
)}
{expanded &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
(r.status === 'processing' || r.status === 'pending') &&
(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
const p = hm?.progress ?? 0;
const stage = hm?.stage ?? '排队或启动中…';
return (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
<div className="text-[10px] text-muted-foreground">{stage}</div>
<div className="industrial-progress h-1.5">
<div
className="industrial-progress-bar transition-[width] duration-300"
style={{
width: `${Math.min(100, Math.max(0, p * 100))}%`,
background: 'oklch(0.55 0.14 250)',
}}
/>
</div>
<p className="text-[9px] text-muted-foreground/80">
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
</p>
</div>
);
})()}
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
</p>
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const am = r.resultData as AssemblyMeta | null;
const tol = am?.toleranceMm ?? 0.3;
const parts = am?.parts ?? [];
return (
<>
<div className="text-[10px] text-muted-foreground">
阈值 &lt; {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
</div>
<div className="overflow-x-auto max-h-48 overflow-y-auto">
<table className="w-full text-[9px] border-collapse">
<thead>
<tr className="text-muted-foreground text-left">
<th className="py-0.5 pr-1">零件</th>
<th className="py-0.5 pr-1 text-right">点数</th>
<th className="py-0.5 pr-1 text-right">min</th>
<th className="py-0.5 pr-1 text-right">max</th>
<th className="py-0.5 pr-1 text-right">mean</th>
<th className="py-0.5 text-right">合格率</th>
</tr>
</thead>
<tbody>
{parts.map((p) => (
<tr key={p.partId} className="border-t border-border/30">
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
{p.name}
</td>
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
<td className="py-0.5 text-right">
<span
className={
p.passRate >= 0.95
? 'text-green-400'
: p.passRate >= 0.85
? 'text-amber-400'
: 'text-red-400'
}
>
{(p.passRate * 100).toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
</p>
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
</p>
</div>
)}
{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">
<GenericResultView
taskId={taskId}
resultId={r.id}
pluginId={r.pluginId ?? undefined}
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
onViewOverlay={onViewPluginOverlay}
/>
</div>
)}
</div>
);
}
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 runningItems = group.results
.filter((r) => r.status === 'processing' || r.status === 'pending')
.slice(0, 2)
.map((r) => {
const data = asRecord(r.resultData);
const progress = numberFromUnknown(data?.progress);
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
});
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">全流程算法结果</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">
{runningItems.length > 0 && (
<div className="text-[10px] text-muted-foreground truncate">
运行中:{runningItems.join('、')}
</div>
)}
<div className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
{group.results.map((result) => renderResultCard(result))}
</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)
))}
</div>
);
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -195,35 +753,51 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`} type="button"
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })} className="industrial-btn py-0.5 px-1 text-[10px] flex items-center justify-center"
disabled={running} onClick={() => setMaximizedOpen(true)}
title="误差统计分析" title="最大化查看分析结果"
> >
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />} <Maximize2 className="w-3 h-3" />
{running ? '分析中…' : '误差分析'}
</button> </button>
{onRunAssembly && ( {/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
<button 误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`} 装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
onClick={onRunAssembly} 偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
disabled={externalPending} {SHOW_LEGACY_ANALYSIS_ACTIONS && (
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计" <>
> <button
<Layers className="w-3 h-3" /> 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' })}
</button> disabled={running}
)} title="误差统计分析"
{onRunDeviation && ( >
<button {running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`} {running ? '分析中…' : '误差分析'}
onClick={onRunDeviation} </button>
disabled={externalPending} {onRunAssembly && (
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色" <button
> className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
<Crosshair className="w-3 h-3" /> onClick={onRunAssembly}
偏差热力 disabled={externalPending}
</button> 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>
</div> </div>
@@ -238,285 +812,27 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</div> </div>
)} )}
<div className="space-y-1.5"> {!isLoading && results.length > 0 && renderResultList('panel')}
{results.map(r => {
const expanded = expandedIds.has(r.id);
const errorStats = r.errorStats as ErrorStats | null;
const measurements = r.measurements as Measurement[] | null;
return ( <Dialog open={maximizedOpen} onOpenChange={setMaximizedOpen}>
<div key={r.id} className="industrial-card rounded overflow-hidden"> <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">
<div <DialogTitle className="text-sm flex items-center gap-2">
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group" <BarChart2 className="w-4 h-4" />
onClick={() => toggleExpand(r.id)} 分析结果
> </DialogTitle>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />} </DialogHeader>
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />} <div className="min-h-0 overflow-y-auto p-4">
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />} {isLoading && (
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />} <div className="text-xs text-muted-foreground text-center py-8">加载中…</div>
{r.type === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-400 flex-shrink-0" />} )}
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />} {!isLoading && results.length === 0 && (
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />} <div className="text-xs text-muted-foreground text-center py-8">暂无分析结果</div>
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />} )}
<span className="text-xs flex-1 truncate"> {!isLoading && results.length > 0 && renderResultList('maximized')}
{r.type === 'error_analysis' </div>
? '误差分析' </DialogContent>
: r.type === 'measurement' </Dialog>
? '尺寸测量'
: r.type === 'smooth_heatmap'
? '平滑热力'
: r.type === 'assembly_inspection'
? '装配检测'
: r.type === 'obj_deviation'
? '偏差热力'
: r.type === 'plugin'
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
: '压缩'}
</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' && (
<button
className="p-0.5 rounded hover:bg-secondary"
title="回放此结果"
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
>
<Play className="w-3 h-3 text-primary" />
</button>
)}
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</div>
{/* 展开内容 */}
{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">
{errorStats && <ErrorStatCard stats={errorStats} />}
{measurements && measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
<MeasurementTable items={measurements} />
</div>
)}
</div>
)}
{expanded &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
r.status === 'processing' &&
(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
const p = hm?.progress ?? 0;
const stage = hm?.stage ?? '排队或启动中…';
return (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
<div className="text-[10px] text-muted-foreground">{stage}</div>
<div className="industrial-progress h-1.5">
<div
className="industrial-progress-bar transition-[width] duration-300"
style={{
width: `${Math.min(100, Math.max(0, p * 100))}%`,
background: 'oklch(0.55 0.14 250)',
}}
/>
</div>
<p className="text-[9px] text-muted-foreground/80">
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
</p>
</div>
);
})()}
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
</p>
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const am = r.resultData as AssemblyMeta | null;
const tol = am?.toleranceMm ?? 0.3;
const parts = am?.parts ?? [];
return (
<>
<div className="text-[10px] text-muted-foreground">
阈值 &lt; {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
</div>
<div className="overflow-x-auto max-h-48 overflow-y-auto">
<table className="w-full text-[9px] border-collapse">
<thead>
<tr className="text-muted-foreground text-left">
<th className="py-0.5 pr-1">零件</th>
<th className="py-0.5 pr-1 text-right">点数</th>
<th className="py-0.5 pr-1 text-right">min</th>
<th className="py-0.5 pr-1 text-right">max</th>
<th className="py-0.5 pr-1 text-right">mean</th>
<th className="py-0.5 text-right">合格率</th>
</tr>
</thead>
<tbody>
{parts.map((p) => (
<tr key={p.partId} className="border-t border-border/30">
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
{p.name}
</td>
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
<td className="py-0.5 text-right">
<span
className={
p.passRate >= 0.95
? 'text-green-400'
: p.passRate >= 0.85
? 'text-amber-400'
: 'text-red-400'
}
>
{(p.passRate * 100).toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
</p>
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
</p>
</div>
)}
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error') && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<GenericResultView
taskId={taskId}
resultId={r.id}
pluginId={(r as { pluginId?: string }).pluginId ?? undefined}
pluginName={(r as { pluginId?: string }).pluginId ?? '算法插件'}
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
onViewOverlay={onViewPluginOverlay}
/>
</div>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }

View File

@@ -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" : ""}`}

View File

@@ -15,7 +15,14 @@ interface Measurement {
value: number; value: number;
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;
failLabel?: string;
} }
interface ErrorStats { interface ErrorStats {
@@ -31,7 +38,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 +50,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;
} }
@@ -54,7 +67,13 @@ interface Props {
pluginName?: string; pluginName?: string;
data: PluginResultLike; data: PluginResultLike;
/** 用户点击「在 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']) { function statusClass(s?: Measurement['status']) {
@@ -70,16 +89,16 @@ function statusClass(s?: Measurement['status']) {
} }
} }
function statusLabel(s?: Measurement['status']) { function statusLabel(m: Measurement) {
switch (s) { switch (m.status) {
case 'pass': case 'pass':
return '合格'; return m.passLabel ?? '合格';
case 'warning': case 'warning':
return '警告'; return '警告';
case 'fail': case 'fail':
return '超差'; return m.failLabel ?? '超差';
default: default:
return '—'; return '参考';
} }
} }
@@ -90,9 +109,52 @@ function formatValue(v: number): string {
return v.toFixed(3); return v.toFixed(3);
} }
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;
}
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,
onViewOverlay, onViewOverlay,
@@ -101,15 +163,19 @@ export default function GenericResultView({
const errorStats = data.errorStats ?? null; const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? []; const measurements = data.measurements ?? [];
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>
)} )}
@@ -152,14 +218,22 @@ export default function GenericResultView({
{m.name} {m.name}
</td> </td>
<td className="text-right font-mono"> <td className="text-right font-mono">
{formatValue(m.value)} {m.unit ?? ''} {m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td> </td>
<td className="text-right text-muted-foreground"> <td className="text-right text-muted-foreground">
{m.tolerance != null ? `±${m.tolerance}` : '—'} {m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
? m.baseline != null
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td> </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>
@@ -173,7 +247,7 @@ export default function GenericResultView({
<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 中查看叠加层

View File

@@ -415,7 +415,12 @@ 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) {
// 点云自带颜色(如平整度热力图)直接使用,不走 errorToColor
colors[i3] = point.r;
colors[i3 + 1] = point.g;
colors[i3 + 2] = point.b;
} else if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError); const color = errorToColor(point.error ?? 0, minError, maxError);
colors[i3] = color.r; colors[i3] = color.r;
colors[i3 + 1] = color.g; colors[i3 + 1] = color.g;
@@ -1018,7 +1023,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;
@@ -1173,6 +1178,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;
@@ -1202,6 +1208,7 @@ const SceneContent = memo(function SceneContent({
pointCloudData, pointCloudData,
pointSize, pointSize,
showHeatmap, showHeatmap,
showPointCloud,
pointCloudRenderMode, pointCloudRenderMode,
enablePointPicking, enablePointPicking,
showCADModel, showCADModel,
@@ -1248,9 +1255,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 +1300,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 +1313,7 @@ const SceneContent = memo(function SceneContent({
{pointCloudData && {pointCloudData &&
pointCloudRenderMode === 'smoothHeatmap' && pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry && cadSurfaceGeometry &&
showCADModel &&
!cadHeatmapSuppressClient && !cadHeatmapSuppressClient &&
!useDirectColoring && ( !useDirectColoring && (
<CadSurfaceHeatmapMesh <CadSurfaceHeatmapMesh
@@ -1309,34 +1323,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)}
/>
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
/> />
{!cadSurfaceGeometry && (
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
/>
)}
</> </>
)} )}
@@ -1389,7 +1392,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 +1406,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 &&
@@ -1529,11 +1533,13 @@ 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 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 +1549,15 @@ 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, 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 +1577,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 +1614,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 视图中点击任意点,
@@ -1617,6 +1630,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,
@@ -1700,6 +1714,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 +1770,96 @@ 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 }); // 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
? overlay.files
: [overlay.file];
const resp = await fetch('/api/files/sample/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
totalPoints: 300_000,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text(); const hasColor = resp.headers.get('X-Has-Color') === '1';
const parsed = parsePlyText(text); const stride = hasColor ? 7 : 4;
if (parsed) setPlyOverlayData(parsed); 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: '加载 PLY 叠加层失败' });
} }
})(); })();
return () => ac.abort(); return () => ac.abort();
}, [pluginOverlay]); }, [pluginOverlay, pointCloudData]);
useEffect(() => { useEffect(() => {
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref // 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
@@ -1881,16 +1964,21 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => { const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null; if (!showHeatmap) 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 +2032,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 +2100,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}

View File

@@ -13,7 +13,10 @@ import {
Square, Square,
FileBox, FileBox,
Eye, Eye,
Library,
} from 'lucide-react'; } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { toast } from 'sonner';
interface Props { interface Props {
modelId: number; modelId: number;
@@ -37,6 +40,38 @@ function formatSize(bytes: number) {
return (bytes / 1e9).toFixed(2) + ' GB'; return (bytes / 1e9).toFixed(2) + ' GB';
} }
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 });
}
type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error';
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
created: '未开始',
pending: '待处理',
processing: '处理中',
completed: '已完成',
error: '异常',
};
function taskStatusClass(status: InspectionTaskStatus) {
switch (status) {
case 'completed':
return 'bg-green-900/40 text-green-400';
case 'processing':
case 'pending':
return 'bg-blue-900/40 text-blue-400';
case 'error':
return 'bg-red-900/40 text-red-400';
case 'created':
default:
return 'bg-secondary text-muted-foreground';
}
}
export default function TaskManager({ export default function TaskManager({
modelId, modelId,
activeTaskId, activeTaskId,
@@ -54,7 +89,11 @@ export default function TaskManager({
const [uploadingFor, setUploadingFor] = useState<number | null>(null); const [uploadingFor, setUploadingFor] = useState<number | null>(null);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const modelPointcloudFileRef = useRef<HTMLInputElement>(null);
const pendingTaskId = useRef<number | null>(null); const pendingTaskId = useRef<number | null>(null);
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
// ── 点云文件多选状态 ────────────────────────────────────────────────────── // ── 点云文件多选状态 ──────────────────────────────────────────────────────
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set()); const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
@@ -62,12 +101,28 @@ export default function TaskManager({
// ── 任务级 CAD 文件多选状态 ─────────────────────────────────────────────── // ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set()); const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
const cadFileRef = useRef<HTMLInputElement>(null); const cadFileRef = useRef<HTMLInputElement>(null);
const modelCadFileRef = useRef<HTMLInputElement>(null);
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null); const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
const [cadUploadProgress, setCadUploadProgress] = useState(0); const [cadUploadProgress, setCadUploadProgress] = useState(0);
const [cadLibraryOpen, setCadLibraryOpen] = useState(false);
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
const [uploadingModelCad, setUploadingModelCad] = useState(false);
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId }); const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery(
{ modelId },
{
refetchInterval: 3000,
refetchIntervalInBackground: false,
},
);
const createTask = trpc.inspectionTask.create.useMutation({ const createTask = trpc.inspectionTask.create.useMutation({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }), onSuccess: () => {
utils.inspectionTask.listByModel.invalidate({ modelId });
setTaskName('');
setAdding(false);
onEndCreate?.();
},
onError: (e) => toast.error(e.message || '新建任务失败'),
}); });
const deleteTask = trpc.inspectionTask.delete.useMutation({ const deleteTask = trpc.inspectionTask.delete.useMutation({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }), onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
@@ -81,6 +136,16 @@ export default function TaskManager({
const deletePcFile = trpc.pointcloudFile.delete.useMutation({ const deletePcFile = trpc.pointcloudFile.delete.useMutation({
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }), onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
}); });
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
{ modelId },
{ enabled: activeTaskId != null },
);
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
onSuccess: () => {
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
setSelectedModelPcFileIds(new Set());
},
});
// 当前选中任务下的 CAD 文件 // 当前选中任务下的 CAD 文件
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery( const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
@@ -90,11 +155,32 @@ export default function TaskManager({
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({ const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }), onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
}); });
const { data: modelCadFiles = [] } = trpc.cadFile.list.useQuery(
{ modelId },
{ enabled: activeTaskId != null },
);
const linkCadFilesFromModel = trpc.taskCadFile.linkFromModel.useMutation({
onSuccess: () => {
if (activeTaskId != null) utils.taskCadFile.list.invalidate({ taskId: activeTaskId });
setSelectedModelCadFileIds(new Set());
},
});
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
const cadFileIds = taskCadFiles.map((f) => f.id);
const { data: cadSampleCacheMap = {} } = trpc.taskCadFile.hasSampleCache.useQuery(
{ cadFileIds },
{ enabled: cadFileIds.length > 0, staleTime: 30_000 },
);
// 切换任务时清空文件选中状态 // 切换任务时清空文件选中状态
useEffect(() => { useEffect(() => {
setSelectedFileIds(new Set()); setSelectedFileIds(new Set());
setSelectedCadFileIds(new Set()); setSelectedCadFileIds(new Set());
setSelectedModelPcFileIds(new Set());
setSelectedModelCadFileIds(new Set());
setPcLibraryOpen(false);
setCadLibraryOpen(false);
}, [activeTaskId]); }, [activeTaskId]);
// 通知父组件点云选中变化 // 通知父组件点云选中变化
@@ -126,11 +212,25 @@ export default function TaskManager({
}); });
} }
function toggleModelPcFile(id: number) {
setSelectedModelPcFileIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function openUpload(taskId: number) { function openUpload(taskId: number) {
pendingTaskId.current = taskId; pendingTaskId.current = taskId;
fileRef.current?.click(); fileRef.current?.click();
} }
function submitCreateTask() {
const name = taskName.trim();
if (!name || createTask.isPending) return;
createTask.mutate({ modelId, name });
}
async function handleUpload(files: FileList) { async function handleUpload(files: FileList) {
const taskId = pendingTaskId.current; const taskId = pendingTaskId.current;
if (!taskId) return; if (!taskId) return;
@@ -165,6 +265,27 @@ export default function TaskManager({
} }
} }
async function handleModelPointcloudUpload(files: FileList) {
setUploadingModelPointcloud(true);
try {
for (let i = 0; i < files.length; i++) {
const fd = new FormData();
fd.append('file', files[i]);
const res = await fetch(`/api/files/upload/model-pointcloud/${modelId}`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error(await res.text());
}
utils.modelPointcloudFile.list.invalidate({ modelId });
} catch (e) {
console.error('Model pointcloud upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploadingModelPointcloud(false);
}
}
// 加载按钮:有勾选就只加载勾选的,否则加载全部 // 加载按钮:有勾选就只加载勾选的,否则加载全部
function handleLoad() { function handleLoad() {
if (activeTaskId == null || pcFiles.length === 0) return; if (activeTaskId == null || pcFiles.length === 0) return;
@@ -191,6 +312,28 @@ export default function TaskManager({
}); });
} }
function toggleModelCadFile(id: number) {
setSelectedModelCadFileIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function taskCadReadUrl(file: { name: string; source?: string }) {
const encoded = encodeURIComponent(file.name);
if (file.source === 'model_library') {
return `/api/files/read/cad/${modelId}/${encoded}`;
}
return `/api/files/read/task-cad/${activeTaskId}/${encoded}`;
}
function canPreviewCadIn3D(file: { name: string; format?: string }) {
const format = file.format?.toLowerCase();
const ext = file.name.split('.').pop()?.toLowerCase();
return format === 'stl' || format === 'obj' || ext === 'stl' || ext === 'obj';
}
async function handleCadUpload(files: FileList) { async function handleCadUpload(files: FileList) {
if (activeTaskId == null) return; if (activeTaskId == null) return;
setUploadingCadFor(activeTaskId); setUploadingCadFor(activeTaskId);
@@ -224,6 +367,27 @@ export default function TaskManager({
} }
} }
async function handleModelCadUpload(files: FileList) {
setUploadingModelCad(true);
try {
for (let i = 0; i < files.length; i++) {
const fd = new FormData();
fd.append('file', files[i]);
const res = await fetch(`/api/files/upload/cad/${modelId}`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error(await res.text());
}
utils.cadFile.list.invalidate({ modelId });
} catch (e) {
console.error('Model CAD upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploadingModelCad(false);
}
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 任务列表 */} {/* 任务列表 */}
@@ -253,10 +417,7 @@ export default function TaskManager({
onChange={(e) => setTaskName(e.target.value)} onChange={(e) => setTaskName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && taskName.trim()) { if (e.key === 'Enter' && taskName.trim()) {
createTask.mutate({ modelId, name: taskName.trim() }); submitCreateTask();
setTaskName('');
setAdding(false);
onEndCreate?.();
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setAdding(false); setAdding(false);
@@ -266,11 +427,8 @@ export default function TaskManager({
/> />
<button <button
className="text-green-400" className="text-green-400"
onClick={() => { disabled={createTask.isPending}
if (taskName.trim()) createTask.mutate({ modelId, name: taskName.trim() }); onClick={submitCreateTask}
setAdding(false);
onEndCreate?.();
}}
> >
<Check className="w-3.5 h-3.5" /> <Check className="w-3.5 h-3.5" />
</button> </button>
@@ -287,52 +445,47 @@ export default function TaskManager({
)} )}
<div className="space-y-1"> <div className="space-y-1">
{tasks.map((task) => ( {tasks.map((task) => {
<div const status = task.status as InspectionTaskStatus;
key={task.id} return (
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer <div
${activeTaskId === task.id ? 'bg-primary/20 text-primary' : 'industrial-card hover:bg-secondary/60'}`} key={task.id}
onClick={() => onSelectTask(task.id)} className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
> ${activeTaskId === task.id ? 'bg-primary/20 text-primary' : 'industrial-card hover:bg-secondary/60'}`}
<ClipboardList onClick={() => onSelectTask(task.id)}
className={`w-3.5 h-3.5 flex-shrink-0 ${activeTaskId === task.id ? 'text-primary' : 'text-amber-400'}`}
/>
<span className="flex-1 truncate">{task.name}</span>
<span
className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${
task.status === 'completed'
? 'bg-green-900/40 text-green-400'
: task.status === 'processing'
? 'bg-blue-900/40 text-blue-400'
: task.status === 'error'
? 'bg-red-900/40 text-red-400'
: 'bg-secondary text-muted-foreground'
}`}
> >
{task.status} <ClipboardList
</span> className={`w-3.5 h-3.5 flex-shrink-0 ${activeTaskId === task.id ? 'text-primary' : 'text-amber-400'}`}
<button />
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary" <span className="flex-1 truncate">{task.name}</span>
title="上传点云文件" <span
onClick={(e) => { className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${taskStatusClass(status)}`}
e.stopPropagation(); >
openUpload(task.id); {taskStatusLabels[status] ?? status}
}} </span>
> <button
<Upload className="w-3 h-3" /> className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
</button> title="上传点云文件"
<button onClick={(e) => {
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive" e.stopPropagation();
title="删除任务" openUpload(task.id);
onClick={(e) => { }}
e.stopPropagation(); >
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id }); <Upload className="w-3 h-3" />
}} </button>
> <button
<Trash2 className="w-3 h-3" /> className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
</button> title="删除任务"
</div> onClick={(e) => {
))} e.stopPropagation();
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div> </div>
{tasks.length === 0 && ( {tasks.length === 0 && (
@@ -346,40 +499,50 @@ export default function TaskManager({
{activeTaskId != null && ( {activeTaskId != null && (
<div> <div>
{/* 标题栏 */} {/* 标题栏 */}
<div className="flex items-center justify-between mb-1.5"> <div className="mb-1.5 space-y-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center justify-between gap-2">
{/* 全选复选框 */} <div className="flex items-center gap-1.5 min-w-0">
{pcFiles.length > 0 && ( {/* 全选复选框 */}
<button {pcFiles.length > 0 && (
type="button" <button
className="text-muted-foreground hover:text-foreground" type="button"
title={allSelected ? '取消全选' : '全选'} className="text-muted-foreground hover:text-foreground flex-shrink-0"
onClick={toggleAllFiles} title={allSelected ? '取消全选' : '全选'}
> onClick={toggleAllFiles}
{allSelected ? ( >
<CheckSquare className="w-3.5 h-3.5 text-primary" /> {allSelected ? (
) : ( <CheckSquare className="w-3.5 h-3.5 text-primary" />
<Square className="w-3.5 h-3.5" /> ) : (
)} <Square className="w-3.5 h-3.5" />
</button> )}
)} </button>
<span className="industrial-label flex items-center gap-1"> )}
<Cloud className="w-3.5 h-3.5" /> 点云文件 <span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<span className="text-[10px] text-muted-foreground font-normal"> <Cloud className="w-3.5 h-3.5" /> 点云文件
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length}) <span className="text-[10px] text-muted-foreground font-normal">
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
</span>
</span> </span>
</span> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => openUpload(activeTaskId)} onClick={() => openUpload(activeTaskId)}
> >
<Upload className="w-3 h-3" /> 上传 <Upload className="w-3 h-3" /> 上传到任务
</button>
<button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
pcLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setPcLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button> </button>
{pcFiles.length > 0 && ( {pcFiles.length > 0 && (
<button <button
className="industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
title={ title={
someSelected someSelected
? `加载选中的 ${selectedFileIds.size} 个文件` ? `加载选中的 ${selectedFileIds.size} 个文件`
@@ -405,9 +568,95 @@ export default function TaskManager({
</div> </div>
)} )}
{pcLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
车型点云库 ({modelPcFiles.length})
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
disabled={uploadingModelPointcloud}
onClick={() => modelPointcloudFileRef.current?.click()}
>
<Upload className="w-3 h-3" />
{uploadingModelPointcloud ? '上传中' : '上传到车型库'}
</button>
</div>
<div className="space-y-1 max-h-40 overflow-auto">
{modelPcFiles.map((f) => {
const isChecked = selectedModelPcFileIds.has(f.id);
return (
<button
key={f.id}
type="button"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
}`}
onClick={() => toggleModelPcFile(f.id)}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<Cloud className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>
</button>
);
})}
{modelPcFiles.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
车型点云库为空
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
linkPcFilesFromModel.mutate({
taskId: activeTaskId,
modelPointcloudFileIds: Array.from(selectedModelPcFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
</button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型点云库"
aria-label="关闭车型点云库"
onClick={() => setPcLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
{pcFiles.map((f) => { {pcFiles.map((f) => {
const isChecked = selectedFileIds.has(f.id); const isChecked = selectedFileIds.has(f.id);
const meta = f as {
source?: string;
sourceResultId?: number | null;
sourcePointcloudFileId?: number | null;
createdAt?: string | Date;
};
const source = meta.source;
const sourcePointcloud = meta.sourcePointcloudFileId
? pcFiles.find((row) => row.id === meta.sourcePointcloudFileId)
: undefined;
return ( return (
<div <div
key={f.id} key={f.id}
@@ -433,14 +682,50 @@ export default function TaskManager({
<Cloud <Cloud
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`} className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
/> />
<span className="flex-1 truncate" title={f.name}> <Tooltip>
{f.name} <TooltipTrigger asChild>
</span> <span className="flex-1 min-w-0 truncate">{f.name}</span>
{(f as { source?: string }).source === 'registered' && ( </TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[min(32rem,calc(100vw-2rem))]"
>
<div className="space-y-1 text-[11px]">
<div className="break-all">{f.name}</div>
{source === 'registered' && (
<>
<div>类型:已配准点云</div>
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
<div>生成时间:{formatDateTime(meta.createdAt)}</div>
<div>
来源:
{sourcePointcloud
? sourcePointcloud.name
: meta.sourcePointcloudFileId != null
? `点云 #${meta.sourcePointcloudFileId}`
: '旧记录未记录来源'}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
{source === 'registered' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap"> <span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
已配准 已配准
</span> </span>
)} )}
{source === 'registered' && meta.sourceResultId != null && (
<span className="text-[9px] px-1 py-0.5 rounded bg-sky-500/15 text-sky-300 border border-sky-500/30 flex-shrink-0 whitespace-nowrap">
R{meta.sourceResultId}
</span>
)}
{source === 'model_library' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
车型库
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0"> <span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)} {formatSize(f.fileSize)}
</span> </span>
@@ -476,17 +761,19 @@ export default function TaskManager({
{/* 任务级 CAD 文件 */} {/* 任务级 CAD 文件 */}
{activeTaskId != null && ( {activeTaskId != null && (
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="mb-1.5 space-y-1.5">
<span className="industrial-label flex items-center gap-1"> <div className="flex items-center justify-between gap-2">
<FileBox className="w-3.5 h-3.5" /> 任务 CAD <span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<span className="text-[10px] text-muted-foreground font-normal"> <FileBox className="w-3.5 h-3.5" /> 任务 CAD
({taskCadFiles.length}) <span className="text-[10px] text-muted-foreground font-normal">
({taskCadFiles.length})
</span>
</span> </span>
</span> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{taskCadFiles.length > 0 && ( {taskCadFiles.length > 0 && (
<button <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={toggleAllCadFiles} onClick={toggleAllCadFiles}
title={allCadSelected ? '取消全选' : '全选'} title={allCadSelected ? '取消全选' : '全选'}
> >
@@ -497,10 +784,18 @@ export default function TaskManager({
</button> </button>
)} )}
<button <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => cadFileRef.current?.click()} onClick={() => cadFileRef.current?.click()}
> >
<Upload className="w-3 h-3" /> 上传 <Upload className="w-3 h-3" /> 上传到任务
</button>
<button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
cadLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setCadLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button> </button>
</div> </div>
</div> </div>
@@ -516,13 +811,91 @@ export default function TaskManager({
</div> </div>
)} )}
{cadLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
车型 CAD 库 ({modelCadFiles.length})
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
disabled={uploadingModelCad}
onClick={() => modelCadFileRef.current?.click()}
>
<Upload className="w-3 h-3" />
{uploadingModelCad ? '上传中' : '上传到车型库'}
</button>
</div>
<div className="space-y-1 max-h-40 overflow-auto">
{modelCadFiles.map((f) => {
const isChecked = selectedModelCadFileIds.has(f.id);
return (
<button
key={f.id}
type="button"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
}`}
onClick={() => toggleModelCadFile(f.id)}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<FileBox className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{f.format.toUpperCase()} · {formatSize(f.fileSize)}
</span>
</button>
);
})}
{modelCadFiles.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
车型 CAD 库为空
</div>
)}
</div>
<div className="flex items-center gap-1">
<button
className="industrial-btn industrial-btn-primary flex-1 min-w-0 py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
linkCadFilesFromModel.mutate({
taskId: activeTaskId,
modelCadFileIds: Array.from(selectedModelCadFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
</button>
<button
type="button"
className="industrial-btn w-7 flex-shrink-0 px-0 py-1 text-[10px] flex items-center justify-center bg-red-900/55 border-red-500/60 text-red-100 hover:bg-red-800/70 hover:border-red-400"
title="关闭车型 CAD 库"
aria-label="关闭车型 CAD 库"
onClick={() => setCadLibraryOpen(false)}
>
<X className="w-3 h-3 flex-shrink-0" />
</button>
</div>
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
{taskCadFiles.map((f) => { {taskCadFiles.map((f) => {
const isChecked = selectedCadFileIds.has(f.id); const isChecked = selectedCadFileIds.has(f.id);
const source = (f as { source?: string }).source;
const canPreview = canPreviewCadIn3D(f);
return ( return (
<div <div
key={f.id} key={f.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer min-w-0
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`} ${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
onClick={() => toggleCadFile(f.id)} onClick={() => toggleCadFile(f.id)}
> >
@@ -538,18 +911,42 @@ export default function TaskManager({
)} )}
</button> </button>
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} /> <FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
<span className="flex-1 truncate" title={f.name}>{f.name}</span> <Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 min-w-0 truncate">{f.name}</span>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[min(32rem,calc(100vw-2rem))] break-all"
>
{f.name}
</TooltipContent>
</Tooltip>
{source === 'model_library' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
车型库
</span>
)}
{cadSampleCacheMap[f.id] && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-green-500/15 text-green-400 border border-green-500/30 flex-shrink-0"
title="已完成三角化预处理,下次配准可跳过 CAD 解析"
>
已预处理
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0"> <span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)} {formatSize(f.fileSize)}
</span> </span>
{onSelectTaskCad && ( {onSelectTaskCad && canPreview && (
<button <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0" className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
title="在 3D 视图中显示" title="在 3D 视图中显示"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSelectTaskCad( onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`, taskCadReadUrl({ name: f.name, source }),
f.format, f.format,
f.name, f.name,
); );
@@ -585,11 +982,14 @@ export default function TaskManager({
</p> </p>
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => { {selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id)); const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
return f ? ( return f && canPreviewCadIn3D(f) ? (
<button <button
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1" className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
onClick={() => onSelectTaskCad( onClick={() => onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`, taskCadReadUrl({
name: f.name,
source: (f as { source?: string }).source,
}),
f.format, f.format,
f.name, f.name,
)} )}
@@ -614,6 +1014,16 @@ export default function TaskManager({
e.target.files && e.target.files.length > 0 && handleUpload(e.target.files) e.target.files && e.target.files.length > 0 && handleUpload(e.target.files)
} }
/> />
<input
ref={modelPointcloudFileRef}
type="file"
multiple
accept=".txt,.xyz,.pts,.ply,.pcd,.las,.laz"
className="hidden"
onChange={(e) =>
e.target.files && e.target.files.length > 0 && handleModelPointcloudUpload(e.target.files)
}
/>
<input <input
ref={cadFileRef} ref={cadFileRef}
type="file" type="file"
@@ -624,6 +1034,16 @@ export default function TaskManager({
e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files) e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files)
} }
/> />
<input
ref={modelCadFileRef}
type="file"
multiple
accept=".obj,.stl,.step,.stp,.iges,.igs"
className="hidden"
onChange={(e) =>
e.target.files && e.target.files.length > 0 && handleModelCadUpload(e.target.files)
}
/>
</div> </div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -69,7 +69,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 ?? {}),

View File

@@ -1,4 +1,4 @@
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 { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer'; import PointCloudViewer from '@/components/PointCloudViewer';
@@ -7,24 +7,22 @@ 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, LogIn,
LogOut,
Box, Box,
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, isAuthenticated } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// ── 树导航选中状态 ───────────────────────────────────────────────────────── // ── 树导航选中状态 ─────────────────────────────────────────────────────────
@@ -70,7 +68,8 @@ export default function Home() {
// ── 视图控制 ─────────────────────────────────────────────────────────────── // ── 视图控制 ───────────────────────────────────────────────────────────────
const [showHeatmap, setShowHeatmap] = useState(true); const [showHeatmap, setShowHeatmap] = useState(true);
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 +88,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 +116,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 +133,6 @@ export default function Home() {
setHeatmapViewResultId(null); setHeatmapViewResultId(null);
setPluginOverlay(null); setPluginOverlay(null);
setPointCloudRenderMode('points'); setPointCloudRenderMode('points');
setIsCreatingTask(false);
setSelectedPcFileIds([]); setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]); setSelectedTaskCadFileIds([]);
setRoiPickMode(false); setRoiPickMode(false);
@@ -253,57 +239,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),
@@ -329,8 +264,6 @@ export default function Home() {
onPcFilesSelectionChange={setSelectedPcFileIds} onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds} onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad} onSelectTaskCad={handleSelectCad}
onStartCreate={() => setIsCreatingTask(true)}
onEndCreate={() => setIsCreatingTask(false)}
/> />
</> </>
); );
@@ -350,8 +283,10 @@ export default function Home() {
<div className="space-y-4"> <div className="space-y-4">
<AlgorithmRunPanel <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}
@@ -372,12 +307,47 @@ export default function Home() {
setHeatmapViewResultId(resultId); setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap'); setPointCloudRenderMode('smoothHeatmap');
}} }}
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');
}
}} }}
externalPending={runAnalysisJob.isPending} externalPending={runAnalysisJob.isPending}
onRunAssembly={() => { onRunAssembly={() => {
@@ -439,33 +409,22 @@ export default function Home() {
</button> </button>
</div> </div>
<div className="flex items-center gap-2 px-3 py-1 industrial-card"> {(pcLoading || cadLoading) && (
<span className={`status-led ${ <div className="flex items-center gap-2 px-3 py-1 industrial-card">
pcLoading ? 'status-led-processing' : <span className="status-led status-led-processing" />
cadLoading ? 'status-led-processing' : <span className="text-xs text-muted-foreground">
'status-led-idle' {pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
}`} /> </span>
<span className="text-xs text-muted-foreground"> </div>
{pcLoading ? `加载点云 ${pcProgress}%` : )}
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
</span>
</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 ? ( ) : !isAuthenticated ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{user?.name || '用户'}</span>
<button className="industrial-btn py-1.5" onClick={() => logout()}>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
) : (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5"> <a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" /> <LogIn className="w-3.5 h-3.5" />
</a> </a>
)} ) : null}
</div> </div>
</header> </header>
@@ -480,13 +439,6 @@ export default function Home() {
{/* 中央 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 +463,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}
@@ -546,13 +499,9 @@ export default function Home() {
{/* 操作提示 */} {/* 操作提示 */}
<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,15 +543,21 @@ 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={() => {
setPointCloudRenderMode('points'); if (pointCloudRenderMode !== 'points') {
setHeatmapViewResultId(null); setPointCloudRenderMode('points');
setPluginOverlay(null); setHeatmapViewResultId(null);
setPluginOverlay(null);
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">
@@ -610,26 +565,12 @@ export default function Home() {
</span> </span>
)} )}
<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>
@@ -656,7 +597,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 +610,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>
)} )}

View File

@@ -38,7 +38,7 @@ 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
@@ -75,10 +75,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

View File

@@ -130,10 +130,11 @@ CREATE TABLE IF NOT EXISTS `inspection_tasks` (
`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` (
@@ -148,8 +149,8 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
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,

View 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`;如果要清理,应先确认其中截图、部署包说明、历史交付内容是否还需要留档。
- 直接在服务器改代码时,务必先确认工作区状态,避免覆盖另一名开发人员正在修改的文件。

View 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`;

View File

@@ -0,0 +1,5 @@
-- 记录配准产物来自哪个任务点云文件,用于阻止链式配准并检测重复配准
ALTER TABLE `task_pointcloud_files`
ADD COLUMN `sourcePointcloudFileId` INT NULL
AFTER `sourceResultId`;

View 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;

View 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';

View File

@@ -0,0 +1,2 @@
ALTER TABLE `inspection_tasks`
ADD UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`);

View File

@@ -1,4 +1,4 @@
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";
// ─── 用户表(保持不变) ──────────────────────────────────────────────────────── // ─── 用户表(保持不变) ────────────────────────────────────────────────────────
@@ -62,6 +62,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
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 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(),
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 +87,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(),
}); });
@@ -87,10 +106,14 @@ export const inspectionTasks = mysqlTable("inspection_tasks", {
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 +128,14 @@ 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"),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); });
@@ -135,7 +162,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"), // 其他分析数据(如压缩比等)

View File

@@ -12,8 +12,8 @@ const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
export async function setupVite(app: Express, server: Server) { export async function setupVite(app: Express, server: Server) {
const serverOptions = { const serverOptions = {
middlewareMode: true, middlewareMode: true,
// Disable Vite client-side auto reload/HMR in dev. // Reuse the app HTTP server for HMR so SSH forwarding only needs the app port.
hmr: false as const, hmr: { server },
allowedHosts: true as const, allowedHosts: true as const,
}; };

View File

@@ -12,7 +12,7 @@
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有 * 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。 * smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
*/ */
import { fork, ChildProcess } from 'node:child_process'; import { fork, ChildProcess, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
@@ -67,7 +67,7 @@ class ConcurrencyPool {
child.kill('SIGKILL'); child.kill('SIGKILL');
return true; return true;
} }
return false; return killOrphanAlgorithmProcesses({ resultId });
} }
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */ /** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
@@ -85,13 +85,17 @@ class ConcurrencyPool {
} }
this.queue = keep; this.queue = keep;
// 运行中 kill // 运行中 kill
for (const [resultId, child] of this.activeChildren) { for (const [resultId, child] of Array.from(this.activeChildren.entries())) {
const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId; const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId;
if (jobTaskId === taskId) { if (jobTaskId === taskId) {
child.kill('SIGKILL'); child.kill('SIGKILL');
cancelled.push(resultId); cancelled.push(resultId);
} }
} }
const orphaned = killOrphanAlgorithmProcesses({ taskId });
if (orphaned.length > 0) {
cancelled.push(...orphaned);
}
return cancelled; return cancelled;
} }
@@ -155,6 +159,113 @@ function resolveAlgorithmRunnerEntry(): string {
const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY); const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY);
interface ProcessRow {
pid: number;
ppid: number;
command: string;
}
function listProcesses(): ProcessRow[] {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!match) return null;
return {
pid: Number(match[1]),
ppid: Number(match[2]),
command: match[3],
};
})
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
} catch (err) {
console.warn('[AlgorithmJob] 无法列出进程,跳过孤儿进程清理:', err);
return [];
}
}
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
const all = new Set(rootPids);
let changed = true;
while (changed) {
changed = false;
for (const proc of processes) {
if (!all.has(proc.pid) && all.has(proc.ppid)) {
all.add(proc.pid);
changed = true;
}
}
}
return all;
}
function killPids(pids: number[]): number[] {
const killed: number[] = [];
for (const pid of pids) {
if (pid <= 1 || pid === process.pid) continue;
try {
process.kill(pid, 'SIGKILL');
killed.push(pid);
} catch {
// Process may already have exited.
}
}
return killed;
}
function killOrphanAlgorithmProcesses(input: { resultId: number }): boolean;
function killOrphanAlgorithmProcesses(input: { taskId: number }): number[];
function killOrphanAlgorithmProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
const processes = listProcesses();
const roots = new Set<number>();
const resultIds = new Set<number>();
for (const proc of processes) {
const cmd = proc.command;
if (input.resultId != null) {
const resultId = String(input.resultId);
if (
new RegExp(`algorithmJobRunner\\.(?:ts|js)\\s+${resultId}\\s+`).test(cmd) ||
cmd.includes(`/analysis/${resultId}/input.json`)
) {
roots.add(proc.pid);
resultIds.add(input.resultId);
}
continue;
}
if (input.taskId != null) {
const runnerMatch = cmd.match(/algorithmJobRunner\.(?:ts|js)\s+(\d+)\s+(\d+)\s+/);
if (runnerMatch && Number(runnerMatch[2]) === input.taskId) {
roots.add(proc.pid);
resultIds.add(Number(runnerMatch[1]));
continue;
}
const inputJsonMatch = cmd.match(new RegExp(`/data/tasks/${input.taskId}/analysis/(\\d+)/input\\.json`));
if (inputJsonMatch) {
roots.add(proc.pid);
resultIds.add(Number(inputJsonMatch[1]));
}
}
}
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
const killed = killPids(targets);
if (killed.length > 0) {
console.warn(`[AlgorithmJob] 已清理孤儿算法进程 pid=${killed.join(', ')}`);
}
if (input.resultId != null) return killed.length > 0;
return Array.from(resultIds);
}
/** /**
* 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。 * 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。 * 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。

View File

@@ -43,6 +43,26 @@ function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
function formatRegistrationTimestamp(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'-',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
function registeredPointcloudDisplayName(sourceName: string | undefined, derivedFile: string, resultId: number): string {
const sourceParsed = path.parse(sourceName || 'pointcloud');
const stem = sourceParsed.name || 'pointcloud';
const ext = path.extname(derivedFile) || sourceParsed.ext || '.ply';
return `${stem}_已配准_${formatRegistrationTimestamp(new Date())}_R${resultId}${ext}`;
}
/** 把 process / mock cmd 模板里的占位符替换成实际路径。 */ /** 把 process / mock cmd 模板里的占位符替换成实际路径。 */
function resolvePlaceholders(value: string, ctx: Record<string, string>): string { function resolvePlaceholders(value: string, ctx: Record<string, string>): string {
return value.replace(/\{(\w+)\}/g, (_, key) => ctx[key] ?? `{${key}}`); return value.replace(/\{(\w+)\}/g, (_, key) => ctx[key] ?? `{${key}}`);
@@ -91,7 +111,7 @@ async function gatherInputs(
} }
} }
return { task, pointCloudPaths, cadPath }; return { task, pointCloudRecords, pointCloudPaths, cadPath };
} }
async function writeInputJson( async function writeInputJson(
@@ -102,6 +122,8 @@ async function writeInputJson(
params: Record<string, unknown>, params: Record<string, unknown>,
pointCloudPaths: string[], pointCloudPaths: string[],
cadPath: string | null, cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
) { ) {
const inputJson = { const inputJson = {
schemaVersion: 1, schemaVersion: 1,
@@ -113,7 +135,9 @@ async function writeInputJson(
outDir: workDir, outDir: workDir,
inputs: { inputs: {
pointCloudPaths, pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
cadPath, cadPath,
cadFileIds: inputCadFileIds,
config: params, config: params,
}, },
}; };
@@ -156,6 +180,27 @@ function readResultJson(workDir: string): PluginResult | null {
} }
} }
const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowRunId',
'demoFlowRole',
'demoFlowIndex',
'demoFlowTotal',
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
] as const;
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
const row = await db.getTaskAnalysisResultById(resultId);
const existing = (row?.resultData as Record<string, unknown> | null) ?? {};
const picked: Record<string, unknown> = {};
for (const key of DEMO_FLOW_RESULT_DATA_KEYS) {
if (existing[key] !== undefined) {
picked[key] = existing[key];
}
}
return picked;
}
async function runProcessRuntime( async function runProcessRuntime(
manifest: PluginManifest, manifest: PluginManifest,
workDir: string, workDir: string,
@@ -266,12 +311,14 @@ export async function runAlgorithmJob(
): Promise<void> { ): Promise<void> {
const manifest = getPlugin(pluginId); const manifest = getPlugin(pluginId);
if (!manifest) { if (!manifest) {
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, { await db.updateTaskAnalysisResult(resultId, {
status: 'error', status: 'error',
resultData: { resultData: {
kind: 'plugin', kind: 'plugin',
pluginId, pluginId,
error: `未注册的插件:${pluginId}`, error: `未注册的插件:${pluginId}`,
...demoFlowResultData,
} as PluginResultData, } as PluginResultData,
}); });
return; return;
@@ -282,7 +329,15 @@ export async function runAlgorithmJob(
try { try {
await patchProgress(resultId, pluginId, 0.02, '准备输入'); await patchProgress(resultId, pluginId, 0.02, '准备输入');
const { pointCloudPaths, cadPath } = await gatherInputs(taskId, manifest, pointCloudFileIds, cadFileIds); const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
taskId,
manifest,
pointCloudFileIds,
cadFileIds,
);
const inputPointCloudFileIds = pointCloudRecords.map((r) => r.id);
const inputCadFileIds = Array.isArray(cadFileIds) ? cadFileIds.filter((id) => Number.isInteger(id)) : [];
const inputJsonPath = await writeInputJson( const inputJsonPath = await writeInputJson(
workDir, workDir,
taskId, taskId,
@@ -291,6 +346,8 @@ export async function runAlgorithmJob(
params, params,
pointCloudPaths, pointCloudPaths,
cadPath, cadPath,
inputPointCloudFileIds,
inputCadFileIds,
); );
let exitCode: number | null = 0; let exitCode: number | null = 0;
@@ -308,12 +365,15 @@ export async function runAlgorithmJob(
? 'completed' ? 'completed'
: 'error'; : 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const resultData: PluginResultData = { const resultData: PluginResultData = {
kind: 'plugin', kind: 'plugin',
pluginId, pluginId,
progress: finalStatus === 'completed' ? 1 : undefined, progress: finalStatus === 'completed' ? 1 : undefined,
stage: finalStatus === 'completed' ? '完成' : '失败', stage: finalStatus === 'completed' ? '完成' : '失败',
exitCode, exitCode,
inputPointCloudFileIds,
inputCadFileIds,
// 把 result.json 内容平展到 resultData 里,方便前端直接使用 // 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion, schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message, message: result?.message,
@@ -326,6 +386,7 @@ export async function runAlgorithmJob(
error: finalStatus === 'error' error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}` ? result?.message ?? `子进程退出码 ${exitCode}`
: undefined, : undefined,
...demoFlowResultData,
}; };
await db.updateTaskAnalysisResult(resultId, { await db.updateTaskAnalysisResult(resultId, {
@@ -350,13 +411,16 @@ export async function runAlgorithmJob(
continue; continue;
} }
try { try {
const sourcePointcloud = pointCloudRecords[0];
const displayName = registeredPointcloudDisplayName(sourcePointcloud?.name, d.file, resultId);
await db.createRegisteredPointcloudFile( await db.createRegisteredPointcloudFile(
taskId, taskId,
d.name ?? d.file, displayName,
absPath, absPath,
resultId, resultId,
sourcePointcloud?.id ?? null,
); );
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${d.name ?? d.file}`); console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${displayName}`);
} catch (err) { } catch (err) {
console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err); console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err);
} }
@@ -376,12 +440,14 @@ export async function runAlgorithmJob(
} catch (err) { } catch (err) {
console.error('[AlgorithmJobRunner]', err); console.error('[AlgorithmJobRunner]', err);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, { await db.updateTaskAnalysisResult(resultId, {
status: 'error', status: 'error',
resultData: { resultData: {
kind: 'plugin', kind: 'plugin',
pluginId, pluginId,
error: message, error: message,
...demoFlowResultData,
} as PluginResultData, } as PluginResultData,
}); });
} }

View File

@@ -0,0 +1,154 @@
import { ChildProcess, execFileSync } from 'node:child_process';
export type LegacyAnalysisKind = 'smooth_heatmap' | 'assembly_inspection' | 'obj_deviation';
interface ActiveLegacyJob {
resultId: number;
taskId: number;
kind: LegacyAnalysisKind;
child: ChildProcess;
}
interface ProcessRow {
pid: number;
ppid: number;
command: string;
}
const activeLegacyJobs = new Map<number, ActiveLegacyJob>();
export function registerLegacyAnalysisChild(
resultId: number,
taskId: number,
kind: LegacyAnalysisKind,
child: ChildProcess,
): void {
activeLegacyJobs.set(resultId, { resultId, taskId, kind, child });
child.once('exit', () => {
activeLegacyJobs.delete(resultId);
});
}
export function cancelLegacyAnalysisJob(resultId: number): boolean {
const job = activeLegacyJobs.get(resultId);
let cancelled = false;
if (job) {
activeLegacyJobs.delete(resultId);
try {
job.child.kill('SIGKILL');
cancelled = true;
} catch {
// Process may already have exited.
}
}
return killOrphanLegacyAnalysisProcesses({ resultId }) || cancelled;
}
export function cancelLegacyAnalysisJobsByTask(taskId: number): number[] {
const cancelled = new Set<number>();
for (const [resultId, job] of Array.from(activeLegacyJobs.entries())) {
if (job.taskId !== taskId) continue;
activeLegacyJobs.delete(resultId);
try {
job.child.kill('SIGKILL');
cancelled.add(resultId);
} catch {
// Process may already have exited.
}
}
for (const resultId of killOrphanLegacyAnalysisProcesses({ taskId })) {
cancelled.add(resultId);
}
return Array.from(cancelled);
}
function listProcesses(): ProcessRow[] {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!match) return null;
return {
pid: Number(match[1]),
ppid: Number(match[2]),
command: match[3],
};
})
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
} catch (err) {
console.warn('[AnalysisJobControl] 无法列出进程,跳过 legacy 孤儿进程清理:', err);
return [];
}
}
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
const all = new Set(rootPids);
let changed = true;
while (changed) {
changed = false;
for (const proc of processes) {
if (!all.has(proc.pid) && all.has(proc.ppid)) {
all.add(proc.pid);
changed = true;
}
}
}
return all;
}
function killPids(pids: number[]): number[] {
const killed: number[] = [];
for (const pid of pids) {
if (pid <= 1 || pid === process.pid) continue;
try {
process.kill(pid, 'SIGKILL');
killed.push(pid);
} catch {
// Process may already have exited.
}
}
return killed;
}
function runnerRegex(): RegExp {
return /(smoothHeatmapJobRunner|assemblyInspectionJobRunner|deviationHeatmapJobRunner)\.(?:ts|js)\s+(\d+)\s+(\d+)\b/;
}
function killOrphanLegacyAnalysisProcesses(input: { resultId: number }): boolean;
function killOrphanLegacyAnalysisProcesses(input: { taskId: number }): number[];
function killOrphanLegacyAnalysisProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
const processes = listProcesses();
const roots = new Set<number>();
const resultIds = new Set<number>();
for (const proc of processes) {
const match = proc.command.match(runnerRegex());
if (!match) continue;
const resultId = Number(match[2]);
const taskId = Number(match[3]);
if (input.resultId != null && resultId === input.resultId) {
roots.add(proc.pid);
resultIds.add(resultId);
}
if (input.taskId != null && taskId === input.taskId) {
roots.add(proc.pid);
resultIds.add(resultId);
}
}
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
const killed = killPids(targets);
if (killed.length > 0) {
console.warn(`[AnalysisJobControl] 已清理 legacy 分析进程 pid=${killed.join(', ')}`);
}
if (input.resultId != null) return killed.length > 0;
return Array.from(resultIds);
}

View File

@@ -17,6 +17,7 @@ import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
import type { SampledPoint } from './pointcloudSample.js'; import type { SampledPoint } from './pointcloudSample.js';
import { runStepParseMvp, stepParseCacheDir } from './stepParse.js'; import { runStepParseMvp, stepParseCacheDir } from './stepParse.js';
import type { StepManifest } from './stepParse.js'; import type { StepManifest } from './stepParse.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data'; const DATA_DIR = process.env.DATA_DIR || '/data';
const TASKS_DIR = path.join(DATA_DIR, 'tasks'); const TASKS_DIR = path.join(DATA_DIR, 'tasks');
@@ -42,6 +43,7 @@ export function enqueueAssemblyInspectionJob(
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
execArgv: isProd ? [] : ['--import', 'tsx'], execArgv: isProd ? [] : ['--import', 'tsx'],
}); });
registerLegacyAnalysisChild(resultId, taskId, 'assembly_inspection', child);
child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err)); child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err));
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {
if (code !== 0 && code != null) { if (code !== 0 && code != null) {

View File

@@ -1,4 +1,4 @@
import { eq, desc, sql } from "drizzle-orm"; import { eq, desc, inArray, sql, and } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2"; import { drizzle } from "drizzle-orm/mysql2";
import type { ResultSetHeader } from "mysql2"; import type { ResultSetHeader } from "mysql2";
import { import {
@@ -6,6 +6,7 @@ import {
vehicleSeries, InsertVehicleSeries, VehicleSeries, vehicleSeries, InsertVehicleSeries, VehicleSeries,
vehicleModels, InsertVehicleModel, VehicleModel, vehicleModels, InsertVehicleModel, VehicleModel,
modelCadFiles, InsertModelCadFile, ModelCadFile, modelCadFiles, InsertModelCadFile, ModelCadFile,
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
taskCadFiles, InsertTaskCadFile, TaskCadFile, taskCadFiles, InsertTaskCadFile, TaskCadFile,
inspectionTasks, InsertInspectionTask, InspectionTask, inspectionTasks, InsertInspectionTask, InspectionTask,
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile, taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
@@ -229,6 +230,37 @@ export async function deleteModelCadFile(id: number): Promise<void> {
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id)); await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
} }
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(modelPointcloudFiles).values(data);
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
return row;
}
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.modelId, modelId))
.orderBy(desc(modelPointcloudFiles.createdAt));
}
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
return row;
}
export async function deleteModelPointcloudFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
}
// ─── 任务 CAD 文件 ───────────────────────────────────────────────────────────── // ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
export async function createTaskCadFile(data: InsertTaskCadFile): Promise<TaskCadFile> { export async function createTaskCadFile(data: InsertTaskCadFile): Promise<TaskCadFile> {
@@ -260,6 +292,46 @@ export async function deleteTaskCadFile(id: number): Promise<void> {
await db.delete(taskCadFiles).where(eq(taskCadFiles.id, id)); await db.delete(taskCadFiles).where(eq(taskCadFiles.id, id));
} }
export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds: number[]): Promise<TaskCadFile[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const task = await getInspectionTaskById(taskId);
if (!task) throw new Error("Task not found");
const existing = await listTaskCadFiles(taskId);
const existingSourceIds = new Set(
existing
.map((row) => row.sourceModelCadFileId)
.filter((id): id is number => typeof id === "number"),
);
const inserted: TaskCadFile[] = [];
for (const id of modelCadFileIds) {
if (existingSourceIds.has(id)) continue;
const source = await getModelCadFileById(id);
if (!source) throw new Error("Model CAD file not found");
if (source.modelId !== task.modelId) {
throw new Error("Cannot link CAD file from another model");
}
const record = await createTaskCadFile({
taskId,
name: source.name,
format: source.format,
fileSize: source.fileSize,
filePath: source.filePath,
source: "model_library",
sourceModelCadFileId: source.id,
} as InsertTaskCadFile);
inserted.push(record);
existingSourceIds.add(id);
}
return inserted;
}
// ─── 检测任务 ───────────────────────────────────────────────────────────────── // ─── 检测任务 ─────────────────────────────────────────────────────────────────
export async function createInspectionTask(data: InsertInspectionTask): Promise<InspectionTask> { export async function createInspectionTask(data: InsertInspectionTask): Promise<InspectionTask> {
@@ -270,19 +342,73 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
return row; return row;
} }
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(inspectionTasks)
.where(and(eq(inspectionTasks.modelId, modelId), eq(inspectionTasks.name, name)))
.limit(1);
return row;
}
export async function listInspectionTasksByModel(modelId: number): Promise<InspectionTask[]> { export async function listInspectionTasksByModel(modelId: number): Promise<InspectionTask[]> {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return db.select().from(inspectionTasks) const tasks = await db.select().from(inspectionTasks)
.where(eq(inspectionTasks.modelId, modelId)) .where(eq(inspectionTasks.modelId, modelId))
.orderBy(desc(inspectionTasks.createdAt)); .orderBy(desc(inspectionTasks.createdAt));
return withComputedInspectionTaskStatuses(tasks);
} }
export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> { export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> {
const db = await getDb(); const db = await getDb();
if (!db) return undefined; if (!db) return undefined;
const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id)); const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id));
return row; if (!row) return undefined;
const [task] = await withComputedInspectionTaskStatuses([row]);
return task;
}
type InspectionTaskStatus = InspectionTask["status"];
type AnalysisStatus = TaskAnalysisResult["status"];
function summarizeInspectionTaskStatus(
fallback: InspectionTaskStatus,
analysisStatuses: AnalysisStatus[],
): InspectionTaskStatus {
if (analysisStatuses.length === 0) return fallback;
if (analysisStatuses.some(status => status === "processing" || status === "pending")) {
return "processing";
}
if (analysisStatuses.some(status => status === "error")) return "error";
if (analysisStatuses.some(status => status === "completed")) return "completed";
return fallback;
}
async function withComputedInspectionTaskStatuses(tasks: InspectionTask[]): Promise<InspectionTask[]> {
if (tasks.length === 0) return tasks;
const db = await getDb();
if (!db) return tasks;
const rows = await db
.select({
taskId: taskAnalysisResults.taskId,
status: taskAnalysisResults.status,
})
.from(taskAnalysisResults)
.where(inArray(taskAnalysisResults.taskId, tasks.map(task => task.id)));
const statusesByTask = new Map<number, AnalysisStatus[]>();
for (const row of rows) {
const statuses = statusesByTask.get(row.taskId) ?? [];
statuses.push(row.status);
statusesByTask.set(row.taskId, statuses);
}
return tasks.map(task => ({
...task,
status: summarizeInspectionTaskStatus(task.status, statusesByTask.get(task.id) ?? []),
}));
} }
export async function updateInspectionTask(id: number, data: Partial<InsertInspectionTask>): Promise<void> { export async function updateInspectionTask(id: number, data: Partial<InsertInspectionTask>): Promise<void> {
@@ -294,6 +420,9 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
export async function deleteInspectionTask(id: number): Promise<void> { export async function deleteInspectionTask(id: number): Promise<void> {
const db = await getDb(); const db = await getDb();
if (!db) return; if (!db) return;
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.taskId, id));
await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.taskId, id));
await db.delete(taskCadFiles).where(eq(taskCadFiles.taskId, id));
await db.delete(inspectionTasks).where(eq(inspectionTasks.id, id)); await db.delete(inspectionTasks).where(eq(inspectionTasks.id, id));
} }
@@ -315,6 +444,7 @@ export async function createRegisteredPointcloudFile(
name: string, name: string,
filePath: string, filePath: string,
sourceResultId: number, sourceResultId: number,
sourcePointcloudFileId?: number | null,
): Promise<TaskPointcloudFile> { ): Promise<TaskPointcloudFile> {
const fs = await import('node:fs'); const fs = await import('node:fs');
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
@@ -325,6 +455,7 @@ export async function createRegisteredPointcloudFile(
fileSize, fileSize,
source: 'registered', source: 'registered',
sourceResultId, sourceResultId,
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
} as InsertTaskPointcloudFile); } as InsertTaskPointcloudFile);
} }
@@ -342,6 +473,48 @@ export async function deleteTaskPointcloudFile(id: number): Promise<void> {
await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id)); await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
} }
export async function linkTaskPointcloudFilesFromModel(
taskId: number,
modelPointcloudFileIds: number[],
): Promise<TaskPointcloudFile[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const task = await getInspectionTaskById(taskId);
if (!task) throw new Error("Task not found");
const existing = await listTaskPointcloudFiles(taskId);
const existingSourceIds = new Set(
existing
.map((row) => row.sourceModelPointcloudId)
.filter((id): id is number => typeof id === "number"),
);
const inserted: TaskPointcloudFile[] = [];
for (const id of modelPointcloudFileIds) {
if (existingSourceIds.has(id)) continue;
const source = await getModelPointcloudFileById(id);
if (!source) throw new Error("Model pointcloud file not found");
if (source.modelId !== task.modelId) {
throw new Error("Cannot link pointcloud file from another model");
}
const record = await createTaskPointcloudFile({
taskId,
name: source.name,
fileSize: source.fileSize,
filePath: source.filePath,
source: "model_library",
sourceModelPointcloudId: source.id,
} as InsertTaskPointcloudFile);
inserted.push(record);
existingSourceIds.add(id);
}
return inserted;
}
// ─── 任务分析结果 ────────────────────────────────────────────────────────────── // ─── 任务分析结果 ──────────────────────────────────────────────────────────────
export async function createTaskAnalysisResult(data: InsertTaskAnalysisResult): Promise<TaskAnalysisResult> { export async function createTaskAnalysisResult(data: InsertTaskAnalysisResult): Promise<TaskAnalysisResult> {

View File

@@ -10,6 +10,7 @@ import {
import { pickCadFileForHeatmap } from './cadFilePick.js'; import { pickCadFileForHeatmap } from './cadFilePick.js';
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js'; import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js'; import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data'; const DATA_DIR = process.env.DATA_DIR || '/data';
const TASKS_DIR = path.join(DATA_DIR, 'tasks'); const TASKS_DIR = path.join(DATA_DIR, 'tasks');
@@ -41,6 +42,7 @@ export function enqueueDeviationHeatmapJob(resultId: number, taskId: number, tot
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
execArgv: isProd ? [] : ['--import', 'tsx'], execArgv: isProd ? [] : ['--import', 'tsx'],
}); });
registerLegacyAnalysisChild(resultId, taskId, 'obj_deviation', child);
child.on('error', (err) => { child.on('error', (err) => {
console.error('[DeviationHeatmapJob] fork 失败:', err); console.error('[DeviationHeatmapJob] fork 失败:', err);
}); });

View File

@@ -24,7 +24,9 @@ ensureDir(TASKS_DIR);
const diskStorage = multer.diskStorage({ const diskStorage = multer.diskStorage({
destination: (req, _file, cb) => { destination: (req, _file, cb) => {
let dir: string; let dir: string;
if (req.params.modelId) { if (req.params.modelId && req.path.includes('/model-pointcloud/')) {
dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'pointcloud');
} else if (req.params.modelId) {
dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'cad'); dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'cad');
} else if (req.params.taskId && req.path.includes('/task-cad/')) { } else if (req.params.taskId && req.path.includes('/task-cad/')) {
dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'cad'); dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'cad');
@@ -92,6 +94,35 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
}); });
}); });
// ─── 车型点云文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/model-pointcloud/:modelId
// multipart/form-data field: file
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
}
const destPath = req.file.path;
const record = await db.createModelPointcloudFile({
modelId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
});
res.json({
success: true,
file: {
id: record.id,
name: record.name,
size: formatFileSize(record.fileSize),
filePath: record.filePath,
},
});
});
// ─── 任务 CAD 文件上传 ──────────────────────────────────────────────────────── // ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/task-cad/:taskId // POST /api/files/upload/task-cad/:taskId
@@ -173,6 +204,17 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
res.sendFile(filePath); res.sendFile(filePath);
}); });
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
// GET /api/files/read/model-pointcloud/:modelId/:filename
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filePath);
});
// ─── 读取点云文件 ───────────────────────────────────────────────────────────── // ─── 读取点云文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/pointcloud/:taskId/:filename // GET /api/files/read/pointcloud/:taskId/:filename
@@ -283,7 +325,10 @@ router.post('/sample/batch', async (req, res) => {
); );
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
const binBuf = Buffer.allocUnsafe(totalSampled * 16); // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
let offset = 0; let offset = 0;
for (const pts of sampledPerFile) { for (const pts of sampledPerFile) {
for (const p of pts) { for (const p of pts) {
@@ -291,7 +336,12 @@ router.post('/sample/batch', async (req, res) => {
binBuf.writeFloatLE(p.y, offset + 4); binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8); binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12); binBuf.writeFloatLE(p.error, offset + 12);
offset += 16; if (hasColor) {
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24);
}
offset += bytesPerPoint;
} }
} }
@@ -304,6 +354,7 @@ router.post('/sample/batch', async (req, res) => {
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`); console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled); res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats)); res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf); res.send(binBuf);
} catch (err) { } catch (err) {
@@ -401,7 +452,45 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
res.sendFile(filePath); res.sendFile(filePath);
}); });
// ─── 插件热重载 ────────────────────────────────────────────────────────────── // ─── 分析结果重新同步 ────────────────────────────────────────────────────────
// POST /api/files/resync/analysis/:taskId/:resultId
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
return res.status(400).json({ error: 'invalid params' });
}
const row = await db.getTaskAnalysisResultById(resultId);
if (!row || row.taskId !== taskId) {
return res.status(404).json({ error: 'not found' });
}
const resultJsonPath = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId), 'result.json');
if (!fs.existsSync(resultJsonPath)) {
return res.status(404).json({ error: 'result.json missing' });
}
try {
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
const mergedRd = {
...existingRd,
message: result.message ?? existingRd.message,
measurements: result.measurements ?? existingRd.measurements,
errorStats: result.errorStats ?? existingRd.errorStats,
overlay3d: result.overlay3d ?? existingRd.overlay3d,
images: result.images ?? existingRd.images,
files: result.files ?? existingRd.files,
extra: result.extra ?? existingRd.extra,
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
};
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
res.json({ success: true, overlay3d: result.overlay3d });
} catch (e) {
res.status(500).json({ error: String(e) });
}
});
// POST /api/files/plugins/reload // POST /api/files/plugins/reload
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。 // 重新从磁盘扫描 manifests/*.json,无需重启服务器。
// 用于更新算法版本后立即生效。 // 用于更新算法版本后立即生效。

View File

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appRouter } from "./routers";
import * as db from "./db";
import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => ({
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
createInspectionTask: vi.fn(async (data) => ({
id: 1,
modelId: data.modelId,
name: data.name,
inspector: data.inspector ?? null,
notes: data.notes ?? null,
status: data.status,
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
})),
}));
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user",
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
describe("inspection task router", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValue(undefined);
});
it("trims name and creates a task in created status", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.inspectionTask.create({
modelId: 1,
name: " 首检任务 ",
});
expect(result.status).toBe("created");
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
expect(db.createInspectionTask).toHaveBeenCalledWith({
modelId: 1,
name: "首检任务",
inspector: null,
notes: null,
status: "created",
});
});
it("returns CONFLICT when the same model already has the task name", async () => {
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
id: 1,
modelId: 1,
name: "首检任务",
inspector: null,
notes: null,
status: "created",
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
});
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.inspectionTask.create({
modelId: 1,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",
message: "该车型下已存在同名检测任务。",
});
expect(db.createInspectionTask).not.toHaveBeenCalled();
});
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
vi.mocked(db.createInspectionTask).mockRejectedValueOnce({
code: "ER_DUP_ENTRY",
errno: 1062,
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
});
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
await expect(caller.inspectionTask.create({
modelId: 1,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",
message: "该车型下已存在同名检测任务。",
});
});
});

View File

@@ -78,7 +78,7 @@ def main() -> int:
z_min, z_max = _f("roiZMin"), _f("roiZMax") z_min, z_max = _f("roiZMin"), _f("roiZMax")
# 至少有一对轴的范围被填写时才构建 ROI # 至少有一对轴的范围被填写时才构建 ROI
if any(v is not None for v in (x_min, x_max, y_min, y_max, z_min, z_max)): if any(v is not None for v in (x_min, x_max, y_min, y_max, z_min, z_max)):
from coupler_panel.roi import ROISpec # type: ignore from coupler_panel.roi_select import ROISpec # type: ignore
roi_spec = ROISpec( roi_spec = ROISpec(
x_min=x_min if x_min is not None else float("-inf"), x_min=x_min if x_min is not None else float("-inf"),
@@ -150,20 +150,27 @@ def main() -> int:
D_y = float(result.D_y_max_mm) D_y = float(result.D_y_max_mm)
D_z = float(result.D_z_max_mm) D_z = float(result.D_z_max_mm)
# 斜率形式:offset_mm = baseline * tan(theta),与 Excel 报表一致
import math as _math
offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right))
offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref))
measurements = [ measurements = [
{ {
"name": "偏转角 θ_right (右向)", "name": "车钩面板距纵向中心的右偏转角",
"value": theta_right, "value": round(offset_right, 6),
"unit": "deg", "unit": "mm",
"tolerance": 0.5, "baseline": 700.0,
"status": _status_for(theta_right, 0.5), "tolerance": 1.0,
"status": _status_for(offset_right, 1.0),
}, },
{ {
"name": "偏转角 θ_ref (参考向)", "name": "车钩面板距参考面的右偏转角",
"value": theta_ref, "value": round(offset_ref, 6),
"unit": "deg", "unit": "mm",
"tolerance": 0.5, "baseline": 375.0,
"status": _status_for(theta_ref, 0.5), "tolerance": 0.8,
"status": _status_for(offset_ref, 0.8),
}, },
{ {
"name": "最大 x 偏差 dx_y", "name": "最大 x 偏差 dx_y",

View File

@@ -7,15 +7,16 @@ v1.5 变更:
- pipeline 新增 result_template_path / combined_context_pointcloud_path 参数 - pipeline 新增 result_template_path / combined_context_pointcloud_path 参数
约定: 约定:
- module=all:plane + curvature(contour 需要 CAD STL,缺失时跳过) - module=contour:轮廓/平整度,需要 CAD STEP(推荐,算法方基准 3min25s)
- module=plane:只跑平面拟合 - module=plane:只跑平面拟合
- module=curvature:只跑曲率估计 - module=curvature:只跑曲率估计(大点云极慢,慎用)
- module=contour:需要 CAD STL,缺失时报错 - module=all:plane + curvature + contour(已从前端选项移除,大点云不可用)
""" """
from __future__ import annotations from __future__ import annotations
import copy import copy
import hashlib
import os import os
import sys import sys
import traceback import traceback
@@ -39,6 +40,39 @@ def _safe_float(v: object) -> float | None:
return None return None
def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path:
"""根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。
优先返回配准阶段已生成的 mesh 缓存(_mesh.ply),
其次返回 flatness 自己的缓存路径,均放在 CAD 同目录的 .cad_sample_cache/ 下。
"""
stat = os.stat(cad_path)
cache_key = "|".join([
os.path.abspath(cad_path),
str(stat.st_size),
str(stat.st_mtime_ns),
f"{mesh_deflection:.12g}",
])
digest = hashlib.sha1(cache_key.encode()).hexdigest()[:16]
cad_stem = Path(cad_path).stem
shared_dir = Path(cad_path).parent / ".cad_sample_cache"
# 配准阶段已缓存的 mesh(与配准采样缓存同目录,命名规则:<stem>_<digest>_mesh.ply)
registration_mesh = shared_dir / f"{cad_stem}_{digest}_mesh.ply"
if registration_mesh.exists():
return registration_mesh
# flatness 自己写缓存的目标路径
try:
shared_dir.mkdir(parents=True, exist_ok=True)
test = shared_dir / ".write_test"
test.touch()
test.unlink()
return shared_dir / f"{cad_stem}_{digest}_mesh.ply"
except OSError:
return Path(cad_path).parent / f"{cad_stem}_{digest}_mesh.ply"
def _find_alg_dir(alg_root: str) -> Path: def _find_alg_dir(alg_root: str) -> Path:
"""定位平整度算法目录,支持多个历史版本路径,优先使用最新版。""" """定位平整度算法目录,支持多个历史版本路径,优先使用最新版。"""
candidates = [ candidates = [
@@ -99,11 +133,12 @@ def main() -> int:
try: try:
alg_root = os.environ.get("ALG_ROOT") or os.environ.get("ALG_ROOT_HOST") or "/alg" alg_root = os.environ.get("ALG_ROOT") or os.environ.get("ALG_ROOT_HOST") or "/alg"
emit_progress(0.05, "定位 point_porj 算法目录") emit_progress(0.05, "定位算法目录")
alg_dir = _find_alg_dir(alg_root) alg_dir = _find_alg_dir(alg_root)
# ── 判断走单点云模式还是 datasets 批量模式 ──────────────────────────────
pc_path_raw = first_pointcloud(payload) pc_path_raw = first_pointcloud(payload)
emit_progress(0.10, f"读取点云:{os.path.basename(pc_path_raw)}") use_batch_mode = bool(config.get("useBatchDatasets", True))
# ── ROI 裁剪(整车点云场景下必须填写,否则算法极慢)────────────────────── # ── ROI 裁剪(整车点云场景下必须填写,否则算法极慢)──────────────────────
def _f(key: str) -> float | None: def _f(key: str) -> float | None:
@@ -147,10 +182,61 @@ def main() -> int:
# v1.5 用 o3d.io.read_point_cloud 读取,直接支持二进制 PLY,无需转 ASCII # v1.5 用 o3d.io.read_point_cloud 读取,直接支持二进制 PLY,无需转 ASCII
pc_path = pc_path_raw pc_path = pc_path_raw
module = str(config.get("module") or "all") module = str(config.get("module") or "contour")
emit_progress(0.15, f"初始化 MeasurementPipeline(module={module})") emit_progress(0.15, f"初始化 MeasurementPipeline(module={module})")
pipeline = _build_pipeline(alg_dir, pc_path, out_dir, config) pipeline = _build_pipeline(alg_dir, pc_path if not use_batch_mode else str(alg_dir), out_dir, config)
# ── datasets 批量模式(useBatchDatasets=true 时) ──────────────────────
if use_batch_mode and module == "contour":
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
data_root = alg_dir / "datasets"
# 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定
context_pc = (inputs.get("contextPointCloudPath")
or pc_path_raw
or None)
save_dir = str(out_dir / "contour_batch")
excel_path = str(out_dir / "contour.xlsx")
res = pipeline.run_contour_batch(
all_tasks=True,
data_root=str(data_root),
pointcloud_kind="noise",
point_chunk_size=8192,
triangle_chunk_size=512,
save_path=save_dir,
excel_output_path=excel_path,
combined_context_pointcloud_path=context_pc,
no_visualize=True,
)
emit_progress(0.95, "整理批量结果")
batch_stats = res.get("batch_stats") or {}
measurements: list[dict] = []
for task_id, task_res in (res.get("results") or {}).items():
s = task_res.get("distance_stats") or {}
if s:
measurements.append({
"name": f"任务{task_id} 轮廓偏差均值",
"value": round(float(s.get("mean", 0)), 4),
"unit": "mm", "status": "info",
})
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
panel_plys = sorted([
f"contour_batch/{p.name}"
for p in (out_dir / "contour_batch").glob("*_contour.ply")
if p.name != "combined_contour.ply"
])
if panel_plys:
overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys}
else:
overlay3d = {"kind": "none"}
write_result(out_dir, {
"status": "completed",
"message": f"批量完成 {len(res.get('results', {}))} 个面板测量",
"measurements": measurements,
"overlay3d": overlay3d,
"extra": {"module": "contour_batch", "excelPath": "contour.xlsx"},
})
return 0
measurements: list[dict] = [] measurements: list[dict] = []
extra: dict[str, Any] = {"module": module} extra: dict[str, Any] = {"module": module}
@@ -226,7 +312,7 @@ def main() -> int:
# ── contour ──────────────────────────────────────────────────────────── # ── contour ────────────────────────────────────────────────────────────
if module in ("all", "contour"): if module in ("all", "contour"):
emit_progress(0.75, "轮廓-平整度(需 CAD STL)") emit_progress(0.75, "轮廓-平整度(需 CAD STEP)")
cad_path = inputs.get("cadPath") or None cad_path = inputs.get("cadPath") or None
mesh_path = inputs.get("meshPath") or None mesh_path = inputs.get("meshPath") or None
if not cad_path and not mesh_path: if not cad_path and not mesh_path:
@@ -235,14 +321,54 @@ def main() -> int:
try: try:
save_path = str(out_dir / "contour.ply") save_path = str(out_dir / "contour.ply")
excel_path = str(out_dir / "contour.xlsx") excel_path = str(out_dir / "contour.xlsx")
# STEP mesh 缓存:避免每次重新三角化 442 MB+ 整车 STEP
cached_mesh_ply: str | None = None
if cad_path and not mesh_path:
_deflection = float(
_safe_float(config.get("meshDeflection")) or 0.1
)
_cache_ply = _mesh_cache_path(cad_path, _deflection)
if _cache_ply.exists():
emit_progress(0.76, "使用已缓存 CAD mesh,跳过 STEP 三角化")
mesh_path = str(_cache_ply)
cad_path = None
else:
# 先单独做三角化,完成后立即缓存,再跑点距计算
# 这样即使点距阶段 OOM,下次也不用重新三角化
emit_progress(0.76, "首次三角化 CAD STEP,结果将缓存供后续复用…")
try:
import open3d as o3d # type: ignore
_orig_cad = cad_path
sys.path.insert(0, str(alg_dir))
from cad_converter import CADConverter # type: ignore
_converter = CADConverter()
_verts, _faces = _converter.step_to_numpy_mesh(
_orig_cad, mesh_deflection=_deflection
)
_mesh_o3d = o3d.geometry.TriangleMesh()
_mesh_o3d.vertices = o3d.utility.Vector3dVector(_verts)
_mesh_o3d.triangles = o3d.utility.Vector3iVector(_faces.astype(int))
o3d.io.write_triangle_mesh(str(_cache_ply), _mesh_o3d)
emit_progress(0.80, f"CAD mesh 已缓存:{_cache_ply.name},开始点距计算…")
mesh_path = str(_cache_ply)
cad_path = None
except Exception as _tri_exc:
emit_progress(0.76, f"三角化缓存写入失败({_tri_exc}),继续直接跑…")
context_pc_path = (inputs.get("contextPointCloudPath") or None)
res = pipeline.run_contour( res = pipeline.run_contour(
pointcloud_path=pc_path, pointcloud_path=pc_path,
cad_path=cad_path, cad_path=cad_path,
mesh_path=mesh_path, mesh_path=mesh_path,
point_chunk_size=8192,
triangle_chunk_size=512,
save_path=save_path, save_path=save_path,
excel_output_path=excel_path, excel_output_path=excel_path,
combined_context_pointcloud_path=context_pc_path,
no_visualize=True, no_visualize=True,
) )
stats = res.get("distance_stats") stats = res.get("distance_stats")
for key, label, unit in [ for key, label, unit in [
("mean", "轮廓偏差均值", "mm"), ("mean", "轮廓偏差均值", "mm"),
@@ -268,7 +394,7 @@ def main() -> int:
overlay3d: dict = {"kind": "none"} overlay3d: dict = {"kind": "none"}
for ply_key in ("contourPly", "curvaturePly"): for ply_key in ("contourPly", "curvaturePly"):
if ply_key in extra: if ply_key in extra:
overlay3d = {"kind": "vertexColors", "url": extra[ply_key]} overlay3d = {"kind": "ply", "file": extra[ply_key]}
break break
if skipped: if skipped:

View File

@@ -5,14 +5,14 @@
v2.2 变更: v2.2 变更:
- 算法路径从 alg/点云测距v1.1 → alg/train_measurement_system_v2.2 - 算法路径从 alg/点云测距v1.1 → alg/train_measurement_system_v2.2
- 新增孔中心拟合(hole_center_fitter)、测量日志、性能日志 - 新增孔中心拟合(hole_center_fitter)、测量日志、性能日志
- 内置配置文件改为 config/measurements_0618.yaml - 内置配置文件改为 config/measurements.yaml(v3.2)
- TRAIN_MEAS_ROOT 指向 train_measurement_system_v2.2/(用于定位 config/) - TRAIN_MEAS_ROOT 指向 train_measurement_system_v3.2_0629/(用于定位 config/)
- 算法内置 _read_binary_ply,直接支持二进制 PLY,无需 ASCII 转换 - 算法内置 _read_binary_ply,直接支持二进制 PLY,无需 ASCII 转换
约定: 约定:
- 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中; - 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中;
- 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之; - 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之;
否则使用算法仓库自带的 config/measurements_0618.yaml 作为模板。 否则使用算法仓库自带的 config/measurements.yaml(v3)作为模板。
- 所有任务结果合成成 measurements[] 列表,附带可视化 PLY 路径到 extra。 - 所有任务结果合成成 measurements[] 列表,附带可视化 PLY 路径到 extra。
""" """
@@ -33,6 +33,57 @@ from _plugin_io import (
) )
def _to_float(value):
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _load_excel_measurement_rows(out_dir: Path) -> dict[str, dict]:
"""读取算法导出的 Excel 结论,作为前端 measurements 的权威来源。"""
try:
from openpyxl import load_workbook
excel_files = sorted(
out_dir.glob("measurement_results_*.xlsx"),
key=lambda p: p.stat().st_mtime,
)
if not excel_files:
return {}
wb = load_workbook(excel_files[-1], data_only=True, read_only=True)
ws = wb.active
rows: dict[str, dict] = {}
for row in ws.iter_rows(min_row=2, values_only=True):
name = str(row[0] or "").strip()
if not name:
continue
conclusion = str(row[6] or "").strip()
status = None
if conclusion == "通过":
status = "pass"
elif conclusion in ("不通过", "失败"):
status = "fail"
elif conclusion == "未配置":
status = "info"
rows[name] = {
"value": _to_float(row[3]),
"toleranceLabel": str(row[2]) if row[2] is not None else None,
"status": status,
}
return rows
except Exception as exc:
print(f"读取 Excel 结论失败,继续使用适配器判定:{exc}", file=sys.stderr)
return {}
def main() -> int: def main() -> int:
payload = load_input(sys.argv) payload = load_input(sys.argv)
inputs = payload.get("inputs") or {} inputs = payload.get("inputs") or {}
@@ -41,14 +92,13 @@ def main() -> int:
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
try: try:
emit_progress(0.05, "加载 train_measurement v2.2 算法包") emit_progress(0.05, "加载 train_measurement v3.2 算法包")
# TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system_v2.2/ # TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system_v3.2_0629/
train_root = Path(os.environ.get("TRAIN_MEAS_ROOT") or "") train_root = Path(os.environ.get("TRAIN_MEAS_ROOT") or "")
if not train_root.exists(): if not train_root.exists():
# 回退:从 ALG_ROOT 推导
alg_root = Path(os.environ.get("ALG_ROOT") or "/alg") alg_root = Path(os.environ.get("ALG_ROOT") or "/alg")
train_root = alg_root / "train_measurement_system_v2.2" / "train_measurement_system_v2.2" train_root = alg_root / "train_measurement_system_v3.2_0629"
if not train_root.exists(): if not train_root.exists():
raise RuntimeError( raise RuntimeError(
@@ -78,8 +128,8 @@ def main() -> int:
# 已经是 YAML 格式,直接写盘 # 已经是 YAML 格式,直接写盘
yaml_path.write_text(custom_yaml_str, encoding="utf-8") yaml_path.write_text(custom_yaml_str, encoding="utf-8")
else: else:
# 使用算法仓库内置的 config/measurements_0618.yaml # 使用算法仓库内置的 config/measurements.yaml(v3)
yaml_path = train_root / "config" / "measurements_0618.yaml" yaml_path = train_root / "config" / "measurements.yaml"
if not yaml_path.exists(): if not yaml_path.exists():
raise RuntimeError(f"内置配置文件不存在:{yaml_path}") raise RuntimeError(f"内置配置文件不存在:{yaml_path}")
@@ -87,16 +137,25 @@ def main() -> int:
# ── 初始化 TaskManager ──────────────────────────────────────────────── # ── 初始化 TaskManager ────────────────────────────────────────────────
tm = TaskManager() tm = TaskManager()
# 先注入点云路径,让 load_config 跳过 YAML 里的 pointcloud_file(开发测试路径)
tm.pointcloud_path_override = pc_path tm.pointcloud_path_override = pc_path
# 把输出目录重定向到 outDir,避免写到只读的 /alg 目录
tm.output_dir_override = str(out_dir) tm.output_dir_override = str(out_dir)
emit_progress(0.25, f"读取点云({os.path.basename(pc_path)},可能需要 1~2 分钟)")
if not tm.load_config(str(yaml_path)): if not tm.load_config(str(yaml_path)):
raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}") raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}")
# 注入点云路径(覆盖 YAML 中的 pointcloud_file) # 注入点云路径(覆盖 YAML 中的 pointcloud_file)
tm.config["pointcloud_file"] = pc_path tm.config["pointcloud_file"] = pc_path
# 过滤 enabled=False 的任务(v3 TaskManager 不处理 enabled 字段)
import yaml as _yaml_check
_raw = _yaml_check.safe_load(yaml_path.read_text(encoding="utf-8"))
_enabled_names = {
t["name"] for t in (_raw.get("tasks") or _raw.get("measurements") or [])
if t.get("enabled", True)
}
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
# 注入可视化输出目录 # 注入可视化输出目录
viz_dir = out_dir / "visualizations" viz_dir = out_dir / "visualizations"
viz_dir.mkdir(parents=True, exist_ok=True) viz_dir.mkdir(parents=True, exist_ok=True)
@@ -104,10 +163,37 @@ def main() -> int:
if hasattr(task, "visualization_dir"): if hasattr(task, "visualization_dir"):
task.visualization_dir = str(viz_dir) task.visualization_dir = str(viz_dir)
emit_progress(0.30, "加载点云(已在 load_config 中完成)") emit_progress(0.40, f"点云已加载,开始执行 {len(tm.tasks)} 个测量任务")
emit_progress(0.40, f"执行 {len(tm.tasks)} 个测量任务") # ── 从 YAML 构建公差映射(name → {theoretical, upper, lower}) ─────────
import yaml as _yaml
try:
raw_yaml = _yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
# v3 用 'tasks' key,v2 用 'measurements' key,兼容两者
task_cfgs = raw_yaml.get("tasks") or raw_yaml.get("measurements") or []
tol_map: dict[str, dict] = {}
for tc in task_cfgs:
tname = tc.get("name")
if not tname:
continue
tol = tc.get("tolerance")
theoretical = tc.get("theoretical_dimension")
if isinstance(tol, dict):
tol_map[tname] = {
"theoretical": theoretical,
"upper": float(tol.get("upper", 0)),
"lower": float(tol.get("lower", 0)),
}
elif tol is not None:
tol_map[tname] = {
"theoretical": theoretical,
"upper": float(tol),
"lower": float(tol),
}
except Exception:
tol_map = {}
results = tm.run_all_tasks() results = tm.run_all_tasks()
report_rows = _load_excel_measurement_rows(out_dir)
emit_progress(0.92, "整理结果") emit_progress(0.92, "整理结果")
measurements: list[dict] = [] measurements: list[dict] = []
@@ -133,39 +219,41 @@ def main() -> int:
value = clean_data.get("mean_distance") value = clean_data.get("mean_distance")
if value is None: if value is None:
value = clean_data.get("distance") value = clean_data.get("distance")
fval = float(value) if isinstance(value, (int, float)) else None
# v3.2 data 里直接有 is_qualified 判定结果(line_to_plane/line_to_line 也支持)
data_pass = clean_data.get("is_qualified")
if data_pass is None:
data_pass = clean_data.get("pass") # 兼容旧字段名
tol_cfg = tol_map.get(name)
tolerance = None
meas_status = "info"
if data_pass is not None:
# 算法已判定,直接用
meas_status = "pass" if data_pass else "fail"
if tol_cfg:
tolerance = tol_cfg.get("upper")
elif tol_cfg and fval is not None:
theoretical = tol_cfg.get("theoretical")
upper = tol_cfg["upper"]
lower = tol_cfg["lower"]
tolerance = upper
if theoretical is not None:
error = fval - float(theoretical)
meas_status = "pass" if (-lower <= error <= upper) else "fail"
measurements.append( measurements.append(
{ {
"name": name, "name": name,
"value": float(value) if isinstance(value, (int, float)) else float("nan"), "value": fval if fval is not None else float("nan"),
"unit": "mm", "unit": "mm",
"status": "info", "tolerance": tolerance,
"status": meas_status,
"passLabel": "通过",
"failLabel": "不通过",
} }
) )
std = clean_data.get("std_distance")
if isinstance(std, (int, float)):
measurements.append(
{
"name": f"{name} · 标准差",
"value": float(std),
"unit": "mm",
"status": "info",
}
)
# v1.1 新增字段:min/max distance
for field_name, label in [
("min_distance", "最小距离"),
("max_distance", "最大距离"),
]:
v = clean_data.get(field_name)
if isinstance(v, (int, float)):
measurements.append(
{
"name": f"{name} · {label}",
"value": float(v),
"unit": "mm",
"status": "info",
}
)
else: else:
measurements.append( measurements.append(
{ {
@@ -176,6 +264,17 @@ def main() -> int:
} }
) )
for measurement in measurements:
report_row = report_rows.get(measurement["name"])
if not report_row:
continue
if report_row.get("value") is not None:
measurement["value"] = report_row["value"]
if report_row.get("toleranceLabel"):
measurement["toleranceLabel"] = report_row["toleranceLabel"]
if report_row.get("status"):
measurement["status"] = report_row["status"]
success_count = sum(1 for r in results if r.get("status") == "success") success_count = sum(1 for r in results if r.get("status") == "success")
payload_out = { payload_out = {
"schemaVersion": 1, "schemaVersion": 1,

View File

@@ -1,18 +1,14 @@
"""underframe-deflection 算法适配器 (v1.2.1)。 """underframe-deflection 算法适配器 (v1.2.1)。
调用 alg/delivery_v1.2.1/underframe_deflection 的 run_both_beams:
- 输入:整车配准点云 + 左梁 ROI JSON + 右梁 ROI JSON + CAD STP(溯源用)
- 输出:left/ right/ 各一份 report.xlsx + report.json + deflection_curve.csv/png
- measurements 展示左右梁最大挠度及检验结论
v1.2.1 变更: v1.2.1 变更:
- 算法包升级至 delivery_v1.2.1(单梁结果新增项点检验字段:公差/测量/误差/结论) - 改用 run_both_beams 双梁并行入口,与算法方命令行一致
- report.xlsx 等 Excel 产物通过 result.json 的 files 字段暴露给前端下载 - leftRoiPath / rightRoiPath 参数默认指向 test_data ROI
- PYTHONPATH 指向 delivery_v1.2.1 - skipRegistration 默认 True(点云已配准)
v1.2 变更:
- 新增 single_beam 单梁管线
v1.1 变更:
- 入口改为 run_measurement_pipeline(返回 dict,同时落盘 report.json / xlsx / PLY)
- 新增 cad_path(CAD 基准 PLY/STL)和 roi_path(ROI JSON)可选输入
- 新增 skip_registration / enable_bilateral 配置项
- 结果从 report dict 中读取,而非 DeflectionResult 对象字段
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,142 +31,139 @@ from _plugin_io import (
def _status_for(value: float | None, tolerance: float | None) -> str: def _status_for(value: float | None, tolerance: float | None) -> str:
if value is None or tolerance is None: if value is None or tolerance is None:
return "info" return "info"
if abs(value) <= tolerance: abs_val = abs(value)
if abs_val <= tolerance:
return "pass" return "pass"
if abs(value) <= tolerance * 1.5: if abs_val <= tolerance * 1.5:
return "warning" return "warning"
return "fail" return "fail"
def _safe_float(v: object) -> float | None:
try:
return float(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def main() -> int: def main() -> int:
payload = load_input(sys.argv) payload = load_input(sys.argv)
inputs = payload.get("inputs") or {} inputs = payload.get("inputs") or {}
config = inputs.get("config") or {} config = inputs.get("config") or {}
out_dir = Path(payload["outDir"]) out_dir = Path(payload["outDir"])
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
try: try:
emit_progress(0.05, "加载 underframe_deflection v1.2.1 算法包") emit_progress(0.05, "加载挠度算法包 v1.2.1")
from underframe_deflection.pipeline import run_measurement_pipeline # type: ignore
pc_path = first_pointcloud(payload) # ── 输入文件 ────────────────────────────────────────────────────────
emit_progress(0.15, f"读取点云:{os.path.basename(pc_path)}") pointcloud_path = first_pointcloud(payload)
if not pointcloud_path:
write_error_result(out_dir, "未提供点云文件")
return 1
profile = str(config.get("profile") or "auto")
skip_registration = bool(config.get("skipRegistration") or False)
enable_bilateral = bool(config.get("enableBilateral") if "enableBilateral" in config else True)
# 可选 CAD 基准(PLY/STL)和 ROI JSON
cad_path = inputs.get("cadPath") or None cad_path = inputs.get("cadPath") or None
roi_path = inputs.get("roiPath") or None
emit_progress(0.20, "调用 run_measurement_pipeline") left_roi = config.get("leftRoiPath") or \
"/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_left_beam.json"
right_roi = config.get("rightRoiPath") or \
"/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_right_beam.json"
# run_measurement_pipeline 内部会打印进度到 stdout, for p, label in [(left_roi, "左梁 ROI"), (right_roi, "右梁 ROI")]:
# 我们在调用前后各报一次进度即可。 if not os.path.isfile(p):
report: dict = run_measurement_pipeline( write_error_result(out_dir, f"{label} 文件不存在:{p}")
input_cloud=pc_path, return 1
out_dir=str(out_dir),
profile=profile, tolerance_mm = float(config.get("toleranceMm") or 3.0)
cad_path=cad_path, left_tolerance_mm = float(config.get("leftToleranceMm") or tolerance_mm or 7.0)
roi_path=roi_path, right_tolerance_mm = float(config.get("rightToleranceMm") or tolerance_mm or 12.0)
skip_registration=skip_registration, skip_registration = bool(config.get("skipRegistration", True))
enable_bilateral=enable_bilateral, enable_bilateral = bool(config.get("enableBilateral", True))
skip_excel=False,
emit_progress(0.10, f"开始左梁挠度测量(容差 {left_tolerance_mm} mm)")
from underframe_deflection import run_single_beam_pipeline # type: ignore
left_out = out_dir / "left"
left_out.mkdir(exist_ok=True)
left_report = run_single_beam_pipeline(
input_cloud = pointcloud_path,
roi_path = left_roi,
out_dir = str(left_out),
side = "left",
tolerance_mm = left_tolerance_mm,
cad_path = cad_path,
enable_registration= not skip_registration,
config = {"enable_bilateral": enable_bilateral},
) )
emit_progress(0.92, "整理结果") emit_progress(0.55, f"左梁完成,开始右梁挠度测量(容差 {right_tolerance_mm} mm)")
right_out = out_dir / "right"
right_out.mkdir(exist_ok=True)
right_report = run_single_beam_pipeline(
input_cloud = pointcloud_path,
roi_path = right_roi,
out_dir = str(right_out),
side = "right",
tolerance_mm = right_tolerance_mm,
cad_path = cad_path,
enable_registration= not skip_registration,
config = {"enable_bilateral": enable_bilateral},
)
# ── 从 report dict 中提取核心测量值 ────────────────────────────────── result = {"left": left_report, "right": right_report}
deflection = report.get("deflection") or {}
left_sel = (deflection.get("left") or {}).get("selected_measurement") or {}
right_sel = (deflection.get("right") or {}).get("selected_measurement") or {}
left_mm = _safe_float(left_sel.get("signed_deflection_mm")) emit_progress(0.95, "整理结果")
right_mm = _safe_float(right_sel.get("signed_deflection_mm"))
registration = report.get("registration") or {} def _beam_measurements(report: dict, label: str) -> list:
reg_rms = _safe_float(registration.get("rms_mm")) insp = report.get("inspection") or {}
item_name = insp.get("item_name") or "纵向梁到直线的垂直偏差(挠度)"
measured = insp.get("measured_mm")
tol = insp.get("tolerance_mm")
verdict = insp.get("verdict") or ""
return [{
"name" : f"{label} · {item_name}",
"value" : round(float(measured), 4) if measured is not None else 0.0,
"unit" : "mm",
"tolerance": float(tol) if tol is not None else None,
"status" : "pass" if verdict == "合格"
else "fail" if verdict == "不合格"
else "info",
}]
quality_flags: list[str] = report.get("quality_flags") or [] measurements = (
_beam_measurements(left_report, "左梁") +
_beam_measurements(right_report, "右梁")
)
# 容差:高铁底架挠度通常要求 ≤ 3 mm(可在 manifest inputs 中暴露) # 收集产物文件(xlsx/csv/png)- write_result 会自动扫 xlsx,
tol = _safe_float(config.get("toleranceMm")) or 3.0 # 这里额外加 csv/png 让前端也能下载
extra_files: list[str] = []
for subdir in ("left", "right"):
sub = out_dir / subdir
if not sub.exists():
continue
for fname in sorted(sub.iterdir()):
if fname.suffix in (".csv", ".png"):
extra_files.append(str(fname.relative_to(out_dir)))
measurements = [ # ── 汇总消息 ────────────────────────────────────────────────────────
left_v = (left_report.get("inspection") or {}).get("verdict") or "—"
right_v = (right_report.get("inspection") or {}).get("verdict") or "—"
left_d = (left_report.get("inspection") or {}).get("measured_mm")
right_d = (right_report.get("inspection") or {}).get("measured_mm")
msg = (
f"挠度测量完成。"
f"左梁 {left_d:.3f} mm({left_v}),"
f"右梁 {right_d:.3f} mm({right_v})。"
if left_d is not None and right_d is not None
else "挠度测量完成。"
)
write_result(
out_dir,
{ {
"name": "左梁挠度", "message": msg,
"value": left_mm if left_mm is not None else float("nan"), "measurements": measurements,
"unit": "mm", "files": extra_files,
"tolerance": tol, "extra": {"leftReport": left_report, "rightReport": right_report},
"status": _status_for(left_mm, tol),
}, },
{ )
"name": "右梁挠度",
"value": right_mm if right_mm is not None else float("nan"),
"unit": "mm",
"tolerance": tol,
"status": _status_for(right_mm, tol),
},
]
if reg_rms is not None:
measurements.append(
{
"name": "配准 RMS",
"value": reg_rms,
"unit": "mm",
"status": "pass" if reg_rms <= 1.0 else "warning" if reg_rms <= 2.0 else "fail",
}
)
# ── 3D 叠加层:优先 out_dir/ply/ 子目录,回退到 out_dir 根目录 ──────────
overlay3d: dict = {"kind": "none"}
ply_files: list = []
ply_dir = out_dir / "ply"
if ply_dir.exists():
ply_files = sorted(ply_dir.glob("*.ply"))
if not ply_files:
# 算法直接输出到 out_dir 根目录;优先取 full overlay
for name in ("roi_measurement_overlay_full.ply", "roi_cropped_cloud.ply"):
candidate = out_dir / name
if candidate.exists():
ply_files = [candidate]
break
if not ply_files:
ply_files = sorted(out_dir.glob("*.ply"))
if ply_files:
rel = ply_files[0].relative_to(out_dir)
overlay3d = {
"kind": "ply",
"file": str(rel),
}
payload_out = {
"schemaVersion": report.get("schema_version", "1.1"),
"status": "completed",
"measurements": measurements,
"overlay3d": overlay3d,
"extra": {
"algorithmVersion": report.get("algorithm_version"),
"qualityFlags": quality_flags,
"registration": registration,
"deflectionFull": deflection,
},
}
write_result(out_dir, payload_out)
emit_progress(1.0, "完成")
return 0 return 0
except Exception as exc: except Exception as exc:
traceback.print_exc()
write_error_result( write_error_result(
out_dir, out_dir,
f"underframe-deflection 计算失败:{exc}", f"underframe-deflection 计算失败:{exc}",

View File

@@ -13,14 +13,15 @@
"name": "profile", "name": "profile",
"label": "调参档位", "label": "调参档位",
"type": "select", "type": "select",
"default": "auto", "default": "train2_coupler",
"options": [ "options": [
{ "value": "train2_coupler", "label": "train2 整车扫描(推荐)" },
{ "value": "auto", "label": "自动 (auto)" }, { "value": "auto", "label": "自动 (auto)" },
{ "value": "default", "label": "默认 (default)" }, { "value": "default", "label": "默认 (default)" },
{ "value": "high_density", "label": "高密度 (high_density)" }, { "value": "high_density", "label": "高密度 (high_density)" },
{ "value": "low_density", "label": "低密度 (low_density)" } { "value": "low_density", "label": "低密度 (low_density)" }
], ],
"description": "建议保持 auto,算法会根据点云密度自动选档。" "description": "train2_coupler:针对 train_2_1T 整车扫描优化,孔半径 11~19mm,含层隔离。auto:根据点云密度自动选档(不适用于整车扫描)。"
}, },
{ {
"name": "rightAxisSign", "name": "rightAxisSign",
@@ -34,14 +35,14 @@
"description": "若车体「右」方向为 +y 保持 +1;若为 -y 改为 -1。" "description": "若车体「右」方向为 +y 保持 +1;若为 -y 改为 -1。"
}, },
{ {
"name": "roiXMin", "label": "ROI X 最小值 (mm)", "type": "number", "default": null, "name": "roiXMin", "label": "ROI X 最小值 (mm)", "type": "number", "default": 11526,
"description": "车钩面板局部 ROI 裁剪范围(相对于配准后 CAD 坐标系)。整车点云时必填,否则算法处理全部点云会极慢。留空 = 不裁剪。" "description": "车钩面板局部 ROI 裁剪范围(相对于配准后 CAD 坐标系,train_2 默认值来自 roi_train2_coupler.json)。整车点云时必须保留有效值,否则算法处理全部点云会极慢。"
}, },
{ "name": "roiXMax", "label": "ROI X 最大值 (mm)", "type": "number", "default": null }, { "name": "roiXMax", "label": "ROI X 最大值 (mm)", "type": "number", "default": 11733 },
{ "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": null }, { "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": 588 },
{ "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": null }, { "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": 1192 },
{ "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": null }, { "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": -950 },
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null } { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": 950 }
], ],
"runtime": { "runtime": {
"kind": "process", "kind": "process",

View File

@@ -2,23 +2,28 @@
"id": "flatness-suite", "id": "flatness-suite",
"name": "平整度 / 平面度 / 轮廓测量", "name": "平整度 / 平面度 / 轮廓测量",
"shortName": "flatness-suite", "shortName": "flatness-suite",
"description": "平整度 / 平面度 / 轮廓 / 曲率 套件(v1.5,来自 alg/平整度、平面度、轮廓测量v1.5)。v1.5 新增多车型 STEP CAD 模板(car_part_v1)与 contour Excel 报表。任意子模块缺依赖时自动跳过并在 message 里说明。", "description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
"version": "1.5.0", "version": "1.5.0",
"schemaVersion": 1, "schemaVersion": 1,
"category": "几何测量", "category": "几何测量",
"requiresPointCloud": true, "requiresPointCloud": false,
"requiresCad": false, "requiresCad": false,
"inputs": [ "inputs": [
{
"name": "useBatchDatasets",
"label": "批量模式(用 datasets/Noise_PointCloud 计算,所选点云仅用于可视化展示)",
"type": "boolean",
"default": true
},
{ {
"name": "module", "name": "module",
"label": "测量模块", "label": "测量模块",
"type": "select", "type": "select",
"default": "all", "default": "contour",
"options": [ "options": [
{ "value": "all", "label": "全部 (plane + contour + curvature)" }, { "value": "contour", "label": "轮廓-平整度 (contour,需 CAD STEP)" },
{ "value": "plane", "label": "平面拟合 (plane)" }, { "value": "plane", "label": "平面拟合 (plane)" },
{ "value": "contour", "label": "轮廓-平整度 (contour,需 STL)" }, { "value": "curvature", "label": "曲率估计 (curvature,大点云慎用)" }
{ "value": "curvature", "label": "曲率估计 (curvature)" }
] ]
}, },
{ {
@@ -39,7 +44,14 @@
{ "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": null }, { "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": null },
{ "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": null }, { "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": null },
{ "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": null }, { "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": null },
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null } { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
{
"name": "contextPointCloudPath",
"label": "整车背景点云路径(可视化合并用)",
"type": "string",
"default": null,
"description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。"
}
], ],
"runtime": { "runtime": {
"kind": "process", "kind": "process",
@@ -49,7 +61,8 @@
"{inputJson}" "{inputJson}"
], ],
"envExtra": { "envExtra": {
"ALG_ROOT": "{algRoot}" "ALG_ROOT": "{algRoot}",
"LD_LIBRARY_PATH": "/lib/x86_64-linux-gnu:/usr/local/nvidia/lib:/usr/local/nvidia/lib64"
}, },
"timeoutMs": 5400000 "timeoutMs": 5400000
} }

View File

@@ -2,8 +2,8 @@
"id": "train-measurement", "id": "train-measurement",
"name": "列车点云测距", "name": "列车点云测距",
"shortName": "train-measurement", "shortName": "train-measurement",
"description": "基于配置 YAML 驱动的列车点云批量测距套件(v2.2)。v2.2 新增孔中心拟合(hole_center_fitter)、测量日志(measurement_logger)、性能日志(performance_logger)。支持「平面到平面 / 点到平面 / 点到点 / 带孔」四类任务,可在 3D 视图中拖拽 ROI 包围盒可视化配置。", "description": "基于配置 YAML 驱动的列车点云批量测距套件(v3.2,2026-06-29)。v3.2 新增 line_to_plane / line_to_line 测量类型,修复原 4 个 disabled 任务(侧门高度、窗口下沿高度、司机室门口宽/高),现全部 16 个任务可用。",
"version": "2.2.0", "version": "3.2.0",
"schemaVersion": 1, "schemaVersion": 1,
"category": "尺寸测量", "category": "尺寸测量",
"requiresPointCloud": true, "requiresPointCloud": true,
@@ -37,8 +37,8 @@
"{inputJson}" "{inputJson}"
], ],
"envExtra": { "envExtra": {
"PYTHONPATH": "{algRoot}/train_measurement_system_v2.2/train_measurement_system_v2.2/src", "PYTHONPATH": "{algRoot}/train_measurement_system_v3.2_0629/src",
"TRAIN_MEAS_ROOT": "{algRoot}/train_measurement_system_v2.2/train_measurement_system_v2.2" "TRAIN_MEAS_ROOT": "{algRoot}/train_measurement_system_v3.2_0629"
}, },
"timeoutMs": 5400000 "timeoutMs": 5400000
} }

View File

@@ -2,7 +2,7 @@
"id": "underframe-deflection", "id": "underframe-deflection",
"name": "构架挠度测量", "name": "构架挠度测量",
"shortName": "underframe-deflection", "shortName": "underframe-deflection",
"description": "基于点云的高铁底架纵梁挠度测量(v1.2.1,来自 alg/delivery_v1.2.1/underframe_deflection)。支持 CAD 基准配准与 ROI 裁剪,输出左右梁挠度、配准 RMS 及挠度曲线 PLY 叠加层。v1.2.1 新增项点检验字段(公差/测量/误差/结论)与 Excel 报表导出。", "description": "基于点云的高铁底架纵梁挠度测量(v1.2.1,来自 alg/delivery_v1.2.1/underframe_deflection)。左右梁各一份 Excel 报表(挠度曲线 + 项点检验),支持流式 ROI 裁剪(10 亿点不爆内存)。",
"version": "1.2.1", "version": "1.2.1",
"schemaVersion": 1, "schemaVersion": 1,
"category": "几何测量", "category": "几何测量",
@@ -10,33 +10,45 @@
"requiresCad": false, "requiresCad": false,
"inputs": [ "inputs": [
{ {
"name": "profile", "name": "leftRoiPath",
"label": "调参档位", "label": "左梁 ROI JSON 路径",
"type": "select", "type": "string",
"default": "auto", "default": "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_left_beam.json",
"options": [ "description": "左侧纵梁的 AABB 裁剪框 JSON 文件绝对路径(容器内路径)。"
{ "value": "auto", "label": "自动 (auto)" },
{ "value": "M", "label": "动车组 (M)" },
{ "value": "TC", "label": "拖车-控制 (TC)" },
{ "value": "TP", "label": "拖车-供电 (TP)" }
]
}, },
{ {
"name": "toleranceMm", "name": "rightRoiPath",
"label": "挠度容差 (mm)", "label": "右梁 ROI JSON 路径",
"type": "string",
"default": "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_right_beam.json",
"description": "右侧纵梁的 AABB 裁剪框 JSON 文件绝对路径(容器内路径)。"
},
{
"name": "leftToleranceMm",
"label": "左梁挠度容差 (mm)",
"type": "number", "type": "number",
"default": 3.0, "default": 7.0,
"min": 0.5, "min": 0.5,
"max": 10.0, "max": 20.0,
"step": 0.5, "step": 0.5,
"description": "超出此值标记为 fail,1.5× 以内标记为 warning。" "description": "左侧纵梁挠度合格/不合格判定容差。"
},
{
"name": "rightToleranceMm",
"label": "右梁挠度容差 (mm)",
"type": "number",
"default": 12.0,
"min": 0.5,
"max": 20.0,
"step": 0.5,
"description": "右侧纵梁挠度合格/不合格判定容差。"
}, },
{ {
"name": "skipRegistration", "name": "skipRegistration",
"label": "跳过 CAD 配准", "label": "跳过 CAD 配准",
"type": "boolean", "type": "boolean",
"default": false, "default": true,
"description": "若点云已在 CAD 坐标系中可跳过配准(需提供 CAD 基准时才生效)。" "description": "点云已在 CAD 坐标系中(整车配准后),跳过 CAD 配准直接测量。"
}, },
{ {
"name": "enableBilateral", "name": "enableBilateral",

View File

@@ -98,7 +98,8 @@
"{inputJson}" "{inputJson}"
], ],
"envExtra": { "envExtra": {
"PYTHONPATH": "{algRoot}/整车配准v1.3" "PYTHONPATH": "{algRoot}/整车配准v1.3",
"LD_LIBRARY_PATH": "/lib/x86_64-linux-gnu:/usr/local/nvidia/lib:/usr/local/nvidia/lib64"
}, },
"timeoutMs": 1800000 "timeoutMs": 1800000
} }

View File

@@ -141,6 +141,8 @@ export type PluginOverlay3D =
/** 着色 PLY 叠加文件(同样相对 outDir) */ /** 着色 PLY 叠加文件(同样相对 outDir) */
kind: 'ply'; kind: 'ply';
file: string; file: string;
/** 批量结果可包含多个 PLY 叠加文件 */
files?: string[];
}; };
/** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */ /** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */
@@ -177,4 +179,8 @@ export interface PluginResultData extends Partial<PluginResult> {
error?: string; error?: string;
/** 子进程已结束时的退出码 */ /** 子进程已结束时的退出码 */
exitCode?: number | null; exitCode?: number | null;
/** 本次算法运行实际使用的任务点云文件 ID,用于前端叠加层匹配校验 */
inputPointCloudFileIds?: number[];
/** 本次算法运行前端选择的任务 CAD 文件 ID,用于后续匹配/追溯 */
inputCadFileIds?: number[];
} }

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export type SampledPoint = { x: number; y: number; z: number; error: number }; export type SampledPoint = { x: number; y: number; z: number; error: number; r?: number; g?: number; b?: number };
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */ /** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
export async function randomSeekSample( export async function randomSeekSample(
@@ -170,15 +170,20 @@ function samplePlyBinary(
const bytesPerVertex = properties.reduce((s, p) => s + p.byteSize, 0); const bytesPerVertex = properties.reduce((s, p) => s + p.byteSize, 0);
// 找 x, y, z(可能叫 x/y/z 或 X/Y/Z;允许宽松匹配) // 找 x, y, z,以及 red/green/blue(uchar 0-255)
let xOff = -1, yOff = -1, zOff = -1; let xOff = -1, yOff = -1, zOff = -1;
let rOff = -1, gOff = -1, bOff = -1;
let xType = 'float', yType = 'float', zType = 'float'; let xType = 'float', yType = 'float', zType = 'float';
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
let byteOff = 0; let byteOff = 0;
for (const p of properties) { for (const p of properties) {
const n = p.name.toLowerCase(); const n = p.name.toLowerCase();
if (n === 'x') { xOff = byteOff; xType = p.type; } if (n === 'x') { xOff = byteOff; xType = p.type; }
else if (n === 'y') { yOff = byteOff; yType = p.type; } else if (n === 'y') { yOff = byteOff; yType = p.type; }
else if (n === 'z') { zOff = byteOff; zType = p.type; } else if (n === 'z') { zOff = byteOff; zType = p.type; }
else if (n === 'red') { rOff = byteOff; rType = p.type; }
else if (n === 'green') { gOff = byteOff; gType = p.type; }
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
byteOff += p.byteSize; byteOff += p.byteSize;
} }
@@ -224,7 +229,13 @@ function samplePlyBinary(
const z = readNum(recBuf, zOff, zType); const z = readNum(recBuf, zOff, zType);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue; if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5; const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
points.push({ x, y, z, error }); const pt: SampledPoint = { x, y, z, error };
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
pt.r = readNum(recBuf, rOff, rType);
pt.g = readNum(recBuf, gOff, gType);
pt.b = readNum(recBuf, bOff, bType);
}
points.push(pt);
} }
} finally { } finally {
fs.closeSync(fd); fs.closeSync(fd);
@@ -277,12 +288,25 @@ function samplePlyAscii(
const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim(); const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim();
if (!line) continue; if (!line) continue;
const parts = line.split(/\s+/); const parts = line.split(/\s+/);
const x = parseFloat(parts[0]); // 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
const y = parseFloat(parts[1]); let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
const z = parseFloat(parts[2]); header.properties.forEach((p, i) => {
const n = p.name.toLowerCase();
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
});
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue; if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5; const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
points.push({ x, y, z, error }); const pt: SampledPoint = { x, y, z, error };
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
pt.r = parseFloat(parts[ri]);
pt.g = parseFloat(parts[gi]);
pt.b = parseFloat(parts[bi]);
}
points.push(pt);
} }
} finally { } finally {
fs.closeSync(fd); fs.closeSync(fd);

View File

@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { randomUUID } from "crypto";
import * as db from "./db"; import * as db from "./db";
import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js"; import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js";
import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js"; import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
@@ -13,6 +14,293 @@ import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
import { stepParseCacheDir } from "./stepParse.js"; import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js"; import { listPlugins, getPlugin } from "./plugins/registry.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js"; import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
const DATA_DIR = process.env.DATA_DIR || "/data";
const TASKS_DIR = path.join(DATA_DIR, "tasks");
const DEMO_MEASUREMENT_PLUGIN_IDS = [
"train-measurement",
"coupler-panel",
"flatness-suite",
"underframe-deflection",
] as const;
const DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS = new Set<string>([
"coupler-panel",
"flatness-suite",
"underframe-deflection",
]);
async function validateRegistrationRunInput(input: {
taskId: number;
pointCloudFileIds?: number[];
cadFileIds?: number[];
allowDuplicateRegistration?: boolean;
}) {
const pointCloudFileIds = input.pointCloudFileIds ?? [];
const cadFileIds = input.cadFileIds ?? [];
if (pointCloudFileIds.length !== 1 || cadFileIds.length !== 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "整车配准必须恰好选择 1 个点云文件和 1 个 CAD 文件。",
});
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const pointcloud = pointcloudFiles.find((row) => row.id === pointCloudFileIds[0]);
if (!pointcloud) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的点云文件不存在。" });
}
if (pointcloud.source === "registered") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "已配准点云不能再次作为配准输入,请选择原始点云进行配准。",
});
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === cadFileIds[0]);
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
const registeredChildren = pointcloudFiles
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
if (registeredChildren.length > 0 && !input.allowDuplicateRegistration) {
const latest = registeredChildren[0];
throw new TRPCError({
code: "CONFLICT",
message: `点云「${pointcloud.name}」已有配准产物「${latest.name}」,重复配准需要确认。`,
});
}
}
function latestRegisteredPointcloud(
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
sourcePointcloudFileId: number,
) {
return pointcloudFiles
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === sourcePointcloudFileId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
}
function normalizeDemoMeasurementPluginIds(input?: string[]) {
const ids = input && input.length > 0 ? input : [...DEMO_MEASUREMENT_PLUGIN_IDS];
const unique = Array.from(new Set(ids));
const supported = new Set<string>(DEMO_MEASUREMENT_PLUGIN_IDS);
const unsupported = unique.filter((id) => !supported.has(id));
if (unsupported.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `一键全流程演示不支持算法:${unsupported.join(", ")}`,
});
}
return unique;
}
function paramsForDemoPlugin(pluginId: string): Record<string, unknown> {
if (pluginId === "flatness-suite") {
return {
useBatchDatasets: true,
module: "contour",
};
}
return {};
}
function defaultParamsForManifest(manifest: NonNullable<ReturnType<typeof getPlugin>>): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
for (const field of manifest.inputs ?? []) {
if (field.default !== undefined) {
defaults[field.name] = field.default;
}
}
return defaults;
}
function mergePluginParams(
manifest: NonNullable<ReturnType<typeof getPlugin>>,
params?: Record<string, unknown>,
): Record<string, unknown> {
return {
...defaultParamsForManifest(manifest),
...(params ?? {}),
};
}
async function createAndEnqueuePluginJob(input: {
taskId: number;
pluginId: string;
params?: Record<string, unknown>;
pointCloudFileIds?: number[];
cadFileIds?: number[];
resultData?: Record<string, unknown>;
}) {
const manifest = getPlugin(input.pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${input.pluginId}`,
});
}
const record = await db.createTaskAnalysisResult({
taskId: input.taskId,
type: "plugin",
pluginId: manifest.id,
schemaVersion: manifest.schemaVersion,
status: "processing",
resultData: input.resultData,
});
const promise = enqueueAlgorithmJob(
record.id,
input.taskId,
manifest.id,
mergePluginParams(manifest, input.params),
input.pointCloudFileIds,
input.cadFileIds,
);
return {
id: record.id,
pluginId: manifest.id,
promise,
};
}
const DUPLICATE_INSPECTION_TASK_NAME_MESSAGE = "该车型下已存在同名检测任务。";
function duplicateInspectionTaskNameError() {
return new TRPCError({
code: "CONFLICT",
message: DUPLICATE_INSPECTION_TASK_NAME_MESSAGE,
});
}
function isDuplicateKeyError(error: unknown) {
const err = error as { code?: string; errno?: number; message?: string };
return err?.code === "ER_DUP_ENTRY" || err?.errno === 1062 ||
(typeof err?.message === "string" && err.message.includes("uk_inspection_tasks_model_name"));
}
const CANCELLABLE_ANALYSIS_STATUSES = new Set(["pending", "processing"]);
const cancelledDemoTaskIds = new Set<number>();
function markDemoFlowCancelled(taskId: number): void {
cancelledDemoTaskIds.add(taskId);
}
function clearDemoFlowCancelled(taskId: number): void {
cancelledDemoTaskIds.delete(taskId);
}
function isDemoFlowCancelled(taskId: number): boolean {
return cancelledDemoTaskIds.has(taskId);
}
function cancelledResultData(
row: Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>,
reason: string,
): Record<string, unknown> {
const previous = (row?.resultData as Record<string, unknown> | null) ?? {};
return {
...previous,
cancelled: true,
cancelReason: reason,
cancelledAt: new Date().toISOString(),
stage: "已停止",
};
}
async function cancelTaskAnalysisResultById(id: number, reason = "用户停止任务") {
const row = await db.getTaskAnalysisResultById(id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "分析结果不存在。" });
}
if (!CANCELLABLE_ANALYSIS_STATUSES.has(row.status)) {
return { success: true as const, id, cancelled: false as const, status: row.status };
}
const pluginCancelled = cancelAlgorithmJob(id);
const legacyCancelled = cancelLegacyAnalysisJob(id);
await db.updateTaskAnalysisResult(id, {
status: "cancelled",
resultData: cancelledResultData(row, reason),
});
return {
success: true as const,
id,
cancelled: true as const,
pluginCancelled,
legacyCancelled,
};
}
async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户停止任务") {
const rows = await db.listTaskAnalysisResults(taskId);
const cancellable = rows.filter((row) => CANCELLABLE_ANALYSIS_STATUSES.has(row.status));
const pluginCancelled = cancelAlgorithmJobsByTask(taskId);
const legacyCancelled = cancelLegacyAnalysisJobsByTask(taskId);
for (const row of cancellable) {
await db.updateTaskAnalysisResult(row.id, {
status: "cancelled",
resultData: cancelledResultData(row, reason),
});
}
return {
success: true as const,
cancelledIds: cancellable.map((row) => row.id),
pluginCancelled,
legacyCancelled,
};
}
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
registrationResultId: number;
measurementPluginIds: string[];
demoFlowRunId: string;
}) {
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
if (registrationResult?.status !== "completed") {
return;
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const registered = latestRegisteredPointcloud(pointcloudFiles, input.sourcePointCloudFileId);
if (!registered) {
console.error(
`[runDemoAll] 配准完成但未找到 registered 点云 taskId=${input.taskId} sourcePointCloudFileId=${input.sourcePointCloudFileId}`,
);
return;
}
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
const pluginId = input.measurementPluginIds[idx];
const manifest = getPlugin(pluginId);
await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId,
params: paramsForDemoPlugin(pluginId),
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
? [registered.id]
: [input.sourcePointCloudFileId],
cadFileIds: manifest?.requiresCad ? [input.cadFileId] : undefined,
resultData: {
demoFlowRunId: input.demoFlowRunId,
demoFlowRole: "measurement",
demoFlowIndex: idx + 2,
demoFlowTotal: input.measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
},
});
}
}
export const appRouter = router({ export const appRouter = router({
system: systemRouter, system: systemRouter,
@@ -147,6 +435,23 @@ export const appRouter = router({
}), }),
}), }),
// ─── 车型点云文件 ─────────────────────────────────────────────────────────
modelPointcloudFile: router({
list: protectedProcedure
.input(z.object({ modelId: z.number() }))
.query(async ({ input }) => {
return db.listModelPointcloudFiles(input.modelId);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteModelPointcloudFile(input.id);
return { success: true };
}),
}),
// ─── 任务 CAD 文件 ──────────────────────────────────────────────────────────── // ─── 任务 CAD 文件 ────────────────────────────────────────────────────────────
taskCadFile: router({ taskCadFile: router({
@@ -162,6 +467,46 @@ export const appRouter = router({
await db.deleteTaskCadFile(input.id); await db.deleteTaskCadFile(input.id);
return { success: true }; return { success: true };
}), }),
linkFromModel: protectedProcedure
.input(z.object({
taskId: z.number(),
modelCadFileIds: z.array(z.number()).min(1),
}))
.mutation(async ({ input }) => {
try {
const files = await db.linkTaskCadFilesFromModel(input.taskId, input.modelCadFileIds);
return { success: true, files };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (message.includes("another model")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的 CAD 库文件" });
}
if (message.includes("not found")) {
throw new TRPCError({ code: "NOT_FOUND", message: "任务或 CAD 库文件不存在" });
}
throw e;
}
}),
// 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY)
hasSampleCache: protectedProcedure
.input(z.object({ cadFileIds: z.array(z.number()) }))
.query(async ({ input }) => {
const result: Record<number, boolean> = {};
for (const id of input.cadFileIds) {
const row = await db.getTaskCadFileById(id);
if (!row?.filePath) { result[id] = false; continue; }
const cacheDir = path.join(path.dirname(row.filePath), '.cad_sample_cache');
try {
const files = fs.readdirSync(cacheDir);
result[id] = files.some(f => f.endsWith('.ply'));
} catch {
result[id] = false;
}
}
return result;
}),
}), }),
// ─── 检测任务 ────────────────────────────────────────────────────────────── // ─── 检测任务 ──────────────────────────────────────────────────────────────
@@ -182,18 +527,30 @@ export const appRouter = router({
create: protectedProcedure create: protectedProcedure
.input(z.object({ .input(z.object({
modelId: z.number(), modelId: z.number(),
name: z.string().min(1).max(255), name: z.string().trim().min(1).max(255),
inspector: z.string().max(100).optional(), inspector: z.string().max(100).optional(),
notes: z.string().optional(), notes: z.string().optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return db.createInspectionTask({ const existing = await db.findInspectionTaskByModelAndName(input.modelId, input.name);
modelId: input.modelId, if (existing) {
name: input.name, throw duplicateInspectionTaskNameError();
inspector: input.inspector ?? null, }
notes: input.notes ?? null,
status: "pending", try {
}); return await db.createInspectionTask({
modelId: input.modelId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
status: "created",
});
} catch (error) {
if (isDuplicateKeyError(error)) {
throw duplicateInspectionTaskNameError();
}
throw error;
}
}), }),
update: protectedProcedure update: protectedProcedure
@@ -202,7 +559,7 @@ export const appRouter = router({
name: z.string().max(255).optional(), name: z.string().max(255).optional(),
inspector: z.string().max(100).optional(), inspector: z.string().max(100).optional(),
notes: z.string().optional(), notes: z.string().optional(),
status: z.enum(["pending", "processing", "completed", "error"]).optional(), status: z.enum(["created", "pending", "processing", "completed", "error"]).optional(),
})) }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { id, ...data } = input; const { id, ...data } = input;
@@ -213,12 +570,19 @@ export const appRouter = router({
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await db.deleteInspectionTask(input.id); // 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。
// 取消该任务下所有正在运行或排队的算法 job const cancelled = [
const cancelled = cancelAlgorithmJobsByTask(input.id); ...cancelAlgorithmJobsByTask(input.id),
...cancelLegacyAnalysisJobsByTask(input.id),
];
if (cancelled.length > 0) { if (cancelled.length > 0) {
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个算法 job:`, cancelled); console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个分析 job:`, cancelled);
} }
await db.deleteInspectionTask(input.id);
const taskDir = path.join(TASKS_DIR, String(input.id));
await fs.promises.rm(taskDir, { recursive: true, force: true });
return { success: true }; return { success: true };
}), }),
}), }),
@@ -238,6 +602,27 @@ export const appRouter = router({
await db.deleteTaskPointcloudFile(input.id); await db.deleteTaskPointcloudFile(input.id);
return { success: true }; return { success: true };
}), }),
linkFromModel: protectedProcedure
.input(z.object({
taskId: z.number(),
modelPointcloudFileIds: z.array(z.number()).min(1),
}))
.mutation(async ({ input }) => {
try {
const files = await db.linkTaskPointcloudFilesFromModel(input.taskId, input.modelPointcloudFileIds);
return { success: true, files };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (message.includes("another model")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的点云库文件" });
}
if (message.includes("not found")) {
throw new TRPCError({ code: "NOT_FOUND", message: "任务或点云库文件不存在" });
}
throw e;
}
}),
}), }),
// ─── 任务分析结果 ──────────────────────────────────────────────────────────── // ─── 任务分析结果 ────────────────────────────────────────────────────────────
@@ -400,11 +785,25 @@ export const appRouter = router({
return { success: true, id: record.id, errorStats, measurements }; return { success: true, id: record.id, errorStats, measurements };
}), }),
cancel: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
return cancelTaskAnalysisResultById(input.id, "用户停止分析任务");
}),
cancelByTask: protectedProcedure
.input(z.object({ taskId: z.number() }))
.mutation(async ({ input }) => {
markDemoFlowCancelled(input.taskId);
return cancelTaskAnalysisResultsByTask(input.taskId, "用户停止检测任务下的运行任务");
}),
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
// 先取消正在运行或排队的 job,再删数据库记录 // 先取消正在运行或排队的 job,再删数据库记录
cancelAlgorithmJob(input.id); cancelAlgorithmJob(input.id);
cancelLegacyAnalysisJob(input.id);
await db.deleteTaskAnalysisResult(input.id); await db.deleteTaskAnalysisResult(input.id);
return { success: true }; return { success: true };
}), }),
@@ -448,6 +847,8 @@ export const appRouter = router({
pointCloudFileIds: z.array(z.number()).optional(), pointCloudFileIds: z.array(z.number()).optional(),
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
cadFileIds: z.array(z.number()).optional(), cadFileIds: z.array(z.number()).optional(),
/** 配准:允许同一原始点云再次生成配准产物 */
allowDuplicateRegistration: z.boolean().optional(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -458,6 +859,9 @@ export const appRouter = router({
message: `未注册的算法插件:${input.pluginId}`, message: `未注册的算法插件:${input.pluginId}`,
}); });
} }
if (manifest.category === "配准") {
await validateRegistrationRunInput(input);
}
try { try {
const record = await db.createTaskAnalysisResult({ const record = await db.createTaskAnalysisResult({
@@ -471,7 +875,7 @@ export const appRouter = router({
record.id, record.id,
input.taskId, input.taskId,
manifest.id, manifest.id,
input.params ?? {}, mergePluginParams(manifest, input.params),
input.pointCloudFileIds, input.pointCloudFileIds,
input.cadFileIds, input.cadFileIds,
); );
@@ -510,6 +914,8 @@ export const appRouter = router({
pointCloudFileIds: z.array(z.number()).optional(), pointCloudFileIds: z.array(z.number()).optional(),
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
cadFileIds: z.array(z.number()).optional(), cadFileIds: z.array(z.number()).optional(),
/** 配准:允许同一原始点云再次生成配准产物 */
allowDuplicateRegistration: z.boolean().optional(),
jobs: z jobs: z
.array( .array(
z.object({ z.object({
@@ -523,6 +929,10 @@ export const appRouter = router({
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const created: { id: number; pluginId: string }[] = []; const created: { id: number; pluginId: string }[] = [];
const hasRegistrationJob = input.jobs.some((job) => getPlugin(job.pluginId)?.category === "配准");
if (hasRegistrationJob) {
await validateRegistrationRunInput(input);
}
for (const job of input.jobs) { for (const job of input.jobs) {
const manifest = getPlugin(job.pluginId); const manifest = getPlugin(job.pluginId);
if (!manifest) continue; if (!manifest) continue;
@@ -537,7 +947,7 @@ export const appRouter = router({
record.id, record.id,
input.taskId, input.taskId,
manifest.id, manifest.id,
job.params ?? {}, mergePluginParams(manifest, job.params),
input.pointCloudFileIds, input.pointCloudFileIds,
input.cadFileIds, input.cadFileIds,
); );
@@ -545,6 +955,109 @@ export const appRouter = router({
} }
return { success: true as const, jobs: created, scheduler: algorithmJobStats() }; return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
}), }),
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
runDemoAll: protectedProcedure
.input(
z.object({
taskId: z.number(),
sourcePointCloudFileId: z.number(),
cadFileId: z.number(),
measurementPluginIds: z.array(z.string().min(1)).optional(),
}),
)
.mutation(async ({ input }) => {
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const pluginId of measurementPluginIds) {
const manifest = getPlugin(pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${pluginId}`,
});
}
if (manifest.category === "配准") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "一键全流程演示的测量算法列表不能包含配准算法。",
});
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
}
if (sourcePointcloud.source === "registered") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "请选择原始点云,系统会自动找到对应配准产物。",
});
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise
.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementPluginIds,
demoFlowRunId,
});
})
.catch((err) => {
console.error("[runDemoAll] 配准链式提交失败:", err);
});
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: measurementPluginIds.length,
scheduler: algorithmJobStats(),
};
}),
cancelDemoAll: protectedProcedure
.input(z.object({ taskId: z.number() }))
.mutation(async ({ input }) => {
markDemoFlowCancelled(input.taskId);
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
return {
...result,
scheduler: algorithmJobStats(),
};
}),
}), }),
}); });

View File

@@ -9,6 +9,7 @@ import {
} from './cadHeatmapCompute.js'; } from './cadHeatmapCompute.js';
import { pickCadFileForHeatmap } from './cadFilePick.js'; import { pickCadFileForHeatmap } from './cadFilePick.js';
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js'; import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data'; const DATA_DIR = process.env.DATA_DIR || '/data';
const TASKS_DIR = path.join(DATA_DIR, 'tasks'); const TASKS_DIR = path.join(DATA_DIR, 'tasks');
@@ -49,6 +50,7 @@ export function enqueueSmoothHeatmapJob(resultId: number, taskId: number, totalP
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
execArgv: isProd ? [] : ['--import', 'tsx'], execArgv: isProd ? [] : ['--import', 'tsx'],
}); });
registerLegacyAnalysisChild(resultId, taskId, 'smooth_heatmap', child);
child.on('error', (err) => { child.on('error', (err) => {
console.error('[SmoothHeatmapJob] fork 失败:', err); console.error('[SmoothHeatmapJob] fork 失败:', err);
}); });

View File

@@ -7,6 +7,9 @@ export interface HeatmapPoint {
y: number; y: number;
z: number; z: number;
error?: number; error?: number;
r?: number;
g?: number;
b?: number;
} }
/** 平滑热力专用:用采样分位数做色标,减弱离群点 */ /** 平滑热力专用:用采样分位数做色标,减弱离群点 */

View File

@@ -6,7 +6,6 @@ import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin, type ViteDevServer } from "vite"; import { defineConfig, type Plugin, type ViteDevServer } from "vite";
import { vitePluginManusRuntime } from "vite-plugin-manus-runtime";
// ============================================================================= // =============================================================================
// Manus Debug Collector - Vite Plugin // Manus Debug Collector - Vite Plugin
@@ -202,7 +201,6 @@ const plugins = [
react(), react(),
tailwindcss(), tailwindcss(),
// jsxLocPlugin(), // 禁用:与 R3F 冲突,会导致 "Cannot set data-loc" 错误 // jsxLocPlugin(), // 禁用:与 R3F 冲突,会导致 "Cannot set data-loc" 错误
vitePluginManusRuntime(),
vitePluginManusDebugCollector(), vitePluginManusDebugCollector(),
vitePluginNoFullReload(), // 禁用自动刷新 vitePluginNoFullReload(), // 禁用自动刷新
]; ];
@@ -235,6 +233,7 @@ export default defineConfig({
".manusvm.computer", ".manusvm.computer",
"localhost", "localhost",
"127.0.0.1", "127.0.0.1",
"npsx.i-llusion.cn",
], ],
fs: { fs: {
strict: true, strict: true,