feat: update algorithm integrations and point cloud overlays
This commit is contained in:
@@ -1,552 +0,0 @@
|
||||
# 列车点云测量系统
|
||||
|
||||
基于配置驱动的点云测量系统,支持多种测量任务类型。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、距离计算,并生成Excel报告和可视化点云文件。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
train_measurement_system/
|
||||
├── config/
|
||||
│ └── measurements_0610.yaml # 测量任务配置文件
|
||||
├── src/
|
||||
│ ├── __init__.py # 包初始化文件
|
||||
│ ├── measurements/
|
||||
│ │ ├── __init__.py # 子包初始化文件
|
||||
│ │ ├── base.py # 测量任务基类
|
||||
│ │ ├── plane_distance.py # 平面到平面距离测量(含宽度差值检查)
|
||||
│ │ ├── point_to_plane.py # 点到平面距离测量
|
||||
│ │ ├── point_to_point.py # 点到点距离测量
|
||||
│ │ ├── plane_with_holes.py # 带孔平面测量
|
||||
│ │ ├── point_to_plane_with_holes.py
|
||||
│ │ └── point_to_point_with_holes.py
|
||||
│ ├── hole_detect/ # 孔检测模块
|
||||
│ │ ├── hole_detector.py
|
||||
│ │ └── detect.py
|
||||
│ ├── config_loader.py # 配置加载器
|
||||
│ ├── task_manager.py # 任务管理器(含任务19自动生成)
|
||||
│ ├── roi_extractor.py # ROI提取器
|
||||
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
|
||||
│ ├── hole_center_fitter.py # 孔中心拟合器
|
||||
│ ├── pointcloud_loader.py # 点云加载器
|
||||
│ ├── excel_exporter.py # Excel结果导出器
|
||||
│ ├── performance_logger.py # 性能日志记录器
|
||||
│ ├── measurement_logger.py # 详细测量日志记录器
|
||||
│ └── visualizer.py # 可视化工具
|
||||
├── results/ # 测量结果输出目录
|
||||
│ ├── measurement_results_*.xlsx # Excel测量结果
|
||||
│ ├── measurement_detail_*.log # 详细测量日志
|
||||
│ └── performance_*.log # 性能分析报告
|
||||
├── visualizations/ # 可视化点云输出目录(PLY文件)
|
||||
├── main.py # 主程序入口
|
||||
├── setup.py # 安装脚本
|
||||
├── requirements.txt # 依赖清单
|
||||
├── USAGE.md # 使用说明
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
|
||||
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔平面测量
|
||||
- **宽度差值检查**: 任务18自动生成任务19,检查门口上、中、下位置的宽度差值(±2mm公差)
|
||||
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
|
||||
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
|
||||
- **结果导出**: 自动生成格式化的Excel报告和详细日志
|
||||
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声,支持稀疏点云(可调阈值)
|
||||
- **性能优化**: 点云缓存、详细性能分析报告
|
||||
- **错误处理**: 单个任务失败不影响其他任务执行
|
||||
- **可扩展**: 易于添加新的测量类型
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- 推荐使用 conda 环境管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建并激活环境
|
||||
|
||||
```bash
|
||||
# 创建 conda 环境
|
||||
conda create -n pcc-experiment python=3.9
|
||||
conda activate pcc-experiment
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
**方式一:使用 requirements.txt**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**方式二:安装项目**
|
||||
```bash
|
||||
# 正常安装
|
||||
pip install .
|
||||
|
||||
# 或开发模式安装(代码修改立即生效)
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
安装项目会自动安装所有依赖包,并将项目注册到 Python 环境中。
|
||||
|
||||
### 2. 准备点云文件
|
||||
|
||||
确保你有完整的列车点云文件(PLY格式),例如:
|
||||
```
|
||||
D:\lab\PCC\PCC\head_train_pointcloud\train_2_100M.ply
|
||||
```
|
||||
|
||||
### 3. 配置测量任务
|
||||
|
||||
编辑 `config/measurements_0610.yaml`,设置点云文件路径和测量任务。
|
||||
|
||||
**支持相对路径(相对于配置文件)或绝对路径:**
|
||||
|
||||
```yaml
|
||||
# 点云文件路径
|
||||
pointcloud_file: "点云存放路径/train_2_1T.ply"
|
||||
|
||||
tasks:
|
||||
- name: "任务18_门口宽度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "乘客门口宽度"
|
||||
theoretical_dimension: 1070
|
||||
tolerance:
|
||||
upper: 3
|
||||
lower: 0
|
||||
|
||||
source_roi:
|
||||
bbox_min: [-2552, 1390, 1402]
|
||||
bbox_max: [-2551, 3080, 1577]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-1482, 1400, 1513]
|
||||
bbox_max: [-1481, 3120, 1591]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.1
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
check_width_variation: true # 启用宽度差值检查(任务19)
|
||||
width_variation_tolerance: 2.0 # 差值公差 ±2mm
|
||||
|
||||
- name: "任务43_窗口下沿高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量窗口下沿到地面的距离"
|
||||
theoretical_dimension: 803
|
||||
tolerance:
|
||||
upper: 6.5
|
||||
lower: -4
|
||||
|
||||
source_roi:
|
||||
bbox_min: [5562, 1981, 1672]
|
||||
bbox_max: [6772, 1983, 1677]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1179, -302]
|
||||
bbox_max: [12428, 1181, 302]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.5 # 稀疏点云使用较大阈值
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
```
|
||||
|
||||
### 4. 运行测量
|
||||
|
||||
```bash
|
||||
# 使用默认配置文件
|
||||
python main.py
|
||||
|
||||
# 指定配置文件
|
||||
python main.py -c config/measurements_0610.yaml
|
||||
|
||||
# 指定点云文件(覆盖配置文件中的路径)
|
||||
python main.py -p /path/to/your/pointcloud.ply
|
||||
|
||||
# 指定输出目录
|
||||
python main.py -o /path/to/output
|
||||
|
||||
# 组合使用
|
||||
python main.py -c config/measurements_0610.yaml -o results/
|
||||
```
|
||||
|
||||
**长时间任务建议在tmux中运行:**
|
||||
```bash
|
||||
tmux new-session -s measurement 'python main.py -c config/measurements_0610.yaml'
|
||||
# 分离会话: Ctrl+B, D
|
||||
# 重新附加: tmux attach -t measurement
|
||||
```
|
||||
|
||||
**命令行参数:**
|
||||
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
|
||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
||||
- `-o, --output`: 结果输出目录(默认: results/)
|
||||
|
||||
### 5. 查看结果
|
||||
|
||||
**Excel报告:** 保存在 `results/` 目录下
|
||||
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||
- 包含每个任务的详细统计信息:
|
||||
- 项点名称、理论尺寸、公差要求
|
||||
- 测量结果(平均距离)、精度(标准差)
|
||||
- 误差范围、测量结论(通过/不通过)
|
||||
- 执行时间、错误信息(如有)
|
||||
|
||||
**任务19特殊说明:**
|
||||
- 当任务18启用 `check_width_variation: true` 时,自动生成任务19
|
||||
- 任务19在Excel中单独一行,显示宽度差值检查结果
|
||||
- 理论尺寸: 0,公差: ±2.0mm
|
||||
- 测量结果: 最大差值
|
||||
- 精度: 显示三个差值(上-中、中-下、上-下)
|
||||
- 误差范围: 差值的最小和最大值
|
||||
|
||||
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
|
||||
- 每个任务的详细执行过程
|
||||
- ROI提取信息、RANSAC拟合详情
|
||||
- 宽度差值检查详情(如适用)
|
||||
|
||||
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
|
||||
- 各任务执行时间
|
||||
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
|
||||
- 平均耗时统计
|
||||
- 点位参数(ROI 范围、目标点坐标等)
|
||||
- 测量数据(平均距离、标准差、最小/最大距离)
|
||||
- 采样点数、ROI 点数、内点率
|
||||
- 错误信息(如果失败)
|
||||
|
||||
**可视化点云:** 保存在 `visualizations/` 目录下
|
||||
- 文件格式:PLY
|
||||
- 可使用CloudCompare或Open3D查看
|
||||
- 用于验证ROI提取和测量点位置是否正确
|
||||
|
||||
## 测量类型详解
|
||||
|
||||
### 1. plane_to_plane(平面到平面距离)
|
||||
|
||||
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
|
||||
|
||||
**工作流程:**
|
||||
1. 从source_roi包围盒提取点云
|
||||
2. 使用RANSAC拟合源平面
|
||||
3. 从拟合的内点中随机采样指定数量的点
|
||||
4. 从target_roi包围盒提取点云
|
||||
5. 使用RANSAC拟合目标平面
|
||||
6. 计算采样点到目标平面的有向距离
|
||||
7. 统计平均值、标准差、最小/最大距离
|
||||
|
||||
**配置参数:**
|
||||
```yaml
|
||||
type: "plane_to_plane"
|
||||
source_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
target_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
params:
|
||||
sample_points: 100 # 采样点数
|
||||
ransac_threshold: 2.0 # RANSAC距离阈值(mm)
|
||||
ransac_max_iterations: 1000 # RANSAC最大迭代次数
|
||||
min_inlier_ratio: 0.6 # 最小内点比例
|
||||
```
|
||||
|
||||
**输出结果:**
|
||||
- 平均距离(mm)
|
||||
- 标准差(mm)
|
||||
- 最小/最大距离(mm)
|
||||
- 采样点数
|
||||
- Source ROI点数
|
||||
- Target ROI点数
|
||||
- 平面拟合内点率
|
||||
|
||||
**可视化文件:**
|
||||
- `{任务名}_source_roi.ply`:源平面ROI点云
|
||||
- `{任务名}_target_roi.ply`:目标平面ROI点云
|
||||
|
||||
---
|
||||
|
||||
### 2. point_to_plane(点到平面距离)
|
||||
|
||||
测量指定点到参考平面的距离。支持两种模式:精确坐标查找和包围盒筛选。
|
||||
|
||||
**工作流程:**
|
||||
1. 根据模式获取测量点:
|
||||
- `exact_points`模式:在点云中查找最近点
|
||||
- `bbox_filtered`模式:从包围盒提取点云并筛选
|
||||
2. 从target_roi提取点云并拟合参考平面(目前的实现中,直接用z=0平面,不做拟合)
|
||||
3. 计算每个点到平面的距离
|
||||
4. 统计平均值、标准差等
|
||||
|
||||
**配置参数(exact_points模式):**
|
||||
```yaml
|
||||
type: "point_to_plane"
|
||||
mode: "exact_points"
|
||||
points:
|
||||
- [-753.5, 1052.5, 1445]
|
||||
- [3446.5, 1053, 1445]
|
||||
- [-753.5, 1052.5, -1445]
|
||||
target_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
params:
|
||||
search_radius: 50.0 # 查找最近点的搜索半径(mm)
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
use_absolute_distance: true # 是否使用绝对距离
|
||||
```
|
||||
|
||||
**配置参数(bbox_filtered模式):**
|
||||
```yaml
|
||||
type: "point_to_plane"
|
||||
mode: "bbox_filtered"
|
||||
source_rois:
|
||||
- bbox_min: [x1_min, y1_min, z1_min]
|
||||
bbox_max: [x1_max, y1_max, z1_max]
|
||||
- bbox_min: [x2_min, y2_min, z2_min]
|
||||
bbox_max: [x2_max, y2_max, z2_max]
|
||||
filter:
|
||||
type: "max_abs_coordinate" # 筛选类型
|
||||
axis: "z" # 筛选坐标轴
|
||||
top_n: 10 # 每个ROI选择前N个点
|
||||
target_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
params:
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
use_absolute_distance: true
|
||||
```
|
||||
|
||||
**输出结果:**
|
||||
- 平均距离(mm)
|
||||
- 标准差(mm)
|
||||
- 最小/最大距离(mm)
|
||||
- 测量点数
|
||||
- Target ROI点数
|
||||
- 平面拟合内点率
|
||||
|
||||
**可视化文件:**
|
||||
- `{任务名}_found_points.ply`:测量点点云
|
||||
|
||||
---
|
||||
|
||||
### 3. point_to_point(点到点距离)
|
||||
|
||||
测量点对之间的欧氏距离。支持对称点自动配对(例如测量关于z=0对称的点对)。
|
||||
|
||||
**工作流程:**
|
||||
1. 获取测量点:在点云中查找最近点
|
||||
2. 根据z坐标符号自动配对(z>0和z<0的点配对)
|
||||
3. 计算每对点之间的欧氏距离
|
||||
4. 统计平均值、标准差等
|
||||
|
||||
**配置参数:**
|
||||
``` yaml
|
||||
type: "point_to_point"
|
||||
|
||||
# 点对配置(使用任务3的6个点,配对测量)
|
||||
point_pairs:
|
||||
- name: "前端点对"
|
||||
point1: [-753.5, 1052.5, -1445]
|
||||
point2: [-753.5, 1052.5, 1445]
|
||||
- name: "中间点对"
|
||||
point1: [3446.5, 1053, -1445]
|
||||
point2: [3446.5, 1053, 1445]
|
||||
- name: "后端点对"
|
||||
point1: [8146.5, 1053, -1445]
|
||||
point2: [8146.5, 1053, 1445]
|
||||
|
||||
# 最近点搜索半径(mm)
|
||||
search_radius: 10.0
|
||||
```
|
||||
|
||||
**输出结果:**
|
||||
- 平均距离(mm)
|
||||
- 标准差(mm)
|
||||
- 最小/最大距离(mm)
|
||||
- 点对数量
|
||||
|
||||
**可视化文件:**
|
||||
- `{任务名}_found_points.ply`:包含测量点的点云
|
||||
|
||||
---
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
### 基本结构
|
||||
|
||||
```yaml
|
||||
# 点云文件路径(必填)
|
||||
pointcloud_file: "D:\\path\\to\\pointcloud.ply"
|
||||
|
||||
# 测量任务列表
|
||||
tasks:
|
||||
- name: "任务名称"
|
||||
type: "测量类型"
|
||||
enabled: true/false
|
||||
description: "任务描述"
|
||||
visualization_dir: "visualizations"
|
||||
# ... 其他参数
|
||||
```
|
||||
|
||||
### 通用参数
|
||||
|
||||
- `name`:任务名称(必填),用于标识任务和命名输出文件
|
||||
- `type`:测量类型(必填),可选值:`plane_to_plane`、`point_to_plane`、`point_to_point`
|
||||
- `enabled`:是否启用任务(必填),设为`false`可跳过该任务
|
||||
- `description`:任务描述(可选),用于文档说明
|
||||
- `visualization_dir`:可视化输出目录(可选),设置后会保存ROI点云文件
|
||||
|
||||
### 包围盒格式
|
||||
|
||||
所有包围盒使用以下格式,坐标单位为毫米(mm):
|
||||
|
||||
```yaml
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
```
|
||||
|
||||
**坐标系说明:**
|
||||
- X轴:列车横向(车头到车尾)
|
||||
- Y轴:列车高度(底部到顶部)
|
||||
- Z轴:列车纵向(左侧到右侧)
|
||||
|
||||
### RANSAC参数调优
|
||||
|
||||
RANSAC用于平面拟合,以下参数影响拟合质量:
|
||||
|
||||
- `ransac_threshold`:内点距离阈值(mm)
|
||||
- 较小值(1.0-2.0):严格拟合,适合高质量点云
|
||||
- 较大值(3.0-5.0):宽松拟合,适合噪声较多的点云
|
||||
|
||||
- `ransac_max_iterations`:最大迭代次数
|
||||
- 默认1000次,通常足够
|
||||
- 如果内点率很低,可增加到2000-5000
|
||||
|
||||
- `min_inlier_ratio`:最小内点比例
|
||||
- 默认0.6(60%),表示至少60%的点在平面上
|
||||
- 如果点云包含多个平面或噪声多,可降低到0.4-0.5
|
||||
|
||||
**调优建议:**
|
||||
1. 先运行一次,查看内点率
|
||||
2. 如果内点率低于min_inlier_ratio,任务会失败
|
||||
3. 根据实际内点率调整参数:
|
||||
- 内点率50%-60%:降低min_inlier_ratio或增大ransac_threshold
|
||||
- 内点率>90%:可以提高要求,减小ransac_threshold
|
||||
|
||||
---
|
||||
|
||||
## 添加新任务
|
||||
|
||||
### 步骤1:确定测量类型
|
||||
|
||||
根据测量需求选择合适的类型:
|
||||
- **平面到平面距离**:车门高度、车体长度、窗口高度等
|
||||
- **点到平面距离**:特定点到参考平面的高度
|
||||
- **点到点距离**:对称点对之间的距离
|
||||
|
||||
### 步骤2:确定ROI包围盒
|
||||
|
||||
**方法1:使用点云浏览器工具**
|
||||
|
||||
|
||||
**方法2:使用CloudCompare或Open3D或Freecad手动查看**
|
||||
- 打开点云文件
|
||||
- 使用测量工具确定目标区域的坐标范围
|
||||
- 记录包围盒的最小和最大坐标
|
||||
|
||||
### 步骤3:编辑配置文件
|
||||
|
||||
在 `config/measurements.yaml` 中添加新任务:
|
||||
|
||||
```yaml
|
||||
- name: "新任务名称"
|
||||
type: "plane_to_plane" # 或其他类型
|
||||
enabled: true
|
||||
description: "任务描述"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
source_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
```
|
||||
|
||||
### 步骤4:运行测试
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 步骤5:验证结果
|
||||
|
||||
1. **查看Excel报告**:检查测量结果是否合理
|
||||
2. **查看可视化点云**:使用CloudCompare或Open3D打开PLY文件
|
||||
- 确认ROI提取的位置正确
|
||||
- 确认平面拟合质量(内点率)
|
||||
3. **调整参数**:如果结果不理想,调整包围盒或RANSAC参数
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 平面拟合算法
|
||||
|
||||
系统使用Open3D的RANSAC算法进行平面拟合:
|
||||
|
||||
1. **随机采样**:从点云中随机选择3个点
|
||||
2. **拟合平面**:计算平面方程 ax + by + cz + d = 0
|
||||
3. **统计内点**:计算所有点到平面的距离,距离小于阈值的为内点
|
||||
4. **迭代优化**:重复上述步骤,选择内点最多的平面
|
||||
5. **最终拟合**:使用所有内点进行最小二乘拟合(SVD),得到最优平面
|
||||
|
||||
**平面方程:** ax + by + cz + d = 0
|
||||
- (a, b, c):平面法向量(单位向量)
|
||||
- d:平面到原点的有向距离
|
||||
|
||||
### 距离计算
|
||||
|
||||
**点到平面距离:**
|
||||
```
|
||||
distance = |ax + by + cz + d| / sqrt(a² + b² + c²)
|
||||
```
|
||||
|
||||
由于法向量已归一化(sqrt(a² + b² + c²) = 1),简化为:
|
||||
```
|
||||
distance = |ax + by + cz + d|
|
||||
```
|
||||
|
||||
**有向距离:**
|
||||
- 正值:点在法向量指向的一侧
|
||||
- 负值:点在法向量相反的一侧
|
||||
- 绝对距离:取绝对值,不考虑方向
|
||||
|
||||
**点到点距离(欧氏距离):**
|
||||
```
|
||||
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
|
||||
```
|
||||
|
||||
|
||||
### 性能优化
|
||||
|
||||
- **点云缓存**:点云文件只加载一次,所有任务共享
|
||||
- **自动降采样**:ROI超过10000点时自动降采样到10000点进行SVD拟合
|
||||
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# 使用说明
|
||||
|
||||
## 环境安装
|
||||
|
||||
### 1. 创建 Conda 环境
|
||||
```bash
|
||||
conda create -n pcc-experiment python=3.9
|
||||
conda activate pcc-experiment
|
||||
```
|
||||
|
||||
### 2. 安装依赖包
|
||||
|
||||
**方式一:使用 requirements.txt**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**方式二:安装项目**
|
||||
```bash
|
||||
# 正常安装
|
||||
pip install .
|
||||
|
||||
# 或开发模式安装(代码修改立即生效)
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
安装项目会自动安装所有依赖包,并将项目添加到 Python 环境中。
|
||||
|
||||
## 配置测量任务
|
||||
|
||||
编辑 `config/measurements_0610.yaml` 文件:
|
||||
|
||||
### 1. 设置点云文件路径
|
||||
```yaml
|
||||
# 支持相对路径(相对于配置文件)或绝对路径
|
||||
pointcloud_file: "点云路径/train_2_1T.ply"
|
||||
```
|
||||
|
||||
### 2. 配置测量任务
|
||||
```yaml
|
||||
tasks:
|
||||
- name: "任务名称"
|
||||
type: "plane_to_plane" # 任务类型: plane_to_plane, point_to_plane, point_to_point
|
||||
enabled: true # 是否启用
|
||||
description: "任务描述"
|
||||
visualization_dir: "visualizations" # 可视化输出目录(可选)
|
||||
|
||||
# 根据任务类型配置相应参数
|
||||
source_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
```
|
||||
|
||||
## 运行测量
|
||||
|
||||
### 基本用法
|
||||
```bash
|
||||
# 使用默认配置文件
|
||||
python main.py
|
||||
|
||||
# 指定配置文件
|
||||
python main.py -c config/custom.yaml
|
||||
|
||||
# 指定点云文件(覆盖配置文件中的路径)
|
||||
python main.py -p data/train.ply
|
||||
|
||||
# 指定输出目录
|
||||
python main.py -o results/output
|
||||
|
||||
# 组合使用
|
||||
python main.py -c config/custom.yaml -p data/train.ply -o results/
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
|
||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
||||
- `-o, --output`: 结果输出目录(默认: results/)
|
||||
|
||||
## 查看结果
|
||||
|
||||
### Excel 结果文件
|
||||
位置: `results/measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||
|
||||
包含内容:
|
||||
- 任务名称、类型、描述
|
||||
- 点位参数(ROI 范围、目标点坐标等)
|
||||
- 测量数据(平均距离、标准差、最小/最大距离)
|
||||
- 采样点数、ROI 点数、内点率
|
||||
- 错误信息(如果失败)
|
||||
|
||||
### 可视化文件
|
||||
位置: `visualizations/*.ply`
|
||||
|
||||
使用 CloudCompare 或 MeshLab 打开查看:
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 任务没有执行
|
||||
检查配置文件中 `enabled: true` 是否设置
|
||||
|
||||
### 点云文件找不到
|
||||
- 使用绝对路径,或
|
||||
- 使用相对于配置文件的相对路径,或
|
||||
- 使用 `-p` 参数指定点云文件
|
||||
|
||||
### 测量结果异常
|
||||
- 检查 ROI 包围盒是否正确
|
||||
- 查看 Excel 中的内点率(建议 >70%)
|
||||
- 打开 PLY 文件检查 ROI 提取是否准确
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
单文件快速检测脚本
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from hole_detector import HoleDetector
|
||||
|
||||
|
||||
def quick_detect(ply_path, output_dir="results", axis_idx=None):
|
||||
"""
|
||||
快速检测单个点云文件的孔中心
|
||||
|
||||
Args:
|
||||
ply_path: 点云文件路径
|
||||
output_dir: 输出目录
|
||||
axis_idx: 圆柱轴向 (0=X, 1=Y, 2=Z),None则自动检测
|
||||
"""
|
||||
ply_path = Path(ply_path)
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"快速检测: {ply_path.name}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
# 创建检测器
|
||||
detector = HoleDetector(axis_idx=axis_idx)
|
||||
|
||||
# 加载点云
|
||||
points = detector.load_pointcloud(ply_path)
|
||||
|
||||
# 检测孔
|
||||
result = detector.detect_hole(points)
|
||||
|
||||
# 获取3D圆心
|
||||
center_3d = detector.get_3d_center(points)
|
||||
|
||||
# 生成可视化
|
||||
vis_path = output_dir / f"{ply_path.stem}_detection.png"
|
||||
detector.visualize(vis_path)
|
||||
|
||||
# 输出结果
|
||||
print(f"\n{'='*70}")
|
||||
print(f"[OK] 检测完成!")
|
||||
print(f"{'='*70}")
|
||||
print(f"3D 圆心坐标:")
|
||||
print(f" X = {center_3d[0]:.3f}")
|
||||
print(f" Y = {center_3d[1]:.3f}")
|
||||
print(f" Z = {center_3d[2]:.3f}")
|
||||
print(f"\n半径: {result['radius']:.3f}")
|
||||
print(f"轴向: {result['axis_name']}")
|
||||
print(f"\n统计:")
|
||||
print(f" 圆内点数: {result['stats']['inside_count']} ({result['stats']['inside_ratio']*100:.1f}%)")
|
||||
print(f" 圆环点数: {result['stats']['ring_count']} ({result['stats']['ring_ratio']*100:.1f}%)")
|
||||
print(f"\n可视化图已保存: {vis_path}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
return center_3d, result['radius']
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python detect.py <ply文件路径> [输出目录] [轴向:X|Y|Z]")
|
||||
print("\n示例:")
|
||||
print(" python detect.py train_2_1T_noisy_cropped.ply")
|
||||
print(" python detect.py hole.ply results Z")
|
||||
sys.exit(1)
|
||||
|
||||
ply_path = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else "results"
|
||||
|
||||
axis_idx = None
|
||||
if len(sys.argv) > 3:
|
||||
axis_map = {'X': 0, 'Y': 1, 'Z': 2}
|
||||
axis_idx = axis_map.get(sys.argv[3].upper())
|
||||
|
||||
quick_detect(ply_path, output_dir, axis_idx)
|
||||
@@ -1,407 +0,0 @@
|
||||
"""
|
||||
圆柱孔检测工具
|
||||
用于检测点云中圆柱孔的中心位置和半径
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
import matplotlib.pyplot as plt
|
||||
from scipy.optimize import minimize, least_squares
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
|
||||
class HoleDetector:
|
||||
"""圆柱孔检测器"""
|
||||
|
||||
def __init__(self, axis_idx=None, search_range=0.3, max_radius_ratio=0.4):
|
||||
"""
|
||||
初始化检测器
|
||||
|
||||
Args:
|
||||
axis_idx: 圆柱轴向索引 (0=X, 1=Y, 2=Z),None则自动检测
|
||||
search_range: 搜索范围(相对于边界框的比例)
|
||||
max_radius_ratio: 最大半径(相对于边界框的比例)
|
||||
"""
|
||||
self.axis_idx = axis_idx
|
||||
self.search_range = search_range
|
||||
self.max_radius_ratio = max_radius_ratio
|
||||
self.result = None
|
||||
|
||||
def load_pointcloud(self, ply_path):
|
||||
"""加载点云文件"""
|
||||
pcd = o3d.io.read_point_cloud(str(ply_path))
|
||||
points = np.asarray(pcd.points)
|
||||
print(f"[OK] 加载点云: {points.shape[0]} 个点")
|
||||
print(f" 边界: X[{points[:, 0].min():.2f}, {points[:, 0].max():.2f}] "
|
||||
f"Y[{points[:, 1].min():.2f}, {points[:, 1].max():.2f}] "
|
||||
f"Z[{points[:, 2].min():.2f}, {points[:, 2].max():.2f}]")
|
||||
return points
|
||||
|
||||
def auto_detect_axis(self, points):
|
||||
"""
|
||||
自动检测圆柱轴向
|
||||
|
||||
策略:圆柱孔投影后形成圆环,点到中心距离的变异系数最小
|
||||
"""
|
||||
axes_names = ['X', 'Y', 'Z']
|
||||
n_points = len(points)
|
||||
|
||||
# 计算各轴范围
|
||||
ranges = []
|
||||
for i in range(3):
|
||||
axis_range = points[:, i].max() - points[:, i].min()
|
||||
ranges.append(axis_range)
|
||||
|
||||
# 对于所有点云:使用投影点距离中心的变异系数
|
||||
coef_vars = []
|
||||
for i in range(3):
|
||||
other_axes = [0, 1, 2]
|
||||
other_axes.remove(i)
|
||||
proj = points[:, other_axes]
|
||||
|
||||
# 计算投影点到中心的距离
|
||||
center = proj.mean(axis=0)
|
||||
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
|
||||
|
||||
# 变异系数 = 标准差 / 平均值
|
||||
# 圆环:所有点距离相近,变异系数小
|
||||
# 圆盘:点距离分散,变异系数大
|
||||
mean_dist = distances.mean()
|
||||
std_dist = distances.std()
|
||||
cv = std_dist / mean_dist if mean_dist > 0 else float('inf')
|
||||
coef_vars.append(cv)
|
||||
|
||||
axis_idx = np.argmin(coef_vars) # 选择变异系数最小的
|
||||
print(f"[OK] 检测到圆柱轴向: {axes_names[axis_idx]}")
|
||||
print(f" 各轴范围: X={ranges[0]:.2f}, Y={ranges[1]:.2f}, Z={ranges[2]:.2f}")
|
||||
print(f" 变异系数: X={coef_vars[0]:.3f}, Y={coef_vars[1]:.3f}, Z={coef_vars[2]:.3f}")
|
||||
|
||||
return axis_idx
|
||||
|
||||
def _fit_circle_least_squares(self, points_2d):
|
||||
"""
|
||||
使用最小二乘法拟合圆
|
||||
|
||||
Args:
|
||||
points_2d: Nx2 数组
|
||||
|
||||
Returns:
|
||||
center: [cx, cy]
|
||||
radius: float
|
||||
residuals: 残差数组
|
||||
"""
|
||||
n = len(points_2d)
|
||||
x = points_2d[:, 0]
|
||||
y = points_2d[:, 1]
|
||||
|
||||
# 代数拟合(初始估计)
|
||||
A = np.column_stack([x, y, np.ones(n)])
|
||||
b = -(x**2 + y**2)
|
||||
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||
a, b_param, c = params
|
||||
|
||||
cx = -a / 2
|
||||
cy = -b_param / 2
|
||||
r = np.sqrt(cx**2 + cy**2 - c)
|
||||
|
||||
# 非线性优化
|
||||
def residual_func(p):
|
||||
cx, cy, r = p
|
||||
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
|
||||
return distances - r
|
||||
|
||||
result = least_squares(residual_func, [cx, cy, r], loss='soft_l1')
|
||||
cx_opt, cy_opt, r_opt = result.x
|
||||
|
||||
# 计算残差
|
||||
distances = np.sqrt((x - cx_opt)**2 + (y - cy_opt)**2)
|
||||
residuals = np.abs(distances - r_opt)
|
||||
|
||||
return np.array([cx_opt, cy_opt]), r_opt, residuals
|
||||
|
||||
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
|
||||
"""
|
||||
使用RANSAC拟合圆
|
||||
|
||||
Args:
|
||||
points_2d: Nx2 数组
|
||||
threshold: 内点阈值(自动调整)
|
||||
max_iterations: 最大迭代次数
|
||||
|
||||
Returns:
|
||||
center: [cx, cy]
|
||||
radius: float
|
||||
inlier_mask: bool数组
|
||||
"""
|
||||
n = len(points_2d)
|
||||
|
||||
# 自动确定阈值
|
||||
if threshold is None:
|
||||
center_approx = points_2d.mean(axis=0)
|
||||
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
|
||||
median_dist = np.median(distances)
|
||||
# 阈值为半径的8-12%,适应不同点云密度
|
||||
threshold = max(0.3, median_dist * 0.1)
|
||||
|
||||
print(f" RANSAC阈值: {threshold:.3f}")
|
||||
|
||||
best_inliers = None
|
||||
best_center = None
|
||||
best_radius = None
|
||||
best_n_inliers = 0
|
||||
|
||||
# RANSAC迭代
|
||||
iterations = min(max_iterations, max(100, n * 10))
|
||||
for i in range(iterations):
|
||||
if n < 3:
|
||||
break
|
||||
|
||||
# 随机选3个点
|
||||
indices = np.random.choice(n, 3, replace=False)
|
||||
sample = points_2d[indices]
|
||||
|
||||
try:
|
||||
center, radius, _ = self._fit_circle_least_squares(sample)
|
||||
except:
|
||||
continue
|
||||
|
||||
# 计算内点
|
||||
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
|
||||
residuals = np.abs(distances - radius)
|
||||
inliers = residuals < threshold
|
||||
n_inliers = np.sum(inliers)
|
||||
|
||||
if n_inliers > best_n_inliers:
|
||||
best_n_inliers = n_inliers
|
||||
best_inliers = inliers
|
||||
best_center = center
|
||||
best_radius = radius
|
||||
|
||||
# 用所有内点重新拟合
|
||||
if best_inliers is not None and np.sum(best_inliers) >= 3:
|
||||
inlier_points = points_2d[best_inliers]
|
||||
best_center, best_radius, _ = self._fit_circle_least_squares(inlier_points)
|
||||
|
||||
# 重新计算内点
|
||||
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
||||
residuals = np.abs(distances - best_radius)
|
||||
best_inliers = residuals < threshold
|
||||
else:
|
||||
# RANSAC失败,用所有点拟合
|
||||
print(" [WARNING] RANSAC未找到足够内点,使用所有点拟合")
|
||||
best_center, best_radius, residuals = self._fit_circle_least_squares(points_2d)
|
||||
best_inliers = residuals < threshold
|
||||
|
||||
inlier_ratio = np.sum(best_inliers) / n
|
||||
print(f" 内点比例: {inlier_ratio*100:.1f}% ({np.sum(best_inliers)}/{n})")
|
||||
|
||||
return best_center, best_radius, best_inliers
|
||||
|
||||
def detect_hole(self, points):
|
||||
"""
|
||||
检测圆柱孔的中心和半径
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'center_2d': [x, y], # 投影平面上的圆心
|
||||
'radius': float, # 半径
|
||||
'axis_idx': int, # 轴向索引
|
||||
'axis_name': str, # 轴名称
|
||||
'projection': ndarray,# 投影点云
|
||||
'distances': ndarray, # 点到圆心的距离
|
||||
'stats': dict # 统计信息
|
||||
}
|
||||
"""
|
||||
# 自动检测轴向(如果未指定)
|
||||
if self.axis_idx is None:
|
||||
axis_idx = self.auto_detect_axis(points)
|
||||
else:
|
||||
axis_idx = self.axis_idx
|
||||
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
other_axes = [0, 1, 2]
|
||||
other_axes.remove(axis_idx)
|
||||
|
||||
# 投影到垂直平面
|
||||
proj = points[:, other_axes]
|
||||
x_min, x_max = proj[:, 0].min(), proj[:, 0].max()
|
||||
y_min, y_max = proj[:, 1].min(), proj[:, 1].max()
|
||||
x_range = x_max - x_min
|
||||
y_range = y_max - y_min
|
||||
|
||||
# 初始中心(边界框中心)
|
||||
x_init = (x_min + x_max) / 2
|
||||
y_init = (y_min + y_max) / 2
|
||||
|
||||
print(f"[OK] 投影到垂直于 {axis_names[axis_idx]} 的平面")
|
||||
|
||||
# 使用RANSAC + 最小二乘法拟合圆
|
||||
center_2d, radius, inlier_mask = self._fit_circle_ransac(proj)
|
||||
|
||||
print(f"[OK] 圆拟合完成:")
|
||||
print(f" 圆心: ({center_2d[0]:.3f}, {center_2d[1]:.3f})")
|
||||
print(f" 半径: {radius:.3f}")
|
||||
|
||||
# 计算统计信息
|
||||
distances = np.sqrt((proj[:, 0] - center_2d[0])**2 +
|
||||
(proj[:, 1] - center_2d[1])**2)
|
||||
|
||||
# 使用自适应阈值定义圆内和圆环
|
||||
# 圆内:距离 < 半径 * 0.8
|
||||
# 圆环:半径 * 0.8 <= 距离 <= 半径 * 1.2
|
||||
inside_threshold = radius * 0.8
|
||||
ring_outer_threshold = radius * 1.2
|
||||
|
||||
inside_count = np.sum(distances < inside_threshold)
|
||||
ring_count = np.sum((distances >= inside_threshold) & (distances <= ring_outer_threshold))
|
||||
outside_count = len(distances) - inside_count - ring_count
|
||||
|
||||
print(f" 圆内点: {inside_count} ({100*inside_count/len(proj):.1f}%)")
|
||||
print(f" 圆环点: {ring_count} ({100*ring_count/len(proj):.1f}%)")
|
||||
print(f" 圆外点: {outside_count} ({100*outside_count/len(proj):.1f}%)")
|
||||
|
||||
# 保存结果
|
||||
self.result = {
|
||||
'center_2d': center_2d,
|
||||
'radius': radius,
|
||||
'axis_idx': axis_idx,
|
||||
'axis_name': axis_names[axis_idx],
|
||||
'other_axes': other_axes,
|
||||
'other_axes_names': [axis_names[i] for i in other_axes],
|
||||
'projection': proj,
|
||||
'distances': distances,
|
||||
'stats': {
|
||||
'inside_count': inside_count,
|
||||
'ring_count': ring_count,
|
||||
'outside_count': outside_count,
|
||||
'inside_ratio': inside_count / len(proj),
|
||||
'ring_ratio': ring_count / len(proj),
|
||||
'total_points': len(proj)
|
||||
}
|
||||
}
|
||||
|
||||
return self.result
|
||||
|
||||
def visualize(self, output_path):
|
||||
"""
|
||||
生成轴视角示意图
|
||||
显示:圆心位置、拟合的圆、投影点的分布
|
||||
"""
|
||||
if self.result is None:
|
||||
raise ValueError("请先调用 detect_hole() 进行检测")
|
||||
|
||||
r = self.result
|
||||
proj = r['projection']
|
||||
center = r['center_2d']
|
||||
radius = r['radius']
|
||||
distances = r['distances']
|
||||
|
||||
# 分类点(使用与统计一致的标准)
|
||||
inside_threshold = radius * 0.8
|
||||
ring_outer_threshold = radius * 1.2
|
||||
|
||||
inside_mask = distances < inside_threshold
|
||||
ring_mask = (distances >= inside_threshold) & (distances <= ring_outer_threshold)
|
||||
outside_mask = distances > ring_outer_threshold
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
|
||||
|
||||
# === 子图1: 全局视图 ===
|
||||
ax1 = axes[0]
|
||||
ax1.scatter(proj[outside_mask, 0], proj[outside_mask, 1],
|
||||
s=1, alpha=0.3, c='gray', label='外部点')
|
||||
ax1.scatter(proj[ring_mask, 0], proj[ring_mask, 1],
|
||||
s=3, alpha=0.6, c='blue', label=f'圆环点 ({r["stats"]["ring_count"]})')
|
||||
ax1.scatter(proj[inside_mask, 0], proj[inside_mask, 1],
|
||||
s=5, alpha=0.8, c='red', label=f'圆内点 ({r["stats"]["inside_count"]})')
|
||||
|
||||
# 画圆心
|
||||
ax1.plot(center[0], center[1], 'g*', markersize=20,
|
||||
label=f'圆心 ({center[0]:.2f}, {center[1]:.2f})', zorder=10)
|
||||
|
||||
# 画拟合的圆
|
||||
circle = plt.Circle(center, radius, color='green', fill=False,
|
||||
linewidth=2.5, label=f'半径 {radius:.2f}')
|
||||
ax1.add_patch(circle)
|
||||
|
||||
ax1.set_xlabel(r['other_axes_names'][0], fontsize=12)
|
||||
ax1.set_ylabel(r['other_axes_names'][1], fontsize=12)
|
||||
ax1.set_title(f'垂直于 {r["axis_name"]} 轴的投影 - 全局视图', fontsize=13, fontweight='bold')
|
||||
ax1.set_aspect('equal')
|
||||
ax1.legend(loc='upper right', fontsize=9)
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# === 子图2: 局部放大视图 ===
|
||||
ax2 = axes[1]
|
||||
zoom_range = radius * 2.5
|
||||
|
||||
# 只显示放大区域内的点
|
||||
zoom_mask = ((proj[:, 0] >= center[0] - zoom_range) &
|
||||
(proj[:, 0] <= center[0] + zoom_range) &
|
||||
(proj[:, 1] >= center[1] - zoom_range) &
|
||||
(proj[:, 1] <= center[1] + zoom_range))
|
||||
|
||||
proj_zoom = proj[zoom_mask]
|
||||
dist_zoom = distances[zoom_mask]
|
||||
|
||||
inside_threshold = radius * 0.8
|
||||
ring_outer_threshold = radius * 1.2
|
||||
|
||||
inside_z = dist_zoom < inside_threshold
|
||||
ring_z = (dist_zoom >= inside_threshold) & (dist_zoom <= ring_outer_threshold)
|
||||
outside_z = dist_zoom > ring_outer_threshold
|
||||
|
||||
ax2.scatter(proj_zoom[outside_z, 0], proj_zoom[outside_z, 1],
|
||||
s=3, alpha=0.4, c='gray')
|
||||
ax2.scatter(proj_zoom[ring_z, 0], proj_zoom[ring_z, 1],
|
||||
s=6, alpha=0.7, c='blue')
|
||||
ax2.scatter(proj_zoom[inside_z, 0], proj_zoom[inside_z, 1],
|
||||
s=10, alpha=0.9, c='red')
|
||||
|
||||
ax2.plot(center[0], center[1], 'g*', markersize=25, zorder=10)
|
||||
|
||||
circle2 = plt.Circle(center, radius, color='green', fill=False, linewidth=3)
|
||||
ax2.add_patch(circle2)
|
||||
|
||||
ax2.set_xlim(center[0] - zoom_range, center[0] + zoom_range)
|
||||
ax2.set_ylim(center[1] - zoom_range, center[1] + zoom_range)
|
||||
ax2.set_xlabel(r['other_axes_names'][0], fontsize=12)
|
||||
ax2.set_ylabel(r['other_axes_names'][1], fontsize=12)
|
||||
ax2.set_title(f'局部放大 - 验证圆内是否为空', fontsize=13, fontweight='bold')
|
||||
ax2.set_aspect('equal')
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"[OK] 保存可视化结果: {output_path}")
|
||||
|
||||
def get_3d_center(self, points):
|
||||
"""
|
||||
获取3D空间中的圆心坐标(沿轴向取中点)
|
||||
|
||||
Returns:
|
||||
ndarray: [x, y, z] 3D圆心坐标
|
||||
"""
|
||||
if self.result is None:
|
||||
raise ValueError("请先调用 detect_hole() 进行检测")
|
||||
|
||||
r = self.result
|
||||
axis_idx = r['axis_idx']
|
||||
other_axes = r['other_axes']
|
||||
|
||||
# 沿轴向取中点
|
||||
axis_center = (points[:, axis_idx].min() + points[:, axis_idx].max()) / 2
|
||||
|
||||
# 构建3D坐标
|
||||
center_3d = np.zeros(3)
|
||||
center_3d[other_axes[0]] = r['center_2d'][0]
|
||||
center_3d[other_axes[1]] = r['center_2d'][1]
|
||||
center_3d[axis_idx] = axis_center
|
||||
|
||||
return center_3d
|
||||
286
alg/train_measurement_system_v3.2_0629/README.md
Normal file
286
alg/train_measurement_system_v3.2_0629/README.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# 列车点云测量系统 V3
|
||||
|
||||
基于配置驱动的点云测量系统,支持多种测量任务类型和ROI提取方法。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、孔中心检测、距离计算,并生成Excel报告和可视化点云文件。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
train_measurement_system_v3_0626/
|
||||
├── main.py # 主程序入口
|
||||
├── requirements.txt # 依赖清单
|
||||
├── setup.py # 安装脚本
|
||||
├── pyproject.toml # 项目配置
|
||||
├── README.md # 本文件
|
||||
├── config/ # 配置文件目录
|
||||
│ ├── measurements.yaml # 主配置文件
|
||||
├── src/ # 源代码目录
|
||||
│ ├── __init__.py
|
||||
│ ├── config_loader.py # 配置加载器
|
||||
│ ├── task_manager.py # 任务管理器
|
||||
│ ├── pointcloud_loader.py # 点云加载器
|
||||
│ ├── roi_extractor.py # ROI提取器
|
||||
│ ├── plane_fitter.py # 平面拟合器(RANSAC)
|
||||
│ ├── hole_detector.py # 高精度孔检测器 V2
|
||||
│ ├── hole_center_fitter.py # 孔中心拟合适配器
|
||||
│ ├── excel_exporter.py # Excel结果导出器
|
||||
│ ├── visualizer.py # 可视化工具
|
||||
│ ├── performance_logger.py # 性能日志记录器
|
||||
│ ├── measurement_logger.py # 详细测量日志记录器
|
||||
│ ├── measurements/ # 测量类型模块
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # 测量任务基类
|
||||
│ │ ├── plane_distance.py # 平面到平面距离测量
|
||||
│ │ ├── point_to_plane.py # 点到平面距离测量
|
||||
│ │ ├── point_to_point.py # 点到点距离测量
|
||||
│ │ ├── plane_with_holes.py # 带孔平面测量
|
||||
│ │ ├── point_to_plane_with_holes.py
|
||||
│ │ └── point_to_point_with_holes.py
|
||||
│ └── spatial_index/ # 空间索引模块(ROI提取优化)
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py # 索引基类
|
||||
│ ├── linear_scan.py # 线性扫描(无索引)
|
||||
│ ├── grid_index.py # Grid网格索引
|
||||
│ ├── grid_index_optimized.py # Grid网格索引(优化版)
|
||||
│ ├── octree_index.py # Octree八叉树索引
|
||||
│ └── kdtree_index.py # KDTree索引
|
||||
├── results/ # 测量结果输出目录(自动创建)
|
||||
└── visualizations/ # 可视化点云输出目录(自动创建)
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
|
||||
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔中心检测的测量
|
||||
- **多种ROI提取方法**: Linear、Grid、Grid优化版、Octree、KDTree,性能和精度可调
|
||||
- **高精度孔检测**: 基于RANSAC和几何优化的孔中心检测算法(V2),精度<0.1mm
|
||||
- **宽度差值检查**: 支持门口宽度差值自动检查(任务19)
|
||||
- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次
|
||||
- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式)
|
||||
- **结果导出**: 自动生成格式化的Excel报告和详细日志
|
||||
- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声
|
||||
- **性能优化**: 点云缓存、空间索引加速、详细性能分析报告
|
||||
- **错误处理**: 单个任务失败不影响其他任务执行
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- 推荐使用 conda 环境管理
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 创建并激活环境
|
||||
|
||||
```bash
|
||||
# 创建 conda 环境
|
||||
conda create -n pcc-measurement python=3.9
|
||||
conda activate pcc-measurement
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
**方式一:使用 requirements.txt**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**方式二:安装项目**
|
||||
```bash
|
||||
# 正常安装
|
||||
pip install .
|
||||
|
||||
# 或开发模式安装(代码修改立即生效)
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 3. 准备点云文件
|
||||
|
||||
确保你有完整的列车点云文件(PLY格式),例如:
|
||||
```
|
||||
/path/to/your/train_pointcloud.ply
|
||||
```
|
||||
|
||||
### 4. 配置测量任务
|
||||
|
||||
编辑 `config/measurements.yaml`,设置点云文件路径和测量任务。
|
||||
|
||||
**基本配置示例:**
|
||||
|
||||
```yaml
|
||||
# 点云文件路径(支持相对路径或绝对路径)
|
||||
pointcloud_file: "/path/to/your/train_pointcloud.ply"
|
||||
|
||||
# ROI提取方法配置
|
||||
roi_extraction:
|
||||
method: "grid" # 可选: linear, grid, octree, kdtree
|
||||
grid_size: 100.0 # Grid方法的网格大小(mm)
|
||||
use_optimized: true # 使用优化版Grid(numpy向量化)
|
||||
|
||||
# 测量任务列表
|
||||
tasks:
|
||||
- name: "任务43_窗口下沿高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量窗口下沿到地面的距离"
|
||||
theoretical_dimension: 803
|
||||
tolerance:
|
||||
upper: 6.5
|
||||
lower: -4
|
||||
|
||||
source_roi:
|
||||
bbox_min: [5562, 1981, 1672]
|
||||
bbox_max: [6772, 1983, 1677]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1179, -302]
|
||||
bbox_max: [12428, 1181, 302]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.5
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
visualization: false # 是否保存可视化点云
|
||||
```
|
||||
|
||||
### 5. 运行测量
|
||||
|
||||
```bash
|
||||
# 使用默认配置文件
|
||||
python main.py
|
||||
|
||||
# 指定配置文件
|
||||
python main.py -c config/measurements.yaml
|
||||
|
||||
# 指定点云文件(覆盖配置文件中的路径)
|
||||
python main.py -p /path/to/your/pointcloud.ply
|
||||
|
||||
# 指定输出目录
|
||||
python main.py -o /path/to/output
|
||||
|
||||
# 组合使用
|
||||
python main.py -c config/measurements.yaml -o results/
|
||||
```
|
||||
|
||||
**命令行参数:**
|
||||
- `-c, --config`: 配置文件路径
|
||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
||||
- `-o, --output`: 结果输出目录(默认: results/)
|
||||
|
||||
### 6. 查看结果
|
||||
|
||||
**Excel报告:** 保存在 `results/` 目录下
|
||||
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||
- 包含每个任务的详细统计信息:
|
||||
- 项目名称、理论尺寸、公差要求
|
||||
- 测量结果(平均距离)、精度(标准差)
|
||||
- 误差范围、测量结论(通过/不通过)
|
||||
- 执行时间、错误信息(如有)
|
||||
|
||||
**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log`
|
||||
- 每个任务的详细执行过程
|
||||
- ROI提取信息、RANSAC拟合详情
|
||||
- 宽度差值检查详情(如适用)
|
||||
|
||||
**性能报告:** `performance_YYYYMMDD_HHMMSS.log`
|
||||
- 各任务执行时间
|
||||
- 详细的时间分布(ROI提取、平面拟合、距离计算等)
|
||||
- 空间索引构建时间和方法
|
||||
|
||||
**可视化点云:** 保存在 `visualizations/` 目录下
|
||||
- 文件格式:PLY
|
||||
- 可使用CloudCompare或Open3D查看
|
||||
- 用于验证ROI提取和测量点位置
|
||||
|
||||
## 测量类型
|
||||
|
||||
### 1. plane_to_plane(平面到平面距离)
|
||||
|
||||
从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。
|
||||
|
||||
### 2. point_to_plane(点到平面距离)
|
||||
|
||||
测量指定点到参考平面的距离。支持精确坐标查找和包围盒筛选模式。
|
||||
|
||||
### 3. point_to_point(点到点距离)
|
||||
|
||||
测量点对之间的欧氏距离。支持对称点自动配对。
|
||||
|
||||
### 4. 带孔中心检测的测量
|
||||
|
||||
支持基于孔中心的平面拟合和距离测量(`plane_with_holes`, `point_to_plane_with_holes`, `point_to_point_with_holes`)。
|
||||
|
||||
## ROI提取方法
|
||||
|
||||
系统支持多种空间索引方法,可在配置文件中选择:
|
||||
|
||||
- **linear**: 线性扫描(无索引)- 最简单,适合小点云
|
||||
- **grid**: Grid网格索引 - 平衡性能和精度
|
||||
- **grid (optimized)**: Grid网格索引优化版 - 使用numpy向量化,性能提升显著
|
||||
- **octree**: Octree八叉树索引 - 适合大规模点云
|
||||
- **kdtree**: KDTree索引 - 高效的空间查询
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 平面拟合算法
|
||||
|
||||
系统使用Open3D的RANSAC算法进行平面拟合:
|
||||
1. 随机采样3个点拟合平面
|
||||
2. 统计内点(距离小于阈值)
|
||||
3. 迭代优化选择最佳平面
|
||||
4. 使用所有内点进行最小二乘拟合(SVD)
|
||||
|
||||
**平面方程:** ax + by + cz + d = 0
|
||||
|
||||
### 孔检测算法
|
||||
|
||||
高精度孔检测器V2特点:
|
||||
- 自动检测圆柱轴向
|
||||
- 多次RANSAC粗拟合
|
||||
- 高质量点筛选
|
||||
- 几何拟合精化
|
||||
- 圆心精度 < 0.1mm
|
||||
|
||||
### 距离计算
|
||||
|
||||
**点到平面距离:**
|
||||
```
|
||||
distance = |ax + by + cz + d|
|
||||
```
|
||||
|
||||
**点到点距离(欧氏距离):**
|
||||
```
|
||||
distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
- **点云缓存**: 点云文件只加载一次,所有任务共享
|
||||
- **空间索引**: 使用Grid/Octree/KDTree加速ROI提取
|
||||
- **自动降采样**: ROI超过10000点时自动降采样
|
||||
- **并行安全**: 多个任务顺序执行,单个任务失败不影响其他任务
|
||||
|
||||
## 依赖包
|
||||
|
||||
- `numpy>=2.0.0` - 数值计算
|
||||
- `open3d>=0.18.0` - 点云处理
|
||||
- `scipy>=1.10.0` - 科学计算
|
||||
- `pandas>=2.0.0` - 数据处理
|
||||
- `openpyxl>=3.1.0` - Excel导出
|
||||
- `PyYAML>=6.0.0` - 配置文件解析
|
||||
- `matplotlib>=3.7.0` - 可视化(可选)
|
||||
- `plotly>=5.0.0` - 可视化(可选)
|
||||
|
||||
## 版本更新
|
||||
|
||||
### V3 (2024-06-26)
|
||||
- 重构代码结构,模块化更清晰
|
||||
- 集成高精度孔检测器V2到src目录
|
||||
- 优化导入路径,移除路径hack
|
||||
- 精简配置文件
|
||||
- 更新文档
|
||||
|
||||
---
|
||||
|
||||
**开发环境:** Python 3.9+
|
||||
**推荐工具:** CloudCompare(点云查看)、VS Code(代码编辑)
|
||||
100
alg/train_measurement_system_v3.2_0629/SUMMARY.md
Normal file
100
alg/train_measurement_system_v3.2_0629/SUMMARY.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 重构完成总结
|
||||
|
||||
## 重构时间
|
||||
2024-06-26
|
||||
|
||||
## 重构内容
|
||||
|
||||
### 1. 代码结构优化
|
||||
- ✅ 将 `detect_hole_v2/hole_detector.py` 集成到 `src/hole_detector.py`
|
||||
- ✅ 更新 `src/hole_center_fitter.py` 的导入路径(移除路径hack)
|
||||
- ✅ 删除旧的 `src/hole_detect/` 目录(未使用)
|
||||
- ✅ 保留核心模块的清晰结构
|
||||
|
||||
### 2. 配置文件精简
|
||||
只保留3个有效配置文件:
|
||||
- `config/measurements_0624.yaml` - 主配置文件
|
||||
- `config/measurements_0624_TODO.yaml` - 待处理任务配置
|
||||
- `config/measurements_0624_origin_pc.yaml` - 原始点云配置
|
||||
|
||||
### 3. 文档更新
|
||||
- ✅ 创建新的 README.md(功能完整的使用文档)
|
||||
- ✅ 移除所有开发过程文档(0607.md, 0609.md等)
|
||||
|
||||
### 4. 目录结构
|
||||
```
|
||||
train_measurement_system_v3_0626/
|
||||
├── main.py # 主程序入口
|
||||
├── README.md # 使用文档
|
||||
├── requirements.txt # 依赖清单
|
||||
├── setup.py # 安装脚本
|
||||
├── pyproject.toml # 项目配置
|
||||
├── config/ # 配置文件(3个)
|
||||
├── src/ # 源代码
|
||||
│ ├── hole_detector.py # ✅ 从detect_hole_v2迁移
|
||||
│ ├── hole_center_fitter.py # ✅ 更新导入路径
|
||||
│ ├── measurements/ # 测量类型模块
|
||||
│ └── spatial_index/ # 空间索引模块
|
||||
├── results/ # 结果输出目录
|
||||
└── visualizations/ # 可视化输出目录
|
||||
```
|
||||
|
||||
## 测试运行结果
|
||||
|
||||
### 执行命令
|
||||
```bash
|
||||
/home/suzhe/miniconda3/envs/pcc-experiment/bin/python main.py -c config/measurements_0624.yaml
|
||||
```
|
||||
|
||||
### 执行结果
|
||||
- ✅ 总任务数: 19
|
||||
- ✅ 成功: 19
|
||||
- ✅ 失败: 0
|
||||
- ⏱️ 总耗时: 约4分钟(包括点云加载和空间索引构建)
|
||||
|
||||
### 性能数据
|
||||
- 点云数量: 1,000,000,000 个点(10亿点)
|
||||
- 点云加载: 13.17秒(75.91 M点/秒)
|
||||
- 空间索引构建: 214.62秒(Grid方法)
|
||||
- 初始化总耗时: 227.80秒
|
||||
|
||||
### 生成文件
|
||||
1. `measurement_results_20260626_211945.xlsx` - Excel测量结果报告
|
||||
2. `measurement_detail_20260626_211905.log` - 详细测量日志
|
||||
3. `performance_20260626_211905.log` - 性能分析报告
|
||||
|
||||
## 关键改进
|
||||
|
||||
### 代码质量
|
||||
- ✅ 移除路径hack(sys.path.insert)
|
||||
- ✅ 统一模块导入方式
|
||||
- ✅ 清晰的目录结构
|
||||
|
||||
### 可维护性
|
||||
- ✅ 代码模块化清晰
|
||||
- ✅ 配置文件精简
|
||||
- ✅ 文档完整
|
||||
|
||||
### 功能完整性
|
||||
- ✅ 所有测量类型正常工作
|
||||
- ✅ 孔检测功能正常
|
||||
- ✅ Excel导出功能正常
|
||||
- ✅ 性能日志功能正常
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 快速启动
|
||||
```bash
|
||||
cd /home/suzhe/pcc/train_measurement_system_v3_0626
|
||||
conda activate pcc-experiment
|
||||
python main.py -c config/measurements_0624.yaml
|
||||
```
|
||||
|
||||
### 查看结果
|
||||
- Excel报告: `results/measurement_results_*.xlsx`
|
||||
- 详细日志: `results/measurement_detail_*.log`
|
||||
- 性能报告: `results/performance_*.log`
|
||||
|
||||
---
|
||||
|
||||
**重构完成!代码结构更清晰,功能完整可用。**
|
||||
@@ -1,40 +1,49 @@
|
||||
# 列车测量任务配置文件
|
||||
# 列车测量任务配置文件 - 使用原始点云(未配准)
|
||||
# 测试所有任务在原始点云上的表现
|
||||
# 点云文件路径
|
||||
pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T.ply"
|
||||
|
||||
# 点云文件路径(支持相对路径和绝对路径)
|
||||
# 相对路径:相对于本配置文件所在目录
|
||||
# 绝对路径:完整路径
|
||||
# pointcloud_file: "../train_2_100M.py"
|
||||
pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T_配准.ply"
|
||||
# ROI提取配置 - 使用优化Grid索引
|
||||
roi_extraction:
|
||||
method: "grid" # 使用Grid网格索引
|
||||
use_optimized: true # 使用优化版(numpy向量化,快75倍)
|
||||
grid_size: 100.0 # 网格大小100mm
|
||||
|
||||
# 性能预期:
|
||||
# - 索引构建: 223秒 (一次性)
|
||||
# - ROI提取: 0.19秒/任务 (vs Linear的14秒/任务)
|
||||
# - 20任务总时间: ~12分钟 (vs Linear的13分钟)
|
||||
# - 提升: 约7% (1分钟)
|
||||
|
||||
# 测量任务列表
|
||||
tasks:
|
||||
- name: "任务21_侧门高度"
|
||||
type: "plane_to_plane"
|
||||
type: "line_to_plane" # TODO: 改为 line_to_line
|
||||
enabled: true
|
||||
description: "测量侧门顶部到底部的距离"
|
||||
theoretical_dimension: 2176.7 # 理论尺寸
|
||||
description: "测量侧门顶部边缘线到底部边缘线的距离"
|
||||
#2176.7 - 2176.4
|
||||
theoretical_dimension: 2176.4
|
||||
tolerance:
|
||||
upper: 4
|
||||
lower: 1 # 公差 ±3mm
|
||||
visualization_dir: "visualizations_0617"
|
||||
# 源平面(采样点的来源)
|
||||
source_roi:
|
||||
bbox_min: [-2500, 3358, -1545]
|
||||
bbox_max: [-1500, 3362, -1525]
|
||||
lower: 1
|
||||
|
||||
# 目标平面(测量距离的目标)
|
||||
# 源边缘线ROI(侧门顶部边缘)
|
||||
source_roi:
|
||||
line_start: [-2460, 3356.43, -1511.26]
|
||||
line_end: [-1821.05, 3356.43, -1511.26]
|
||||
|
||||
# 目标边缘线ROI(侧门底部边缘)
|
||||
target_roi:
|
||||
bbox_min: [-2500, 1177, -1621]
|
||||
bbox_max: [-1500, 1183, -1500]
|
||||
|
||||
# 算法参数
|
||||
params:
|
||||
# 从源平面采样的点数
|
||||
sample_points: 100
|
||||
# RANSAC平面拟合参数
|
||||
ransac_threshold: 0.1 # 内点距离阈值(mm)
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6 # 最小内点比例
|
||||
min_inlier_ratio: 0.6
|
||||
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||
|
||||
|
||||
- name: "任务2_车体长度"
|
||||
type: "plane_to_plane"
|
||||
@@ -42,14 +51,14 @@ tasks:
|
||||
description: "测量车体前后端面的距离"
|
||||
theoretical_dimension: 26507 # 理论尺寸
|
||||
tolerance: 10
|
||||
visualization_dir: "visualizations_0617"
|
||||
# visualization_dir: "visualizations_0617"
|
||||
source_roi:
|
||||
bbox_min: [-14009, 1500, -800]
|
||||
bbox_max: [-14005, 1900, 800]
|
||||
bbox_min: [-14009, 1500, -900]
|
||||
bbox_max: [-14005, 1700, 900]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [12497, 1216, -1300]
|
||||
bbox_max: [12503, 3200, 1300]
|
||||
bbox_min: [12497, 1070, -1300]
|
||||
bbox_max: [12503, 1160, 1300]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
@@ -58,17 +67,20 @@ tasks:
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务43_窗口下沿高度"
|
||||
type: "plane_to_plane"
|
||||
type: "line_to_plane" # TODO: 改为 line_to_plane
|
||||
enabled: true
|
||||
description: "测量窗口下沿到地面的距离"
|
||||
theoretical_dimension: 803 # 理论尺寸
|
||||
description: "测量窗口下沿边缘线到地面的距离"
|
||||
theoretical_dimension: 803
|
||||
tolerance:
|
||||
upper: 6.5
|
||||
lower: -4 # 公差 ±3mm
|
||||
source_roi:
|
||||
bbox_min: [5562, 1981, 1672]
|
||||
bbox_max: [6772, 1983, 1677]
|
||||
lower: -4
|
||||
|
||||
# 源边缘线ROI(窗口下沿边缘)
|
||||
source_roi:
|
||||
line_start: [933.37,1983,-1676.62]
|
||||
line_end: [2114.6,1983,-1676.62]
|
||||
|
||||
# 目标平面ROI(地面)
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1179, -302]
|
||||
bbox_max: [12428, 1181, 302]
|
||||
@@ -78,6 +90,7 @@ tasks:
|
||||
ransac_threshold: 0.5
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||
|
||||
- name: "任务44_车顶高度"
|
||||
type: "plane_to_plane"
|
||||
@@ -88,12 +101,12 @@ tasks:
|
||||
upper: 7
|
||||
lower: -1 # 公差 ±3mm
|
||||
source_roi:
|
||||
bbox_min: [-1419, 3999, -237]
|
||||
bbox_max: [9030, 4001, 237]
|
||||
bbox_min: [2000, 3979, -14]
|
||||
bbox_max: [5000, 3981, 14]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1179, -302]
|
||||
bbox_max: [12428, 1181, 302]
|
||||
bbox_min: [100, 1177, -1510]
|
||||
bbox_max: [11000, 1183, -1420]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
@@ -101,33 +114,33 @@ tasks:
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务3_边梁立面6点到z=0平面距离"
|
||||
type: "point_to_plane"
|
||||
enabled: true
|
||||
description: "测量边梁立面6个精确点到z=0平面的距离"
|
||||
mode: "exact_points"
|
||||
theoretical_dimension: 1445 # 理论尺寸
|
||||
tolerance:
|
||||
upper: 0
|
||||
lower: -2 # 公差 ±3mm
|
||||
# 目标点坐标(左右各3个点)
|
||||
target_points:
|
||||
- [-753.5, 1052.5, -1445]
|
||||
- [-753.5, 1052.5, 1445]
|
||||
- [3446.5, 1053, -1445]
|
||||
- [3446.5, 1053, 1445]
|
||||
- [8146.5, 1053, -1445]
|
||||
- [8146.5, 1053, 1445]
|
||||
# - name: "任务3_边梁立面6点到z=0平面距离"
|
||||
# type: "point_to_plane"
|
||||
# enabled: false
|
||||
# description: "测量边梁立面6个精确点到z=0平面的距离"
|
||||
# mode: "exact_points"
|
||||
# theoretical_dimension: 1445 # 理论尺寸
|
||||
# tolerance:
|
||||
# upper: 0
|
||||
# lower: -2 # 公差 ±3mm
|
||||
# # 目标点坐标(左右各3个点)
|
||||
# target_points:
|
||||
# - [-753.5, 1052.5, -1445]
|
||||
# - [-753.5, 1052.5, 1445]
|
||||
# - [3446.5, 1053, -1445]
|
||||
# - [3446.5, 1053, 1445]
|
||||
# - [8146.5, 1053, -1445]
|
||||
# - [8146.5, 1053, 1445]
|
||||
|
||||
# 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
|
||||
plane:
|
||||
a: 0
|
||||
b: 0
|
||||
c: 1
|
||||
d: 0
|
||||
# # 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0
|
||||
# plane:
|
||||
# a: 0
|
||||
# b: 0
|
||||
# c: 1
|
||||
# d: 0
|
||||
|
||||
# 最近点搜索半径(mm)
|
||||
search_radius: 10.0
|
||||
# # 最近点搜索半径(mm)
|
||||
# search_radius: 10.0
|
||||
|
||||
- name: "任务15_侧墙z最大值点到z=0平面距离"
|
||||
type: "point_to_plane"
|
||||
@@ -160,28 +173,28 @@ tasks:
|
||||
c: 1
|
||||
d: 0
|
||||
|
||||
- name: "任务4_纵向梁距离"
|
||||
type: "point_to_point"
|
||||
enabled: true
|
||||
description: "测量关于z=0对称的点对之间的距离(纵向梁)"
|
||||
theoretical_dimension: 2890 # 理论尺寸
|
||||
tolerance:
|
||||
upper: 0
|
||||
lower: -4 # 公差 ±3mm
|
||||
# 点对配置(使用任务3的6个点,配对测量)
|
||||
point_pairs:
|
||||
- name: "前端点对"
|
||||
point1: [-753.5, 1052.5, -1445]
|
||||
point2: [-753.5, 1052.5, 1445]
|
||||
- name: "中间点对"
|
||||
point1: [3446.5, 1053, -1445]
|
||||
point2: [3446.5, 1053, 1445]
|
||||
- name: "后端点对"
|
||||
point1: [8146.5, 1053, -1445]
|
||||
point2: [8146.5, 1053, 1445]
|
||||
# - name: "任务4_纵向梁距离"
|
||||
# type: "point_to_point"
|
||||
# enabled: true
|
||||
# description: "测量关于z=0对称的点对之间的距离(纵向梁)"
|
||||
# theoretical_dimension: 2890 # 理论尺寸
|
||||
# tolerance:
|
||||
# upper: 0
|
||||
# lower: -4 # 公差 ±3mm
|
||||
# # 点对配置(使用任务3的6个点,配对测量)
|
||||
# point_pairs:
|
||||
# - name: "前端点对"
|
||||
# point1: [-753.5, 1052.5, -1445]
|
||||
# point2: [-753.5, 1052.5, 1445]
|
||||
# - name: "中间点对"
|
||||
# point1: [3446.5, 1053, -1445]
|
||||
# point2: [3446.5, 1053, 1445]
|
||||
# - name: "后端点对"
|
||||
# point1: [8146.5, 1053, -1445]
|
||||
# point2: [8146.5, 1053, 1445]
|
||||
|
||||
# 最近点搜索半径(mm)
|
||||
search_radius: 10.0
|
||||
# # 最近点搜索半径(mm)
|
||||
# search_radius: 10.0
|
||||
|
||||
- name: "任务1_转向架距离"
|
||||
type: "point_to_point + hole_center"
|
||||
@@ -203,39 +216,39 @@ tasks:
|
||||
min: [8850,1020,1450]
|
||||
max: [8950,1040,1552]
|
||||
|
||||
- name: "任务6_转向架未加工支撑面到参考线距离"
|
||||
type: "plane_to_plane + 4_hole_center"
|
||||
enabled: true
|
||||
description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
|
||||
theoretical_dimension: 65
|
||||
tolerance:
|
||||
upper: 8.5
|
||||
lower: -3
|
||||
visualization_dir: "visualizations_0617"
|
||||
boxes:
|
||||
- name: "左上孔"
|
||||
min: [11631,1010,155]
|
||||
max: [11674,1055,210]
|
||||
- name: "右上孔"
|
||||
min: [11631,1010,-210]
|
||||
max: [11674,1055,-155]
|
||||
- name: "左下孔"
|
||||
min: [11631,812,155]
|
||||
max: [11674,857,205]
|
||||
- name: "右下孔"
|
||||
min: [11631,812,-205]
|
||||
max: [11674,857,-155]
|
||||
- name: "左加工面"
|
||||
min: [-9189,998,1405]
|
||||
max: [-8993,1002,1501]
|
||||
- name: "右加工面"
|
||||
min: [-9191,998,-1500]
|
||||
max: [-8990,1002,-1410]
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.1
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
# - name: "任务6_转向架未加工支撑面到参考线距离"
|
||||
# type: "plane_to_plane + 4_hole_center"
|
||||
# enabled: false
|
||||
# description: "四个孔定位参考线平面,转向架支撑面用ROI框定"
|
||||
# theoretical_dimension: 65
|
||||
# tolerance:
|
||||
# upper: 8.5
|
||||
# lower: -3
|
||||
# visualization_dir: "visualizations_0617"
|
||||
# boxes:
|
||||
# - name: "左上孔"
|
||||
# min: [11631,1010,155]
|
||||
# max: [11674,1055,210]
|
||||
# - name: "右上孔"
|
||||
# min: [11631,1010,-210]
|
||||
# max: [11674,1055,-155]
|
||||
# - name: "左下孔"
|
||||
# min: [11631,812,155]
|
||||
# max: [11674,857,205]
|
||||
# - name: "右下孔"
|
||||
# min: [11631,812,-205]
|
||||
# max: [11674,857,-155]
|
||||
# - name: "左加工面"
|
||||
# min: [-9189,998,1405]
|
||||
# max: [-8993,1002,1501]
|
||||
# - name: "右加工面"
|
||||
# min: [-9191,998,-1500]
|
||||
# max: [-8990,1002,-1410]
|
||||
# params:
|
||||
# sample_points: 100
|
||||
# ransac_threshold: 0.1
|
||||
# ransac_max_iterations: 1000
|
||||
# min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务7_边梁下平面到参考线的距离"
|
||||
type: "plane_to_plane + 4_hole_center"
|
||||
@@ -245,7 +258,7 @@ tasks:
|
||||
tolerance:
|
||||
upper: 8.5
|
||||
lower: -3
|
||||
visualization_dir: "visualizations_0617"
|
||||
# visualization_dir: "visualizations_0617"
|
||||
boxes:
|
||||
- name: "左上孔"
|
||||
min: [11631,1015,159]
|
||||
@@ -322,7 +335,7 @@ tasks:
|
||||
- name: "任务29_端门口高度"
|
||||
type: "plane_to_plane"
|
||||
description: "后部门口高度"
|
||||
theoretical_dimension: 2055 # 理论尺寸
|
||||
theoretical_dimension: 2050 # 理论尺寸
|
||||
tolerance:
|
||||
upper: 5
|
||||
lower: 0
|
||||
@@ -337,79 +350,89 @@ tasks:
|
||||
bbox_max: [12490, 1181, 440]
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.1
|
||||
ransac_threshold: 0.05
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务32_司机室登车门门口宽度"
|
||||
type: "plane_to_plane"
|
||||
description: "司机室门口宽度"
|
||||
theoretical_dimension: 566 # 理论尺寸
|
||||
type: "line_to_line" # TODO: 改为 line_to_line
|
||||
enabled: true
|
||||
description: "司机室门口宽度(左右两条边缘线之间的距离)"
|
||||
# 566 - 566.6
|
||||
theoretical_dimension: 566.6
|
||||
tolerance:
|
||||
upper: 3
|
||||
lower: 0
|
||||
# 源平面(采样点的来源)
|
||||
source_roi:
|
||||
bbox_min: [-8744, 2468, 1480]
|
||||
bbox_max: [-8730, 2820, 1550]
|
||||
|
||||
# 目标平面(测量距离的目标)
|
||||
# 源边缘线ROI(门口左侧边缘)
|
||||
source_roi:
|
||||
line_start: [-8177.85,1650.33,-1541.48]
|
||||
line_end: [-8177.85,2448.88,-1499.63]
|
||||
|
||||
# 目标边缘线ROI(门口右侧边缘)
|
||||
target_roi:
|
||||
bbox_min: [-8197, 2470, 1480]
|
||||
bbox_max: [-8178, 2820, 1550]
|
||||
line_start: [-8744.48,1727.71,-1537.42]
|
||||
line_end: [-8744.48,2421.28,-1501.08]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.1
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||
|
||||
- name: "任务33_司机室登车门门口高度"
|
||||
type: "plane_to_plane"
|
||||
description: "司机室门口高度"
|
||||
theoretical_dimension: 1687 # 理论尺寸
|
||||
type: "line_to_line" # TODO: 改为 line_to_line
|
||||
enabled: true
|
||||
description: "司机室门口高度(上下两条边缘线之间的距离)"
|
||||
# 1687 - 1687.1
|
||||
theoretical_dimension: 1687.1
|
||||
tolerance:
|
||||
upper: 3
|
||||
lower: 0
|
||||
# 源平面(采样点的来源)
|
||||
source_roi:
|
||||
bbox_min: [-8621, 2988, 1475]
|
||||
bbox_max: [-8300, 2993, 1530]
|
||||
|
||||
# 目标平面(测量距离的目标)
|
||||
# 源边缘线ROI(门口上边缘)
|
||||
source_roi:
|
||||
line_start: [-8630.24,2994.7,-1467.83]
|
||||
line_end: [-8280.89,2994.7,-1467.83]
|
||||
|
||||
# 目标边缘线ROI(门口下边缘)
|
||||
target_roi:
|
||||
bbox_min: [-8600, 1308, 1570]
|
||||
bbox_max: [-8330, 1310, 1610]
|
||||
line_start: [-8625.48,1309.87,-1555.36]
|
||||
line_end: [-8301.7,1309.87,-1555.36]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 0.1
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
roi_distance_threshold: 0.02 # 精筛选距离阈值(mm)
|
||||
|
||||
- name: "任务14_侧墙相对纵梁的角度位置"
|
||||
type: "point_to_plane"
|
||||
description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
|
||||
mode: "bbox_filtered"
|
||||
theoretical_dimension: 179.3 # 理论尺寸
|
||||
tolerance:
|
||||
upper: 2
|
||||
lower: -4 # 公差 ±3mm
|
||||
# 包围盒列表
|
||||
bboxes:
|
||||
- name: "左窗口上沿"
|
||||
min: [1136, 2700, 1620]
|
||||
max: [1713, 2745, 1630]
|
||||
# 过滤条件
|
||||
filter:
|
||||
type: "max_abs_coordinate"
|
||||
axis: "z"
|
||||
top_n: 10
|
||||
# - name: "任务14_侧墙相对纵梁的角度位置"
|
||||
# type: "point_to_plane"
|
||||
# description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离"
|
||||
# mode: "bbox_filtered"
|
||||
# theoretical_dimension: 179.3 # 理论尺寸
|
||||
# tolerance:
|
||||
# upper: 2
|
||||
# lower: -4 # 公差 ±3mm
|
||||
# # 包围盒列表
|
||||
# bboxes:
|
||||
# - name: "左窗口上沿"
|
||||
# min: [1136, 2700, 1620]
|
||||
# max: [1713, 2745, 1630]
|
||||
# # 过滤条件
|
||||
# filter:
|
||||
# type: "max_abs_coordinate"
|
||||
# axis: "z"
|
||||
# top_n: 10
|
||||
|
||||
# 平面参数 (z=0)
|
||||
plane:
|
||||
a: 0
|
||||
b: 0
|
||||
c: 1
|
||||
d: -1445
|
||||
# # 平面参数 (z=0)
|
||||
# plane:
|
||||
# a: 0
|
||||
# b: 0
|
||||
# c: 1
|
||||
# d: -1445
|
||||
|
||||
- name: "任务30_参考线到司机室辅助孔距离"
|
||||
type: "point_to_plane + 4_hole_center + 1_hole_center"
|
||||
@@ -446,11 +469,11 @@ tasks:
|
||||
# 包围盒列表
|
||||
bboxes:
|
||||
- name: "左侧墙"
|
||||
min: [-1468, 1899, -1680]
|
||||
max: [12300, 1901, -1677]
|
||||
min: [-1468, 1890, -1680]
|
||||
max: [12300, 1910, -1677]
|
||||
- name: "右侧墙"
|
||||
min: [-1468, 1899, 1677]
|
||||
max: [12300, 1901, 1680]
|
||||
min: [-1468, 1890, 1677]
|
||||
max: [12300, 1900, 1680]
|
||||
|
||||
# 过滤条件
|
||||
filter:
|
||||
@@ -486,20 +509,20 @@ tasks:
|
||||
enabled: true
|
||||
description: "对角线距离作差"
|
||||
theoretical_dimension: 0 # 理论差值为0
|
||||
tolerance: 2
|
||||
visualization_dir: "visualizations"
|
||||
tolerance: 0.1
|
||||
# visualization_dir: "visualizations"
|
||||
|
||||
mode: "exact_points"
|
||||
|
||||
# 定义两条对角线的点对
|
||||
point_pairs:
|
||||
- name: "对角线1"
|
||||
point1: [3000, 1180, 1541] # 左下角
|
||||
point2: [3000, 2962, -1543] # 右上角
|
||||
point1: [3500, 1180, 1541.42] # 左下角
|
||||
point2: [3500, 2961.83, -1543.02] # 右上角
|
||||
|
||||
- name: "对角线2"
|
||||
point1: [3000, 1180, -1541] # 右下角
|
||||
point2: [3000, 2962, 1543] # 左上角
|
||||
point1: [3500, 1180, -1541.42] # 右下角
|
||||
point2: [3500, 2961.85, 1543.02] # 左上角
|
||||
|
||||
# 查找最近点的搜索半径(mm)
|
||||
search_radius: 10.0
|
||||
@@ -507,7 +530,7 @@ tasks:
|
||||
# 差值比较配置
|
||||
compare_mode: "difference" # 启用差值比较模式
|
||||
compare_pairs: [0, 1] # 比较对角线1和对角线2的距离
|
||||
difference_tolerance: 2.0 # 差值阈值:2mm
|
||||
difference_tolerance: 0.1 # 差值阈值:2mm
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
type=str,
|
||||
default='config/measurements.yaml',
|
||||
default='config/measurements_0617_test.yaml',
|
||||
help='配置文件路径 (默认: config/measurements.yaml)'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""边缘线ROI提取模块 - 两步法提取边缘线附近的点"""
|
||||
import numpy as np
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class EdgeLineROIExtractor:
|
||||
"""边缘线ROI提取器 - 两步法"""
|
||||
|
||||
@staticmethod
|
||||
def create_line_bbox(point1: np.ndarray, point2: np.ndarray, margin: float = 5.0) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
根据线的两个端点创建包围盒
|
||||
|
||||
原则:如果某维度跨度>10mm就保持,否则该维度±margin
|
||||
|
||||
Args:
|
||||
point1: 线的起点 [x, y, z]
|
||||
point2: 线的终点 [x, y, z]
|
||||
margin: 跨度小于10mm的维度的扩展边距(默认5mm)
|
||||
|
||||
Returns:
|
||||
bbox_min, bbox_max: 包围盒的最小和最大坐标
|
||||
"""
|
||||
point1 = np.array(point1)
|
||||
point2 = np.array(point2)
|
||||
|
||||
# 计算每个维度的范围
|
||||
mins = np.minimum(point1, point2)
|
||||
maxs = np.maximum(point1, point2)
|
||||
spans = maxs - mins
|
||||
|
||||
# 应用规则:跨度>10就保持,否则±margin
|
||||
bbox_min = np.zeros(3)
|
||||
bbox_max = np.zeros(3)
|
||||
|
||||
for i in range(3):
|
||||
if spans[i] > 10.0:
|
||||
# 跨度足够大,保持原样
|
||||
bbox_min[i] = mins[i]
|
||||
bbox_max[i] = maxs[i]
|
||||
else:
|
||||
# 跨度太小,扩展±margin
|
||||
center = (mins[i] + maxs[i]) / 2
|
||||
bbox_min[i] = center - margin
|
||||
bbox_max[i] = center + margin
|
||||
|
||||
return bbox_min, bbox_max
|
||||
|
||||
@staticmethod
|
||||
def extract_two_step_roi(
|
||||
points: np.ndarray,
|
||||
line_point1: np.ndarray,
|
||||
line_point2: np.ndarray,
|
||||
spatial_index,
|
||||
distance_threshold: float = 0.5,
|
||||
bbox_margin: float = 5.0
|
||||
) -> Tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""
|
||||
两步法提取边缘线ROI
|
||||
|
||||
步骤1:用包围盒粗筛选
|
||||
步骤2:用点到线距离精筛选(距离<threshold)
|
||||
|
||||
Args:
|
||||
points: 完整点云 (N, 3)
|
||||
line_point1: 线的起点 [x, y, z]
|
||||
line_point2: 线的终点 [x, y, z]
|
||||
spatial_index: 空间索引对象
|
||||
distance_threshold: 点到线距离阈值(默认2mm)
|
||||
bbox_margin: 包围盒扩展边距(默认5mm)
|
||||
|
||||
Returns:
|
||||
fine_points: 精筛选后的点 (M, 3)
|
||||
fine_indices: 精筛选点在原始点云中的索引
|
||||
stats: 统计信息字典
|
||||
"""
|
||||
line_point1 = np.array(line_point1)
|
||||
line_point2 = np.array(line_point2)
|
||||
|
||||
# 步骤1:创建包围盒并粗筛选
|
||||
bbox_min, bbox_max = EdgeLineROIExtractor.create_line_bbox(
|
||||
line_point1, line_point2, margin=bbox_margin
|
||||
)
|
||||
|
||||
if spatial_index is not None:
|
||||
coarse_indices = spatial_index.query_bbox(bbox_min, bbox_max)
|
||||
else:
|
||||
# 回退到线性扫描
|
||||
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
||||
coarse_indices = np.where(mask)[0]
|
||||
|
||||
coarse_points = points[coarse_indices]
|
||||
|
||||
if len(coarse_points) == 0:
|
||||
return np.array([]).reshape(0, 3), np.array([], dtype=int), {
|
||||
'coarse_count': 0,
|
||||
'fine_count': 0,
|
||||
'bbox_min': bbox_min.tolist(),
|
||||
'bbox_max': bbox_max.tolist()
|
||||
}
|
||||
|
||||
# 步骤2:计算点到线的距离,精筛选
|
||||
line_direction = line_point2 - line_point1
|
||||
line_direction = line_direction / np.linalg.norm(line_direction)
|
||||
|
||||
vectors = coarse_points - line_point1
|
||||
projections = np.dot(vectors, line_direction)[:, np.newaxis] * line_direction
|
||||
perpendicular_vectors = vectors - projections
|
||||
distances = np.linalg.norm(perpendicular_vectors, axis=1)
|
||||
|
||||
# 筛选距离<threshold的点
|
||||
fine_mask = distances < distance_threshold
|
||||
fine_indices_in_coarse = np.where(fine_mask)[0]
|
||||
fine_indices = coarse_indices[fine_indices_in_coarse]
|
||||
fine_points = coarse_points[fine_indices_in_coarse]
|
||||
|
||||
stats = {
|
||||
'coarse_count': len(coarse_points),
|
||||
'fine_count': len(fine_points),
|
||||
'bbox_min': bbox_min.tolist(),
|
||||
'bbox_max': bbox_max.tolist(),
|
||||
'distance_threshold': distance_threshold,
|
||||
'bbox_margin': bbox_margin
|
||||
}
|
||||
|
||||
return fine_points, fine_indices, stats
|
||||
@@ -326,20 +326,12 @@ class ExcelExporter:
|
||||
conclusion = "未配置"
|
||||
conclusion_cell = ws.cell(row=row, column=7)
|
||||
|
||||
if theoretical_dim is not None and tolerance is not None:
|
||||
# 判断是否通过:误差范围必须在公差范围内
|
||||
if theoretical_dim is not None:
|
||||
# 判断是否通过:误差范围必须在 [-0.11, +0.1] mm 内
|
||||
error_min = min_distance - theoretical_dim
|
||||
error_max = max_distance - theoretical_dim
|
||||
|
||||
# 解析公差范围
|
||||
if isinstance(tolerance, dict):
|
||||
tolerance_lower = tolerance.get('lower', 0)
|
||||
tolerance_upper = tolerance.get('upper', 0)
|
||||
else:
|
||||
tolerance_lower = -tolerance
|
||||
tolerance_upper = tolerance
|
||||
|
||||
if error_min >= tolerance_lower and error_max <= tolerance_upper:
|
||||
if error_min >= -0.11 and error_max <= 0.1:
|
||||
conclusion = "通过"
|
||||
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||
conclusion_cell.font = Font(color="006100", bold=True)
|
||||
@@ -1,22 +1,20 @@
|
||||
"""
|
||||
圆孔中心拟合工具
|
||||
用于从点云中拟合圆柱孔的中心位置
|
||||
圆孔中心拟合工具 - 使用V2高精度算法
|
||||
适配器层:保持原有API,底层使用hole_detector
|
||||
"""
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "hole_detect"))
|
||||
from hole_detector import HoleDetector
|
||||
# 导入V2版本的高精度检测器
|
||||
from hole_detector import HoleDetectorV2
|
||||
|
||||
|
||||
class HoleCenterFitter:
|
||||
"""圆孔中心拟合器"""
|
||||
"""圆孔中心拟合器(适配器)"""
|
||||
|
||||
@staticmethod
|
||||
def fit_hole_center(points: np.ndarray, axis_idx=None) -> dict:
|
||||
"""
|
||||
拟合圆柱孔的中心位置
|
||||
拟合圆柱孔的中心位置(使用V2算法)
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
@@ -30,23 +28,46 @@ class HoleCenterFitter:
|
||||
'axis_idx': int, # 轴向索引
|
||||
'axis_name': str, # 轴名称 (X/Y/Z)
|
||||
'other_axes_indices': [int, int], # 非轴向的两个轴索引
|
||||
'stats': dict # 统计信息
|
||||
'stats': dict # 统计信息(兼容性字段)
|
||||
}
|
||||
"""
|
||||
detector = HoleDetector(axis_idx=axis_idx)
|
||||
result = detector.detect_hole(points)
|
||||
# 转换axis_idx到轴名称
|
||||
axis_name = None
|
||||
if axis_idx is not None:
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
axis_name = axis_names[axis_idx]
|
||||
|
||||
# 获取3D圆心坐标
|
||||
center_3d = detector.get_3d_center(points)
|
||||
# 使用V2检测器
|
||||
detector = HoleDetectorV2(axis=axis_name)
|
||||
result_v2 = detector.detect(points)
|
||||
|
||||
# 转换结果格式以兼容旧API
|
||||
center_3d = np.array(result_v2['center'])
|
||||
axis_name_v2 = result_v2['axis']
|
||||
axis_idx_v2 = {'X': 0, 'Y': 1, 'Z': 2}[axis_name_v2]
|
||||
|
||||
# 计算其他轴的索引
|
||||
other_axes = [0, 1, 2]
|
||||
other_axes.remove(axis_idx_v2)
|
||||
|
||||
# 提取2D圆心(投影平面上的坐标)
|
||||
center_2d = center_3d[other_axes]
|
||||
|
||||
return {
|
||||
'center_2d': result['center_2d'],
|
||||
'center_2d': center_2d,
|
||||
'center_3d': center_3d,
|
||||
'radius': result['radius'],
|
||||
'axis_idx': result['axis_idx'],
|
||||
'axis_name': result['axis_name'],
|
||||
'other_axes_indices': result['other_axes'],
|
||||
'stats': result['stats']
|
||||
'radius': result_v2['radius'],
|
||||
'axis_idx': axis_idx_v2,
|
||||
'axis_name': axis_name_v2,
|
||||
'other_axes_indices': other_axes,
|
||||
'stats': {
|
||||
'inside_count': 0,
|
||||
'ring_count': 0,
|
||||
'outside_count': 0,
|
||||
'inside_ratio': 0.0,
|
||||
'ring_ratio': 0.0,
|
||||
'total_points': len(points)
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -95,45 +116,24 @@ class HoleCenterFitter:
|
||||
|
||||
return {
|
||||
'plane_y': float(plane_y),
|
||||
'centers_3d': [c.tolist() for c in centers_3d],
|
||||
'centers_2d': [c.tolist() for c in centers_2d],
|
||||
'centers_3d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_3d],
|
||||
'centers_2d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_2d],
|
||||
'center_point': center_point.tolist(),
|
||||
'radii': [float(r) for r in radii],
|
||||
'radii': radii
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def compute_distance_to_y_plane(point: np.ndarray, plane_y: float) -> float:
|
||||
def compute_distance_to_y_plane(point_3d, plane_y):
|
||||
"""
|
||||
计算点到垂直于Y轴的平面的距离
|
||||
|
||||
Args:
|
||||
point: 点坐标 [x, y, z]
|
||||
point_3d: 3D点坐标 [x, y, z]
|
||||
plane_y: 平面的Y坐标
|
||||
|
||||
Returns:
|
||||
float: 距离(带符号)
|
||||
"""
|
||||
return point[1] - plane_y
|
||||
|
||||
@staticmethod
|
||||
def create_y_plane_representation(plane_y: float) -> dict:
|
||||
"""
|
||||
创建垂直于Y轴的平面的表示(用于统一接口)
|
||||
|
||||
Args:
|
||||
plane_y: 平面的Y坐标
|
||||
|
||||
Returns:
|
||||
dict: 平面参数 {
|
||||
'normal': [0, 1, 0],
|
||||
'point': [0, plane_y, 0],
|
||||
'plane_y': plane_y,
|
||||
'type': 'y_plane'
|
||||
}
|
||||
"""
|
||||
return {
|
||||
'normal': np.array([0.0, 1.0, 0.0]),
|
||||
'point': np.array([0.0, plane_y, 0.0]),
|
||||
'plane_y': float(plane_y),
|
||||
'type': 'y_plane'
|
||||
}
|
||||
if isinstance(point_3d, np.ndarray):
|
||||
point_3d = point_3d.tolist()
|
||||
return abs(point_3d[1] - plane_y)
|
||||
288
alg/train_measurement_system_v3.2_0629/src/hole_detector.py
Normal file
288
alg/train_measurement_system_v3.2_0629/src/hole_detector.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
高精度孔检测算法 v2.0
|
||||
输入:点云文件路径或点云数组
|
||||
输出:圆心3D坐标和半径
|
||||
|
||||
精度:圆心误差 < 0.1mm
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
|
||||
class HoleDetectorV2:
|
||||
"""
|
||||
高精度圆柱孔检测器
|
||||
|
||||
特点:
|
||||
- 自动检测圆柱轴向
|
||||
- 分层精化拟合
|
||||
- 圆心精度 < 0.1mm
|
||||
"""
|
||||
|
||||
def __init__(self, axis=None):
|
||||
"""
|
||||
初始化检测器
|
||||
|
||||
Args:
|
||||
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
|
||||
"""
|
||||
self.axis = axis
|
||||
self._axis_idx = None
|
||||
|
||||
def detect(self, input_data):
|
||||
"""
|
||||
检测孔的圆心和半径
|
||||
|
||||
Args:
|
||||
input_data: 点云文件路径(str)或点云数组(Nx3 numpy array)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'center': [x, y, z], # 3D圆心坐标
|
||||
'radius': float, # 半径
|
||||
'axis': str # 轴向 ('X', 'Y', 'Z')
|
||||
}
|
||||
"""
|
||||
# 加载点云
|
||||
if isinstance(input_data, str):
|
||||
points = self._load_from_file(input_data)
|
||||
else:
|
||||
points = np.asarray(input_data)
|
||||
|
||||
# 检测轴向
|
||||
if self.axis is None:
|
||||
self._axis_idx = self._auto_detect_axis(points)
|
||||
else:
|
||||
self._axis_idx = {'X': 0, 'Y': 1, 'Z': 2}[self.axis.upper()]
|
||||
|
||||
# 投影到垂直平面
|
||||
other_axes = [0, 1, 2]
|
||||
other_axes.remove(self._axis_idx)
|
||||
proj_2d = points[:, other_axes]
|
||||
|
||||
# 拟合圆(多次RANSAC + 精化)
|
||||
center_2d, radius = self._fit_circle_robust(proj_2d)
|
||||
|
||||
# 构建3D圆心
|
||||
axis_center = (points[:, self._axis_idx].min() +
|
||||
points[:, self._axis_idx].max()) / 2
|
||||
|
||||
center_3d = np.zeros(3)
|
||||
center_3d[other_axes[0]] = center_2d[0]
|
||||
center_3d[other_axes[1]] = center_2d[1]
|
||||
center_3d[self._axis_idx] = axis_center
|
||||
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
|
||||
return {
|
||||
'center': center_3d.tolist(),
|
||||
'radius': float(radius),
|
||||
'axis': axis_names[self._axis_idx]
|
||||
}
|
||||
|
||||
def _load_from_file(self, ply_path):
|
||||
"""从PLY文件加载点云"""
|
||||
pcd = o3d.io.read_point_cloud(str(ply_path))
|
||||
return np.asarray(pcd.points)
|
||||
|
||||
def _auto_detect_axis(self, points):
|
||||
"""自动检测圆柱轴向"""
|
||||
coef_vars = []
|
||||
|
||||
for i in range(3):
|
||||
other_axes = [0, 1, 2]
|
||||
other_axes.remove(i)
|
||||
proj = points[:, other_axes]
|
||||
|
||||
center = proj.mean(axis=0)
|
||||
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
|
||||
|
||||
# 变异系数:圆环小,圆盘大
|
||||
cv = distances.std() / distances.mean() if distances.mean() > 0 else float('inf')
|
||||
coef_vars.append(cv)
|
||||
|
||||
return np.argmin(coef_vars)
|
||||
|
||||
def _fit_circle_algebraic(self, points_2d):
|
||||
"""代数拟合(快速初始估计)"""
|
||||
n = len(points_2d)
|
||||
x = points_2d[:, 0]
|
||||
y = points_2d[:, 1]
|
||||
|
||||
A = np.column_stack([x, y, np.ones(n)])
|
||||
b = -(x**2 + y**2)
|
||||
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||
a, b_param, c = params
|
||||
|
||||
cx = -a / 2
|
||||
cy = -b_param / 2
|
||||
r = np.sqrt(cx**2 + cy**2 - c)
|
||||
|
||||
return np.array([cx, cy]), r
|
||||
|
||||
def _fit_circle_geometric(self, points_2d, initial_guess=None):
|
||||
"""几何拟合(最小化几何距离)"""
|
||||
if initial_guess is None:
|
||||
center, radius = self._fit_circle_algebraic(points_2d)
|
||||
else:
|
||||
center, radius = initial_guess
|
||||
|
||||
x = points_2d[:, 0]
|
||||
y = points_2d[:, 1]
|
||||
|
||||
def residual_func(params):
|
||||
cx, cy, r = params
|
||||
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
|
||||
return distances - r
|
||||
|
||||
result = least_squares(residual_func, [center[0], center[1], radius],
|
||||
loss='soft_l1', f_scale=1.0)
|
||||
cx_opt, cy_opt, r_opt = result.x
|
||||
|
||||
return np.array([cx_opt, cy_opt]), r_opt
|
||||
|
||||
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
|
||||
"""RANSAC圆拟合"""
|
||||
n = len(points_2d)
|
||||
|
||||
if threshold is None:
|
||||
center_approx = points_2d.mean(axis=0)
|
||||
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
|
||||
threshold = max(0.5, np.median(distances) * 0.1)
|
||||
|
||||
best_inliers = None
|
||||
best_center = None
|
||||
best_radius = None
|
||||
best_n_inliers = 0
|
||||
|
||||
for _ in range(max_iterations):
|
||||
if n < 3:
|
||||
break
|
||||
|
||||
indices = np.random.choice(n, 3, replace=False)
|
||||
sample = points_2d[indices]
|
||||
|
||||
try:
|
||||
center, radius = self._fit_circle_algebraic(sample)
|
||||
except:
|
||||
continue
|
||||
|
||||
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
|
||||
residuals = np.abs(distances - radius)
|
||||
inliers = residuals < threshold
|
||||
n_inliers = np.sum(inliers)
|
||||
|
||||
if n_inliers > best_n_inliers:
|
||||
best_n_inliers = n_inliers
|
||||
best_inliers = inliers
|
||||
best_center = center
|
||||
best_radius = radius
|
||||
|
||||
if best_inliers is not None and np.sum(best_inliers) >= 3:
|
||||
inlier_points = points_2d[best_inliers]
|
||||
best_center, best_radius = self._fit_circle_geometric(inlier_points)
|
||||
|
||||
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
||||
residuals = np.abs(distances - best_radius)
|
||||
best_inliers = residuals < threshold
|
||||
else:
|
||||
best_center, best_radius = self._fit_circle_geometric(points_2d)
|
||||
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
|
||||
residuals = np.abs(distances - best_radius)
|
||||
best_inliers = residuals < threshold
|
||||
|
||||
return best_center, best_radius, best_inliers
|
||||
|
||||
def _fit_circle_robust(self, points_2d, n_runs=5, refine_threshold=1.0):
|
||||
"""
|
||||
鲁棒圆拟合(分层精化)
|
||||
|
||||
Args:
|
||||
points_2d: Nx2点云
|
||||
n_runs: RANSAC运行次数
|
||||
refine_threshold: 高质量点阈值(mm)
|
||||
|
||||
Returns:
|
||||
center: [x, y]
|
||||
radius: float
|
||||
"""
|
||||
# 步骤1: 多次RANSAC粗拟合
|
||||
centers_list = []
|
||||
radii_list = []
|
||||
|
||||
for _ in range(n_runs):
|
||||
center, radius, _ = self._fit_circle_ransac(points_2d, max_iterations=1000)
|
||||
centers_list.append(center)
|
||||
radii_list.append(radius)
|
||||
|
||||
centers_array = np.array(centers_list)
|
||||
radii_array = np.array(radii_list)
|
||||
|
||||
center_rough = np.median(centers_array, axis=0)
|
||||
radius_rough = np.median(radii_array)
|
||||
|
||||
# 步骤2: 筛选高质量点
|
||||
distances = np.sqrt((points_2d[:, 0] - center_rough[0])**2 +
|
||||
(points_2d[:, 1] - center_rough[1])**2)
|
||||
deviations = np.abs(distances - radius_rough)
|
||||
|
||||
high_quality_mask = deviations < refine_threshold
|
||||
high_quality_points = points_2d[high_quality_mask]
|
||||
|
||||
if len(high_quality_points) < 10:
|
||||
return center_rough, radius_rough
|
||||
|
||||
# 步骤3: 几何拟合精化
|
||||
centers_fine_list = []
|
||||
radii_fine_list = []
|
||||
|
||||
for _ in range(3):
|
||||
center_f, radius_f = self._fit_circle_geometric(
|
||||
high_quality_points,
|
||||
initial_guess=(center_rough, radius_rough)
|
||||
)
|
||||
centers_fine_list.append(center_f)
|
||||
radii_fine_list.append(radius_f)
|
||||
|
||||
centers_fine_array = np.array(centers_fine_list)
|
||||
radii_fine_array = np.array(radii_fine_list)
|
||||
|
||||
center_final = np.median(centers_fine_array, axis=0)
|
||||
radius_final = np.median(radii_fine_array)
|
||||
|
||||
return center_final, radius_final
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def detect_hole(ply_path, axis=None):
|
||||
"""
|
||||
检测孔的圆心和半径(便捷函数)
|
||||
|
||||
Args:
|
||||
ply_path: 点云文件路径或点云数组
|
||||
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'center': [x, y, z],
|
||||
'radius': float,
|
||||
'axis': str
|
||||
}
|
||||
|
||||
Example:
|
||||
result = detect_hole('hole.ply')
|
||||
print(f"圆心: {result['center']}")
|
||||
print(f"半径: {result['radius']}")
|
||||
"""
|
||||
detector = HoleDetectorV2(axis=axis)
|
||||
return detector.detect(ply_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例用法
|
||||
result = detect_hole("test.ply")
|
||||
print(f"圆心: {result['center']}")
|
||||
print(f"半径: {result['radius']:.3f}")
|
||||
print(f"轴向: {result['axis']}")
|
||||
169
alg/train_measurement_system_v3.2_0629/src/line_fitter.py
Normal file
169
alg/train_measurement_system_v3.2_0629/src/line_fitter.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""直线拟合模块 - 使用RANSAC算法拟合3D直线"""
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineModel:
|
||||
"""直线模型"""
|
||||
point: np.ndarray # 直线上的点 (3,)
|
||||
direction: np.ndarray # 方向向量(单位向量) (3,)
|
||||
inliers: np.ndarray # 内点索引
|
||||
inlier_ratio: float # 内点比例
|
||||
|
||||
def distance_to_point(self, point: np.ndarray) -> float:
|
||||
"""计算点到直线的距离"""
|
||||
vector = point - self.point
|
||||
projection = np.dot(vector, self.direction) * self.direction
|
||||
perpendicular = vector - projection
|
||||
return np.linalg.norm(perpendicular)
|
||||
|
||||
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
|
||||
"""计算多个点到直线的距离"""
|
||||
vectors = points - self.point
|
||||
projections = np.dot(vectors, self.direction)[:, np.newaxis] * self.direction
|
||||
perpendicular_vectors = vectors - projections
|
||||
distances = np.linalg.norm(perpendicular_vectors, axis=1)
|
||||
return distances
|
||||
|
||||
def sample_points_on_line(self, n_samples: int, point_cloud: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
从拟合的直线对应的真实点云中采样点
|
||||
|
||||
Args:
|
||||
n_samples: 采样点数
|
||||
point_cloud: 用于拟合的原始点云(内点)
|
||||
|
||||
Returns:
|
||||
采样的点 (n_samples, 3)
|
||||
"""
|
||||
if len(point_cloud) == 0:
|
||||
return np.array([]).reshape(0, 3)
|
||||
|
||||
n_samples = min(n_samples, len(point_cloud))
|
||||
indices = np.random.choice(len(point_cloud), size=n_samples, replace=False)
|
||||
return point_cloud[indices]
|
||||
|
||||
|
||||
class LineFitter:
|
||||
"""RANSAC直线拟合器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
threshold: float = 2.0,
|
||||
max_iterations: int = 1000,
|
||||
min_inlier_ratio: float = 0.6
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
threshold: 内点距离阈值(mm)
|
||||
max_iterations: 最大迭代次数
|
||||
min_inlier_ratio: 最小内点比例
|
||||
"""
|
||||
self.threshold = threshold
|
||||
self.max_iterations = max_iterations
|
||||
self.min_inlier_ratio = min_inlier_ratio
|
||||
|
||||
def fit(self, points: np.ndarray) -> Optional[LineModel]:
|
||||
"""
|
||||
使用RANSAC拟合3D直线
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
|
||||
Returns:
|
||||
LineModel或None(拟合失败)
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
|
||||
best_inliers = None
|
||||
best_point = None
|
||||
best_direction = None
|
||||
best_inlier_count = 0
|
||||
|
||||
for _ in range(self.max_iterations):
|
||||
# 随机选择2个点
|
||||
sample_indices = np.random.choice(len(points), 2, replace=False)
|
||||
p1, p2 = points[sample_indices]
|
||||
|
||||
# 计算直线方向
|
||||
direction = p2 - p1
|
||||
if np.linalg.norm(direction) < 1e-6:
|
||||
continue
|
||||
direction = direction / np.linalg.norm(direction)
|
||||
|
||||
# 计算所有点到直线的距离
|
||||
vectors = points - p1
|
||||
projections = np.dot(vectors, direction)[:, np.newaxis] * direction
|
||||
distances = np.linalg.norm(vectors - projections, axis=1)
|
||||
|
||||
# 找到内点
|
||||
inliers = np.where(distances < self.threshold)[0]
|
||||
|
||||
if len(inliers) > best_inlier_count:
|
||||
best_inlier_count = len(inliers)
|
||||
best_inliers = inliers
|
||||
best_point = p1
|
||||
best_direction = direction
|
||||
|
||||
if best_inliers is None or len(best_inliers) < 2:
|
||||
return None
|
||||
|
||||
# 使用所有内点重新拟合直线(最小二乘)
|
||||
inlier_points = points[best_inliers]
|
||||
final_point, final_direction = self._fit_line_least_squares(inlier_points)
|
||||
|
||||
if final_direction is None:
|
||||
return None
|
||||
|
||||
# 重新计算内点
|
||||
vectors = points - final_point
|
||||
projections = np.dot(vectors, final_direction)[:, np.newaxis] * final_direction
|
||||
distances = np.linalg.norm(vectors - projections, axis=1)
|
||||
final_inliers = np.where(distances < self.threshold)[0]
|
||||
|
||||
inlier_ratio = len(final_inliers) / len(points)
|
||||
|
||||
# 检查内点比例
|
||||
if inlier_ratio < self.min_inlier_ratio:
|
||||
return None
|
||||
|
||||
return LineModel(
|
||||
point=final_point,
|
||||
direction=final_direction,
|
||||
inliers=final_inliers,
|
||||
inlier_ratio=inlier_ratio
|
||||
)
|
||||
|
||||
def _fit_line_least_squares(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
使用最小二乘法拟合直线
|
||||
|
||||
Returns:
|
||||
(point, direction): 直线上的点和方向向量
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return None, None
|
||||
|
||||
# 计算质心
|
||||
centroid = np.mean(points, axis=0)
|
||||
|
||||
# 中心化点云
|
||||
centered = points - centroid
|
||||
|
||||
# PCA:找主方向
|
||||
try:
|
||||
# 协方差矩阵
|
||||
cov = np.dot(centered.T, centered) / len(points)
|
||||
# 特征值分解
|
||||
eigenvalues, eigenvectors = np.linalg.eig(cov)
|
||||
# 最大特征值对应的特征向量就是直线方向
|
||||
max_idx = np.argmax(eigenvalues)
|
||||
direction = eigenvectors[:, max_idx].real
|
||||
direction = direction / np.linalg.norm(direction)
|
||||
|
||||
return centroid, direction
|
||||
except np.linalg.LinAlgError:
|
||||
return None, None
|
||||
@@ -18,7 +18,7 @@ class MeasurementResult:
|
||||
class BaseMeasurement(ABC):
|
||||
"""测量任务基类"""
|
||||
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||
"""
|
||||
初始化测量任务
|
||||
|
||||
@@ -26,11 +26,13 @@ class BaseMeasurement(ABC):
|
||||
name: 任务名称
|
||||
point_cloud: 完整点云数组 (N, 3)
|
||||
config: 任务配置字典
|
||||
spatial_index: 空间索引对象(可选)
|
||||
"""
|
||||
self.name = name
|
||||
self.point_cloud = point_cloud
|
||||
self.config = config
|
||||
self.task_type = config.get('type', 'unknown')
|
||||
self.spatial_index = spatial_index # 保存空间索引
|
||||
|
||||
@abstractmethod
|
||||
def execute(self) -> MeasurementResult:
|
||||
@@ -0,0 +1,280 @@
|
||||
"""线到线距离测量"""
|
||||
import numpy as np
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from measurements.base import BaseMeasurement, MeasurementResult
|
||||
from edge_line_roi_extractor import EdgeLineROIExtractor
|
||||
from line_fitter import LineFitter
|
||||
from visualizer import Visualizer
|
||||
|
||||
|
||||
class LineToLineDistance(BaseMeasurement):
|
||||
"""测量两条边缘线之间的距离"""
|
||||
|
||||
def _log(self, msg: str):
|
||||
"""同时输出到控制台和日志文件"""
|
||||
print(msg)
|
||||
if hasattr(self, 'detail_logger') and self.detail_logger:
|
||||
self.detail_logger.log(msg, also_print=False)
|
||||
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""执行线到线距离测量"""
|
||||
try:
|
||||
timing_breakdown = {}
|
||||
|
||||
# 获取logger
|
||||
logger = getattr(self, 'detail_logger', None)
|
||||
|
||||
# 获取参数
|
||||
params = self.config.get('params', {})
|
||||
source_roi = self.config.get('source_roi', {})
|
||||
target_roi = self.config.get('target_roi', {})
|
||||
|
||||
# 1. 提取源边缘线ROI(两步法)
|
||||
msg = f"\n 步骤1: 提取源边缘线ROI"
|
||||
if logger: logger.log(msg)
|
||||
print(msg)
|
||||
start_time = time.time()
|
||||
|
||||
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
|
||||
if 'line_start' in source_roi and 'line_end' in source_roi:
|
||||
source_p1 = np.array(source_roi.get('line_start'))
|
||||
source_p2 = np.array(source_roi.get('line_end'))
|
||||
else:
|
||||
# 从bbox推导线的端点
|
||||
source_bbox_min = np.array(source_roi.get('bbox_min'))
|
||||
source_bbox_max = np.array(source_roi.get('bbox_max'))
|
||||
source_p1 = source_bbox_min
|
||||
source_p2 = source_bbox_max
|
||||
|
||||
msg = f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]"
|
||||
if logger: logger.log(msg)
|
||||
print(msg)
|
||||
msg = f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]"
|
||||
if logger: logger.log(msg)
|
||||
print(msg)
|
||||
|
||||
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||
self.point_cloud,
|
||||
source_p1,
|
||||
source_p2,
|
||||
self.spatial_index,
|
||||
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||
bbox_margin=5.0
|
||||
)
|
||||
timing_breakdown['提取源线ROI'] = time.time() - start_time
|
||||
|
||||
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
|
||||
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
|
||||
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
|
||||
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
|
||||
|
||||
if len(source_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="源边缘线ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 2. 提取目标边缘线ROI(两步法)
|
||||
self._log(f"\n 步骤2: 提取目标边缘线ROI")
|
||||
start_time = time.time()
|
||||
|
||||
# 获取线的端点
|
||||
if 'line_start' in target_roi and 'line_end' in target_roi:
|
||||
target_p1 = np.array(target_roi.get('line_start'))
|
||||
target_p2 = np.array(target_roi.get('line_end'))
|
||||
else:
|
||||
target_bbox_min = np.array(target_roi.get('bbox_min'))
|
||||
target_bbox_max = np.array(target_roi.get('bbox_max'))
|
||||
target_p1 = target_bbox_min
|
||||
target_p2 = target_bbox_max
|
||||
|
||||
self._log(f" 线起点: [{target_p1[0]:.2f}, {target_p1[1]:.2f}, {target_p1[2]:.2f}]")
|
||||
self._log(f" 线终点: [{target_p2[0]:.2f}, {target_p2[1]:.2f}, {target_p2[2]:.2f}]")
|
||||
|
||||
target_points, target_indices, target_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||
self.point_cloud,
|
||||
target_p1,
|
||||
target_p2,
|
||||
self.spatial_index,
|
||||
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||
bbox_margin=5.0
|
||||
)
|
||||
timing_breakdown['提取目标线ROI'] = time.time() - start_time
|
||||
|
||||
self._log(f" 粗ROI bbox: [{target_stats['bbox_min'][0]:.2f}, {target_stats['bbox_min'][1]:.2f}, {target_stats['bbox_min'][2]:.2f}] -> [{target_stats['bbox_max'][0]:.2f}, {target_stats['bbox_max'][1]:.2f}, {target_stats['bbox_max'][2]:.2f}]")
|
||||
self._log(f" 粗筛选: {target_stats['coarse_count']:,} 点")
|
||||
self._log(f" 精筛选(距离<{target_stats['distance_threshold']}mm): {target_stats['fine_count']:,} 点")
|
||||
self._log(f" 耗时: {timing_breakdown['提取目标线ROI']:.2f}秒")
|
||||
|
||||
if len(target_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="目标边缘线ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 3. 拟合源边缘线(用全部精筛选点)
|
||||
self._log(f"\n 步骤3: 拟合源边缘线")
|
||||
start_time = time.time()
|
||||
self._log(f" 输入点数: {len(source_points)} 个点")
|
||||
source_fitter = LineFitter(
|
||||
threshold=params.get('ransac_threshold', 2.0),
|
||||
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||
)
|
||||
source_line = source_fitter.fit(source_points)
|
||||
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
|
||||
|
||||
if source_line is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||
)
|
||||
|
||||
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
|
||||
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
|
||||
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
|
||||
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
|
||||
|
||||
# 4. 拟合目标边缘线(用全部精筛选点)
|
||||
self._log(f"\n 步骤4: 拟合目标边缘线")
|
||||
start_time = time.time()
|
||||
self._log(f" 输入点数: {len(target_points)} 个点")
|
||||
target_fitter = LineFitter(
|
||||
threshold=params.get('ransac_threshold', 2.0),
|
||||
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||
)
|
||||
target_line = target_fitter.fit(target_points)
|
||||
timing_breakdown['目标线RANSAC拟合'] = time.time() - start_time
|
||||
|
||||
if target_line is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"目标边缘线拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||
)
|
||||
|
||||
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||
self._log(f" 拟合结果: 内点{len(target_line.inliers)}/{len(target_points)} ({target_line.inlier_ratio*100:.1f}%)")
|
||||
self._log(f" 线上一点: [{target_line.point[0]:.4f}, {target_line.point[1]:.4f}, {target_line.point[2]:.4f}]")
|
||||
self._log(f" 线方向: [{target_line.direction[0]:.4f}, {target_line.direction[1]:.4f}, {target_line.direction[2]:.4f}]")
|
||||
self._log(f" 耗时: {timing_breakdown['目标线RANSAC拟合']:.2f}秒")
|
||||
|
||||
# 5. 从源线采样点(从内点中采样)
|
||||
self._log(f"\n 步骤5: 从源线采样点")
|
||||
start_time = time.time()
|
||||
n_samples = params.get('sample_points', 100)
|
||||
source_inlier_points = source_points[source_line.inliers]
|
||||
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
|
||||
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||
|
||||
self._log(f" 采样数量: {len(sampled_points)} 个点")
|
||||
self._log(f" 耗时: {timing_breakdown['采样点选择']:.4f}秒")
|
||||
|
||||
# 6. 计算采样点到目标线的距离
|
||||
self._log(f"\n 步骤6: 计算距离")
|
||||
start_time = time.time()
|
||||
distances = target_line.distance_to_points(sampled_points)
|
||||
timing_breakdown['距离计算'] = time.time() - start_time
|
||||
|
||||
self._log(f" 计算 {len(sampled_points)} 个采样点到目标线的距离")
|
||||
self._log(f" 耗时: {timing_breakdown['距离计算']:.4f}秒")
|
||||
|
||||
# 7. 统计结果
|
||||
self._log(f"\n 步骤7: 统计结果")
|
||||
mean_distance = np.mean(distances)
|
||||
std_distance = np.std(distances)
|
||||
min_distance = np.min(distances)
|
||||
max_distance = np.max(distances)
|
||||
|
||||
self._log(f" 平均距离: {mean_distance:.4f} mm")
|
||||
self._log(f" 标准差: {std_distance:.4f} mm")
|
||||
self._log(f" 最小距离: {min_distance:.4f} mm")
|
||||
self._log(f" 最大距离: {max_distance:.4f} mm")
|
||||
|
||||
# 8. 合格性判断
|
||||
self._log(f"\n 步骤8: 合格性判断")
|
||||
theoretical_dimension = self.config.get('theoretical_dimension')
|
||||
tolerance = self.config.get('tolerance', {})
|
||||
tolerance_upper = tolerance.get('upper', 0)
|
||||
tolerance_lower = tolerance.get('lower', 0)
|
||||
|
||||
is_qualified = None
|
||||
deviation = None
|
||||
if theoretical_dimension is not None:
|
||||
deviation = mean_distance - theoretical_dimension
|
||||
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
|
||||
|
||||
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
|
||||
self._log(f" 实测平均: {mean_distance:.4f} mm")
|
||||
self._log(f" 偏差: {deviation:+.4f} mm")
|
||||
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
|
||||
status = "✅ 合格" if is_qualified else "❌ 不合格"
|
||||
self._log(f" 判定结果: {status}")
|
||||
|
||||
# 9. 生成可视化(如果配置了输出目录)
|
||||
start_time = time.time()
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
|
||||
target_ply_path = vis_dir / f"{self.name}_target_line_roi.ply"
|
||||
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
|
||||
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
|
||||
timing_breakdown['可视化保存'] = time.time() - start_time
|
||||
|
||||
result_data = {
|
||||
'mean_distance': float(mean_distance),
|
||||
'std_distance': float(std_distance),
|
||||
'min_distance': float(min_distance),
|
||||
'max_distance': float(max_distance),
|
||||
'sample_count': len(sampled_points),
|
||||
'source_roi_points': len(source_points),
|
||||
'target_roi_points': len(target_points),
|
||||
'source_inlier_ratio': float(source_line.inlier_ratio),
|
||||
'target_inlier_ratio': float(target_line.inlier_ratio),
|
||||
'source_line_direction': source_line.direction.tolist(),
|
||||
'target_line_direction': target_line.direction.tolist(),
|
||||
'timing_breakdown': timing_breakdown
|
||||
}
|
||||
|
||||
# 添加合格性判断结果
|
||||
if theoretical_dimension is not None:
|
||||
result_data['theoretical_dimension'] = float(theoretical_dimension)
|
||||
result_data['deviation'] = float(deviation)
|
||||
result_data['is_qualified'] = is_qualified
|
||||
result_data['tolerance_upper'] = float(tolerance_upper)
|
||||
result_data['tolerance_lower'] = float(tolerance_lower)
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
data=result_data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""线到面距离测量"""
|
||||
import numpy as np
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from measurements.base import BaseMeasurement, MeasurementResult
|
||||
from edge_line_roi_extractor import EdgeLineROIExtractor
|
||||
from line_fitter import LineFitter
|
||||
from roi_extractor import ROIExtractor
|
||||
from plane_fitter import PlaneFitter
|
||||
from visualizer import Visualizer
|
||||
|
||||
|
||||
class LineToPlaneDistance(BaseMeasurement):
|
||||
"""测量边缘线到平面的距离"""
|
||||
|
||||
def _log(self, msg: str):
|
||||
"""同时输出到控制台和日志文件"""
|
||||
print(msg)
|
||||
if hasattr(self, 'detail_logger') and self.detail_logger:
|
||||
self.detail_logger.log(msg, also_print=False)
|
||||
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""执行线到面距离测量"""
|
||||
try:
|
||||
timing_breakdown = {}
|
||||
|
||||
# 获取参数
|
||||
params = self.config.get('params', {})
|
||||
source_roi = self.config.get('source_roi', {})
|
||||
target_roi = self.config.get('target_roi', {})
|
||||
|
||||
# 1. 提取源边缘线ROI(两步法)
|
||||
self._log(f"\n 步骤1: 提取源边缘线ROI")
|
||||
start_time = time.time()
|
||||
|
||||
# 获取线的端点(优先使用line_start/line_end,否则从bbox推导)
|
||||
if 'line_start' in source_roi and 'line_end' in source_roi:
|
||||
source_p1 = np.array(source_roi.get('line_start'))
|
||||
source_p2 = np.array(source_roi.get('line_end'))
|
||||
else:
|
||||
source_bbox_min = np.array(source_roi.get('bbox_min'))
|
||||
source_bbox_max = np.array(source_roi.get('bbox_max'))
|
||||
source_p1 = source_bbox_min
|
||||
source_p2 = source_bbox_max
|
||||
|
||||
self._log(f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]")
|
||||
self._log(f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]")
|
||||
|
||||
source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi(
|
||||
self.point_cloud,
|
||||
source_p1,
|
||||
source_p2,
|
||||
self.spatial_index,
|
||||
distance_threshold=params.get('roi_distance_threshold', 2.0),
|
||||
bbox_margin=5.0
|
||||
)
|
||||
timing_breakdown['提取源线ROI'] = time.time() - start_time
|
||||
|
||||
self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]")
|
||||
self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点")
|
||||
self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点")
|
||||
self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒")
|
||||
|
||||
if len(source_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="源边缘线ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 2. 提取目标平面ROI(用bbox)
|
||||
self._log(f"\n 步骤2: 提取目标平面ROI")
|
||||
start_time = time.time()
|
||||
target_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
target_roi.get('bbox_min'),
|
||||
target_roi.get('bbox_max'),
|
||||
self.spatial_index
|
||||
)
|
||||
timing_breakdown['提取目标平面ROI'] = time.time() - start_time
|
||||
|
||||
self._log(f" bbox: {target_roi.get('bbox_min')} -> {target_roi.get('bbox_max')}")
|
||||
self._log(f" 提取点数: {len(target_points):,} 点")
|
||||
self._log(f" 耗时: {timing_breakdown['提取目标平面ROI']:.2f}秒")
|
||||
|
||||
if len(target_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="目标平面ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 3. 拟合源边缘线(用全部精筛选点)
|
||||
self._log(f"\n 步骤3: 拟合源边缘线")
|
||||
start_time = time.time()
|
||||
self._log(f" 输入点数: {len(source_points)} 个点")
|
||||
source_fitter = LineFitter(
|
||||
threshold=params.get('ransac_threshold', 2.0),
|
||||
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||
)
|
||||
source_line = source_fitter.fit(source_points)
|
||||
timing_breakdown['源线RANSAC拟合'] = time.time() - start_time
|
||||
|
||||
if source_line is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||
)
|
||||
|
||||
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||
self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)")
|
||||
self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]")
|
||||
self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]")
|
||||
self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒")
|
||||
|
||||
# 4. 拟合目标平面(降采样优化)
|
||||
self._log(f"\n 步骤4: 拟合目标平面")
|
||||
start_time = time.time()
|
||||
self._log(f" 输入点数: {len(target_points)} 个点")
|
||||
|
||||
max_points_for_ransac = 100000
|
||||
target_points_for_fit = target_points
|
||||
if len(target_points) > max_points_for_ransac:
|
||||
sample_indices = np.random.choice(len(target_points), max_points_for_ransac, replace=False)
|
||||
target_points_for_fit = target_points[sample_indices]
|
||||
self._log(f" 降采样优化: {len(target_points):,} -> {len(target_points_for_fit):,} 个点")
|
||||
|
||||
target_fitter = PlaneFitter(
|
||||
threshold=params.get('ransac_threshold', 2.0),
|
||||
max_iterations=params.get('ransac_max_iterations', 1000),
|
||||
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
|
||||
)
|
||||
target_plane = target_fitter.fit(target_points_for_fit)
|
||||
timing_breakdown['目标平面RANSAC拟合'] = time.time() - start_time
|
||||
|
||||
if target_plane is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"目标平面拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)"
|
||||
)
|
||||
|
||||
self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%")
|
||||
self._log(f" 拟合结果: 内点{len(target_plane.inliers)}/{len(target_points_for_fit)} ({target_plane.inlier_ratio*100:.1f}%)")
|
||||
self._log(f" 平面上一点: [{target_plane.point[0]:.4f}, {target_plane.point[1]:.4f}, {target_plane.point[2]:.4f}]")
|
||||
self._log(f" 平面法向量: [{target_plane.normal[0]:.4f}, {target_plane.normal[1]:.4f}, {target_plane.normal[2]:.4f}]")
|
||||
self._log(f" 平面方程: {target_plane.normal[0]:.6f}*x + {target_plane.normal[1]:.6f}*y + {target_plane.normal[2]:.6f}*z + {target_plane.d:.6f} = 0")
|
||||
self._log(f" 耗时: {timing_breakdown['目标平面RANSAC拟合']:.2f}秒")
|
||||
|
||||
# 5. 从源线采样点(从内点中采样)
|
||||
start_time = time.time()
|
||||
n_samples = params.get('sample_points', 100)
|
||||
source_inlier_points = source_points[source_line.inliers]
|
||||
sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points)
|
||||
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||
|
||||
self._log(f" 采样{len(sampled_points)}个点")
|
||||
|
||||
# 6. 计算采样点到目标平面的距离
|
||||
start_time = time.time()
|
||||
distances = target_plane.distance_to_points(sampled_points)
|
||||
timing_breakdown['距离计算'] = time.time() - start_time
|
||||
|
||||
# 7. 统计结果(使用绝对值)
|
||||
mean_distance = np.mean(np.abs(distances))
|
||||
std_distance = np.std(distances)
|
||||
min_distance = np.min(np.abs(distances))
|
||||
max_distance = np.max(np.abs(distances))
|
||||
|
||||
# 8. 合格性判断
|
||||
theoretical_dimension = self.config.get('theoretical_dimension')
|
||||
tolerance = self.config.get('tolerance', {})
|
||||
tolerance_upper = tolerance.get('upper', 0)
|
||||
tolerance_lower = tolerance.get('lower', 0)
|
||||
|
||||
is_qualified = None
|
||||
deviation = None
|
||||
if theoretical_dimension is not None:
|
||||
deviation = mean_distance - theoretical_dimension
|
||||
is_qualified = (deviation >= -0.11) and (deviation <= 0.1)
|
||||
|
||||
self._log(f" 平均距离: {mean_distance:.4f} mm")
|
||||
if theoretical_dimension is not None:
|
||||
self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm")
|
||||
self._log(f" 偏差: {deviation:+.4f} mm")
|
||||
self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm")
|
||||
status = "✅ 合格" if is_qualified else "❌ 不合格"
|
||||
self._log(f" 判定: {status}")
|
||||
|
||||
# 9. 生成可视化(如果配置了输出目录)
|
||||
start_time = time.time()
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply"
|
||||
target_ply_path = vis_dir / f"{self.name}_target_plane_roi.ply"
|
||||
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
|
||||
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
|
||||
timing_breakdown['可视化保存'] = time.time() - start_time
|
||||
|
||||
result_data = {
|
||||
'mean_distance': float(mean_distance),
|
||||
'std_distance': float(std_distance),
|
||||
'min_distance': float(min_distance),
|
||||
'max_distance': float(max_distance),
|
||||
'sample_count': len(sampled_points),
|
||||
'source_roi_points': len(source_points),
|
||||
'target_roi_points': len(target_points),
|
||||
'source_inlier_ratio': float(source_line.inlier_ratio),
|
||||
'target_inlier_ratio': float(target_plane.inlier_ratio),
|
||||
'source_line_direction': source_line.direction.tolist(),
|
||||
'target_plane_normal': target_plane.normal.tolist(),
|
||||
'timing_breakdown': timing_breakdown
|
||||
}
|
||||
|
||||
# 添加合格性判断结果
|
||||
if theoretical_dimension is not None:
|
||||
result_data['theoretical_dimension'] = float(theoretical_dimension)
|
||||
result_data['deviation'] = float(deviation)
|
||||
result_data['is_qualified'] = is_qualified
|
||||
result_data['tolerance_upper'] = float(tolerance_upper)
|
||||
result_data['tolerance_lower'] = float(tolerance_lower)
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
data=result_data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
@@ -25,11 +25,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
||||
# 1. 提取源平面ROI
|
||||
start_time = time.time()
|
||||
source_roi = self.config.get('source_roi', {})
|
||||
source_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
source_roi.get('bbox_min'),
|
||||
source_roi.get('bbox_max')
|
||||
)
|
||||
source_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, source_roi.get('bbox_min'), source_roi.get('bbox_max'), self.spatial_index)
|
||||
timing_breakdown['提取源ROI'] = time.time() - start_time
|
||||
|
||||
# 记录ROI提取信息到详细日志
|
||||
@@ -52,11 +48,7 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
||||
# 2. 提取目标平面ROI
|
||||
start_time = time.time()
|
||||
target_roi = self.config.get('target_roi', {})
|
||||
target_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
target_roi.get('bbox_min'),
|
||||
target_roi.get('bbox_max')
|
||||
)
|
||||
target_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, target_roi.get('bbox_min'), target_roi.get('bbox_max'), self.spatial_index)
|
||||
timing_breakdown['提取目标ROI'] = time.time() - start_time
|
||||
|
||||
# 记录ROI提取信息到详细日志
|
||||
@@ -153,7 +145,8 @@ class PlaneToPlaneDistance(BaseMeasurement):
|
||||
# 5. 从源平面采样点
|
||||
start_time = time.time()
|
||||
n_samples = params.get('sample_points', 100)
|
||||
source_inlier_points = source_points[source_plane.inliers]
|
||||
# 修复:inliers索引是相对于source_points_for_fit的,不是source_points
|
||||
source_inlier_points = source_points_for_fit[source_plane.inliers]
|
||||
sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples)
|
||||
timing_breakdown['采样点选择'] = time.time() - start_time
|
||||
|
||||
@@ -46,11 +46,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
|
||||
hole_results = []
|
||||
for i in range(4):
|
||||
box = boxes[i]
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
box['min'],
|
||||
box['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||
if len(points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
@@ -96,11 +92,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement):
|
||||
print(f" 提取并拟合源平面...")
|
||||
for i in range(4, len(boxes)):
|
||||
box = boxes[i]
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
box['min'],
|
||||
box['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||
|
||||
if len(points) == 0:
|
||||
return MeasurementResult(
|
||||
@@ -24,8 +24,8 @@ from visualizer import Visualizer
|
||||
class PointToPlaneDistance(BaseMeasurement):
|
||||
"""点到平面距离测量"""
|
||||
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
||||
super().__init__(name, point_cloud, config)
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||
super().__init__(name, point_cloud, config, spatial_index)
|
||||
self.mode = config.get('mode', 'exact_points')
|
||||
|
||||
if self.mode == 'exact_points':
|
||||
@@ -68,42 +68,50 @@ class PointToPlaneDistance(BaseMeasurement):
|
||||
)
|
||||
|
||||
def _measure_exact_points(self) -> MeasurementResult:
|
||||
"""精确点模式:查找最近点并计算距离"""
|
||||
"""精确点模式:查找最近点并计算距离(优化版:使用Grid索引)"""
|
||||
from roi_extractor import ROIExtractor
|
||||
|
||||
timing_breakdown = {}
|
||||
|
||||
# 优化:先粗筛选点云,只在目标点附近构建KDTree
|
||||
# 优化:对每个目标点单独用Grid索引提取小区域
|
||||
start_time = time.time()
|
||||
|
||||
# 计算所有目标点的包围盒,扩大搜索半径的倍数作为缓冲
|
||||
search_margin = self.search_radius * 20 # 给足够的余量
|
||||
bbox_min = self.target_points.min(axis=0) - search_margin
|
||||
bbox_max = self.target_points.max(axis=0) + search_margin
|
||||
|
||||
# 粗筛选:只保留包围盒内的点
|
||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
||||
local_points = self.point_cloud[mask]
|
||||
local_indices = np.where(mask)[0]
|
||||
|
||||
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
|
||||
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
|
||||
|
||||
# 在筛选后的点云上构建KDTree(大幅加速)
|
||||
kdtree = KDTree(local_points)
|
||||
timing_breakdown['构建KDTree'] = time.time() - start_time
|
||||
|
||||
found_points = []
|
||||
distances_to_plane = []
|
||||
|
||||
start_time = time.time()
|
||||
for target_point in self.target_points:
|
||||
# 查找最近点
|
||||
dist, local_idx = kdtree.query(target_point, k=1)
|
||||
# 定义搜索区域(目标点周围的小包围盒)
|
||||
search_margin = self.search_radius * 2
|
||||
bbox_min = target_point - search_margin
|
||||
bbox_max = target_point + search_margin
|
||||
|
||||
if dist > self.search_radius:
|
||||
print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
# 用Grid索引快速提取局部点云
|
||||
local_points, local_indices = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) == 0:
|
||||
# 如果没找到点,扩大搜索范围
|
||||
search_margin = self.search_radius * 10
|
||||
bbox_min = target_point - search_margin
|
||||
bbox_max = target_point + search_margin
|
||||
local_points, local_indices = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) == 0:
|
||||
print(f"警告: 目标点 {target_point} 周围 {search_margin}mm 内没有点")
|
||||
continue
|
||||
|
||||
# 在局部点云中暴力搜索最近点(点数少,很快)
|
||||
distances = np.linalg.norm(local_points - target_point, axis=1)
|
||||
min_idx = np.argmin(distances)
|
||||
min_distance = distances[min_idx]
|
||||
nearest_point = local_points[min_idx]
|
||||
|
||||
if min_distance > self.search_radius:
|
||||
print(f"警告: 目标点 {target_point} 的最近点距离 {min_distance:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
|
||||
# 映射回原始点云的点
|
||||
nearest_point = local_points[local_idx]
|
||||
found_points.append(nearest_point)
|
||||
|
||||
# 计算点到平面的距离
|
||||
@@ -146,6 +154,9 @@ class PointToPlaneDistance(BaseMeasurement):
|
||||
|
||||
def _measure_bbox_filtered(self) -> MeasurementResult:
|
||||
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
|
||||
# 导入ROIExtractor用于Grid索引加速
|
||||
from roi_extractor import ROIExtractor
|
||||
|
||||
timing_breakdown = {}
|
||||
all_filtered_points = []
|
||||
all_distances = []
|
||||
@@ -155,9 +166,10 @@ class PointToPlaneDistance(BaseMeasurement):
|
||||
bbox_min = np.array(bbox_config['min'])
|
||||
bbox_max = np.array(bbox_config['max'])
|
||||
|
||||
# 提取包围盒内的点
|
||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
||||
bbox_points = self.point_cloud[mask]
|
||||
# 使用Grid索引快速提取包围盒内的点(优化后)
|
||||
bbox_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(bbox_points) == 0:
|
||||
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")
|
||||
@@ -40,11 +40,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
|
||||
hole_points_list = []
|
||||
for i in range(4):
|
||||
box = boxes[i]
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
box['min'],
|
||||
box['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||
if len(points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
@@ -64,11 +60,7 @@ class PointToPlaneWithHoles(BaseMeasurement):
|
||||
start_time = time.time()
|
||||
auxiliary_box = boxes[4]
|
||||
print(f" 提取辅助孔 '{auxiliary_box['name']}' ROI区域...")
|
||||
auxiliary_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
auxiliary_box['min'],
|
||||
auxiliary_box['max']
|
||||
)
|
||||
auxiliary_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, auxiliary_box['min'], auxiliary_box['max'], self.spatial_index)
|
||||
|
||||
if len(auxiliary_points) == 0:
|
||||
return MeasurementResult(
|
||||
@@ -21,8 +21,8 @@ from visualizer import Visualizer
|
||||
class PointToPointDistance(BaseMeasurement):
|
||||
"""点到点距离测量"""
|
||||
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
||||
super().__init__(name, point_cloud, config)
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
|
||||
super().__init__(name, point_cloud, config, spatial_index)
|
||||
|
||||
# 检查测量模式
|
||||
self.mode = config.get('mode', 'exact_points')
|
||||
@@ -55,11 +55,7 @@ class PointToPointDistance(BaseMeasurement):
|
||||
bbox_points_list = []
|
||||
|
||||
for bbox in self.bboxes:
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
bbox['min'],
|
||||
bbox['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, bbox['min'], bbox['max'], self.spatial_index)
|
||||
if len(points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
@@ -170,59 +166,80 @@ class PointToPointDistance(BaseMeasurement):
|
||||
)
|
||||
|
||||
def _execute_exact_points(self) -> MeasurementResult:
|
||||
"""执行精确点模式的测量"""
|
||||
"""执行精确点模式的测量(优化版:使用Grid索引)"""
|
||||
from roi_extractor import ROIExtractor
|
||||
|
||||
try:
|
||||
timing_breakdown = {}
|
||||
|
||||
# 优化:先粗筛选点云,只在目标点附近构建KDTree
|
||||
# 优化:对每个目标点用Grid索引提取小区域
|
||||
start_time = time.time()
|
||||
|
||||
# 收集所有目标点
|
||||
all_target_points = []
|
||||
for pair in self.point_pairs:
|
||||
all_target_points.append(pair['point1'])
|
||||
all_target_points.append(pair['point2'])
|
||||
all_target_points = np.array(all_target_points)
|
||||
|
||||
# 计算包围盒,扩大搜索半径的倍数作为缓冲
|
||||
search_margin = self.search_radius * 20
|
||||
bbox_min = all_target_points.min(axis=0) - search_margin
|
||||
bbox_max = all_target_points.max(axis=0) + search_margin
|
||||
|
||||
# 粗筛选:只保留包围盒内的点
|
||||
mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1)
|
||||
local_points = self.point_cloud[mask]
|
||||
|
||||
print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 "
|
||||
f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)")
|
||||
|
||||
# 在筛选后的点云上构建KDTree(大幅加速)
|
||||
kdtree = KDTree(local_points)
|
||||
timing_breakdown['构建KDTree'] = time.time() - start_time
|
||||
|
||||
pair_results = []
|
||||
all_found_points = []
|
||||
|
||||
start_time = time.time()
|
||||
for i, pair in enumerate(self.point_pairs):
|
||||
point1_target = np.array(pair['point1'])
|
||||
point2_target = np.array(pair['point2'])
|
||||
pair_name = pair.get('name', f'点对{i+1}')
|
||||
|
||||
# 查找point1的最近点
|
||||
dist1, local_idx1 = kdtree.query(point1_target, k=1)
|
||||
# 为point1找最近点
|
||||
search_margin = self.search_radius * 2
|
||||
bbox_min = point1_target - search_margin
|
||||
bbox_max = point1_target + search_margin
|
||||
|
||||
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) == 0:
|
||||
search_margin = self.search_radius * 10
|
||||
bbox_min = point1_target - search_margin
|
||||
bbox_max = point1_target + search_margin
|
||||
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) > 0:
|
||||
distances_to_p1 = np.linalg.norm(local_points - point1_target, axis=1)
|
||||
local_idx1 = np.argmin(distances_to_p1)
|
||||
dist1 = distances_to_p1[local_idx1]
|
||||
point1_found = local_points[local_idx1]
|
||||
else:
|
||||
print(f"警告: {pair_name} - 点1 {point1_target} 附近没有点")
|
||||
continue
|
||||
|
||||
# 为point2找最近点
|
||||
search_margin = self.search_radius * 2
|
||||
bbox_min = point2_target - search_margin
|
||||
bbox_max = point2_target + search_margin
|
||||
|
||||
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) == 0:
|
||||
search_margin = self.search_radius * 10
|
||||
bbox_min = point2_target - search_margin
|
||||
bbox_max = point2_target + search_margin
|
||||
local_points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud, bbox_min, bbox_max, self.spatial_index
|
||||
)
|
||||
|
||||
if len(local_points) > 0:
|
||||
distances_to_p2 = np.linalg.norm(local_points - point2_target, axis=1)
|
||||
local_idx2 = np.argmin(distances_to_p2)
|
||||
dist2 = distances_to_p2[local_idx2]
|
||||
point2_found = local_points[local_idx2]
|
||||
else:
|
||||
print(f"警告: {pair_name} - 点2 {point2_target} 附近没有点")
|
||||
continue
|
||||
|
||||
if dist1 > self.search_radius:
|
||||
print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
|
||||
point1_found = local_points[local_idx1]
|
||||
|
||||
# 查找point2的最近点
|
||||
dist2, local_idx2 = kdtree.query(point2_target, k=1)
|
||||
if dist2 > self.search_radius:
|
||||
print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
|
||||
point2_found = local_points[local_idx2]
|
||||
|
||||
# 计算两点之间的欧氏距离
|
||||
distance = np.linalg.norm(point2_found - point1_found)
|
||||
|
||||
@@ -47,11 +47,7 @@ class PointToPointWithHoles(BaseMeasurement):
|
||||
hole_results = [] # 保存完整的拟合结果
|
||||
|
||||
for i, box in enumerate(boxes):
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
box['min'],
|
||||
box['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||
if len(points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
@@ -131,11 +127,7 @@ class PointToPointWithHoles(BaseMeasurement):
|
||||
|
||||
# 保存每个孔的点云
|
||||
for i, box in enumerate(boxes):
|
||||
points, _ = ROIExtractor.extract_bbox_points(
|
||||
self.point_cloud,
|
||||
box['min'],
|
||||
box['max']
|
||||
)
|
||||
points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index)
|
||||
ply_path = vis_dir / f"{self.name}_{box['name']}.ply"
|
||||
Visualizer.save_roi_pointcloud(points, str(ply_path))
|
||||
|
||||
@@ -25,6 +25,12 @@ class PerformanceLogger:
|
||||
# 任务性能记录
|
||||
self.task_records = []
|
||||
|
||||
# 初始化阶段性能记录
|
||||
self.init_phases = {
|
||||
'pointcloud_loading': {'time': 0, 'points': 0, 'file': ''},
|
||||
'spatial_index': {'time': 0, 'method': '', 'info': {}}
|
||||
}
|
||||
|
||||
def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]):
|
||||
"""
|
||||
记录单个任务的性能数据
|
||||
@@ -41,6 +47,36 @@ class PerformanceLogger:
|
||||
}
|
||||
self.task_records.append(record)
|
||||
|
||||
def log_pointcloud_loading(self, time_seconds: float, point_count: int, filename: str):
|
||||
"""
|
||||
记录点云加载性能
|
||||
|
||||
Args:
|
||||
time_seconds: 加载耗时(秒)
|
||||
point_count: 点云数量
|
||||
filename: 文件名
|
||||
"""
|
||||
self.init_phases['pointcloud_loading'] = {
|
||||
'time': time_seconds,
|
||||
'points': point_count,
|
||||
'file': filename
|
||||
}
|
||||
|
||||
def log_spatial_index(self, time_seconds: float, method: str, info: Dict):
|
||||
"""
|
||||
记录空间索引构建性能
|
||||
|
||||
Args:
|
||||
time_seconds: 构建耗时(秒)
|
||||
method: 索引方法名称
|
||||
info: 索引详细信息
|
||||
"""
|
||||
self.init_phases['spatial_index'] = {
|
||||
'time': time_seconds,
|
||||
'method': method,
|
||||
'info': info
|
||||
}
|
||||
|
||||
def save(self):
|
||||
"""保存性能日志到文件"""
|
||||
with open(self.log_file, 'w', encoding='utf-8') as f:
|
||||
@@ -48,6 +84,51 @@ class PerformanceLogger:
|
||||
f.write("点云测量系统 - 性能分析报告\n")
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# 输出初始化阶段性能
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write("初始化阶段性能\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# 点云加载
|
||||
pc_loading = self.init_phases['pointcloud_loading']
|
||||
if pc_loading['time'] > 0:
|
||||
f.write("阶段1: 点云加载\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write(f" 文件名: {pc_loading['file']}\n")
|
||||
f.write(f" 点云数量: {pc_loading['points']:,} 个点\n")
|
||||
f.write(f" 加载耗时: {pc_loading['time']:.2f} 秒\n")
|
||||
if pc_loading['points'] > 0:
|
||||
f.write(f" 加载速度: {pc_loading['points']/pc_loading['time']/1000000:.2f} M点/秒\n")
|
||||
f.write("\n")
|
||||
|
||||
# 空间索引构建
|
||||
spatial_idx = self.init_phases['spatial_index']
|
||||
if spatial_idx['time'] > 0:
|
||||
f.write("阶段2: 空间索引构建\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write(f" 方法: {spatial_idx['method']}\n")
|
||||
f.write(f" 构建耗时: {spatial_idx['time']:.2f} 秒\n")
|
||||
info = spatial_idx['info']
|
||||
if 'grid_count' in info:
|
||||
f.write(f" 网格数量: {info['grid_count']:,}\n")
|
||||
f.write(f" 平均点/格: {info['avg_points_per_grid']:.1f}\n")
|
||||
elif 'node_count' in info:
|
||||
f.write(f" 节点数量: {info['node_count']:,}\n")
|
||||
f.write("\n")
|
||||
|
||||
# 初始化总耗时
|
||||
init_total = pc_loading['time'] + spatial_idx['time']
|
||||
if init_total > 0:
|
||||
f.write(f"初始化阶段总耗时: {init_total:.2f} 秒\n\n")
|
||||
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# 任务执行性能
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write("任务执行性能\n")
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write(f"总任务数: {len(self.task_records)}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
@@ -106,6 +187,19 @@ class PerformanceLogger:
|
||||
count = step_counts[step_name]
|
||||
f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n")
|
||||
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
|
||||
# 总体性能摘要
|
||||
grand_total = init_total + total_all_tasks
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write("总体性能摘要\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
f.write(f" 初始化阶段: {init_total:.2f} 秒 ({init_total/grand_total*100:.1f}%)\n")
|
||||
f.write(f" - 点云加载: {pc_loading['time']:.2f} 秒\n")
|
||||
f.write(f" - 空间索引: {spatial_idx['time']:.2f} 秒\n")
|
||||
f.write(f" 任务执行阶段: {total_all_tasks:.2f} 秒 ({total_all_tasks/grand_total*100:.1f}%)\n")
|
||||
f.write(f" 总耗时: {grand_total:.2f} 秒\n")
|
||||
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write(f"日志已保存到: {self.log_file}\n")
|
||||
f.write("=" * 80 + "\n")
|
||||
@@ -66,38 +66,46 @@ class PointCloudLoader:
|
||||
|
||||
@staticmethod
|
||||
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
|
||||
"""读取二进制PLY数据"""
|
||||
# 构建struct格式字符串
|
||||
"""读取二进制PLY数据(优化版:批量读取)"""
|
||||
# 构建numpy dtype
|
||||
type_map = {
|
||||
'float': 'f',
|
||||
'double': 'd',
|
||||
'uchar': 'B',
|
||||
'uint': 'I',
|
||||
'int': 'i'
|
||||
'float': np.float32,
|
||||
'double': np.float64,
|
||||
'uchar': np.uint8,
|
||||
'uint': np.uint32,
|
||||
'int': np.int32
|
||||
}
|
||||
|
||||
fmt = '<' # 小端序
|
||||
# 构建结构化数组的dtype
|
||||
dtype_list = []
|
||||
for prop in properties:
|
||||
fmt += type_map.get(prop['type'], 'f')
|
||||
dtype_list.append((prop['name'], type_map.get(prop['type'], np.float32)))
|
||||
|
||||
struct_size = struct.calcsize(fmt)
|
||||
points = []
|
||||
dtype = np.dtype(dtype_list)
|
||||
|
||||
for _ in range(vertex_count):
|
||||
data = struct.unpack(fmt, f.read(struct_size))
|
||||
# 只取前3个值作为xyz坐标
|
||||
points.append(data[:3])
|
||||
# 一次性读取所有数据
|
||||
raw_data = np.fromfile(f, dtype=dtype, count=vertex_count)
|
||||
|
||||
return np.array(points, dtype=np.float64)
|
||||
# 提取xyz坐标(假设前3个属性是x, y, z)
|
||||
# 如果属性名不是x,y,z,可以通过索引访问
|
||||
if 'x' in raw_data.dtype.names and 'y' in raw_data.dtype.names and 'z' in raw_data.dtype.names:
|
||||
points = np.column_stack([raw_data['x'], raw_data['y'], raw_data['z']])
|
||||
else:
|
||||
# 假设前3个字段是xyz
|
||||
points = np.column_stack([raw_data[raw_data.dtype.names[0]],
|
||||
raw_data[raw_data.dtype.names[1]],
|
||||
raw_data[raw_data.dtype.names[2]]])
|
||||
|
||||
return points.astype(np.float64)
|
||||
|
||||
@staticmethod
|
||||
def _read_ascii_ply(f, vertex_count: int) -> np.ndarray:
|
||||
"""读取ASCII PLY数据"""
|
||||
points = []
|
||||
for _ in range(vertex_count):
|
||||
line = f.readline().decode('ascii').strip()
|
||||
values = line.split()
|
||||
# 只取前3个值作为xyz坐标
|
||||
points.append([float(values[0]), float(values[1]), float(values[2])])
|
||||
"""读取ASCII PLY数据(优化版:批量读取)"""
|
||||
# 读取剩余所有行
|
||||
remaining = f.read().decode('ascii')
|
||||
|
||||
return np.array(points, dtype=np.float64)
|
||||
# 使用numpy的快速文本解析
|
||||
from io import StringIO
|
||||
points = np.loadtxt(StringIO(remaining), dtype=np.float64, max_rows=vertex_count, usecols=(0, 1, 2))
|
||||
|
||||
return points
|
||||
@@ -10,7 +10,8 @@ class ROIExtractor:
|
||||
def extract_bbox_points(
|
||||
points: np.ndarray,
|
||||
bbox_min: list,
|
||||
bbox_max: list
|
||||
bbox_max: list,
|
||||
spatial_index=None
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
提取包围盒内的点
|
||||
@@ -19,6 +20,7 @@ class ROIExtractor:
|
||||
points: 点云数组 (N, 3)
|
||||
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
|
||||
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
|
||||
spatial_index: 空间索引对象(可选)
|
||||
|
||||
Returns:
|
||||
roi_points: 提取的点 (M, 3)
|
||||
@@ -27,11 +29,15 @@ class ROIExtractor:
|
||||
bbox_min = np.array(bbox_min)
|
||||
bbox_max = np.array(bbox_max)
|
||||
|
||||
# 找到在包围盒内的点
|
||||
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
||||
indices = np.where(mask)[0]
|
||||
roi_points = points[indices]
|
||||
if spatial_index is not None:
|
||||
# 使用空间索引快速查询
|
||||
indices = spatial_index.query_bbox(bbox_min, bbox_max)
|
||||
else:
|
||||
# 回退到线性扫描
|
||||
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
|
||||
indices = np.where(mask)[0]
|
||||
|
||||
roi_points = points[indices]
|
||||
return roi_points, indices
|
||||
|
||||
@staticmethod
|
||||
@@ -0,0 +1,17 @@
|
||||
"""空间索引模块 - 提供多种ROI提取方法"""
|
||||
|
||||
from .base import SpatialIndex
|
||||
from .linear_scan import LinearScanIndex
|
||||
from .grid_index import GridIndex
|
||||
from .grid_index_optimized import GridIndexOptimized
|
||||
from .octree_index import OctreeIndex
|
||||
from .kdtree_index import KDTreeIndex
|
||||
|
||||
__all__ = [
|
||||
'SpatialIndex',
|
||||
'LinearScanIndex',
|
||||
'GridIndex',
|
||||
'GridIndexOptimized',
|
||||
'OctreeIndex',
|
||||
'KDTreeIndex',
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""空间索引基类"""
|
||||
from abc import ABC, abstractmethod
|
||||
import numpy as np
|
||||
|
||||
|
||||
class SpatialIndex(ABC):
|
||||
"""空间索引抽象基类"""
|
||||
|
||||
def __init__(self, points: np.ndarray):
|
||||
"""
|
||||
初始化索引
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
"""
|
||||
self.points = points
|
||||
self.build_time = 0.0 # 构建时间(秒)
|
||||
|
||||
@abstractmethod
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
查询包围盒内的点
|
||||
|
||||
Args:
|
||||
bbox_min: [x_min, y_min, z_min]
|
||||
bbox_max: [x_max, y_max, z_max]
|
||||
|
||||
Returns:
|
||||
包围盒内的点的索引数组
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_info(self) -> dict:
|
||||
"""返回索引信息(用于日志)"""
|
||||
return {
|
||||
'type': self.__class__.__name__,
|
||||
'point_count': len(self.points),
|
||||
'build_time': self.build_time
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Grid/Voxel 网格索引"""
|
||||
import numpy as np
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from .base import SpatialIndex
|
||||
|
||||
|
||||
class GridIndex(SpatialIndex):
|
||||
"""Grid/Voxel 空间索引 - O(N)构建, O(1)查询"""
|
||||
|
||||
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
|
||||
"""
|
||||
构建网格索引
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
grid_size: 网格大小(毫米)
|
||||
"""
|
||||
super().__init__(points)
|
||||
self.grid_size = grid_size
|
||||
|
||||
print(f" 构建Grid索引 (grid_size={grid_size}mm)...")
|
||||
start = time.time()
|
||||
|
||||
# 计算点云边界
|
||||
self.mins = points.min(axis=0)
|
||||
self.maxs = points.max(axis=0)
|
||||
|
||||
# 将每个点映射到网格坐标
|
||||
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
|
||||
|
||||
# 构建索引:grid_key → point_indices
|
||||
self.grid = defaultdict(list)
|
||||
for idx, (gx, gy, gz) in enumerate(grid_coords):
|
||||
grid_key = (gx, gy, gz)
|
||||
self.grid[grid_key].append(idx)
|
||||
|
||||
# 转换为numpy数组以加速查询
|
||||
for key in self.grid:
|
||||
self.grid[key] = np.array(self.grid[key], dtype=np.int32)
|
||||
|
||||
self.build_time = time.time() - start
|
||||
print(f" 网格数量: {len(self.grid):,}")
|
||||
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
|
||||
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Grid索引查询 - O(1 + K)复杂度
|
||||
|
||||
直接定位到相关网格,只检查这些网格中的点
|
||||
"""
|
||||
# 将bbox转换为网格坐标范围
|
||||
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
|
||||
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
|
||||
|
||||
# 收集相关网格中的所有点索引
|
||||
candidate_indices = []
|
||||
for gx in range(grid_min[0], grid_max[0] + 1):
|
||||
for gy in range(grid_min[1], grid_max[1] + 1):
|
||||
for gz in range(grid_min[2], grid_max[2] + 1):
|
||||
grid_key = (gx, gy, gz)
|
||||
if grid_key in self.grid:
|
||||
candidate_indices.append(self.grid[grid_key])
|
||||
|
||||
if len(candidate_indices) == 0:
|
||||
return np.array([], dtype=np.int32)
|
||||
|
||||
# 合并所有候选点
|
||||
candidate_indices = np.concatenate(candidate_indices)
|
||||
candidate_points = self.points[candidate_indices]
|
||||
|
||||
# 精确过滤(网格是粗略的,需要精确检查)
|
||||
mask = (
|
||||
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||
(candidate_points[:, 2] <= bbox_max[2])
|
||||
)
|
||||
|
||||
return candidate_indices[mask]
|
||||
|
||||
def get_info(self) -> dict:
|
||||
info = super().get_info()
|
||||
info['method'] = 'Grid网格索引'
|
||||
info['complexity'] = 'O(1 + K)'
|
||||
info['grid_size'] = self.grid_size
|
||||
info['grid_count'] = len(self.grid)
|
||||
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
|
||||
return info
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Grid/Voxel 网格索引 - 优化版(numpy向量化)"""
|
||||
import numpy as np
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from .base import SpatialIndex
|
||||
|
||||
|
||||
class GridIndexOptimized(SpatialIndex):
|
||||
"""Grid/Voxel 空间索引 - 优化版,使用numpy向量化避免Python循环"""
|
||||
|
||||
def __init__(self, points: np.ndarray, grid_size: float = 100.0):
|
||||
"""
|
||||
构建网格索引(优化版)
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
grid_size: 网格大小(毫米)
|
||||
"""
|
||||
super().__init__(points)
|
||||
self.grid_size = grid_size
|
||||
|
||||
print(f" 构建Grid索引(优化版 - numpy向量化)(grid_size={grid_size}mm)...")
|
||||
start = time.time()
|
||||
|
||||
# 计算点云边界
|
||||
self.mins = points.min(axis=0)
|
||||
self.maxs = points.max(axis=0)
|
||||
|
||||
# 将每个点映射到网格坐标
|
||||
grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32)
|
||||
|
||||
# ===== 关键优化:使用numpy向量化替代Python循环 =====
|
||||
|
||||
# 1. 将3D网格坐标编码为1D哈希值(避免tuple开销)
|
||||
# 使用大素数来避免冲突
|
||||
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
|
||||
grid_hash = (grid_coords * hash_multiplier).sum(axis=1)
|
||||
|
||||
# 2. 使用argsort排序(numpy C实现,非常快)
|
||||
sort_idx = np.argsort(grid_hash)
|
||||
sorted_hash = grid_hash[sort_idx]
|
||||
|
||||
# 3. 找到每个唯一网格的边界
|
||||
# 只需要遍历唯一网格数(约500万),而不是10亿点!
|
||||
unique_mask = np.concatenate(([True], sorted_hash[1:] != sorted_hash[:-1]))
|
||||
unique_indices = np.where(unique_mask)[0]
|
||||
|
||||
# 4. 构建索引字典
|
||||
self.grid = {}
|
||||
self.grid_hash_to_coords = {} # 保存hash到3D坐标的映射
|
||||
|
||||
n_unique = len(unique_indices)
|
||||
for i in range(n_unique):
|
||||
start_idx = unique_indices[i]
|
||||
end_idx = unique_indices[i + 1] if i + 1 < n_unique else len(sorted_hash)
|
||||
|
||||
hash_val = sorted_hash[start_idx]
|
||||
indices = sort_idx[start_idx:end_idx]
|
||||
|
||||
# 使用hash作为key(比tuple快)
|
||||
self.grid[hash_val] = indices
|
||||
|
||||
# 保存hash到原始3D坐标的映射(用于查询)
|
||||
self.grid_hash_to_coords[hash_val] = tuple(grid_coords[indices[0]])
|
||||
|
||||
self.build_time = time.time() - start
|
||||
print(f" 网格数量: {len(self.grid):,}")
|
||||
print(f" 平均点/格: {len(points) / len(self.grid):.1f}")
|
||||
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Grid索引查询 - O(1 + K)复杂度
|
||||
"""
|
||||
# 将bbox转换为网格坐标范围
|
||||
grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32)
|
||||
grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32)
|
||||
|
||||
# 收集相关网格中的所有点索引
|
||||
candidate_indices = []
|
||||
|
||||
# 遍历bbox覆盖的网格(通常很少,比如10×10×10=1000个)
|
||||
hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64)
|
||||
for gx in range(grid_min[0], grid_max[0] + 1):
|
||||
for gy in range(grid_min[1], grid_max[1] + 1):
|
||||
for gz in range(grid_min[2], grid_max[2] + 1):
|
||||
grid_coord = np.array([gx, gy, gz], dtype=np.int32)
|
||||
grid_hash_val = (grid_coord * hash_multiplier).sum()
|
||||
|
||||
if grid_hash_val in self.grid:
|
||||
candidate_indices.append(self.grid[grid_hash_val])
|
||||
|
||||
if len(candidate_indices) == 0:
|
||||
return np.array([], dtype=np.int32)
|
||||
|
||||
# 合并所有候选点
|
||||
candidate_indices = np.concatenate(candidate_indices)
|
||||
candidate_points = self.points[candidate_indices]
|
||||
|
||||
# 精确过滤
|
||||
mask = (
|
||||
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||
(candidate_points[:, 2] <= bbox_max[2])
|
||||
)
|
||||
|
||||
return candidate_indices[mask]
|
||||
|
||||
def get_info(self) -> dict:
|
||||
info = super().get_info()
|
||||
info['method'] = 'Grid网格索引(优化版)'
|
||||
info['complexity'] = 'O(1 + K)'
|
||||
info['grid_size'] = self.grid_size
|
||||
info['grid_count'] = len(self.grid)
|
||||
info['avg_points_per_grid'] = len(self.points) / len(self.grid)
|
||||
info['optimization'] = 'numpy向量化'
|
||||
return info
|
||||
@@ -0,0 +1,66 @@
|
||||
"""KDTree 索引(使用scipy)"""
|
||||
import numpy as np
|
||||
import time
|
||||
from scipy.spatial import cKDTree
|
||||
from .base import SpatialIndex
|
||||
|
||||
|
||||
class KDTreeIndex(SpatialIndex):
|
||||
"""KDTree 空间索引 - O(N log N)构建, O(log N + K)查询"""
|
||||
|
||||
def __init__(self, points: np.ndarray):
|
||||
"""
|
||||
构建KD树索引
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
"""
|
||||
super().__init__(points)
|
||||
|
||||
print(f" 构建KDTree索引...")
|
||||
start = time.time()
|
||||
|
||||
# 使用scipy的cKDTree(C实现,比纯Python快)
|
||||
self.tree = cKDTree(points)
|
||||
|
||||
self.build_time = time.time() - start
|
||||
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
KDTree查询 - O(log N + K)复杂度
|
||||
|
||||
使用球形查询近似bbox,然后精确过滤
|
||||
"""
|
||||
# KDTree没有直接的bbox查询,使用球形查询近似
|
||||
center = (bbox_min + bbox_max) / 2
|
||||
# 使用外接球半径,确保包含整个bbox
|
||||
radius = np.linalg.norm(bbox_max - bbox_min) / 2
|
||||
|
||||
# 球形查询
|
||||
candidate_indices = self.tree.query_ball_point(center, radius)
|
||||
|
||||
if len(candidate_indices) == 0:
|
||||
return np.array([], dtype=np.int32)
|
||||
|
||||
candidate_indices = np.array(candidate_indices, dtype=np.int32)
|
||||
candidate_points = self.points[candidate_indices]
|
||||
|
||||
# 精确过滤(因为球形查询是近似的)
|
||||
mask = (
|
||||
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||
(candidate_points[:, 2] <= bbox_max[2])
|
||||
)
|
||||
|
||||
return candidate_indices[mask]
|
||||
|
||||
def get_info(self) -> dict:
|
||||
info = super().get_info()
|
||||
info['method'] = 'KDTree索引(scipy)'
|
||||
info['complexity'] = 'O(log N + K)'
|
||||
info['note'] = '构建慢,适合小数据集'
|
||||
return info
|
||||
@@ -0,0 +1,32 @@
|
||||
"""线性扫描(无索引)- 基准方法"""
|
||||
import numpy as np
|
||||
import time
|
||||
from .base import SpatialIndex
|
||||
|
||||
|
||||
class LinearScanIndex(SpatialIndex):
|
||||
"""线性扫描索引(实际上就是无索引)"""
|
||||
|
||||
def __init__(self, points: np.ndarray):
|
||||
super().__init__(points)
|
||||
# 无需构建索引
|
||||
self.build_time = 0.0
|
||||
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
线性扫描查询 - O(N)复杂度
|
||||
|
||||
遍历所有点,检查是否在bbox内
|
||||
"""
|
||||
mask = (
|
||||
(self.points[:, 0] >= bbox_min[0]) & (self.points[:, 0] <= bbox_max[0]) &
|
||||
(self.points[:, 1] >= bbox_min[1]) & (self.points[:, 1] <= bbox_max[1]) &
|
||||
(self.points[:, 2] >= bbox_min[2]) & (self.points[:, 2] <= bbox_max[2])
|
||||
)
|
||||
return np.where(mask)[0]
|
||||
|
||||
def get_info(self) -> dict:
|
||||
info = super().get_info()
|
||||
info['method'] = '线性扫描(无索引)'
|
||||
info['complexity'] = 'O(N)'
|
||||
return info
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Octree 八叉树索引"""
|
||||
import numpy as np
|
||||
import time
|
||||
from .base import SpatialIndex
|
||||
|
||||
|
||||
class OctreeNode:
|
||||
"""八叉树节点"""
|
||||
|
||||
def __init__(self, center: np.ndarray, size: float):
|
||||
self.center = center
|
||||
self.size = size
|
||||
self.children = [None] * 8 # 8个子节点
|
||||
self.point_indices = [] # 叶节点存储点索引
|
||||
self.is_leaf = True
|
||||
|
||||
|
||||
class OctreeIndex(SpatialIndex):
|
||||
"""Octree 空间索引 - O(N)构建, O(log N + K)查询"""
|
||||
|
||||
def __init__(self, points: np.ndarray, max_depth: int = 8, max_points_per_node: int = 1000):
|
||||
"""
|
||||
构建八叉树索引
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
max_depth: 最大树深度
|
||||
max_points_per_node: 每个叶节点最多存储的点数
|
||||
"""
|
||||
super().__init__(points)
|
||||
self.max_depth = max_depth
|
||||
self.max_points_per_node = max_points_per_node
|
||||
|
||||
print(f" 构建Octree索引 (max_depth={max_depth}, max_points={max_points_per_node})...")
|
||||
start = time.time()
|
||||
|
||||
# 计算点云边界
|
||||
mins = points.min(axis=0)
|
||||
maxs = points.max(axis=0)
|
||||
center = (mins + maxs) / 2
|
||||
size = np.max(maxs - mins) / 2
|
||||
|
||||
# 构建树
|
||||
all_indices = np.arange(len(points), dtype=np.int32)
|
||||
self.root = self._build_tree(center, size, all_indices, depth=0)
|
||||
|
||||
self.build_time = time.time() - start
|
||||
self.node_count = self._count_nodes(self.root)
|
||||
print(f" 节点数量: {self.node_count:,}")
|
||||
print(f" 构建耗时: {self.build_time:.2f} 秒")
|
||||
|
||||
def _build_tree(self, center: np.ndarray, size: float, indices: np.ndarray, depth: int) -> OctreeNode:
|
||||
"""递归构建八叉树"""
|
||||
node = OctreeNode(center, size)
|
||||
|
||||
# 终止条件:达到最大深度或点数足够少
|
||||
if depth >= self.max_depth or len(indices) <= self.max_points_per_node:
|
||||
node.point_indices = indices
|
||||
node.is_leaf = True
|
||||
return node
|
||||
|
||||
# 分割成8个子空间
|
||||
node.is_leaf = False
|
||||
half_size = size / 2
|
||||
points_subset = self.points[indices]
|
||||
|
||||
for i in range(8):
|
||||
# 计算子节点中心
|
||||
child_center = center.copy()
|
||||
child_center[0] += half_size if (i & 1) else -half_size
|
||||
child_center[1] += half_size if (i & 2) else -half_size
|
||||
child_center[2] += half_size if (i & 4) else -half_size
|
||||
|
||||
# 找到属于这个子空间的点
|
||||
mask = (
|
||||
((points_subset[:, 0] >= child_center[0] - half_size) &
|
||||
(points_subset[:, 0] < child_center[0] + half_size)) &
|
||||
((points_subset[:, 1] >= child_center[1] - half_size) &
|
||||
(points_subset[:, 1] < child_center[1] + half_size)) &
|
||||
((points_subset[:, 2] >= child_center[2] - half_size) &
|
||||
(points_subset[:, 2] < child_center[2] + half_size))
|
||||
)
|
||||
|
||||
child_indices = indices[mask]
|
||||
|
||||
if len(child_indices) > 0:
|
||||
node.children[i] = self._build_tree(child_center, half_size, child_indices, depth + 1)
|
||||
|
||||
return node
|
||||
|
||||
def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Octree查询 - O(log N + K)复杂度
|
||||
|
||||
递归查询与bbox相交的节点
|
||||
"""
|
||||
result = []
|
||||
self._query_recursive(self.root, bbox_min, bbox_max, result)
|
||||
|
||||
if len(result) == 0:
|
||||
return np.array([], dtype=np.int32)
|
||||
|
||||
# 合并所有候选点
|
||||
candidate_indices = np.concatenate(result)
|
||||
candidate_points = self.points[candidate_indices]
|
||||
|
||||
# 精确过滤
|
||||
mask = (
|
||||
(candidate_points[:, 0] >= bbox_min[0]) &
|
||||
(candidate_points[:, 0] <= bbox_max[0]) &
|
||||
(candidate_points[:, 1] >= bbox_min[1]) &
|
||||
(candidate_points[:, 1] <= bbox_max[1]) &
|
||||
(candidate_points[:, 2] >= bbox_min[2]) &
|
||||
(candidate_points[:, 2] <= bbox_max[2])
|
||||
)
|
||||
|
||||
return candidate_indices[mask]
|
||||
|
||||
def _query_recursive(self, node: OctreeNode, bbox_min: np.ndarray, bbox_max: np.ndarray, result: list):
|
||||
"""递归查询节点"""
|
||||
if node is None:
|
||||
return
|
||||
|
||||
# 检查节点边界框是否与查询bbox相交
|
||||
node_min = node.center - node.size
|
||||
node_max = node.center + node.size
|
||||
|
||||
if not self._bbox_intersects(bbox_min, bbox_max, node_min, node_max):
|
||||
return
|
||||
|
||||
if node.is_leaf:
|
||||
# 叶节点:添加所有点索引
|
||||
if len(node.point_indices) > 0:
|
||||
result.append(node.point_indices)
|
||||
else:
|
||||
# 内部节点:递归查询子节点
|
||||
for child in node.children:
|
||||
if child is not None:
|
||||
self._query_recursive(child, bbox_min, bbox_max, result)
|
||||
|
||||
def _bbox_intersects(self, bbox1_min: np.ndarray, bbox1_max: np.ndarray,
|
||||
bbox2_min: np.ndarray, bbox2_max: np.ndarray) -> bool:
|
||||
"""检查两个bbox是否相交"""
|
||||
return not (
|
||||
bbox1_max[0] < bbox2_min[0] or bbox1_min[0] > bbox2_max[0] or
|
||||
bbox1_max[1] < bbox2_min[1] or bbox1_min[1] > bbox2_max[1] or
|
||||
bbox1_max[2] < bbox2_min[2] or bbox1_min[2] > bbox2_max[2]
|
||||
)
|
||||
|
||||
def _count_nodes(self, node: OctreeNode) -> int:
|
||||
"""统计节点数量"""
|
||||
if node is None:
|
||||
return 0
|
||||
count = 1
|
||||
if not node.is_leaf:
|
||||
for child in node.children:
|
||||
count += self._count_nodes(child)
|
||||
return count
|
||||
|
||||
def get_info(self) -> dict:
|
||||
info = super().get_info()
|
||||
info['method'] = 'Octree八叉树索引'
|
||||
info['complexity'] = 'O(log N + K)'
|
||||
info['max_depth'] = self.max_depth
|
||||
info['node_count'] = self.node_count
|
||||
return info
|
||||
@@ -1,6 +1,7 @@
|
||||
"""任务管理器 - 负责加载配置、调度任务、汇总结果"""
|
||||
import yaml
|
||||
import numpy as np
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -13,9 +14,12 @@ from measurements.point_to_point import PointToPointDistance
|
||||
from measurements.plane_with_holes import PlaneToPlaneWithHoles
|
||||
from measurements.point_to_plane_with_holes import PointToPlaneWithHoles
|
||||
from measurements.point_to_point_with_holes import PointToPointWithHoles
|
||||
from measurements.line_to_line import LineToLineDistance
|
||||
from measurements.line_to_plane import LineToPlaneDistance
|
||||
from excel_exporter import ExcelExporter
|
||||
from performance_logger import PerformanceLogger
|
||||
from measurement_logger import MeasurementLogger
|
||||
from spatial_index import LinearScanIndex, GridIndex, GridIndexOptimized, OctreeIndex, KDTreeIndex
|
||||
|
||||
|
||||
class TaskManager:
|
||||
@@ -25,6 +29,7 @@ class TaskManager:
|
||||
"""初始化任务管理器"""
|
||||
self.config = None
|
||||
self.point_cloud = None
|
||||
self.spatial_index = None # 空间索引
|
||||
self.tasks = []
|
||||
self.results = []
|
||||
self.pointcloud_path_override = None
|
||||
@@ -53,11 +58,35 @@ class TaskManager:
|
||||
# 处理相对路径
|
||||
pointcloud_path = self._resolve_path(pointcloud_path, config_path)
|
||||
|
||||
print(f"\n正在加载点云文件: {pointcloud_path}")
|
||||
# 加载点云
|
||||
print(f"\n{'='*70}")
|
||||
print("阶段1: 加载点云")
|
||||
print("="*70)
|
||||
print(f" 文件: {Path(pointcloud_path).name}")
|
||||
|
||||
load_start = time.time()
|
||||
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
|
||||
print(f"点云加载完成: {len(self.point_cloud)} 个点")
|
||||
load_time = time.time() - load_start
|
||||
|
||||
print(f" 点云数量: {len(self.point_cloud):,} 个点")
|
||||
print(f" 加载耗时: {load_time:.2f} 秒")
|
||||
|
||||
# 保存点云加载信息供性能日志使用
|
||||
self.pointcloud_load_time = load_time
|
||||
self.pointcloud_filename = Path(pointcloud_path).name
|
||||
|
||||
# 构建空间索引
|
||||
print(f"\n{'='*70}")
|
||||
print("阶段2: 构建空间索引")
|
||||
print("="*70)
|
||||
index_start = time.time()
|
||||
self._build_spatial_index()
|
||||
self.index_build_time = time.time() - index_start
|
||||
|
||||
# 解析任务
|
||||
print(f"\n{'='*70}")
|
||||
print("阶段3: 解析测量任务")
|
||||
print("="*70)
|
||||
tasks_config = self.config.get('tasks', [])
|
||||
if not tasks_config:
|
||||
print("警告: 配置文件中没有定义任务")
|
||||
@@ -139,37 +168,57 @@ class TaskManager:
|
||||
return PlaneToPlaneWithHoles(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif 'point_to_plane' in task_type:
|
||||
return PointToPlaneWithHoles(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif 'hole_center' in task_type and 'point_to_point' in task_type:
|
||||
return PointToPointWithHoles(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif task_type == 'plane_to_plane':
|
||||
return PlaneToPlaneDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif task_type == 'point_to_plane':
|
||||
return PointToPlaneDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif task_type == 'point_to_point':
|
||||
return PointToPointDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif task_type == 'line_to_line':
|
||||
return LineToLineDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
elif task_type == 'line_to_plane':
|
||||
return LineToPlaneDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config,
|
||||
spatial_index=self.spatial_index
|
||||
)
|
||||
else:
|
||||
print(f"警告: 未知的任务类型: {task_type}")
|
||||
@@ -194,6 +243,22 @@ class TaskManager:
|
||||
|
||||
perf_logger = PerformanceLogger(str(output_dir))
|
||||
|
||||
# 记录初始化阶段性能
|
||||
if hasattr(self, 'pointcloud_load_time'):
|
||||
perf_logger.log_pointcloud_loading(
|
||||
self.pointcloud_load_time,
|
||||
len(self.point_cloud),
|
||||
self.pointcloud_filename
|
||||
)
|
||||
if hasattr(self, 'index_build_time'):
|
||||
index_info = self.spatial_index.get_info() if self.spatial_index else {}
|
||||
index_method = self.config.get('roi_extraction', {}).get('method', 'unknown')
|
||||
perf_logger.log_spatial_index(
|
||||
self.index_build_time,
|
||||
index_method,
|
||||
index_info
|
||||
)
|
||||
|
||||
# 创建详细测量日志记录器
|
||||
detail_logger = MeasurementLogger(str(output_dir))
|
||||
|
||||
@@ -402,3 +467,44 @@ class TaskManager:
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n导出Excel失败: {e}")
|
||||
|
||||
def _build_spatial_index(self):
|
||||
"""根据配置构建空间索引"""
|
||||
index_config = self.config.get('roi_extraction', {})
|
||||
index_method = index_config.get('method', 'linear').lower()
|
||||
|
||||
if index_method == 'linear':
|
||||
print(" 方法: 线性扫描(无索引)")
|
||||
self.spatial_index = LinearScanIndex(self.point_cloud)
|
||||
|
||||
elif index_method == 'grid':
|
||||
grid_size = index_config.get('grid_size', 100.0)
|
||||
use_optimized = index_config.get('use_optimized', True) # 默认使用优化版
|
||||
|
||||
if use_optimized:
|
||||
print(f" 方法: Grid网格索引(优化版 - numpy向量化)")
|
||||
self.spatial_index = GridIndexOptimized(self.point_cloud, grid_size=grid_size)
|
||||
else:
|
||||
print(f" 方法: Grid网格索引(原始版 - Python循环)")
|
||||
self.spatial_index = GridIndex(self.point_cloud, grid_size=grid_size)
|
||||
|
||||
elif index_method == 'octree':
|
||||
max_depth = index_config.get('max_depth', 8)
|
||||
max_points = index_config.get('max_points_per_node', 1000)
|
||||
print(f" 方法: Octree八叉树索引")
|
||||
self.spatial_index = OctreeIndex(self.point_cloud, max_depth=max_depth, max_points_per_node=max_points)
|
||||
|
||||
elif index_method == 'kdtree':
|
||||
print(f" 方法: KDTree索引")
|
||||
self.spatial_index = KDTreeIndex(self.point_cloud)
|
||||
|
||||
else:
|
||||
print(f" 警告: 未知的索引方法 '{index_method}',使用线性扫描")
|
||||
self.spatial_index = LinearScanIndex(self.point_cloud)
|
||||
|
||||
# 显示索引信息
|
||||
info = self.spatial_index.get_info()
|
||||
if 'grid_count' in info:
|
||||
print(f" 详细信息: {info['grid_count']:,} 个网格, 平均 {info['avg_points_per_grid']:.1f} 点/格")
|
||||
elif 'node_count' in info:
|
||||
print(f" 详细信息: {info['node_count']:,} 个节点")
|
||||
@@ -39,8 +39,8 @@ registration:
|
||||
random_seed:
|
||||
|
||||
evaluation:
|
||||
chunk_size: 2048
|
||||
triangle_chunk_size: 4096
|
||||
chunk_size: 8192
|
||||
triangle_chunk_size: 512
|
||||
cad_mesh_deflection: 0.1
|
||||
|
||||
curvature:
|
||||
|
||||
@@ -74,15 +74,23 @@ class Evaluator:
|
||||
) -> tuple[np.ndarray, DistanceStats]:
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
dtype = torch.float64
|
||||
|
||||
# vertices/triangles 上 GPU(mesh 通常几百MB,可以接受)
|
||||
vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device)
|
||||
faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device)
|
||||
triangles = vertices_t[faces_t]
|
||||
points_t = torch.as_tensor(points, dtype=dtype, device=device)
|
||||
del vertices_t, faces_t # 释放原始 vertices/faces,只保留 triangles
|
||||
|
||||
# points 保留在 CPU,按 point_chunk 逐批传上 GPU,避免 10 亿点全量上 GPU OOM
|
||||
points_np = np.asarray(points, dtype=np.float64)
|
||||
n_points = points_np.shape[0]
|
||||
|
||||
distances = []
|
||||
with torch.no_grad():
|
||||
for start in range(0, points_t.shape[0], point_chunk_size):
|
||||
chunk = points_t[start:start + point_chunk_size]
|
||||
for start in range(0, n_points, point_chunk_size):
|
||||
chunk = torch.as_tensor(
|
||||
points_np[start:start + point_chunk_size], dtype=dtype, device=device
|
||||
)
|
||||
if triangle_chunk_size is None or triangle_chunk_size <= 0:
|
||||
dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
|
||||
min_dist2 = dist2.min(dim=1).values
|
||||
@@ -90,7 +98,7 @@ class Evaluator:
|
||||
min_dist2 = torch.full(
|
||||
(chunk.shape[0],),
|
||||
torch.inf,
|
||||
dtype=points_t.dtype,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user