feat: update algorithm integrations and point cloud overlays

This commit is contained in:
2026-06-30 16:46:36 +08:00
parent ddca7e5436
commit 7e2e56a50d
64 changed files with 4356 additions and 2781 deletions

4
.gitignore vendored
View File

@@ -162,3 +162,7 @@ alg/train_measurement_system_v2.2/**/visualizations*/
alg/整车配准v1.3/**/*.ply alg/整车配准v1.3/**/*.ply
alg/整车配准v1.3/datasets/ alg/整车配准v1.3/datasets/
alg/整车配准v1.3/results/ alg/整车配准v1.3/results/
# ─── train_measurement_system_v3.2(运行产物排除)──────────────────────────────
alg/train_measurement_system_v3.2_0629/results/
alg/train_measurement_system_v3.2_0629/visualizations*/

View File

@@ -1,552 +0,0 @@
# 列车点云测量系统
基于配置驱动的点云测量系统,支持多种测量任务类型。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、距离计算,并生成Excel报告和可视化点云文件。
## 项目结构
```
train_measurement_system/
├── config/
│ └── measurements_0610.yaml # 测量任务配置文件
├── src/
│ ├── __init__.py # 包初始化文件
│ ├── measurements/
│ │ ├── __init__.py # 子包初始化文件
│ │ ├── base.py # 测量任务基类
│ │ ├── plane_distance.py # 平面到平面距离测量(含宽度差值检查)
│ │ ├── point_to_plane.py # 点到平面距离测量
│ │ ├── point_to_point.py # 点到点距离测量
│ │ ├── plane_with_holes.py # 带孔平面测量
│ │ ├── point_to_plane_with_holes.py
│ │ └── point_to_point_with_holes.py
│ ├── hole_detect/ # 孔检测模块
│ │ ├── hole_detector.py
│ │ └── detect.py
│ ├── config_loader.py # 配置加载器
│ ├── task_manager.py # 任务管理器(含任务19自动生成)
│ ├── roi_extractor.py # ROI提取器
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
│ ├── hole_center_fitter.py # 孔中心拟合器
│ ├── pointcloud_loader.py # 点云加载器
│ ├── excel_exporter.py # Excel结果导出器
│ ├── performance_logger.py # 性能日志记录器
│ ├── measurement_logger.py # 详细测量日志记录器
│ └── visualizer.py # 可视化工具
├── results/ # 测量结果输出目录
│ ├── measurement_results_*.xlsx # Excel测量结果
│ ├── measurement_detail_*.log # 详细测量日志
│ └── performance_*.log # 性能分析报告
├── visualizations/ # 可视化点云输出目录(PLY文件)
├── main.py # 主程序入口
├── setup.py # 安装脚本
├── requirements.txt # 依赖清单
├── USAGE.md # 使用说明
└── README.md # 本文件
```
## 功能特性
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔平面测量
- **宽度差值检查**: 任务18自动生成任务19,检查门口上、中、下位置的宽度差值(±2mm公差)
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
- **结果导出**: 自动生成格式化的Excel报告和详细日志
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声,支持稀疏点云(可调阈值)
- **性能优化**: 点云缓存、详细性能分析报告
- **错误处理**: 单个任务失败不影响其他任务执行
- **可扩展**: 易于添加新的测量类型
## 环境要求
- Python 3.9+
- 推荐使用 conda 环境管理
## 快速开始
### 1. 创建并激活环境
```bash
# 创建 conda 环境
conda create -n pcc-experiment python=3.9
conda activate pcc-experiment
```
### 2. 安装依赖
**方式一:使用 requirements.txt**
```bash
pip install -r requirements.txt
```
**方式二:安装项目**
```bash
# 正常安装
pip install .
# 或开发模式安装(代码修改立即生效)
pip install -e .
```
安装项目会自动安装所有依赖包,并将项目注册到 Python 环境中。
### 2. 准备点云文件
确保你有完整的列车点云文件(PLY格式),例如:
```
D:\lab\PCC\PCC\head_train_pointcloud\train_2_100M.ply
```
### 3. 配置测量任务
编辑 `config/measurements_0610.yaml`,设置点云文件路径和测量任务。
**支持相对路径(相对于配置文件)或绝对路径:**
```yaml
# 点云文件路径
pointcloud_file: "点云存放路径/train_2_1T.ply"
tasks:
- name: "任务18_门口宽度"
type: "plane_to_plane"
enabled: true
description: "乘客门口宽度"
theoretical_dimension: 1070
tolerance:
upper: 3
lower: 0
source_roi:
bbox_min: [-2552, 1390, 1402]
bbox_max: [-2551, 3080, 1577]
target_roi:
bbox_min: [-1482, 1400, 1513]
bbox_max: [-1481, 3120, 1591]
params:
sample_points: 100
ransac_threshold: 0.1
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
check_width_variation: true # 启用宽度差值检查(任务19)
width_variation_tolerance: 2.0 # 差值公差 ±2mm
- name: "任务43_窗口下沿高度"
type: "plane_to_plane"
enabled: true
description: "测量窗口下沿到地面的距离"
theoretical_dimension: 803
tolerance:
upper: 6.5
lower: -4
source_roi:
bbox_min: [5562, 1981, 1672]
bbox_max: [6772, 1983, 1677]
target_roi:
bbox_min: [-10222, 1179, -302]
bbox_max: [12428, 1181, 302]
params:
sample_points: 100
ransac_threshold: 0.5 # 稀疏点云使用较大阈值
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
```
### 4. 运行测量
```bash
# 使用默认配置文件
python main.py
# 指定配置文件
python main.py -c config/measurements_0610.yaml
# 指定点云文件(覆盖配置文件中的路径)
python main.py -p /path/to/your/pointcloud.ply
# 指定输出目录
python main.py -o /path/to/output
# 组合使用
python main.py -c config/measurements_0610.yaml -o results/
```
**长时间任务建议在tmux中运行:**
```bash
tmux new-session -s measurement 'python main.py -c config/measurements_0610.yaml'
# 分离会话: Ctrl+B, D
# 重新附加: tmux attach -t measurement
```
**命令行参数:**
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
- `-o, --output`: 结果输出目录(默认: results/)
### 5. 查看结果
**Excel报告:** 保存在 `results/` 目录下
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
- 包含每个任务的详细统计信息:
- 项点名称、理论尺寸、公差要求
- 测量结果(平均距离)、精度(标准差)
- 误差范围、测量结论(通过/不通过)
- 执行时间、错误信息(如有)
**任务19特殊说明:**
- 当任务18启用 `check_width_variation: true` 时,自动生成任务19
- 任务19在Excel中单独一行,显示宽度差值检查结果
- 理论尺寸: 0,公差: ±2.0mm
- 测量结果: 最大差值
- 精度: 显示三个差值(上-中、中-下、上-下)
- 误差范围: 差值的最小和最大值
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
- 每个任务的详细执行过程
- ROI提取信息、RANSAC拟合详情
- 宽度差值检查详情(如适用)
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
- 各任务执行时间
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
- 平均耗时统计
- 点位参数(ROI 范围、目标点坐标等)
- 测量数据(平均距离、标准差、最小/最大距离)
- 采样点数、ROI 点数、内点率
- 错误信息(如果失败)
**可视化点云:** 保存在 `visualizations/` 目录下
- 文件格式:PLY
- 可使用CloudCompare或Open3D查看
- 用于验证ROI提取和测量点位置是否正确
## 测量类型详解
### 1. plane_to_plane(平面到平面距离)
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
**工作流程:**
1. 从source_roi包围盒提取点云
2. 使用RANSAC拟合源平面
3. 从拟合的内点中随机采样指定数量的点
4. 从target_roi包围盒提取点云
5. 使用RANSAC拟合目标平面
6. 计算采样点到目标平面的有向距离
7. 统计平均值、标准差、最小/最大距离
**配置参数:**
```yaml
type: "plane_to_plane"
source_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
target_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
params:
sample_points: 100 # 采样点数
ransac_threshold: 2.0 # RANSAC距离阈值(mm)
ransac_max_iterations: 1000 # RANSAC最大迭代次数
min_inlier_ratio: 0.6 # 最小内点比例
```
**输出结果:**
- 平均距离(mm)
- 标准差(mm)
- 最小/最大距离(mm)
- 采样点数
- Source ROI点数
- Target ROI点数
- 平面拟合内点率
**可视化文件:**
- `{任务名}_source_roi.ply`:源平面ROI点云
- `{任务名}_target_roi.ply`:目标平面ROI点云
---
### 2. point_to_plane(点到平面距离)
测量指定点到参考平面的距离。支持两种模式:精确坐标查找和包围盒筛选。
**工作流程:**
1. 根据模式获取测量点:
- `exact_points`模式:在点云中查找最近点
- `bbox_filtered`模式:从包围盒提取点云并筛选
2. 从target_roi提取点云并拟合参考平面(目前的实现中,直接用z=0平面,不做拟合)
3. 计算每个点到平面的距离
4. 统计平均值、标准差等
**配置参数(exact_points模式):**
```yaml
type: "point_to_plane"
mode: "exact_points"
points:
- [-753.5, 1052.5, 1445]
- [3446.5, 1053, 1445]
- [-753.5, 1052.5, -1445]
target_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
params:
search_radius: 50.0 # 查找最近点的搜索半径(mm)
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
use_absolute_distance: true # 是否使用绝对距离
```
**配置参数(bbox_filtered模式):**
```yaml
type: "point_to_plane"
mode: "bbox_filtered"
source_rois:
- bbox_min: [x1_min, y1_min, z1_min]
bbox_max: [x1_max, y1_max, z1_max]
- bbox_min: [x2_min, y2_min, z2_min]
bbox_max: [x2_max, y2_max, z2_max]
filter:
type: "max_abs_coordinate" # 筛选类型
axis: "z" # 筛选坐标轴
top_n: 10 # 每个ROI选择前N个点
target_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
params:
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
use_absolute_distance: true
```
**输出结果:**
- 平均距离(mm)
- 标准差(mm)
- 最小/最大距离(mm)
- 测量点数
- Target ROI点数
- 平面拟合内点率
**可视化文件:**
- `{任务名}_found_points.ply`:测量点点云
---
### 3. point_to_point(点到点距离)
测量点对之间的欧氏距离。支持对称点自动配对(例如测量关于z=0对称的点对)。
**工作流程:**
1. 获取测量点:在点云中查找最近点
2. 根据z坐标符号自动配对(z>0和z<0的点配对)
3. 计算每对点之间的欧氏距离
4. 统计平均值、标准差等
**配置参数:**
``` yaml
type: "point_to_point"
# 点对配置(使用任务3的6个点,配对测量)
point_pairs:
- name: "前端点对"
point1: [-753.5, 1052.5, -1445]
point2: [-753.5, 1052.5, 1445]
- name: "中间点对"
point1: [3446.5, 1053, -1445]
point2: [3446.5, 1053, 1445]
- name: "后端点对"
point1: [8146.5, 1053, -1445]
point2: [8146.5, 1053, 1445]
# 最近点搜索半径(mm)
search_radius: 10.0
```
**输出结果:**
- 平均距离(mm)
- 标准差(mm)
- 最小/最大距离(mm)
- 点对数量
**可视化文件:**
- `{任务名}_found_points.ply`:包含测量点的点云
---
## 配置文件说明
### 基本结构
```yaml
# 点云文件路径(必填)
pointcloud_file: "D:\\path\\to\\pointcloud.ply"
# 测量任务列表
tasks:
- name: "任务名称"
type: "测量类型"
enabled: true/false
description: "任务描述"
visualization_dir: "visualizations"
# ... 其他参数
```
### 通用参数
- `name`:任务名称(必填),用于标识任务和命名输出文件
- `type`:测量类型(必填),可选值:`plane_to_plane`、`point_to_plane`、`point_to_point`
- `enabled`:是否启用任务(必填),设为`false`可跳过该任务
- `description`:任务描述(可选),用于文档说明
- `visualization_dir`:可视化输出目录(可选),设置后会保存ROI点云文件
### 包围盒格式
所有包围盒使用以下格式,坐标单位为毫米(mm):
```yaml
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
```
**坐标系说明:**
- X轴:列车横向(车头到车尾)
- Y轴:列车高度(底部到顶部)
- Z轴:列车纵向(左侧到右侧)
### RANSAC参数调优
RANSAC用于平面拟合,以下参数影响拟合质量:
- `ransac_threshold`:内点距离阈值(mm)
- 较小值(1.0-2.0):严格拟合,适合高质量点云
- 较大值(3.0-5.0):宽松拟合,适合噪声较多的点云
- `ransac_max_iterations`:最大迭代次数
- 默认1000次,通常足够
- 如果内点率很低,可增加到2000-5000
- `min_inlier_ratio`:最小内点比例
- 默认0.6(60%),表示至少60%的点在平面上
- 如果点云包含多个平面或噪声多,可降低到0.4-0.5
**调优建议:**
1. 先运行一次,查看内点率
2. 如果内点率低于min_inlier_ratio,任务会失败
3. 根据实际内点率调整参数:
- 内点率50%-60%:降低min_inlier_ratio或增大ransac_threshold
- 内点率>90%:可以提高要求,减小ransac_threshold
---
## 添加新任务
### 步骤1:确定测量类型
根据测量需求选择合适的类型:
- **平面到平面距离**:车门高度、车体长度、窗口高度等
- **点到平面距离**:特定点到参考平面的高度
- **点到点距离**:对称点对之间的距离
### 步骤2:确定ROI包围盒
**方法1:使用点云浏览器工具**
**方法2:使用CloudCompare或Open3D或Freecad手动查看**
- 打开点云文件
- 使用测量工具确定目标区域的坐标范围
- 记录包围盒的最小和最大坐标
### 步骤3:编辑配置文件
在 `config/measurements.yaml` 中添加新任务:
```yaml
- name: "新任务名称"
type: "plane_to_plane" # 或其他类型
enabled: true
description: "任务描述"
visualization_dir: "visualizations"
source_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
target_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
params:
sample_points: 100
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
```
### 步骤4:运行测试
```bash
python main.py
```
### 步骤5:验证结果
1. **查看Excel报告**:检查测量结果是否合理
2. **查看可视化点云**:使用CloudCompare或Open3D打开PLY文件
- 确认ROI提取的位置正确
- 确认平面拟合质量(内点率)
3. **调整参数**:如果结果不理想,调整包围盒或RANSAC参数
---
## 技术细节
### 平面拟合算法
系统使用Open3D的RANSAC算法进行平面拟合:
1. **随机采样**:从点云中随机选择3个点
2. **拟合平面**:计算平面方程 ax + by + cz + d = 0
3. **统计内点**:计算所有点到平面的距离,距离小于阈值的为内点
4. **迭代优化**:重复上述步骤,选择内点最多的平面
5. **最终拟合**:使用所有内点进行最小二乘拟合(SVD),得到最优平面
**平面方程:** ax + by + cz + d = 0
- (a, b, c):平面法向量(单位向量)
- d:平面到原点的有向距离
### 距离计算
**点到平面距离:**
```
distance = |ax + by + cz + d| / sqrt(a² + b² + c²)
```
由于法向量已归一化(sqrt(a² + b² + c²) = 1),简化为:
```
distance = |ax + by + cz + d|
```
**有向距离:**
- 正值:点在法向量指向的一侧
- 负值:点在法向量相反的一侧
- 绝对距离:取绝对值,不考虑方向
**点到点距离(欧氏距离):**
```
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
```
### 性能优化
- **点云缓存**:点云文件只加载一次,所有任务共享
- **自动降采样**:ROI超过10000点时自动降采样到10000点进行SVD拟合
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务

View File

@@ -1,119 +0,0 @@
# 使用说明
## 环境安装
### 1. 创建 Conda 环境
```bash
conda create -n pcc-experiment python=3.9
conda activate pcc-experiment
```
### 2. 安装依赖包
**方式一:使用 requirements.txt**
```bash
pip install -r requirements.txt
```
**方式二:安装项目**
```bash
# 正常安装
pip install .
# 或开发模式安装(代码修改立即生效)
pip install -e .
```
安装项目会自动安装所有依赖包,并将项目添加到 Python 环境中。
## 配置测量任务
编辑 `config/measurements_0610.yaml` 文件:
### 1. 设置点云文件路径
```yaml
# 支持相对路径(相对于配置文件)或绝对路径
pointcloud_file: "点云路径/train_2_1T.ply"
```
### 2. 配置测量任务
```yaml
tasks:
- name: "任务名称"
type: "plane_to_plane" # 任务类型: plane_to_plane, point_to_plane, point_to_point
enabled: true # 是否启用
description: "任务描述"
visualization_dir: "visualizations" # 可视化输出目录(可选)
# 根据任务类型配置相应参数
source_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
target_roi:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
params:
sample_points: 100
ransac_threshold: 2.0
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
```
## 运行测量
### 基本用法
```bash
# 使用默认配置文件
python main.py
# 指定配置文件
python main.py -c config/custom.yaml
# 指定点云文件(覆盖配置文件中的路径)
python main.py -p data/train.ply
# 指定输出目录
python main.py -o results/output
# 组合使用
python main.py -c config/custom.yaml -p data/train.ply -o results/
```
### 命令行参数
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
- `-o, --output`: 结果输出目录(默认: results/)
## 查看结果
### Excel 结果文件
位置: `results/measurement_results_YYYYMMDD_HHMMSS.xlsx`
包含内容:
- 任务名称、类型、描述
- 点位参数(ROI 范围、目标点坐标等)
- 测量数据(平均距离、标准差、最小/最大距离)
- 采样点数、ROI 点数、内点率
- 错误信息(如果失败)
### 可视化文件
位置: `visualizations/*.ply`
使用 CloudCompare 或 MeshLab 打开查看:
## 常见问题
### 任务没有执行
检查配置文件中 `enabled: true` 是否设置
### 点云文件找不到
- 使用绝对路径,或
- 使用相对于配置文件的相对路径,或
- 使用 `-p` 参数指定点云文件
### 测量结果异常
- 检查 ROI 包围盒是否正确
- 查看 Excel 中的内点率(建议 >70%)
- 打开 PLY 文件检查 ROI 提取是否准确

View File

@@ -1,79 +0,0 @@
"""
单文件快速检测脚本
"""
from pathlib import Path
from hole_detector import HoleDetector
def quick_detect(ply_path, output_dir="results", axis_idx=None):
"""
快速检测单个点云文件的孔中心
Args:
ply_path: 点云文件路径
output_dir: 输出目录
axis_idx: 圆柱轴向 (0=X, 1=Y, 2=Z),None则自动检测
"""
ply_path = Path(ply_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"\n{'='*70}")
print(f"快速检测: {ply_path.name}")
print(f"{'='*70}\n")
# 创建检测器
detector = HoleDetector(axis_idx=axis_idx)
# 加载点云
points = detector.load_pointcloud(ply_path)
# 检测孔
result = detector.detect_hole(points)
# 获取3D圆心
center_3d = detector.get_3d_center(points)
# 生成可视化
vis_path = output_dir / f"{ply_path.stem}_detection.png"
detector.visualize(vis_path)
# 输出结果
print(f"\n{'='*70}")
print(f"[OK] 检测完成!")
print(f"{'='*70}")
print(f"3D 圆心坐标:")
print(f" X = {center_3d[0]:.3f}")
print(f" Y = {center_3d[1]:.3f}")
print(f" Z = {center_3d[2]:.3f}")
print(f"\n半径: {result['radius']:.3f}")
print(f"轴向: {result['axis_name']}")
print(f"\n统计:")
print(f" 圆内点数: {result['stats']['inside_count']} ({result['stats']['inside_ratio']*100:.1f}%)")
print(f" 圆环点数: {result['stats']['ring_count']} ({result['stats']['ring_ratio']*100:.1f}%)")
print(f"\n可视化图已保存: {vis_path}")
print(f"{'='*70}\n")
return center_3d, result['radius']
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("用法: python detect.py <ply文件路径> [输出目录] [轴向:X|Y|Z]")
print("\n示例:")
print(" python detect.py train_2_1T_noisy_cropped.ply")
print(" python detect.py hole.ply results Z")
sys.exit(1)
ply_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "results"
axis_idx = None
if len(sys.argv) > 3:
axis_map = {'X': 0, 'Y': 1, 'Z': 2}
axis_idx = axis_map.get(sys.argv[3].upper())
quick_detect(ply_path, output_dir, axis_idx)

View File

@@ -1,407 +0,0 @@
"""
圆柱孔检测工具
用于检测点云中圆柱孔的中心位置和半径
"""
import numpy as np
import open3d as o3d
import matplotlib.pyplot as plt
from scipy.optimize import minimize, least_squares
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial']
plt.rcParams['axes.unicode_minus'] = False
class HoleDetector:
"""圆柱孔检测器"""
def __init__(self, axis_idx=None, search_range=0.3, max_radius_ratio=0.4):
"""
初始化检测器
Args:
axis_idx: 圆柱轴向索引 (0=X, 1=Y, 2=Z),None则自动检测
search_range: 搜索范围(相对于边界框的比例)
max_radius_ratio: 最大半径(相对于边界框的比例)
"""
self.axis_idx = axis_idx
self.search_range = search_range
self.max_radius_ratio = max_radius_ratio
self.result = None
def load_pointcloud(self, ply_path):
"""加载点云文件"""
pcd = o3d.io.read_point_cloud(str(ply_path))
points = np.asarray(pcd.points)
print(f"[OK] 加载点云: {points.shape[0]} 个点")
print(f" 边界: X[{points[:, 0].min():.2f}, {points[:, 0].max():.2f}] "
f"Y[{points[:, 1].min():.2f}, {points[:, 1].max():.2f}] "
f"Z[{points[:, 2].min():.2f}, {points[:, 2].max():.2f}]")
return points
def auto_detect_axis(self, points):
"""
自动检测圆柱轴向
策略:圆柱孔投影后形成圆环,点到中心距离的变异系数最小
"""
axes_names = ['X', 'Y', 'Z']
n_points = len(points)
# 计算各轴范围
ranges = []
for i in range(3):
axis_range = points[:, i].max() - points[:, i].min()
ranges.append(axis_range)
# 对于所有点云:使用投影点距离中心的变异系数
coef_vars = []
for i in range(3):
other_axes = [0, 1, 2]
other_axes.remove(i)
proj = points[:, other_axes]
# 计算投影点到中心的距离
center = proj.mean(axis=0)
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
# 变异系数 = 标准差 / 平均值
# 圆环:所有点距离相近,变异系数小
# 圆盘:点距离分散,变异系数大
mean_dist = distances.mean()
std_dist = distances.std()
cv = std_dist / mean_dist if mean_dist > 0 else float('inf')
coef_vars.append(cv)
axis_idx = np.argmin(coef_vars) # 选择变异系数最小的
print(f"[OK] 检测到圆柱轴向: {axes_names[axis_idx]}")
print(f" 各轴范围: X={ranges[0]:.2f}, Y={ranges[1]:.2f}, Z={ranges[2]:.2f}")
print(f" 变异系数: X={coef_vars[0]:.3f}, Y={coef_vars[1]:.3f}, Z={coef_vars[2]:.3f}")
return axis_idx
def _fit_circle_least_squares(self, points_2d):
"""
使用最小二乘法拟合圆
Args:
points_2d: Nx2 数组
Returns:
center: [cx, cy]
radius: float
residuals: 残差数组
"""
n = len(points_2d)
x = points_2d[:, 0]
y = points_2d[:, 1]
# 代数拟合(初始估计)
A = np.column_stack([x, y, np.ones(n)])
b = -(x**2 + y**2)
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
a, b_param, c = params
cx = -a / 2
cy = -b_param / 2
r = np.sqrt(cx**2 + cy**2 - c)
# 非线性优化
def residual_func(p):
cx, cy, r = p
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
return distances - r
result = least_squares(residual_func, [cx, cy, r], loss='soft_l1')
cx_opt, cy_opt, r_opt = result.x
# 计算残差
distances = np.sqrt((x - cx_opt)**2 + (y - cy_opt)**2)
residuals = np.abs(distances - r_opt)
return np.array([cx_opt, cy_opt]), r_opt, residuals
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
"""
使用RANSAC拟合圆
Args:
points_2d: Nx2 数组
threshold: 内点阈值(自动调整)
max_iterations: 最大迭代次数
Returns:
center: [cx, cy]
radius: float
inlier_mask: bool数组
"""
n = len(points_2d)
# 自动确定阈值
if threshold is None:
center_approx = points_2d.mean(axis=0)
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
median_dist = np.median(distances)
# 阈值为半径的8-12%,适应不同点云密度
threshold = max(0.3, median_dist * 0.1)
print(f" RANSAC阈值: {threshold:.3f}")
best_inliers = None
best_center = None
best_radius = None
best_n_inliers = 0
# RANSAC迭代
iterations = min(max_iterations, max(100, n * 10))
for i in range(iterations):
if n < 3:
break
# 随机选3个点
indices = np.random.choice(n, 3, replace=False)
sample = points_2d[indices]
try:
center, radius, _ = self._fit_circle_least_squares(sample)
except:
continue
# 计算内点
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
residuals = np.abs(distances - radius)
inliers = residuals < threshold
n_inliers = np.sum(inliers)
if n_inliers > best_n_inliers:
best_n_inliers = n_inliers
best_inliers = inliers
best_center = center
best_radius = radius
# 用所有内点重新拟合
if best_inliers is not None and np.sum(best_inliers) >= 3:
inlier_points = points_2d[best_inliers]
best_center, best_radius, _ = self._fit_circle_least_squares(inlier_points)
# 重新计算内点
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
residuals = np.abs(distances - best_radius)
best_inliers = residuals < threshold
else:
# RANSAC失败,用所有点拟合
print(" [WARNING] RANSAC未找到足够内点,使用所有点拟合")
best_center, best_radius, residuals = self._fit_circle_least_squares(points_2d)
best_inliers = residuals < threshold
inlier_ratio = np.sum(best_inliers) / n
print(f" 内点比例: {inlier_ratio*100:.1f}% ({np.sum(best_inliers)}/{n})")
return best_center, best_radius, best_inliers
def detect_hole(self, points):
"""
检测圆柱孔的中心和半径
Returns:
dict: {
'center_2d': [x, y], # 投影平面上的圆心
'radius': float, # 半径
'axis_idx': int, # 轴向索引
'axis_name': str, # 轴名称
'projection': ndarray,# 投影点云
'distances': ndarray, # 点到圆心的距离
'stats': dict # 统计信息
}
"""
# 自动检测轴向(如果未指定)
if self.axis_idx is None:
axis_idx = self.auto_detect_axis(points)
else:
axis_idx = self.axis_idx
axis_names = ['X', 'Y', 'Z']
other_axes = [0, 1, 2]
other_axes.remove(axis_idx)
# 投影到垂直平面
proj = points[:, other_axes]
x_min, x_max = proj[:, 0].min(), proj[:, 0].max()
y_min, y_max = proj[:, 1].min(), proj[:, 1].max()
x_range = x_max - x_min
y_range = y_max - y_min
# 初始中心(边界框中心)
x_init = (x_min + x_max) / 2
y_init = (y_min + y_max) / 2
print(f"[OK] 投影到垂直于 {axis_names[axis_idx]} 的平面")
# 使用RANSAC + 最小二乘法拟合圆
center_2d, radius, inlier_mask = self._fit_circle_ransac(proj)
print(f"[OK] 圆拟合完成:")
print(f" 圆心: ({center_2d[0]:.3f}, {center_2d[1]:.3f})")
print(f" 半径: {radius:.3f}")
# 计算统计信息
distances = np.sqrt((proj[:, 0] - center_2d[0])**2 +
(proj[:, 1] - center_2d[1])**2)
# 使用自适应阈值定义圆内和圆环
# 圆内:距离 < 半径 * 0.8
# 圆环:半径 * 0.8 <= 距离 <= 半径 * 1.2
inside_threshold = radius * 0.8
ring_outer_threshold = radius * 1.2
inside_count = np.sum(distances < inside_threshold)
ring_count = np.sum((distances >= inside_threshold) & (distances <= ring_outer_threshold))
outside_count = len(distances) - inside_count - ring_count
print(f" 圆内点: {inside_count} ({100*inside_count/len(proj):.1f}%)")
print(f" 圆环点: {ring_count} ({100*ring_count/len(proj):.1f}%)")
print(f" 圆外点: {outside_count} ({100*outside_count/len(proj):.1f}%)")
# 保存结果
self.result = {
'center_2d': center_2d,
'radius': radius,
'axis_idx': axis_idx,
'axis_name': axis_names[axis_idx],
'other_axes': other_axes,
'other_axes_names': [axis_names[i] for i in other_axes],
'projection': proj,
'distances': distances,
'stats': {
'inside_count': inside_count,
'ring_count': ring_count,
'outside_count': outside_count,
'inside_ratio': inside_count / len(proj),
'ring_ratio': ring_count / len(proj),
'total_points': len(proj)
}
}
return self.result
def visualize(self, output_path):
"""
生成轴视角示意图
显示:圆心位置、拟合的圆、投影点的分布
"""
if self.result is None:
raise ValueError("请先调用 detect_hole() 进行检测")
r = self.result
proj = r['projection']
center = r['center_2d']
radius = r['radius']
distances = r['distances']
# 分类点(使用与统计一致的标准)
inside_threshold = radius * 0.8
ring_outer_threshold = radius * 1.2
inside_mask = distances < inside_threshold
ring_mask = (distances >= inside_threshold) & (distances <= ring_outer_threshold)
outside_mask = distances > ring_outer_threshold
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# === 子图1: 全局视图 ===
ax1 = axes[0]
ax1.scatter(proj[outside_mask, 0], proj[outside_mask, 1],
s=1, alpha=0.3, c='gray', label='外部点')
ax1.scatter(proj[ring_mask, 0], proj[ring_mask, 1],
s=3, alpha=0.6, c='blue', label=f'圆环点 ({r["stats"]["ring_count"]})')
ax1.scatter(proj[inside_mask, 0], proj[inside_mask, 1],
s=5, alpha=0.8, c='red', label=f'圆内点 ({r["stats"]["inside_count"]})')
# 画圆心
ax1.plot(center[0], center[1], 'g*', markersize=20,
label=f'圆心 ({center[0]:.2f}, {center[1]:.2f})', zorder=10)
# 画拟合的圆
circle = plt.Circle(center, radius, color='green', fill=False,
linewidth=2.5, label=f'半径 {radius:.2f}')
ax1.add_patch(circle)
ax1.set_xlabel(r['other_axes_names'][0], fontsize=12)
ax1.set_ylabel(r['other_axes_names'][1], fontsize=12)
ax1.set_title(f'垂直于 {r["axis_name"]} 轴的投影 - 全局视图', fontsize=13, fontweight='bold')
ax1.set_aspect('equal')
ax1.legend(loc='upper right', fontsize=9)
ax1.grid(True, alpha=0.3)
# === 子图2: 局部放大视图 ===
ax2 = axes[1]
zoom_range = radius * 2.5
# 只显示放大区域内的点
zoom_mask = ((proj[:, 0] >= center[0] - zoom_range) &
(proj[:, 0] <= center[0] + zoom_range) &
(proj[:, 1] >= center[1] - zoom_range) &
(proj[:, 1] <= center[1] + zoom_range))
proj_zoom = proj[zoom_mask]
dist_zoom = distances[zoom_mask]
inside_threshold = radius * 0.8
ring_outer_threshold = radius * 1.2
inside_z = dist_zoom < inside_threshold
ring_z = (dist_zoom >= inside_threshold) & (dist_zoom <= ring_outer_threshold)
outside_z = dist_zoom > ring_outer_threshold
ax2.scatter(proj_zoom[outside_z, 0], proj_zoom[outside_z, 1],
s=3, alpha=0.4, c='gray')
ax2.scatter(proj_zoom[ring_z, 0], proj_zoom[ring_z, 1],
s=6, alpha=0.7, c='blue')
ax2.scatter(proj_zoom[inside_z, 0], proj_zoom[inside_z, 1],
s=10, alpha=0.9, c='red')
ax2.plot(center[0], center[1], 'g*', markersize=25, zorder=10)
circle2 = plt.Circle(center, radius, color='green', fill=False, linewidth=3)
ax2.add_patch(circle2)
ax2.set_xlim(center[0] - zoom_range, center[0] + zoom_range)
ax2.set_ylim(center[1] - zoom_range, center[1] + zoom_range)
ax2.set_xlabel(r['other_axes_names'][0], fontsize=12)
ax2.set_ylabel(r['other_axes_names'][1], fontsize=12)
ax2.set_title(f'局部放大 - 验证圆内是否为空', fontsize=13, fontweight='bold')
ax2.set_aspect('equal')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches='tight')
plt.close()
print(f"[OK] 保存可视化结果: {output_path}")
def get_3d_center(self, points):
"""
获取3D空间中的圆心坐标(沿轴向取中点)
Returns:
ndarray: [x, y, z] 3D圆心坐标
"""
if self.result is None:
raise ValueError("请先调用 detect_hole() 进行检测")
r = self.result
axis_idx = r['axis_idx']
other_axes = r['other_axes']
# 沿轴向取中点
axis_center = (points[:, axis_idx].min() + points[:, axis_idx].max()) / 2
# 构建3D坐标
center_3d = np.zeros(3)
center_3d[other_axes[0]] = r['center_2d'][0]
center_3d[other_axes[1]] = r['center_2d'][1]
center_3d[axis_idx] = axis_center
return center_3d

View File

@@ -0,0 +1,286 @@
# 列车点云测量系统 V3
基于配置驱动的点云测量系统,支持多种测量任务类型和ROI提取方法。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、孔中心检测、距离计算,并生成Excel报告和可视化点云文件。
## 项目结构
```
train_measurement_system_v3_0626/
├── main.py # 主程序入口
├── requirements.txt # 依赖清单
├── setup.py # 安装脚本
├── pyproject.toml # 项目配置
├── README.md # 本文件
├── config/ # 配置文件目录
│ ├── measurements.yaml # 主配置文件
├── src/ # 源代码目录
│ ├── __init__.py
│ ├── config_loader.py # 配置加载器
│ ├── task_manager.py # 任务管理器
│ ├── pointcloud_loader.py # 点云加载器
│ ├── roi_extractor.py # ROI提取器
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
│ ├── hole_detector.py # 高精度孔检测器 V2
│ ├── hole_center_fitter.py # 孔中心拟合适配器
│ ├── excel_exporter.py # Excel结果导出器
│ ├── visualizer.py # 可视化工具
│ ├── performance_logger.py # 性能日志记录器
│ ├── measurement_logger.py # 详细测量日志记录器
│ ├── measurements/ # 测量类型模块
│ │ ├── __init__.py
│ │ ├── base.py # 测量任务基类
│ │ ├── plane_distance.py # 平面到平面距离测量
│ │ ├── point_to_plane.py # 点到平面距离测量
│ │ ├── point_to_point.py # 点到点距离测量
│ │ ├── plane_with_holes.py # 带孔平面测量
│ │ ├── point_to_plane_with_holes.py
│ │ └── point_to_point_with_holes.py
│ └── spatial_index/ # 空间索引模块(ROI提取优化)
│ ├── __init__.py
│ ├── base.py # 索引基类
│ ├── linear_scan.py # 线性扫描(无索引)
│ ├── grid_index.py # Grid网格索引
│ ├── grid_index_optimized.py # Grid网格索引(优化版)
│ ├── octree_index.py # Octree八叉树索引
│ └── kdtree_index.py # KDTree索引
├── results/ # 测量结果输出目录(自动创建)
└── visualizations/ # 可视化点云输出目录(自动创建)
```
## 功能特性
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔中心检测的测量
- **多种ROI提取方法**: Linear、Grid、Grid优化版、Octree、KDTree,性能和精度可调
- **高精度孔检测**: 基于RANSAC和几何优化的孔中心检测算法(V2),精度<0.1mm
- **宽度差值检查**: 支持门口宽度差值自动检查(任务19)
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
- **结果导出**: 自动生成格式化的Excel报告和详细日志
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声
- **性能优化**: 点云缓存、空间索引加速、详细性能分析报告
- **错误处理**: 单个任务失败不影响其他任务执行
## 环境要求
- Python 3.9+
- 推荐使用 conda 环境管理
## 快速开始
### 1. 创建并激活环境
```bash
# 创建 conda 环境
conda create -n pcc-measurement python=3.9
conda activate pcc-measurement
```
### 2. 安装依赖
**方式一:使用 requirements.txt**
```bash
pip install -r requirements.txt
```
**方式二:安装项目**
```bash
# 正常安装
pip install .
# 或开发模式安装(代码修改立即生效)
pip install -e .
```
### 3. 准备点云文件
确保你有完整的列车点云文件(PLY格式),例如:
```
/path/to/your/train_pointcloud.ply
```
### 4. 配置测量任务
编辑 `config/measurements.yaml`,设置点云文件路径和测量任务。
**基本配置示例:**
```yaml
# 点云文件路径(支持相对路径或绝对路径)
pointcloud_file: "/path/to/your/train_pointcloud.ply"
# ROI提取方法配置
roi_extraction:
method: "grid" # 可选: linear, grid, octree, kdtree
grid_size: 100.0 # Grid方法的网格大小(mm)
use_optimized: true # 使用优化版Grid(numpy向量化)
# 测量任务列表
tasks:
- name: "任务43_窗口下沿高度"
type: "plane_to_plane"
enabled: true
description: "测量窗口下沿到地面的距离"
theoretical_dimension: 803
tolerance:
upper: 6.5
lower: -4
source_roi:
bbox_min: [5562, 1981, 1672]
bbox_max: [6772, 1983, 1677]
target_roi:
bbox_min: [-10222, 1179, -302]
bbox_max: [12428, 1181, 302]
params:
sample_points: 100
ransac_threshold: 0.5
ransac_max_iterations: 1000
min_inlier_ratio: 0.6
visualization: false # 是否保存可视化点云
```
### 5. 运行测量
```bash
# 使用默认配置文件
python main.py
# 指定配置文件
python main.py -c config/measurements.yaml
# 指定点云文件(覆盖配置文件中的路径)
python main.py -p /path/to/your/pointcloud.ply
# 指定输出目录
python main.py -o /path/to/output
# 组合使用
python main.py -c config/measurements.yaml -o results/
```
**命令行参数:**
- `-c, --config`: 配置文件路径
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
- `-o, --output`: 结果输出目录(默认: results/)
### 6. 查看结果
**Excel报告:** 保存在 `results/` 目录下
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
- 包含每个任务的详细统计信息:
- 项目名称、理论尺寸、公差要求
- 测量结果(平均距离)、精度(标准差)
- 误差范围、测量结论(通过/不通过)
- 执行时间、错误信息(如有)
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
- 每个任务的详细执行过程
- ROI提取信息、RANSAC拟合详情
- 宽度差值检查详情(如适用)
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
- 各任务执行时间
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
- 空间索引构建时间和方法
**可视化点云:** 保存在 `visualizations/` 目录下
- 文件格式:PLY
- 可使用CloudCompare或Open3D查看
- 用于验证ROI提取和测量点位置
## 测量类型
### 1. plane_to_plane(平面到平面距离)
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
### 2. point_to_plane(点到平面距离)
测量指定点到参考平面的距离。支持精确坐标查找和包围盒筛选模式。
### 3. point_to_point(点到点距离)
测量点对之间的欧氏距离。支持对称点自动配对。
### 4. 带孔中心检测的测量
支持基于孔中心的平面拟合和距离测量(`plane_with_holes`, `point_to_plane_with_holes`, `point_to_point_with_holes`)。
## ROI提取方法
系统支持多种空间索引方法,可在配置文件中选择:
- **linear**: 线性扫描(无索引)- 最简单,适合小点云
- **grid**: Grid网格索引 - 平衡性能和精度
- **grid (optimized)**: Grid网格索引优化版 - 使用numpy向量化,性能提升显著
- **octree**: Octree八叉树索引 - 适合大规模点云
- **kdtree**: KDTree索引 - 高效的空间查询
## 技术细节
### 平面拟合算法
系统使用Open3D的RANSAC算法进行平面拟合:
1. 随机采样3个点拟合平面
2. 统计内点(距离小于阈值)
3. 迭代优化选择最佳平面
4. 使用所有内点进行最小二乘拟合(SVD)
**平面方程:** ax + by + cz + d = 0
### 孔检测算法
高精度孔检测器V2特点:
- 自动检测圆柱轴向
- 多次RANSAC粗拟合
- 高质量点筛选
- 几何拟合精化
- 圆心精度 < 0.1mm
### 距离计算
**点到平面距离:**
```
distance = |ax + by + cz + d|
```
**点到点距离(欧氏距离):**
```
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
```
## 性能优化
- **点云缓存**: 点云文件只加载一次,所有任务共享
- **空间索引**: 使用Grid/Octree/KDTree加速ROI提取
- **自动降采样**: ROI超过10000点时自动降采样
- **并行安全**: 多个任务顺序执行,单个任务失败不影响其他任务
## 依赖包
- `numpy>=2.0.0` - 数值计算
- `open3d>=0.18.0` - 点云处理
- `scipy>=1.10.0` - 科学计算
- `pandas>=2.0.0` - 数据处理
- `openpyxl>=3.1.0` - Excel导出
- `PyYAML>=6.0.0` - 配置文件解析
- `matplotlib>=3.7.0` - 可视化(可选)
- `plotly>=5.0.0` - 可视化(可选)
## 版本更新
### V3 (2024-06-26)
- 重构代码结构,模块化更清晰
- 集成高精度孔检测器V2到src目录
- 优化导入路径,移除路径hack
- 精简配置文件
- 更新文档
---
**开发环境:** Python 3.9+
**推荐工具:** CloudCompare(点云查看)、VS Code(代码编辑)

View File

@@ -0,0 +1,100 @@
# 重构完成总结
## 重构时间
2024-06-26
## 重构内容
### 1. 代码结构优化
- ✅ 将 `detect_hole_v2/hole_detector.py` 集成到 `src/hole_detector.py`
- ✅ 更新 `src/hole_center_fitter.py` 的导入路径(移除路径hack)
- ✅ 删除旧的 `src/hole_detect/` 目录(未使用)
- ✅ 保留核心模块的清晰结构
### 2. 配置文件精简
只保留3个有效配置文件:
- `config/measurements_0624.yaml` - 主配置文件
- `config/measurements_0624_TODO.yaml` - 待处理任务配置
- `config/measurements_0624_origin_pc.yaml` - 原始点云配置
### 3. 文档更新
- ✅ 创建新的 README.md(功能完整的使用文档)
- ✅ 移除所有开发过程文档(0607.md, 0609.md等)
### 4. 目录结构
```
train_measurement_system_v3_0626/
├── main.py # 主程序入口
├── README.md # 使用文档
├── requirements.txt # 依赖清单
├── setup.py # 安装脚本
├── pyproject.toml # 项目配置
├── config/ # 配置文件(3个)
├── src/ # 源代码
│ ├── hole_detector.py # ✅ 从detect_hole_v2迁移
│ ├── hole_center_fitter.py # ✅ 更新导入路径
│ ├── measurements/ # 测量类型模块
│ └── spatial_index/ # 空间索引模块
├── results/ # 结果输出目录
└── visualizations/ # 可视化输出目录
```
## 测试运行结果
### 执行命令
```bash
/home/suzhe/miniconda3/envs/pcc-experiment/bin/python main.py -c config/measurements_0624.yaml
```
### 执行结果
- ✅ 总任务数: 19
- ✅ 成功: 19
- ✅ 失败: 0
- ⏱️ 总耗时: 约4分钟(包括点云加载和空间索引构建)
### 性能数据
- 点云数量: 1,000,000,000 个点(10亿点)
- 点云加载: 13.17秒(75.91 M点/秒)
- 空间索引构建: 214.62秒(Grid方法)
- 初始化总耗时: 227.80秒
### 生成文件
1. `measurement_results_20260626_211945.xlsx` - Excel测量结果报告
2. `measurement_detail_20260626_211905.log` - 详细测量日志
3. `performance_20260626_211905.log` - 性能分析报告
## 关键改进
### 代码质量
- ✅ 移除路径hack(sys.path.insert)
- ✅ 统一模块导入方式
- ✅ 清晰的目录结构
### 可维护性
- ✅ 代码模块化清晰
- ✅ 配置文件精简
- ✅ 文档完整
### 功能完整性
- ✅ 所有测量类型正常工作
- ✅ 孔检测功能正常
- ✅ Excel导出功能正常
- ✅ 性能日志功能正常
## 使用说明
### 快速启动
```bash
cd /home/suzhe/pcc/train_measurement_system_v3_0626
conda activate pcc-experiment
python main.py -c config/measurements_0624.yaml
```
### 查看结果
- Excel报告: `results/measurement_results_*.xlsx`
- 详细日志: `results/measurement_detail_*.log`
- 性能报告: `results/performance_*.log`
---
**重构完成!代码结构更清晰,功能完整可用。**

View File

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

View File

@@ -27,7 +27,7 @@ def parse_args():
parser.add_argument( parser.add_argument(
'-c', '--config', '-c', '--config',
type=str, type=str,
default='config/measurements.yaml', default='config/measurements_0617_test.yaml',
help='配置文件路径 (默认: config/measurements.yaml)' help='配置文件路径 (默认: config/measurements.yaml)'
) )

View File

@@ -0,0 +1,126 @@
"""边缘线ROI提取模块 - 两步法提取边缘线附近的点"""
import numpy as np
from typing import Tuple
class EdgeLineROIExtractor:
"""边缘线ROI提取器 - 两步法"""
@staticmethod
def create_line_bbox(point1: np.ndarray, point2: np.ndarray, margin: float = 5.0) -> Tuple[np.ndarray, np.ndarray]:
"""
根据线的两个端点创建包围盒
原则:如果某维度跨度>10mm就保持,否则该维度±margin
Args:
point1: 线的起点 [x, y, z]
point2: 线的终点 [x, y, z]
margin: 跨度小于10mm的维度的扩展边距(默认5mm)
Returns:
bbox_min, bbox_max: 包围盒的最小和最大坐标
"""
point1 = np.array(point1)
point2 = np.array(point2)
# 计算每个维度的范围
mins = np.minimum(point1, point2)
maxs = np.maximum(point1, point2)
spans = maxs - mins
# 应用规则:跨度>10就保持,否则±margin
bbox_min = np.zeros(3)
bbox_max = np.zeros(3)
for i in range(3):
if spans[i] > 10.0:
# 跨度足够大,保持原样
bbox_min[i] = mins[i]
bbox_max[i] = maxs[i]
else:
# 跨度太小,扩展±margin
center = (mins[i] + maxs[i]) / 2
bbox_min[i] = center - margin
bbox_max[i] = center + margin
return bbox_min, bbox_max
@staticmethod
def extract_two_step_roi(
points: np.ndarray,
line_point1: np.ndarray,
line_point2: np.ndarray,
spatial_index,
distance_threshold: float = 0.5,
bbox_margin: float = 5.0
) -> Tuple[np.ndarray, np.ndarray, dict]:
"""
两步法提取边缘线ROI
步骤1:用包围盒粗筛选
步骤2:用点到线距离精筛选(距离<threshold)
Args:
points: 完整点云 (N, 3)
line_point1: 线的起点 [x, y, z]
line_point2: 线的终点 [x, y, z]
spatial_index: 空间索引对象
distance_threshold: 点到线距离阈值(默认2mm)
bbox_margin: 包围盒扩展边距(默认5mm)
Returns:
fine_points: 精筛选后的点 (M, 3)
fine_indices: 精筛选点在原始点云中的索引
stats: 统计信息字典
"""
line_point1 = np.array(line_point1)
line_point2 = np.array(line_point2)
# 步骤1:创建包围盒并粗筛选
bbox_min, bbox_max = EdgeLineROIExtractor.create_line_bbox(
line_point1, line_point2, margin=bbox_margin
)
if spatial_index is not None:
coarse_indices = spatial_index.query_bbox(bbox_min, bbox_max)
else:
# 回退到线性扫描
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
coarse_indices = np.where(mask)[0]
coarse_points = points[coarse_indices]
if len(coarse_points) == 0:
return np.array([]).reshape(0, 3), np.array([], dtype=int), {
'coarse_count': 0,
'fine_count': 0,
'bbox_min': bbox_min.tolist(),
'bbox_max': bbox_max.tolist()
}
# 步骤2:计算点到线的距离,精筛选
line_direction = line_point2 - line_point1
line_direction = line_direction / np.linalg.norm(line_direction)
vectors = coarse_points - line_point1
projections = np.dot(vectors, line_direction)[:, np.newaxis] * line_direction
perpendicular_vectors = vectors - projections
distances = np.linalg.norm(perpendicular_vectors, axis=1)
# 筛选距离<threshold的点
fine_mask = distances < distance_threshold
fine_indices_in_coarse = np.where(fine_mask)[0]
fine_indices = coarse_indices[fine_indices_in_coarse]
fine_points = coarse_points[fine_indices_in_coarse]
stats = {
'coarse_count': len(coarse_points),
'fine_count': len(fine_points),
'bbox_min': bbox_min.tolist(),
'bbox_max': bbox_max.tolist(),
'distance_threshold': distance_threshold,
'bbox_margin': bbox_margin
}
return fine_points, fine_indices, stats

View File

@@ -326,20 +326,12 @@ class ExcelExporter:
conclusion = "未配置" conclusion = "未配置"
conclusion_cell = ws.cell(row=row, column=7) conclusion_cell = ws.cell(row=row, column=7)
if theoretical_dim is not None and tolerance is not None: if theoretical_dim is not None:
# 判断是否通过:误差范围必须在公差范围内 # 判断是否通过:误差范围必须在 [-0.11, +0.1] mm 内
error_min = min_distance - theoretical_dim error_min = min_distance - theoretical_dim
error_max = max_distance - theoretical_dim error_max = max_distance - theoretical_dim
# 解析公差范围 if error_min >= -0.11 and error_max <= 0.1:
if isinstance(tolerance, dict):
tolerance_lower = tolerance.get('lower', 0)
tolerance_upper = tolerance.get('upper', 0)
else:
tolerance_lower = -tolerance
tolerance_upper = tolerance
if error_min >= tolerance_lower and error_max <= tolerance_upper:
conclusion = "通过" conclusion = "通过"
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
conclusion_cell.font = Font(color="006100", bold=True) conclusion_cell.font = Font(color="006100", bold=True)

View File

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

View File

@@ -0,0 +1,288 @@
"""
高精度孔检测算法 v2.0
输入:点云文件路径或点云数组
输出:圆心3D坐标和半径
精度:圆心误差 < 0.1mm
"""
import numpy as np
import open3d as o3d
from scipy.optimize import least_squares
class HoleDetectorV2:
"""
高精度圆柱孔检测器
特点:
- 自动检测圆柱轴向
- 分层精化拟合
- 圆心精度 < 0.1mm
"""
def __init__(self, axis=None):
"""
初始化检测器
Args:
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
"""
self.axis = axis
self._axis_idx = None
def detect(self, input_data):
"""
检测孔的圆心和半径
Args:
input_data: 点云文件路径(str)或点云数组(Nx3 numpy array)
Returns:
dict: {
'center': [x, y, z], # 3D圆心坐标
'radius': float, # 半径
'axis': str # 轴向 ('X', 'Y', 'Z')
}
"""
# 加载点云
if isinstance(input_data, str):
points = self._load_from_file(input_data)
else:
points = np.asarray(input_data)
# 检测轴向
if self.axis is None:
self._axis_idx = self._auto_detect_axis(points)
else:
self._axis_idx = {'X': 0, 'Y': 1, 'Z': 2}[self.axis.upper()]
# 投影到垂直平面
other_axes = [0, 1, 2]
other_axes.remove(self._axis_idx)
proj_2d = points[:, other_axes]
# 拟合圆(多次RANSAC + 精化)
center_2d, radius = self._fit_circle_robust(proj_2d)
# 构建3D圆心
axis_center = (points[:, self._axis_idx].min() +
points[:, self._axis_idx].max()) / 2
center_3d = np.zeros(3)
center_3d[other_axes[0]] = center_2d[0]
center_3d[other_axes[1]] = center_2d[1]
center_3d[self._axis_idx] = axis_center
axis_names = ['X', 'Y', 'Z']
return {
'center': center_3d.tolist(),
'radius': float(radius),
'axis': axis_names[self._axis_idx]
}
def _load_from_file(self, ply_path):
"""从PLY文件加载点云"""
pcd = o3d.io.read_point_cloud(str(ply_path))
return np.asarray(pcd.points)
def _auto_detect_axis(self, points):
"""自动检测圆柱轴向"""
coef_vars = []
for i in range(3):
other_axes = [0, 1, 2]
other_axes.remove(i)
proj = points[:, other_axes]
center = proj.mean(axis=0)
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
# 变异系数:圆环小,圆盘大
cv = distances.std() / distances.mean() if distances.mean() > 0 else float('inf')
coef_vars.append(cv)
return np.argmin(coef_vars)
def _fit_circle_algebraic(self, points_2d):
"""代数拟合(快速初始估计)"""
n = len(points_2d)
x = points_2d[:, 0]
y = points_2d[:, 1]
A = np.column_stack([x, y, np.ones(n)])
b = -(x**2 + y**2)
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
a, b_param, c = params
cx = -a / 2
cy = -b_param / 2
r = np.sqrt(cx**2 + cy**2 - c)
return np.array([cx, cy]), r
def _fit_circle_geometric(self, points_2d, initial_guess=None):
"""几何拟合(最小化几何距离)"""
if initial_guess is None:
center, radius = self._fit_circle_algebraic(points_2d)
else:
center, radius = initial_guess
x = points_2d[:, 0]
y = points_2d[:, 1]
def residual_func(params):
cx, cy, r = params
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
return distances - r
result = least_squares(residual_func, [center[0], center[1], radius],
loss='soft_l1', f_scale=1.0)
cx_opt, cy_opt, r_opt = result.x
return np.array([cx_opt, cy_opt]), r_opt
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
"""RANSAC圆拟合"""
n = len(points_2d)
if threshold is None:
center_approx = points_2d.mean(axis=0)
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
threshold = max(0.5, np.median(distances) * 0.1)
best_inliers = None
best_center = None
best_radius = None
best_n_inliers = 0
for _ in range(max_iterations):
if n < 3:
break
indices = np.random.choice(n, 3, replace=False)
sample = points_2d[indices]
try:
center, radius = self._fit_circle_algebraic(sample)
except:
continue
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
residuals = np.abs(distances - radius)
inliers = residuals < threshold
n_inliers = np.sum(inliers)
if n_inliers > best_n_inliers:
best_n_inliers = n_inliers
best_inliers = inliers
best_center = center
best_radius = radius
if best_inliers is not None and np.sum(best_inliers) >= 3:
inlier_points = points_2d[best_inliers]
best_center, best_radius = self._fit_circle_geometric(inlier_points)
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
residuals = np.abs(distances - best_radius)
best_inliers = residuals < threshold
else:
best_center, best_radius = self._fit_circle_geometric(points_2d)
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
residuals = np.abs(distances - best_radius)
best_inliers = residuals < threshold
return best_center, best_radius, best_inliers
def _fit_circle_robust(self, points_2d, n_runs=5, refine_threshold=1.0):
"""
鲁棒圆拟合(分层精化)
Args:
points_2d: Nx2点云
n_runs: RANSAC运行次数
refine_threshold: 高质量点阈值(mm)
Returns:
center: [x, y]
radius: float
"""
# 步骤1: 多次RANSAC粗拟合
centers_list = []
radii_list = []
for _ in range(n_runs):
center, radius, _ = self._fit_circle_ransac(points_2d, max_iterations=1000)
centers_list.append(center)
radii_list.append(radius)
centers_array = np.array(centers_list)
radii_array = np.array(radii_list)
center_rough = np.median(centers_array, axis=0)
radius_rough = np.median(radii_array)
# 步骤2: 筛选高质量点
distances = np.sqrt((points_2d[:, 0] - center_rough[0])**2 +
(points_2d[:, 1] - center_rough[1])**2)
deviations = np.abs(distances - radius_rough)
high_quality_mask = deviations < refine_threshold
high_quality_points = points_2d[high_quality_mask]
if len(high_quality_points) < 10:
return center_rough, radius_rough
# 步骤3: 几何拟合精化
centers_fine_list = []
radii_fine_list = []
for _ in range(3):
center_f, radius_f = self._fit_circle_geometric(
high_quality_points,
initial_guess=(center_rough, radius_rough)
)
centers_fine_list.append(center_f)
radii_fine_list.append(radius_f)
centers_fine_array = np.array(centers_fine_list)
radii_fine_array = np.array(radii_fine_list)
center_final = np.median(centers_fine_array, axis=0)
radius_final = np.median(radii_fine_array)
return center_final, radius_final
# 便捷函数
def detect_hole(ply_path, axis=None):
"""
检测孔的圆心和半径(便捷函数)
Args:
ply_path: 点云文件路径或点云数组
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
Returns:
dict: {
'center': [x, y, z],
'radius': float,
'axis': str
}
Example:
result = detect_hole('hole.ply')
print(f"圆心: {result['center']}")
print(f"半径: {result['radius']}")
"""
detector = HoleDetectorV2(axis=axis)
return detector.detect(ply_path)
if __name__ == "__main__":
# 示例用法
result = detect_hole("test.ply")
print(f"圆心: {result['center']}")
print(f"半径: {result['radius']:.3f}")
print(f"轴向: {result['axis']}")

View File

@@ -0,0 +1,169 @@
"""直线拟合模块 - 使用RANSAC算法拟合3D直线"""
import numpy as np
from dataclasses import dataclass
from typing import Tuple, Optional
@dataclass
class LineModel:
"""直线模型"""
point: np.ndarray # 直线上的点 (3,)
direction: np.ndarray # 方向向量(单位向量) (3,)
inliers: np.ndarray # 内点索引
inlier_ratio: float # 内点比例
def distance_to_point(self, point: np.ndarray) -> float:
"""计算点到直线的距离"""
vector = point - self.point
projection = np.dot(vector, self.direction) * self.direction
perpendicular = vector - projection
return np.linalg.norm(perpendicular)
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
"""计算多个点到直线的距离"""
vectors = points - self.point
projections = np.dot(vectors, self.direction)[:, np.newaxis] * self.direction
perpendicular_vectors = vectors - projections
distances = np.linalg.norm(perpendicular_vectors, axis=1)
return distances
def sample_points_on_line(self, n_samples: int, point_cloud: np.ndarray) -> np.ndarray:
"""
从拟合的直线对应的真实点云中采样点
Args:
n_samples: 采样点数
point_cloud: 用于拟合的原始点云(内点)
Returns:
采样的点 (n_samples, 3)
"""
if len(point_cloud) == 0:
return np.array([]).reshape(0, 3)
n_samples = min(n_samples, len(point_cloud))
indices = np.random.choice(len(point_cloud), size=n_samples, replace=False)
return point_cloud[indices]
class LineFitter:
"""RANSAC直线拟合器"""
def __init__(
self,
threshold: float = 2.0,
max_iterations: int = 1000,
min_inlier_ratio: float = 0.6
):
"""
Args:
threshold: 内点距离阈值(mm)
max_iterations: 最大迭代次数
min_inlier_ratio: 最小内点比例
"""
self.threshold = threshold
self.max_iterations = max_iterations
self.min_inlier_ratio = min_inlier_ratio
def fit(self, points: np.ndarray) -> Optional[LineModel]:
"""
使用RANSAC拟合3D直线
Args:
points: 点云数组 (N, 3)
Returns:
LineModel或None(拟合失败)
"""
if len(points) < 2:
return None
best_inliers = None
best_point = None
best_direction = None
best_inlier_count = 0
for _ in range(self.max_iterations):
# 随机选择2个点
sample_indices = np.random.choice(len(points), 2, replace=False)
p1, p2 = points[sample_indices]
# 计算直线方向
direction = p2 - p1
if np.linalg.norm(direction) < 1e-6:
continue
direction = direction / np.linalg.norm(direction)
# 计算所有点到直线的距离
vectors = points - p1
projections = np.dot(vectors, direction)[:, np.newaxis] * direction
distances = np.linalg.norm(vectors - projections, axis=1)
# 找到内点
inliers = np.where(distances < self.threshold)[0]
if len(inliers) > best_inlier_count:
best_inlier_count = len(inliers)
best_inliers = inliers
best_point = p1
best_direction = direction
if best_inliers is None or len(best_inliers) < 2:
return None
# 使用所有内点重新拟合直线(最小二乘)
inlier_points = points[best_inliers]
final_point, final_direction = self._fit_line_least_squares(inlier_points)
if final_direction is None:
return None
# 重新计算内点
vectors = points - final_point
projections = np.dot(vectors, final_direction)[:, np.newaxis] * final_direction
distances = np.linalg.norm(vectors - projections, axis=1)
final_inliers = np.where(distances < self.threshold)[0]
inlier_ratio = len(final_inliers) / len(points)
# 检查内点比例
if inlier_ratio < self.min_inlier_ratio:
return None
return LineModel(
point=final_point,
direction=final_direction,
inliers=final_inliers,
inlier_ratio=inlier_ratio
)
def _fit_line_least_squares(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
使用最小二乘法拟合直线
Returns:
(point, direction): 直线上的点和方向向量
"""
if len(points) < 2:
return None, None
# 计算质心
centroid = np.mean(points, axis=0)
# 中心化点云
centered = points - centroid
# PCA:找主方向
try:
# 协方差矩阵
cov = np.dot(centered.T, centered) / len(points)
# 特征值分解
eigenvalues, eigenvectors = np.linalg.eig(cov)
# 最大特征值对应的特征向量就是直线方向
max_idx = np.argmax(eigenvalues)
direction = eigenvectors[:, max_idx].real
direction = direction / np.linalg.norm(direction)
return centroid, direction
except np.linalg.LinAlgError:
return None, None

View File

@@ -18,7 +18,7 @@ class MeasurementResult:
class BaseMeasurement(ABC): class BaseMeasurement(ABC):
"""测量任务基类""" """测量任务基类"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
""" """
初始化测量任务 初始化测量任务
@@ -26,11 +26,13 @@ class BaseMeasurement(ABC):
name: 任务名称 name: 任务名称
point_cloud: 完整点云数组 (N, 3) point_cloud: 完整点云数组 (N, 3)
config: 任务配置字典 config: 任务配置字典
spatial_index: 空间索引对象(可选)
""" """
self.name = name self.name = name
self.point_cloud = point_cloud self.point_cloud = point_cloud
self.config = config self.config = config
self.task_type = config.get('type', 'unknown') self.task_type = config.get('type', 'unknown')
self.spatial_index = spatial_index # 保存空间索引
@abstractmethod @abstractmethod
def execute(self) -> MeasurementResult: def execute(self) -> MeasurementResult:

View File

@@ -0,0 +1,280 @@
"""线到线距离测量"""
import numpy as np
import time
from typing import Dict, Any
import sys
from pathlib import Path
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from measurements.base import BaseMeasurement, MeasurementResult
from edge_line_roi_extractor import EdgeLineROIExtractor
from line_fitter import LineFitter
from visualizer import Visualizer
class LineToLineDistance(BaseMeasurement):
"""测量两条边缘线之间的距离"""
def _log(self, msg: str):
"""同时输出到控制台和日志文件"""
print(msg)
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log(msg, also_print=False)
def execute(self) -> MeasurementResult:
"""执行线到线距离测量"""
try:
timing_breakdown = {}
# 获取logger
logger = getattr(self, 'detail_logger', None)
# 获取参数
params = self.config.get('params', {})
source_roi = self.config.get('source_roi', {})
target_roi = self.config.get('target_roi', {})
# 1. 提取源边缘线ROI(两步法)
msg = f"\n 步骤1: 提取源边缘线ROI"
if logger: logger.log(msg)
print(msg)
start_time = time.time()
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
if 'line_start' in source_roi and 'line_end' in source_roi:
source_p1 = np.array(source_roi.get('line_start'))
source_p2 = np.array(source_roi.get('line_end'))
else:
# 从bbox推导线的端点
source_bbox_min = np.array(source_roi.get('bbox_min'))
source_bbox_max = np.array(source_roi.get('bbox_max'))
source_p1 = source_bbox_min
source_p2 = source_bbox_max
msg = f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]"
if logger: logger.log(msg)
print(msg)
msg = f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]"
if logger: logger.log(msg)
print(msg)
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
self.point_cloud,
source_p1,
source_p2,
self.spatial_index,
distance_threshold=params.get('roi_distance_threshold', 2.0),
bbox_margin=5.0
)
timing_breakdown['提取源线ROI'] = time.time() - start_time
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
if len(source_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="源边缘线ROI区域内没有点"
)
# 2. 提取目标边缘线ROI(两步法)
self._log(f"\n 步骤2: 提取目标边缘线ROI")
start_time = time.time()
# 获取线的端点
if 'line_start' in target_roi and 'line_end' in target_roi:
target_p1 = np.array(target_roi.get('line_start'))
target_p2 = np.array(target_roi.get('line_end'))
else:
target_bbox_min = np.array(target_roi.get('bbox_min'))
target_bbox_max = np.array(target_roi.get('bbox_max'))
target_p1 = target_bbox_min
target_p2 = target_bbox_max
self._log(f" 线起点: [{target_p1[0]:.2f}, {target_p1[1]:.2f}, {target_p1[2]:.2f}]")
self._log(f" 线终点: [{target_p2[0]:.2f}, {target_p2[1]:.2f}, {target_p2[2]:.2f}]")
target_points, target_indices, target_stats = EdgeLineROIExtractor.extract_two_step_roi(
self.point_cloud,
target_p1,
target_p2,
self.spatial_index,
distance_threshold=params.get('roi_distance_threshold', 2.0),
bbox_margin=5.0
)
timing_breakdown['提取目标线ROI'] = time.time() - start_time
self._log(f" 粗ROI bbox: [{target_stats['bbox_min'][0]:.2f}, {target_stats['bbox_min'][1]:.2f}, {target_stats['bbox_min'][2]:.2f}] -> [{target_stats['bbox_max'][0]:.2f}, {target_stats['bbox_max'][1]:.2f}, {target_stats['bbox_max'][2]:.2f}]")
self._log(f" 粗筛选: {target_stats['coarse_count']:,} 点")
self._log(f" 精筛选(距离<{target_stats['distance_threshold']}mm): {target_stats['fine_count']:,} 点")
self._log(f" 耗时: {timing_breakdown['提取目标线ROI']:.2f}秒")
if len(target_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="目标边缘线ROI区域内没有点"
)
# 3. 拟合源边缘线(用全部精筛选点)
self._log(f"\n 步骤3: 拟合源边缘线")
start_time = time.time()
self._log(f" 输入点数: {len(source_points)} 个点")
source_fitter = LineFitter(
threshold=params.get('ransac_threshold', 2.0),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
source_line = source_fitter.fit(source_points)
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
if source_line is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
)
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
# 4. 拟合目标边缘线(用全部精筛选点)
self._log(f"\n 步骤4: 拟合目标边缘线")
start_time = time.time()
self._log(f" 输入点数: {len(target_points)} 个点")
target_fitter = LineFitter(
threshold=params.get('ransac_threshold', 2.0),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
target_line = target_fitter.fit(target_points)
timing_breakdown['目标线RANSAC拟合'] = time.time() - start_time
if target_line is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"目标边缘线拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
)
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
self._log(f" 拟合结果: 内点{len(target_line.inliers)}/{len(target_points)} ({target_line.inlier_ratio*100:.1f}%)")
self._log(f" 线上一点: [{target_line.point[0]:.4f}, {target_line.point[1]:.4f}, {target_line.point[2]:.4f}]")
self._log(f" 线方向: [{target_line.direction[0]:.4f}, {target_line.direction[1]:.4f}, {target_line.direction[2]:.4f}]")
self._log(f" 耗时: {timing_breakdown['目标线RANSAC拟合']:.2f}秒")
# 5. 从源线采样点(从内点中采样)
self._log(f"\n 步骤5: 从源线采样点")
start_time = time.time()
n_samples = params.get('sample_points', 100)
source_inlier_points = source_points[source_line.inliers]
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
timing_breakdown['采样点选择'] = time.time() - start_time
self._log(f" 采样数量: {len(sampled_points)} 个点")
self._log(f" 耗时: {timing_breakdown['采样点选择']:.4f}秒")
# 6. 计算采样点到目标线的距离
self._log(f"\n 步骤6: 计算距离")
start_time = time.time()
distances = target_line.distance_to_points(sampled_points)
timing_breakdown['距离计算'] = time.time() - start_time
self._log(f" 计算 {len(sampled_points)} 个采样点到目标线的距离")
self._log(f" 耗时: {timing_breakdown['距离计算']:.4f}秒")
# 7. 统计结果
self._log(f"\n 步骤7: 统计结果")
mean_distance = np.mean(distances)
std_distance = np.std(distances)
min_distance = np.min(distances)
max_distance = np.max(distances)
self._log(f" 平均距离: {mean_distance:.4f} mm")
self._log(f" 标准差: {std_distance:.4f} mm")
self._log(f" 最小距离: {min_distance:.4f} mm")
self._log(f" 最大距离: {max_distance:.4f} mm")
# 8. 合格性判断
self._log(f"\n 步骤8: 合格性判断")
theoretical_dimension = self.config.get('theoretical_dimension')
tolerance = self.config.get('tolerance', {})
tolerance_upper = tolerance.get('upper', 0)
tolerance_lower = tolerance.get('lower', 0)
is_qualified = None
deviation = None
if theoretical_dimension is not None:
deviation = mean_distance - theoretical_dimension
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
self._log(f" 实测平均: {mean_distance:.4f} mm")
self._log(f" 偏差: {deviation:+.4f} mm")
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
status = "✅ 合格" if is_qualified else "❌ 不合格"
self._log(f" 判定结果: {status}")
# 9. 生成可视化(如果配置了输出目录)
start_time = time.time()
visualization_dir = self.config.get('visualization_dir')
if visualization_dir:
vis_dir = Path(visualization_dir)
vis_dir.mkdir(parents=True, exist_ok=True)
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
target_ply_path = vis_dir / f"{self.name}_target_line_roi.ply"
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
result_data = {
'mean_distance': float(mean_distance),
'std_distance': float(std_distance),
'min_distance': float(min_distance),
'max_distance': float(max_distance),
'sample_count': len(sampled_points),
'source_roi_points': len(source_points),
'target_roi_points': len(target_points),
'source_inlier_ratio': float(source_line.inlier_ratio),
'target_inlier_ratio': float(target_line.inlier_ratio),
'source_line_direction': source_line.direction.tolist(),
'target_line_direction': target_line.direction.tolist(),
'timing_breakdown': timing_breakdown
}
# 添加合格性判断结果
if theoretical_dimension is not None:
result_data['theoretical_dimension'] = float(theoretical_dimension)
result_data['deviation'] = float(deviation)
result_data['is_qualified'] = is_qualified
result_data['tolerance_upper'] = float(tolerance_upper)
result_data['tolerance_lower'] = float(tolerance_lower)
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data=result_data
)
except Exception as e:
import traceback
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
)

View File

@@ -0,0 +1,252 @@
"""线到面距离测量"""
import numpy as np
import time
from typing import Dict, Any
import sys
from pathlib import Path
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from measurements.base import BaseMeasurement, MeasurementResult
from edge_line_roi_extractor import EdgeLineROIExtractor
from line_fitter import LineFitter
from roi_extractor import ROIExtractor
from plane_fitter import PlaneFitter
from visualizer import Visualizer
class LineToPlaneDistance(BaseMeasurement):
"""测量边缘线到平面的距离"""
def _log(self, msg: str):
"""同时输出到控制台和日志文件"""
print(msg)
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log(msg, also_print=False)
def execute(self) -> MeasurementResult:
"""执行线到面距离测量"""
try:
timing_breakdown = {}
# 获取参数
params = self.config.get('params', {})
source_roi = self.config.get('source_roi', {})
target_roi = self.config.get('target_roi', {})
# 1. 提取源边缘线ROI(两步法)
self._log(f"\n 步骤1: 提取源边缘线ROI")
start_time = time.time()
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
if 'line_start' in source_roi and 'line_end' in source_roi:
source_p1 = np.array(source_roi.get('line_start'))
source_p2 = np.array(source_roi.get('line_end'))
else:
source_bbox_min = np.array(source_roi.get('bbox_min'))
source_bbox_max = np.array(source_roi.get('bbox_max'))
source_p1 = source_bbox_min
source_p2 = source_bbox_max
self._log(f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]")
self._log(f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]")
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
self.point_cloud,
source_p1,
source_p2,
self.spatial_index,
distance_threshold=params.get('roi_distance_threshold', 2.0),
bbox_margin=5.0
)
timing_breakdown['提取源线ROI'] = time.time() - start_time
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
if len(source_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="源边缘线ROI区域内没有点"
)
# 2. 提取目标平面ROI(用bbox)
self._log(f"\n 步骤2: 提取目标平面ROI")
start_time = time.time()
target_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud,
target_roi.get('bbox_min'),
target_roi.get('bbox_max'),
self.spatial_index
)
timing_breakdown['提取目标平面ROI'] = time.time() - start_time
self._log(f" bbox: {target_roi.get('bbox_min')} -> {target_roi.get('bbox_max')}")
self._log(f" 提取点数: {len(target_points):,} 点")
self._log(f" 耗时: {timing_breakdown['提取目标平面ROI']:.2f}秒")
if len(target_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="目标平面ROI区域内没有点"
)
# 3. 拟合源边缘线(用全部精筛选点)
self._log(f"\n 步骤3: 拟合源边缘线")
start_time = time.time()
self._log(f" 输入点数: {len(source_points)} 个点")
source_fitter = LineFitter(
threshold=params.get('ransac_threshold', 2.0),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
source_line = source_fitter.fit(source_points)
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
if source_line is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
)
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
# 4. 拟合目标平面(降采样优化)
self._log(f"\n 步骤4: 拟合目标平面")
start_time = time.time()
self._log(f" 输入点数: {len(target_points)} 个点")
max_points_for_ransac = 100000
target_points_for_fit = target_points
if len(target_points) > max_points_for_ransac:
sample_indices = np.random.choice(len(target_points), max_points_for_ransac, replace=False)
target_points_for_fit = target_points[sample_indices]
self._log(f" 降采样优化: {len(target_points):,} -> {len(target_points_for_fit):,} 个点")
target_fitter = PlaneFitter(
threshold=params.get('ransac_threshold', 2.0),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
target_plane = target_fitter.fit(target_points_for_fit)
timing_breakdown['目标平面RANSAC拟合'] = time.time() - start_time
if target_plane is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"目标平面拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
)
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
self._log(f" 拟合结果: 内点{len(target_plane.inliers)}/{len(target_points_for_fit)} ({target_plane.inlier_ratio*100:.1f}%)")
self._log(f" 平面上一点: [{target_plane.point[0]:.4f}, {target_plane.point[1]:.4f}, {target_plane.point[2]:.4f}]")
self._log(f" 平面法向量: [{target_plane.normal[0]:.4f}, {target_plane.normal[1]:.4f}, {target_plane.normal[2]:.4f}]")
self._log(f" 平面方程: {target_plane.normal[0]:.6f}*x + {target_plane.normal[1]:.6f}*y + {target_plane.normal[2]:.6f}*z + {target_plane.d:.6f} = 0")
self._log(f" 耗时: {timing_breakdown['目标平面RANSAC拟合']:.2f}秒")
# 5. 从源线采样点(从内点中采样)
start_time = time.time()
n_samples = params.get('sample_points', 100)
source_inlier_points = source_points[source_line.inliers]
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
timing_breakdown['采样点选择'] = time.time() - start_time
self._log(f" 采样{len(sampled_points)}个点")
# 6. 计算采样点到目标平面的距离
start_time = time.time()
distances = target_plane.distance_to_points(sampled_points)
timing_breakdown['距离计算'] = time.time() - start_time
# 7. 统计结果(使用绝对值)
mean_distance = np.mean(np.abs(distances))
std_distance = np.std(distances)
min_distance = np.min(np.abs(distances))
max_distance = np.max(np.abs(distances))
# 8. 合格性判断
theoretical_dimension = self.config.get('theoretical_dimension')
tolerance = self.config.get('tolerance', {})
tolerance_upper = tolerance.get('upper', 0)
tolerance_lower = tolerance.get('lower', 0)
is_qualified = None
deviation = None
if theoretical_dimension is not None:
deviation = mean_distance - theoretical_dimension
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
self._log(f" 平均距离: {mean_distance:.4f} mm")
if theoretical_dimension is not None:
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
self._log(f" 偏差: {deviation:+.4f} mm")
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
status = "✅ 合格" if is_qualified else "❌ 不合格"
self._log(f" 判定: {status}")
# 9. 生成可视化(如果配置了输出目录)
start_time = time.time()
visualization_dir = self.config.get('visualization_dir')
if visualization_dir:
vis_dir = Path(visualization_dir)
vis_dir.mkdir(parents=True, exist_ok=True)
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
target_ply_path = vis_dir / f"{self.name}_target_plane_roi.ply"
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
result_data = {
'mean_distance': float(mean_distance),
'std_distance': float(std_distance),
'min_distance': float(min_distance),
'max_distance': float(max_distance),
'sample_count': len(sampled_points),
'source_roi_points': len(source_points),
'target_roi_points': len(target_points),
'source_inlier_ratio': float(source_line.inlier_ratio),
'target_inlier_ratio': float(target_plane.inlier_ratio),
'source_line_direction': source_line.direction.tolist(),
'target_plane_normal': target_plane.normal.tolist(),
'timing_breakdown': timing_breakdown
}
# 添加合格性判断结果
if theoretical_dimension is not None:
result_data['theoretical_dimension'] = float(theoretical_dimension)
result_data['deviation'] = float(deviation)
result_data['is_qualified'] = is_qualified
result_data['tolerance_upper'] = float(tolerance_upper)
result_data['tolerance_lower'] = float(tolerance_lower)
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data=result_data
)
except Exception as e:
import traceback
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
)

View File

@@ -25,11 +25,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
# 1. 提取源平面ROI # 1. 提取源平面ROI
start_time = time.time() start_time = time.time()
source_roi = self.config.get('source_roi', {}) source_roi = self.config.get('source_roi', {})
source_points, _ = ROIExtractor.extract_bbox_points( source_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, source_roi.get('bbox_min'), source_roi.get('bbox_max'), self.spatial_index)
self.point_cloud,
source_roi.get('bbox_min'),
source_roi.get('bbox_max')
)
timing_breakdown['提取源ROI'] = time.time() - start_time timing_breakdown['提取源ROI'] = time.time() - start_time
# 记录ROI提取信息到详细日志 # 记录ROI提取信息到详细日志
@@ -52,11 +48,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
# 2. 提取目标平面ROI # 2. 提取目标平面ROI
start_time = time.time() start_time = time.time()
target_roi = self.config.get('target_roi', {}) target_roi = self.config.get('target_roi', {})
target_points, _ = ROIExtractor.extract_bbox_points( target_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, target_roi.get('bbox_min'), target_roi.get('bbox_max'), self.spatial_index)
self.point_cloud,
target_roi.get('bbox_min'),
target_roi.get('bbox_max')
)
timing_breakdown['提取目标ROI'] = time.time() - start_time timing_breakdown['提取目标ROI'] = time.time() - start_time
# 记录ROI提取信息到详细日志 # 记录ROI提取信息到详细日志
@@ -153,7 +145,8 @@ class PlaneToPlaneDistance(BaseMeasurement):
# 5. 从源平面采样点 # 5. 从源平面采样点
start_time = time.time() start_time = time.time()
n_samples = params.get('sample_points', 100) n_samples = params.get('sample_points', 100)
source_inlier_points = source_points[source_plane.inliers] # 修复:inliers索引是相对于source_points_for_fit的,不是source_points
source_inlier_points = source_points_for_fit[source_plane.inliers]
sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples) sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples)
timing_breakdown['采样点选择'] = time.time() - start_time timing_breakdown['采样点选择'] = time.time() - start_time

View File

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

View File

@@ -24,8 +24,8 @@ from visualizer import Visualizer
class PointToPlaneDistance(BaseMeasurement): class PointToPlaneDistance(BaseMeasurement):
"""点到平面距离测量""" """点到平面距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config) super().__init__(name, point_cloud, config, spatial_index)
self.mode = config.get('mode', 'exact_points') self.mode = config.get('mode', 'exact_points')
if self.mode == 'exact_points': if self.mode == 'exact_points':
@@ -68,42 +68,50 @@ class PointToPlaneDistance(BaseMeasurement):
) )
def _measure_exact_points(self) -> MeasurementResult: def _measure_exact_points(self) -> MeasurementResult:
"""精确点模式:查找最近点并计算距离""" """精确点模式:查找最近点并计算距离(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
timing_breakdown = {} timing_breakdown = {}
# 优化:先粗筛选点云,只在目标点附近构建KDTree # 优化:对每个目标点单独用Grid索引提取小区域
start_time = time.time() start_time = time.time()
# 计算所有目标点的包围盒,扩大搜索半径的倍数作为缓冲
search_margin = self.search_radius * 20 # 给足够的余量
bbox_min = self.target_points.min(axis=0) - search_margin
bbox_max = self.target_points.max(axis=0) + search_margin
# 粗筛选:只保留包围盒内的点
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
local_points = self.point_cloud[mask]
local_indices = np.where(mask)[0]
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
# 在筛选后的点云上构建KDTree(大幅加速)
kdtree = KDTree(local_points)
timing_breakdown['构建KDTree'] = time.time() - start_time
found_points = [] found_points = []
distances_to_plane = [] distances_to_plane = []
start_time = time.time()
for target_point in self.target_points: for target_point in self.target_points:
# 查找最近点 # 定义搜索区域(目标点周围的小包围盒)
dist, local_idx = kdtree.query(target_point, k=1) search_margin = self.search_radius * 2
bbox_min = target_point - search_margin
bbox_max = target_point + search_margin
if dist > self.search_radius: # 用Grid索引快速提取局部点云
print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm") local_points, local_indices = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) == 0:
# 如果没找到点,扩大搜索范围
search_margin = self.search_radius * 10
bbox_min = target_point - search_margin
bbox_max = target_point + search_margin
local_points, local_indices = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) == 0:
print(f"警告: 目标点 {target_point} 周围 {search_margin}mm 内没有点")
continue
# 在局部点云中暴力搜索最近点(点数少,很快)
distances = np.linalg.norm(local_points - target_point, axis=1)
min_idx = np.argmin(distances)
min_distance = distances[min_idx]
nearest_point = local_points[min_idx]
if min_distance > self.search_radius:
print(f"警告: 目标点 {target_point} 的最近点距离 {min_distance:.2f}mm 超过搜索半径 {self.search_radius}mm")
# 映射回原始点云的点
nearest_point = local_points[local_idx]
found_points.append(nearest_point) found_points.append(nearest_point)
# 计算点到平面的距离 # 计算点到平面的距离
@@ -146,6 +154,9 @@ class PointToPlaneDistance(BaseMeasurement):
def _measure_bbox_filtered(self) -> MeasurementResult: def _measure_bbox_filtered(self) -> MeasurementResult:
"""包围盒过滤模式:提取点云,筛选特定点,计算距离""" """包围盒过滤模式:提取点云,筛选特定点,计算距离"""
# 导入ROIExtractor用于Grid索引加速
from roi_extractor import ROIExtractor
timing_breakdown = {} timing_breakdown = {}
all_filtered_points = [] all_filtered_points = []
all_distances = [] all_distances = []
@@ -155,9 +166,10 @@ class PointToPlaneDistance(BaseMeasurement):
bbox_min = np.array(bbox_config['min']) bbox_min = np.array(bbox_config['min'])
bbox_max = np.array(bbox_config['max']) bbox_max = np.array(bbox_config['max'])
# 提取包围盒内的点 # 使用Grid索引快速提取包围盒内的点(优化后)
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1) bbox_points, _ = ROIExtractor.extract_bbox_points(
bbox_points = self.point_cloud[mask] self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(bbox_points) == 0: if len(bbox_points) == 0:
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点") print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")

View File

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

View File

@@ -21,8 +21,8 @@ from visualizer import Visualizer
class PointToPointDistance(BaseMeasurement): class PointToPointDistance(BaseMeasurement):
"""点到点距离测量""" """点到点距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config) super().__init__(name, point_cloud, config, spatial_index)
# 检查测量模式 # 检查测量模式
self.mode = config.get('mode', 'exact_points') self.mode = config.get('mode', 'exact_points')
@@ -55,11 +55,7 @@ class PointToPointDistance(BaseMeasurement):
bbox_points_list = [] bbox_points_list = []
for bbox in self.bboxes: for bbox in self.bboxes:
points, _ = ROIExtractor.extract_bbox_points( points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, bbox['min'], bbox['max'], self.spatial_index)
self.point_cloud,
bbox['min'],
bbox['max']
)
if len(points) == 0: if len(points) == 0:
return MeasurementResult( return MeasurementResult(
task_name=self.name, task_name=self.name,
@@ -170,59 +166,80 @@ class PointToPointDistance(BaseMeasurement):
) )
def _execute_exact_points(self) -> MeasurementResult: def _execute_exact_points(self) -> MeasurementResult:
"""执行精确点模式的测量""" """执行精确点模式的测量(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
try: try:
timing_breakdown = {} timing_breakdown = {}
# 优化:先粗筛选点云,只在目标点附近构建KDTree # 优化:对每个目标点用Grid索引提取小区域
start_time = time.time() start_time = time.time()
# 收集所有目标点
all_target_points = []
for pair in self.point_pairs:
all_target_points.append(pair['point1'])
all_target_points.append(pair['point2'])
all_target_points = np.array(all_target_points)
# 计算包围盒,扩大搜索半径的倍数作为缓冲
search_margin = self.search_radius * 20
bbox_min = all_target_points.min(axis=0) - search_margin
bbox_max = all_target_points.max(axis=0) + search_margin
# 粗筛选:只保留包围盒内的点
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
local_points = self.point_cloud[mask]
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
# 在筛选后的点云上构建KDTree(大幅加速)
kdtree = KDTree(local_points)
timing_breakdown['构建KDTree'] = time.time() - start_time
pair_results = [] pair_results = []
all_found_points = [] all_found_points = []
start_time = time.time()
for i, pair in enumerate(self.point_pairs): for i, pair in enumerate(self.point_pairs):
point1_target = np.array(pair['point1']) point1_target = np.array(pair['point1'])
point2_target = np.array(pair['point2']) point2_target = np.array(pair['point2'])
pair_name = pair.get('name', f'点对{i+1}') pair_name = pair.get('name', f'点对{i+1}')
# 查找point1的最近点 # 为point1找最近点
dist1, local_idx1 = kdtree.query(point1_target, k=1) search_margin = self.search_radius * 2
bbox_min = point1_target - search_margin
bbox_max = point1_target + search_margin
local_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) == 0:
search_margin = self.search_radius * 10
bbox_min = point1_target - search_margin
bbox_max = point1_target + search_margin
local_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) > 0:
distances_to_p1 = np.linalg.norm(local_points - point1_target, axis=1)
local_idx1 = np.argmin(distances_to_p1)
dist1 = distances_to_p1[local_idx1]
point1_found = local_points[local_idx1]
else:
print(f"警告: {pair_name} - 点1 {point1_target} 附近没有点")
continue
# 为point2找最近点
search_margin = self.search_radius * 2
bbox_min = point2_target - search_margin
bbox_max = point2_target + search_margin
local_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) == 0:
search_margin = self.search_radius * 10
bbox_min = point2_target - search_margin
bbox_max = point2_target + search_margin
local_points, _ = ROIExtractor.extract_bbox_points(
self.point_cloud, bbox_min, bbox_max, self.spatial_index
)
if len(local_points) > 0:
distances_to_p2 = np.linalg.norm(local_points - point2_target, axis=1)
local_idx2 = np.argmin(distances_to_p2)
dist2 = distances_to_p2[local_idx2]
point2_found = local_points[local_idx2]
else:
print(f"警告: {pair_name} - 点2 {point2_target} 附近没有点")
continue
if dist1 > self.search_radius: if dist1 > self.search_radius:
print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm") print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm")
point1_found = local_points[local_idx1]
# 查找point2的最近点
dist2, local_idx2 = kdtree.query(point2_target, k=1)
if dist2 > self.search_radius: if dist2 > self.search_radius:
print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm") print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm")
point2_found = local_points[local_idx2]
# 计算两点之间的欧氏距离 # 计算两点之间的欧氏距离
distance = np.linalg.norm(point2_found - point1_found) distance = np.linalg.norm(point2_found - point1_found)

View File

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

View File

@@ -25,6 +25,12 @@ class PerformanceLogger:
# 任务性能记录 # 任务性能记录
self.task_records = [] self.task_records = []
# 初始化阶段性能记录
self.init_phases = {
'pointcloud_loading': {'time': 0, 'points': 0, 'file': ''},
'spatial_index': {'time': 0, 'method': '', 'info': {}}
}
def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]): def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]):
""" """
记录单个任务的性能数据 记录单个任务的性能数据
@@ -41,6 +47,36 @@ class PerformanceLogger:
} }
self.task_records.append(record) self.task_records.append(record)
def log_pointcloud_loading(self, time_seconds: float, point_count: int, filename: str):
"""
记录点云加载性能
Args:
time_seconds: 加载耗时(秒)
point_count: 点云数量
filename: 文件名
"""
self.init_phases['pointcloud_loading'] = {
'time': time_seconds,
'points': point_count,
'file': filename
}
def log_spatial_index(self, time_seconds: float, method: str, info: Dict):
"""
记录空间索引构建性能
Args:
time_seconds: 构建耗时(秒)
method: 索引方法名称
info: 索引详细信息
"""
self.init_phases['spatial_index'] = {
'time': time_seconds,
'method': method,
'info': info
}
def save(self): def save(self):
"""保存性能日志到文件""" """保存性能日志到文件"""
with open(self.log_file, 'w', encoding='utf-8') as f: with open(self.log_file, 'w', encoding='utf-8') as f:
@@ -48,6 +84,51 @@ class PerformanceLogger:
f.write("点云测量系统 - 性能分析报告\n") f.write("点云测量系统 - 性能分析报告\n")
f.write("=" * 80 + "\n") f.write("=" * 80 + "\n")
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write("=" * 80 + "\n\n")
# 输出初始化阶段性能
f.write("=" * 80 + "\n")
f.write("初始化阶段性能\n")
f.write("=" * 80 + "\n\n")
# 点云加载
pc_loading = self.init_phases['pointcloud_loading']
if pc_loading['time'] > 0:
f.write("阶段1: 点云加载\n")
f.write("-" * 80 + "\n")
f.write(f" 文件名: {pc_loading['file']}\n")
f.write(f" 点云数量: {pc_loading['points']:,} 个点\n")
f.write(f" 加载耗时: {pc_loading['time']:.2f} 秒\n")
if pc_loading['points'] > 0:
f.write(f" 加载速度: {pc_loading['points']/pc_loading['time']/1000000:.2f} M点/秒\n")
f.write("\n")
# 空间索引构建
spatial_idx = self.init_phases['spatial_index']
if spatial_idx['time'] > 0:
f.write("阶段2: 空间索引构建\n")
f.write("-" * 80 + "\n")
f.write(f" 方法: {spatial_idx['method']}\n")
f.write(f" 构建耗时: {spatial_idx['time']:.2f} 秒\n")
info = spatial_idx['info']
if 'grid_count' in info:
f.write(f" 网格数量: {info['grid_count']:,}\n")
f.write(f" 平均点/格: {info['avg_points_per_grid']:.1f}\n")
elif 'node_count' in info:
f.write(f" 节点数量: {info['node_count']:,}\n")
f.write("\n")
# 初始化总耗时
init_total = pc_loading['time'] + spatial_idx['time']
if init_total > 0:
f.write(f"初始化阶段总耗时: {init_total:.2f} 秒\n\n")
f.write("=" * 80 + "\n\n")
# 任务执行性能
f.write("=" * 80 + "\n")
f.write("任务执行性能\n")
f.write("=" * 80 + "\n")
f.write(f"总任务数: {len(self.task_records)}\n") f.write(f"总任务数: {len(self.task_records)}\n")
f.write("=" * 80 + "\n\n") f.write("=" * 80 + "\n\n")
@@ -106,6 +187,19 @@ class PerformanceLogger:
count = step_counts[step_name] count = step_counts[step_name]
f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n") f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n")
f.write("\n" + "=" * 80 + "\n")
# 总体性能摘要
grand_total = init_total + total_all_tasks
f.write("\n" + "=" * 80 + "\n")
f.write("总体性能摘要\n")
f.write("=" * 80 + "\n\n")
f.write(f" 初始化阶段: {init_total:.2f} 秒 ({init_total/grand_total*100:.1f}%)\n")
f.write(f" - 点云加载: {pc_loading['time']:.2f} 秒\n")
f.write(f" - 空间索引: {spatial_idx['time']:.2f} 秒\n")
f.write(f" 任务执行阶段: {total_all_tasks:.2f} 秒 ({total_all_tasks/grand_total*100:.1f}%)\n")
f.write(f" 总耗时: {grand_total:.2f} 秒\n")
f.write("\n" + "=" * 80 + "\n") f.write("\n" + "=" * 80 + "\n")
f.write(f"日志已保存到: {self.log_file}\n") f.write(f"日志已保存到: {self.log_file}\n")
f.write("=" * 80 + "\n") f.write("=" * 80 + "\n")

View File

@@ -66,38 +66,46 @@ class PointCloudLoader:
@staticmethod @staticmethod
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray: def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
"""读取二进制PLY数据""" """读取二进制PLY数据(优化版:批量读取)"""
# 构建struct格式字符串 # 构建numpy dtype
type_map = { type_map = {
'float': 'f', 'float': np.float32,
'double': 'd', 'double': np.float64,
'uchar': 'B', 'uchar': np.uint8,
'uint': 'I', 'uint': np.uint32,
'int': 'i' 'int': np.int32
} }
fmt = '<' # 小端序 # 构建结构化数组的dtype
dtype_list = []
for prop in properties: for prop in properties:
fmt += type_map.get(prop['type'], 'f') dtype_list.append((prop['name'], type_map.get(prop['type'], np.float32)))
struct_size = struct.calcsize(fmt) dtype = np.dtype(dtype_list)
points = []
for _ in range(vertex_count): # 一次性读取所有数据
data = struct.unpack(fmt, f.read(struct_size)) raw_data = np.fromfile(f, dtype=dtype, count=vertex_count)
# 只取前3个值作为xyz坐标
points.append(data[:3])
return np.array(points, dtype=np.float64) # 提取xyz坐标(假设前3个属性是x, y, z)
# 如果属性名不是x,y,z,可以通过索引访问
if 'x' in raw_data.dtype.names and 'y' in raw_data.dtype.names and 'z' in raw_data.dtype.names:
points = np.column_stack([raw_data['x'], raw_data['y'], raw_data['z']])
else:
# 假设前3个字段是xyz
points = np.column_stack([raw_data[raw_data.dtype.names[0]],
raw_data[raw_data.dtype.names[1]],
raw_data[raw_data.dtype.names[2]]])
return points.astype(np.float64)
@staticmethod @staticmethod
def _read_ascii_ply(f, vertex_count: int) -> np.ndarray: def _read_ascii_ply(f, vertex_count: int) -> np.ndarray:
"""读取ASCII PLY数据""" """读取ASCII PLY数据(优化版:批量读取)"""
points = [] # 读取剩余所有行
for _ in range(vertex_count): remaining = f.read().decode('ascii')
line = f.readline().decode('ascii').strip()
values = line.split()
# 只取前3个值作为xyz坐标
points.append([float(values[0]), float(values[1]), float(values[2])])
return np.array(points, dtype=np.float64) # 使用numpy的快速文本解析
from io import StringIO
points = np.loadtxt(StringIO(remaining), dtype=np.float64, max_rows=vertex_count, usecols=(0, 1, 2))
return points

View File

@@ -10,7 +10,8 @@ class ROIExtractor:
def extract_bbox_points( def extract_bbox_points(
points: np.ndarray, points: np.ndarray,
bbox_min: list, bbox_min: list,
bbox_max: list bbox_max: list,
spatial_index=None
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
""" """
提取包围盒内的点 提取包围盒内的点
@@ -19,6 +20,7 @@ class ROIExtractor:
points: 点云数组 (N, 3) points: 点云数组 (N, 3)
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min] bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max] bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
spatial_index: 空间索引对象(可选)
Returns: Returns:
roi_points: 提取的点 (M, 3) roi_points: 提取的点 (M, 3)
@@ -27,11 +29,15 @@ class ROIExtractor:
bbox_min = np.array(bbox_min) bbox_min = np.array(bbox_min)
bbox_max = np.array(bbox_max) bbox_max = np.array(bbox_max)
# 找到在包围盒内的点 if spatial_index is not None:
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1) # 使用空间索引快速查询
indices = np.where(mask)[0] indices = spatial_index.query_bbox(bbox_min, bbox_max)
roi_points = points[indices] else:
# 回退到线性扫描
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
indices = np.where(mask)[0]
roi_points = points[indices]
return roi_points, indices return roi_points, indices
@staticmethod @staticmethod

View File

@@ -0,0 +1,17 @@
"""空间索引模块 - 提供多种ROI提取方法"""
from .base import SpatialIndex
from .linear_scan import LinearScanIndex
from .grid_index import GridIndex
from .grid_index_optimized import GridIndexOptimized
from .octree_index import OctreeIndex
from .kdtree_index import KDTreeIndex
__all__ = [
'SpatialIndex',
'LinearScanIndex',
'GridIndex',
'GridIndexOptimized',
'OctreeIndex',
'KDTreeIndex',
]

View File

@@ -0,0 +1,39 @@
"""空间索引基类"""
from abc import ABC, abstractmethod
import numpy as np
class SpatialIndex(ABC):
"""空间索引抽象基类"""
def __init__(self, points: np.ndarray):
"""
初始化索引
Args:
points: 点云数组 (N, 3)
"""
self.points = points
self.build_time = 0.0 # 构建时间(秒)
@abstractmethod
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
查询包围盒内的点
Args:
bbox_min: [x_min, y_min, z_min]
bbox_max: [x_max, y_max, z_max]
Returns:
包围盒内的点的索引数组
"""
pass
def get_info(self) -> dict:
"""返回索引信息(用于日志)"""
return {
'type': self.__class__.__name__,
'point_count': len(self.points),
'build_time': self.build_time
}

View File

@@ -0,0 +1,92 @@
"""Grid/Voxel 网格索引"""
import numpy as np
import time
from collections import defaultdict
from .base import SpatialIndex
class GridIndex(SpatialIndex):
"""Grid/Voxel 空间索引 - O(N)构建, O(1)查询"""
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
"""
构建网格索引
Args:
points: 点云数组 (N, 3)
grid_size: 网格大小(毫米)
"""
super().__init__(points)
self.grid_size = grid_size
print(f" 构建Grid索引 (grid_size={grid_size}mm)...")
start = time.time()
# 计算点云边界
self.mins = points.min(axis=0)
self.maxs = points.max(axis=0)
# 将每个点映射到网格坐标
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
# 构建索引:grid_key → point_indices
self.grid = defaultdict(list)
for idx, (gx, gy, gz) in enumerate(grid_coords):
grid_key = (gx, gy, gz)
self.grid[grid_key].append(idx)
# 转换为numpy数组以加速查询
for key in self.grid:
self.grid[key] = np.array(self.grid[key], dtype=np.int32)
self.build_time = time.time() - start
print(f" 网格数量: {len(self.grid):,}")
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
print(f" 构建耗时: {self.build_time:.2f} 秒")
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
Grid索引查询 - O(1 + K)复杂度
直接定位到相关网格,只检查这些网格中的点
"""
# 将bbox转换为网格坐标范围
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
# 收集相关网格中的所有点索引
candidate_indices = []
for gx in range(grid_min[0], grid_max[0] + 1):
for gy in range(grid_min[1], grid_max[1] + 1):
for gz in range(grid_min[2], grid_max[2] + 1):
grid_key = (gx, gy, gz)
if grid_key in self.grid:
candidate_indices.append(self.grid[grid_key])
if len(candidate_indices) == 0:
return np.array([], dtype=np.int32)
# 合并所有候选点
candidate_indices = np.concatenate(candidate_indices)
candidate_points = self.points[candidate_indices]
# 精确过滤(网格是粗略的,需要精确检查)
mask = (
(candidate_points[:, 0] >= bbox_min[0]) &
(candidate_points[:, 0] <= bbox_max[0]) &
(candidate_points[:, 1] >= bbox_min[1]) &
(candidate_points[:, 1] <= bbox_max[1]) &
(candidate_points[:, 2] >= bbox_min[2]) &
(candidate_points[:, 2] <= bbox_max[2])
)
return candidate_indices[mask]
def get_info(self) -> dict:
info = super().get_info()
info['method'] = 'Grid网格索引'
info['complexity'] = 'O(1 + K)'
info['grid_size'] = self.grid_size
info['grid_count'] = len(self.grid)
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
return info

View File

@@ -0,0 +1,120 @@
"""Grid/Voxel 网格索引 - 优化版(numpy向量化)"""
import numpy as np
import time
from collections import defaultdict
from .base import SpatialIndex
class GridIndexOptimized(SpatialIndex):
"""Grid/Voxel 空间索引 - 优化版,使用numpy向量化避免Python循环"""
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
"""
构建网格索引(优化版)
Args:
points: 点云数组 (N, 3)
grid_size: 网格大小(毫米)
"""
super().__init__(points)
self.grid_size = grid_size
print(f" 构建Grid索引(优化版 - numpy向量化)(grid_size={grid_size}mm)...")
start = time.time()
# 计算点云边界
self.mins = points.min(axis=0)
self.maxs = points.max(axis=0)
# 将每个点映射到网格坐标
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
# ===== 关键优化:使用numpy向量化替代Python循环 =====
# 1. 将3D网格坐标编码为1D哈希值(避免tuple开销)
# 使用大素数来避免冲突
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
grid_hash = (grid_coords * hash_multiplier).sum(axis=1)
# 2. 使用argsort排序(numpy C实现,非常快)
sort_idx = np.argsort(grid_hash)
sorted_hash = grid_hash[sort_idx]
# 3. 找到每个唯一网格的边界
# 只需要遍历唯一网格数(约500万),而不是10亿点!
unique_mask = np.concatenate(([True], sorted_hash[1:] != sorted_hash[:-1]))
unique_indices = np.where(unique_mask)[0]
# 4. 构建索引字典
self.grid = {}
self.grid_hash_to_coords = {} # 保存hash到3D坐标的映射
n_unique = len(unique_indices)
for i in range(n_unique):
start_idx = unique_indices[i]
end_idx = unique_indices[i + 1] if i + 1 < n_unique else len(sorted_hash)
hash_val = sorted_hash[start_idx]
indices = sort_idx[start_idx:end_idx]
# 使用hash作为key(比tuple快)
self.grid[hash_val] = indices
# 保存hash到原始3D坐标的映射(用于查询)
self.grid_hash_to_coords[hash_val] = tuple(grid_coords[indices[0]])
self.build_time = time.time() - start
print(f" 网格数量: {len(self.grid):,}")
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
print(f" 构建耗时: {self.build_time:.2f} 秒")
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
Grid索引查询 - O(1 + K)复杂度
"""
# 将bbox转换为网格坐标范围
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
# 收集相关网格中的所有点索引
candidate_indices = []
# 遍历bbox覆盖的网格(通常很少,比如10×10×10=1000个)
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
for gx in range(grid_min[0], grid_max[0] + 1):
for gy in range(grid_min[1], grid_max[1] + 1):
for gz in range(grid_min[2], grid_max[2] + 1):
grid_coord = np.array([gx, gy, gz], dtype=np.int32)
grid_hash_val = (grid_coord * hash_multiplier).sum()
if grid_hash_val in self.grid:
candidate_indices.append(self.grid[grid_hash_val])
if len(candidate_indices) == 0:
return np.array([], dtype=np.int32)
# 合并所有候选点
candidate_indices = np.concatenate(candidate_indices)
candidate_points = self.points[candidate_indices]
# 精确过滤
mask = (
(candidate_points[:, 0] >= bbox_min[0]) &
(candidate_points[:, 0] <= bbox_max[0]) &
(candidate_points[:, 1] >= bbox_min[1]) &
(candidate_points[:, 1] <= bbox_max[1]) &
(candidate_points[:, 2] >= bbox_min[2]) &
(candidate_points[:, 2] <= bbox_max[2])
)
return candidate_indices[mask]
def get_info(self) -> dict:
info = super().get_info()
info['method'] = 'Grid网格索引(优化版)'
info['complexity'] = 'O(1 + K)'
info['grid_size'] = self.grid_size
info['grid_count'] = len(self.grid)
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
info['optimization'] = 'numpy向量化'
return info

View File

@@ -0,0 +1,66 @@
"""KDTree 索引(使用scipy)"""
import numpy as np
import time
from scipy.spatial import cKDTree
from .base import SpatialIndex
class KDTreeIndex(SpatialIndex):
"""KDTree 空间索引 - O(N log N)构建, O(log N + K)查询"""
def __init__(self, points: np.ndarray):
"""
构建KD树索引
Args:
points: 点云数组 (N, 3)
"""
super().__init__(points)
print(f" 构建KDTree索引...")
start = time.time()
# 使用scipy的cKDTree(C实现,比纯Python快)
self.tree = cKDTree(points)
self.build_time = time.time() - start
print(f" 构建耗时: {self.build_time:.2f} 秒")
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
KDTree查询 - O(log N + K)复杂度
使用球形查询近似bbox,然后精确过滤
"""
# KDTree没有直接的bbox查询,使用球形查询近似
center = (bbox_min + bbox_max) / 2
# 使用外接球半径,确保包含整个bbox
radius = np.linalg.norm(bbox_max - bbox_min) / 2
# 球形查询
candidate_indices = self.tree.query_ball_point(center, radius)
if len(candidate_indices) == 0:
return np.array([], dtype=np.int32)
candidate_indices = np.array(candidate_indices, dtype=np.int32)
candidate_points = self.points[candidate_indices]
# 精确过滤(因为球形查询是近似的)
mask = (
(candidate_points[:, 0] >= bbox_min[0]) &
(candidate_points[:, 0] <= bbox_max[0]) &
(candidate_points[:, 1] >= bbox_min[1]) &
(candidate_points[:, 1] <= bbox_max[1]) &
(candidate_points[:, 2] >= bbox_min[2]) &
(candidate_points[:, 2] <= bbox_max[2])
)
return candidate_indices[mask]
def get_info(self) -> dict:
info = super().get_info()
info['method'] = 'KDTree索引(scipy)'
info['complexity'] = 'O(log N + K)'
info['note'] = '构建慢,适合小数据集'
return info

View File

@@ -0,0 +1,32 @@
"""线性扫描(无索引)- 基准方法"""
import numpy as np
import time
from .base import SpatialIndex
class LinearScanIndex(SpatialIndex):
"""线性扫描索引(实际上就是无索引)"""
def __init__(self, points: np.ndarray):
super().__init__(points)
# 无需构建索引
self.build_time = 0.0
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
线性扫描查询 - O(N)复杂度
遍历所有点,检查是否在bbox内
"""
mask = (
(self.points[:, 0] >= bbox_min[0]) & (self.points[:, 0] <= bbox_max[0]) &
(self.points[:, 1] >= bbox_min[1]) & (self.points[:, 1] <= bbox_max[1]) &
(self.points[:, 2] >= bbox_min[2]) & (self.points[:, 2] <= bbox_max[2])
)
return np.where(mask)[0]
def get_info(self) -> dict:
info = super().get_info()
info['method'] = '线性扫描(无索引)'
info['complexity'] = 'O(N)'
return info

View File

@@ -0,0 +1,166 @@
"""Octree 八叉树索引"""
import numpy as np
import time
from .base import SpatialIndex
class OctreeNode:
"""八叉树节点"""
def __init__(self, center: np.ndarray, size: float):
self.center = center
self.size = size
self.children = [None] * 8 # 8个子节点
self.point_indices = [] # 叶节点存储点索引
self.is_leaf = True
class OctreeIndex(SpatialIndex):
"""Octree 空间索引 - O(N)构建, O(log N + K)查询"""
def __init__(self, points: np.ndarray, max_depth: int = 8, max_points_per_node: int = 1000):
"""
构建八叉树索引
Args:
points: 点云数组 (N, 3)
max_depth: 最大树深度
max_points_per_node: 每个叶节点最多存储的点数
"""
super().__init__(points)
self.max_depth = max_depth
self.max_points_per_node = max_points_per_node
print(f" 构建Octree索引 (max_depth={max_depth}, max_points={max_points_per_node})...")
start = time.time()
# 计算点云边界
mins = points.min(axis=0)
maxs = points.max(axis=0)
center = (mins + maxs) / 2
size = np.max(maxs - mins) / 2
# 构建树
all_indices = np.arange(len(points), dtype=np.int32)
self.root = self._build_tree(center, size, all_indices, depth=0)
self.build_time = time.time() - start
self.node_count = self._count_nodes(self.root)
print(f" 节点数量: {self.node_count:,}")
print(f" 构建耗时: {self.build_time:.2f} 秒")
def _build_tree(self, center: np.ndarray, size: float, indices: np.ndarray, depth: int) -> OctreeNode:
"""递归构建八叉树"""
node = OctreeNode(center, size)
# 终止条件:达到最大深度或点数足够少
if depth >= self.max_depth or len(indices) <= self.max_points_per_node:
node.point_indices = indices
node.is_leaf = True
return node
# 分割成8个子空间
node.is_leaf = False
half_size = size / 2
points_subset = self.points[indices]
for i in range(8):
# 计算子节点中心
child_center = center.copy()
child_center[0] += half_size if (i & 1) else -half_size
child_center[1] += half_size if (i & 2) else -half_size
child_center[2] += half_size if (i & 4) else -half_size
# 找到属于这个子空间的点
mask = (
((points_subset[:, 0] >= child_center[0] - half_size) &
(points_subset[:, 0] < child_center[0] + half_size)) &
((points_subset[:, 1] >= child_center[1] - half_size) &
(points_subset[:, 1] < child_center[1] + half_size)) &
((points_subset[:, 2] >= child_center[2] - half_size) &
(points_subset[:, 2] < child_center[2] + half_size))
)
child_indices = indices[mask]
if len(child_indices) > 0:
node.children[i] = self._build_tree(child_center, half_size, child_indices, depth + 1)
return node
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
"""
Octree查询 - O(log N + K)复杂度
递归查询与bbox相交的节点
"""
result = []
self._query_recursive(self.root, bbox_min, bbox_max, result)
if len(result) == 0:
return np.array([], dtype=np.int32)
# 合并所有候选点
candidate_indices = np.concatenate(result)
candidate_points = self.points[candidate_indices]
# 精确过滤
mask = (
(candidate_points[:, 0] >= bbox_min[0]) &
(candidate_points[:, 0] <= bbox_max[0]) &
(candidate_points[:, 1] >= bbox_min[1]) &
(candidate_points[:, 1] <= bbox_max[1]) &
(candidate_points[:, 2] >= bbox_min[2]) &
(candidate_points[:, 2] <= bbox_max[2])
)
return candidate_indices[mask]
def _query_recursive(self, node: OctreeNode, bbox_min: np.ndarray, bbox_max: np.ndarray, result: list):
"""递归查询节点"""
if node is None:
return
# 检查节点边界框是否与查询bbox相交
node_min = node.center - node.size
node_max = node.center + node.size
if not self._bbox_intersects(bbox_min, bbox_max, node_min, node_max):
return
if node.is_leaf:
# 叶节点:添加所有点索引
if len(node.point_indices) > 0:
result.append(node.point_indices)
else:
# 内部节点:递归查询子节点
for child in node.children:
if child is not None:
self._query_recursive(child, bbox_min, bbox_max, result)
def _bbox_intersects(self, bbox1_min: np.ndarray, bbox1_max: np.ndarray,
bbox2_min: np.ndarray, bbox2_max: np.ndarray) -> bool:
"""检查两个bbox是否相交"""
return not (
bbox1_max[0] < bbox2_min[0] or bbox1_min[0] > bbox2_max[0] or
bbox1_max[1] < bbox2_min[1] or bbox1_min[1] > bbox2_max[1] or
bbox1_max[2] < bbox2_min[2] or bbox1_min[2] > bbox2_max[2]
)
def _count_nodes(self, node: OctreeNode) -> int:
"""统计节点数量"""
if node is None:
return 0
count = 1
if not node.is_leaf:
for child in node.children:
count += self._count_nodes(child)
return count
def get_info(self) -> dict:
info = super().get_info()
info['method'] = 'Octree八叉树索引'
info['complexity'] = 'O(log N + K)'
info['max_depth'] = self.max_depth
info['node_count'] = self.node_count
return info

View File

@@ -1,6 +1,7 @@
"""任务管理器 - 负责加载配置、调度任务、汇总结果""" """任务管理器 - 负责加载配置、调度任务、汇总结果"""
import yaml import yaml
import numpy as np import numpy as np
import time
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any from typing import List, Dict, Any
from datetime import datetime from datetime import datetime
@@ -13,9 +14,12 @@ from measurements.point_to_point import PointToPointDistance
from measurements.plane_with_holes import PlaneToPlaneWithHoles from measurements.plane_with_holes import PlaneToPlaneWithHoles
from measurements.point_to_plane_with_holes import PointToPlaneWithHoles from measurements.point_to_plane_with_holes import PointToPlaneWithHoles
from measurements.point_to_point_with_holes import PointToPointWithHoles from measurements.point_to_point_with_holes import PointToPointWithHoles
from measurements.line_to_line import LineToLineDistance
from measurements.line_to_plane import LineToPlaneDistance
from excel_exporter import ExcelExporter from excel_exporter import ExcelExporter
from performance_logger import PerformanceLogger from performance_logger import PerformanceLogger
from measurement_logger import MeasurementLogger from measurement_logger import MeasurementLogger
from spatial_index import LinearScanIndex, GridIndex, GridIndexOptimized, OctreeIndex, KDTreeIndex
class TaskManager: class TaskManager:
@@ -25,6 +29,7 @@ class TaskManager:
"""初始化任务管理器""" """初始化任务管理器"""
self.config = None self.config = None
self.point_cloud = None self.point_cloud = None
self.spatial_index = None # 空间索引
self.tasks = [] self.tasks = []
self.results = [] self.results = []
self.pointcloud_path_override = None self.pointcloud_path_override = None
@@ -53,11 +58,35 @@ class TaskManager:
# 处理相对路径 # 处理相对路径
pointcloud_path = self._resolve_path(pointcloud_path, config_path) pointcloud_path = self._resolve_path(pointcloud_path, config_path)
print(f"\n正在加载点云文件: {pointcloud_path}") # 加载点云
print(f"\n{'='*70}")
print("阶段1: 加载点云")
print("="*70)
print(f" 文件: {Path(pointcloud_path).name}")
load_start = time.time()
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path) self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
print(f"点云加载完成: {len(self.point_cloud)} 个点") load_time = time.time() - load_start
print(f" 点云数量: {len(self.point_cloud):,} 个点")
print(f" 加载耗时: {load_time:.2f} 秒")
# 保存点云加载信息供性能日志使用
self.pointcloud_load_time = load_time
self.pointcloud_filename = Path(pointcloud_path).name
# 构建空间索引
print(f"\n{'='*70}")
print("阶段2: 构建空间索引")
print("="*70)
index_start = time.time()
self._build_spatial_index()
self.index_build_time = time.time() - index_start
# 解析任务 # 解析任务
print(f"\n{'='*70}")
print("阶段3: 解析测量任务")
print("="*70)
tasks_config = self.config.get('tasks', []) tasks_config = self.config.get('tasks', [])
if not tasks_config: if not tasks_config:
print("警告: 配置文件中没有定义任务") print("警告: 配置文件中没有定义任务")
@@ -139,37 +168,57 @@ class TaskManager:
return PlaneToPlaneWithHoles( return PlaneToPlaneWithHoles(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
) )
elif 'point_to_plane' in task_type: elif 'point_to_plane' in task_type:
return PointToPlaneWithHoles( return PointToPlaneWithHoles(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
) )
elif 'hole_center' in task_type and 'point_to_point' in task_type: elif 'hole_center' in task_type and 'point_to_point' in task_type:
return PointToPointWithHoles( return PointToPointWithHoles(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
) )
elif task_type == 'plane_to_plane': elif task_type == 'plane_to_plane':
return PlaneToPlaneDistance( return PlaneToPlaneDistance(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
) )
elif task_type == 'point_to_plane': elif task_type == 'point_to_plane':
return PointToPlaneDistance( return PointToPlaneDistance(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
) )
elif task_type == 'point_to_point': elif task_type == 'point_to_point':
return PointToPointDistance( return PointToPointDistance(
name=task_name, name=task_name,
point_cloud=self.point_cloud, point_cloud=self.point_cloud,
config=task_config config=task_config,
spatial_index=self.spatial_index
)
elif task_type == 'line_to_line':
return LineToLineDistance(
name=task_name,
point_cloud=self.point_cloud,
config=task_config,
spatial_index=self.spatial_index
)
elif task_type == 'line_to_plane':
return LineToPlaneDistance(
name=task_name,
point_cloud=self.point_cloud,
config=task_config,
spatial_index=self.spatial_index
) )
else: else:
print(f"警告: 未知的任务类型: {task_type}") print(f"警告: 未知的任务类型: {task_type}")
@@ -194,6 +243,22 @@ class TaskManager:
perf_logger = PerformanceLogger(str(output_dir)) perf_logger = PerformanceLogger(str(output_dir))
# 记录初始化阶段性能
if hasattr(self, 'pointcloud_load_time'):
perf_logger.log_pointcloud_loading(
self.pointcloud_load_time,
len(self.point_cloud),
self.pointcloud_filename
)
if hasattr(self, 'index_build_time'):
index_info = self.spatial_index.get_info() if self.spatial_index else {}
index_method = self.config.get('roi_extraction', {}).get('method', 'unknown')
perf_logger.log_spatial_index(
self.index_build_time,
index_method,
index_info
)
# 创建详细测量日志记录器 # 创建详细测量日志记录器
detail_logger = MeasurementLogger(str(output_dir)) detail_logger = MeasurementLogger(str(output_dir))
@@ -402,3 +467,44 @@ class TaskManager:
except Exception as e: except Exception as e:
print(f"\n导出Excel失败: {e}") print(f"\n导出Excel失败: {e}")
def _build_spatial_index(self):
"""根据配置构建空间索引"""
index_config = self.config.get('roi_extraction', {})
index_method = index_config.get('method', 'linear').lower()
if index_method == 'linear':
print(" 方法: 线性扫描(无索引)")
self.spatial_index = LinearScanIndex(self.point_cloud)
elif index_method == 'grid':
grid_size = index_config.get('grid_size', 100.0)
use_optimized = index_config.get('use_optimized', True) # 默认使用优化版
if use_optimized:
print(f" 方法: Grid网格索引(优化版 - numpy向量化)")
self.spatial_index = GridIndexOptimized(self.point_cloud, grid_size=grid_size)
else:
print(f" 方法: Grid网格索引(原始版 - Python循环)")
self.spatial_index = GridIndex(self.point_cloud, grid_size=grid_size)
elif index_method == 'octree':
max_depth = index_config.get('max_depth', 8)
max_points = index_config.get('max_points_per_node', 1000)
print(f" 方法: Octree八叉树索引")
self.spatial_index = OctreeIndex(self.point_cloud, max_depth=max_depth, max_points_per_node=max_points)
elif index_method == 'kdtree':
print(f" 方法: KDTree索引")
self.spatial_index = KDTreeIndex(self.point_cloud)
else:
print(f" 警告: 未知的索引方法 '{index_method}',使用线性扫描")
self.spatial_index = LinearScanIndex(self.point_cloud)
# 显示索引信息
info = self.spatial_index.get_info()
if 'grid_count' in info:
print(f" 详细信息: {info['grid_count']:,} 个网格, 平均 {info['avg_points_per_grid']:.1f} 点/格")
elif 'node_count' in info:
print(f" 详细信息: {info['node_count']:,} 个节点")

View File

@@ -39,8 +39,8 @@ registration:
random_seed: random_seed:
evaluation: evaluation:
chunk_size: 2048 chunk_size: 8192
triangle_chunk_size: 4096 triangle_chunk_size: 512
cad_mesh_deflection: 0.1 cad_mesh_deflection: 0.1
curvature: curvature:

View File

@@ -74,15 +74,23 @@ class Evaluator:
) -> tuple[np.ndarray, DistanceStats]: ) -> tuple[np.ndarray, DistanceStats]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dtype = torch.float64 dtype = torch.float64
# vertices/triangles 上 GPU(mesh 通常几百MB,可以接受)
vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device) vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device)
faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device) faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device)
triangles = vertices_t[faces_t] triangles = vertices_t[faces_t]
points_t = torch.as_tensor(points, dtype=dtype, device=device) del vertices_t, faces_t # 释放原始 vertices/faces,只保留 triangles
# points 保留在 CPU,按 point_chunk 逐批传上 GPU,避免 10 亿点全量上 GPU OOM
points_np = np.asarray(points, dtype=np.float64)
n_points = points_np.shape[0]
distances = [] distances = []
with torch.no_grad(): with torch.no_grad():
for start in range(0, points_t.shape[0], point_chunk_size): for start in range(0, n_points, point_chunk_size):
chunk = points_t[start:start + point_chunk_size] chunk = torch.as_tensor(
points_np[start:start + point_chunk_size], dtype=dtype, device=device
)
if triangle_chunk_size is None or triangle_chunk_size <= 0: if triangle_chunk_size is None or triangle_chunk_size <= 0:
dist2 = self._point_to_triangle_distance_squared(chunk, triangles) dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
min_dist2 = dist2.min(dim=1).values min_dist2 = dist2.min(dim=1).values
@@ -90,7 +98,7 @@ class Evaluator:
min_dist2 = torch.full( min_dist2 = torch.full(
(chunk.shape[0],), (chunk.shape[0],),
torch.inf, torch.inf,
dtype=points_t.dtype, dtype=dtype,
device=device, device=device,
) )
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size): for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,14 @@ interface Measurement {
value: number; value: number;
unit?: string; unit?: string;
tolerance?: number; tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
toleranceLabel?: string;
/** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */
baseline?: number;
status?: 'pass' | 'warning' | 'fail' | 'info'; status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
failLabel?: string;
} }
interface ErrorStats { interface ErrorStats {
@@ -42,7 +49,7 @@ interface PluginResultLike {
overlay3d?: Overlay3D; overlay3d?: Overlay3D;
images?: string[]; images?: string[];
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */ /** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
files?: string[]; files?: (string | { name?: string; path?: string })[];
extra?: Record<string, unknown>; extra?: Record<string, unknown>;
error?: string; error?: string;
} }
@@ -70,16 +77,16 @@ function statusClass(s?: Measurement['status']) {
} }
} }
function statusLabel(s?: Measurement['status']) { function statusLabel(m: Measurement) {
switch (s) { switch (m.status) {
case 'pass': case 'pass':
return '合格'; return m.passLabel ?? '合格';
case 'warning': case 'warning':
return '警告'; return '警告';
case 'fail': case 'fail':
return '超差'; return m.failLabel ?? '超差';
default: default:
return '—'; return '参考';
} }
} }
@@ -101,7 +108,9 @@ export default function GenericResultView({
const errorStats = data.errorStats ?? null; const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? []; const measurements = data.measurements ?? [];
const images = data.images ?? []; const images = data.images ?? [];
const files = data.files ?? []; const files = (data.files ?? []).map((f) =>
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
);
const overlay = data.overlay3d ?? { kind: 'none' }; const overlay = data.overlay3d ?? { kind: 'none' };
return ( return (
@@ -152,14 +161,22 @@ export default function GenericResultView({
{m.name} {m.name}
</td> </td>
<td className="text-right font-mono"> <td className="text-right font-mono">
{formatValue(m.value)} {m.unit ?? ''} {m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td> </td>
<td className="text-right text-muted-foreground"> <td className="text-right text-muted-foreground">
{m.tolerance != null ? `±${m.tolerance}` : '—'} {m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
? m.baseline != null
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td> </td>
<td className="text-right"> <td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}> <span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
{m.status === 'info' ? '参考' : statusLabel(m.status)} {m.status === 'info' ? '参考' : statusLabel(m)}
</span> </span>
</td> </td>
</tr> </tr>

View File

@@ -415,7 +415,12 @@ const PointCloudMesh = memo(function PointCloudMesh({
positions[i3 + 1] = point.y; positions[i3 + 1] = point.y;
positions[i3 + 2] = point.z; positions[i3 + 2] = point.z;
if (showHeatmap) { if (point.r !== undefined && point.g !== undefined && point.b !== undefined) {
// 点云自带颜色(如平整度热力图)直接使用,不走 errorToColor
colors[i3] = point.r;
colors[i3 + 1] = point.g;
colors[i3 + 2] = point.b;
} else if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError); const color = errorToColor(point.error ?? 0, minError, maxError);
colors[i3] = color.r; colors[i3] = color.r;
colors[i3 + 1] = color.g; colors[i3 + 1] = color.g;
@@ -1389,7 +1394,7 @@ const SceneContent = memo(function SceneContent({
/> />
</bufferGeometry> </bufferGeometry>
<pointsMaterial <pointsMaterial
size={0.04} size={pointSize ?? 0.03}
vertexColors vertexColors
sizeAttenuation sizeAttenuation
transparent transparent
@@ -1529,11 +1534,13 @@ async function fetchBatchSampled(
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// 接收二进制,直接解析为 Float32Array,无 JSON 开销 // 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const arrayBuffer = await resp.arrayBuffer(); const arrayBuffer = await resp.arrayBuffer();
const floats = new Float32Array(arrayBuffer); const floats = new Float32Array(arrayBuffer);
const count = Math.floor(floats.length / 4); const count = Math.floor(floats.length / stride);
console.log(`[PointCloudViewer] Binary received: ${count} pts, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`); console.log(`[PointCloudViewer] Binary received: ${count} pts, hasColor=${hasColor}, ${(arrayBuffer.byteLength / 1e6).toFixed(1)} MB`);
if (count === 0) return { points: [] }; if (count === 0) return { points: [] };
@@ -1543,9 +1550,15 @@ async function fetchBatchSampled(
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const base = i * 4; const base = i * stride;
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3]; const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
pts[i] = { x, y, z, error }; const pt: Point = { x, y, z, error };
if (hasColor) {
pt.r = floats[base + 4];
pt.g = floats[base + 5];
pt.b = floats[base + 6];
}
pts[i] = pt;
if (x < minX) minX = x; if (x > maxX) maxX = x; if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y; if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
@@ -1700,6 +1713,9 @@ export default function PointCloudViewer({
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ); const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
const scale = maxDim > 0 ? 10 / maxDim : 1; const scale = maxDim > 0 ? 10 / maxDim : 1;
// 保存变换参数,供 ply 叠加层对齐用
pcTransformRef.current = { cx: centerX, cy: centerY, cz: centerZ, scale };
for (let i = 0; i < pts.length; i++) { for (let i = 0; i < pts.length; i++) {
const p = pts[i]; const p = pts[i];
p.x = (p.x - centerX) * scale; p.x = (p.x - centerX) * scale;
@@ -1753,23 +1769,73 @@ export default function PointCloudViewer({
// PLY 叠加层状态(kind=ply) // PLY 叠加层状态(kind=ply)
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null); const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
// 主点云的坐标变换参数,供 ply 叠加层对齐用
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
// 加载 PLY 叠加层 // 加载 PLY 叠加层:走采样接口,避免浏览器加载 15GB 文件
useEffect(() => { useEffect(() => {
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') { if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
setPlyOverlayData(null); setPlyOverlayData(null);
return; return;
} }
const { taskId, resultId, overlay } = pluginOverlay; const { taskId, resultId, overlay } = pluginOverlay;
const url = `/api/files/read/analysis/${taskId}/${resultId}/${overlay.file}`;
const ac = new AbortController(); const ac = new AbortController();
(async () => { (async () => {
try { try {
const resp = await fetch(url, { signal: ac.signal }); // 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样
const plyFiles: string[] = (overlay as { files?: string[]; file: string }).files
?? [overlay.file];
const resp = await fetch('/api/files/sample/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
totalPoints: 300_000,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const text = await resp.text(); const hasColor = resp.headers.get('X-Has-Color') === '1';
const parsed = parsePlyText(text); const stride = hasColor ? 7 : 4;
if (parsed) setPlyOverlayData(parsed); const buf = await resp.arrayBuffer();
const floats = new Float32Array(buf);
const count = Math.floor(floats.length / stride);
if (count === 0) 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;
}
}
// 用主点云的坐标变换对齐叠加层坐标
const t = pcTransformRef.current;
if (t) {
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 });
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return; if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PlyOverlay] load failed:', e); console.warn('[PlyOverlay] load failed:', e);

View File

@@ -91,6 +91,13 @@ export default function TaskManager({
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }), onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
}); });
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
const cadFileIds = taskCadFiles.map((f) => f.id);
const { data: cadSampleCacheMap = {} } = trpc.taskCadFile.hasSampleCache.useQuery(
{ cadFileIds },
{ enabled: cadFileIds.length > 0, staleTime: 30_000 },
);
// 切换任务时清空文件选中状态 // 切换任务时清空文件选中状态
useEffect(() => { useEffect(() => {
setSelectedFileIds(new Set()); setSelectedFileIds(new Set());
@@ -539,6 +546,14 @@ export default function TaskManager({
</button> </button>
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} /> <FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
<span className="flex-1 truncate" title={f.name}>{f.name}</span> <span className="flex-1 truncate" title={f.name}>{f.name}</span>
{cadSampleCacheMap[f.id] && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-green-500/15 text-green-400 border border-green-500/30 flex-shrink-0"
title="已完成三角化预处理,下次配准可跳过 CAD 解析"
>
已预处理
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0"> <span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)} {formatSize(f.fileSize)}
</span> </span>

View File

@@ -38,7 +38,7 @@ services:
container_name: massive-point-cloud-app-dev container_name: massive-point-cloud-app-dev
working_dir: /app working_dir: /app
network_mode: host network_mode: host
runtime: nvidia # docker-compose V1 用 # runtime: nvidia # docker-compose V1 用
# gpus: all # docker compose V2 用这行,同时注释上面的 runtime # gpus: all # docker compose V2 用这行,同时注释上面的 runtime
environment: environment:
- NODE_ENV=development - NODE_ENV=development
@@ -75,10 +75,13 @@ services:
- ${LOCAL_DATA_DIR:-./data}:/data - ${LOCAL_DATA_DIR:-./data}:/data
# 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载) # 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载)
- ${LOCAL_ALG_DIR:-./alg}:/alg:ro - ${LOCAL_ALG_DIR:-./alg}:/alg:ro
command: sh -c " command:
LIBSTDCPP=\$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1) && - bash
[ -n \"\$LIBSTDCPP\" ] && ln -sf \"\$LIBSTDCPP\" /usr/lib/x86_64-linux-gnu/libstdc++.so.6 && - -c
corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0" - |
LIBSTDCPP=$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1)
[ -n "$LIBSTDCPP" ] && ln -sf "$LIBSTDCPP" /usr/lib/x86_64-linux-gnu/libstdc++.so.6
corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

View File

@@ -283,7 +283,10 @@ router.post('/sample/batch', async (req, res) => {
); );
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
const binBuf = Buffer.allocUnsafe(totalSampled * 16); // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
let offset = 0; let offset = 0;
for (const pts of sampledPerFile) { for (const pts of sampledPerFile) {
for (const p of pts) { for (const p of pts) {
@@ -291,7 +294,12 @@ router.post('/sample/batch', async (req, res) => {
binBuf.writeFloatLE(p.y, offset + 4); binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8); binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12); binBuf.writeFloatLE(p.error, offset + 12);
offset += 16; if (hasColor) {
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24);
}
offset += bytesPerPoint;
} }
} }
@@ -304,6 +312,7 @@ router.post('/sample/batch', async (req, res) => {
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`); console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled); res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats)); res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf); res.send(binBuf);
} catch (err) { } catch (err) {
@@ -401,7 +410,45 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
res.sendFile(filePath); res.sendFile(filePath);
}); });
// ─── 插件热重载 ────────────────────────────────────────────────────────────── // ─── 分析结果重新同步 ────────────────────────────────────────────────────────
// POST /api/files/resync/analysis/:taskId/:resultId
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
return res.status(400).json({ error: 'invalid params' });
}
const row = await db.getTaskAnalysisResultById(resultId);
if (!row || row.taskId !== taskId) {
return res.status(404).json({ error: 'not found' });
}
const resultJsonPath = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId), 'result.json');
if (!fs.existsSync(resultJsonPath)) {
return res.status(404).json({ error: 'result.json missing' });
}
try {
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
const mergedRd = {
...existingRd,
message: result.message ?? existingRd.message,
measurements: result.measurements ?? existingRd.measurements,
errorStats: result.errorStats ?? existingRd.errorStats,
overlay3d: result.overlay3d ?? existingRd.overlay3d,
images: result.images ?? existingRd.images,
files: result.files ?? existingRd.files,
extra: result.extra ?? existingRd.extra,
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
};
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
res.json({ success: true, overlay3d: result.overlay3d });
} catch (e) {
res.status(500).json({ error: String(e) });
}
});
// POST /api/files/plugins/reload // POST /api/files/plugins/reload
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。 // 重新从磁盘扫描 manifests/*.json,无需重启服务器。
// 用于更新算法版本后立即生效。 // 用于更新算法版本后立即生效。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -162,6 +162,25 @@ export const appRouter = router({
await db.deleteTaskCadFile(input.id); await db.deleteTaskCadFile(input.id);
return { success: true }; return { success: true };
}), }),
// 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY)
hasSampleCache: protectedProcedure
.input(z.object({ cadFileIds: z.array(z.number()) }))
.query(async ({ input }) => {
const result: Record<number, boolean> = {};
for (const id of input.cadFileIds) {
const row = await db.getTaskCadFileById(id);
if (!row?.filePath) { result[id] = false; continue; }
const cacheDir = path.join(path.dirname(row.filePath), '.cad_sample_cache');
try {
const files = fs.readdirSync(cacheDir);
result[id] = files.some(f => f.endsWith('.ply'));
} catch {
result[id] = false;
}
}
return result;
}),
}), }),
// ─── 检测任务 ────────────────────────────────────────────────────────────── // ─── 检测任务 ──────────────────────────────────────────────────────────────

View File

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

View File

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