feat: initial commit - massive-cloud point cloud processing system

This commit is contained in:
2026-05-27 09:51:42 +00:00
commit 16811100d1
391 changed files with 78338 additions and 0 deletions

View File

@@ -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 *)"
]
}
}

View 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

View 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拟合
- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务

View 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 提取是否准确

View 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

View 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())

View 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"]

View 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

View 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()

View 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",
]

View 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')

View 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

View File

@@ -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']

View File

@@ -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)

View File

@@ -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()}"
)

View File

@@ -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

View File

@@ -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()}"
)

View 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

View 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)

View 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]

View 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}")

View 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}")