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/datasets/
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"
# 点云文件路径(支持相对路径和绝对路径)
# 相对路径:相对于本配置文件所在目录
# 绝对路径:完整路径
# pointcloud_file: "../train_2_100M.py"
pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T_配准.ply"
# ROI提取配置 - 使用优化Grid索引
roi_extraction:
method: "grid" # 使用Grid网格索引
use_optimized: true # 使用优化版(numpy向量化,快75倍)
grid_size: 100.0 # 网格大小100mm
# 性能预期:
# - 索引构建: 223秒 (一次性)
# - ROI提取: 0.19秒/任务 (vs Linear的14秒/任务)
# - 20任务总时间: ~12分钟 (vs Linear的13分钟)
# - 提升: 约7% (1分钟)
# 测量任务列表
tasks:
- name: "任务21_侧门高度"
type: "plane_to_plane"
type: "line_to_plane" # TODO: 改为 line_to_line
enabled: true
description: "测量侧门顶部到底部的距离"
theoretical_dimension: 2176.7 # 理论尺寸
description: "测量侧门顶部边缘线到底部边缘线的距离"
#2176.7 - 2176.4
theoretical_dimension: 2176.4
tolerance:
upper: 4
lower: 1 # 公差 ±3mm
visualization_dir: "visualizations_0617"
# 源平面(采样点的来源)
source_roi:
bbox_min: [-2500, 3358, -1545]
bbox_max: [-1500, 3362, -1525]
lower: 1
# 目标平面(测量距离的目标)
# 源边缘线ROI(侧门顶部边缘)
source_roi:
line_start: [-2460, 3356.43, -1511.26]
line_end: [-1821.05, 3356.43, -1511.26]
# 目标边缘线ROI(侧门底部边缘)
target_roi:
bbox_min: [-2500, 1177, -1621]
bbox_max: [-1500, 1183, -1500]
# 算法参数
params:
# 从源平面采样的点数
sample_points: 100
# RANSAC平面拟合参数
ransac_threshold: 0.1 # 内点距离阈值(mm)
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6 # 最小内点比例
min_inlier_ratio: 0.6
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
- name: "任务2_车体长度"
type: "plane_to_plane"
@@ -42,14 +51,14 @@ tasks:
description: "测量车体前后端面的距离"
theoretical_dimension: 26507 # 理论尺寸
tolerance: 10
visualization_dir: "visualizations_0617"
# visualization_dir: "visualizations_0617"
source_roi:
bbox_min: [-14009, 1500, -800]
bbox_max: [-14005, 1900, 800]
bbox_min: [-14009, 1500, -900]
bbox_max: [-14005, 1700, 900]
target_roi:
bbox_min: [12497, 1216, -1300]
bbox_max: [12503, 3200, 1300]
bbox_min: [12497, 1070, -1300]
bbox_max: [12503, 1160, 1300]
params:
sample_points: 100
@@ -58,17 +67,20 @@ tasks:
min_inlier_ratio: 0.6
- name: "任务43_窗口下沿高度"
type: "plane_to_plane"
type: "line_to_plane" # TODO: 改为 line_to_plane
enabled: true
description: "测量窗口下沿到地面的距离"
theoretical_dimension: 803 # 理论尺寸
description: "测量窗口下沿边缘线到地面的距离"
theoretical_dimension: 803
tolerance:
upper: 6.5
lower: -4 # 公差 ±3mm
source_roi:
bbox_min: [5562, 1981, 1672]
bbox_max: [6772, 1983, 1677]
lower: -4
# 源边缘线ROI(窗口下沿边缘)
source_roi:
line_start: [933.37,1983,-1676.62]
line_end: [2114.6,1983,-1676.62]
# 目标平面ROI(地面)
target_roi:
bbox_min: [-10222, 1179, -302]
bbox_max: [12428, 1181, 302]
@@ -78,6 +90,7 @@ tasks:
ransac_threshold: 0.5
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
- name: "任务44_车顶高度"
type: "plane_to_plane"
@@ -88,12 +101,12 @@ tasks:
upper: 7
lower: -1 # 公差 ±3mm
source_roi:
bbox_min: [-1419, 3999, -237]
bbox_max: [9030, 4001, 237]
bbox_min: [2000, 3979, -14]
bbox_max: [5000, 3981, 14]
target_roi:
bbox_min: [-10222, 1179, -302]
bbox_max: [12428, 1181, 302]
bbox_min: [100, 1177, -1510]
bbox_max: [11000, 1183, -1420]
params:
sample_points: 100
@@ -101,33 +114,33 @@ tasks:
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
- name: "任务3_边梁立面6点到z=0平面距离"
type: "point_to_plane"
enabled: true
description: "测量边梁立面6个精确点到z=0平面的距离"
mode: "exact_points"
theoretical_dimension: 1445 # 理论尺寸
tolerance:
upper: 0
lower: -2 # 公差 ±3mm
# 目标点坐标(左右各3个点)
target_points:
- [-753.5, 1052.5, -1445]
- [-753.5, 1052.5, 1445]
- [3446.5, 1053, -1445]
- [3446.5, 1053, 1445]
- [8146.5, 1053, -1445]
- [8146.5, 1053, 1445]
# - name: "任务3_边梁立面6点到z=0平面距离"
# type: "point_to_plane"
# enabled: false
# description: "测量边梁立面6个精确点到z=0平面的距离"
# mode: "exact_points"
# theoretical_dimension: 1445 # 理论尺寸
# tolerance:
# upper: 0
# lower: -2 # 公差 ±3mm
# # 目标点坐标(左右各3个点)
# target_points:
# - [-753.5, 1052.5, -1445]
# - [-753.5, 1052.5, 1445]
# - [3446.5, 1053, -1445]
# - [3446.5, 1053, 1445]
# - [8146.5, 1053, -1445]
# - [8146.5, 1053, 1445]
# 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
plane:
a: 0
b: 0
c: 1
d: 0
# # 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
# plane:
# a: 0
# b: 0
# c: 1
# d: 0
# 最近点搜索半径(mm)
search_radius: 10.0
# # 最近点搜索半径(mm)
# search_radius: 10.0
- name: "任务15_侧墙z最大值点到z=0平面距离"
type: "point_to_plane"
@@ -160,28 +173,28 @@ tasks:
c: 1
d: 0
- name: "任务4_纵向梁距离"
type: "point_to_point"
enabled: true
description: "测量关于z=0对称的点对之间的距离(纵向梁)"
theoretical_dimension: 2890 # 理论尺寸
tolerance:
upper: 0
lower: -4 # 公差 ±3mm
# 点对配置(使用任务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]
# - name: "任务4_纵向梁距离"
# type: "point_to_point"
# enabled: true
# description: "测量关于z=0对称的点对之间的距离(纵向梁)"
# theoretical_dimension: 2890 # 理论尺寸
# tolerance:
# upper: 0
# lower: -4 # 公差 ±3mm
# # 点对配置(使用任务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)
# search_radius: 10.0
- name: "任务1_转向架距离"
type: "point_to_point + hole_center"
@@ -203,39 +216,39 @@ tasks:
min: [8850,1020,1450]
max: [8950,1040,1552]
- name: "任务6_转向架未加工支撑面到参考线距离"
type: "plane_to_plane + 4_hole_center"
enabled: true
description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
theoretical_dimension: 65
tolerance:
upper: 8.5
lower: -3
visualization_dir: "visualizations_0617"
boxes:
- name: "左上孔"
min: [11631,1010,155]
max: [11674,1055,210]
- name: "右上孔"
min: [11631,1010,-210]
max: [11674,1055,-155]
- name: "左下孔"
min: [11631,812,155]
max: [11674,857,205]
- name: "右下孔"
min: [11631,812,-205]
max: [11674,857,-155]
- name: "左加工面"
min: [-9189,998,1405]
max: [-8993,1002,1501]
- name: "右加工面"
min: [-9191,998,-1500]
max: [-8990,1002,-1410]
params:
sample_points: 100
ransac_threshold: 0.1
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
# - name: "任务6_转向架未加工支撑面到参考线距离"
# type: "plane_to_plane + 4_hole_center"
# enabled: false
# description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
# theoretical_dimension: 65
# tolerance:
# upper: 8.5
# lower: -3
# visualization_dir: "visualizations_0617"
# boxes:
# - name: "左上孔"
# min: [11631,1010,155]
# max: [11674,1055,210]
# - name: "右上孔"
# min: [11631,1010,-210]
# max: [11674,1055,-155]
# - name: "左下孔"
# min: [11631,812,155]
# max: [11674,857,205]
# - name: "右下孔"
# min: [11631,812,-205]
# max: [11674,857,-155]
# - name: "左加工面"
# min: [-9189,998,1405]
# max: [-8993,1002,1501]
# - name: "右加工面"
# min: [-9191,998,-1500]
# max: [-8990,1002,-1410]
# params:
# sample_points: 100
# ransac_threshold: 0.1
# ransac_max_iterations: 1000
# min_inlier_ratio: 0.6
- name: "任务7_边梁下平面到参考线的距离"
type: "plane_to_plane + 4_hole_center"
@@ -245,7 +258,7 @@ tasks:
tolerance:
upper: 8.5
lower: -3
visualization_dir: "visualizations_0617"
# visualization_dir: "visualizations_0617"
boxes:
- name: "左上孔"
min: [11631,1015,159]
@@ -322,7 +335,7 @@ tasks:
- name: "任务29_端门口高度"
type: "plane_to_plane"
description: "后部门口高度"
theoretical_dimension: 2055 # 理论尺寸
theoretical_dimension: 2050 # 理论尺寸
tolerance:
upper: 5
lower: 0
@@ -337,79 +350,89 @@ tasks:
bbox_max: [12490, 1181, 440]
params:
sample_points: 100
ransac_threshold: 0.1
ransac_threshold: 0.05
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
- name: "任务32_司机室登车门门口宽度"
type: "plane_to_plane"
description: "司机室门口宽度"
theoretical_dimension: 566 # 理论尺寸
type: "line_to_line" # TODO: 改为 line_to_line
enabled: true
description: "司机室门口宽度(左右两条边缘线之间的距离)"
# 566 - 566.6
theoretical_dimension: 566.6
tolerance:
upper: 3
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:
bbox_min: [-8197, 2470, 1480]
bbox_max: [-8178, 2820, 1550]
line_start: [-8744.48,1727.71,-1537.42]
line_end: [-8744.48,2421.28,-1501.08]
params:
sample_points: 100
ransac_threshold: 0.1
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
- name: "任务33_司机室登车门门口高度"
type: "plane_to_plane"
description: "司机室门口高度"
theoretical_dimension: 1687 # 理论尺寸
type: "line_to_line" # TODO: 改为 line_to_line
enabled: true
description: "司机室门口高度(上下两条边缘线之间的距离)"
# 1687 - 1687.1
theoretical_dimension: 1687.1
tolerance:
upper: 3
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:
bbox_min: [-8600, 1308, 1570]
bbox_max: [-8330, 1310, 1610]
line_start: [-8625.48,1309.87,-1555.36]
line_end: [-8301.7,1309.87,-1555.36]
params:
sample_points: 100
ransac_threshold: 0.1
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
- name: "任务14_侧墙相对纵梁的角度位置"
type: "point_to_plane"
description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
mode: "bbox_filtered"
theoretical_dimension: 179.3 # 理论尺寸
tolerance:
upper: 2
lower: -4 # 公差 ±3mm
# 包围盒列表
bboxes:
- name: "左窗口上沿"
min: [1136, 2700, 1620]
max: [1713, 2745, 1630]
# 过滤条件
filter:
type: "max_abs_coordinate"
axis: "z"
top_n: 10
# - name: "任务14_侧墙相对纵梁的角度位置"
# type: "point_to_plane"
# description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
# mode: "bbox_filtered"
# theoretical_dimension: 179.3 # 理论尺寸
# tolerance:
# upper: 2
# lower: -4 # 公差 ±3mm
# # 包围盒列表
# bboxes:
# - name: "左窗口上沿"
# min: [1136, 2700, 1620]
# max: [1713, 2745, 1630]
# # 过滤条件
# filter:
# type: "max_abs_coordinate"
# axis: "z"
# top_n: 10
# 平面参数 (z=0)
plane:
a: 0
b: 0
c: 1
d: -1445
# # 平面参数 (z=0)
# plane:
# a: 0
# b: 0
# c: 1
# d: -1445
- name: "任务30_参考线到司机室辅助孔距离"
type: "point_to_plane + 4_hole_center + 1_hole_center"
@@ -446,11 +469,11 @@ tasks:
# 包围盒列表
bboxes:
- name: "左侧墙"
min: [-1468, 1899, -1680]
max: [12300, 1901, -1677]
min: [-1468, 1890, -1680]
max: [12300, 1910, -1677]
- name: "右侧墙"
min: [-1468, 1899, 1677]
max: [12300, 1901, 1680]
min: [-1468, 1890, 1677]
max: [12300, 1900, 1680]
# 过滤条件
filter:
@@ -486,20 +509,20 @@ tasks:
enabled: true
description: "对角线距离作差"
theoretical_dimension: 0 # 理论差值为0
tolerance: 2
visualization_dir: "visualizations"
tolerance: 0.1
# visualization_dir: "visualizations"
mode: "exact_points"
# 定义两条对角线的点对
point_pairs:
- name: "对角线1"
point1: [3000, 1180, 1541] # 左下角
point2: [3000, 2962, -1543] # 右上角
point1: [3500, 1180, 1541.42] # 左下角
point2: [3500, 2961.83, -1543.02] # 右上角
- name: "对角线2"
point1: [3000, 1180, -1541] # 右下角
point2: [3000, 2962, 1543] # 左上角
point1: [3500, 1180, -1541.42] # 右下角
point2: [3500, 2961.85, 1543.02] # 左上角
# 查找最近点的搜索半径(mm)
search_radius: 10.0
@@ -507,7 +530,7 @@ tasks:
# 差值比较配置
compare_mode: "difference" # 启用差值比较模式
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(
'-c', '--config',
type=str,
default='config/measurements.yaml',
default='config/measurements_0617_test.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_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_max = max_distance - theoretical_dim
# 解析公差范围
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:
if error_min >= -0.11 and error_max <= 0.1:
conclusion = "通过"
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
conclusion_cell.font = Font(color="006100", bold=True)

View File

@@ -1,22 +1,20 @@
"""
圆孔中心拟合工具
用于从点云中拟合圆柱孔的中心位置
圆孔中心拟合工具 - 使用V2高精度算法
适配器层:保持原有API,底层使用hole_detector
"""
import numpy as np
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent / "hole_detect"))
from hole_detector import HoleDetector
# 导入V2版本的高精度检测器
from hole_detector import HoleDetectorV2
class HoleCenterFitter:
"""圆孔中心拟合器"""
"""圆孔中心拟合器(适配器)"""
@staticmethod
def fit_hole_center(points: np.ndarray, axis_idx=None) -> dict:
"""
拟合圆柱孔的中心位置
拟合圆柱孔的中心位置(使用V2算法)
Args:
points: 点云数组 (N, 3)
@@ -30,23 +28,46 @@ class HoleCenterFitter:
'axis_idx': int, # 轴向索引
'axis_name': str, # 轴名称 (X/Y/Z)
'other_axes_indices': [int, int], # 非轴向的两个轴索引
'stats': dict # 统计信息
'stats': dict # 统计信息(兼容性字段)
}
"""
detector = HoleDetector(axis_idx=axis_idx)
result = detector.detect_hole(points)
# 转换axis_idx到轴名称
axis_name = None
if axis_idx is not None:
axis_names = ['X', 'Y', 'Z']
axis_name = axis_names[axis_idx]
# 获取3D圆心坐标
center_3d = detector.get_3d_center(points)
# 使用V2检测器
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 {
'center_2d': result['center_2d'],
'center_2d': center_2d,
'center_3d': center_3d,
'radius': result['radius'],
'axis_idx': result['axis_idx'],
'axis_name': result['axis_name'],
'other_axes_indices': result['other_axes'],
'stats': result['stats']
'radius': result_v2['radius'],
'axis_idx': axis_idx_v2,
'axis_name': axis_name_v2,
'other_axes_indices': other_axes,
'stats': {
'inside_count': 0,
'ring_count': 0,
'outside_count': 0,
'inside_ratio': 0.0,
'ring_ratio': 0.0,
'total_points': len(points)
}
}
@staticmethod
@@ -95,45 +116,24 @@ class HoleCenterFitter:
return {
'plane_y': float(plane_y),
'centers_3d': [c.tolist() for c in centers_3d],
'centers_2d': [c.tolist() for c in centers_2d],
'centers_3d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_3d],
'centers_2d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_2d],
'center_point': center_point.tolist(),
'radii': [float(r) for r in radii],
'radii': radii
}
@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轴的平面的距离
Args:
point: 点坐标 [x, y, z]
point_3d: 3D点坐标 [x, y, z]
plane_y: 平面的Y坐标
Returns:
float: 距离(带符号)
"""
return point[1] - plane_y
@staticmethod
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'
}
if isinstance(point_3d, np.ndarray):
point_3d = point_3d.tolist()
return abs(point_3d[1] - plane_y)

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):
"""测量任务基类"""
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: 任务名称
point_cloud: 完整点云数组 (N, 3)
config: 任务配置字典
spatial_index: 空间索引对象(可选)
"""
self.name = name
self.point_cloud = point_cloud
self.config = config
self.task_type = config.get('type', 'unknown')
self.spatial_index = spatial_index # 保存空间索引
@abstractmethod
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
start_time = time.time()
source_roi = self.config.get('source_roi', {})
source_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
source_roi.get('bbox_min'),
source_roi.get('bbox_max')
)
source_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, source_roi.get('bbox_min'), source_roi.get('bbox_max'), self.spatial_index)
timing_breakdown['提取源ROI'] = time.time() - start_time
# 记录ROI提取信息到详细日志
@@ -52,11 +48,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
# 2. 提取目标平面ROI
start_time = time.time()
target_roi = self.config.get('target_roi', {})
target_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
target_roi.get('bbox_min'),
target_roi.get('bbox_max')
)
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
# 记录ROI提取信息到详细日志
@@ -153,7 +145,8 @@ class PlaneToPlaneDistance(BaseMeasurement):
# 5. 从源平面采样点
start_time = time.time()
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)
timing_breakdown['采样点选择'] = time.time() - start_time

View File

@@ -46,11 +46,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
hole_results = []
for i in range(4):
box = boxes[i]
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
box['min'],
box['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
if len(points) == 0:
return MeasurementResult(
task_name=self.name,
@@ -96,11 +92,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
print(f" 提取并拟合源平面...")
for i in range(4, len(boxes)):
box = boxes[i]
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
box['min'],
box['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
if len(points) == 0:
return MeasurementResult(

View File

@@ -24,8 +24,8 @@ from visualizer import Visualizer
class PointToPlaneDistance(BaseMeasurement):
"""点到平面距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
super().__init__(name, point_cloud, config)
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config, spatial_index)
self.mode = config.get('mode', 'exact_points')
if self.mode == 'exact_points':
@@ -68,42 +68,50 @@ class PointToPlaneDistance(BaseMeasurement):
)
def _measure_exact_points(self) -> MeasurementResult:
"""精确点模式:查找最近点并计算距离"""
"""精确点模式:查找最近点并计算距离(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
timing_breakdown = {}
# 优化:先粗筛选点云,只在目标点附近构建KDTree
# 优化:对每个目标点单独用Grid索引提取小区域
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 = []
distances_to_plane = []
start_time = time.time()
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:
print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm")
# 用Grid索引快速提取局部点云
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)
# 计算点到平面的距离
@@ -146,6 +154,9 @@ class PointToPlaneDistance(BaseMeasurement):
def _measure_bbox_filtered(self) -> MeasurementResult:
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
# 导入ROIExtractor用于Grid索引加速
from roi_extractor import ROIExtractor
timing_breakdown = {}
all_filtered_points = []
all_distances = []
@@ -155,9 +166,10 @@ class PointToPlaneDistance(BaseMeasurement):
bbox_min = np.array(bbox_config['min'])
bbox_max = np.array(bbox_config['max'])
# 提取包围盒内的点
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
bbox_points = self.point_cloud[mask]
# 使用Grid索引快速提取包围盒内的点(优化后)
bbox_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(bbox_points) == 0:
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")

View File

@@ -40,11 +40,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
hole_points_list = []
for i in range(4):
box = boxes[i]
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
box['min'],
box['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
if len(points) == 0:
return MeasurementResult(
task_name=self.name,
@@ -64,11 +60,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
start_time = time.time()
auxiliary_box = boxes[4]
print(f" 提取辅助孔 '{auxiliary_box['name']}' ROI区域...")
auxiliary_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
auxiliary_box['min'],
auxiliary_box['max']
)
auxiliary_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, auxiliary_box['min'], auxiliary_box['max'], self.spatial_index)
if len(auxiliary_points) == 0:
return MeasurementResult(

View File

@@ -21,8 +21,8 @@ from visualizer import Visualizer
class PointToPointDistance(BaseMeasurement):
"""点到点距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
super().__init__(name, point_cloud, config)
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config, spatial_index)
# 检查测量模式
self.mode = config.get('mode', 'exact_points')
@@ -55,11 +55,7 @@ class PointToPointDistance(BaseMeasurement):
bbox_points_list = []
for bbox in self.bboxes:
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
bbox['min'],
bbox['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, bbox['min'], bbox['max'], self.spatial_index)
if len(points) == 0:
return MeasurementResult(
task_name=self.name,
@@ -170,59 +166,80 @@ class PointToPointDistance(BaseMeasurement):
)
def _execute_exact_points(self) -> MeasurementResult:
"""执行精确点模式的测量"""
"""执行精确点模式的测量(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
try:
timing_breakdown = {}
# 优化:先粗筛选点云,只在目标点附近构建KDTree
# 优化:对每个目标点用Grid索引提取小区域
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 = []
all_found_points = []
start_time = time.time()
for i, pair in enumerate(self.point_pairs):
point1_target = np.array(pair['point1'])
point2_target = np.array(pair['point2'])
pair_name = pair.get('name', f'点对{i+1}')
# 查找point1的最近点
dist1, local_idx1 = kdtree.query(point1_target, k=1)
# 为point1找最近点
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:
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:
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)

View File

@@ -47,11 +47,7 @@ class PointToPointWithHoles(BaseMeasurement):
hole_results = [] # 保存完整的拟合结果
for i, box in enumerate(boxes):
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
box['min'],
box['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
if len(points) == 0:
return MeasurementResult(
task_name=self.name,
@@ -131,11 +127,7 @@ class PointToPointWithHoles(BaseMeasurement):
# 保存每个孔的点云
for i, box in enumerate(boxes):
points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
box['min'],
box['max']
)
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
ply_path = vis_dir / f"{self.name}_{box['name']}.ply"
Visualizer.save_roi_pointcloud(points, str(ply_path))

View File

@@ -25,6 +25,12 @@ class PerformanceLogger:
# 任务性能记录
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]):
"""
记录单个任务的性能数据
@@ -41,6 +47,36 @@ class PerformanceLogger:
}
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):
"""保存性能日志到文件"""
with open(self.log_file, 'w', encoding='utf-8') as f:
@@ -48,6 +84,51 @@ class PerformanceLogger:
f.write("点云测量系统 - 性能分析报告\n")
f.write("=" * 80 + "\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("=" * 80 + "\n\n")
@@ -106,6 +187,19 @@ class PerformanceLogger:
count = step_counts[step_name]
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(f"日志已保存到: {self.log_file}\n")
f.write("=" * 80 + "\n")

View File

@@ -66,38 +66,46 @@ class PointCloudLoader:
@staticmethod
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
"""读取二进制PLY数据"""
# 构建struct格式字符串
"""读取二进制PLY数据(优化版:批量读取)"""
# 构建numpy dtype
type_map = {
'float': 'f',
'double': 'd',
'uchar': 'B',
'uint': 'I',
'int': 'i'
'float': np.float32,
'double': np.float64,
'uchar': np.uint8,
'uint': np.uint32,
'int': np.int32
}
fmt = '<' # 小端序
# 构建结构化数组的dtype
dtype_list = []
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)
points = []
dtype = np.dtype(dtype_list)
for _ in range(vertex_count):
data = struct.unpack(fmt, f.read(struct_size))
# 只取前3个值作为xyz坐标
points.append(data[:3])
# 一次性读取所有数据
raw_data = np.fromfile(f, dtype=dtype, count=vertex_count)
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
def _read_ascii_ply(f, vertex_count: int) -> np.ndarray:
"""读取ASCII PLY数据"""
points = []
for _ in range(vertex_count):
line = f.readline().decode('ascii').strip()
values = line.split()
# 只取前3个值作为xyz坐标
points.append([float(values[0]), float(values[1]), float(values[2])])
"""读取ASCII PLY数据(优化版:批量读取)"""
# 读取剩余所有行
remaining = f.read().decode('ascii')
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(
points: np.ndarray,
bbox_min: list,
bbox_max: list
bbox_max: list,
spatial_index=None
) -> Tuple[np.ndarray, np.ndarray]:
"""
提取包围盒内的点
@@ -19,6 +20,7 @@ class ROIExtractor:
points: 点云数组 (N, 3)
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
spatial_index: 空间索引对象(可选)
Returns:
roi_points: 提取的点 (M, 3)
@@ -27,11 +29,15 @@ class ROIExtractor:
bbox_min = np.array(bbox_min)
bbox_max = np.array(bbox_max)
# 找到在包围盒内的点
if spatial_index is not None:
# 使用空间索引快速查询
indices = spatial_index.query_bbox(bbox_min, bbox_max)
else:
# 回退到线性扫描
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
indices = np.where(mask)[0]
roi_points = points[indices]
roi_points = points[indices]
return roi_points, indices
@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 numpy as np
import time
from pathlib import Path
from typing import List, Dict, Any
from datetime import datetime
@@ -13,9 +14,12 @@ from measurements.point_to_point import PointToPointDistance
from measurements.plane_with_holes import PlaneToPlaneWithHoles
from measurements.point_to_plane_with_holes import PointToPlaneWithHoles
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 performance_logger import PerformanceLogger
from measurement_logger import MeasurementLogger
from spatial_index import LinearScanIndex, GridIndex, GridIndexOptimized, OctreeIndex, KDTreeIndex
class TaskManager:
@@ -25,6 +29,7 @@ class TaskManager:
"""初始化任务管理器"""
self.config = None
self.point_cloud = None
self.spatial_index = None # 空间索引
self.tasks = []
self.results = []
self.pointcloud_path_override = None
@@ -53,11 +58,35 @@ class TaskManager:
# 处理相对路径
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)
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', [])
if not tasks_config:
print("警告: 配置文件中没有定义任务")
@@ -139,37 +168,57 @@ class TaskManager:
return PlaneToPlaneWithHoles(
name=task_name,
point_cloud=self.point_cloud,
config=task_config
config=task_config,
spatial_index=self.spatial_index
)
elif 'point_to_plane' in task_type:
return PointToPlaneWithHoles(
name=task_name,
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:
return PointToPointWithHoles(
name=task_name,
point_cloud=self.point_cloud,
config=task_config
config=task_config,
spatial_index=self.spatial_index
)
elif task_type == 'plane_to_plane':
return PlaneToPlaneDistance(
name=task_name,
point_cloud=self.point_cloud,
config=task_config
config=task_config,
spatial_index=self.spatial_index
)
elif task_type == 'point_to_plane':
return PointToPlaneDistance(
name=task_name,
point_cloud=self.point_cloud,
config=task_config
config=task_config,
spatial_index=self.spatial_index
)
elif task_type == 'point_to_point':
return PointToPointDistance(
name=task_name,
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:
print(f"警告: 未知的任务类型: {task_type}")
@@ -194,6 +243,22 @@ class TaskManager:
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))
@@ -402,3 +467,44 @@ class TaskManager:
except Exception as 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:
evaluation:
chunk_size: 2048
triangle_chunk_size: 4096
chunk_size: 8192
triangle_chunk_size: 512
cad_mesh_deflection: 0.1
curvature:

View File

@@ -74,15 +74,23 @@ class Evaluator:
) -> tuple[np.ndarray, DistanceStats]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dtype = torch.float64
# vertices/triangles 上 GPU(mesh 通常几百MB,可以接受)
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)
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 = []
with torch.no_grad():
for start in range(0, points_t.shape[0], point_chunk_size):
chunk = points_t[start:start + point_chunk_size]
for start in range(0, n_points, 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:
dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
min_dist2 = dist2.min(dim=1).values
@@ -90,7 +98,7 @@ class Evaluator:
min_dist2 = torch.full(
(chunk.shape[0],),
torch.inf,
dtype=points_t.dtype,
dtype=dtype,
device=device,
)
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):

View File

@@ -52,7 +52,20 @@ def build_cad_sample_cache_path(cad_path, output_dir, mesh_deflection, cad_sampl
])
digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16]
cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad"
return os.path.join(output_dir, "cad_cache", f"{cad_name}_{digest}.ply")
# 优先存在 CAD 文件同目录下的 .cad_sample_cache/,跨任务复用;
# 目录不可写时回退到 output_dir/cad_cache/
cad_dir = os.path.dirname(os.path.abspath(cad_path))
shared_cache_dir = os.path.join(cad_dir, ".cad_sample_cache")
try:
os.makedirs(shared_cache_dir, exist_ok=True)
# 写权限探测
test_path = os.path.join(shared_cache_dir, ".write_test")
open(test_path, "w").close()
os.remove(test_path)
cache_dir = shared_cache_dir
except OSError:
cache_dir = os.path.join(output_dir, "cad_cache")
return os.path.join(cache_dir, f"{cad_name}_{digest}.ply")
# ============================================================

View File

@@ -9,12 +9,19 @@
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
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 TrainMeasurementPanel from './TrainMeasurementPanel';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
'coupler-panel',
'flatness-suite',
'underframe-deflection',
];
interface PluginInputField {
name: string;
label: string;
@@ -42,12 +49,24 @@ interface PluginSummary {
runtimeKind: 'process' | 'mock';
}
interface PointcloudFileSummary {
id: number;
name: string;
source?: string;
sourceResultId?: number | null;
sourcePointcloudFileId?: number | null;
createdAt?: string | Date;
}
interface Props {
taskId: number | null;
modelId: number | null;
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
pointCloudLoaded: boolean;
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
taskPcFileCount?: number;
/** 当前任务下的点云文件元数据(用于配准输入校验与重复配准确认) */
taskPointcloudFiles?: PointcloudFileSummary[];
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
selectedPcFileIds?: number[];
/** 当前任务中选中的点云中是否包含配准后的点云 */
@@ -60,6 +79,13 @@ interface Props {
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> {
const out: Record<string, unknown> = {};
for (const field of plugin.inputs) {
@@ -68,7 +94,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
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 { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
staleTime: 60_000,
@@ -78,13 +104,53 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
toast.success(`已提交 ${jobs.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 });
}
},
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 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 [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -125,14 +191,120 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
}));
};
const canRun = useMemo(
// 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载
() => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending,
[taskId, taskPcFileCount, selected, runBatch.isPending],
);
// 区分配准插件 vs 测量插件
const regPlugins = plugins.filter((p) => p.category === '配准');
const measPlugins = plugins.filter((p) => p.category !== '配准');
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[]) => {
if (!canRun || taskId == null) return;
const selectedPcCount = selectedPcFileIds?.length ?? 0;
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) => ({
pluginId,
params: paramsById[pluginId] ?? {},
@@ -140,25 +312,90 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
runBatch.mutate({
taskId,
jobs,
...(selectedPcFileIds && selectedPcFileIds.length > 0
? { pointCloudFileIds: selectedPcFileIds }
: {}),
...(selectedCadFileIds && selectedCadFileIds.length > 0
? { cadFileIds: selectedCadFileIds }
: {}),
...(pointCloudFileIds ? { pointCloudFileIds } : {}),
...(cadFileIds ? { cadFileIds } : {}),
...(allowDuplicateRegistration ? { allowDuplicateRegistration } : {}),
});
};
// 区分配准插件 vs 测量插件
const regPlugins = plugins.filter((p) => p.category === '配准');
const measPlugins = plugins.filter((p) => p.category !== '配准');
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 handleRunDemoAll = () => {
if (demoBlockReason) {
toast.error(demoBlockReason);
return;
}
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 里
return (
<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:整车配准 ────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-1">
@@ -169,11 +406,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<button
type="button"
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}
onClick={() => handleRun(selectedRegIds)}
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
disabled={!canRunRegistration}
onClick={() => handleRun(selectedRegIds, 'registration')}
title={registrationBlockReason ?? '执行配准'}
>
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedRegIds.length})
@@ -182,11 +419,23 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
{regPlugins.length === 0 && !isLoading && (
<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) => (
<PluginCard
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
onToggleSelect={toggleSelect}
@@ -207,7 +456,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<Cpu className="w-3.5 h-3.5" />
第 2 步 · 测量分析(多选并发)
</span>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-wrap justify-end">
{scheduler && (
<span
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}
</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
type="button"
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}
onClick={() => handleRun(selectedMeasIds)}
title={
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
: '并发执行已勾选算法'
}
disabled={!canRunMeasurement}
onClick={() => handleRun(selectedMeasIds, 'measurement')}
title={measurementBlockReason ?? '并发执行已勾选算法'}
>
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedMeasIds.length})
@@ -237,7 +491,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
</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">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-amber-300">
@@ -256,7 +510,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<PluginCard
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
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 { 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 { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
function fmtDuration(ms: number): string {
@@ -53,7 +55,13 @@ interface Props {
/** 外部分析任务是否正在提交中 */
externalPending?: boolean;
/** 插件叠加层:用户点击「在 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 };
@@ -162,20 +170,182 @@ type AssemblyMeta = SmoothHeatmapMeta & {
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) {
const utils = trpc.useUtils();
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(
{ taskId },
{ 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({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const deleteResult = trpc.analysisResult.delete.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const cancelResult = trpc.analysisResult.cancel.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
function toggleExpand(id: number) {
setExpandedIds(prev => {
@@ -186,72 +356,66 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
}
const running = runAnalysis.isPending;
const analysisResults = results as AnalysisResultRow[];
const listItems = buildAnalysisListItems(analysisResults);
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-1 flex-wrap">
<span className="industrial-label flex items-center gap-1.5">
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
</span>
<div className="flex items-center gap-1">
<button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair className="w-3 h-3" />
偏差热力
</button>
)}
</div>
</div>
useEffect(() => {
const flowIdsToExpand: string[] = [];
const resultIdsToExpand: number[] = [];
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
)}
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);
}
}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
</div>
)}
if (flowIdsToExpand.length > 0) {
setExpandedFlowIds((prev) => {
const next = new Set(prev);
for (const id of flowIdsToExpand) next.add(id);
return next;
});
}
<div className="space-y-1.5">
{results.map(r => {
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">
<div key={r.id} className={`industrial-card rounded overflow-hidden min-w-0 ${extraClassName}`}>
{/* 标题行 */}
<div
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
className="px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
onClick={() => toggleExpand(r.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<div className="flex items-center gap-2 min-w-0">
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />}
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
{r.type === '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" />}
@@ -259,42 +423,19 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
{r.type === '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" />}
<span className="text-xs flex-1 truncate">
{r.type === 'error_analysis'
? '误差分析'
: r.type === 'measurement'
? '尺寸测量'
: 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">
<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"
@@ -304,6 +445,16 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<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="删除"
@@ -313,6 +464,23 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</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' && (
@@ -329,7 +497,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{expanded &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
r.status === 'processing' &&
(r.status === 'processing' || r.status === 'pending') &&
(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
const p = hm?.progress ?? 0;
@@ -501,13 +669,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</div>
)}
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error') && (
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<GenericResultView
taskId={taskId}
resultId={r.id}
pluginId={(r as { pluginId?: string }).pluginId ?? undefined}
pluginName={(r as { pluginId?: string }).pluginId ?? '算法插件'}
pluginId={r.pluginId ?? undefined}
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
onViewOverlay={onViewPluginOverlay}
/>
@@ -515,8 +683,156 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
)}
</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 (
<div className="space-y-3">
<div className="flex items-center justify-between gap-1 flex-wrap">
<span className="industrial-label flex items-center gap-1.5">
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
</span>
<div className="flex items-center gap-1">
<button
type="button"
className="industrial-btn py-0.5 px-1 text-[10px] flex items-center justify-center"
onClick={() => setMaximizedOpen(true)}
title="最大化查看分析结果"
>
<Maximize2 className="w-3 h-3" />
</button>
{/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
<>
<button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair className="w-3 h-3" />
偏差热力
</button>
)}
</>
)}
</div>
</div>
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
)}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
</div>
)}
{!isLoading && results.length > 0 && renderResultList('panel')}
<Dialog open={maximizedOpen} onOpenChange={setMaximizedOpen}>
<DialogContent className="w-[calc(100vw-2rem)] h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-2rem)] p-0 overflow-hidden gap-0">
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<DialogTitle className="text-sm flex items-center gap-2">
<BarChart2 className="w-4 h-4" />
分析结果
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-4">
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-8">加载中…</div>
)}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-8">暂无分析结果</div>
)}
{!isLoading && results.length > 0 && renderResultList('maximized')}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,15 +1,7 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
@@ -21,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
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 { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -106,7 +98,6 @@ function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
@@ -201,36 +192,6 @@ function DashboardLayoutContent({
</SidebarMenu>
</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>
<div
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;
unit?: string;
tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
toleranceLabel?: string;
/** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */
baseline?: number;
status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
failLabel?: string;
}
interface ErrorStats {
@@ -31,7 +38,11 @@ interface ErrorStats {
type Overlay3D =
| { kind: 'none' }
| { 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 {
pluginId?: string;
@@ -39,10 +50,12 @@ interface PluginResultLike {
message?: string;
measurements?: Measurement[];
errorStats?: ErrorStats | null;
overlay3d?: Overlay3D;
overlay3d?: RawOverlay3D;
inputPointCloudFileIds?: unknown;
inputCadFileIds?: unknown;
images?: string[];
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
files?: string[];
files?: (string | { name?: string; path?: string })[];
extra?: Record<string, unknown>;
error?: string;
}
@@ -54,7 +67,13 @@ interface Props {
pluginName?: string;
data: PluginResultLike;
/** 用户点击「在 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']) {
@@ -70,16 +89,16 @@ function statusClass(s?: Measurement['status']) {
}
}
function statusLabel(s?: Measurement['status']) {
switch (s) {
function statusLabel(m: Measurement) {
switch (m.status) {
case 'pass':
return '合格';
return m.passLabel ?? '合格';
case 'warning':
return '警告';
case 'fail':
return '超差';
return m.failLabel ?? '超差';
default:
return '—';
return '参考';
}
}
@@ -90,9 +109,52 @@ function formatValue(v: number): string {
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({
taskId,
resultId,
pluginId,
pluginName,
data,
onViewOverlay,
@@ -101,15 +163,19 @@ export default function GenericResultView({
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const images = data.images ?? [];
const files = data.files ?? [];
const overlay = data.overlay3d ?? { kind: 'none' };
const files = (data.files ?? []).map((f) =>
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 (
<div className="space-y-3">
{pluginName && (
{displayPluginName && (
<div className="text-[10px] text-muted-foreground">
{pluginName}
{data.pluginId ? ` · ${data.pluginId}` : ''}
{displayPluginName}
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
</div>
)}
@@ -152,14 +218,22 @@ export default function GenericResultView({
{m.name}
</td>
<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 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 className="text-right">
<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>
</td>
</tr>
@@ -173,7 +247,7 @@ export default function GenericResultView({
<button
type="button"
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" />
在 3D 中查看叠加层

View File

@@ -415,7 +415,12 @@ const PointCloudMesh = memo(function PointCloudMesh({
positions[i3 + 1] = point.y;
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);
colors[i3] = color.r;
colors[i3 + 1] = color.g;
@@ -1018,7 +1023,7 @@ const CADModel = memo(function CADModel({
}
}, [useVertexColors, geometry]);
if (!visible && !useVertexColors) return null;
if (!visible) return null;
if (!geometry && !loading) return null;
@@ -1173,6 +1178,7 @@ interface SceneContentProps {
pointCloudData?: PointCloudData | null;
pointSize: number;
showHeatmap: boolean;
showPointCloud: boolean;
pointCloudRenderMode: PointCloudRenderMode;
enablePointPicking: boolean;
showCADModel: boolean;
@@ -1202,6 +1208,7 @@ const SceneContent = memo(function SceneContent({
pointCloudData,
pointSize,
showHeatmap,
showPointCloud,
pointCloudRenderMode,
enablePointPicking,
showCADModel,
@@ -1248,9 +1255,15 @@ const SceneContent = memo(function SceneContent({
const cadShellHeatmapOn =
pointCloudRenderMode === 'smoothHeatmap' &&
Boolean(pointCloudData && cadSurfaceGeometry) &&
showCADModel &&
!cadHeatmapSuppressClient &&
!useDirectColoring;
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring;
const showCadReferenceMesh = showCADModel && !cadShellHeatmapOn;
const showSmoothPointFallback =
pointCloudData &&
showPointCloud &&
pointCloudRenderMode === 'smoothHeatmap' &&
(!cadSurfaceGeometry || cadHeatmapSuppressClient || !showCADModel);
return (
<>
@@ -1287,7 +1300,7 @@ const SceneContent = memo(function SceneContent({
{!lightweightScene && <AmbientParticles />}
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
{pointCloudData && pointCloudRenderMode === 'points' && (
{pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
<PointCloudMesh
data={pointCloudData}
pointSize={pointSize}
@@ -1300,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
{pointCloudData &&
pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry &&
showCADModel &&
!cadHeatmapSuppressClient &&
!useDirectColoring && (
<CadSurfaceHeatmapMesh
@@ -1309,34 +1323,23 @@ const SceneContent = memo(function SceneContent({
showWireframe={showWireframe}
/>
)}
{pointCloudData &&
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 && (
{showSmoothPointFallback && (
<>
<PointCloudMesh
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}
enablePointPicking={false}
errorRangeMode="percentile"
pointOpacity={showHeatmap ? 0.42 : 0.6}
pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
/>
{!cadSurfaceGeometry && (
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
/>
)}
</>
)}
@@ -1389,7 +1392,7 @@ const SceneContent = memo(function SceneContent({
/>
</bufferGeometry>
<pointsMaterial
size={0.04}
size={pointSize ?? 0.03}
vertexColors
sizeAttenuation
transparent
@@ -1403,6 +1406,7 @@ const SceneContent = memo(function SceneContent({
return prevProps.pointCloudData === nextProps.pointCloudData &&
prevProps.pointSize === nextProps.pointSize &&
prevProps.showHeatmap === nextProps.showHeatmap &&
prevProps.showPointCloud === nextProps.showPointCloud &&
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
prevProps.enablePointPicking === nextProps.enablePointPicking &&
prevProps.showCADModel === nextProps.showCADModel &&
@@ -1529,11 +1533,13 @@ async function fetchBatchSampled(
} 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 floats = new Float32Array(arrayBuffer);
const count = Math.floor(floats.length / 4);
console.log(`[PointCloudViewer] Binary received: ${count} pts, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
const count = Math.floor(floats.length / stride);
console.log(`[PointCloudViewer] Binary received: ${count} pts, hasColor=${hasColor}, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
if (count === 0) return { points: [] };
@@ -1543,9 +1549,15 @@ async function fetchBatchSampled(
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
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];
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 (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
@@ -1565,6 +1577,7 @@ interface PointCloudViewerProps {
*/
loadKey?: number;
showHeatmap?: boolean;
showPointCloud?: boolean;
showCADModel?: boolean;
showWireframe?: boolean;
pointSize?: number;
@@ -1601,7 +1614,7 @@ interface PointCloudViewerProps {
overlay:
| { kind: 'none' }
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
| { kind: 'ply'; file: string };
| { kind: 'ply'; file: string; files?: string[] };
} | null;
/**
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
@@ -1617,6 +1630,7 @@ export default function PointCloudViewer({
batchTaskId,
loadKey,
showHeatmap = true,
showPointCloud = true,
showCADModel = true,
showWireframe = false,
pointSize = 0.03,
@@ -1700,6 +1714,9 @@ export default function PointCloudViewer({
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
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++) {
const p = pts[i];
p.x = (p.x - centerX) * scale;
@@ -1753,30 +1770,96 @@ export default function PointCloudViewer({
// PLY 叠加层状态(kind=ply)
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(() => {
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'idle' });
return;
}
const t = pcTransformRef.current;
if (!t || !pointCloudData?.points?.length) {
setPlyOverlayData(null);
setPlyOverlayStatus({
status: 'error',
message: '请先加载本算法结果对应的点云文件,再查看 PLY 叠加层',
});
return;
}
const { taskId, resultId, overlay } = pluginOverlay;
const url = `/api/files/read/analysis/${taskId}/${resultId}/${overlay.file}`;
const ac = new AbortController();
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
(async () => {
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}`);
const text = await resp.text();
const parsed = parsePlyText(text);
if (parsed) setPlyOverlayData(parsed);
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const buf = await resp.arrayBuffer();
const floats = new Float32Array(buf);
const count = Math.floor(floats.length / stride);
if (count === 0) {
setPlyOverlayStatus({ status: 'error', message: 'PLY 叠加层没有可渲染点' });
return;
}
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
if (hasColor) {
// stride=7: [x,y,z,error,r,g,b] — 用批量提取代替逐点循环
for (let i = 0; i < count; i++) {
const s = i * 7;
positions[i * 3] = floats[s];
positions[i * 3 + 1] = floats[s + 1];
positions[i * 3 + 2] = floats[s + 2];
colors[i * 3] = floats[s + 4];
colors[i * 3 + 1] = floats[s + 5];
colors[i * 3 + 2] = floats[s + 6];
}
} else {
// stride=4: [x,y,z,error] — 只提取 xyz,颜色统一蓝色
for (let i = 0; i < count; i++) {
const s = i * 4;
positions[i * 3] = floats[s];
positions[i * 3 + 1] = floats[s + 1];
positions[i * 3 + 2] = floats[s + 2];
colors[i * 3] = 0.4; colors[i * 3 + 1] = 0.8; colors[i * 3 + 2] = 1.0;
}
}
// 用主点云的坐标变换对齐叠加层坐标
for (let i = 0; i < count; i++) {
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
}
setPlyOverlayData({ positions, colors });
setPlyOverlayStatus({ status: 'ready' });
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PlyOverlay] load failed:', e);
setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
}
})();
return () => ac.abort();
}, [pluginOverlay]);
}, [pluginOverlay, pointCloudData]);
useEffect(() => {
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
@@ -1881,16 +1964,21 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null;
const hasVisibleCadHeatmap =
pointCloudRenderMode === 'smoothHeatmap' &&
serverCadHeatmap.status === 'ready' &&
showCADModel;
const pts = pointCloudData?.points;
if (!pts?.length) return null;
if (!hasVisibleCadHeatmap && (!showPointCloud || !pts?.length)) return null;
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
const lo = serverCadHeatmap.dispMin;
const hi = serverCadHeatmap.dispMax;
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
return { min: lo, max: hi, midTick };
}
if (!pts?.length) return null;
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
}, [showHeatmap, showPointCloud, showCADModel, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
return (
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
@@ -1944,6 +2032,29 @@ export default function PointCloudViewer({
</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 && (
<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}
pointSize={pointSize}
showHeatmap={showHeatmap}
showPointCloud={showPointCloud}
pointCloudRenderMode={pointCloudRenderMode}
enablePointPicking={enableErrorPicking}
showCADModel={showCADModel}

View File

@@ -13,7 +13,10 @@ import {
Square,
FileBox,
Eye,
Library,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { toast } from 'sonner';
interface Props {
modelId: number;
@@ -37,6 +40,38 @@ function formatSize(bytes: number) {
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({
modelId,
activeTaskId,
@@ -54,7 +89,11 @@ export default function TaskManager({
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const modelPointcloudFileRef = useRef<HTMLInputElement>(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());
@@ -62,12 +101,28 @@ export default function TaskManager({
// ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
const cadFileRef = useRef<HTMLInputElement>(null);
const modelCadFileRef = useRef<HTMLInputElement>(null);
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
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({
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({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
@@ -81,6 +136,16 @@ export default function TaskManager({
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
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 文件
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
@@ -90,11 +155,32 @@ export default function TaskManager({
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
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(() => {
setSelectedFileIds(new Set());
setSelectedCadFileIds(new Set());
setSelectedModelPcFileIds(new Set());
setSelectedModelCadFileIds(new Set());
setPcLibraryOpen(false);
setCadLibraryOpen(false);
}, [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) {
pendingTaskId.current = taskId;
fileRef.current?.click();
}
function submitCreateTask() {
const name = taskName.trim();
if (!name || createTask.isPending) return;
createTask.mutate({ modelId, name });
}
async function handleUpload(files: FileList) {
const taskId = pendingTaskId.current;
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() {
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) {
if (activeTaskId == null) return;
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 (
<div className="space-y-3">
{/* 任务列表 */}
@@ -253,10 +417,7 @@ export default function TaskManager({
onChange={(e) => setTaskName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && taskName.trim()) {
createTask.mutate({ modelId, name: taskName.trim() });
setTaskName('');
setAdding(false);
onEndCreate?.();
submitCreateTask();
}
if (e.key === 'Escape') {
setAdding(false);
@@ -266,11 +427,8 @@ export default function TaskManager({
/>
<button
className="text-green-400"
onClick={() => {
if (taskName.trim()) createTask.mutate({ modelId, name: taskName.trim() });
setAdding(false);
onEndCreate?.();
}}
disabled={createTask.isPending}
onClick={submitCreateTask}
>
<Check className="w-3.5 h-3.5" />
</button>
@@ -287,7 +445,9 @@ export default function TaskManager({
)}
<div className="space-y-1">
{tasks.map((task) => (
{tasks.map((task) => {
const status = task.status as InspectionTaskStatus;
return (
<div
key={task.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
@@ -299,17 +459,9 @@ export default function TaskManager({
/>
<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'
}`}
className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${taskStatusClass(status)}`}
>
{task.status}
{taskStatusLabels[status] ?? status}
</span>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
@@ -332,7 +484,8 @@ export default function TaskManager({
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
);
})}
</div>
{tasks.length === 0 && (
@@ -346,13 +499,14 @@ export default function TaskManager({
{activeTaskId != null && (
<div>
{/* 标题栏 */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<div className="mb-1.5 space-y-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
type="button"
className="text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground flex-shrink-0"
title={allSelected ? '取消全选' : '全选'}
onClick={toggleAllFiles}
>
@@ -363,23 +517,32 @@ export default function TaskManager({
)}
</button>
)}
<span className="industrial-label flex items-center gap-1">
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<Cloud className="w-3.5 h-3.5" /> 点云文件
<span className="text-[10px] text-muted-foreground font-normal">
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
</span>
</span>
</div>
</div>
<div className="flex items-center gap-1">
<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)}
>
<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>
{pcFiles.length > 0 && (
<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={
someSelected
? `加载选中的 ${selectedFileIds.size} 个文件`
@@ -405,9 +568,95 @@ export default function TaskManager({
</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">
{pcFiles.map((f) => {
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 (
<div
key={f.id}
@@ -433,14 +682,50 @@ export default function TaskManager({
<Cloud
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
/>
<span className="flex-1 truncate" title={f.name}>
{f.name}
</span>
{(f as { source?: string }).source === 'registered' && (
<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))]"
>
<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>
)}
{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">
{formatSize(f.fileSize)}
</span>
@@ -476,17 +761,19 @@ export default function TaskManager({
{/* 任务级 CAD 文件 */}
{activeTaskId != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="industrial-label flex items-center gap-1">
<div className="mb-1.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
<span className="text-[10px] text-muted-foreground font-normal">
({taskCadFiles.length})
</span>
</span>
</div>
<div className="flex items-center gap-1">
{taskCadFiles.length > 0 && (
<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}
title={allCadSelected ? '取消全选' : '全选'}
>
@@ -497,10 +784,18 @@ export default function TaskManager({
</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()}
>
<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>
</div>
</div>
@@ -516,13 +811,91 @@ export default function TaskManager({
</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">
{taskCadFiles.map((f) => {
const isChecked = selectedCadFileIds.has(f.id);
const source = (f as { source?: string }).source;
const canPreview = canPreviewCadIn3D(f);
return (
<div
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'}`}
onClick={() => toggleCadFile(f.id)}
>
@@ -538,18 +911,42 @@ export default function TaskManager({
)}
</button>
<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">
{formatSize(f.fileSize)}
</span>
{onSelectTaskCad && (
{onSelectTaskCad && canPreview && (
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
title="在 3D 视图中显示"
onClick={(e) => {
e.stopPropagation();
onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
taskCadReadUrl({ name: f.name, source }),
f.format,
f.name,
);
@@ -585,11 +982,14 @@ export default function TaskManager({
</p>
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
return f ? (
return f && canPreviewCadIn3D(f) ? (
<button
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
onClick={() => onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
taskCadReadUrl({
name: f.name,
source: (f as { source?: string }).source,
}),
f.format,
f.name,
)}
@@ -614,6 +1014,16 @@ export default function TaskManager({
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
ref={cadFileRef}
type="file"
@@ -624,6 +1034,16 @@ export default function TaskManager({
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>
);
}

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('?') ? '&' : '?';
url = `${input}${sep}_t=${Date.now()}`;
} 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, {
...(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 { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer';
@@ -7,24 +7,22 @@ import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
import type { Overlay3D } from '@/components/GenericResultView';
import WorkflowProgress, { type WorkflowState, type WorkflowStepId, type StepStatus } from '@/components/WorkflowProgress';
import { trpc } from '@/lib/trpc';
import {
LogIn,
LogOut,
Box,
Cloud,
Eye,
EyeOff,
Settings,
Maximize2,
Crosshair,
Train,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
export default function Home() {
const { user, loading, isAuthenticated, logout } = useAuth();
const { loading, isAuthenticated } = useAuth();
const utils = trpc.useUtils();
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
@@ -70,7 +68,8 @@ export default function Home() {
// ── 视图控制 ───────────────────────────────────────────────────────────────
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 [pointSize, setPointSize] = useState(0.03);
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
@@ -89,12 +88,7 @@ export default function Home() {
resultId: number;
overlay: Overlay3D;
} | null>(null);
const [errorPickEnabled, setErrorPickEnabled] = 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 [errorPickEnabled] = useState(false);
const [showLeftPanel, setShowLeftPanel] = useState(true);
const [showRightPanel, setShowRightPanel] = useState(true);
@@ -122,13 +116,6 @@ export default function Home() {
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
}
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 || '提交失败'),
});
@@ -146,7 +133,6 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setIsCreatingTask(false);
setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]);
setRoiPickMode(false);
@@ -253,57 +239,6 @@ export default function Home() {
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
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 会以为点云源变了而反复全量请求
const pointCloudUrlsStable = useMemo(
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
@@ -329,8 +264,6 @@ export default function Home() {
onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
onStartCreate={() => setIsCreatingTask(true)}
onEndCreate={() => setIsCreatingTask(false)}
/>
</>
);
@@ -350,8 +283,10 @@ export default function Home() {
<div className="space-y-4">
<AlgorithmRunPanel
taskId={selection.taskId}
modelId={selection.modelId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
taskPcFileCount={taskPcFiles.length}
taskPointcloudFiles={taskPcFiles}
selectedPcFileIds={selectedPcFileIds}
hasRegisteredCloud={hasRegisteredCloud}
selectedCadFileIds={selectedTaskCadFileIds}
@@ -372,12 +307,47 @@ export default function Home() {
setHeatmapViewResultId(resultId);
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);
setPluginOverlay({ taskId, resultId, overlay });
if (overlay.kind === 'vertexColors') {
setPointCloudRenderMode('smoothHeatmap');
}
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
}}
externalPending={runAnalysisJob.isPending}
onRunAssembly={() => {
@@ -439,33 +409,22 @@ export default function Home() {
</button>
</div>
{(pcLoading || cadLoading) && (
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
<span className={`status-led ${
pcLoading ? 'status-led-processing' :
cadLoading ? 'status-led-processing' :
'status-led-idle'
}`} />
<span className="status-led status-led-processing" />
<span className="text-xs text-muted-foreground">
{pcLoading ? `加载点云 ${pcProgress}%` :
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
</span>
</div>
)}
{loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : 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>
) : (
) : !isAuthenticated ? (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" />
</a>
)}
) : null}
</div>
</header>
@@ -480,13 +439,6 @@ export default function Home() {
{/* 中央 3D 视图 */}
<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="absolute inset-2">
<div className="viewer-container w-full h-full relative">
@@ -511,6 +463,7 @@ export default function Home() {
pointCloudUrls={pointCloudUrlsStable}
batchTaskId={batchTaskId ?? undefined}
loadKey={pcLoadKey}
showPointCloud={showPointCloud}
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
onCadProgress={handleCadProgress}
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">
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && (
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span>
)}
{errorPickEnabled && pointCloudRenderMode === 'points'
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
}
<span>左键旋转</span>
<span>右键平移</span>
<span>滚轮缩放</span>
</div>
{/* 选中点信息 */}
@@ -594,15 +543,21 @@ export default function Home() {
<div className="flex items-center gap-2">
<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={() => {
if (pointCloudRenderMode !== 'points') {
setPointCloudRenderMode('points');
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>
{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">
@@ -610,26 +565,12 @@ export default function Home() {
</span>
)}
<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)}
>
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span className="ml-1">热力图</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>
<span>热力图</span>
</button>
</div>
@@ -656,7 +597,7 @@ export default function Home() {
<input
type="range"
min={100_000}
max={5_000_000}
max={2_000_000}
step={100_000}
value={renderPointBudget}
onChange={e => setRenderPointBudget(Number(e.target.value))}
@@ -669,7 +610,7 @@ export default function Home() {
{/* 右侧面板 - 分析结果 */}
{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()}
</aside>
)}

View File

@@ -38,7 +38,7 @@ services:
container_name: massive-point-cloud-app-dev
working_dir: /app
network_mode: host
runtime: nvidia # docker-compose V1 用
# runtime: nvidia # docker-compose V1 用
# gpus: all # docker compose V2 用这行,同时注释上面的 runtime
environment:
- NODE_ENV=development
@@ -75,10 +75,13 @@ services:
- ${LOCAL_DATA_DIR:-./data}:/data
# 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载)
- ${LOCAL_ALG_DIR:-./alg}:/alg:ro
command: sh -c "
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"
command:
- bash
- -c
- |
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:
mysql:
condition: service_healthy

View File

@@ -130,10 +130,11 @@ CREATE TABLE IF NOT EXISTS `inspection_tasks` (
`name` VARCHAR(255) NOT NULL,
`inspector` VARCHAR(100),
`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,
`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;
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` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`taskId` INT NOT NULL,
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation') NOT NULL,
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation','plugin') NOT NULL,
`status` ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending',
`errorStats` JSON,
`measurements` 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 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 独立,用于本次检测的实测参考)
@@ -72,6 +87,10 @@ export const taskCadFiles = mysqlTable("task_cad_files", {
format: varchar("format", { length: 32 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).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(),
});
@@ -87,10 +106,14 @@ export const inspectionTasks = mysqlTable("inspection_tasks", {
name: varchar("name", { length: 255 }).notNull(),
inspector: varchar("inspector", { length: 100 }),
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(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
completedAt: timestamp("completedAt"),
}, (table) => {
return {
modelNameUnique: uniqueIndex("uk_inspection_tasks_model_name").on(table.modelId, table.name),
};
});
export type InspectionTask = typeof inspectionTasks.$inferSelect;
@@ -105,10 +128,14 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
name: varchar("name", { length: 255 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
filePath: varchar("filePath", { length: 512 }).notNull(),
/** 来源:uploaded = 用户上传;registered = 配准算法产出 */
/** 来源:uploaded = 用户上传;registered = 配准算法产出;model_library = 车型点云库关联 */
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
/** 产出该点云的分析结果 ID(仅 registered 时有值) */
sourceResultId: int("sourceResultId"),
/** 生成该配准产物的原始任务点云 ID(仅 registered 时有值) */
sourcePointcloudFileId: int("sourcePointcloudFileId"),
/** 来源车型点云文件 ID(仅 model_library 时有值) */
sourceModelPointcloudId: int("sourceModelPointcloudId"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -135,7 +162,7 @@ export const taskAnalysisResults = mysqlTable("task_analysis_results", {
pluginId: varchar("pluginId", { length: 64 }),
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
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 }
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
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) {
const serverOptions = {
middlewareMode: true,
// Disable Vite client-side auto reload/HMR in dev.
hmr: false as const,
// Reuse the app HTTP server for HMR so SSH forwarding only needs the app port.
hmr: { server },
allowedHosts: true as const,
};

View File

@@ -12,7 +12,7 @@
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
*/
import { fork, ChildProcess } from 'node:child_process';
import { fork, ChildProcess, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -67,7 +67,7 @@ class ConcurrencyPool {
child.kill('SIGKILL');
return true;
}
return false;
return killOrphanAlgorithmProcesses({ resultId });
}
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
@@ -85,13 +85,17 @@ class ConcurrencyPool {
}
this.queue = keep;
// 运行中 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;
if (jobTaskId === taskId) {
child.kill('SIGKILL');
cancelled.push(resultId);
}
}
const orphaned = killOrphanAlgorithmProcesses({ taskId });
if (orphaned.length > 0) {
cancelled.push(...orphaned);
}
return cancelled;
}
@@ -155,6 +159,113 @@ function resolveAlgorithmRunnerEntry(): string {
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(无论成功/失败)。
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。

View File

@@ -43,6 +43,26 @@ function ensureDir(dir: string) {
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 模板里的占位符替换成实际路径。 */
function resolvePlaceholders(value: string, ctx: Record<string, string>): string {
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(
@@ -102,6 +122,8 @@ async function writeInputJson(
params: Record<string, unknown>,
pointCloudPaths: string[],
cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
) {
const inputJson = {
schemaVersion: 1,
@@ -113,7 +135,9 @@ async function writeInputJson(
outDir: workDir,
inputs: {
pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
cadPath,
cadFileIds: inputCadFileIds,
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(
manifest: PluginManifest,
workDir: string,
@@ -266,12 +311,14 @@ export async function runAlgorithmJob(
): Promise<void> {
const manifest = getPlugin(pluginId);
if (!manifest) {
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, {
status: 'error',
resultData: {
kind: 'plugin',
pluginId,
error: `未注册的插件:${pluginId}`,
...demoFlowResultData,
} as PluginResultData,
});
return;
@@ -282,7 +329,15 @@ export async function runAlgorithmJob(
try {
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(
workDir,
taskId,
@@ -291,6 +346,8 @@ export async function runAlgorithmJob(
params,
pointCloudPaths,
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
);
let exitCode: number | null = 0;
@@ -308,12 +365,15 @@ export async function runAlgorithmJob(
? 'completed'
: 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const resultData: PluginResultData = {
kind: 'plugin',
pluginId,
progress: finalStatus === 'completed' ? 1 : undefined,
stage: finalStatus === 'completed' ? '完成' : '失败',
exitCode,
inputPointCloudFileIds,
inputCadFileIds,
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message,
@@ -326,6 +386,7 @@ export async function runAlgorithmJob(
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
...demoFlowResultData,
};
await db.updateTaskAnalysisResult(resultId, {
@@ -350,13 +411,16 @@ export async function runAlgorithmJob(
continue;
}
try {
const sourcePointcloud = pointCloudRecords[0];
const displayName = registeredPointcloudDisplayName(sourcePointcloud?.name, d.file, resultId);
await db.createRegisteredPointcloudFile(
taskId,
d.name ?? d.file,
displayName,
absPath,
resultId,
sourcePointcloud?.id ?? null,
);
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${d.name ?? d.file}`);
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${displayName}`);
} catch (err) {
console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err);
}
@@ -376,12 +440,14 @@ export async function runAlgorithmJob(
} catch (err) {
console.error('[AlgorithmJobRunner]', err);
const message = err instanceof Error ? err.message : String(err);
const demoFlowResultData = await readDemoFlowResultData(resultId);
await db.updateTaskAnalysisResult(resultId, {
status: 'error',
resultData: {
kind: 'plugin',
pluginId,
error: message,
...demoFlowResultData,
} 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 { runStepParseMvp, stepParseCacheDir } from './stepParse.js';
import type { StepManifest } from './stepParse.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data';
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
@@ -42,6 +43,7 @@ export function enqueueAssemblyInspectionJob(
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
execArgv: isProd ? [] : ['--import', 'tsx'],
});
registerLegacyAnalysisChild(resultId, taskId, 'assembly_inspection', child);
child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err));
child.on('exit', (code, signal) => {
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 type { ResultSetHeader } from "mysql2";
import {
@@ -6,6 +6,7 @@ import {
vehicleSeries, InsertVehicleSeries, VehicleSeries,
vehicleModels, InsertVehicleModel, VehicleModel,
modelCadFiles, InsertModelCadFile, ModelCadFile,
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
taskCadFiles, InsertTaskCadFile, TaskCadFile,
inspectionTasks, InsertInspectionTask, InspectionTask,
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
@@ -229,6 +230,37 @@ export async function deleteModelCadFile(id: number): Promise<void> {
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 文件 ─────────────────────────────────────────────────────────────
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));
}
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> {
@@ -270,19 +342,73 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
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[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(inspectionTasks)
const tasks = await db.select().from(inspectionTasks)
.where(eq(inspectionTasks.modelId, modelId))
.orderBy(desc(inspectionTasks.createdAt));
return withComputedInspectionTaskStatuses(tasks);
}
export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> {
const db = await getDb();
if (!db) return undefined;
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> {
@@ -294,6 +420,9 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
export async function deleteInspectionTask(id: number): Promise<void> {
const db = await getDb();
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));
}
@@ -315,6 +444,7 @@ export async function createRegisteredPointcloudFile(
name: string,
filePath: string,
sourceResultId: number,
sourcePointcloudFileId?: number | null,
): Promise<TaskPointcloudFile> {
const fs = await import('node:fs');
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
@@ -325,6 +455,7 @@ export async function createRegisteredPointcloudFile(
fileSize,
source: 'registered',
sourceResultId,
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
} as InsertTaskPointcloudFile);
}
@@ -342,6 +473,48 @@ export async function deleteTaskPointcloudFile(id: number): Promise<void> {
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> {

View File

@@ -10,6 +10,7 @@ import {
import { pickCadFileForHeatmap } from './cadFilePick.js';
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data';
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'],
execArgv: isProd ? [] : ['--import', 'tsx'],
});
registerLegacyAnalysisChild(resultId, taskId, 'obj_deviation', child);
child.on('error', (err) => {
console.error('[DeviationHeatmapJob] fork 失败:', err);
});

View File

@@ -24,7 +24,9 @@ ensureDir(TASKS_DIR);
const diskStorage = multer.diskStorage({
destination: (req, _file, cb) => {
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');
} else if (req.params.taskId && req.path.includes('/task-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 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/task-cad/:taskId
@@ -173,6 +204,17 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
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
@@ -283,7 +325,10 @@ router.post('/sample/batch', async (req, res) => {
);
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;
for (const pts of sampledPerFile) {
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.z, offset + 8);
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`);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf);
} catch (err) {
@@ -401,7 +452,45 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
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
// 重新从磁盘扫描 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")
# 至少有一对轴的范围被填写时才构建 ROI
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(
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_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 = [
{
"name": "偏转角 θ_right (右向)",
"value": theta_right,
"unit": "deg",
"tolerance": 0.5,
"status": _status_for(theta_right, 0.5),
"name": "车钩面板距纵向中心的右偏转角",
"value": round(offset_right, 6),
"unit": "mm",
"baseline": 700.0,
"tolerance": 1.0,
"status": _status_for(offset_right, 1.0),
},
{
"name": "偏转角 θ_ref (参考向)",
"value": theta_ref,
"unit": "deg",
"tolerance": 0.5,
"status": _status_for(theta_ref, 0.5),
"name": "车钩面板距参考面的右偏转角",
"value": round(offset_ref, 6),
"unit": "mm",
"baseline": 375.0,
"tolerance": 0.8,
"status": _status_for(offset_ref, 0.8),
},
{
"name": "最大 x 偏差 dx_y",

View File

@@ -7,15 +7,16 @@ v1.5 变更:
- pipeline 新增 result_template_path / combined_context_pointcloud_path 参数
约定:
- module=all:plane + curvature(contour 需要 CAD STL,缺失时跳过)
- module=contour:轮廓/平整度,需要 CAD STEP(推荐,算法方基准 3min25s)
- module=plane:只跑平面拟合
- module=curvature:只跑曲率估计
- module=contour:需要 CAD STL,缺失时报错
- module=curvature:只跑曲率估计(大点云极慢,慎用)
- module=all:plane + curvature + contour(已从前端选项移除,大点云不可用)
"""
from __future__ import annotations
import copy
import hashlib
import os
import sys
import traceback
@@ -39,6 +40,39 @@ def _safe_float(v: object) -> float | 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:
"""定位平整度算法目录,支持多个历史版本路径,优先使用最新版。"""
candidates = [
@@ -99,11 +133,12 @@ def main() -> int:
try:
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)
# ── 判断走单点云模式还是 datasets 批量模式 ──────────────────────────────
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 裁剪(整车点云场景下必须填写,否则算法极慢)──────────────────────
def _f(key: str) -> float | None:
@@ -147,10 +182,61 @@ def main() -> int:
# v1.5 用 o3d.io.read_point_cloud 读取,直接支持二进制 PLY,无需转 ASCII
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})")
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] = []
extra: dict[str, Any] = {"module": module}
@@ -226,7 +312,7 @@ def main() -> int:
# ── 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
mesh_path = inputs.get("meshPath") or None
if not cad_path and not mesh_path:
@@ -235,14 +321,54 @@ def main() -> int:
try:
save_path = str(out_dir / "contour.ply")
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(
pointcloud_path=pc_path,
cad_path=cad_path,
mesh_path=mesh_path,
point_chunk_size=8192,
triangle_chunk_size=512,
save_path=save_path,
excel_output_path=excel_path,
combined_context_pointcloud_path=context_pc_path,
no_visualize=True,
)
stats = res.get("distance_stats")
for key, label, unit in [
("mean", "轮廓偏差均值", "mm"),
@@ -268,7 +394,7 @@ def main() -> int:
overlay3d: dict = {"kind": "none"}
for ply_key in ("contourPly", "curvaturePly"):
if ply_key in extra:
overlay3d = {"kind": "vertexColors", "url": extra[ply_key]}
overlay3d = {"kind": "ply", "file": extra[ply_key]}
break
if skipped:

View File

@@ -5,14 +5,14 @@
v2.2 变更:
- 算法路径从 alg/点云测距v1.1 → alg/train_measurement_system_v2.2
- 新增孔中心拟合(hole_center_fitter)、测量日志、性能日志
- 内置配置文件改为 config/measurements_0618.yaml
- TRAIN_MEAS_ROOT 指向 train_measurement_system_v2.2/(用于定位 config/)
- 内置配置文件改为 config/measurements.yaml(v3.2)
- TRAIN_MEAS_ROOT 指向 train_measurement_system_v3.2_0629/(用于定位 config/)
- 算法内置 _read_binary_ply,直接支持二进制 PLY,无需 ASCII 转换
约定:
- 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中;
- 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之;
否则使用算法仓库自带的 config/measurements_0618.yaml 作为模板。
否则使用算法仓库自带的 config/measurements.yaml(v3)作为模板。
- 所有任务结果合成成 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:
payload = load_input(sys.argv)
inputs = payload.get("inputs") or {}
@@ -41,14 +92,13 @@ def main() -> int:
out_dir.mkdir(parents=True, exist_ok=True)
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 "")
if not train_root.exists():
# 回退:从 ALG_ROOT 推导
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():
raise RuntimeError(
@@ -78,8 +128,8 @@ def main() -> int:
# 已经是 YAML 格式,直接写盘
yaml_path.write_text(custom_yaml_str, encoding="utf-8")
else:
# 使用算法仓库内置的 config/measurements_0618.yaml
yaml_path = train_root / "config" / "measurements_0618.yaml"
# 使用算法仓库内置的 config/measurements.yaml(v3)
yaml_path = train_root / "config" / "measurements.yaml"
if not yaml_path.exists():
raise RuntimeError(f"内置配置文件不存在:{yaml_path}")
@@ -87,16 +137,25 @@ def main() -> int:
# ── 初始化 TaskManager ────────────────────────────────────────────────
tm = TaskManager()
# 先注入点云路径,让 load_config 跳过 YAML 里的 pointcloud_file(开发测试路径)
tm.pointcloud_path_override = pc_path
# 把输出目录重定向到 outDir,避免写到只读的 /alg 目录
tm.output_dir_override = str(out_dir)
emit_progress(0.25, f"读取点云({os.path.basename(pc_path)},可能需要 1~2 分钟)")
if not tm.load_config(str(yaml_path)):
raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}")
# 注入点云路径(覆盖 YAML 中的 pointcloud_file)
tm.config["pointcloud_file"] = pc_path
# 过滤 enabled=False 的任务(v3 TaskManager 不处理 enabled 字段)
import yaml as _yaml_check
_raw = _yaml_check.safe_load(yaml_path.read_text(encoding="utf-8"))
_enabled_names = {
t["name"] for t in (_raw.get("tasks") or _raw.get("measurements") or [])
if t.get("enabled", True)
}
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
# 注入可视化输出目录
viz_dir = out_dir / "visualizations"
viz_dir.mkdir(parents=True, exist_ok=True)
@@ -104,10 +163,37 @@ def main() -> int:
if hasattr(task, "visualization_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()
report_rows = _load_excel_measurement_rows(out_dir)
emit_progress(0.92, "整理结果")
measurements: list[dict] = []
@@ -133,37 +219,39 @@ def main() -> int:
value = clean_data.get("mean_distance")
if value is None:
value = clean_data.get("distance")
fval = float(value) if isinstance(value, (int, float)) else None
# v3.2 data 里直接有 is_qualified 判定结果(line_to_plane/line_to_line 也支持)
data_pass = clean_data.get("is_qualified")
if data_pass is None:
data_pass = clean_data.get("pass") # 兼容旧字段名
tol_cfg = tol_map.get(name)
tolerance = None
meas_status = "info"
if data_pass is not None:
# 算法已判定,直接用
meas_status = "pass" if data_pass else "fail"
if tol_cfg:
tolerance = tol_cfg.get("upper")
elif tol_cfg and fval is not None:
theoretical = tol_cfg.get("theoretical")
upper = tol_cfg["upper"]
lower = tol_cfg["lower"]
tolerance = upper
if theoretical is not None:
error = fval - float(theoretical)
meas_status = "pass" if (-lower <= error <= upper) else "fail"
measurements.append(
{
"name": name,
"value": float(value) if isinstance(value, (int, float)) else float("nan"),
"value": fval if fval is not None else float("nan"),
"unit": "mm",
"status": "info",
}
)
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",
"tolerance": tolerance,
"status": meas_status,
"passLabel": "通过",
"failLabel": "不通过",
}
)
else:
@@ -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")
payload_out = {
"schemaVersion": 1,

View File

@@ -1,18 +1,14 @@
"""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 变更:
- 算法包升级至 delivery_v1.2.1(单梁结果新增项点检验字段:公差/测量/误差/结论)
- report.xlsx 等 Excel 产物通过 result.json 的 files 字段暴露给前端下载
- PYTHONPATH 指向 delivery_v1.2.1
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 对象字段
- 改用 run_both_beams 双梁并行入口,与算法方命令行一致
- leftRoiPath / rightRoiPath 参数默认指向 test_data ROI
- skipRegistration 默认 True(点云已配准)
"""
from __future__ import annotations
@@ -35,20 +31,14 @@ from _plugin_io import (
def _status_for(value: float | None, tolerance: float | None) -> str:
if value is None or tolerance is None:
return "info"
if abs(value) <= tolerance:
abs_val = abs(value)
if abs_val <= tolerance:
return "pass"
if abs(value) <= tolerance * 1.5:
if abs_val <= tolerance * 1.5:
return "warning"
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:
payload = load_input(sys.argv)
inputs = payload.get("inputs") or {}
@@ -57,120 +47,123 @@ def main() -> int:
out_dir.mkdir(parents=True, exist_ok=True)
try:
emit_progress(0.05, "加载 underframe_deflection v1.2.1 算法包")
from underframe_deflection.pipeline import run_measurement_pipeline # type: ignore
emit_progress(0.05, "加载挠度算法包 v1.2.1")
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
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,
# 我们在调用前后各报一次进度即可。
report: dict = run_measurement_pipeline(
input_cloud=pc_path,
out_dir=str(out_dir),
profile=profile,
for p, label in [(left_roi, "左梁 ROI"), (right_roi, "右梁 ROI")]:
if not os.path.isfile(p):
write_error_result(out_dir, f"{label} 文件不存在:{p}")
return 1
tolerance_mm = float(config.get("toleranceMm") or 3.0)
left_tolerance_mm = float(config.get("leftToleranceMm") or tolerance_mm or 7.0)
right_tolerance_mm = float(config.get("rightToleranceMm") or tolerance_mm or 12.0)
skip_registration = bool(config.get("skipRegistration", True))
enable_bilateral = bool(config.get("enableBilateral", True))
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,
roi_path=roi_path,
skip_registration=skip_registration,
enable_bilateral=enable_bilateral,
skip_excel=False,
enable_registration= not skip_registration,
config = {"enable_bilateral": enable_bilateral},
)
emit_progress(0.92, "整理结果")
# ── 从 report dict 中提取核心测量值 ──────────────────────────────────
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"))
right_mm = _safe_float(right_sel.get("signed_deflection_mm"))
registration = report.get("registration") or {}
reg_rms = _safe_float(registration.get("rms_mm"))
quality_flags: list[str] = report.get("quality_flags") or []
# 容差:高铁底架挠度通常要求 ≤ 3 mm(可在 manifest inputs 中暴露)
tol = _safe_float(config.get("toleranceMm")) or 3.0
measurements = [
{
"name": "左梁挠度",
"value": left_mm if left_mm is not None else float("nan"),
"unit": "mm",
"tolerance": tol,
"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",
}
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},
)
# ── 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),
}
result = {"left": left_report, "right": right_report}
payload_out = {
"schemaVersion": report.get("schema_version", "1.1"),
"status": "completed",
emit_progress(0.95, "整理结果")
def _beam_measurements(report: dict, label: str) -> list:
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",
}]
measurements = (
_beam_measurements(left_report, "左梁") +
_beam_measurements(right_report, "右梁")
)
# 收集产物文件(xlsx/csv/png)- write_result 会自动扫 xlsx,
# 这里额外加 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)))
# ── 汇总消息 ────────────────────────────────────────────────────────
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,
{
"message": msg,
"measurements": measurements,
"overlay3d": overlay3d,
"extra": {
"algorithmVersion": report.get("algorithm_version"),
"qualityFlags": quality_flags,
"registration": registration,
"deflectionFull": deflection,
"files": extra_files,
"extra": {"leftReport": left_report, "rightReport": right_report},
},
}
write_result(out_dir, payload_out)
emit_progress(1.0, "完成")
)
return 0
except Exception as exc:
traceback.print_exc()
write_error_result(
out_dir,
f"underframe-deflection 计算失败:{exc}",

View File

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

View File

@@ -2,23 +2,28 @@
"id": "flatness-suite",
"name": "平整度 / 平面度 / 轮廓测量",
"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",
"schemaVersion": 1,
"category": "几何测量",
"requiresPointCloud": true,
"requiresPointCloud": false,
"requiresCad": false,
"inputs": [
{
"name": "useBatchDatasets",
"label": "批量模式(用 datasets/Noise_PointCloud 计算,所选点云仅用于可视化展示)",
"type": "boolean",
"default": true
},
{
"name": "module",
"label": "测量模块",
"type": "select",
"default": "all",
"default": "contour",
"options": [
{ "value": "all", "label": "全部 (plane + contour + curvature)" },
{ "value": "contour", "label": "轮廓-平整度 (contour,需 CAD STEP)" },
{ "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": "roiYMax", "label": "ROI Y 最大值 (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": {
"kind": "process",
@@ -49,7 +61,8 @@
"{inputJson}"
],
"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
}

View File

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

View File

@@ -2,7 +2,7 @@
"id": "underframe-deflection",
"name": "构架挠度测量",
"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",
"schemaVersion": 1,
"category": "几何测量",
@@ -10,33 +10,45 @@
"requiresCad": false,
"inputs": [
{
"name": "profile",
"label": "调参档位",
"type": "select",
"default": "auto",
"options": [
{ "value": "auto", "label": "自动 (auto)" },
{ "value": "M", "label": "动车组 (M)" },
{ "value": "TC", "label": "拖车-控制 (TC)" },
{ "value": "TP", "label": "拖车-供电 (TP)" }
]
"name": "leftRoiPath",
"label": "左梁 ROI JSON 路径",
"type": "string",
"default": "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_left_beam.json",
"description": "左侧纵梁的 AABB 裁剪框 JSON 文件绝对路径(容器内路径)。"
},
{
"name": "toleranceMm",
"label": "挠度容差 (mm)",
"name": "rightRoiPath",
"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",
"default": 3.0,
"default": 7.0,
"min": 0.5,
"max": 10.0,
"max": 20.0,
"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",
"label": "跳过 CAD 配准",
"type": "boolean",
"default": false,
"description": "若点云已在 CAD 坐标系中可跳过配准(需提供 CAD 基准时才生效)。"
"default": true,
"description": "点云已在 CAD 坐标系中(整车配准后),跳过 CAD 配准直接测量。"
},
{
"name": "enableBilateral",

View File

@@ -98,7 +98,8 @@
"{inputJson}"
],
"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
}

View File

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

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs';
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 文本点云行 */
export async function randomSeekSample(
@@ -170,15 +170,20 @@ function samplePlyBinary(
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 rOff = -1, gOff = -1, bOff = -1;
let xType = 'float', yType = 'float', zType = 'float';
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
let byteOff = 0;
for (const p of properties) {
const n = p.name.toLowerCase();
if (n === 'x') { xOff = byteOff; xType = p.type; }
else if (n === 'y') { yOff = byteOff; yType = 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;
}
@@ -224,7 +229,13 @@ function samplePlyBinary(
const z = readNum(recBuf, zOff, zType);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
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 {
fs.closeSync(fd);
@@ -277,12 +288,25 @@ function samplePlyAscii(
const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim();
if (!line) continue;
const parts = line.split(/\s+/);
const x = parseFloat(parts[0]);
const y = parseFloat(parts[1]);
const z = parseFloat(parts[2]);
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
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;
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 {
fs.closeSync(fd);

View File

@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import * as db from "./db";
import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js";
import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
@@ -13,6 +14,293 @@ import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.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({
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 文件 ────────────────────────────────────────────────────────────
taskCadFile: router({
@@ -162,6 +467,46 @@ export const appRouter = router({
await db.deleteTaskCadFile(input.id);
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
.input(z.object({
modelId: z.number(),
name: z.string().min(1).max(255),
name: z.string().trim().min(1).max(255),
inspector: z.string().max(100).optional(),
notes: z.string().optional(),
}))
.mutation(async ({ input }) => {
return db.createInspectionTask({
const existing = await db.findInspectionTaskByModelAndName(input.modelId, input.name);
if (existing) {
throw duplicateInspectionTaskNameError();
}
try {
return await db.createInspectionTask({
modelId: input.modelId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
status: "pending",
status: "created",
});
} catch (error) {
if (isDuplicateKeyError(error)) {
throw duplicateInspectionTaskNameError();
}
throw error;
}
}),
update: protectedProcedure
@@ -202,7 +559,7 @@ export const appRouter = router({
name: z.string().max(255).optional(),
inspector: z.string().max(100).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 }) => {
const { id, ...data } = input;
@@ -213,12 +570,19 @@ export const appRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteInspectionTask(input.id);
// 取消该任务下所有正在运行或排队的算法 job
const cancelled = cancelAlgorithmJobsByTask(input.id);
// 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。
const cancelled = [
...cancelAlgorithmJobsByTask(input.id),
...cancelLegacyAnalysisJobsByTask(input.id),
];
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 };
}),
}),
@@ -238,6 +602,27 @@ export const appRouter = router({
await db.deleteTaskPointcloudFile(input.id);
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 };
}),
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
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
// 先取消正在运行或排队的 job,再删数据库记录
cancelAlgorithmJob(input.id);
cancelLegacyAnalysisJob(input.id);
await db.deleteTaskAnalysisResult(input.id);
return { success: true };
}),
@@ -448,6 +847,8 @@ export const appRouter = router({
pointCloudFileIds: z.array(z.number()).optional(),
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
cadFileIds: z.array(z.number()).optional(),
/** 配准:允许同一原始点云再次生成配准产物 */
allowDuplicateRegistration: z.boolean().optional(),
}),
)
.mutation(async ({ input }) => {
@@ -458,6 +859,9 @@ export const appRouter = router({
message: `未注册的算法插件:${input.pluginId}`,
});
}
if (manifest.category === "配准") {
await validateRegistrationRunInput(input);
}
try {
const record = await db.createTaskAnalysisResult({
@@ -471,7 +875,7 @@ export const appRouter = router({
record.id,
input.taskId,
manifest.id,
input.params ?? {},
mergePluginParams(manifest, input.params),
input.pointCloudFileIds,
input.cadFileIds,
);
@@ -510,6 +914,8 @@ export const appRouter = router({
pointCloudFileIds: z.array(z.number()).optional(),
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
cadFileIds: z.array(z.number()).optional(),
/** 配准:允许同一原始点云再次生成配准产物 */
allowDuplicateRegistration: z.boolean().optional(),
jobs: z
.array(
z.object({
@@ -523,6 +929,10 @@ export const appRouter = router({
)
.mutation(async ({ input }) => {
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) {
const manifest = getPlugin(job.pluginId);
if (!manifest) continue;
@@ -537,7 +947,7 @@ export const appRouter = router({
record.id,
input.taskId,
manifest.id,
job.params ?? {},
mergePluginParams(manifest, job.params),
input.pointCloudFileIds,
input.cadFileIds,
);
@@ -545,6 +955,109 @@ export const appRouter = router({
}
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';
import { pickCadFileForHeatmap } from './cadFilePick.js';
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
const DATA_DIR = process.env.DATA_DIR || '/data';
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'],
execArgv: isProd ? [] : ['--import', 'tsx'],
});
registerLegacyAnalysisChild(resultId, taskId, 'smooth_heatmap', child);
child.on('error', (err) => {
console.error('[SmoothHeatmapJob] fork 失败:', err);
});

View File

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