feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(conda activate *)",
|
||||
"Bash(pip list *)",
|
||||
"Bash(conda run *)",
|
||||
"Bash(python main.py --help)",
|
||||
"Bash(C:/Users/suzhe/anaconda3/envs/pcc-experiment/python.exe main.py)",
|
||||
"Bash(C:/Users/suzhe/anaconda3/envs/pcc-experiment/python.exe main.py -o results/test_output)",
|
||||
"Bash(conda create *)",
|
||||
"Bash(conda env *)",
|
||||
"Bash(python *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
145
alg/点云测距v1.1/train_measurement_system/CLAUDE.md
Normal file
145
alg/点云测距v1.1/train_measurement_system/CLAUDE.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Train Measurement System - Developer Guide
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a **configuration-driven, plugin-based point cloud measurement system** for train inspection. The core design principle: measurements are defined in YAML, not hardcoded in Python.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
**Why configuration-driven?**
|
||||
- Users need to measure 20+ different features per train scan
|
||||
- Measurement locations are consistent across scans but vary by train model
|
||||
- Non-programmers should be able to add new measurement tasks by editing YAML
|
||||
|
||||
**Why plugin-based measurement types?**
|
||||
- Three fundamentally different measurement algorithms: plane-to-plane, point-to-plane, point-to-point
|
||||
- Each type has different input requirements (ROI bboxes vs exact coordinates)
|
||||
- Easy to add new types (e.g., circle-to-circle) without modifying core logic
|
||||
|
||||
**Why single large point cloud input?**
|
||||
- Train scans are 100M points covering the entire vehicle
|
||||
- ROI extraction happens at runtime based on bbox parameters
|
||||
- Avoids pre-segmentation and maintains spatial context
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Running measurements
|
||||
```bash
|
||||
conda activate pcc-experiment
|
||||
cd train_measurement_system
|
||||
python main.py
|
||||
```
|
||||
|
||||
Results: `results/measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||
Visualizations: `visualizations/*.ply`
|
||||
|
||||
### Adding a new measurement task
|
||||
|
||||
1. **Edit `config/measurements.yaml`** - add task definition with appropriate type
|
||||
2. **Set `enabled: true`** and provide required parameters for that type
|
||||
3. **Run `python main.py`** - the task will execute automatically
|
||||
4. **Check Excel results** - verify mean, std dev, inlier ratio
|
||||
5. **Inspect PLY files** - confirm ROI extraction is correct
|
||||
|
||||
### Adding a new measurement type
|
||||
|
||||
1. **Create `src/measurements/your_type.py`** inheriting from `BaseMeasurement`
|
||||
2. **Implement `execute() -> MeasurementResult`** with your algorithm
|
||||
3. **Register in `src/measurements/__init__.py`** - add to `__all__`
|
||||
4. **Update `src/task_manager.py`** - add branch in `_create_measurement_task()`
|
||||
5. **Document in README.md** - add config example and parameter descriptions
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
```
|
||||
YAML Config → TaskManager → [Measurement Plugin] → Excel + PLY
|
||||
↓
|
||||
PointCloudLoader (cached)
|
||||
↓
|
||||
ROIExtractor / PlaneFitter (shared utilities)
|
||||
```
|
||||
|
||||
**TaskManager** orchestrates everything:
|
||||
- Loads point cloud once, caches in memory
|
||||
- Iterates through enabled tasks
|
||||
- Instantiates appropriate measurement class based on `type` field
|
||||
- Collects results and delegates to ExcelExporter
|
||||
|
||||
**Measurement plugins** are stateless:
|
||||
- Receive full point cloud + task config in constructor
|
||||
- Extract ROI, run algorithm, return `MeasurementResult`
|
||||
- No shared state between tasks
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### Coordinate System
|
||||
- Point cloud uses **global coordinates** (mm units)
|
||||
- Z-axis is vertical (up positive)
|
||||
- ROI bboxes are in global coordinates
|
||||
- No coordinate transformations applied during extraction
|
||||
|
||||
### RANSAC Plane Fitting
|
||||
- `inlier_ratio` in results indicates ROI quality, not measurement accuracy
|
||||
- 100% inlier ratio = ROI perfectly contains only the target plane
|
||||
- Low inlier ratio (<70%) = ROI includes multiple surfaces or noise
|
||||
- **Automatic downsampling**: ROIs >10k points are sampled to 10k before SVD fitting (prevents memory explosion)
|
||||
|
||||
### Point-to-Plane Distance
|
||||
- Two modes: `exact_points` (KDTree nearest neighbor) vs `bbox_filtered` (extract then filter)
|
||||
- `exact_points` is faster but requires knowing precise coordinates
|
||||
- `bbox_filtered` supports filters like `max_abs_coordinate` for finding extreme points
|
||||
|
||||
### Plane-to-Plane Distance
|
||||
- **Unidirectional sampling**: samples points from source plane, measures to target plane
|
||||
- Not symmetric: swapping source/target may give different results if planes aren't parallel
|
||||
- Standard deviation indicates parallelism quality
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**When modifying measurement algorithms:**
|
||||
1. Disable all tasks except one test case (`enabled: false`)
|
||||
2. Run and verify Excel output matches expected values
|
||||
3. Open PLY files in CloudCompare/MeshLab to visually verify ROI extraction
|
||||
4. Check console output for warnings (low inlier ratio, insufficient points)
|
||||
|
||||
**When adding new measurement types:**
|
||||
1. Start with a simple test case with known ground truth
|
||||
2. Verify the `MeasurementResult.data` dictionary contains all required fields
|
||||
3. Ensure visualization saves the relevant points (not the entire ROI)
|
||||
4. Test with edge cases: empty ROI, single point, coplanar points
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Forgetting to set `enabled: true`** - task silently skipped
|
||||
- **Wrong coordinate order** - YAML uses `[x, y, z]`, some tools use `[z, y, x]`
|
||||
- **Bbox too large** - includes multiple surfaces, low inlier ratio
|
||||
- **Bbox too small** - insufficient points for robust fitting
|
||||
- **Missing `visualization_dir`** - no PLY files generated (optional but recommended)
|
||||
- **Modifying cached point cloud** - reload by restarting Python process
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Point cloud loading: ~10-30 seconds for 100M points (cached after first load)
|
||||
- Single plane-to-plane task: ~5-10 seconds (ROI extraction + RANSAC + sampling)
|
||||
- Bottleneck is usually RANSAC iterations, not point cloud I/O
|
||||
- Visualization (PLY save) adds ~1-2 seconds per task
|
||||
|
||||
## Extension Points
|
||||
|
||||
**Want to add CAD-based ROI extraction?**
|
||||
- Modify `roi_extractor.py` to accept plane parameters from CAD
|
||||
- Keep bbox-based extraction as fallback
|
||||
- Add `roi_source: "cad"` vs `"bbox"` in config
|
||||
|
||||
**Want to add statistical outlier removal?**
|
||||
- Add preprocessing step in measurement plugins before RANSAC
|
||||
- Make it optional via `params.remove_outliers: true`
|
||||
|
||||
**Want to support multiple point cloud files?**
|
||||
- Modify `task_manager.py` to accept per-task `pointcloud_file` override
|
||||
- Update `pointcloud_loader.py` to cache multiple files by path
|
||||
|
||||
**Want to add 2D projections or cross-sections?**
|
||||
- Create new measurement type `cross_section_profile`
|
||||
- Use `roi_extractor` to get 3D points, then project to 2D plane
|
||||
- Output profile data as additional sheet in Excel
|
||||
486
alg/点云测距v1.1/train_measurement_system/README.md
Normal file
486
alg/点云测距v1.1/train_measurement_system/README.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# 列车点云测量系统
|
||||
|
||||
基于配置驱动的点云测量系统,支持多种测量任务类型。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、距离计算,并生成Excel报告和可视化点云文件。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
train_measurement_system/
|
||||
├── config/
|
||||
│ └── measurements.yaml # 测量任务配置文件
|
||||
├── src/
|
||||
│ ├── __init__.py # 包初始化文件
|
||||
│ ├── measurements/
|
||||
│ │ ├── __init__.py # 子包初始化文件
|
||||
│ │ ├── base.py # 测量任务基类
|
||||
│ │ ├── plane_distance.py # 平面到平面距离测量
|
||||
│ │ ├── point_to_plane.py # 点到平面距离测量
|
||||
│ │ └── point_to_point.py # 点到点距离测量
|
||||
│ ├── config_loader.py # 配置加载器
|
||||
│ ├── task_manager.py # 任务管理器
|
||||
│ ├── roi_extractor.py # ROI提取
|
||||
│ ├── plane_fitter.py # 平面拟合(RANSAC)
|
||||
│ ├── pointcloud_loader.py # 点云加载
|
||||
│ ├── visualizer.py # 可视化工具
|
||||
│ └── excel_exporter.py # Excel导出
|
||||
├── results/ # 测量结果输出目录(Excel文件)
|
||||
├── visualizations/ # 可视化点云输出目录(PLY文件)
|
||||
├── main.py # 主程序入口
|
||||
├── pyproject.toml # 项目配置文件(PEP 621)
|
||||
├── setup.py # 安装脚本(向后兼容)
|
||||
├── requirements.txt # 依赖清单
|
||||
├── USAGE.md # 使用说明
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码
|
||||
- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型
|
||||
- **批量处理**: 一次运行可执行多个测量任务
|
||||
- **自动可视化**: 自动保存每个任务的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.yaml`,设置点云文件路径和测量任务。
|
||||
|
||||
**支持相对路径(相对于配置文件)或绝对路径:**
|
||||
|
||||
```yaml
|
||||
# 相对路径(推荐)
|
||||
pointcloud_file: "../../../head_train_pointcloud/train_2_100M.ply"
|
||||
|
||||
# 或绝对路径
|
||||
pointcloud_file: "D:\\lab\\PCC\\PCC\\head_train_pointcloud\\train_2_100M.ply"
|
||||
|
||||
tasks:
|
||||
- name: "任务21_侧门高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量侧门顶部到底部的距离"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
source_roi:
|
||||
bbox_min: [-2500, 3358, -1545]
|
||||
bbox_max: [-1500, 3362, -1525]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-2500, 1180, -1621]
|
||||
bbox_max: [-1500, 1180, -1500]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
```
|
||||
|
||||
### 4. 运行测量
|
||||
|
||||
```bash
|
||||
# 使用默认配置文件
|
||||
python main.py
|
||||
|
||||
# 指定配置文件
|
||||
python main.py -c config/custom.yaml
|
||||
|
||||
# 指定点云文件(覆盖配置文件中的路径)
|
||||
python main.py -p data/train.ply
|
||||
|
||||
# 指定输出目录
|
||||
python main.py -o results/
|
||||
|
||||
# 组合使用
|
||||
python main.py -c config/custom.yaml -p data/train.ply -o results/
|
||||
```
|
||||
|
||||
**命令行参数:**
|
||||
- `-c, --config`: 配置文件路径(默认: config/measurements.yaml)
|
||||
- `-p, --pointcloud`: 点云文件路径(覆盖配置文件)
|
||||
- `-o, --output`: 结果输出目录(默认: results/)
|
||||
|
||||
### 5. 查看结果
|
||||
|
||||
**Excel报告:** 保存在 `results/` 目录下
|
||||
- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx`
|
||||
- 包含每个任务的详细统计信息:
|
||||
- 任务名称、类型、描述
|
||||
- 点位参数(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拟合
|
||||
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务
|
||||
|
||||
122
alg/点云测距v1.1/train_measurement_system/USAGE.md
Normal file
122
alg/点云测距v1.1/train_measurement_system/USAGE.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 使用说明
|
||||
|
||||
## 环境安装
|
||||
|
||||
### 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.yaml` 文件:
|
||||
|
||||
### 1. 设置点云文件路径
|
||||
```yaml
|
||||
# 支持相对路径(相对于配置文件)或绝对路径
|
||||
pointcloud_file: "../../head_train_pointcloud/train_2_100M.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 提取是否准确
|
||||
171
alg/点云测距v1.1/train_measurement_system/config/measurements.yaml
Normal file
171
alg/点云测距v1.1/train_measurement_system/config/measurements.yaml
Normal file
@@ -0,0 +1,171 @@
|
||||
# 列车测量任务配置文件
|
||||
|
||||
# 点云文件路径(支持相对路径和绝对路径)
|
||||
# 相对路径:相对于本配置文件所在目录
|
||||
# 绝对路径:完整路径
|
||||
pointcloud_file: "../train_2_100M.py"
|
||||
# pointcloud_file: "../../../head_train_pointcloud/train_2_100M.ply"
|
||||
|
||||
# 测量任务列表
|
||||
tasks:
|
||||
- name: "任务21_侧门高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量侧门顶部到底部的距离"
|
||||
visualization_dir: "visualizations" # 可视化输出目录
|
||||
|
||||
# 源平面(采样点的来源)
|
||||
source_roi:
|
||||
bbox_min: [-2500, 3358, -1545]
|
||||
bbox_max: [-1500, 3362, -1525]
|
||||
|
||||
# 目标平面(测量距离的目标)
|
||||
target_roi:
|
||||
bbox_min: [-2500, 1180, -1621]
|
||||
bbox_max: [-1500, 1180, -1500]
|
||||
|
||||
# 算法参数
|
||||
params:
|
||||
# 从源平面采样的点数
|
||||
sample_points: 100
|
||||
# RANSAC平面拟合参数
|
||||
ransac_threshold: 2.0 # 内点距离阈值(mm)
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6 # 最小内点比例
|
||||
|
||||
- name: "任务2_车体长度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量车体前后端面的距离"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
source_roi:
|
||||
bbox_min: [-14007, 1500, -800]
|
||||
bbox_max: [-14006, 1900, 800]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [12499, 1216, -1300]
|
||||
bbox_max: [12501, 3200, 1300]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务43_窗口下沿高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量窗口下沿到地面的距离"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
source_roi:
|
||||
bbox_min: [5562, 1982, 1672]
|
||||
bbox_max: [6772, 1983, 1677]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1180, -302]
|
||||
bbox_max: [12428, 1180, 302]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
ransac_max_iterations: 1000
|
||||
min_inlier_ratio: 0.6
|
||||
|
||||
- name: "任务44_车顶高度"
|
||||
type: "plane_to_plane"
|
||||
enabled: true
|
||||
description: "测量车顶到地面的距离"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
source_roi:
|
||||
bbox_min: [-1419, 4000, -237]
|
||||
bbox_max: [9030, 4000, 237]
|
||||
|
||||
target_roi:
|
||||
bbox_min: [-10222, 1180, -302]
|
||||
bbox_max: [12428, 1180, 302]
|
||||
|
||||
params:
|
||||
sample_points: 100
|
||||
ransac_threshold: 2.0
|
||||
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"
|
||||
visualization_dir: "visualizations" # 可视化输出目录
|
||||
|
||||
# 目标点坐标(左右各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
|
||||
|
||||
# 最近点搜索半径(mm)
|
||||
search_radius: 10.0
|
||||
|
||||
- name: "任务15_侧墙z最大值点到z=0平面距离"
|
||||
type: "point_to_plane"
|
||||
enabled: true
|
||||
description: "从两个包围盒提取侧墙点云,选取z绝对值最大的10个点,计算到z=0平面的距离"
|
||||
mode: "bbox_filtered"
|
||||
visualization_dir: "visualizations" # 可视化输出目录
|
||||
|
||||
# 包围盒列表
|
||||
bboxes:
|
||||
- name: "左侧墙"
|
||||
min: [-1468, 1899, -1680]
|
||||
max: [12300, 1901, -1677]
|
||||
- name: "右侧墙"
|
||||
min: [-1468, 1899, 1677]
|
||||
max: [12300, 1901, 1680]
|
||||
|
||||
# 过滤条件
|
||||
filter:
|
||||
type: "max_abs_coordinate"
|
||||
axis: "z"
|
||||
top_n: 10
|
||||
|
||||
# 平面参数 (z=0)
|
||||
plane:
|
||||
a: 0
|
||||
b: 0
|
||||
c: 1
|
||||
d: 0
|
||||
|
||||
- name: "任务4_纵向梁距离"
|
||||
type: "point_to_point"
|
||||
enabled: true
|
||||
description: "测量关于z=0对称的点对之间的距离(纵向梁)"
|
||||
visualization_dir: "visualizations"
|
||||
|
||||
# 点对配置(使用任务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
|
||||
|
||||
126
alg/点云测距v1.1/train_measurement_system/main.py
Normal file
126
alg/点云测距v1.1/train_measurement_system/main.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""列车点云测量系统 - 主程序入口"""
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# 添加src目录到Python路径
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
from task_manager import TaskManager
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="列车点云测量系统",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
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/
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-c', '--config',
|
||||
type=str,
|
||||
default='config/measurements.yaml',
|
||||
help='配置文件路径 (默认: config/measurements.yaml)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-p', '--pointcloud',
|
||||
type=str,
|
||||
default=None,
|
||||
help='点云文件路径 (覆盖配置文件中的路径)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'-o', '--output',
|
||||
type=str,
|
||||
default=None,
|
||||
help='结果输出目录 (覆盖配置文件中的路径,默认: results/)'
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("=" * 60)
|
||||
print("列车点云测量系统")
|
||||
print("=" * 60)
|
||||
|
||||
# 解析命令行参数
|
||||
args = parse_args()
|
||||
|
||||
# 配置文件路径(支持相对路径和绝对路径)
|
||||
config_file = Path(args.config)
|
||||
if not config_file.is_absolute():
|
||||
config_file = Path(__file__).parent / config_file
|
||||
|
||||
if not config_file.exists():
|
||||
print(f"错误: 配置文件不存在: {config_file}")
|
||||
return 1
|
||||
|
||||
print(f"\n加载配置文件: {config_file}")
|
||||
|
||||
# 创建任务管理器
|
||||
manager = TaskManager()
|
||||
|
||||
# 加载配置
|
||||
if not manager.load_config(str(config_file)):
|
||||
print("错误: 配置文件加载失败")
|
||||
return 1
|
||||
|
||||
# 命令行参数覆盖配置
|
||||
if args.pointcloud:
|
||||
pointcloud_path = Path(args.pointcloud)
|
||||
if not pointcloud_path.is_absolute():
|
||||
pointcloud_path = Path(__file__).parent / pointcloud_path
|
||||
manager.override_pointcloud_path(str(pointcloud_path))
|
||||
print(f"使用命令行指定的点云文件: {pointcloud_path}")
|
||||
|
||||
if args.output:
|
||||
output_dir = Path(args.output)
|
||||
if not output_dir.is_absolute():
|
||||
output_dir = Path(__file__).parent / output_dir
|
||||
manager.override_output_dir(str(output_dir))
|
||||
print(f"使用命令行指定的输出目录: {output_dir}")
|
||||
|
||||
# 执行所有测量任务
|
||||
print("\n开始执行测量任务...")
|
||||
print("-" * 60)
|
||||
|
||||
results = manager.run_all_tasks()
|
||||
|
||||
# 输出结果摘要
|
||||
print("\n" + "=" * 60)
|
||||
print("测量结果摘要")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = sum(1 for r in results if r['status'] == 'success')
|
||||
failed_count = len(results) - success_count
|
||||
|
||||
print(f"\n总任务数: {len(results)}")
|
||||
print(f"成功: {success_count}")
|
||||
print(f"失败: {failed_count}")
|
||||
|
||||
if failed_count > 0:
|
||||
print("\n失败的任务:")
|
||||
for result in results:
|
||||
if result['status'] == 'failed':
|
||||
print(f" - {result['name']}: {result.get('error', '未知错误')}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测量完成!")
|
||||
print("=" * 60)
|
||||
|
||||
return 0 if failed_count == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
52
alg/点云测距v1.1/train_measurement_system/pyproject.toml
Normal file
52
alg/点云测距v1.1/train_measurement_system/pyproject.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "train-measurement-system"
|
||||
version = "1.1.0"
|
||||
description = "配置驱动的列车点云测量系统"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9,<3.12"
|
||||
license = {text = "MIT"}
|
||||
keywords = ["point-cloud", "measurement", "train", "3d"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Science/Research",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"numpy>=2.0.0",
|
||||
"open3d>=0.18.0",
|
||||
"scipy>=1.10.0",
|
||||
"pandas>=2.0.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"PyYAML>=6.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
viz = [
|
||||
"matplotlib>=3.7.0",
|
||||
"plotly>=5.0.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/train-measurement-system"
|
||||
Documentation = "https://github.com/yourusername/train-measurement-system/blob/main/README.md"
|
||||
Repository = "https://github.com/yourusername/train-measurement-system"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "."}
|
||||
packages = ["src", "src.measurements"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.yaml", "*.yml"]
|
||||
19
alg/点云测距v1.1/train_measurement_system/requirements.txt
Normal file
19
alg/点云测距v1.1/train_measurement_system/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# 列车点云测量系统依赖包
|
||||
# Python 版本要求: 3.9, 3.10, 3.11 (open3d 暂不支持 3.12+)
|
||||
# 推荐使用 Python 3.10
|
||||
|
||||
# 核心依赖
|
||||
numpy>=2.0.0
|
||||
open3d>=0.18.0
|
||||
scipy>=1.10.0
|
||||
|
||||
# 数据处理
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# 配置文件
|
||||
PyYAML>=6.0.0
|
||||
|
||||
# 可视化(可选)
|
||||
matplotlib>=3.7.0
|
||||
plotly>=5.0.0
|
||||
11
alg/点云测距v1.1/train_measurement_system/setup.py
Normal file
11
alg/点云测距v1.1/train_measurement_system/setup.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Setup script for train-measurement-system.
|
||||
|
||||
This is a minimal setup.py for backward compatibility.
|
||||
All configuration is in pyproject.toml.
|
||||
"""
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup()
|
||||
25
alg/点云测距v1.1/train_measurement_system/src/__init__.py
Normal file
25
alg/点云测距v1.1/train_measurement_system/src/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Train Measurement System
|
||||
|
||||
配置驱动的列车点云测量系统
|
||||
"""
|
||||
|
||||
__version__ = "1.1.0"
|
||||
|
||||
from .config_loader import ConfigLoader
|
||||
from .pointcloud_loader import PointCloudLoader
|
||||
from .roi_extractor import ROIExtractor
|
||||
from .plane_fitter import PlaneFitter
|
||||
from .task_manager import TaskManager
|
||||
from .excel_exporter import ExcelExporter
|
||||
from .visualizer import Visualizer
|
||||
|
||||
__all__ = [
|
||||
"ConfigLoader",
|
||||
"PointCloudLoader",
|
||||
"ROIExtractor",
|
||||
"PlaneFitter",
|
||||
"TaskManager",
|
||||
"ExcelExporter",
|
||||
"Visualizer",
|
||||
]
|
||||
82
alg/点云测距v1.1/train_measurement_system/src/config_loader.py
Normal file
82
alg/点云测距v1.1/train_measurement_system/src/config_loader.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""配置加载模块"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""加载和验证YAML配置文件"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
"""
|
||||
Args:
|
||||
config_path: 配置文件路径
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.config = None
|
||||
|
||||
def load(self) -> Dict[str, Any]:
|
||||
"""加载配置文件"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
self._validate()
|
||||
return self.config
|
||||
|
||||
def _validate(self):
|
||||
"""验证配置文件格式"""
|
||||
if not isinstance(self.config, dict):
|
||||
raise ValueError("配置文件格式错误:根节点必须是字典")
|
||||
|
||||
# 检查必需字段
|
||||
if 'point_cloud' not in self.config:
|
||||
raise ValueError("配置文件缺少 'point_cloud' 字段")
|
||||
|
||||
if 'tasks' not in self.config:
|
||||
raise ValueError("配置文件缺少 'tasks' 字段")
|
||||
|
||||
if not isinstance(self.config['tasks'], list):
|
||||
raise ValueError("'tasks' 必须是列表")
|
||||
|
||||
# 验证每个任务
|
||||
for i, task in enumerate(self.config['tasks']):
|
||||
self._validate_task(task, i)
|
||||
|
||||
def _validate_task(self, task: Dict[str, Any], index: int):
|
||||
"""验证单个任务配置"""
|
||||
required_fields = ['name', 'type']
|
||||
for field in required_fields:
|
||||
if field not in task:
|
||||
raise ValueError(f"任务 {index} 缺少必需字段: {field}")
|
||||
|
||||
# 验证任务类型
|
||||
valid_types = ['plane_to_plane', 'point_to_plane', 'point_to_point']
|
||||
if task['type'] not in valid_types:
|
||||
raise ValueError(f"任务 {index} 类型无效: {task['type']}, 必须是 {valid_types} 之一")
|
||||
|
||||
def get_point_cloud_path(self) -> str:
|
||||
"""获取点云文件路径"""
|
||||
return self.config['point_cloud']
|
||||
|
||||
def get_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有任务配置"""
|
||||
return self.config['tasks']
|
||||
|
||||
def get_global_settings(self) -> Dict[str, Any]:
|
||||
"""获取全局设置"""
|
||||
return self.config.get('global_settings', {})
|
||||
|
||||
def is_visualization_enabled(self) -> bool:
|
||||
"""是否启用可视化"""
|
||||
return self.get_global_settings().get('enable_visualization', False)
|
||||
|
||||
def get_visualization_dir(self) -> str:
|
||||
"""获取可视化输出目录"""
|
||||
return self.get_global_settings().get('visualization_dir', 'visualizations')
|
||||
|
||||
def get_results_dir(self) -> str:
|
||||
"""获取结果输出目录"""
|
||||
return self.get_global_settings().get('results_dir', 'results')
|
||||
245
alg/点云测距v1.1/train_measurement_system/src/excel_exporter.py
Normal file
245
alg/点云测距v1.1/train_measurement_system/src/excel_exporter.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Excel结果导出器"""
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from measurements.base import MeasurementResult
|
||||
|
||||
|
||||
class ExcelExporter:
|
||||
"""将测量结果导出为Excel文件"""
|
||||
|
||||
def __init__(self, output_dir: str):
|
||||
"""
|
||||
初始化导出器
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录路径
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def export(self, results: List[MeasurementResult], filename: str = None, task_configs: List[Dict[str, Any]] = None) -> str:
|
||||
"""
|
||||
导出测量结果到Excel
|
||||
|
||||
Args:
|
||||
results: 测量结果列表
|
||||
filename: 输出文件名(不含扩展名),默认使用时间戳
|
||||
task_configs: 任务配置列表(用于提取描述和参数)
|
||||
|
||||
Returns:
|
||||
str: 导出的Excel文件路径
|
||||
"""
|
||||
if filename is None:
|
||||
filename = f"measurement_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
output_path = self.output_dir / f"{filename}.xlsx"
|
||||
|
||||
# 创建任务名称到配置的映射
|
||||
task_config_map = {}
|
||||
if task_configs:
|
||||
for config in task_configs:
|
||||
task_config_map[config.get('name')] = config
|
||||
|
||||
# 创建工作簿
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "测量结果"
|
||||
|
||||
# 写入标题
|
||||
self._write_header(ws)
|
||||
|
||||
# 写入数据
|
||||
row = 2
|
||||
for result in results:
|
||||
task_config = task_config_map.get(result.task_name, {})
|
||||
row = self._write_result(ws, result, row, task_config)
|
||||
|
||||
# 调整列宽
|
||||
self._adjust_column_widths(ws)
|
||||
|
||||
# 保存文件
|
||||
wb.save(output_path)
|
||||
|
||||
return str(output_path)
|
||||
|
||||
def _write_header(self, ws):
|
||||
"""写入表头"""
|
||||
headers = [
|
||||
"任务名称",
|
||||
"任务类型",
|
||||
"任务描述",
|
||||
"点位参数",
|
||||
"状态",
|
||||
"平均距离 (mm)",
|
||||
"标准差 (mm)",
|
||||
"最小距离 (mm)",
|
||||
"最大距离 (mm)",
|
||||
"采样点数",
|
||||
"源ROI点数",
|
||||
"目标ROI点数",
|
||||
"源平面内点率",
|
||||
"目标平面内点率",
|
||||
"错误信息"
|
||||
]
|
||||
|
||||
# 样式
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col)
|
||||
cell.value = header
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
cell.border = border
|
||||
|
||||
def _format_task_params(self, task_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
格式化任务参数为字符串
|
||||
|
||||
Args:
|
||||
task_config: 任务配置字典
|
||||
|
||||
Returns:
|
||||
str: 格式化后的参数字符串
|
||||
"""
|
||||
task_type = task_config.get('type', '')
|
||||
params_parts = []
|
||||
|
||||
if task_type == 'plane_to_plane':
|
||||
# 平面到平面:显示源ROI和目标ROI
|
||||
source_roi = task_config.get('source_roi', {})
|
||||
target_roi = task_config.get('target_roi', {})
|
||||
if source_roi:
|
||||
params_parts.append(f"源ROI: min{source_roi.get('bbox_min', [])} max{source_roi.get('bbox_max', [])}")
|
||||
if target_roi:
|
||||
params_parts.append(f"目标ROI: min{target_roi.get('bbox_min', [])} max{target_roi.get('bbox_max', [])}")
|
||||
|
||||
elif task_type == 'point_to_plane':
|
||||
mode = task_config.get('mode', '')
|
||||
if mode == 'exact_points':
|
||||
# 精确点模式:显示目标点坐标
|
||||
target_points = task_config.get('target_points', [])
|
||||
if target_points:
|
||||
params_parts.append(f"目标点({len(target_points)}个): {target_points}")
|
||||
plane = task_config.get('plane', {})
|
||||
if plane:
|
||||
params_parts.append(f"平面: {plane.get('a', 0)}x+{plane.get('b', 0)}y+{plane.get('c', 0)}z+{plane.get('d', 0)}=0")
|
||||
elif mode == 'bbox_filtered':
|
||||
# 包围盒过滤模式:显示包围盒
|
||||
bboxes = task_config.get('bboxes', [])
|
||||
if bboxes:
|
||||
bbox_strs = [f"{bbox.get('name', '')}: min{bbox.get('min', [])} max{bbox.get('max', [])}" for bbox in bboxes]
|
||||
params_parts.append(f"包围盒: {'; '.join(bbox_strs)}")
|
||||
filter_info = task_config.get('filter', {})
|
||||
if filter_info:
|
||||
params_parts.append(f"过滤: {filter_info.get('type', '')} axis={filter_info.get('axis', '')} top_n={filter_info.get('top_n', '')}")
|
||||
|
||||
elif task_type == 'point_to_point':
|
||||
# 点到点:显示点对
|
||||
point_pairs = task_config.get('point_pairs', [])
|
||||
if point_pairs:
|
||||
pair_strs = [f"{pair.get('name', '')}: {pair.get('point1', [])} <-> {pair.get('point2', [])}" for pair in point_pairs]
|
||||
params_parts.append(f"点对({len(point_pairs)}对): {'; '.join(pair_strs)}")
|
||||
|
||||
return '\n'.join(params_parts) if params_parts else "无"
|
||||
|
||||
def _write_result(self, ws, result: MeasurementResult, row: int, task_config: Dict[str, Any]) -> int:
|
||||
"""
|
||||
写入单个测量结果
|
||||
|
||||
Args:
|
||||
ws: 工作表
|
||||
result: 测量结果
|
||||
row: 起始行号
|
||||
task_config: 任务配置
|
||||
|
||||
Returns:
|
||||
int: 下一行号
|
||||
"""
|
||||
border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
# 基本信息
|
||||
ws.cell(row=row, column=1).value = result.task_name
|
||||
ws.cell(row=row, column=2).value = result.task_type
|
||||
ws.cell(row=row, column=3).value = task_config.get('description', '')
|
||||
ws.cell(row=row, column=4).value = self._format_task_params(task_config)
|
||||
ws.cell(row=row, column=5).value = "成功" if result.success else "失败"
|
||||
|
||||
# 状态颜色
|
||||
status_cell = ws.cell(row=row, column=5)
|
||||
if result.success:
|
||||
status_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
||||
status_cell.font = Font(color="006100")
|
||||
else:
|
||||
status_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
||||
status_cell.font = Font(color="9C0006")
|
||||
|
||||
# 测量数据
|
||||
if result.success and result.data:
|
||||
data = result.data
|
||||
ws.cell(row=row, column=6).value = round(data.get('mean_distance', 0), 2)
|
||||
ws.cell(row=row, column=7).value = round(data.get('std_distance', 0), 2)
|
||||
ws.cell(row=row, column=8).value = round(data.get('min_distance', 0), 2)
|
||||
ws.cell(row=row, column=9).value = round(data.get('max_distance', 0), 2)
|
||||
ws.cell(row=row, column=10).value = data.get('sample_count', 0)
|
||||
ws.cell(row=row, column=11).value = data.get('source_roi_points', 0)
|
||||
ws.cell(row=row, column=12).value = data.get('target_roi_points', 0)
|
||||
ws.cell(row=row, column=13).value = f"{data.get('source_inlier_ratio', 0) * 100:.1f}%"
|
||||
ws.cell(row=row, column=14).value = f"{data.get('target_inlier_ratio', 0) * 100:.1f}%"
|
||||
else:
|
||||
# 失败时显示错误信息
|
||||
ws.cell(row=row, column=15).value = result.error_message
|
||||
|
||||
# 应用边框和对齐
|
||||
for col in range(1, 16):
|
||||
cell = ws.cell(row=row, column=col)
|
||||
cell.border = border
|
||||
# 点位参数列使用左对齐,其他居中
|
||||
if col == 4:
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||
else:
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
return row + 1
|
||||
|
||||
def _adjust_column_widths(self, ws):
|
||||
"""自动调整列宽"""
|
||||
column_widths = {
|
||||
1: 25, # 任务名称
|
||||
2: 18, # 任务类型
|
||||
3: 30, # 任务描述
|
||||
4: 60, # 点位参数
|
||||
5: 10, # 状态
|
||||
6: 15, # 平均距离
|
||||
7: 15, # 标准差
|
||||
8: 15, # 最小距离
|
||||
9: 15, # 最大距离
|
||||
10: 12, # 采样点数
|
||||
11: 12, # 源ROI点数
|
||||
12: 12, # 目标ROI点数
|
||||
13: 15, # 源平面内点率
|
||||
14: 15, # 目标平面内点率
|
||||
15: 40 # 错误信息
|
||||
}
|
||||
|
||||
for col, width in column_widths.items():
|
||||
ws.column_dimensions[get_column_letter(col)].width = width
|
||||
@@ -0,0 +1,7 @@
|
||||
"""measurements包初始化"""
|
||||
from .base import BaseMeasurement, MeasurementResult
|
||||
from .plane_distance import PlaneToPlaneDistance
|
||||
from .point_to_plane import PointToPlaneDistance
|
||||
from .point_to_point import PointToPointDistance
|
||||
|
||||
__all__ = ['BaseMeasurement', 'MeasurementResult', 'PlaneToPlaneDistance', 'PointToPlaneDistance', 'PointToPointDistance']
|
||||
@@ -0,0 +1,47 @@
|
||||
"""测量任务基类"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeasurementResult:
|
||||
"""测量结果数据类"""
|
||||
task_name: str
|
||||
task_type: str
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class BaseMeasurement(ABC):
|
||||
"""测量任务基类"""
|
||||
|
||||
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]):
|
||||
"""
|
||||
初始化测量任务
|
||||
|
||||
Args:
|
||||
name: 任务名称
|
||||
point_cloud: 完整点云数组 (N, 3)
|
||||
config: 任务配置字典
|
||||
"""
|
||||
self.name = name
|
||||
self.point_cloud = point_cloud
|
||||
self.config = config
|
||||
self.task_type = config.get('type', 'unknown')
|
||||
|
||||
@abstractmethod
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""
|
||||
执行测量任务
|
||||
|
||||
Returns:
|
||||
MeasurementResult: 测量结果
|
||||
"""
|
||||
pass
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""检查任务是否启用"""
|
||||
return self.config.get('enabled', True)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""平面到平面距离测量"""
|
||||
import numpy as np
|
||||
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 roi_extractor import ROIExtractor
|
||||
from plane_fitter import PlaneFitter
|
||||
from visualizer import Visualizer
|
||||
|
||||
|
||||
class PlaneToPlaneDistance(BaseMeasurement):
|
||||
"""测量两个平面之间的距离(单向采样)"""
|
||||
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""执行平面到平面距离测量"""
|
||||
try:
|
||||
# 1. 提取源平面ROI
|
||||
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')
|
||||
)
|
||||
|
||||
if len(source_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="源平面ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 2. 提取目标平面ROI
|
||||
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')
|
||||
)
|
||||
|
||||
if len(target_points) == 0:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message="目标平面ROI区域内没有点"
|
||||
)
|
||||
|
||||
# 3. 拟合源平面
|
||||
params = self.config.get('params', {})
|
||||
source_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)
|
||||
)
|
||||
source_plane = source_fitter.fit(source_points)
|
||||
|
||||
if source_plane is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"源平面拟合失败(点数: {len(source_points)})"
|
||||
)
|
||||
|
||||
# 4. 拟合目标平面
|
||||
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)
|
||||
|
||||
if target_plane is None:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=f"目标平面拟合失败(点数: {len(target_points)})"
|
||||
)
|
||||
|
||||
# 5. 从源平面采样点
|
||||
n_samples = params.get('sample_points', 100)
|
||||
source_inlier_points = source_points[source_plane.inliers]
|
||||
sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples)
|
||||
|
||||
# 6. 计算采样点到目标平面的距离
|
||||
distances = target_plane.distance_to_points(sampled_points)
|
||||
|
||||
# 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. 生成可视化(如果配置了输出目录)
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 只保存ROI点云文件
|
||||
source_ply_path = vis_dir / f"{self.name}_source_roi.ply"
|
||||
target_ply_path = vis_dir / f"{self.name}_target_roi.ply"
|
||||
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
|
||||
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
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_plane.inlier_ratio),
|
||||
'target_inlier_ratio': float(target_plane.inlier_ratio),
|
||||
'source_plane_normal': source_plane.normal.tolist(),
|
||||
'target_plane_normal': target_plane.normal.tolist()
|
||||
}
|
||||
)
|
||||
|
||||
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,207 @@
|
||||
"""
|
||||
点到平面距离测量模块
|
||||
|
||||
支持两种模式:
|
||||
1. exact_points: 在点云中查找给定坐标的最近点,计算到平面的距离
|
||||
2. bbox_filtered: 从包围盒提取点云,筛选特定条件的点,计算到平面的距离
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from scipy.spatial import KDTree
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from measurements.base import BaseMeasurement, MeasurementResult
|
||||
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)
|
||||
self.mode = config.get('mode', 'exact_points')
|
||||
|
||||
if self.mode == 'exact_points':
|
||||
self.target_points = np.array(config['target_points'])
|
||||
self.search_radius = config.get('search_radius', 10.0)
|
||||
elif self.mode == 'bbox_filtered':
|
||||
self.bboxes = config['bboxes']
|
||||
self.filter_config = config['filter']
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {self.mode}")
|
||||
|
||||
# 平面参数 (ax + by + cz + d = 0)
|
||||
plane_config = config['plane']
|
||||
self.plane_normal = np.array([
|
||||
plane_config.get('a', 0),
|
||||
plane_config.get('b', 0),
|
||||
plane_config.get('c', 1)
|
||||
])
|
||||
self.plane_d = plane_config.get('d', 0)
|
||||
|
||||
# 归一化法向量
|
||||
norm = np.linalg.norm(self.plane_normal)
|
||||
if norm > 0:
|
||||
self.plane_normal = self.plane_normal / norm
|
||||
self.plane_d = self.plane_d / norm
|
||||
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""执行测量"""
|
||||
try:
|
||||
if self.mode == 'exact_points':
|
||||
return self._measure_exact_points()
|
||||
elif self.mode == 'bbox_filtered':
|
||||
return self._measure_bbox_filtered()
|
||||
except Exception as e:
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
def _measure_exact_points(self) -> MeasurementResult:
|
||||
"""精确点模式:查找最近点并计算距离"""
|
||||
# 构建KDTree
|
||||
kdtree = KDTree(self.point_cloud)
|
||||
|
||||
found_points = []
|
||||
distances_to_plane = []
|
||||
|
||||
for target_point in self.target_points:
|
||||
# 查找最近点
|
||||
dist, idx = kdtree.query(target_point, k=1)
|
||||
|
||||
if dist > self.search_radius:
|
||||
print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
|
||||
nearest_point = self.point_cloud[idx]
|
||||
found_points.append(nearest_point)
|
||||
|
||||
# 计算点到平面的距离
|
||||
distance = self._point_to_plane_distance(nearest_point)
|
||||
distances_to_plane.append(distance)
|
||||
|
||||
distances_array = np.array(distances_to_plane)
|
||||
found_points_array = np.array(found_points)
|
||||
|
||||
# 保存找到的点为点云文件
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
ply_path = vis_dir / f"{self.name}_found_points.ply"
|
||||
Visualizer.save_roi_pointcloud(found_points_array, str(ply_path))
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
data={
|
||||
'mean_distance': float(np.mean(np.abs(distances_array))),
|
||||
'std_distance': float(np.std(distances_array)),
|
||||
'min_distance': float(np.min(distances_array)),
|
||||
'max_distance': float(np.max(distances_array)),
|
||||
'point_count': len(distances_array),
|
||||
'mode': 'exact_points',
|
||||
'target_points': self.target_points.tolist(),
|
||||
'found_points': [p.tolist() for p in found_points],
|
||||
'distances': distances_array.tolist()
|
||||
}
|
||||
)
|
||||
|
||||
def _measure_bbox_filtered(self) -> MeasurementResult:
|
||||
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
|
||||
all_filtered_points = []
|
||||
all_distances = []
|
||||
|
||||
for bbox_config in self.bboxes:
|
||||
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]
|
||||
|
||||
if len(bbox_points) == 0:
|
||||
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点")
|
||||
continue
|
||||
|
||||
# 应用过滤条件
|
||||
filtered_points = self._apply_filter(bbox_points)
|
||||
|
||||
if len(filtered_points) == 0:
|
||||
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 过滤后没有点")
|
||||
continue
|
||||
|
||||
all_filtered_points.extend(filtered_points)
|
||||
|
||||
# 计算每个点到平面的距离
|
||||
for point in filtered_points:
|
||||
distance = self._point_to_plane_distance(point)
|
||||
all_distances.append(distance)
|
||||
|
||||
if len(all_distances) == 0:
|
||||
raise ValueError("没有找到符合条件的点")
|
||||
|
||||
distances_array = np.array(all_distances)
|
||||
filtered_points_array = np.array(all_filtered_points)
|
||||
|
||||
# 保存筛选后的点为点云文件
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
ply_path = vis_dir / f"{self.name}_filtered_points.ply"
|
||||
Visualizer.save_roi_pointcloud(filtered_points_array, str(ply_path))
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
data={
|
||||
'mean_distance': float(np.mean(np.abs(distances_array))),
|
||||
'std_distance': float(np.std(distances_array)),
|
||||
'min_distance': float(np.min(distances_array)),
|
||||
'max_distance': float(np.max(distances_array)),
|
||||
'point_count': len(distances_array),
|
||||
'mode': 'bbox_filtered',
|
||||
'num_bboxes': len(self.bboxes),
|
||||
'filtered_points': all_filtered_points,
|
||||
'distances': distances_array.tolist()
|
||||
}
|
||||
)
|
||||
|
||||
def _apply_filter(self, points: np.ndarray) -> List[np.ndarray]:
|
||||
"""应用过滤条件"""
|
||||
filter_type = self.filter_config['type']
|
||||
|
||||
if filter_type == 'max_abs_coordinate':
|
||||
axis = self.filter_config['axis']
|
||||
top_n = self.filter_config['top_n']
|
||||
|
||||
axis_map = {'x': 0, 'y': 1, 'z': 2}
|
||||
axis_idx = axis_map[axis.lower()]
|
||||
|
||||
# 计算绝对值
|
||||
abs_values = np.abs(points[:, axis_idx])
|
||||
|
||||
# 获取top_n个最大值的索引
|
||||
if len(points) <= top_n:
|
||||
return points.tolist()
|
||||
|
||||
top_indices = np.argpartition(abs_values, -top_n)[-top_n:]
|
||||
return points[top_indices].tolist()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown filter type: {filter_type}")
|
||||
|
||||
def _point_to_plane_distance(self, point: np.ndarray) -> float:
|
||||
"""计算点到平面的有符号距离"""
|
||||
return np.dot(self.plane_normal, point) + self.plane_d
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
点到点距离测量模块
|
||||
|
||||
用于测量点云中两个指定点之间的距离
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from scipy.spatial import KDTree
|
||||
|
||||
# 添加父目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from measurements.base import BaseMeasurement, MeasurementResult
|
||||
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)
|
||||
|
||||
# 点对列表,每对包含两个点的坐标
|
||||
self.point_pairs = config['point_pairs']
|
||||
self.search_radius = config.get('search_radius', 10.0)
|
||||
|
||||
def execute(self) -> MeasurementResult:
|
||||
"""执行测量"""
|
||||
try:
|
||||
# 构建KDTree用于快速查找最近点
|
||||
kdtree = KDTree(self.point_cloud)
|
||||
|
||||
pair_results = []
|
||||
all_found_points = []
|
||||
|
||||
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, idx1 = kdtree.query(point1_target, k=1)
|
||||
if dist1 > self.search_radius:
|
||||
print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm")
|
||||
|
||||
point1_found = self.point_cloud[idx1]
|
||||
|
||||
# 查找point2的最近点
|
||||
dist2, 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 = self.point_cloud[idx2]
|
||||
|
||||
# 计算两点之间的欧氏距离
|
||||
distance = np.linalg.norm(point2_found - point1_found)
|
||||
|
||||
pair_results.append({
|
||||
'name': pair_name,
|
||||
'point1_target': point1_target.tolist(),
|
||||
'point2_target': point2_target.tolist(),
|
||||
'point1_found': point1_found.tolist(),
|
||||
'point2_found': point2_found.tolist(),
|
||||
'point1_search_distance': float(dist1),
|
||||
'point2_search_distance': float(dist2),
|
||||
'distance': float(distance)
|
||||
})
|
||||
|
||||
all_found_points.extend([point1_found, point2_found])
|
||||
|
||||
# 统计所有距离
|
||||
distances = np.array([pr['distance'] for pr in pair_results])
|
||||
mean_distance = float(np.mean(distances))
|
||||
std_distance = float(np.std(distances))
|
||||
min_distance = float(np.min(distances))
|
||||
max_distance = float(np.max(distances))
|
||||
|
||||
# 保存找到的点为点云文件
|
||||
visualization_dir = self.config.get('visualization_dir')
|
||||
if visualization_dir:
|
||||
vis_dir = Path(visualization_dir)
|
||||
vis_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存所有找到的点
|
||||
all_points_array = np.array(all_found_points)
|
||||
ply_path = vis_dir / f"{self.name}_found_points.ply"
|
||||
Visualizer.save_roi_pointcloud(all_points_array, str(ply_path))
|
||||
|
||||
return MeasurementResult(
|
||||
task_name=self.name,
|
||||
task_type=self.task_type,
|
||||
success=True,
|
||||
data={
|
||||
'mean_distance': mean_distance,
|
||||
'std_distance': std_distance,
|
||||
'min_distance': min_distance,
|
||||
'max_distance': max_distance,
|
||||
'pair_count': len(pair_results),
|
||||
'pair_results': pair_results
|
||||
}
|
||||
)
|
||||
|
||||
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()}"
|
||||
)
|
||||
169
alg/点云测距v1.1/train_measurement_system/src/plane_fitter.py
Normal file
169
alg/点云测距v1.1/train_measurement_system/src/plane_fitter.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""平面拟合模块 - 使用RANSAC算法拟合平面"""
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaneModel:
|
||||
"""平面模型"""
|
||||
normal: np.ndarray # 法向量 (3,)
|
||||
point: np.ndarray # 平面上的点 (3,)
|
||||
d: float # 平面方程 ax+by+cz+d=0 中的d
|
||||
inliers: np.ndarray # 内点索引
|
||||
inlier_ratio: float # 内点比例
|
||||
|
||||
def distance_to_point(self, point: np.ndarray) -> float:
|
||||
"""计算点到平面的有向距离"""
|
||||
return np.dot(self.normal, point) + self.d
|
||||
|
||||
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
|
||||
"""计算多个点到平面的有向距离"""
|
||||
return np.dot(points, self.normal) + self.d
|
||||
|
||||
|
||||
class PlaneFitter:
|
||||
"""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[PlaneModel]:
|
||||
"""
|
||||
使用RANSAC拟合平面
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
|
||||
Returns:
|
||||
PlaneModel或None(拟合失败)
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return None
|
||||
|
||||
best_inliers = None
|
||||
best_model = None
|
||||
best_inlier_count = 0
|
||||
|
||||
for _ in range(self.max_iterations):
|
||||
# 随机选择3个点
|
||||
sample_indices = np.random.choice(len(points), 3, replace=False)
|
||||
sample_points = points[sample_indices]
|
||||
|
||||
# 计算平面参数
|
||||
model = self._fit_plane_from_points(sample_points)
|
||||
if model is None:
|
||||
continue
|
||||
|
||||
# 计算所有点到平面的距离
|
||||
distances = np.abs(model.distance_to_points(points))
|
||||
|
||||
# 找到内点
|
||||
inliers = np.where(distances < self.threshold)[0]
|
||||
|
||||
if len(inliers) > best_inlier_count:
|
||||
best_inlier_count = len(inliers)
|
||||
best_inliers = inliers
|
||||
best_model = model
|
||||
|
||||
if best_inliers is None or len(best_inliers) < 3:
|
||||
return None
|
||||
|
||||
# 使用所有内点重新拟合平面
|
||||
inlier_points = points[best_inliers]
|
||||
final_model = self._fit_plane_least_squares(inlier_points)
|
||||
|
||||
if final_model is None:
|
||||
return None
|
||||
|
||||
final_model.inliers = best_inliers
|
||||
final_model.inlier_ratio = len(best_inliers) / len(points)
|
||||
|
||||
# 检查内点比例
|
||||
if final_model.inlier_ratio < self.min_inlier_ratio:
|
||||
return None
|
||||
|
||||
return final_model
|
||||
|
||||
def _fit_plane_from_points(self, points: np.ndarray) -> Optional[PlaneModel]:
|
||||
"""从3个点拟合平面"""
|
||||
if len(points) != 3:
|
||||
return None
|
||||
|
||||
# 计算两个向量
|
||||
v1 = points[1] - points[0]
|
||||
v2 = points[2] - points[0]
|
||||
|
||||
# 法向量 = v1 × v2
|
||||
normal = np.cross(v1, v2)
|
||||
norm = np.linalg.norm(normal)
|
||||
|
||||
if norm < 1e-6:
|
||||
return None
|
||||
|
||||
normal = normal / norm
|
||||
|
||||
# 平面方程: n·(p - p0) = 0 => n·p = n·p0 => ax+by+cz+d=0, d=-n·p0
|
||||
d = -np.dot(normal, points[0])
|
||||
|
||||
return PlaneModel(
|
||||
normal=normal,
|
||||
point=points[0],
|
||||
d=d,
|
||||
inliers=np.array([]),
|
||||
inlier_ratio=0.0
|
||||
)
|
||||
|
||||
def _fit_plane_least_squares(self, points: np.ndarray) -> Optional[PlaneModel]:
|
||||
"""使用最小二乘法拟合平面"""
|
||||
if len(points) < 3:
|
||||
return None
|
||||
|
||||
# 如果点数过多,降采样以避免内存爆炸
|
||||
# SVD需要O(n^2)内存,超过10000个点会导致内存问题
|
||||
max_points_for_svd = 10000
|
||||
if len(points) > max_points_for_svd:
|
||||
indices = np.random.choice(len(points), max_points_for_svd, replace=False)
|
||||
points_for_fit = points[indices]
|
||||
else:
|
||||
points_for_fit = points
|
||||
|
||||
# 计算质心
|
||||
centroid = np.mean(points_for_fit, axis=0)
|
||||
|
||||
# 中心化点云
|
||||
centered = points_for_fit - centroid
|
||||
|
||||
# SVD分解
|
||||
try:
|
||||
_, _, vh = np.linalg.svd(centered)
|
||||
normal = vh[2] # 最小奇异值对应的向量
|
||||
|
||||
# 确保法向量指向正方向(可选)
|
||||
if normal[2] < 0:
|
||||
normal = -normal
|
||||
|
||||
d = -np.dot(normal, centroid)
|
||||
|
||||
return PlaneModel(
|
||||
normal=normal,
|
||||
point=centroid,
|
||||
d=d,
|
||||
inliers=np.array([]),
|
||||
inlier_ratio=0.0
|
||||
)
|
||||
except np.linalg.LinAlgError:
|
||||
return None
|
||||
103
alg/点云测距v1.1/train_measurement_system/src/pointcloud_loader.py
Normal file
103
alg/点云测距v1.1/train_measurement_system/src/pointcloud_loader.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""点云加载模块"""
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
class PointCloudLoader:
|
||||
"""加载点云文件"""
|
||||
|
||||
@staticmethod
|
||||
def load_ply(file_path: str) -> np.ndarray:
|
||||
"""
|
||||
加载PLY格式点云
|
||||
|
||||
Args:
|
||||
file_path: PLY文件路径
|
||||
|
||||
Returns:
|
||||
np.ndarray: 点云数组 (N, 3)
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"点云文件不存在: {file_path}")
|
||||
|
||||
print(f"正在加载点云: {file_path}")
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
# 读取头部
|
||||
header_lines = []
|
||||
while True:
|
||||
line = f.readline().decode('ascii').strip()
|
||||
header_lines.append(line)
|
||||
if line == 'end_header':
|
||||
break
|
||||
|
||||
# 解析头部
|
||||
vertex_count = 0
|
||||
format_type = None
|
||||
properties = []
|
||||
|
||||
for line in header_lines:
|
||||
if line.startswith('element vertex'):
|
||||
vertex_count = int(line.split()[-1])
|
||||
elif line.startswith('format'):
|
||||
format_type = line.split()[1]
|
||||
elif line.startswith('property'):
|
||||
parts = line.split()
|
||||
properties.append({
|
||||
'type': parts[1],
|
||||
'name': parts[2]
|
||||
})
|
||||
|
||||
if vertex_count == 0:
|
||||
raise ValueError("PLY文件中没有顶点数据")
|
||||
|
||||
# 读取顶点数据
|
||||
if format_type == 'binary_little_endian':
|
||||
points = PointCloudLoader._read_binary_ply(f, vertex_count, properties)
|
||||
elif format_type == 'ascii':
|
||||
points = PointCloudLoader._read_ascii_ply(f, vertex_count)
|
||||
else:
|
||||
raise ValueError(f"不支持的PLY格式: {format_type}")
|
||||
|
||||
print(f" 加载完成: {len(points)} 个点")
|
||||
return points
|
||||
|
||||
@staticmethod
|
||||
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
|
||||
"""读取二进制PLY数据"""
|
||||
# 构建struct格式字符串
|
||||
type_map = {
|
||||
'float': 'f',
|
||||
'double': 'd',
|
||||
'uchar': 'B',
|
||||
'uint': 'I',
|
||||
'int': 'i'
|
||||
}
|
||||
|
||||
fmt = '<' # 小端序
|
||||
for prop in properties:
|
||||
fmt += type_map.get(prop['type'], 'f')
|
||||
|
||||
struct_size = struct.calcsize(fmt)
|
||||
points = []
|
||||
|
||||
for _ in range(vertex_count):
|
||||
data = struct.unpack(fmt, f.read(struct_size))
|
||||
# 只取前3个值作为xyz坐标
|
||||
points.append(data[:3])
|
||||
|
||||
return np.array(points, dtype=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])])
|
||||
|
||||
return np.array(points, dtype=np.float64)
|
||||
59
alg/点云测距v1.1/train_measurement_system/src/roi_extractor.py
Normal file
59
alg/点云测距v1.1/train_measurement_system/src/roi_extractor.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""ROI提取模块 - 从点云中提取包围盒区域的点"""
|
||||
import numpy as np
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class ROIExtractor:
|
||||
"""从点云中提取指定包围盒区域的点"""
|
||||
|
||||
@staticmethod
|
||||
def extract_bbox_points(
|
||||
points: np.ndarray,
|
||||
bbox_min: list,
|
||||
bbox_max: list
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
提取包围盒内的点
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
|
||||
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
|
||||
|
||||
Returns:
|
||||
roi_points: 提取的点 (M, 3)
|
||||
indices: 原始点云中的索引 (M,)
|
||||
"""
|
||||
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]
|
||||
|
||||
return roi_points, indices
|
||||
|
||||
@staticmethod
|
||||
def sample_points(points: np.ndarray, n_samples: int, seed: Optional[int] = 42) -> np.ndarray:
|
||||
"""
|
||||
从点云中随机采样点
|
||||
|
||||
Args:
|
||||
points: 点云数组 (N, 3)
|
||||
n_samples: 采样点数
|
||||
seed: 随机种子
|
||||
|
||||
Returns:
|
||||
sampled_points: 采样后的点 (n_samples, 3)
|
||||
"""
|
||||
if len(points) == 0:
|
||||
return np.array([]).reshape(0, 3)
|
||||
|
||||
n_samples = min(n_samples, len(points))
|
||||
|
||||
if seed is not None:
|
||||
np.random.seed(seed)
|
||||
|
||||
indices = np.random.choice(len(points), size=n_samples, replace=False)
|
||||
return points[indices]
|
||||
282
alg/点云测距v1.1/train_measurement_system/src/task_manager.py
Normal file
282
alg/点云测距v1.1/train_measurement_system/src/task_manager.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""任务管理器 - 负责加载配置、调度任务、汇总结果"""
|
||||
import yaml
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from pointcloud_loader import PointCloudLoader
|
||||
from measurements.base import MeasurementResult
|
||||
from measurements.plane_distance import PlaneToPlaneDistance
|
||||
from measurements.point_to_plane import PointToPlaneDistance
|
||||
from measurements.point_to_point import PointToPointDistance
|
||||
from excel_exporter import ExcelExporter
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""测量任务管理器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化任务管理器"""
|
||||
self.config = None
|
||||
self.point_cloud = None
|
||||
self.tasks = []
|
||||
self.results = []
|
||||
self.pointcloud_path_override = None
|
||||
self.output_dir_override = None
|
||||
|
||||
def load_config(self, config_path: str) -> bool:
|
||||
"""
|
||||
加载配置文件
|
||||
|
||||
Args:
|
||||
config_path: 配置文件路径
|
||||
|
||||
Returns:
|
||||
bool: 是否加载成功
|
||||
"""
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
# 加载点云(支持相对路径)
|
||||
pointcloud_path = self.pointcloud_path_override or self.config.get('pointcloud_file')
|
||||
if not pointcloud_path:
|
||||
print("错误: 配置文件中未指定点云文件路径")
|
||||
return False
|
||||
|
||||
# 处理相对路径
|
||||
pointcloud_path = self._resolve_path(pointcloud_path, config_path)
|
||||
|
||||
print(f"\n正在加载点云文件: {pointcloud_path}")
|
||||
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
|
||||
print(f"点云加载完成: {len(self.point_cloud)} 个点")
|
||||
|
||||
# 解析任务
|
||||
tasks_config = self.config.get('tasks', [])
|
||||
if not tasks_config:
|
||||
print("警告: 配置文件中没有定义任务")
|
||||
return True
|
||||
|
||||
print(f"\n找到 {len(tasks_config)} 个测量任务")
|
||||
|
||||
for task_config in tasks_config:
|
||||
task = self._create_task(task_config)
|
||||
if task:
|
||||
self.tasks.append(task)
|
||||
print(f" - {task.name} ({task.task_type})")
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"错误: 配置文件不存在: {config_path}")
|
||||
return False
|
||||
except yaml.YAMLError as e:
|
||||
print(f"错误: 配置文件格式错误: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"错误: 加载配置失败: {e}")
|
||||
return False
|
||||
|
||||
def _resolve_path(self, path: str, config_path: str) -> str:
|
||||
"""
|
||||
解析路径(支持相对路径和绝对路径)
|
||||
|
||||
Args:
|
||||
path: 待解析的路径
|
||||
config_path: 配置文件路径(用于相对路径的基准)
|
||||
|
||||
Returns:
|
||||
str: 解析后的绝对路径
|
||||
"""
|
||||
path_obj = Path(path)
|
||||
if path_obj.is_absolute():
|
||||
return str(path_obj)
|
||||
else:
|
||||
# 相对于配置文件所在目录
|
||||
config_dir = Path(config_path).parent
|
||||
return str(config_dir / path_obj)
|
||||
|
||||
def override_pointcloud_path(self, path: str):
|
||||
"""
|
||||
覆盖点云文件路径(用于命令行参数)
|
||||
|
||||
Args:
|
||||
path: 点云文件路径
|
||||
"""
|
||||
self.pointcloud_path_override = path
|
||||
|
||||
def override_output_dir(self, output_dir: str):
|
||||
"""
|
||||
覆盖输出目录(用于命令行参数)
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录路径
|
||||
"""
|
||||
self.output_dir_override = output_dir
|
||||
|
||||
def _create_task(self, task_config: Dict[str, Any]):
|
||||
"""
|
||||
根据配置创建测量任务
|
||||
|
||||
Args:
|
||||
task_config: 任务配置字典
|
||||
|
||||
Returns:
|
||||
BaseMeasurement: 测量任务实例
|
||||
"""
|
||||
task_type = task_config.get('type')
|
||||
task_name = task_config.get('name', 'Unnamed Task')
|
||||
|
||||
if task_type == 'plane_to_plane':
|
||||
return PlaneToPlaneDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
)
|
||||
elif task_type == 'point_to_plane':
|
||||
return PointToPlaneDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
)
|
||||
elif task_type == 'point_to_point':
|
||||
return PointToPointDistance(
|
||||
name=task_name,
|
||||
point_cloud=self.point_cloud,
|
||||
config=task_config
|
||||
)
|
||||
else:
|
||||
print(f"警告: 未知的任务类型: {task_type}")
|
||||
return None
|
||||
|
||||
def run_all_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
执行所有测量任务
|
||||
|
||||
Returns:
|
||||
List[Dict]: 任务结果列表
|
||||
"""
|
||||
self.results = []
|
||||
|
||||
for i, task in enumerate(self.tasks, 1):
|
||||
print(f"\n[{i}/{len(self.tasks)}] 执行任务: {task.name}")
|
||||
print("-" * 60)
|
||||
|
||||
# 执行任务
|
||||
result = task.execute()
|
||||
|
||||
# 记录结果
|
||||
result_dict = {
|
||||
'name': result.task_name,
|
||||
'type': result.task_type,
|
||||
'status': 'success' if result.success else 'failed',
|
||||
'data': result.data if result.success else None,
|
||||
'error': result.error_message if not result.success else None
|
||||
}
|
||||
self.results.append(result_dict)
|
||||
|
||||
# 打印结果
|
||||
if result.success:
|
||||
print(f"[成功] 任务完成")
|
||||
if result.data:
|
||||
print(f" 平均距离: {result.data.get('mean_distance', 0):.2f} mm")
|
||||
print(f" 标准差: {result.data.get('std_distance', 0):.2f} mm")
|
||||
print(f" 范围: [{result.data.get('min_distance', 0):.2f}, {result.data.get('max_distance', 0):.2f}] mm")
|
||||
print(f" 测量点数: {result.data.get('point_count', result.data.get('sample_count', 0))}")
|
||||
if 'source_inlier_ratio' in result.data:
|
||||
print(f" 源平面内点率: {result.data.get('source_inlier_ratio', 0):.1%}")
|
||||
if 'target_inlier_ratio' in result.data:
|
||||
print(f" 目标平面内点率: {result.data.get('target_inlier_ratio', 0):.1%}")
|
||||
if 'plane_inlier_ratio' in result.data:
|
||||
print(f" 平面内点率: {result.data.get('plane_inlier_ratio', 0):.1%}")
|
||||
else:
|
||||
print(f"[失败] {result.error_message}")
|
||||
|
||||
# 可视化(如果配置中启用)
|
||||
if result.success and task.config.get('visualization', False):
|
||||
self._visualize_result(task, result)
|
||||
|
||||
# 导出Excel结果
|
||||
self._export_results()
|
||||
|
||||
return self.results
|
||||
|
||||
def _visualize_result(self, task, result: MeasurementResult):
|
||||
"""
|
||||
可视化测量结果
|
||||
|
||||
Args:
|
||||
task: 测量任务
|
||||
result: 测量结果
|
||||
"""
|
||||
try:
|
||||
import open3d as o3d
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
print(" 生成可视化...")
|
||||
|
||||
data = result.data
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 创建可视化窗口
|
||||
vis = o3d.visualization.Visualizer()
|
||||
vis.create_window(window_name=f"测量结果: {task.name}", width=1200, height=800)
|
||||
|
||||
# 显示源平面点云(蓝色)
|
||||
source_pcd = o3d.geometry.PointCloud()
|
||||
source_pcd.points = o3d.utility.Vector3dVector(data['_source_points'])
|
||||
source_pcd.paint_uniform_color([0.2, 0.4, 0.8])
|
||||
vis.add_geometry(source_pcd)
|
||||
|
||||
# 显示目标平面点云(绿色)
|
||||
target_pcd = o3d.geometry.PointCloud()
|
||||
target_pcd.points = o3d.utility.Vector3dVector(data['_target_points'])
|
||||
target_pcd.paint_uniform_color([0.2, 0.8, 0.4])
|
||||
vis.add_geometry(target_pcd)
|
||||
|
||||
# 显示采样点(红色)
|
||||
sampled_pcd = o3d.geometry.PointCloud()
|
||||
sampled_pcd.points = o3d.utility.Vector3dVector(data['_sampled_points'])
|
||||
sampled_pcd.paint_uniform_color([0.9, 0.2, 0.2])
|
||||
vis.add_geometry(sampled_pcd)
|
||||
|
||||
# 运行可视化
|
||||
vis.run()
|
||||
vis.destroy_window()
|
||||
|
||||
except Exception as e:
|
||||
print(f" 可视化失败: {e}")
|
||||
|
||||
def _export_results(self):
|
||||
"""导出结果到Excel"""
|
||||
try:
|
||||
# 转换为MeasurementResult对象列表
|
||||
measurement_results = []
|
||||
for result_dict in self.results:
|
||||
measurement_results.append(MeasurementResult(
|
||||
task_name=result_dict['name'],
|
||||
task_type=result_dict['type'],
|
||||
success=result_dict['status'] == 'success',
|
||||
data=result_dict.get('data'),
|
||||
error_message=result_dict.get('error')
|
||||
))
|
||||
|
||||
# 导出Excel(支持命令行覆盖输出目录)
|
||||
if self.output_dir_override:
|
||||
output_dir = Path(self.output_dir_override)
|
||||
else:
|
||||
output_dir = Path(__file__).parent.parent / "results"
|
||||
|
||||
exporter = ExcelExporter(str(output_dir))
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"measurement_results_{timestamp}"
|
||||
|
||||
# 传递任务配置信息到导出器
|
||||
excel_path = exporter.export(measurement_results, filename, self.config.get('tasks', []))
|
||||
print(f"\n结果已导出到: {excel_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n导出Excel失败: {e}")
|
||||
195
alg/点云测距v1.1/train_measurement_system/src/visualizer.py
Normal file
195
alg/点云测距v1.1/train_measurement_system/src/visualizer.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""可视化工具 - 用于显示点云、平面、ROI等"""
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
|
||||
class Visualizer:
|
||||
"""点云和测量结果可视化工具"""
|
||||
|
||||
@staticmethod
|
||||
def visualize_roi_in_context(
|
||||
full_cloud: np.ndarray,
|
||||
roi_cloud: np.ndarray,
|
||||
bbox_min: List[float],
|
||||
bbox_max: List[float],
|
||||
title: str = "ROI in Context",
|
||||
downsample_full: int = 10,
|
||||
save_path: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
在完整点云上下文中可视化ROI区域
|
||||
|
||||
Args:
|
||||
full_cloud: 完整点云 (N, 3)
|
||||
roi_cloud: ROI区域点云 (M, 3)
|
||||
bbox_min: 包围盒最小坐标 [x, y, z]
|
||||
bbox_max: 包围盒最大坐标 [x, y, z]
|
||||
title: 窗口标题
|
||||
downsample_full: 完整点云降采样倍数(每N个点取1个)
|
||||
save_path: 保存截图路径(可选)
|
||||
"""
|
||||
# 降采样完整点云以提高显示性能
|
||||
full_cloud_downsampled = full_cloud[::downsample_full]
|
||||
|
||||
# 创建完整点云对象(灰色)
|
||||
pcd_full = o3d.geometry.PointCloud()
|
||||
pcd_full.points = o3d.utility.Vector3dVector(full_cloud_downsampled)
|
||||
pcd_full.paint_uniform_color([0.7, 0.7, 0.7]) # 灰色
|
||||
|
||||
# 创建ROI点云对象(红色)
|
||||
pcd_roi = o3d.geometry.PointCloud()
|
||||
pcd_roi.points = o3d.utility.Vector3dVector(roi_cloud)
|
||||
pcd_roi.paint_uniform_color([1.0, 0.0, 0.0]) # 红色
|
||||
|
||||
# 创建包围盒线框
|
||||
bbox = Visualizer._create_bbox_lineset(bbox_min, bbox_max, color=[0, 1, 0])
|
||||
|
||||
# 可视化
|
||||
geometries = [pcd_full, pcd_roi, bbox]
|
||||
o3d.visualization.draw_geometries(
|
||||
geometries,
|
||||
window_name=title,
|
||||
width=1200,
|
||||
height=800,
|
||||
point_show_normal=False
|
||||
)
|
||||
|
||||
# 保存截图(如果指定)
|
||||
if save_path:
|
||||
vis = o3d.visualization.Visualizer()
|
||||
vis.create_window(window_name=title, width=1200, height=800, visible=False)
|
||||
for geom in geometries:
|
||||
vis.add_geometry(geom)
|
||||
vis.update_renderer()
|
||||
vis.capture_screen_image(save_path)
|
||||
vis.destroy_window()
|
||||
print(f"截图已保存: {save_path}")
|
||||
|
||||
@staticmethod
|
||||
def visualize_plane_measurement(
|
||||
source_cloud: np.ndarray,
|
||||
target_cloud: np.ndarray,
|
||||
source_plane_normal: np.ndarray,
|
||||
source_plane_point: np.ndarray,
|
||||
target_plane_normal: np.ndarray,
|
||||
target_plane_point: np.ndarray,
|
||||
sample_points: np.ndarray,
|
||||
distances: np.ndarray,
|
||||
title: str = "Plane Measurement",
|
||||
save_path: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
可视化平面到平面的距离测量
|
||||
|
||||
Args:
|
||||
source_cloud: 源平面点云
|
||||
target_cloud: 目标平面点云
|
||||
source_plane_normal: 源平面法向量
|
||||
source_plane_point: 源平面上的点
|
||||
target_plane_normal: 目标平面法向量
|
||||
target_plane_point: 目标平面上的点
|
||||
sample_points: 采样点
|
||||
distances: 每个采样点到目标平面的距离
|
||||
title: 窗口标题
|
||||
save_path: 保存截图路径
|
||||
"""
|
||||
# 创建源平面点云(蓝色)
|
||||
pcd_source = o3d.geometry.PointCloud()
|
||||
pcd_source.points = o3d.utility.Vector3dVector(source_cloud)
|
||||
pcd_source.paint_uniform_color([0.0, 0.5, 1.0]) # 蓝色
|
||||
|
||||
# 创建目标平面点云(绿色)
|
||||
pcd_target = o3d.geometry.PointCloud()
|
||||
pcd_target.points = o3d.utility.Vector3dVector(target_cloud)
|
||||
pcd_target.paint_uniform_color([0.0, 1.0, 0.0]) # 绿色
|
||||
|
||||
# 创建采样点(红色)
|
||||
pcd_samples = o3d.geometry.PointCloud()
|
||||
pcd_samples.points = o3d.utility.Vector3dVector(sample_points)
|
||||
pcd_samples.paint_uniform_color([1.0, 0.0, 0.0]) # 红色
|
||||
|
||||
# 创建距离线(从采样点到目标平面的投影)
|
||||
lines = []
|
||||
for i, (point, dist) in enumerate(zip(sample_points, distances)):
|
||||
# 计算投影点
|
||||
projection = point - dist * target_plane_normal
|
||||
lines.append([point, projection])
|
||||
|
||||
# 创建线集
|
||||
line_points = []
|
||||
line_indices = []
|
||||
for i, (start, end) in enumerate(lines):
|
||||
line_points.append(start)
|
||||
line_points.append(end)
|
||||
line_indices.append([2*i, 2*i+1])
|
||||
|
||||
lineset = o3d.geometry.LineSet()
|
||||
lineset.points = o3d.utility.Vector3dVector(line_points)
|
||||
lineset.lines = o3d.utility.Vector2iVector(line_indices)
|
||||
lineset.paint_uniform_color([1.0, 0.5, 0.0]) # 橙色
|
||||
|
||||
# 可视化
|
||||
geometries = [pcd_source, pcd_target, pcd_samples, lineset]
|
||||
o3d.visualization.draw_geometries(
|
||||
geometries,
|
||||
window_name=title,
|
||||
width=1200,
|
||||
height=800,
|
||||
point_show_normal=False
|
||||
)
|
||||
|
||||
if save_path:
|
||||
vis = o3d.visualization.Visualizer()
|
||||
vis.create_window(window_name=title, width=1200, height=800, visible=False)
|
||||
for geom in geometries:
|
||||
vis.add_geometry(geom)
|
||||
vis.update_renderer()
|
||||
vis.capture_screen_image(save_path)
|
||||
vis.destroy_window()
|
||||
print(f"截图已保存: {save_path}")
|
||||
|
||||
@staticmethod
|
||||
def _create_bbox_lineset(
|
||||
bbox_min: List[float],
|
||||
bbox_max: List[float],
|
||||
color: List[float] = [0, 1, 0]
|
||||
) -> o3d.geometry.LineSet:
|
||||
"""创建包围盒线框"""
|
||||
# 8个顶点
|
||||
points = [
|
||||
[bbox_min[0], bbox_min[1], bbox_min[2]], # 0
|
||||
[bbox_max[0], bbox_min[1], bbox_min[2]], # 1
|
||||
[bbox_max[0], bbox_max[1], bbox_min[2]], # 2
|
||||
[bbox_min[0], bbox_max[1], bbox_min[2]], # 3
|
||||
[bbox_min[0], bbox_min[1], bbox_max[2]], # 4
|
||||
[bbox_max[0], bbox_min[1], bbox_max[2]], # 5
|
||||
[bbox_max[0], bbox_max[1], bbox_max[2]], # 6
|
||||
[bbox_min[0], bbox_max[1], bbox_max[2]], # 7
|
||||
]
|
||||
|
||||
# 12条边
|
||||
lines = [
|
||||
[0, 1], [1, 2], [2, 3], [3, 0], # 底面
|
||||
[4, 5], [5, 6], [6, 7], [7, 4], # 顶面
|
||||
[0, 4], [1, 5], [2, 6], [3, 7], # 竖边
|
||||
]
|
||||
|
||||
lineset = o3d.geometry.LineSet()
|
||||
lineset.points = o3d.utility.Vector3dVector(points)
|
||||
lineset.lines = o3d.utility.Vector2iVector(lines)
|
||||
lineset.paint_uniform_color(color)
|
||||
|
||||
return lineset
|
||||
|
||||
@staticmethod
|
||||
def save_roi_pointcloud(
|
||||
roi_cloud: np.ndarray,
|
||||
save_path: str
|
||||
):
|
||||
"""保存ROI点云为PLY文件"""
|
||||
pcd = o3d.geometry.PointCloud()
|
||||
pcd.points = o3d.utility.Vector3dVector(roi_cloud)
|
||||
o3d.io.write_point_cloud(save_path, pcd)
|
||||
print(f"ROI点云已保存: {save_path}")
|
||||
Reference in New Issue
Block a user