Compare commits

..

16 Commits

143 changed files with 13755 additions and 4813 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

@@ -34,11 +34,7 @@ COPY . .
# 设置构建时环境变量
ARG VITE_APP_TITLE="宏量点云处理系统"
ARG VITE_OAUTH_PORTAL_URL="disabled"
ARG VITE_APP_ID=""
ENV VITE_APP_TITLE=$VITE_APP_TITLE
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
ENV VITE_APP_ID=$VITE_APP_ID
# 构建应用
RUN pnpm build

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)
# 找到在包围盒内的点
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
indices = np.where(mask)[0]
roi_points = points[indices]
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]
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

@@ -322,6 +322,7 @@ class MeasurementPipeline:
excel_output_path: str | None = None,
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
save_combined_visualization: bool = True,
no_visualize: bool = False,
) -> dict[str, Any]:
batch_start_time = time.perf_counter()
@@ -443,7 +444,7 @@ class MeasurementPipeline:
combined_visualization_output = None
combined_pointclouds = []
if combined_visualizations:
if save_combined_visualization and combined_visualizations:
combined_visualization_output = self._contour_combined_save_path(save_path)
self._save_contour_combined_pointcloud_stream(
combined_visualizations,
@@ -627,6 +628,7 @@ class MeasurementPipeline:
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
result_col = self._find_header_index(headers, ["测量结果"])
error_col = self._find_header_index(headers, ["误差结果"])
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
result_by_task = {
@@ -648,6 +650,7 @@ class MeasurementPipeline:
continue
output_row[result_col] = self._row_measurement_mean(result_row)
output_row[error_col] = result_row.get("max")
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
table.append(output_row)
used_task_ids.add(task_id)
@@ -659,6 +662,7 @@ class MeasurementPipeline:
output_row = [""] * len(headers)
output_row[task_col] = task_id
output_row[result_col] = self._row_measurement_mean(result_row)
output_row[error_col] = result_row.get("max")
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
table.append(output_row)
@@ -729,7 +733,9 @@ class MeasurementPipeline:
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
status = str(row.get("status", "")).strip()
if status and status != "成功":
if status in {"成功", "部分成功", "失败"}:
return status
if status:
return "失败"
value = self._row_measurement_mean(row)

View File

@@ -0,0 +1,62 @@
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
import tempfile
import numpy as np
from pipeline import MeasurementPipeline
class CombinedContourRetentionTest(TestCase):
def make_pipeline(self, root: Path) -> MeasurementPipeline:
mesh = root / "mesh.ply"
pointcloud = root / "points.ply"
mesh.touch()
pointcloud.touch()
pipeline = MeasurementPipeline.__new__(MeasurementPipeline)
pipeline._resolve_contour_task_inputs = Mock(return_value=[{
"task_id": "1",
"sample_id": "sample",
"cad_path": None,
"mesh_path": mesh,
"pointcloud_path": pointcloud,
}])
pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply")
pipeline.run_contour = Mock(return_value={
"distance_stats": SimpleNamespace(max=0.5),
"distances": np.array([0.1, 0.2]),
"pointcloud": object(),
})
pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply")
pipeline._save_contour_combined_pointcloud_stream = Mock()
pipeline._should_visualize = Mock(return_value=False)
return pipeline
def test_skips_combined_file_when_disabled(self) -> None:
with tempfile.TemporaryDirectory() as temp:
pipeline = self.make_pipeline(Path(temp))
result = pipeline.run_contour_batch(
all_tasks=True,
save_path=temp,
save_combined_visualization=False,
no_visualize=True,
)
self.assertIsNone(result["combined_visualization_output"])
pipeline._save_contour_combined_pointcloud_stream.assert_not_called()
def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None:
with tempfile.TemporaryDirectory() as temp:
pipeline = self.make_pipeline(Path(temp))
result = pipeline.run_contour_batch(
all_tasks=True,
save_path=temp,
save_combined_visualization=True,
no_visualize=True,
)
self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply")
pipeline._save_contour_combined_pointcloud_stream.assert_called_once()

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,33 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import { Redirect, Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import ChangePassword from "./pages/ChangePassword";
import Users from "./pages/Users";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
const { user, loading } = useAuth();
if (loading) return <DashboardLayoutSkeleton />;
if (!user) return <Redirect to="/login" />;
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
return <>{children}</>;
}
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path="/login" component={Login} />
<Route path="/change-password" component={ChangePassword} />
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
},
});
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
isSuperAdmin: meQuery.data?.role === "super_admin",
};
}, [
meQuery.data,
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
window.location.href = redirectPath;
}, [
redirectOnUnauthenticated,
redirectPath,

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}

File diff suppressed because it is too large Load Diff

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

@@ -1,4 +1,4 @@
import { Skeleton } from './ui/skeleton';
import { Skeleton } from "./ui/skeleton";
export function DashboardLayoutSkeleton() {
return (
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
<Skeleton className="bg-muted h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
<Skeleton className="bg-muted h-3 w-20" />
<Skeleton className="bg-muted h-2 w-32" />
</div>
</div>
</div>
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="bg-muted h-64 rounded-xl" />
</div>
</div>
);

View File

@@ -0,0 +1,211 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
import {
formatElapsedTime,
type DemoFlowState,
type DemoFlowStep,
type DemoFlowStepStatus,
} from '@/hooks/useDemoFlowState';
interface DemoFlowOverlayProps {
state: DemoFlowState;
pluginNameById?: Map<string, string>;
}
function statusIcon(status: DemoFlowStepStatus) {
switch (status) {
case 'completed':
return (<div className="w-5 h-5 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
<Check className="w-3 h-3" />
</div>);
case 'processing':
return (<div className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center">
<Loader2 className="w-3 h-3 animate-spin" />
</div>);
case 'pending':
return (<div className="w-5 h-5 rounded-full bg-slate-500/20 text-slate-400 flex items-center justify-center">
<Circle className="w-3 h-3" />
</div>);
case 'error':
return (<div className="w-5 h-5 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
<X className="w-3 h-3" />
</div>);
case 'cancelled':
return (<div className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center">
<AlertTriangle className="w-3 h-3" />
</div>);
default:
return null;
}
}
function statusBorder(status: DemoFlowStepStatus) {
switch (status) {
case 'completed': return 'border-green-500/30 bg-green-500/5';
case 'processing': return 'border-blue-500/40 bg-blue-500/10 animate-pulse';
case 'pending': return 'border-slate-500/20 bg-slate-500/5';
case 'error': return 'border-red-500/30 bg-red-500/5';
case 'cancelled': return 'border-amber-500/30 bg-amber-500/5';
default: return 'border-border/40 bg-card/30';
}
}
function stepLabel(step: DemoFlowStep, pluginNameById?: Map<string, string>): string {
if (step.role === 'registration') return '整车配准';
const name = step.pluginId ? (pluginNameById?.get(step.pluginId) ?? step.pluginId) : '测量算法';
return name;
}
function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
switch (status) {
case 'processing': return '运行中';
case 'pending': return '排队中';
case 'completed': return '已完成';
case 'error': return '失败';
case 'cancelled': return '已停止';
default: return '';
}
}
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
const title = useMemo(
() => state.completedPointCount == null
? '点云数量暂不可用'
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
[state.completedPointCount],
);
const elapsedTime = formatElapsedTime(state.elapsedMs);
if (!state.active && state.overallStatus === 'idle') return null;
const isCollapsed = state.runId != null && collapsedRunIds.has(state.runId);
const setCurrentRunCollapsed = (collapsed: boolean) => {
if (state.runId == null) return;
setCollapsedRunIds((previous) => {
const next = new Set(previous);
if (collapsed) next.add(state.runId!);
else next.delete(state.runId!);
return next;
});
};
if (isCollapsed) {
return (
<div className="absolute inset-0 z-30 pointer-events-none">
<div className="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-72 max-w-[calc(100%-1.5rem)] items-center gap-3 border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-3 py-2.5 shadow-2xl backdrop-blur-sm">
<div className="min-w-0 flex-1">
<div className="truncate text-[10px] font-medium" title={title}>{title}</div>
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
style={{ width: `${state.overallProgress}%` }}
/>
</div>
</div>
<button
type="button"
title="展开进度"
aria-label="展开进度"
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setCurrentRunCollapsed(false)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
return (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
<div className="industrial-panel pointer-events-auto mx-4 w-full max-w-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-5 shadow-2xl backdrop-blur-sm sm:px-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
{state.overallStatus === 'processing' ? (
<Scan className="w-4 h-4 text-primary animate-pulse" />
) : (
<Cpu className="w-4 h-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="break-words text-sm font-medium">{title}</h3>
<p className="text-[10px] text-muted-foreground">
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
</p>
</div>
<button
type="button"
title="收起进度"
aria-label="收起进度"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setCurrentRunCollapsed(true)}
>
<Minimize2 className="h-4 w-4" />
</button>
</div>
{/* 总进度条 */}
<div className="h-1.5 w-full bg-secondary rounded-full overflow-hidden mb-5">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
style={{ width: `${state.overallProgress}%` }}
/>
</div>
{/* 步骤条 */}
<div className="space-y-3">
{state.steps.map((step, idx) => {
const isCurrent = state.currentStep?.id === step.id;
const label = stepLabel(step, pluginNameById);
const showProgress = step.status === 'processing' && step.progress != null;
return (
<div key={step.id} className="relative flex items-start gap-3">
{idx !== state.steps.length - 1 && (
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
)}
<div className="relative z-10 mt-0.5">{statusIcon(step.status)}</div>
<div className={`min-w-0 flex-1 rounded border p-2.5 ${statusBorder(step.status)} ${isCurrent ? 'ring-1 ring-primary/20' : ''}`}>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
<span className="text-[9px] text-muted-foreground flex-shrink-0">
{step.status === 'processing' ? '运行中'
: step.status === 'pending' ? '排队中'
: step.status === 'completed' ? '已完成'
: step.status === 'error' ? '失败'
: step.status === 'cancelled' ? '已停止'
: step.status}
</span>
</div>
{showProgress && (
<div className="mt-1.5">
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
/>
</div>
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
{Math.round((step.progress ?? 0) * 100)}%
</div>
</div>
)}
</div>
</div>
);
})}
</div>
{state.overallStatus === 'processing' && state.currentStep && (
<div className="mt-4 text-[10px] text-muted-foreground text-center">
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -12,10 +12,18 @@ import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } f
interface Measurement {
name: string;
value: number;
value: number | null;
unit?: string;
tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
toleranceLabel?: string;
/** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */
baseline?: number;
status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
warningLabel?: string;
failLabel?: string;
}
interface ErrorStats {
@@ -31,7 +39,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 +51,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;
}
@@ -53,11 +67,19 @@ interface Props {
pluginId?: string;
pluginName?: string;
data: PluginResultLike;
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
displayMode?: 'panel' | 'maximized';
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
onViewOverlay?: (overlay: {
taskId: number;
resultId: number;
overlay: Overlay3D;
inputPointCloudFileIds?: number[];
inputCadFileIds?: number[];
}) => void;
}
function statusClass(s?: Measurement['status']) {
export function statusClass(s?: Measurement['status']) {
switch (s) {
case 'pass':
return 'bg-green-900/40 text-green-400';
@@ -70,46 +92,104 @@ function statusClass(s?: Measurement['status']) {
}
}
function statusLabel(s?: Measurement['status']) {
switch (s) {
export function statusLabel(m: Measurement) {
switch (m.status) {
case 'pass':
return '合格';
return m.passLabel ?? '合格';
case 'warning':
return '警告';
return m.warningLabel ?? '警告';
case 'fail':
return '超差';
return m.failLabel ?? '超差';
default:
return '—';
return '参考';
}
}
function formatValue(v: number): string {
export function isReferenceMeasurement(measurement: { status?: unknown }): boolean {
return measurement.status === 'info';
}
export function filterVisibleMeasurements<T extends { status?: unknown }>(measurements: readonly T[]): T[] {
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
}
export function formatValue(v: number | null): string {
if (v == null) return '—';
if (!Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
return v.toFixed(3);
}
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
if (overlay.kind === 'ply') {
const files = Array.isArray(overlay.files)
? overlay.files.filter((f): f is string => typeof f === 'string' && f.length > 0)
: undefined;
return files && files.length > 0
? { kind: 'ply', file: overlay.file, files }
: { kind: 'ply', file: overlay.file };
}
const legacyUrl = 'url' in overlay ? overlay.url : undefined;
const file = typeof overlay.file === 'string' && overlay.file.length > 0
? overlay.file
: typeof legacyUrl === 'string' && legacyUrl.length > 0
? legacyUrl
: undefined;
if (!file) return { kind: 'none' };
if (
typeof overlay.vertexCount === 'number' &&
typeof overlay.dispMin === 'number' &&
typeof overlay.dispMax === 'number'
) {
return {
kind: 'vertexColors',
file,
vertexCount: overlay.vertexCount,
dispMin: overlay.dispMin,
dispMax: overlay.dispMax,
};
}
return { kind: 'ply', file };
}
export default function GenericResultView({
taskId,
resultId,
pluginId,
pluginName,
data,
displayMode = 'panel',
onViewOverlay,
}: Props) {
const [showExtra, setShowExtra] = useState(false);
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const measurements = filterVisibleMeasurements(data.measurements ?? []);
const showMeasurementDetails = displayMode === 'maximized';
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>
)}
@@ -140,8 +220,8 @@ export default function GenericResultView({
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
<th className="text-right py-1 font-normal">结果</th>
</tr>
</thead>
@@ -151,15 +231,23 @@ export default function GenericResultView({
<td className="py-1 truncate max-w-[140px]" title={m.name}>
{m.name}
</td>
<td className="text-right font-mono">
{formatValue(m.value)} {m.unit ?? ''}
</td>
<td className="text-right text-muted-foreground">
{m.tolerance != null ? `±${m.tolerance}` : '—'}
</td>
{showMeasurementDetails && <td className="text-right font-mono">
{m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td>}
{showMeasurementDetails && <td className="text-right text-muted-foreground">
{m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
? m.baseline != null
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td>}
<td className="text-right">
<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 +261,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

@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from './ui/button';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from './ui/alert-dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { toast } from 'sonner';
type Props = { modelId: number };
const stateLabels = {
missing_pointcloud: '未配置点云',
missing_cad: '未配置 CAD',
ready_draft: '配置完整,未启用',
active: '已启用',
} as const;
type ReferenceCounts = {
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
};
function referenceReason(object: ReferenceCounts) {
return [
object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null,
object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null,
object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null,
].filter(Boolean).join('、');
}
export default function InspectionObjectConfig({ modelId }: Props) {
const utils = trpc.useUtils();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [pointcloudId, setPointcloudId] = useState<number | null>(null);
const [cadId, setCadId] = useState<number | null>(null);
const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open });
const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open });
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open });
const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]);
useEffect(() => {
if (!selected && objects.length > 0) setSelectedId(objects[0].id);
}, [objects, selected]);
useEffect(() => {
setPointcloudId(selected?.defaultModelPointcloudId ?? null);
setCadId(selected?.defaultModelCadFileId ?? null);
}, [selected]);
const invalidate = async () => {
await Promise.all([
utils.inspectionObject.list.invalidate({ modelId }),
utils.modelPointcloudFile.list.invalidate({ modelId }),
utils.cadFile.list.invalidate({ modelId }),
]);
};
const create = trpc.inspectionObject.create.useMutation({
onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); },
onError: error => toast.error(error.message),
});
const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({
onSuccess: async (_file, variables) => {
await invalidate();
const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name;
toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新');
},
onError: error => toast.error(error.message),
});
const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({
onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); },
onError: error => toast.error(error.message),
});
const configure = trpc.inspectionObject.configureDefaults.useMutation({
onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); },
onError: error => toast.error(error.message),
});
const setStatus = trpc.inspectionObject.setStatus.useMutation({
onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); },
onError: error => toast.error(error.message),
});
const deleteObject = trpc.inspectionObject.delete.useMutation({
onSuccess: async () => {
setSelectedId(null);
await invalidate();
toast.success('检测对象已删除');
},
onError: error => toast.error(error.message),
});
const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available');
const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available');
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="industrial-btn flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-0.5 text-[10px]"><Settings2 className="h-3 w-3" /> 检测对象配置</button>
</DialogTrigger>
<DialogContent className="flex max-h-[92vh] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-4 sm:max-w-4xl sm:p-6">
<DialogHeader className="shrink-0">
<DialogTitle>检测对象配置</DialogTitle>
<DialogDescription>资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。</DialogDescription>
</DialogHeader>
<Tabs defaultValue="objects" className="min-h-0 flex-1 overflow-hidden">
<TabsList className="grid w-full shrink-0 grid-cols-2 sm:w-80">
<TabsTrigger value="objects">对象配置</TabsTrigger>
<TabsTrigger value="resources">资源归属</TabsTrigger>
</TabsList>
<TabsContent value="objects" className="min-h-0 overflow-y-auto pr-1">
<div className="grid min-w-0 gap-4 md:grid-cols-[15rem_minmax(0,1fr)]">
<aside className="min-w-0 space-y-3 rounded-lg border bg-secondary/20 p-3">
<div className="flex min-w-0 gap-1">
<input aria-label="新检测对象名称" className="min-w-0 flex-1 rounded border bg-background px-2 py-1.5 text-xs" placeholder="如:头车" value={name} onChange={event => setName(event.target.value)} />
<Button aria-label="创建检测对象" size="icon" className="h-7 w-7 shrink-0" disabled={!name.trim() || create.isPending} onClick={() => create.mutate({ modelId, name: name.trim() })}><Plus className="h-3.5 w-3.5" /></Button>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto md:max-h-[25rem]">
{objects.map(object => <button key={object.id} onClick={() => setSelectedId(object.id)} className={`w-full rounded border px-2 py-2 text-left text-xs ${selectedId === object.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-secondary'}`}>
<span className="flex items-center justify-between gap-2"><strong className="min-w-0 truncate" title={object.name}>{object.name}</strong>{object.configurationState === 'active' ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}</span>
<span className="mt-1 block text-[10px] text-muted-foreground">{stateLabels[object.configurationState]}</span>
</button>)}
{objects.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">尚未创建检测对象</p>}
</div>
</aside>
{selected ? <main className="min-w-0 space-y-4">
<section className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0"><h3 className="truncate text-sm font-medium" title={selected.name}>{selected.name}</h3><p className="text-xs text-muted-foreground">{stateLabels[selected.configurationState]}</p></div>
<span className={`rounded-full px-2 py-1 text-[10px] ${selected.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-amber-500/15 text-amber-400'}`}>{selected.status === 'active' ? '已启用' : '草稿'}</span>
</div>
<div><h3 className="text-sm font-medium">默认输入方案</h3><p className="text-xs text-muted-foreground">新建“{selected.name}”任务时自动关联并勾选以下资源。</p></div>
<label className="grid min-w-0 gap-1 text-xs"><span>预置点云</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={pointcloudId ?? ''} onChange={event => setPointcloudId(Number(event.target.value) || null)}><option value="">请选择</option>{compatiblePointclouds.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
<label className="grid min-w-0 gap-1 text-xs"><span>默认 CAD</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={cadId ?? ''} onChange={event => setCadId(Number(event.target.value) || null)}><option value="">请选择</option>{compatibleCadFiles.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
<div className="flex flex-wrap gap-2"><Button size="sm" disabled={!pointcloudId || !cadId || configure.isPending} onClick={() => configure.mutate({ id: selected.id, defaultModelPointcloudId: pointcloudId!, defaultModelCadFileId: cadId!, active: true })}>保存并启用</Button><Button size="sm" variant="outline" disabled={selected.status === 'draft' || setStatus.isPending} onClick={() => setStatus.mutate({ id: selected.id, status: 'draft' })}>停用</Button></div>
</section>
<section className="space-y-2 rounded-lg border border-destructive/30 p-4">
<div><h3 className="text-sm font-medium text-destructive">删除检测对象</h3><p className="text-xs text-muted-foreground">仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。</p></div>
{selected.canDelete ? <AlertDialog>
<AlertDialogTrigger asChild><Button size="sm" variant="destructive"><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>确认删除“{selected.name}”?</AlertDialogTitle><AlertDialogDescription>删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90" disabled={deleteObject.isPending} onClick={() => deleteObject.mutate({ id: selected.id })}>确认删除</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> : <div className="space-y-1"><Button size="sm" variant="destructive" disabled><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button><p className="text-xs text-amber-500">需先处理:{referenceReason(selected)}。</p></div>}
</section>
</main> : <div className="grid min-h-48 place-items-center rounded-lg border border-dashed text-sm text-muted-foreground">请先创建检测对象</div>}
</div>
</TabsContent>
<TabsContent value="resources" className="min-h-0 overflow-y-auto pr-1">
<section className="min-w-0 space-y-3">
<div><h3 className="text-sm font-medium">车型级资源归属</h3><p className="text-xs text-muted-foreground">每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。</p></div>
{objects.length === 0 && <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">请先在“对象配置”中创建检测对象</div>}
{objects.length > 0 && <div className="grid min-w-0 gap-3 lg:grid-cols-2">
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库点云归属</h4><div className="max-h-[50vh] space-y-1 overflow-y-auto overflow-x-hidden">
{pointclouds.map(file => <div key={file.id} className="flex min-w-0 flex-col gap-1 rounded px-1.5 py-1.5 text-xs hover:bg-secondary sm:flex-row sm:items-center sm:gap-2"><span className="min-w-0 flex-1 truncate" title={file.name}>{file.name}</span><select className={`min-w-0 rounded border bg-background px-1.5 py-1 text-[10px] outline-none focus:border-primary sm:w-32 sm:shrink-0 ${file.inspectionObjectId == null ? 'border-amber-500/50 text-amber-400' : 'border-border'}`} aria-label={`${file.name} 的归属检测对象`} value={file.inspectionObjectId ?? ''} disabled={assignPointcloud.isPending} onChange={event => { const inspectionObjectId = Number(event.target.value); if (inspectionObjectId && inspectionObjectId !== file.inspectionObjectId) assignPointcloud.mutate({ id: file.id, inspectionObjectId }); }}><option value="" disabled>未分类</option>{objects.map(object => <option key={object.id} value={object.id}>{object.name}</option>)}</select></div>)}
{pointclouds.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无点云</p>}
</div></div>
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库 CAD · 适用对象</h4><div className="max-h-[50vh] space-y-2 overflow-y-auto overflow-x-hidden">
{cadFiles.map(file => <fieldset key={file.id} className="min-w-0 rounded border p-2"><legend className="max-w-full truncate px-1 text-xs font-medium" title={file.name}>{file.name}</legend><div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return <label key={object.id} className="flex min-w-0 items-center gap-1.5 text-xs"><input type="checkbox" checked={checked} disabled={setCadObjects.isPending} onChange={() => { const next = checked ? file.inspectionObjectIds.filter(id => id !== object.id) : [...file.inspectionObjectIds, object.id]; if (next.length === 0) return toast.error('CAD 至少需要一个适用检测对象'); setCadObjects.mutate({ id: file.id, inspectionObjectIds: next }); }} /><span className="max-w-32 truncate" title={object.name}>{object.name}</span></label>; })}</div></fieldset>)}
{cadFiles.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无 CAD</p>}
</div></div>
</div>}
</section>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>;
}

View File

@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
function getHeatmapLegendBounds(
points: Point[],
mode: 'points' | 'smoothHeatmap',
): { min: number; max: number; midTick: string } {
): { min: number; max: number; midTick: string } | null {
if (mode === 'smoothHeatmap') {
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
if (pointsWithError.length === 0) return null;
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
const midTick =
minError < 0 && maxError > 0
? '0'
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
let minE = Infinity;
let maxE = -Infinity;
for (let i = 0; i < points.length; i++) {
const e = points[i].error ?? 0;
const e = points[i].error;
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
if (e < minE) minE = e;
if (e > maxE) maxE = e;
}
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
if (!(maxE > minE)) {
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
}
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
const positions = new Float32Array(data.points.length * 3);
const colors = new Float32Array(data.points.length * 3);
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
const hasErrorValues = pointsWithError.length > 0;
let minError: number;
let maxError: number;
if (errorRangeMode === 'percentile') {
const r = displayErrorRangeSmoothHeatmap(data.points);
if (errorRangeMode === 'percentile' && hasErrorValues) {
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
minError = r.minError;
maxError = r.maxError;
} else {
minError = Infinity;
maxError = -Infinity;
for (let i = 0; i < data.points.length; i++) {
const err = data.points[i].error ?? 0;
const err = data.points[i].error;
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
if (err < minError) minError = err;
if (err > maxError) maxError = err;
}
@@ -415,8 +422,13 @@ const PointCloudMesh = memo(function PointCloudMesh({
positions[i3 + 1] = point.y;
positions[i3 + 2] = point.z;
if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError);
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 && hasErrorValues && Number.isFinite(point.error)) {
const color = errorToColor(point.error!, minError, maxError);
colors[i3] = color.r;
colors[i3 + 1] = color.g;
colors[i3 + 2] = color.b;
@@ -1018,7 +1030,7 @@ const CADModel = memo(function CADModel({
}
}, [useVertexColors, geometry]);
if (!visible && !useVertexColors) return null;
if (!visible) return null;
if (!geometry && !loading) return null;
@@ -1160,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
);
});
// 一键全流程演示扫描动画
interface DemoFlowScannerProps {
active: boolean;
}
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
const ringRef = useRef<THREE.Mesh>(null);
const sweepRef = useRef<THREE.Mesh>(null);
const particlesRef = useRef<THREE.Points>(null);
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
const { positions, colors } = useMemo(() => {
const count = 120;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
positions[i * 3] = Math.cos(angle) * radius;
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
positions[i * 3 + 2] = Math.sin(angle) * radius;
colors[i * 3] = 0.2;
colors[i * 3 + 1] = 0.75;
colors[i * 3 + 2] = 1.0;
}
return { positions, colors };
}, []);
useFrame((state) => {
if (!active) return;
const t = state.clock.elapsedTime;
if (ringRef.current) {
ringRef.current.rotation.z = -t * 0.3;
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
}
if (sweepRef.current) {
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
}
if (particlesRef.current) {
particlesRef.current.rotation.y = t * 0.15;
}
});
if (!active) return null;
return (
<group>
{/* 旋转圆环 */}
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<ringGeometry args={[3.5, 3.7, 96]} />
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
{/* 扫描平面 */}
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[7.6, 7.6, 48, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
{/* 粒子环 */}
<points ref={particlesRef}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
</points>
</group>
);
});
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
@@ -1173,6 +1261,7 @@ interface SceneContentProps {
pointCloudData?: PointCloudData | null;
pointSize: number;
showHeatmap: boolean;
showPointCloud: boolean;
pointCloudRenderMode: PointCloudRenderMode;
enablePointPicking: boolean;
showCADModel: boolean;
@@ -1196,12 +1285,15 @@ interface SceneContentProps {
/** ROI 坐标拾取模式 */
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:展示扫描动画 */
demoFlowActive?: boolean;
}
const SceneContent = memo(function SceneContent({
pointCloudData,
pointSize,
showHeatmap,
showPointCloud,
pointCloudRenderMode,
enablePointPicking,
showCADModel,
@@ -1221,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
plyOverlayData = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: SceneContentProps) {
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
@@ -1248,9 +1341,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 +1386,7 @@ const SceneContent = memo(function SceneContent({
{!lightweightScene && <AmbientParticles />}
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
{pointCloudData && pointCloudRenderMode === 'points' && (
{pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
<PointCloudMesh
data={pointCloudData}
pointSize={pointSize}
@@ -1300,6 +1399,7 @@ const SceneContent = memo(function SceneContent({
{pointCloudData &&
pointCloudRenderMode === 'smoothHeatmap' &&
cadSurfaceGeometry &&
showCADModel &&
!cadHeatmapSuppressClient &&
!useDirectColoring && (
<CadSurfaceHeatmapMesh
@@ -1309,34 +1409,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}
/>
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
/>
{!cadSurfaceGeometry && (
<SmoothHeatmapMesh
data={pointCloudData}
showHeatmap={showHeatmap}
showWireframe={showWireframe}
/>
)}
</>
)}
@@ -1375,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
{/* Grid */}
<IndustrialGrid />
{/* 一键全流程演示扫描动画 */}
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
{/* PLY 叠加层(kind=ply 插件结果) */}
{plyOverlayData && (
<points>
@@ -1389,7 +1481,7 @@ const SceneContent = memo(function SceneContent({
/>
</bufferGeometry>
<pointsMaterial
size={0.04}
size={pointSize ?? 0.03}
vertexColors
sizeAttenuation
transparent
@@ -1403,6 +1495,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 &&
@@ -1414,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
prevProps.dispRange === nextProps.dispRange &&
prevProps.plyOverlayData === nextProps.plyOverlayData &&
prevProps.roiPickMode === nextProps.roiPickMode;
prevProps.roiPickMode === nextProps.roiPickMode &&
prevProps.demoFlowActive === nextProps.demoFlowActive;
});
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
@@ -1529,11 +1623,14 @@ async function fetchBatchSampled(
} catch { /* ignore */ }
}
// 接收二进制,直接解析为 Float32Array,无 JSON 开销
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
const hasColor = resp.headers.get('X-Has-Color') === '1';
const hasError = resp.headers.get('X-Has-Error') === '1';
const stride = hasColor ? 7 : 4;
const arrayBuffer = await resp.arrayBuffer();
const 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 +1640,16 @@ 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 };
if (hasError && Number.isFinite(error)) pt.error = error;
if (hasColor) {
pt.r = floats[base + 4];
pt.g = floats[base + 5];
pt.b = floats[base + 6];
}
pts[i] = pt;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
@@ -1565,6 +1669,7 @@ interface PointCloudViewerProps {
*/
loadKey?: number;
showHeatmap?: boolean;
showPointCloud?: boolean;
showCADModel?: boolean;
showWireframe?: boolean;
pointSize?: number;
@@ -1601,7 +1706,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 视图中点击任意点,
@@ -1609,6 +1714,8 @@ interface PointCloudViewerProps {
*/
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
demoFlowActive?: boolean;
}
export default function PointCloudViewer({
@@ -1617,6 +1724,7 @@ export default function PointCloudViewer({
batchTaskId,
loadKey,
showHeatmap = true,
showPointCloud = true,
showCADModel = true,
showWireframe = false,
pointSize = 0.03,
@@ -1637,6 +1745,7 @@ export default function PointCloudViewer({
pluginOverlay = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: PointCloudViewerProps) {
// 点云数据状态
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
@@ -1700,6 +1809,9 @@ export default function PointCloudViewer({
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
const 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 +1865,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 +2059,22 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null;
if (pointCloudRenderMode !== 'smoothHeatmap') 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 +2128,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 +2196,7 @@ export default function PointCloudViewer({
pointCloudData={pointCloudData}
pointSize={pointSize}
showHeatmap={showHeatmap}
showPointCloud={showPointCloud}
pointCloudRenderMode={pointCloudRenderMode}
enablePointPicking={enableErrorPicking}
showCADModel={showCADModel}
@@ -2008,6 +2216,7 @@ export default function PointCloudViewer({
plyOverlayData={plyOverlayData}
roiPickMode={roiPickMode}
onRoiPointPicked={onRoiPointPicked}
demoFlowActive={demoFlowActive}
/>
</Canvas>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import {
Check,
X,
} from 'lucide-react';
import { useAuth } from '@/_core/hooks/useAuth';
export type TreeSelection = {
seriesId: number | null;
@@ -25,9 +26,11 @@ interface Props {
function EditableText({
value,
onSave,
canEdit,
}: {
value: string;
onSave: (v: string) => void;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -35,7 +38,7 @@ function EditableText({
return (
<span
className="flex-1 cursor-pointer hover:text-foreground truncate"
onDoubleClick={() => { setDraft(value); setEditing(true); }}
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
>
{value}
</span>
@@ -60,6 +63,7 @@ function EditableText({
}
export default function VehicleTree({ selection, onSelect }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
const [newSeriesName, setNewSeriesName] = useState('');
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<div className="industrial-panel p-2 space-y-1">
<div className="flex items-center justify-between mb-2">
<span className="industrial-label">车系 / 车型 / 任务</span>
<button
{canWrite && <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => setAddingSeries(true)}
>
<Plus className="w-3 h-3" /> 车系
</button>
</button>}
</div>
{addingSeries && (
{canWrite && addingSeries && (
<div className="flex items-center gap-1 pl-1">
<input
autoFocus
@@ -176,9 +180,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
<EditableText
value={series.name}
canEdit={canWrite}
onSave={name => updateSeries.mutate({ id: series.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary"
title="新增车型"
@@ -196,11 +201,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
{/* 新增车型输入 */}
{addingModelFor === series.id && (
{canWrite && addingModelFor === series.id && (
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
<input
autoFocus
@@ -258,9 +263,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
<EditableText
value={model.name}
canEdit={canWrite}
onSave={name => updateModel.mutate({ id: model.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除车型"
@@ -271,7 +277,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
</div>
@@ -283,7 +289,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
{seriesList.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">
暂无车系,点击右上角新增
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
</div>
)}
</div>

View File

@@ -1,111 +0,0 @@
import { type ElementType } from 'react';
import { CheckCircle2, Loader2, FolderOpen, Cloud, Box, BarChart3, Save } from 'lucide-react';
export type WorkflowStepId = 'cad' | 'pointcloud' | 'render' | 'analysis' | 'save';
export type StepStatus = 'done' | 'active' | 'pending';
export interface WorkflowState {
cad: StepStatus;
pointcloud: StepStatus;
render: StepStatus;
analysis: StepStatus;
save: StepStatus;
}
interface StepDef {
id: WorkflowStepId;
label: string;
icon: ElementType;
}
const STEPS: StepDef[] = [
{ id: 'cad', label: 'CAD 模型', icon: Box },
{ id: 'pointcloud', label: '点云采集', icon: Cloud },
{ id: 'render', label: '渲染完成', icon: FolderOpen },
{ id: 'analysis', label: '分析完成', icon: BarChart3 },
{ id: 'save', label: '保存完成', icon: Save },
];
function StepNode({
step,
status,
onClick,
}: {
step: StepDef;
status: StepStatus;
onClick?: () => void;
}) {
const Icon = step.icon;
const circleClass =
status === 'done'
? 'bg-[oklch(0.55_0.18_145)] border-[oklch(0.55_0.18_145)] text-white'
: status === 'active'
? 'bg-primary/20 border-primary text-primary'
: 'bg-transparent border-border text-muted-foreground/40';
const labelClass =
status === 'done'
? 'text-[oklch(0.75_0.14_145)]'
: status === 'active'
? 'text-primary'
: 'text-muted-foreground/40';
return (
<button
onClick={onClick}
disabled={!onClick || status === 'pending'}
className={`flex flex-col items-center gap-1 transition-opacity
${status === 'pending' ? 'cursor-default' : 'cursor-pointer hover:opacity-80'}`}
>
<div className={`w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all ${circleClass}`}>
{status === 'done' ? (
<CheckCircle2 className="w-4 h-4" />
) : status === 'active' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<span className={`text-[10px] leading-tight whitespace-nowrap transition-colors ${labelClass}`}>
{step.label}
</span>
</button>
);
}
function Connector({ done }: { done: boolean }) {
return (
<div className="flex-1 flex items-center pb-4 px-1 min-w-[24px]">
<div className={`h-px w-full transition-colors duration-500 ${done ? 'bg-[oklch(0.55_0.18_145)]' : 'bg-border'}`} />
</div>
);
}
interface Props {
state: WorkflowState;
onStepClick?: (id: WorkflowStepId) => void;
}
export default function WorkflowProgress({ state, onStepClick }: Props) {
return (
<div className="h-14 border-b border-border bg-card/60 flex items-center px-6 flex-shrink-0">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider mr-4 flex-shrink-0">
工作流程
</span>
<div className="flex items-center flex-1 min-w-0">
{STEPS.map((step, i) => (
<div key={step.id} className="flex items-center flex-1 min-w-0">
<StepNode
step={step}
status={state[step.id]}
onClick={onStepClick ? () => onStepClick(step.id) : undefined}
/>
{i < STEPS.length - 1 && <Connector done={state[step.id] === 'done'} />}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
}
const nameOf = (item: DemoFlowSourceResult) => `算法${item.id}`;
describe('aggregateDemoFlowResults', () => {
it('按全流程顺序合并测量项,并保留来源和算法内顺序', () => {
const model = aggregateDemoFlowResults([
result(20, 2, 'completed', { measurements: [{ name: 'B1', value: 2 }, { name: 'B2', value: 3 }] }),
result(10, 1, 'completed', { measurements: [{ name: 'A1', value: 1 }] }),
], nameOf);
expect(model.measurementRows.map((row) => [row.measurement.name, row.resultId, row.flowIndex, row.measurementIndex])).toEqual([
['A1', 10, 1, 0],
['B1', 20, 2, 0],
['B2', 20, 2, 1],
]);
});
it('原样保留空值、公差文本、基准长度和自定义结论文案,并忽略空测量', () => {
const measurement = {
name: '斜率', value: null, unit: 'mm', tolerance: 0.5, toleranceLabel: '+8.5/-3', baseline: 1000,
status: 'warning' as const, passLabel: '达标', warningLabel: '复核', failLabel: '未达标',
};
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', { measurements: [] }),
result(20, 2, 'completed', { measurements: [measurement] }),
], nameOf);
expect(model.measurementRows).toHaveLength(1);
expect(model.measurementRows[0].measurement).toEqual(measurement);
});
it('默认不生成算法分组行,开启后按算法生成分组', () => {
const inputs = [
result(10, 1, 'completed', { measurements: [{ name: 'A', value: 1 }] }),
result(20, 2, 'completed', { measurements: [{ name: 'B', value: 2 }] }),
];
expect(aggregateDemoFlowResults(inputs, nameOf).tableRows.every((row) => row.kind === 'measurement')).toBe(true);
expect(aggregateDemoFlowResults(inputs, nameOf, { includeAlgorithmGroups: true }).tableRows.map((row) => row.kind)).toEqual([
'algorithm', 'measurement', 'algorithm', 'measurement',
]);
});
it('过滤参考项,同时保留其他状态和未设置状态的测量项', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '参考项', value: 0.1, status: 'info' },
{ name: '合格项', value: 1, status: 'pass' },
{ name: '警告项', value: 2, status: 'warning' },
{ name: '失败项', value: 3, status: 'fail' },
{ name: '无状态项', value: 4 },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
'合格项', '警告项', '失败项', '无状态项',
]);
expect(model.tableRows).toHaveLength(4);
});
it('过滤全流程指定的中间指标,同时保持其他有效测量项的原顺序', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '车体长度', value: 100, status: 'pass' },
{ name: '前后端平移差', value: 0.1, status: 'pass' },
{ name: '车体宽度', value: 20, status: 'warning' },
{ name: 'ICP fitness', value: 0.98, status: 'pass' },
{ name: '配准 RMSE', value: 0.02, status: 'pass' },
{ name: '车体高度', value: 30, status: 'fail' },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
'车体长度', '车体宽度', '车体高度',
]);
});
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
expect(demoFlowStatusLabel({ status: 'fail' })).toBe('超差');
expect(demoFlowStatusLabel({ status: 'info' })).toBe('参考');
expect(demoFlowStatusLabel({})).toBe('参考');
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '通过项', value: 1, status: 'pass', passLabel: '通过' },
{ name: '警告项', value: 2, status: 'warning', warningLabel: '部分成功' },
{ name: '失败项', value: 3, status: 'fail', failLabel: '失败' },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => demoFlowStatusLabel(row.measurement))).toEqual([
'合格', '警告', '超差',
]);
});
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '配准误差 p95', value: 0.1, status: 'info' },
{ name: '配准误差均值', value: 0.05, status: 'info' },
],
}),
], nameOf, { includeAlgorithmGroups: true });
expect(model.measurementRows).toEqual([]);
expect(model.tableRows).toEqual([]);
});
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
}),
result(20, 2, 'completed', {
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
}),
], nameOf);
expect(model.artifactGroups).toMatchObject([
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
]);
});
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
result(30, 3, 'pending', { stage: '等待资源' }),
result(40, 4, 'error', { error: '输入无效' }),
result(50, 5, 'cancelled', {}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
expect(model.statusNotices).toMatchObject([
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
{ resultId: 30, status: 'pending', detail: '等待资源' },
{ resultId: 40, status: 'error', detail: '输入无效' },
{ resultId: 50, status: 'cancelled' },
]);
});
});

View File

@@ -0,0 +1,191 @@
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
'前后端平移差',
'ICP fitness',
'配准 RMSE',
]);
export type DemoFlowSourceResult = {
id: number;
type: string;
pluginId?: string | null;
status: string;
resultData: unknown;
};
export type DemoFlowMeasurementRow = {
kind: 'measurement';
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
measurementIndex: number;
measurement: Measurement;
};
export type DemoFlowMeasurementGroupRow = {
kind: 'algorithm';
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
};
export type DemoFlowTableRow = DemoFlowMeasurementRow | DemoFlowMeasurementGroupRow;
export type DemoFlowArtifactGroup = {
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
files: { label: string; path: string }[];
images: string[];
overlay: PluginResultLike['overlay3d'];
inputPointCloudFileIds: unknown;
inputCadFileIds: unknown;
extra?: Record<string, unknown>;
};
export type DemoFlowStatusNotice = {
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
status: string;
progress?: number;
detail?: string;
};
export type DemoFlowAggregation = {
tableRows: DemoFlowTableRow[];
measurementRows: DemoFlowMeasurementRow[];
artifactGroups: DemoFlowArtifactGroup[];
statusNotices: DemoFlowStatusNotice[];
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function flowIndex(result: DemoFlowSourceResult, fallback: number) {
return finiteNumber(asRecord(result.resultData)?.demoFlowIndex) ?? fallback;
}
function normalizeFiles(value: unknown): { label: string; path: string }[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (typeof item === 'string' && item.length > 0) return [{ label: item, path: item }];
const record = asRecord(item);
if (!record) return [];
const path = typeof record.path === 'string'
? record.path
: typeof record.name === 'string'
? record.name
: undefined;
if (!path) return [];
const label = typeof record.name === 'string' ? record.name : path;
return [{ label, path }];
});
}
function normalizeImages(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
function hasOverlay(value: PluginResultLike['overlay3d']) {
return Boolean(value && value.kind !== 'none');
}
export function filterDemoFlowMeasurements(measurements: readonly Measurement[]): Measurement[] {
return filterVisibleMeasurements(measurements)
.filter((measurement) => !DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES.has(measurement.name));
}
export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): string {
switch (measurement.status) {
case 'pass':
return '合格';
case 'warning':
return '警告';
case 'fail':
return '超差';
default:
return '参考';
}
}
export function aggregateDemoFlowResults(
results: DemoFlowSourceResult[],
getAlgorithmName: (result: DemoFlowSourceResult) => string,
options: { includeAlgorithmGroups?: boolean } = {},
): DemoFlowAggregation {
const sorted = results
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
.sort((a, b) => a.index - b.index || a.originalIndex - b.originalIndex);
const measurementRows: DemoFlowMeasurementRow[] = [];
const tableRows: DemoFlowTableRow[] = [];
const artifactGroups: DemoFlowArtifactGroup[] = [];
const statusNotices: DemoFlowStatusNotice[] = [];
for (const { result, index } of sorted) {
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
const algorithmName = getAlgorithmName(result);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
if (measurements.length > 0 && options.includeAlgorithmGroups) {
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
}
measurements.forEach((measurement, measurementIndex) => {
const row: DemoFlowMeasurementRow = {
kind: 'measurement',
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
measurementIndex,
measurement,
};
measurementRows.push(row);
tableRows.push(row);
});
const files = normalizeFiles(data.files);
const images = normalizeImages(data.images);
const extra = asRecord(data.extra) ?? undefined;
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
artifactGroups.push({
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
files,
images,
overlay: data.overlay3d,
inputPointCloudFileIds: data.inputPointCloudFileIds,
inputCadFileIds: data.inputCadFileIds,
extra,
});
}
if (result.status !== 'completed') {
const progress = finiteNumber(data.progress);
const detail = result.status === 'error'
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
: typeof data.stage === 'string'
? data.stage
: typeof data.error === 'string'
? data.error
: undefined;
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
}
}
return { tableRows, measurementRows, artifactGroups, statusNotices };
}

View File

@@ -1,23 +1,2 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
return '/';
}
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId || '');
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
};
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
export const getLoginUrl = () => "/login";

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
aggregateDemoFlowState,
formatElapsedTime,
type AnalysisResultRow,
} from './useDemoFlowState';
const STARTED_AT = '2026-07-24T00:00:10.000Z';
function result(
overrides: Partial<AnalysisResultRow> & Pick<AnalysisResultRow, 'id' | 'status'>,
): AnalysisResultRow {
return {
taskId: 1,
type: 'plugin',
pluginId: 'vehicle-registration',
createdAt: STARTED_AT,
updatedAt: STARTED_AT,
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 2,
demoFlowSourcePointCloudFileId: 42,
progress: 0.7,
},
...overrides,
};
}
describe('aggregateDemoFlowState', () => {
it('按源点云总点数和整体进度估算已完成点云量', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[{ id: 42, metadataStatus: 'ready', pointCount: 1_000_000_000 }],
new Date('2026-07-24T00:00:20.000Z').getTime(),
);
expect(state.overallProgress).toBe(35);
expect(state.completedPointCount).toBe(350_000_000);
});
it('元数据缺失时不生成虚假的点云数量', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[{ id: 42, metadataStatus: 'pending', pointCount: null }],
new Date('2026-07-24T00:00:20.000Z').getTime(),
);
expect(state.sourcePointCount).toBeNull();
expect(state.completedPointCount).toBeNull();
});
it('运行中使用当前时间计时,结束后冻结在最后更新时间', () => {
const running = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[],
new Date('2026-07-24T00:00:40.000Z').getTime(),
);
const completed = aggregateDemoFlowState(
1,
[result({
id: 1,
status: 'completed',
updatedAt: '2026-07-24T00:00:25.000Z',
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 1,
demoFlowSourcePointCloudFileId: 42,
progress: 1,
},
})],
[],
new Date('2026-07-24T00:01:40.000Z').getTime(),
);
expect(running.elapsedMs).toBe(30_000);
expect(completed.elapsedMs).toBe(15_000);
expect(completed.endedAt).toBe(new Date('2026-07-24T00:00:25.000Z').getTime());
});
it('配准失败后把未创建步骤标为停止,不再聚合成排队中', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'error', updatedAt: '2026-07-24T00:00:25.000Z' })],
[],
new Date('2026-07-24T00:00:40.000Z').getTime(),
);
expect(state.active).toBe(false);
expect(state.overallStatus).toBe('error');
expect(state.steps[1]?.status).toBe('cancelled');
});
it('停止标记可让已完成配准后的占位步骤和计时正确冻结', () => {
const cancelledAt = '2026-07-24T00:00:28.000Z';
const state = aggregateDemoFlowState(
1,
[result({
id: 1,
status: 'completed',
updatedAt: cancelledAt,
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 2,
demoFlowSourcePointCloudFileId: 42,
progress: 1,
demoFlowCancelledAt: cancelledAt,
},
})],
[],
new Date('2026-07-24T00:01:40.000Z').getTime(),
);
expect(state.overallStatus).toBe('cancelled');
expect(state.steps[1]?.status).toBe('cancelled');
expect(state.elapsedMs).toBe(18_000);
});
});
describe('formatElapsedTime', () => {
it('小时超过 24 后继续累计', () => {
expect(formatElapsedTime((27 * 3600 + 2 * 60 + 3) * 1000)).toBe('27:02:03');
});
});

View File

@@ -0,0 +1,273 @@
import { useEffect, useMemo, useState } from 'react';
import { trpc } from '@/lib/trpc';
export type AnalysisResultRow = {
id: number;
taskId?: number | null;
type: string;
pluginId?: string | null;
status: string;
createdAt: string | Date;
updatedAt: string | Date;
resultData: unknown;
};
export type PointcloudFileRow = {
id: number;
metadataStatus?: string | null;
pointCount?: number | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function numberFromUnknown(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function timestampFromUnknown(value: unknown): number | null {
if (typeof value !== 'string' && !(value instanceof Date)) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
export function formatElapsedTime(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':');
}
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
export interface DemoFlowStep {
id: number;
index: number;
role: 'registration' | 'measurement';
pluginId: string | null;
status: DemoFlowStepStatus;
progress?: number;
createdAt: string | Date;
updatedAt: string | Date;
}
export interface DemoFlowState {
active: boolean;
runId: string | null;
total: number;
completed: number;
steps: DemoFlowStep[];
overallStatus: DemoFlowStepStatus | 'idle';
currentStep: DemoFlowStep | null;
overallProgress: number;
sourcePointCloudFileId: number | null;
sourcePointCount: number | null;
completedPointCount: number | null;
startedAt: number | null;
endedAt: number | null;
elapsedMs: number;
}
const EMPTY_STATE: DemoFlowState = {
active: false,
runId: null,
total: 0,
completed: 0,
steps: [],
overallStatus: 'idle',
currentStep: null,
overallProgress: 0,
sourcePointCloudFileId: null,
sourcePointCount: null,
completedPointCount: null,
startedAt: null,
endedAt: null,
elapsedMs: 0,
};
function isActiveStatus(step: DemoFlowStep): boolean {
return step.status === 'processing' || step.status === 'pending';
}
export function aggregateDemoFlowState(
taskId: number | null,
results: AnalysisResultRow[],
pointcloudFiles: PointcloudFileRow[],
nowMs: number,
): DemoFlowState {
if (taskId == null || results.length === 0) return EMPTY_STATE;
const steps: DemoFlowStep[] = [];
let activeRunId: string | null = null;
let declaredTotal = 0;
let sourcePointCloudFileId: number | null = null;
let cancelledAt: number | null = null;
for (const r of results) {
const data = asRecord(r.resultData);
if (!data) continue;
const runId = data.demoFlowRunId;
if (typeof runId !== 'string' || runId.length === 0) continue;
if (activeRunId == null) {
activeRunId = runId;
} else if (activeRunId !== runId) {
// 分析结果按 createdAt 倒序返回,只聚合最新的一次全流程。
continue;
}
const index = numberFromUnknown(data.demoFlowIndex) ?? steps.length + 1;
const roleRaw = data.demoFlowRole;
const role = roleRaw === 'registration' ? 'registration' : 'measurement';
const progress = numberFromUnknown(data.progress);
const stepTotal = numberFromUnknown(data.demoFlowTotal) ?? 0;
const sourceId = numberFromUnknown(data.demoFlowSourcePointCloudFileId);
const rowCancelledAt = timestampFromUnknown(data.demoFlowCancelledAt);
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
if (sourcePointCloudFileId == null && sourceId != null) sourcePointCloudFileId = sourceId;
if (rowCancelledAt != null && (cancelledAt == null || rowCancelledAt > cancelledAt)) {
cancelledAt = rowCancelledAt;
}
steps.push({
id: r.id,
index,
role,
pluginId: r.pluginId ?? null,
status: r.status as DemoFlowStepStatus,
progress,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
});
}
if (steps.length === 0 || activeRunId == null) return EMPTY_STATE;
steps.sort((a, b) => a.index - b.index);
const actualSteps = [...steps];
const total = Math.max(actualSteps.length, declaredTotal);
const hasActiveActualStep = actualSteps.some(isActiveStatus);
const hasError = actualSteps.some((step) => step.status === 'error');
const hasCancelled = cancelledAt != null || actualSteps.some((step) => step.status === 'cancelled');
const terminalStatus: 'error' | 'cancelled' | null = !hasActiveActualStep
? hasError
? 'error'
: hasCancelled
? 'cancelled'
: null
: null;
// 链路已失败或停止时,尚未创建的步骤不会再执行,不能继续显示为“排队中”。
for (let index = actualSteps.length + 1; index <= total; index += 1) {
steps.push({
id: -index,
index,
role: index === 1 ? 'registration' : 'measurement',
pluginId: null,
status: terminalStatus == null ? 'pending' : 'cancelled',
createdAt: steps[0]?.createdAt ?? new Date(nowMs),
updatedAt: steps[0]?.updatedAt ?? new Date(nowMs),
});
}
const completed = steps.filter((step) => step.status === 'completed').length;
let overallStatus: DemoFlowState['overallStatus'];
if (actualSteps.some((step) => step.status === 'processing')) overallStatus = 'processing';
else if (actualSteps.some((step) => step.status === 'pending')) overallStatus = 'pending';
else if (terminalStatus != null) overallStatus = terminalStatus;
else if (actualSteps.length === total && actualSteps.every((step) => step.status === 'completed')) {
overallStatus = 'completed';
} else {
overallStatus = 'pending';
}
const active = overallStatus === 'processing' || overallStatus === 'pending';
const currentStep =
steps.find((step) => step.status === 'processing') ??
steps.find((step) => step.status === 'pending') ??
actualSteps[actualSteps.length - 1] ??
null;
const doneProgress = total > 0 ? completed / total : 0;
const currentProgress = currentStep && isActiveStatus(currentStep) && total > 0
? Math.min(1, Math.max(0, currentStep.progress ?? 0)) / total
: 0;
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
const sourcePointcloud = sourcePointCloudFileId == null
? undefined
: pointcloudFiles.find((file) => file.id === sourcePointCloudFileId);
const sourcePointCount =
sourcePointcloud?.metadataStatus === 'ready'
&& typeof sourcePointcloud.pointCount === 'number'
&& Number.isFinite(sourcePointcloud.pointCount)
&& sourcePointcloud.pointCount >= 0
? sourcePointcloud.pointCount
: null;
const completedPointCount =
sourcePointCount == null ? null : sourcePointCount * (overallProgress / 100);
const startTimestamps = actualSteps
.map((step) => timestampFromUnknown(step.createdAt))
.filter((value): value is number => value != null);
const updateTimestamps = actualSteps
.map((step) => timestampFromUnknown(step.updatedAt))
.filter((value): value is number => value != null);
const startedAt = startTimestamps.length > 0 ? Math.min(...startTimestamps) : null;
const endedAt = active
? null
: cancelledAt ?? (updateTimestamps.length > 0 ? Math.max(...updateTimestamps) : startedAt);
const elapsedMs = startedAt == null
? 0
: Math.max(0, (endedAt ?? nowMs) - startedAt);
return {
active,
runId: activeRunId,
total,
completed,
steps,
overallStatus,
currentStep,
overallProgress,
sourcePointCloudFileId,
sourcePointCount,
completedPointCount,
startedAt,
endedAt,
elapsedMs,
};
}
export function useDemoFlowState(taskId: number | null): DemoFlowState {
const { data: results = [] } = trpc.analysisResult.list.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
const { data: pointcloudFiles = [] } = trpc.pointcloudFile.list.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null },
);
const [nowMs, setNowMs] = useState(() => Date.now());
const state = useMemo(
() => aggregateDemoFlowState(
taskId,
results as AnalysisResultRow[],
pointcloudFiles as PointcloudFileRow[],
nowMs,
),
[nowMs, pointcloudFiles, results, taskId],
);
useEffect(() => {
setNowMs(Date.now());
if (!state.active) return;
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [state.active, state.runId]);
return state;
}

View File

@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
import { createRoot } from "react-dom/client";
import superjson from "superjson";
import { toast } from "sonner";
import App from "./App";
import { getLoginUrl } from "./const";
import "./index.css";
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!isUnauthorized) return;
window.location.href = getLoginUrl();
if (window.location.pathname !== "/login") window.location.href = "/login";
};
queryClient.getQueryCache().subscribe(event => {
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.mutation.state.error;
redirectToLoginIfUnauthorized(error);
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
toast.error(error.message || "无权执行此操作");
}
console.error("[API Mutation Error]", error);
}
});
@@ -69,7 +72,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

@@ -0,0 +1,48 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ChangePassword() {
const { user, loading, logout } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const change = trpc.auth.changePassword.useMutation({
onSuccess: async () => {
await utils.auth.me.invalidate();
setLocation("/");
},
});
if (!loading && !user) return <Redirect to="/login" />;
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
event.preventDefault();
if (!mismatch) change.mutate({ currentPassword, newPassword });
}}>
<div>
<h1 className="text-xl font-semibold">修改密码</h1>
<p className="text-sm text-muted-foreground mt-1">
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
</p>
</div>
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
</form>
</div>
);
}

View File

@@ -1,31 +1,46 @@
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';
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
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,
Users,
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, user, logout, isSuperAdmin, canWrite } = useAuth();
const utils = trpc.useUtils();
const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
// 本次使用时间只属于当前页面会话;刷新或重新登录后从零开始。
useEffect(() => {
if (!user) {
setSessionElapsedMs(0);
return;
}
const startedAt = Date.now();
setSessionElapsedMs(0);
const intervalId = window.setInterval(() => {
setSessionElapsedMs(Date.now() - startedAt);
}, 1000);
return () => window.clearInterval(intervalId);
}, [user?.id]);
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
const [selection, setSelection] = useState<TreeSelection>({
@@ -68,9 +83,19 @@ export default function Home() {
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
const demoFlowState = useDemoFlowState(selection.taskId);
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
const pluginNameById = useMemo(() => {
const map = new Map<string, string>();
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
return map;
}, [pluginList]);
// ── 视图控制 ───────────────────────────────────────────────────────────────
const [showHeatmap, setShowHeatmap] = useState(true);
const [showCADModel, setShowCADModel] = useState(true);
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPointCloud, setShowPointCloud] = useState(true);
const [showCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false);
const [pointSize, setPointSize] = useState(0.03);
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
@@ -89,12 +114,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 +142,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 +159,7 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setIsCreatingTask(false);
setShowHeatmap(false);
setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]);
setRoiPickMode(false);
@@ -231,6 +244,10 @@ export default function Home() {
);
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setShowHeatmap(false);
setPointCloudTaskId(taskId);
setPointCloudUrls([`__task:${taskId}`]);
setPcLoading(true);
@@ -253,57 +270,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),
@@ -318,7 +284,7 @@ export default function Home() {
);
}
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<VehicleTree selection={selection} onSelect={setSelection} />
<div className="h-px bg-border my-1" />
<TaskManager
@@ -329,10 +295,8 @@ export default function Home() {
onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
onStartCreate={() => setIsCreatingTask(true)}
onEndCreate={() => setIsCreatingTask(false)}
/>
</>
</div>
);
};
@@ -348,10 +312,12 @@ export default function Home() {
}
return (
<div className="space-y-4">
<AlgorithmRunPanel
{canWrite && <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}
@@ -362,8 +328,8 @@ export default function Home() {
setRoiPickedPoint(null);
}}
activeRoiPickTarget={activeRoiPickTarget}
/>
<div className="h-px bg-border" />
/>}
{canWrite && <div className="h-px bg-border" />}
<AnalysisPanel
taskId={selection.taskId}
onReplay={resultId => console.log('[Replay] resultId', resultId)}
@@ -371,13 +337,50 @@ export default function Home() {
setPluginOverlay(null);
setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap');
setShowHeatmap(true);
}}
onViewPluginOverlay={({ taskId, resultId, overlay }) => {
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
const expectedIds = inputPointCloudFileIds ?? [];
const expectedFiles = expectedIds
.map((id) => taskPcFiles.find((f) => f.id === id))
.filter((f): f is NonNullable<typeof f> => f != null);
const expectedNames = expectedIds
.map((id) => taskPcFiles.find((f) => f.id === id)?.name ?? `#${id}`)
.join('、');
const expectedLabel = expectedFiles.length > 0 && expectedFiles.every((f) => (f as { source?: string }).source === 'registered')
? '配准后的点云'
: '本算法结果对应的点云文件';
const expectedHint = expectedNames ? `${expectedLabel}:${expectedNames}` : expectedLabel;
if (pointCloudTaskId == null || pointCloudUrls.length === 0) {
toast.error(`请先在左侧选择并加载${expectedHint}`);
return;
}
if (pcLoading) {
toast.warning('点云仍在加载中,请等待加载完成后再查看叠加层');
return;
}
if (pointCloudTaskId !== taskId) {
toast.error(`当前 3D 视口加载的点云不属于该算法结果,请先在左侧选择并加载${expectedHint}`);
return;
}
if (expectedIds.length === 0) {
toast.warning('无法确认匹配关系,请加载该结果运行时使用的点云');
} else {
const loadedIds = selectedPcFileIds.length > 0
? selectedPcFileIds
: taskPcFiles.map((f) => f.id);
const missingIds = expectedIds.filter((id) => !loadedIds.includes(id));
if (missingIds.length > 0) {
toast.error(`当前加载的点云与该结果不匹配,请在左侧选择并加载${expectedHint}`);
return;
}
}
setHeatmapViewResultId(null);
setPluginOverlay({ taskId, resultId, overlay });
if (overlay.kind === 'vertexColors') {
setPointCloudRenderMode('smoothHeatmap');
}
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
setShowHeatmap(overlay.kind === 'vertexColors');
}}
externalPending={runAnalysisJob.isPending}
onRunAssembly={() => {
@@ -439,33 +442,32 @@ export default function Home() {
</button>
</div>
<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="text-xs text-muted-foreground">
{pcLoading ? `加载点云 ${pcProgress}%` :
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
</span>
</div>
{(pcLoading || cadLoading) && (
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
<span className="status-led status-led-processing" />
<span className="text-xs text-muted-foreground">
{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>
) : user ? (
<div className="flex items-center gap-2 text-xs">
<div className="text-right leading-tight">
<div>{user.username}</div>
<div className="whitespace-nowrap text-[10px] text-muted-foreground">
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
<span className="mx-1">·</span>
本次使用时间 <span className="font-mono tabular-nums">{formatElapsedTime(sessionElapsedMs)}</span>
</div>
</div>
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
</div>
) : (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" />
</a>
)}
) : null}
</div>
</header>
@@ -473,20 +475,13 @@ export default function Home() {
<div className="flex-1 flex overflow-hidden">
{/* 左侧面板 */}
{showLeftPanel && (
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
<aside className="flex w-72 flex-shrink-0 flex-col overflow-hidden border-r border-border bg-card/30 p-3">
{renderLeftPanel()}
</aside>
)}
{/* 中央 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 +506,7 @@ export default function Home() {
pointCloudUrls={pointCloudUrlsStable}
batchTaskId={batchTaskId ?? undefined}
loadKey={pcLoadKey}
showPointCloud={showPointCloud}
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
onCadProgress={handleCadProgress}
onCadLoaded={handleCadLoaded}
@@ -528,6 +524,7 @@ export default function Home() {
setRoiPickedPoint(pt);
setRoiPickMode(false);
}}
demoFlowActive={demoFlowState.active}
className="w-full h-full"
/>
@@ -542,17 +539,15 @@ export default function Home() {
</div>
</div>
)}
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
</div>
{/* 操作提示 */}
<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,43 +589,38 @@ 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={() => {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
if (pointCloudRenderMode !== 'points') {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
setShowHeatmap(false);
setShowPointCloud(true);
return;
}
setShowPointCloud(v => !v);
}}
title="逐点显示(支持误差拾取)"
title={showPointCloud ? '隐藏点云' : '显示点云'}
>
<span className="text-[10px]">点云</span>
<Cloud className="w-3.5 h-3.5" />
<span>点云</span>
</button>
{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">
正在显示分析结果 #{heatmapViewResultId}
</span>
)}
<button
className={`industrial-btn py-1 ${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>
</button>
{pointCloudRenderMode === 'smoothHeatmap' && (
<button
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>热力图</span>
</button>
)}
</div>
<div className="h-5 w-px bg-border" />
@@ -656,7 +646,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 +659,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

@@ -0,0 +1,53 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Login() {
const { user, loading } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const login = trpc.auth.login.useMutation({
onSuccess: async (signedInUser) => {
utils.auth.me.setData(undefined, signedInUser);
await utils.auth.me.invalidate();
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
},
});
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form
className="w-full max-w-sm industrial-panel p-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
login.mutate({ username, password });
}}
>
<div>
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
</div>
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
{login.isPending ? "正在登录…" : "登录"}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { ArrowLeft, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
type Role = keyof typeof roleNames;
export default function Users() {
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [draftSearch, setDraftSearch] = useState("");
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
const list = trpc.users.list.useQuery(search ? { search } : {});
const refresh = () => utils.users.list.invalidate();
const onError = (error: { message: string }) => toast.error(error.message);
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
return (
<div className="min-h-screen bg-background p-6 space-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
</header>
<section className="industrial-panel p-5">
<h2 className="font-medium mb-4">新建用户</h2>
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
</form>
</section>
<section className="industrial-panel p-5 space-y-4">
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
<tbody>{list.data?.map(user => (
<tr key={user.id} className="border-b border-border/60">
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
</tr>
))}</tbody>
</table>
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
</div>
</section>
</div>
);
}

View File

@@ -38,22 +38,21 @@ 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
- PORT=3002
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- AUTH_MODE=local
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
- S3_ENDPOINT=http://localhost:9010
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
- S3_BUCKET=pointcloud-data
- S3_REGION=us-east-1
- VITE_APP_TITLE=宏量点云处理系统
- VITE_OAUTH_PORTAL_URL=disabled
- VITE_APP_ID=
# 数据目录路径
- DATA_DIR=/data
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
@@ -75,10 +74,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

@@ -28,9 +28,9 @@ services:
- NODE_ENV=production
- PORT=3002
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
# 内网部署禁用OAuth,使用本地认证
- AUTH_MODE=local
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
# MinIO S3兼容存储配置
- S3_ENDPOINT=http://localhost:9010
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}

View File

@@ -16,15 +16,17 @@ USE pointcloud_db;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openId VARCHAR(64) NOT NULL UNIQUE,
username VARCHAR(64) NOT NULL UNIQUE,
passwordHash VARCHAR(255),
name TEXT,
email VARCHAR(320),
loginMethod VARCHAR(64),
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
isActive INT NOT NULL DEFAULT 1,
mustChangePassword INT NOT NULL DEFAULT 1,
sessionVersion INT NOT NULL DEFAULT 0,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_openId (openId),
lastLoginAt TIMESTAMP NULL,
INDEX idx_email (email(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -124,16 +126,33 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` (
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
`pointCount` BIGINT NULL,
`pointAttributes` JSON NULL,
`pointDataFormat` VARCHAR(32) NULL,
`bytesPerPoint` INT NULL,
`metadataError` TEXT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_model_pointcloud_modelId` (`modelId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`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` (
@@ -142,14 +161,24 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
`name` VARCHAR(255) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'uploaded',
`sourceResultId` INT NULL,
`sourcePointcloudFileId` INT NULL,
`sourceModelPointcloudId` INT NULL,
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
`pointCount` BIGINT NULL,
`pointAttributes` JSON NULL,
`pointDataFormat` VARCHAR(32) NULL,
`bytesPerPoint` INT NULL,
`metadataError` TEXT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
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,
@@ -157,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` (
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建默认管理员用户 (内网部署使用)
INSERT INTO users (openId, name, email, role) VALUES
('admin-local', '系统管理员', 'admin@localhost', 'admin')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- 输出初始化完成信息
SELECT 'Database initialization completed successfully!' AS message;

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

@@ -0,0 +1,22 @@
ALTER TABLE `users`
MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`,
ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`,
ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`,
ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`,
ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`,
ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`;
UPDATE `users`
SET `username` = CONCAT('legacy_user_', `id`),
`isActive` = 0,
`mustChangePassword` = 1,
`sessionVersion` = `sessionVersion` + 1;
ALTER TABLE `users`
MODIFY COLUMN `username` VARCHAR(64) NOT NULL,
ADD UNIQUE INDEX `users_username_unique` (`username`),
DROP INDEX `users_openId_unique`,
DROP COLUMN `openId`,
DROP COLUMN `loginMethod`,
DROP COLUMN `lastSignedIn`;

View File

@@ -0,0 +1,15 @@
ALTER TABLE `model_pointcloud_files`
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
ADD COLUMN `pointCount` bigint NULL,
ADD COLUMN `pointAttributes` json NULL,
ADD COLUMN `pointDataFormat` varchar(32) NULL,
ADD COLUMN `bytesPerPoint` int NULL,
ADD COLUMN `metadataError` text NULL;
ALTER TABLE `task_pointcloud_files`
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
ADD COLUMN `pointCount` bigint NULL,
ADD COLUMN `pointAttributes` json NULL,
ADD COLUMN `pointDataFormat` varchar(32) NULL,
ADD COLUMN `bytesPerPoint` int NULL,
ADD COLUMN `metadataError` text NULL;

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS `inspection_objects` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`status` ENUM('draft','active') NOT NULL DEFAULT 'draft',
`defaultModelPointcloudId` INT NULL,
`defaultModelCadFileId` INT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_inspection_objects_model_name` (`modelId`,`name`),
KEY `idx_inspection_objects_model` (`modelId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `model_pointcloud_files`
ADD COLUMN `inspectionObjectId` INT NULL AFTER `filePath`,
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `inspectionObjectId`,
ADD KEY `idx_model_pointcloud_object` (`inspectionObjectId`);
ALTER TABLE `model_cad_files`
ADD COLUMN `contentScope` ENUM('whole_vehicle') NOT NULL DEFAULT 'whole_vehicle' AFTER `filePath`,
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `contentScope`;
CREATE TABLE IF NOT EXISTS `model_cad_inspection_objects` (
`cadFileId` INT NOT NULL,
`inspectionObjectId` INT NOT NULL,
UNIQUE KEY `uk_model_cad_inspection_object` (`cadFileId`,`inspectionObjectId`),
KEY `idx_model_cad_object` (`inspectionObjectId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `inspection_tasks`
ADD COLUMN `inspectionObjectId` INT NULL AFTER `modelId`,
ADD KEY `idx_inspection_tasks_object` (`inspectionObjectId`);

View File

@@ -1,17 +1,21 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json } from "drizzle-orm/mysql-core";
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
import type { PointAttribute } from "../shared/pointcloudMetadata";
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
// ─── 本地用户 ──────────────────────────────────────────────────────────────────
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
openId: varchar("openId", { length: 64 }).notNull().unique(),
username: varchar("username", { length: 64 }).notNull().unique(),
passwordHash: varchar("passwordHash", { length: 255 }),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
isActive: int("isActive").default(1).notNull(),
mustChangePassword: int("mustChangePassword").default(1).notNull(),
sessionVersion: int("sessionVersion").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
lastLoginAt: timestamp("lastLoginAt"),
});
export type User = typeof users.$inferSelect;
@@ -46,6 +50,25 @@ export const vehicleModels = mysqlTable("vehicle_models", {
export type VehicleModel = typeof vehicleModels.$inferSelect;
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
// ─── 检测对象 ────────────────────────────────────────────────────────────────
// 车型下真正执行检测的对象,例如头车、中间车。默认资源只影响新建任务。
export const inspectionObjects = mysqlTable("inspection_objects", {
id: int("id").autoincrement().primaryKey(),
modelId: int("modelId").notNull(),
name: varchar("name", { length: 100 }).notNull(),
description: text("description"),
status: mysqlEnum("status", ["draft", "active"]).default("draft").notNull(),
defaultModelPointcloudId: int("defaultModelPointcloudId"),
defaultModelCadFileId: int("defaultModelCadFileId"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
modelNameUnique: uniqueIndex("uk_inspection_objects_model_name").on(table.modelId, table.name),
}));
export type InspectionObject = typeof inspectionObjects.$inferSelect;
export type InsertInspectionObject = typeof inspectionObjects.$inferInsert;
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
@@ -56,12 +79,44 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
format: varchar("format", { length: 32 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
filePath: varchar("filePath", { length: 512 }).notNull(),
contentScope: mysqlEnum("contentScope", ["whole_vehicle"]).default("whole_vehicle").notNull(),
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
export const modelCadInspectionObjects = mysqlTable("model_cad_inspection_objects", {
cadFileId: int("cadFileId").notNull(),
inspectionObjectId: int("inspectionObjectId").notNull(),
}, (table) => ({
cadObjectUnique: uniqueIndex("uk_model_cad_inspection_object").on(table.cadFileId, table.inspectionObjectId),
}));
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
// 一个车型可维护多个通用点云文件,供任务按需关联复用
export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
id: int("id").autoincrement().primaryKey(),
modelId: int("modelId").notNull(),
name: varchar("name", { length: 255 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
filePath: varchar("filePath", { length: 512 }).notNull(),
inspectionObjectId: int("inspectionObjectId"),
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
pointCount: bigint("pointCount", { mode: "number" }),
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
bytesPerPoint: int("bytesPerPoint"),
metadataError: text("metadataError"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type ModelPointcloudFile = typeof modelPointcloudFiles.$inferSelect;
export type InsertModelPointcloudFile = typeof modelPointcloudFiles.$inferInsert;
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
// 每个任务可关联任意个 CAD 文件(与车型级 CAD 独立,用于本次检测的实测参考)
@@ -72,6 +127,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(),
});
@@ -84,13 +143,18 @@ export type InsertTaskCadFile = typeof taskCadFiles.$inferInsert;
export const inspectionTasks = mysqlTable("inspection_tasks", {
id: int("id").autoincrement().primaryKey(),
modelId: int("modelId").notNull(),
inspectionObjectId: int("inspectionObjectId"),
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 +169,20 @@ 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"),
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
pointCount: bigint("pointCount", { mode: "number" }),
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
bytesPerPoint: int("bytesPerPoint"),
metadataError: text("metadataError"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -135,7 +209,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

@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { execFile as execFileCallback } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise";
const execFile = promisify(execFileCallback);
export type Measurement = Record<string, unknown>;
export interface CandidateRow extends RowDataPacket {
id: number;
taskId: number;
measurements: unknown;
resultData: Record<string, unknown> | null;
}
export interface BackfillPlan {
row: CandidateRow;
resultJsonPath: string;
originalResultJson: Record<string, unknown>;
measurements: Measurement[];
}
export interface ExecutionSummary {
planned: number;
applied: number;
skipped: number;
failed: number;
}
export function mergeMeasurements<T extends Record<string, unknown>>(
value: T | null,
measurements: Measurement[],
): T & { measurements: Measurement[] } {
return { ...(value ?? ({} as T)), measurements };
}
export async function executePlans(
plans: BackfillPlan[],
apply: boolean,
persist: (plan: BackfillPlan) => Promise<boolean>,
): Promise<ExecutionSummary> {
const summary: ExecutionSummary = {
planned: plans.length,
applied: 0,
skipped: apply ? 0 : plans.length,
failed: 0,
};
if (!apply) return summary;
for (const plan of plans) {
try {
if (await persist(plan)) summary.applied += 1;
else summary.skipped += 1;
} catch (error) {
summary.failed += 1;
console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error);
}
}
return summary;
}
function argumentValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function timestamp(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
async function parseMeasurements(
excelPath: string,
templatePath: string,
helperPath: string,
): Promise<Measurement[]> {
const python = process.env.PYTHON3_PATH || "/usr/bin/python3";
const { stdout } = await execFile(
python,
[helperPath, "--excel", excelPath, "--template", templatePath],
{ maxBuffer: 10 * 1024 * 1024 },
);
const parsed = JSON.parse(stdout) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("解析结果为空")
}
return parsed.filter((item): item is Measurement => !!item && typeof item === "object");
}
async function listCandidates(pool: Pool, resultId?: number): Promise<CandidateRow[]> {
const filters = [
"pluginId = 'flatness-suite'",
"status = 'completed'",
"(measurements IS NULL OR JSON_LENGTH(measurements) = 0)",
];
const params: number[] = [];
if (resultId !== undefined) {
filters.push("id = ?");
params.push(resultId);
}
const [rows] = await pool.query<CandidateRow[]>(
`SELECT id, taskId, measurements, resultData
FROM task_analysis_results
WHERE ${filters.join(" AND ")}
ORDER BY id`,
params,
);
return rows;
}
async function buildPlans(
rows: CandidateRow[],
dataDir: string,
templatePath: string,
helperPath: string,
): Promise<{ plans: BackfillPlan[]; skipped: number }> {
const plans: BackfillPlan[] = [];
let skipped = 0;
for (const row of rows) {
const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id));
const resultJsonPath = path.join(resultDir, "result.json");
const excelPath = path.join(resultDir, "contour.xlsx");
try {
const [resultText] = await Promise.all([
fs.readFile(resultJsonPath, "utf8"),
fs.access(excelPath),
]);
const resultJson = JSON.parse(resultText) as Record<string, unknown>;
const extra = resultJson.extra as Record<string, unknown> | undefined;
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | undefined;
if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") {
throw new Error("不是 contour_batch 批量结果")
}
const measurements = await parseMeasurements(excelPath, templatePath, helperPath);
plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements });
console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`);
} catch (error) {
skipped += 1;
console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return { plans, skipped };
}
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`;
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await fs.rename(temporaryPath, filePath);
}
async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise<string> {
const backupDir = path.join(dataDir, "maintenance-backups");
await fs.mkdir(backupDir, { recursive: true });
const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`);
await writeJsonAtomically(backupPath, {
version: 1,
createdAt: new Date().toISOString(),
entries: plans.map((plan) => ({
id: plan.row.id,
taskId: plan.row.taskId,
resultJsonPath: plan.resultJsonPath,
measurements: plan.row.measurements,
resultData: plan.row.resultData,
resultJson: plan.originalResultJson,
})),
});
return backupPath;
}
async function persistPlan(pool: Pool, plan: BackfillPlan): Promise<boolean> {
const connection = await pool.getConnection();
const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements);
const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements);
try {
await connection.beginTransaction();
await writeJsonAtomically(plan.resultJsonPath, updatedResultJson);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE task_analysis_results
SET measurements = ?, resultData = ?
WHERE id = ?
AND pluginId = 'flatness-suite'
AND status = 'completed'
AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`,
[JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id],
);
if (result.affectedRows !== 1) {
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson);
await connection.rollback();
return false;
}
await connection.commit();
return true;
} catch (error) {
await connection.rollback();
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined);
throw error;
} finally {
connection.release();
}
}
interface BackupManifest {
entries: Array<{
id: number;
resultJsonPath: string;
measurements: unknown;
resultData: unknown;
resultJson: unknown;
}>;
}
async function restoreBackup(pool: Pool, backupPath: string): Promise<void> {
const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest;
if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效")
for (const entry of manifest.entries) {
await writeJsonAtomically(entry.resultJsonPath, entry.resultJson);
await pool.execute(
"UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'",
[
entry.measurements == null ? null : JSON.stringify(entry.measurements),
entry.resultData == null ? null : JSON.stringify(entry.resultData),
entry.id,
],
);
console.log(`[backfill] 已恢复结果 ${entry.id}`);
}
}
async function main(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("缺少 DATABASE_URL")
const dataDir = process.env.DATA_DIR || "/data";
const algRoot = process.env.ALG_ROOT || "/alg";
const resultIdRaw = argumentValue("--result-id");
const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw);
if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数")
const apply = process.argv.includes("--apply");
const restorePath = argumentValue("--restore");
const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx");
const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py");
const pool = mysql.createPool(databaseUrl);
try {
if (restorePath) {
await restoreBackup(pool, restorePath);
return;
}
const rows = await listCandidates(pool, resultId);
const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath);
let backupPath: string | undefined;
if (apply && plans.length > 0) {
backupPath = await writeBackup(plans, dataDir);
console.log(`[backfill] 写入前备份: ${backupPath}`);
}
const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan));
summary.skipped += skipped;
console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`);
if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入");
if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`);
} finally {
await pool.end();
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
main().catch((error) => {
console.error("[backfill] 失败:", error);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,60 @@
import mysql from 'mysql2/promise';
type Mapping = { modelId: number; objectName: string; pointclouds: string[]; defaultPointcloud: string; cadFiles: string[]; defaultCad: string };
const mappings: Mapping[] = [
{ modelId: 1, objectName: '中间车', pointclouds: ['1_10.ply', '1_11.ply', '1_12.ply'], defaultPointcloud: '1_10.ply', cadFiles: ['train_1_mesh.ply'], defaultCad: 'train_1_mesh.ply' },
{ modelId: 2, objectName: '头车', pointclouds: ['2_10.ply', '2_11.ply', '2_12.ply', 'train_2_10M.ply', 'train_2_1T.ply'], defaultPointcloud: '2_10.ply', cadFiles: ['m000004802418tc01_asm.stp', 'train_2_mesh.ply'], defaultCad: 'm000004802418tc01_asm.stp' },
];
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required');
const connection = await mysql.createConnection(connectionString);
let createdObjects = 0;
let assignedPointclouds = 0;
let assignedCads = 0;
let classifiedTasks = 0;
const warnings: Array<{ taskId: number; reason: string }> = [];
const conflicts: string[] = [];
try {
await connection.beginTransaction();
for (const mapping of mappings) {
const [existing] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
if (existing.length === 0) {
await connection.execute('INSERT INTO inspection_objects (modelId,name,status) VALUES (?,?,\'draft\')', [mapping.modelId, mapping.objectName]);
createdObjects += 1;
}
const [objectRows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
const objectId = Number(objectRows[0].id);
for (const name of mapping.pointclouds) {
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id,inspectionObjectId FROM model_pointcloud_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
if (rows.length !== 1) { conflicts.push(`点云 model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
if (rows[0].inspectionObjectId && Number(rows[0].inspectionObjectId) !== objectId) { conflicts.push(`点云 ${name} 已属于其他检测对象`); continue; }
if (!rows[0].inspectionObjectId) { await connection.execute('UPDATE model_pointcloud_files SET inspectionObjectId=?,status=\'available\' WHERE id=?', [objectId, rows[0].id]); assignedPointclouds += 1; }
}
for (const name of mapping.cadFiles) {
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_cad_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
if (rows.length !== 1) { conflicts.push(`CAD model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
const [result] = await connection.execute<mysql.ResultSetHeader>('INSERT IGNORE INTO model_cad_inspection_objects (cadFileId,inspectionObjectId) VALUES (?,?)', [rows[0].id, objectId]);
assignedCads += result.affectedRows;
}
const [[defaultPc]] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_pointcloud_files WHERE modelId=? AND name=? AND inspectionObjectId=?', [mapping.modelId, mapping.defaultPointcloud, objectId]);
const [[defaultCad]] = await connection.query<mysql.RowDataPacket[]>('SELECT c.id FROM model_cad_files c JOIN model_cad_inspection_objects r ON r.cadFileId=c.id WHERE c.modelId=? AND c.name=? AND r.inspectionObjectId=?', [mapping.modelId, mapping.defaultCad, objectId]);
if (defaultPc && defaultCad) await connection.execute('UPDATE inspection_objects SET defaultModelPointcloudId=?,defaultModelCadFileId=?,status=\'active\' WHERE id=?', [defaultPc.id, defaultCad.id, objectId]);
else conflicts.push(`${mapping.objectName} 默认输入方案资源不完整`);
}
const [tasks] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_tasks WHERE inspectionObjectId IS NULL ORDER BY id');
for (const task of tasks) {
const [sources] = await connection.query<mysql.RowDataPacket[]>('SELECT DISTINCT mp.inspectionObjectId FROM task_pointcloud_files tp JOIN model_pointcloud_files mp ON mp.id=tp.sourceModelPointcloudId WHERE tp.taskId=? AND mp.inspectionObjectId IS NOT NULL', [task.id]);
if (sources.length === 1) { await connection.execute('UPDATE inspection_tasks SET inspectionObjectId=? WHERE id=? AND inspectionObjectId IS NULL', [sources[0].inspectionObjectId, task.id]); classifiedTasks += 1; }
else warnings.push({ taskId: Number(task.id), reason: sources.length > 1 ? '包含不同检测对象的车型库点云' : '无可推断的车型库来源点云' });
}
await connection.commit();
console.log(JSON.stringify({ createdObjects, assignedPointclouds, assignedCads, classifiedTasks, unclassifiedTasks: warnings, conflicts }, null, 2));
} catch (error) {
await connection.rollback();
throw error;
} finally {
await connection.end();
}

153
server/_core/auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { parse as parseCookieHeader } from "cookie";
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import { promisify } from "util";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
const scrypt = promisify(scryptCallback);
const PASSWORD_MIN_LENGTH = 8;
function sessionSecret() {
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
}
return new TextEncoder().encode(ENV.cookieSecret);
}
export function validatePassword(password: string) {
if (password.length < PASSWORD_MIN_LENGTH) {
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
}
}
export async function hashPassword(password: string) {
validatePassword(password);
const salt = randomBytes(16);
const derived = await scrypt(password, salt, 64) as Buffer;
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
}
export async function verifyPassword(password: string, encoded: string | null) {
if (!encoded) return false;
const [scheme, saltValue, hashValue] = encoded.split("$");
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
try {
const expected = Buffer.from(hashValue, "base64url");
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
return actual.length === expected.length && timingSafeEqual(actual, expected);
} catch {
return false;
}
}
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
.sign(sessionSecret());
}
function readSessionCookie(req: Request) {
if (!req.headers.cookie) return undefined;
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
}
export async function authenticateRequest(req: Request): Promise<User | null> {
const token = readSessionCookie(req);
if (!token) return null;
try {
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
const userId = Number(payload.userId);
const sessionVersion = Number(payload.sessionVersion);
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
const user = await db.getUserById(userId);
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
return user;
} catch {
return null;
}
}
type Attempt = { failures: number[] };
const attempts = new Map<string, Attempt>();
const RATE_WINDOW_MS = 15 * 60 * 1000;
const RATE_MAX_FAILURES = 5;
function normalizedUsername(username: string) {
return username.trim().toLowerCase();
}
function attemptKeys(ip: string, username: string) {
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
}
function recentFailures(key: string, now = Date.now()) {
const record = attempts.get(key);
if (!record) return [];
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
if (record.failures.length === 0) attempts.delete(key);
return record.failures;
}
export function isLoginRateLimited(ip: string, username: string) {
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
}
export function recordLoginFailure(ip: string, username: string) {
const now = Date.now();
for (const key of attemptKeys(ip, username)) {
const failures = recentFailures(key, now);
failures.push(now);
attempts.set(key, { failures });
}
}
export function clearLoginFailures(ip: string, username: string) {
for (const key of attemptKeys(ip, username)) attempts.delete(key);
}
export function resetLoginRateLimitForTests() {
attempts.clear();
}
export async function ensureBootstrapAdmin() {
if (await db.countActiveSuperAdmins() > 0) return;
const username = ENV.bootstrapAdminUsername.trim();
const password = ENV.bootstrapAdminPassword;
if (!username || !password) {
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
}
validatePassword(password);
const existing = await db.getUserByUsername(username);
const passwordHash = await hashPassword(password);
if (existing) {
await db.updateUser(existing.id, {
passwordHash,
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: existing.sessionVersion + 1,
});
} else {
await db.createUser({
username,
passwordHash,
name: "系统管理员",
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: 0,
});
}
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
}
export function publicUser(user: User) {
const { passwordHash: _passwordHash, ...safe } = user;
return safe;
}

View File

@@ -1,6 +1,6 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { User } from "../../drizzle/schema";
import { sdk } from "./sdk";
import { authenticateRequest } from "./auth";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
@@ -14,7 +14,7 @@ export async function createContext(
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
user = await authenticateRequest(opts.req);
} catch (error) {
// Authentication is optional for public procedures.
user = null;

View File

@@ -1,13 +1,5 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
return {
httpOnly: true,
path: "/",
sameSite: "none",
sameSite: "lax",
secure: isSecureRequest(req),
};
}

View File

@@ -1,13 +1,8 @@
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
authMode: process.env.AUTH_MODE ?? "oauth",
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",

View File

@@ -7,11 +7,11 @@ import path from "path";
import { fileURLToPath } from "url";
import mysql from "mysql2/promise";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { fileRouter } from "../fileRoutes";
import { ensureBootstrapAdmin } from "./auth";
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
}
async function startServer() {
try {
await runMigrations();
} catch (e) {
console.error("[Migration] Unexpected error, server will still start:", e);
}
await runMigrations();
await ensureBootstrapAdmin();
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
@@ -128,8 +125,6 @@ async function startServer() {
server.timeout = 0; // 禁用 Node http.Server 全局超时
server.keepAliveTimeout = 0; // 长连接不超时
server.headersTimeout = 0; // 读请求头不超时
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// 禁止代理(NPS 等)缓存 API 响应
app.use('/api', (_req, res, next) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');

View File

@@ -1,53 +0,0 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
function getQueryParam(req: Request, key: string): string | undefined {
const value = req.query[key];
return typeof value === "string" ? value : undefined;
}
export function registerOAuthRoutes(app: Express) {
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
if (!userInfo.openId) {
res.status(400).json({ error: "openId missing from user info" });
return;
}
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: new Date(),
});
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.redirect(302, "/");
} catch (error) {
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});
}

View File

@@ -1,346 +0,0 @@
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import type {
ExchangeTokenRequest,
ExchangeTokenResponse,
GetUserInfoResponse,
GetUserInfoWithJwtRequest,
GetUserInfoWithJwtResponse,
} from "./types/manusTypes";
// Utility function
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
export type SessionPayload = {
openId: string;
appId: string;
name: string;
};
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
class OAuthService {
constructor(private client: ReturnType<typeof axios.create>) {
const isLocalAuth = ENV.authMode === "local";
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
if (!ENV.oAuthServerUrl && !isLocalAuth) {
console.error(
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
);
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
}
}
private decodeState(state: string): string {
const redirectUri = atob(state);
return redirectUri;
}
async getTokenByCode(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
const payload: ExchangeTokenRequest = {
clientId: ENV.appId,
grantType: "authorization_code",
code,
redirectUri: this.decodeState(state),
};
const { data } = await this.client.post<ExchangeTokenResponse>(
EXCHANGE_TOKEN_PATH,
payload
);
return data;
}
async getUserInfoByToken(
token: ExchangeTokenResponse
): Promise<GetUserInfoResponse> {
const { data } = await this.client.post<GetUserInfoResponse>(
GET_USER_INFO_PATH,
{
accessToken: token.accessToken,
}
);
return data;
}
}
const createOAuthHttpClient = (): AxiosInstance =>
axios.create({
baseURL: ENV.oAuthServerUrl,
timeout: AXIOS_TIMEOUT_MS,
});
class SDKServer {
private readonly client: AxiosInstance;
private readonly oauthService: OAuthService;
constructor(client: AxiosInstance = createOAuthHttpClient()) {
this.client = client;
this.oauthService = new OAuthService(this.client);
}
private isLocalAuthMode() {
return ENV.authMode === "local";
}
private deriveLoginMethod(
platforms: unknown,
fallback: string | null | undefined
): string | null {
if (fallback && fallback.length > 0) return fallback;
if (!Array.isArray(platforms) || platforms.length === 0) return null;
const set = new Set<string>(
platforms.filter((p): p is string => typeof p === "string")
);
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
if (
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
set.has("REGISTERED_PLATFORM_AZURE")
)
return "microsoft";
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
const first = Array.from(set)[0];
return first ? first.toLowerCase() : null;
}
/**
* Exchange OAuth authorization code for access token
* @example
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
*/
async exchangeCodeForToken(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
return this.oauthService.getTokenByCode(code, state);
}
/**
* Get user information using access token
* @example
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
*/
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
const data = await this.oauthService.getUserInfoByToken({
accessToken,
} as ExchangeTokenResponse);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoResponse;
}
private parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) {
return new Map<string, string>();
}
const parsed = parseCookieHeader(cookieHeader);
return new Map(Object.entries(parsed));
}
private getSessionSecret() {
const secret = ENV.cookieSecret;
return new TextEncoder().encode(secret);
}
/**
* Create a session token for a Manus user openId
* @example
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
*/
async createSessionToken(
openId: string,
options: { expiresInMs?: number; name?: string } = {}
): Promise<string> {
return this.signSession(
{
openId,
appId: ENV.appId,
name: options.name || "",
},
options
);
}
async signSession(
payload: SessionPayload,
options: { expiresInMs?: number } = {}
): Promise<string> {
const issuedAt = Date.now();
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
const secretKey = this.getSessionSecret();
return new SignJWT({
openId: payload.openId,
appId: payload.appId,
name: payload.name,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
.sign(secretKey);
}
async verifySession(
cookieValue: string | undefined | null
): Promise<{ openId: string; appId: string; name: string } | null> {
if (!cookieValue) {
console.warn("[Auth] Missing session cookie");
return null;
}
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
const { openId, appId, name } = payload as Record<string, unknown>;
if (
!isNonEmptyString(openId) ||
!isNonEmptyString(appId) ||
!isNonEmptyString(name)
) {
console.warn("[Auth] Session payload missing required fields");
return null;
}
return {
openId,
appId,
name,
};
} catch (error) {
console.warn("[Auth] Session verification failed", String(error));
return null;
}
}
async getUserInfoWithJwt(
jwtToken: string
): Promise<GetUserInfoWithJwtResponse> {
const payload: GetUserInfoWithJwtRequest = {
jwtToken,
projectId: ENV.appId,
};
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
GET_USER_INFO_WITH_JWT_PATH,
payload
);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoWithJwtResponse;
}
async authenticateRequest(req: Request): Promise<User> {
if (this.isLocalAuthMode()) {
const signedInAt = new Date();
const localOpenId = ENV.localDevOpenId;
try {
await db.upsertUser({
openId: localOpenId,
name: ENV.localDevName,
email: ENV.localDevEmail,
loginMethod: "local",
lastSignedIn: signedInAt,
});
const localUser = await db.getUserByOpenId(localOpenId);
if (localUser) return localUser;
} catch {
// DB not ready yet (e.g. MySQL still starting up); fall through to
// an in-memory guest user so the UI keeps working.
console.warn("[Auth] DB unavailable in local mode, using fallback user");
}
// Fallback: return a transient in-memory user so requests don't fail
// while MySQL is warming up.
return {
id: 0,
openId: localOpenId,
name: ENV.localDevName ?? "Dev User",
email: ENV.localDevEmail ?? null,
loginMethod: "local",
role: "admin" as const,
createdAt: signedInAt,
updatedAt: signedInAt,
lastSignedIn: signedInAt,
};
}
// Regular authentication flow
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = cookies.get(COOKIE_NAME);
const session = await this.verifySession(sessionCookie);
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const sessionUserId = session.openId;
const signedInAt = new Date();
let user = await db.getUserByOpenId(sessionUserId);
// If user not in DB, sync from OAuth server automatically
if (!user) {
try {
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);
} catch (error) {
console.error("[Auth] Failed to sync user from OAuth:", error);
throw ForbiddenError("Failed to sync user info");
}
}
if (!user) {
throw ForbiddenError("User not found");
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: signedInAt,
});
return user;
}
}
export const sdk = new SDKServer();

View File

@@ -1,4 +1,4 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const requireSession = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const sessionProcedure = t.procedure.use(requireSession);
const requireReadableUser = t.middleware(async opts => {
if (!opts.ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
});
export const protectedProcedure = t.procedure.use(requireReadableUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
});
}),
);
export const superAdminProcedure = t.procedure.use(
t.middleware(async opts => {
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.ctx.user.role !== "super_admin") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
}),
);

View File

@@ -1,69 +0,0 @@
// WebDev Auth TypeScript types
// Auto-generated from protobuf definitions
// Generated on: 2025-09-24T05:57:57.338Z
export interface AuthorizeRequest {
redirectUri: string;
projectId: string;
state: string;
responseType: string;
scope: string;
}
export interface AuthorizeResponse {
redirectUrl: string;
}
export interface ExchangeTokenRequest {
grantType: string;
code: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}
export interface ExchangeTokenResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
refreshToken?: string;
scope: string;
idToken: string;
}
export interface GetUserInfoRequest {
accessToken: string;
}
export interface GetUserInfoResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}
export interface CanAccessRequest {
openId: string;
projectId: string;
}
export interface CanAccessResponse {
canAccess: boolean;
}
export interface GetUserInfoWithJwtRequest {
jwtToken: string;
projectId: string;
}
export interface GetUserInfoWithJwtResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}

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,28 @@ function readResultJson(workDir: string): PluginResult | null {
}
}
const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowRunId',
'demoFlowRole',
'demoFlowIndex',
'demoFlowTotal',
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
'demoFlowCancelledAt',
] 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 +312,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 +330,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 +347,8 @@ export async function runAlgorithmJob(
params,
pointCloudPaths,
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
);
let exitCode: number | null = 0;
@@ -308,12 +366,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 +387,7 @@ export async function runAlgorithmJob(
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
...demoFlowResultData,
};
await db.updateTaskAnalysisResult(resultId, {
@@ -350,13 +412,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 +441,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,57 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupAnalysisArtifacts, getAnalysisArtifactPaths } from "./analysisArtifacts";
const tempDirs: string[] = [];
async function makeDataDir() {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "analysis-artifacts-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })));
});
describe("cleanupAnalysisArtifacts", () => {
it("deletes the plugin directory and all legacy files for only the requested result", async () => {
const dataDir = await makeDataDir();
const analysisDir = path.join(dataDir, "tasks", "7", "analysis");
await fs.promises.mkdir(path.join(analysisDir, "57"), { recursive: true });
await fs.promises.mkdir(path.join(analysisDir, "58"), { recursive: true });
const files = [
"heatmap_57.bin",
"deviation_57.bin",
"assembly_vertex_57_car-body.bin",
"assembly_vertex_57_bogie.bin",
"heatmap_58.bin",
"assembly_vertex_570_other.bin",
];
await Promise.all(files.map((name) => fs.promises.writeFile(path.join(analysisDir, name), name)));
await cleanupAnalysisArtifacts(7, 57, dataDir);
await expect(fs.promises.stat(path.join(analysisDir, "57"))).rejects.toMatchObject({ code: "ENOENT" });
for (const name of files.slice(0, 4)) {
await expect(fs.promises.stat(path.join(analysisDir, name))).rejects.toMatchObject({ code: "ENOENT" });
}
await expect(fs.promises.stat(path.join(analysisDir, "58"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "heatmap_58.bin"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "assembly_vertex_570_other.bin"))).resolves.toBeDefined();
});
it("rejects invalid IDs before constructing a filesystem target", () => {
expect(() => getAnalysisArtifactPaths(7, Number.NaN, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(7, -1, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(1.5, 2, "/data")).toThrow("taskId");
});
it("is idempotent when artifacts are already absent", async () => {
const dataDir = await makeDataDir();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More