feat: 算法升级、整车配准集成与性能分析文档

算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
This commit is contained in:
2026-06-25 08:22:20 +00:00
parent cec0766e67
commit 29f1d45d66
266 changed files with 1311475 additions and 2846 deletions

View File

@@ -0,0 +1,460 @@
# 测量工具
这是一个面向工业零件点云、网格和 CAD STEP 模型的几何测量工具。项目支持 CAD 转 STL、点云加噪/滤波、点云配准、点云到 Mesh/CAD 的距离评估、点云到 CAD 的仅平移对齐、曲率估计和平面拟合。
正式代码位于项目根目录,辅助脚本放在 `archive/`,示例数据放在 `datasets/`,打包工具放在 `packaging/`。推荐在项目根目录执行命令。
面向开发人员的完整使用说明见 [USAGE.md](USAGE.md)。
## 1. 项目结构
```text
.
main.py # 命令行入口
pipeline.py # MeasurementPipeline,总流程入口类
io_handler.py # 输入检查、点云/Mesh/CAD 读取
config_loader.py # YAML 配置加载
config.yaml # 默认配置
cad_converter.py # STEP -> STL
registration.py # RANSAC + ICP 配准
evaluation.py # Chamfer、点云到 Mesh 距离、统计指标
visualization.py # Open3D 可视化
preprocessing/ # 加噪、滤波、曲率、平面拟合
datasets/ # 示例 CAD、Mesh、PointCloud、Noise_PointCloud
vis/ # 建议保存命令日志、可视化点云和中间结果
results/ # translate-align 默认输出目录
archive/ # 辅助脚本
packaging/ # pip 打包文件夹
```
## 2. 环境安装
建议使用独立的 Python 3.10 环境。当前项目建议使用 `High-Speed` 环境运行,不要直接安装到 conda 的 `base` 环境。`pythonocc-core` 需要通过 conda 安装,`requirements.txt` 只管理 pip 依赖。
```bash
conda env create -n High-Speed -f environment.yml
conda activate High-Speed
pip install -r requirements.txt
pip install git+https://github.com/AutodeskAILab/occwl
```
如果已经创建过环境,需要补装或更新 CAD 转 Mesh 依赖:
```bash
conda env update -n High-Speed -f environment.yml
conda activate High-Speed
pip install git+https://github.com/AutodeskAILab/occwl
```
如果当前终端显示 `(base)`,并且 base 环境是 Python 3.12,直接安装 `pythonocc-core=7.5.1` 会失败;该版本只支持 Python 3.7 到 3.10。
验证环境:
```bash
python -c "import open3d, trimesh, torch, yaml, OCC.Core.STEPControl; print('env ok')"
```
如果日志里出现 CUDA driver 版本过旧的 warning,程序会退回 CPU 计算。当前配准流程本身主要使用 Open3D CPU 管线;点云到 Mesh/CAD 的距离计算使用 torch,理论上可用 GPU,但取决于 `torch.cuda.is_available()`。
## 3. 数据要求
默认数据目录结构如下:
```text
datasets/
CAD/ # STEP CAD 文件,示例:11.step、40.step
Mesh/ # STL/OBJ/PLY Mesh 文件,示例:11.stl、40.stl
PointCloud/ # 原始/干净点云 PLY,示例:11.ply、40.ply
Noise_PointCloud/ # 加噪或待配准点云 PLY,示例:11_noisy.ply、40_noisy.ply
shift_test_output/ # translate-align 测试点云目录,默认读取 shifted_whole_pointcloud.ply
```
当前批量评估示例数据包含编号 `11`、`13`、`17`、`20`、`22`、`23`、`25`、`26`、`27`、`31`、`34`、`35`、`36`、`37`、`38`、`39`、`40`、`41`、`42`、`45`,同一编号通常应同时存在:
```text
datasets/CAD/11.step
datasets/Mesh/11.stl
datasets/PointCloud/11.ply
datasets/Noise_PointCloud/11_noisy.ply
```
同一任务可以包含多个样本,样本名使用下划线后缀区分;例如 `13_1.stl` 和 `13_2.stl` 都归属任务 `13`,并分别匹配 `13_1_noisy.ply`、`13_2_noisy.ply`。
`translate-align` 默认使用:
```text
datasets/CAD/M02.stp #标准CAD
datasets/shift_test_output/shifted_whole_pointcloud.ply #实际采集点云
```
输入格式要求:
- CAD:推荐 STEP,扩展名通常为 `.step` 或 `.stp`。用于 `cad2mesh` 和 `contour`。
- Mesh:支持 `trimesh` 能读取的格式,常用 `.stl`、`.obj`、`.ply`。Mesh 必须包含顶点和三角面。
- PointCloud:推荐 ASCII 或 binary `.ply`,必须包含三维点坐标。曲率、配准、距离评估都需要非空点云。
- 路径:可以使用相对路径或绝对路径;相对路径按当前运行目录解析,建议在项目根目录执行。
- 单位:示例数据按毫米量级处理。CAD、Mesh 和点云必须使用同一坐标系和同一单位,否则距离、配准结果没有可比性。
- 对齐关系:`contour` 假设点云已经与 Mesh/CAD 在同一坐标系下;`registration` 会模拟旋转后做配准。
数据规模注意:
- 百万级点云可以运行,但 `registration` 和 `contour` 会比较慢。
- `evaluation.chunk_size` 控制点云分块大小。
- `evaluation.triangle_chunk_size` 控制 Mesh/CAD 三角面分块大小。内存或显存不足时调小,例如 `1024` 或 `2048`。
- `evaluation.cad_mesh_deflection` 控制 STEP 三角化精度。越小越精细,但三角面更多、计算更慢。
## 4. 输出说明
建议将通用实验结果放到 `vis/`,Excel 结果放到 `excel/`,`translate-align` 默认写入 `results/`:
```bash
mkdir -p vis excel results
```
常见输出包括:
- `.log`:命令行指标输出。
- `.time`:命令耗时记录。
- `.stl`:CAD 转 Mesh 结果。
- `.ply`:带颜色的点云结果,例如曲率点云或 CAD 距离热力图。
- `.xlsx`:`contour --excel-output` 生成的 Excel,默认保存到 `excel/contour.xlsx`;默认按 `excel/测量结果.xlsx` 模板迁移对应任务行,并写入本次测量均值和测量结论。
- `results/aligned_pointcloud_translation_only.ply`:`translate-align` 默认输出的对齐后点云。
- `results/T_translation_only.txt`:`translate-align` 默认输出的 4x4 平移矩阵。
本项目不会自动把所有命令结果写入 `vis/`;如果需要保留日志,可以用 shell 重定向:
```bash
python main.py contour --pointcloud datasets/PointCloud/11.ply --cad datasets/CAD/11.step --no-visualize > vis/contour.log 2>&1
```
## 5. 方法输入输出总览
| 方法 | 功能 | 输入 | 输出 |
|------|------|------|------|
| `cad2mesh` | 将 STEP CAD 转成 STL 三角网格 | `--cad`:STEP 文件 | `--mesh`:STL 文件 |
| `denoise` | 给干净点云加噪,并运行配置中的滤波方法 | `--pointcloud`:干净 PLY 点云;`--noise-std`:噪声标准差 | 终端输出点数、平均噪声偏移、滤波保留比例、Chamfer Distance;当前不自动保存滤波点云 |
| `registration` | 模拟旋转后进行点云到 Mesh 的配准,并评估配准后距离 | `--pointcloud`:待配准 PLY 点云;`--mesh`:参考 STL/OBJ/PLY Mesh | 终端输出 fitness、RMSE、变换矩阵、配准后点云到 Mesh 的距离统计 |
| `contour` | 轮廓/平整度/平面度评估,计算点云到 STEP CAD 三角面的距离 | 单任务:`--pointcloud` 和 `--cad`;批量:`--tasks` 或 `--all`,并指定 `--data-root`、`--pointcloud-kind` | 终端输出 Mean、Max、RMSE、Min、Std;可用 `--save` 保存热力图 PLY;可用 `--excel-output` 保存 Excel |
| `curvature` | 估计点云局部曲率并着色 | `--pointcloud`:PLY 点云;`--radius`:邻域半径;`--max-nn`:最大邻居数 | 终端输出曲率 mean/max;可用 `--save` 保存彩色曲率 PLY |
| `plane` | 使用 RANSAC 拟合平面 | 可选 `--pointcloud`:PLY 点云;不传则生成模拟带噪平面 | 终端输出平面模型、内点比例、法向角度误差、RMSE;可视化拟合结果 |
| `full` | 按配置串联运行加噪/滤波、配准和距离评估 | `--config`:YAML 配置文件;默认使用 `config.yaml` | 返回完整流程结果;终端输出各阶段指标;默认不自动执行 CAD 转 Mesh |
`contour --excel-output` 生成的 Excel 默认读取 `excel/测量结果.xlsx` 作为模板,按 `任务号` 迁移对应行,新表头保持模板一致:`任务号、项点名称、公差要求、理论尺寸、测量结果、误差结果、测量结论`。脚本只写入 `测量结果` 和 `测量结论`:测量结果填本次测量均值,测量成功填 `成功`,否则填 `失败`。`--excel-output` 不带值时保存为 `excel/contour.xlsx`;只传文件名时会自动保存到 `excel/` 文件夹。可用 `--result-template` 指定其它模板。
## 6. 快速使用
查看所有命令:
```bash
python main.py --help
```
查看某个命令参数:
```bash
python main.py registration --help
```
## 7. 常用命令
轮廓/平整度/平面度评估(全部数据):
```bash
python main.py contour --all --data-root datasets --pointcloud-kind noise --save vis/contour --excel-output all_contour.xlsx --no-visualize
```
批量模式会优先从 `datasets/Mesh/{样本名}.stl` 读取 Mesh,并按样本名匹配点云。`--tasks 13` 会匹配 `13_1.stl`、`13_2.stl` 等同属任务 13 的样本;`--tasks 13_1` 只运行该样本。`--pointcloud-kind noise` 读取 `datasets/Noise_PointCloud/{样本名}_noisy.ply`;`--pointcloud-kind clean` 读取 `datasets/PointCloud/{样本名}.ply`。如果没有找到 Mesh 样本,则退回 `datasets/CAD/{任务号}.step`。批量模式下 `--save vis/contour` 会按样本名保存为 `vis/contour/{样本名}_contour.ply`,并按原始坐标额外保存合并点云 `vis/contour/combined_contour.ply`;未传 `--save` 时合并点云默认保存到 `vis/combined_contour.ply`。合并点云默认会加入完整点云 `datasets/train/train_2_10M.ply`,可用 `--combined-context-pointcloud datasets/train/train_2_100M.ply` 或 `datasets/train/train_2_1T_配准.ply` 切换;保存合并 PLY 时会按块流式读取大背景点云,不再整体加载进内存。Excel 会按任务号把模板中的对应行迁移到新表;批量模式会在终端最后打印总执行时间 `Contour batch total execution time`,该时间包含任务计算、Excel 保存和合并 PLY 保存。未传 `--no-visualize` 时,所有成功热力图会按原始坐标合并到一个 Open3D 窗口中展示。使用 `train_2_1T_配准.ply` 时必须加 `--no-visualize`,只保存合并结果,不打开窗口。
使用 `datasets/train/train_2_1T_配准.ply` 作为完整背景点云保存合并结果:
注意:使用时,利用combined-context-pointcloud指定配准后的点云路径
```bash
conda activate High-Speed
python main.py contour --all --data-root datasets --pointcloud-kind noise --save vis/contour --excel-output all_contour.xlsx --combined-context-pointcloud datasets/train/train_2_1T_配准.ply --no-visualize
```
CAD 转 Mesh:
```bash
python main.py cad2mesh --cad datasets/CAD/11.step --mesh vis/11_cad2mesh.stl --precision 1.0
```
加噪与去噪:
```bash
python main.py denoise --pointcloud datasets/PointCloud/11.ply --noise-std 4 --no-visualize
```
点云配准与 Mesh 距离评估:
```bash
python main.py registration --pointcloud datasets/Noise_PointCloud/11_noisy.ply --mesh datasets/Mesh/11.stl --no-visualize
```
轮廓/平整度/平面度评估(快速查看统计结果):
```bash
python main.py contour --pointcloud datasets/PointCloud/11.ply --cad datasets/CAD/11.step --no-visualize
```
轮廓/平整度/平面度评估(保存热力图和 Excel):
```bash
python main.py contour --cad datasets/CAD/11.step --pointcloud datasets/Noise_PointCloud/11_noisy.ply --save vis/11_contour.ply --excel-output --no-visualize
```
`contour` 是轮廓/平整度/平面度评估的统一入口:不加导出参数时适合快速在命令行查看统计结果;添加 `--save` 时会保存距离热力图 PLY;添加 `--excel-output` 时会把统计结果保存到 `excel/contour.xlsx`。也可以写成 `--excel-output 11_contour.xlsx`,结果会保存到 `excel/11_contour.xlsx`。Excel 默认使用 `excel/测量结果.xlsx` 模板,可通过 `--result-template` 替换。
轮廓/平整度/平面度评估(指定多个数据):
```bash
python main.py contour --tasks 13,31,40 --data-root datasets --pointcloud-kind noise --save vis/contour --excel-output selected_contour.xlsx --no-visualize
```
曲率估计:
```bash
python main.py curvature --pointcloud datasets/PointCloud/11.ply --radius 2 --max-nn 30 --save vis/11_curvature.ply --no-visualize
```
平面拟合:
```bash
python main.py plane --no-visualize
```
完整流程:
```bash
python main.py --config config.yaml full --no-visualize
```
`full` 会按配置串联加噪/滤波、配准和距离评估。默认 `cad.enabled: false`,不会自动执行 CAD 转 Mesh。
## 8. 打包使用
生成独立 Python 包源码:
```bash
python packaging/build_package.py
```
本地开发安装:
```bash
pip install -e packaging/build
geometry-measure --help
```
构建 wheel:
```bash
pip install build
python packaging/build_package.py
python -m build packaging/build
```
打包说明见 [packaging/README.md](packaging/README.md)。
## 9. 输入不同数据
方式一:命令行覆盖路径。
点云配准:
```bash
python main.py registration \
--pointcloud datasets/Noise_PointCloud/40_noisy.ply \
--mesh datasets/Mesh/40.stl \
--no-visualize
```
单个数据快速评估:
```bash
python main.py contour \
--pointcloud datasets/PointCloud/40.ply \
--cad datasets/CAD/40.step \
--no-visualize
```
单个数据保存热力图和 Excel:
```bash
python main.py contour \
--cad datasets/CAD/40.step \
--pointcloud datasets/Noise_PointCloud/40_noisy.ply \
--save vis/40_contour.ply \
--excel-output 40_contour.xlsx \
--no-visualize
```
指定多个数据批量评估:
```bash
python main.py contour \
--tasks 13,31,40 \
--data-root datasets \
--pointcloud-kind noise \
--save vis/contour \
--excel-output selected_contour.xlsx \
--no-visualize
```
全部数据批量评估:
```bash
python main.py contour \
--all \
--data-root datasets \
--pointcloud-kind noise \
--save vis/contour \
--excel-output all_contour.xlsx \
--no-visualize
```
方式二:修改 `config.yaml`。
```yaml
paths:
cad: datasets/CAD/40.step
mesh: datasets/Mesh/40.stl
pointcloud: datasets/PointCloud/40.ply
noisy_pointcloud: datasets/Noise_PointCloud/40_noisy.ply
output_mesh: vis/40_cad2mesh.stl
curvature_output: vis/40_curvature.ply
```
然后运行:
```bash
python main.py --config config.yaml full --no-visualize
```
## 10. 参数配置
常用参数都在 `config.yaml`:
- `noise.std`:高斯噪声标准差。
- `filters.methods`:启用哪些滤波器。
- `registration.voxel_size`:配准下采样体素大小。
- `registration.roll/pitch/yaw`:模拟旋转角度。
- `registration.trans_range`:模拟平移范围。
- `evaluation.chunk_size`:点云到 Mesh 距离分块大小。
- `evaluation.triangle_chunk_size`:距离计算时三角面分块大小,显存/内存不足时调小。
- `evaluation.cad_mesh_deflection`:STEP CAD 三角化精度,越小越精细但越慢。
- `visualization.enabled`:是否默认开启可视化。
命令行参数优先级高于配置文件。
## 11. 命令说明
### 11.1 `translate-align`
- 用途:将 `archive/translate_align_pointcloud_to_cad.py` 接入统一入口,按车头/车尾局部点的质心差估计整体平移,只修正平移,不修正旋转。
- 默认输入:`--cad datasets/CAD/M02.stp --pointcloud datasets/shift_test_output/`。当 `--pointcloud` 传目录时,会优先读取目录下的 `shifted_whole_pointcloud.ply`。
- 默认输出目录:`results/`。
- 常用调参:`--axis` 指定车辆长度方向;`--crop-ratio` 控制头尾截取比例;`--cad-sample-points` 控制从 CAD mesh 采样的点数;`--local-sample-points` 控制头尾局部用于算质心的采样点数;`--scale-pointcloud` 用于 CAD 与点云单位不一致时缩放点云。
- 输出文件:
```text
results/aligned_pointcloud_translation_only.ply # 最终配准后的完整点云
results/T_translation_only.txt # 4x4 平移矩阵,可复用到其它点云或评估流程
results/cad_sampled.ply # 从 CAD mesh 采样得到的 CAD 点云
results/cad_front_sample.ply # CAD 车头区域采样点,用于检查质心计算
results/cad_tail_sample.ply # CAD 车尾区域采样点,用于检查质心计算
results/source_front_sample.ply # 输入点云车头区域采样点,用于检查质心计算
results/source_tail_sample.ply # 输入点云车尾区域采样点,用于检查质心计算
```
- 终端输出:前端/后端质心、前端平移量 `t_front`、后端平移量 `t_tail`、二者差值、最终融合平移 `t_final` 和最终矩阵 `T_final`。如果前后端平移差值较大,通常说明点云和 CAD 存在旋转误差、裁剪区域不合适或单位不一致。
- 示例:
```bash
python main.py translate-align --cad datasets/CAD/M02.stp --pointcloud datasets/shift_test_output/ --output-dir results --axis x --crop-ratio 0.01 --no-visualize
```
也可以直接运行辅助脚本:
```bash
python archive/translate_align_pointcloud_to_cad.py -c datasets/CAD/M02.stp -p datasets/shift_test_output/ -o results --no_visualize
```
### 11.2 `cad2mesh`
- 输入:STEP 文件。
- 输出:STL 文件。
- 依赖:`pythonocc-core`。
- 典型用途:将 CAD 转成后续可用于 Mesh 距离评估的三角网格。
### 11.3 `denoise`
- 输入:干净点云。
- 输出:终端指标;当前不会自动保存滤波后的点云。
- 指标:点数、平均噪声偏移、滤波保留比例、Chamfer Distance。
### 11.4 `registration`
- 输入:点云和参考 Mesh。
- 输出:配准 fitness、RMSE、变换矩阵、配准后点云到 Mesh 距离统计。
- 注意:百万级点云可能耗时较长;当前 Open3D 配准部分主要走 CPU。
### 11.5 `contour`
- 用途:轮廓/平整度/平面度评估的统一入口。
- 输入:单任务使用点云和 STEP CAD;批量任务使用 `--tasks` 或 `--all` 从 `--data-root` 中读取多组 CAD/点云;如果显式传 `--mesh`,会走点云到 Mesh 距离计算。
- 输出:命令行打印点云到 CAD 三角面的 Mean、Max、RMSE、Min、Std;可通过 `--save` 保存距离热力图 PLY,可通过 `--excel-output` 保存 Excel。
- 注意:这是全量点到三角面的距离计算,数据越大越慢;STEP 三角化精度由 `--mesh-deflection` 控制。
- 常用调参:`--mesh-deflection`、`--point-chunk-size`、`--triangle-chunk-size`。
- 批量参数:`--tasks 13,31,40` 执行指定任务,其中 `13` 会自动展开 `13_1`、`13_2` 等样本;`--tasks 13_1` 只执行单个样本;`--all` 优先执行 `--data-root/Mesh` 下全部样本,没有 Mesh 时退回 CAD;`--pointcloud-kind noise|clean` 选择加噪点云或干净点云。
- 批量热力图:`--save vis/contour` 会为每个任务生成一个 PLY,并保存合并点云 `vis/contour/combined_contour.ply`;也可以用 `--save vis/{task_id}_contour.ply` 自定义文件名模板,合并点云会保存为 `vis/combined_contour.ply`。未传 `--save` 时,合并点云默认保存为 `vis/combined_contour.ply`。合并点云默认包含完整点云 `datasets/train/train_2_10M.ply`,可用 `--combined-context-pointcloud` 切换为 `train_2_100M.ply` 或 `train_2_1T_配准.ply`;保存合并 PLY 时大背景点云会流式写入,输出文件会因为追加 RGB 颜色而比原始背景点云更大。
- 批量可视化:未传 `--no-visualize` 时,所有成功执行的热力图会按原始坐标合并到一个窗口展示,不会为了展示改变点云位置。使用 `train_2_1T_配准.ply` 时必须传 `--no-visualize`,不要打开 Open3D 窗口。
- Excel 输出:`--excel-output` 不带值时保存为 `excel/contour.xlsx`;只传文件名时自动保存到 `excel/` 文件夹。默认读取 `excel/测量结果.xlsx` 作为模板,按 `任务号` 迁移对应行,表头保持模板一致,只写入 `测量结果` 和 `测量结论`。可用 `--result-template` 指定其它模板。
### 11.6 `archive/Contour_Flatness.py`
- 默认批量模式:优先从 `Mesh/{样本名}.stl` 和 `Noise_PointCloud/{样本名}_noisy.ply` 读取样本;例如 `13_1.stl`、`13_2.stl` 都归属任务 `13`,Excel 会按任务号汇总。
- 单任务模式:加 `--single` 后使用 `--CAD/-c` 和 `--pointcloud/-p`,可加 `--visualize` 显示距离热力图;批量模式加 `--visualize` 会把所有成功热力图合并到一个窗口展示。使用 `datasets/train/train_2_1T_配准.ply` 作为 `--combined_context_pointcloud` 时不要加 `--visualize`。
- 距离计算:默认参考 `archive/test8.py`,先用点到三角形 AABB 的距离筛出每个点附近的候选三角面,再计算精确点到三角形距离;`--candidate_triangle_count 0` 可退回全量精确搜索。
- Excel 输出:默认读取 `excel/测量结果.xlsx` 作为模板,按 `任务号` 迁移对应行,新表头保持模板一致:`任务号、项点名称、公差要求、理论尺寸、测量结果、误差结果、测量结论`。脚本只写入 `测量结果` 和 `测量结论`:测量结果填本次测量均值,测量成功填 `成功`,否则填 `失败`。可用 `--result_template` 指定其它模板。
- 示例:
```bash
python archive/Contour_Flatness.py --tasks "13,31,40" --data_root datasets --pointcloud_kind noise --excel_output vis/flatness.xlsx --result_template excel/测量结果.xlsx --candidate_triangle_count 32
python archive/Contour_Flatness.py --single -c High_Speed_Data/CAD/11.step -p High_Speed_Data/Noise_PointCloud/11_noisy.ply --candidate_triangle_count 32 --visualize
```
### 11.7 `curvature`
- 输入:点云。
- 输出:曲率 mean/max;可通过 `--save` 保存彩色曲率点云。
### 11.8 `plane`
- 输入:可选点云;不传点云时会生成一块带噪平面用于测试。
- 输出:平面模型、内点比例、法向角度误差、RMSE。
## 12. 常见问题
`No module named OCC`:
说明 CAD 依赖没有装好。执行:
```bash
conda env update -n High-Speed -f environment.yml
```
`pythonocc-core` 无法通过 pip 安装:
这是正常的,`pythonocc-core` 应通过 conda 安装。不要把它放进 `requirements.txt`。
`pythonocc-core=7.5.1` 和 Python 3.12 冲突:
新建 Python 3.10 环境,不要在 base 里安装。
`registration` 或 `contour` 很慢:
这是因为示例点云超过百万点,且距离计算要比较大量三角面。可以先换小点云测试,或者调小点数、增大 `registration.voxel_size`、调小 `evaluation.chunk_size`/`triangle_chunk_size` 以降低内存压力。
CUDA warning:
如果 torch 提示 NVIDIA driver 太旧,距离计算会退回 CPU。功能不受影响,但速度会慢。

View File

@@ -0,0 +1,720 @@
# USAGE
本文档面向后续开发人员,说明本项目的运行环境、数据约定、命令输入输出、常用工作流和排错方法。
项目根目录下的 `main.py` 是统一命令行入口。除非特别说明,所有命令都建议在项目根目录执行。
```bash
python main.py --help
```
当前公开命令:
```text
translate-align
cad2mesh
denoise
registration
contour
curvature
plane
full
```
## 1. 环境准备
推荐使用独立 conda 环境。当前项目建议使用 `High-Speed` 环境运行;不要直接在 `base` 环境中安装,尤其不要在 Python 3.12 的 base 环境里安装 `pythonocc-core`。
```bash
conda env create -n High-Speed -f environment.yml
conda activate High-Speed
pip install -r requirements.txt
pip install git+https://github.com/AutodeskAILab/occwl
```
如果环境已经存在:
```bash
conda env update -n High-Speed -f environment.yml
conda activate High-Speed
pip install -r requirements.txt
pip install git+https://github.com/AutodeskAILab/occwl
```
验证依赖:
```bash
python -c "import open3d, trimesh, torch, yaml, OCC.Core.STEPControl; print('env ok')"
```
如果 `torch` 提示 CUDA driver 版本过旧,程序会退回 CPU。功能不受影响,但点云到三角面的距离计算会慢。
## 2. 数据目录约定
默认示例数据放在 `datasets/`:
```text
datasets/
CAD/ # STEP CAD,例如 11.step
Mesh/ # STL/OBJ/PLY Mesh,例如 11.stl
PointCloud/ # 原始/干净点云 PLY,例如 11.ply
Noise_PointCloud/ # 加噪或待配准点云 PLY,例如 11_noisy.ply
shift_test_output/ # translate-align 测试输出,默认读取 shifted_whole_pointcloud.ply
```
同一编号的数据通常应成组存在:
```text
datasets/CAD/11.step
datasets/Mesh/11.stl
datasets/PointCloud/11.ply
datasets/Noise_PointCloud/11_noisy.ply
```
`translate-align` 默认使用下面这组输入:
```text
datasets/CAD/M02.stp
datasets/shift_test_output/shifted_whole_pointcloud.ply
```
数据要求:
- CAD 使用 STEP,扩展名通常是 `.step` 或 `.stp`。
- Mesh 使用 `trimesh` 可读取的格式,例如 `.stl`、`.obj`、`.ply`。
- PointCloud 推荐 PLY,必须包含三维点坐标。
- CAD、Mesh、PointCloud 必须处于同一坐标系和同一单位。
- 示例数据按毫米量级处理。
- `contour` 假设点云已与 CAD/Mesh 对齐。
- `registration` 会模拟旋转后做配准。
## 3. 输出目录约定
建议保留以下输出目录:
```text
vis/ # 日志、热力图点云、曲率点云、中间结果
excel/ # contour 输出的 Excel 表
results/ # translate-align 默认输出目录
```
可以手动创建:
```bash
mkdir -p vis excel results
```
常见输出:
```text
*.stl # CAD 转 Mesh 结果
*.ply # 彩色点云、热力图点云
*.xlsx # 轮廓/平整度/平面度 Excel 汇总
*.log # shell 重定向保存的终端输出
results/T_translation_only.txt
results/aligned_pointcloud_translation_only.ply
```
## 4. 方法输入输出总览
| 方法 | 用途 | 输入 | 输出 |
|------|------|------|------|
| `translate-align` | 点云到标准CAD的配准 | 默认 `--cad datasets/CAD/M02.stp --pointcloud datasets/shift_test_output/`;可调头尾截取和采样参数 | 默认写入 `results/`,输出对齐后点云、4x4 平移矩阵和头尾采样检查文件 |
| `cad2mesh` | STEP CAD 转 STL Mesh | `--cad` STEP 文件 | `--mesh` STL 文件 |
| `denoise` | 给干净点云加噪,并运行滤波方法 | `--pointcloud` 干净 PLY;`--noise-std` 噪声标准差 | 终端输出点数、平均噪声偏移、滤波保留比例、Chamfer Distance |
| `registration` | 点云到 Mesh 配准与距离评估 | `--pointcloud` 待配准 PLY;`--mesh` 参考 Mesh | 终端输出 fitness、RMSE、变换矩阵、配准后距离统计 |
| `contour` | 轮廓/平整度/平面度评估 | 单任务:`--pointcloud` 和 `--cad`;批量:`--tasks` 或 `--all`,并指定 `--data-root`、`--pointcloud-kind` | 终端输出 Mean、Max、RMSE、Min、Std;可保存热力图 PLY 和 Excel |
| `curvature` | 点云曲率估计 | `--pointcloud` PLY;`--radius` 邻域半径;`--max-nn` 最大邻居数 | 终端输出曲率 mean/max;可保存彩色曲率 PLY |
| `plane` | RANSAC 平面拟合 | 可选 `--pointcloud` PLY;不传则生成模拟带噪平面 | 终端输出平面模型、内点比例、法向角度误差、RMSE |
| `full` | 配置驱动的完整流程 | `--config` YAML 配置;默认 `config.yaml` | 串联输出加噪/滤波、配准和距离评估结果 |
## 5. 命令详解
### 5.1 轮廓/平整度/平面度评估:`contour`
功能:统一的轮廓/平整度/平面度评估入口。默认读取 STEP CAD,三角化后计算点云到 CAD 三角面的最近距离。
输入:
- `--pointcloud`:待评估 PLY 点云。
- `--cad`:参考 STEP CAD。
- `--mesh`:可传参考 STL/OBJ/PLY Mesh。传了 `--mesh` 时会走点云到 Mesh 距离计算。
- `--tasks`:批量执行指定任务编号,用英文逗号分隔,例如 `11,31,40`。
- `--all`:批量执行 `--data-root` 下 `CAD/` 中的全部 STEP 数据。
- `--data-root`:批量数据根目录,默认 `datasets`。
- `--pointcloud-kind`:批量点云来源,`noise` 读取 `Noise_PointCloud/{id}_noisy.ply`,`clean` 读取 `PointCloud/{id}.ply`。
- `--mesh-deflection`:STEP 三角化精度,越小越精细,但三角面更多、计算更慢。
- `--point-chunk-size`:点云分块大小。
- `--triangle-chunk-size`:三角面分块大小。
- `--save`:保存距离热力图 PLY。
- `--excel-output`:保存 Excel。
- `--result-template`:Excel 参考模板,默认 `excel/测量结果.xlsx`。
- `--combined-context-pointcloud`:合并热力图时一起加入的完整点云,默认 `datasets/train/train_2_10M.ply`。可指定 `datasets/train/train_2_1T_配准.ply`;保存合并 PLY 时会按块流式写入,避免整体加载完整点云。
- `--no-visualize`:禁用 Open3D 窗口。使用 `train_2_1T_配准.ply` 时必须加该参数,否则窗口展示仍会尝试加载超大点云。
输出:
- 终端距离统计:Mean、Max、RMSE、Min、Std。
- 如果传 `--save`:保存彩色热力图 PLY。
- 如果传 `--excel-output`:保存 Excel。
- 批量模式:所有成功执行的热力图会按原始坐标额外保存成一个合并 PLY;保存阶段支持对 `datasets/train/train_2_1T_配准.ply` 进行流式合并。未传 `--no-visualize` 时也会按原始坐标合并到一个 Open3D 窗口展示,但超大背景点云不适合窗口展示。
- 批量模式会在终端最后打印总执行时间 `Contour batch total execution time`,该时间包含任务计算、Excel 保存和合并 PLY 保存。
Excel 格式:
```text
任务号 | 项点名称 | 公差要求 | 理论尺寸 | 测量结果 | 误差结果 | 测量结论
```
默认读取 `excel/测量结果.xlsx` 作为模板,按 `任务号` 迁移对应行到新 Excel。程序只填本次的 `测量结果` 和 `测量结论`:测量结果为本次测量均值;测量成功填 `成功`,否则填 `失败`。如果需要使用其它模板,传 `--result-template path/to/template.xlsx`。
任务号从 CAD 文件名提取。例如:
```text
datasets/CAD/11.step -> 11
```
Excel 路径规则:
- `--excel-output` 不带值:保存到 `excel/contour.xlsx`
- `--excel-output 11_contour.xlsx`:保存到 `excel/11_contour.xlsx`
- `--excel-output reports/11.xlsx`:保存到 `reports/11.xlsx`
- `--result-template` 不传:默认读取 `excel/测量结果.xlsx`
批量模式路径规则:
- CAD:`{data_root}/CAD/{任务号}.step`
- 加噪点云:`{data_root}/Noise_PointCloud/{任务号}_noisy.ply`
- 干净点云:`{data_root}/PointCloud/{任务号}.ply`
- 如果 `--save` 是目录,例如 `--save vis/contour`,每个任务会保存为 `vis/contour/{任务号}_contour.ply`
- 如果 `--save` 是带 `{task_id}` 的文件模板,例如 `--save vis/{task_id}_contour.ply`,会替换任务号
- 如果 `--save` 是普通 PLY 文件,例如 `--save vis/contour.ply`,批量模式会保存为 `vis/contour_{任务号}.ply`
- 合并点云保存规则:未传 `--save` 时保存为 `vis/combined_contour.ply`;`--save vis/contour` 时保存为 `vis/contour/combined_contour.ply`;`--save vis/{task_id}_contour.ply` 时保存为 `vis/combined_contour.ply`;`--save vis/contour.ply` 时保存为 `vis/contour_combined.ply`
- 合并点云默认会加入 `datasets/train/train_2_10M.ply`;可用 `--combined-context-pointcloud datasets/train/train_2_100M.ply` 或 `datasets/train/train_2_1T_配准.ply` 切换。保存合并 PLY 时,大背景点云按块从输入文件写入输出文件,不再用 Open3D 整体读入内存;输出文件会比原始背景点云更大,因为会额外写入 RGB 颜色。注意:由于 `train_2_100M.ply` 或 `train_2_1T_配准.ply` 太大,通常需要使用者自行导入或修改地址。
- 如果没有传 `--no-visualize`,批量模式会把所有成功热力图合并显示在一个窗口里。使用 `train_2_1T_配准.ply` 时不要打开窗口,必须保留 `--no-visualize`。
快速查看统计结果:
```bash
python main.py contour \
--pointcloud datasets/PointCloud/11.ply \
--cad datasets/CAD/11.step \
--no-visualize
```
保存热力图和 Excel:
```bash
python main.py contour \
--cad datasets/CAD/11.step \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--save vis/11_contour.ply \
--excel-output 11_contour.xlsx \
--no-visualize
```
指定多个任务:
```bash
python main.py contour \
--tasks 11,31,40 \
--data-root datasets \
--pointcloud-kind noise \
--save vis/contour \
--excel-output selected_contour.xlsx \
--no-visualize
```
执行全部任务:
```bash
conda activate High-Speed
python main.py contour \
--all \
--data-root datasets \
--pointcloud-kind noise \
--save vis/contour \
--excel-output all_contour.xlsx \
--combined-context-pointcloud datasets/train/train_2_1T_配准.ply \
--no-visualize
```
注意:使用时,利用combined-context-pointcloud指定配准后的点云路径
该模式只保存合并 PLY,不打开 Open3D 展示窗口。`train_2_1T_配准.ply` 会被流式读取,适合用于生成最终大文件;不要去掉 `--no-visualize`。
显存或内存不足时调小分块:
```bash
python main.py contour \
--cad datasets/CAD/11.step \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--mesh-deflection 0.5 \
--point-chunk-size 512 \
--triangle-chunk-size 1024 \
--excel-output \
--no-visualize
```
### 5.2 CAD 转 Mesh:`cad2mesh`
功能:读取 STEP CAD,离散成 STL 三角网格。
输入:
- `--cad`:输入 STEP 文件。
- `--mesh`:输出 STL 文件。
- `--precision`:离散精度,单位为 mm。值越小通常越精细,生成文件也可能越大。
输出:
- 一个 STL 文件。
- 终端输出 STEP 读取、网格化和 STL 写入进度。
示例:
```bash
python main.py cad2mesh \
--cad datasets/CAD/11.step \
--mesh vis/11_cad2mesh.stl \
--precision 1.0
```
### 5.3 加噪与滤波:`denoise`
功能:读取干净点云,加高斯噪声,并按配置运行滤波方法。
输入:
- `--pointcloud`:干净 PLY 点云。
- `--noise-std`:高斯噪声标准差。
- `--no-visualize`:禁用 Open3D 窗口。
输出:
- 终端输出干净点数、加噪点数、平均噪声偏移。
- 对每个滤波方法输出保留比例和 Chamfer Distance。
- 当前主流程不会自动保存滤波后的点云文件。
示例:
```bash
python main.py denoise \
--pointcloud datasets/PointCloud/11.ply \
--noise-std 4 \
--no-visualize
```
### 5.4 配准:`registration`
功能:对输入点云模拟旋转,然后通过 RANSAC + ICP 配准到 Mesh,并计算配准后距离。
输入:
- `--pointcloud`:待配准 PLY 点云,通常使用 `datasets/Noise_PointCloud/*_noisy.ply`。
- `--mesh`:参考 STL/OBJ/PLY Mesh。
- `--voxel-size`:配准下采样体素大小。
- `--roll`、`--pitch`、`--yaw`:模拟旋转角度,单位为 degree。
- `--trans-range`:随机平移范围。
- `--no-visualize`:禁用 Open3D 窗口。
输出:
- `Fitness`
- `RMSE`
- 4x4 transformation matrix
- 配准后点云到 Mesh 的距离统计:Mean、Max、RMSE、Min、Std
示例:
```bash
python main.py registration \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--mesh datasets/Mesh/11.stl \
--voxel-size 10 \
--roll 10 \
--pitch 10 \
--yaw 10 \
--no-visualize
```
### 5.5 曲率估计:`curvature`
功能:计算点云局部曲率,并按曲率着色。
输入:
- `--pointcloud`:PLY 点云。
- `--radius`:邻域搜索半径。
- `--max-nn`:最大邻居数。
- `--color-scale`:颜色缩放系数。
- `--save`:保存彩色曲率点云。
- `--no-visualize`:禁用 Open3D 窗口。
输出:
- 终端输出曲率 mean 和 max。
- 如果传 `--save`:保存彩色曲率 PLY。
示例:
```bash
python main.py curvature \
--pointcloud datasets/PointCloud/11.ply \
--radius 2 \
--max-nn 30 \
--save vis/11_curvature.ply \
--no-visualize
```
### 5.6 平面拟合:`plane`
功能:使用 RANSAC 拟合平面。
输入:
- `--pointcloud`:可选 PLY 点云。不传时会根据 `config.yaml` 生成模拟带噪平面。
- `--threshold-mm`:RANSAC 距离阈值,单位 mm。
- `--max-iterations`:RANSAC 最大迭代次数。
- `--no-visualize`:禁用 Open3D 窗口。
输出:
- 平面模型参数。
- 内点比例。
- 法向角度误差。
- 平面 RMSE。
示例:
```bash
python main.py plane --no-visualize
```
带输入点云:
```bash
python main.py plane \
--pointcloud datasets/PointCloud/11.ply \
--threshold-mm 0.5 \
--max-iterations 2000 \
--no-visualize
```
### 5.7 完整流程:`full`
功能:按 `config.yaml` 串联运行流程。
当前默认行为:
- 如果 `cad.enabled: false`,不会自动执行 CAD 转 Mesh。
- 如果 `noise.enabled: true`,会先运行 `denoise`,再对加噪点云做配准。
- 最后运行 `contour`。
输入:
- `--config`:配置文件路径。不传时使用内置默认配置。
- `--no-visualize`:禁用 Open3D 窗口。
输出:
- 每个阶段的终端指标。
- Python 调用时会返回一个结果字典。
示例:
```bash
python main.py --config config.yaml full --no-visualize
```
## 6. 配置文件
默认配置在 `config.yaml`。
常用字段:
```yaml
paths:
cad: datasets/CAD/11.step
mesh: datasets/Mesh/11.stl
pointcloud: datasets/PointCloud/11.ply
noisy_pointcloud: datasets/Noise_PointCloud/11_noisy.ply
output_mesh: datasets/Mesh/11.stl
curvature_output:
cad:
enabled: false
precision: 1.0
noise:
enabled: true
std: 4.0
registration:
voxel_size: 10.0
roll: 10.0
pitch: 10.0
yaw: 10.0
trans_range: 0.0
evaluation:
chunk_size: 2048
triangle_chunk_size: 4096
cad_mesh_deflection: 0.1
visualization:
enabled: true
```
命令行参数优先级高于配置文件。例如:
```bash
python main.py --config config.yaml contour \
--cad datasets/CAD/40.step \
--pointcloud datasets/Noise_PointCloud/40_noisy.ply \
--excel-output 40_contour.xlsx \
--no-visualize
```
## 7. Python API 用法
除了命令行,也可以在 Python 中调用:
```python
from pipeline import MeasurementPipeline
pipeline = MeasurementPipeline(config_path="config.yaml")
result = pipeline.run_contour(
cad_path="datasets/CAD/11.step",
pointcloud_path="datasets/Noise_PointCloud/11_noisy.ply",
save_path="vis/11_contour.ply",
excel_output_path="11_contour.xlsx",
result_template_path="excel/测量结果.xlsx",
no_visualize=True,
)
print(result["distance_stats"])
```
常用返回字段:
- `run_contour`:`pointcloud`、`distances`、`distance_stats`、`cad_vertices`、`cad_faces`、`excel_output`
- `run_translate_align_pointcloud_to_cad`:`translation`、`T_final`、`aligned_path`、`matrix_path`
- `run_registration`:`source`、`registration`、`distance_stats`
- `run_curvature`:`pointcloud`、`curvatures`
- `run_plane`:`plane_result`
## 8. 打包使用
生成可安装包源码:
```bash
python packaging/build_package.py
```
本地开发安装:
```bash
pip install -e packaging/build
geometry-measure --help
```
安装后命令与 `main.py` 对应:
```bash
geometry-measure contour \
--cad datasets/CAD/11.step \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--save vis/11_contour.ply \
--excel-output 11_contour.xlsx \
--no-visualize
```
每次修改根目录源码后,如果需要同步到打包目录,重新运行:
```bash
python packaging/build_package.py
```
## 9. 辅助脚本说明
辅助脚本放在 `archive/`。常用流程推荐优先使用 `main.py` 统一入口。
`archive/Contour_Flatness.py` 可用于批量轮廓/平整度/平面度评估:
```bash
python archive/Contour_Flatness.py \
--tasks "11,31,40" \
--data_root High_Speed_Data \
--pointcloud_kind noise \
--excel_output vis/flatness.xlsx
```
单任务模式:
```bash
python archive/Contour_Flatness.py \
--single \
-c High_Speed_Data/CAD/11.step \
-p High_Speed_Data/Noise_PointCloud/11_noisy.ply \
--visualize
```
`archive/translate_align_pointcloud_to_cad.py` 已接入统一入口,推荐用 `main.py translate-align` 调用;需要单独运行脚本时也可以直接执行:
```bash
python archive/translate_align_pointcloud_to_cad.py \
-c datasets/CAD/M02.stp \
-p datasets/shift_test_output/ \
-o results \
--axis x \
--crop_ratio 0.01 \
--no_visualize
```
## 10. 常见开发任务
### 添加一个新数据编号
例如添加编号 `45`:
```text
datasets/CAD/45.step
datasets/Mesh/45.stl
datasets/PointCloud/45.ply
datasets/Noise_PointCloud/45_noisy.ply
```
运行评估:
```bash
python main.py contour \
--tasks 45 \
--data-root datasets \
--pointcloud-kind noise \
--save vis/contour \
--excel-output 45_contour.xlsx \
--no-visualize
```
### 保存命令日志
```bash
python main.py contour \
--cad datasets/CAD/11.step \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--excel-output 11_contour.xlsx \
--no-visualize > vis/11_contour.log 2>&1
```
### 内存不足或显存不足
优先调小:
```bash
--point-chunk-size 512
--triangle-chunk-size 1024
```
如果 CAD 三角面过多,也可以增大:
```bash
--mesh-deflection 0.5
```
注意:`mesh_deflection` 越大,CAD 三角化越粗,速度更快但几何近似更粗。
如果内存错误发生在批量合并完整背景点云阶段,并且使用的是 `datasets/train/train_2_1T_配准.ply`,请确认命令包含 `--no-visualize`。保存合并 PLY 已支持流式写入;Open3D 窗口展示仍不适合 10 亿点背景点云。
## 11. 排错
### `No module named OCC`
说明 CAD 依赖未安装或当前 Python 环境不对。
```bash
conda activate High-Speed
conda env update -n High-Speed -f environment.yml
```
### `pythonocc-core` 无法通过 pip 安装
这是正常情况。`pythonocc-core` 应通过 conda 安装,不要放进纯 pip 安装流程里。
### 找不到文件
检查当前工作目录是否为项目根目录。相对路径都按当前运行目录解析。
推荐:
```bash
pwd
python main.py --help
```
Windows PowerShell 下可以使用:
```powershell
Get-Location
python main.py --help
```
### Open3D 可视化窗口影响自动化运行
加 `--no-visualize`。
```bash
python main.py contour --cad datasets/CAD/11.step --pointcloud datasets/PointCloud/11.ply --no-visualize
```
### 结果距离明显不合理
重点检查:
- CAD 和点云是否同单位。
- CAD 和点云是否已经对齐。
- 是否把干净点云和加噪点云用错目录。
- `mesh_deflection` 是否过大导致 CAD 近似过粗。
### Excel 没有生成
`contour` 只有传了 `--excel-output` 才会生成 Excel:
```bash
python main.py contour \
--cad datasets/CAD/11.step \
--pointcloud datasets/Noise_PointCloud/11_noisy.ply \
--excel-output \
--no-visualize
```
默认输出:
```text
excel/contour.xlsx
```
默认模板:
```text
excel/测量结果.xlsx
```
输出表头保持模板一致,程序只更新 `测量结果` 和 `测量结论`。
### `registration` 或 `contour` 很慢
原因通常是点云超过百万点,且距离计算要遍历大量三角面。可以:
- 调小点云规模。
- 调小 `--point-chunk-size` 和 `--triangle-chunk-size` 以降低内存压力。
- 增大 `--mesh-deflection` 减少 CAD 三角面数量。
- 对配准增加 `--voxel-size` 做更强下采样。
## 12. 开发约定
- 面向用户的新功能优先接入 `main.py` 和 `MeasurementPipeline`。
- `contour` 是轮廓/平整度/平面度评估的唯一公开入口,不再新增其它容易混淆的同类命令。
- 新增输出文件时优先放入已有输出目录:通用可视化和日志放 `vis/`,表格放 `excel/`,对齐结果放 `results/`。
- 修改根目录源码后,如果需要打包版本同步,运行 `python packaging/build_package.py`。
- 不要在文档中引入其它同类距离评估命令名,避免和 `contour` 产生歧义。

View File

@@ -0,0 +1,55 @@
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.StlAPI import StlAPI_Writer
import time
import argparse # 命令行参数解析
def step2lightweight_stl(mesh_precision, input_step, output_stl):
"""STEP转轻量化STL,纯后台处理,无显示,体积暴减"""
start_total = time.time()
# 1. 读取STEP文件
print(f"【1/3】读取STEP文件:{input_step}")
step_reader = STEPControl_Reader()
if step_reader.ReadFile(input_step) != IFSelect_RetDone:
raise Exception("STEP文件读取失败!检查路径/文件完整性")
step_reader.TransferRoots()
shape = step_reader.OneShape()
print(f"【1/3】读取完成,耗时:{time.time()-start_total:.2f}秒")
# 2. 核心:网格离散化
print(f"【2/3】网格离散化(精度{mesh_precision}mm),开始轻量化...")
mesh = BRepMesh_IncrementalMesh(shape, mesh_precision, False)
mesh.Perform()
if not mesh.IsDone():
raise Exception("网格离散化失败!")
print(f"【2/3】轻量化完成,耗时:{time.time()-start_total:.2f}秒")
# 3. 保存为STL
print(f"【3/3】保存轻量化STL:{output_stl}")
stl_writer = StlAPI_Writer()
stl_writer.Write(shape, output_stl)
total_time = time.time() - start_total
print(f"✅ 转换完成!总耗时:{total_time:.2f}秒")
print(f"💡 提示:STL文件可直接用FreeCAD/MeshLab/3D Viewer打开")
if __name__ == "__main__":
# ========== 命令行参数定义 ==========
parser = argparse.ArgumentParser(description="STEP 转 轻量化 STL (命令行args版)")
parser.add_argument("--precision", "-p", type=float, default=1.0, help="网格精度 MESH_PRECISION (mm),越大文件越小")
parser.add_argument("--input", "-i", type=str, default="High_Speed_Data/CAD/11.step", help="输入STEP文件路径 INPUT_STEP_PATH")
parser.add_argument("--output", "-o", type=str, default="High_Speed_Data/Mesh/11.stl", help="输出STL文件路径 OUTPUT_STL_PATH")
# 解析参数
args = parser.parse_args()
# 调用转换函数
try:
step2lightweight_stl(
mesh_precision=args.precision,
input_step=args.input,
output_stl=args.output
)
except Exception as e:
print(f"❌ 处理失败:{str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
import open3d as o3d
import numpy as np
import argparse
import os
def compute_curvature_open3d(pcd, radius=0.05, max_nn=30):
"""
使用 Open3D 计算点云曲率
Args:
pcd: Open3D 点云对象
radius: 搜索半径
max_nn: 最大邻居数
Returns:
curvatures: (N,) 曲率数组
"""
# 估计法线(同时获取协方差矩阵信息)
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn)
)
# 获取点云数据
points = np.asarray(pcd.points)
n_points = len(points)
curvatures = np.zeros(n_points)
# 构建 KD-Tree
tree = o3d.geometry.KDTreeFlann(pcd)
for i in range(n_points):
# 搜索邻域点
[k, idx, _] = tree.search_radius_vector_3d(pcd.points[i], radius)
if k < 3:
curvatures[i] = 1.0
continue
# 获取邻域点坐标
neighbors = points[idx]
# 计算协方差矩阵
centroid = np.mean(neighbors, axis=0)
centered = neighbors - centroid
cov = np.cov(centered.T)
# 特征值分解
eigenvalues = np.linalg.eigvalsh(cov)
eigenvalues = np.sort(eigenvalues)
# 计算曲率
curvatures[i] = eigenvalues[0] / (eigenvalues.sum() + 1e-8)
return curvatures
def visualize_curvature(pcd, curvatures, color_scale=3.0):
"""
根据曲率可视化点云
Args:
pcd: Open3D 点云对象
curvatures: 曲率数组
color_scale: 颜色缩放因子
"""
colors = np.zeros((len(pcd.points), 3))
# 红色通道表示曲率(高曲率区域为红色)
colors[:, 0] = np.clip(curvatures * color_scale, 0, 1)
# 绿色通道为互补色(低曲率区域为绿色)
colors[:, 1] = np.clip(1 - curvatures * color_scale, 0, 1)
# 蓝色通道设为0
colors[:, 2] = 0
pcd.colors = o3d.utility.Vector3dVector(colors)
def main():
parser = argparse.ArgumentParser(description='计算点云曲率并可视化')
parser.add_argument('--ply_path', type=str,default="./High_Speed_Data/PointCloud/11.ply", help='输入点云文件路径 (.ply)')
parser.add_argument('--radius', type=float, default=2, help='搜索半径 (默认: 50)')
parser.add_argument('--max_nn', type=int, default=30, help='最大邻居数 (默认: 30)')
parser.add_argument('--color_scale', type=float, default=3.0, help='颜色缩放因子 (默认: 3.0)')
parser.add_argument('--save', type=str, default=None, help='保存可视化图像路径 (可选)')
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.ply_path):
print(f"错误: 文件不存在 - {args.ply_path}")
return
print(f"读取点云: {args.ply_path}")
pcd = o3d.io.read_point_cloud(args.ply_path)
# points=np.array(pcd.points)
if len(pcd.points) == 0:
print("错误: 读取点云失败,文件可能为空或格式不正确")
return
print(f"点云点数: {len(pcd.points)}")
# 计算曲率
print("计算曲率中...")
curvatures = compute_curvature_open3d(pcd, radius=args.radius, max_nn=args.max_nn)
# 统计曲率信息
print(f"曲率统计:")
print(f" 最小值: {curvatures.min():.6f}")
print(f" 最大值: {curvatures.max():.6f}")
print(f" 平均值: {curvatures.mean():.6f}")
print(f" 标准差: {curvatures.std():.6f}")
# 可视化
print("可视化曲率...")
visualize_curvature(pcd, curvatures, color_scale=args.color_scale)
# 保存截图(如果指定)
if args.save:
o3d.io.write_point_cloud(args.save, pcd)
print(f"点云已保存: {args.save}")
# 显示窗口
o3d.visualization.draw_geometries([pcd], window_name=f"Curvature Visualization - {os.path.basename(args.ply_path)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,431 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
import copy
import argparse
import os
# ========== 1. Chamfer Distance 计算 ==========
def chamfer_distance(pcd1, pcd2, sqrt=True):
"""
计算两个点云之间的 CD 距离(Chamfer Distance)
Args:
pcd1: Open3D 点云对象1
pcd2: Open3D 点云对象2
sqrt: 是否对最终结果开平方根
Returns:
cd: Chamfer Distance 值
cd1: 点云1 到 点云2 的平均距离
cd2: 点云2 到 点云1 的平均距离
"""
points1 = np.asarray(pcd1.points)
points2 = np.asarray(pcd2.points)
if len(points1) == 0 or len(points2) == 0:
return float('inf'), 0, 0
tree1 = KDTree(points1)
tree2 = KDTree(points2)
dist1, _ = tree2.query(points1, k=1)
cd1 = np.mean(dist1 ** 2)
dist2, _ = tree1.query(points2, k=1)
cd2 = np.mean(dist2 ** 2)
cd = cd1 + cd2
if sqrt:
return np.sqrt(cd), cd1, cd2
return cd, cd1, cd2
# ========== 添加高斯噪声 ==========
def add_gaussian_noise(pcd, noise_std=4.0):
"""
给点云添加高斯噪声
Args:
pcd: Open3D 点云对象
noise_std: 噪声标准差
Returns:
noisy_pcd: 添加噪声后的点云
"""
noisy_pcd = copy.deepcopy(pcd)
points = np.asarray(noisy_pcd.points)
noise = np.random.normal(0, noise_std, points.shape)
noisy_points = points + noise
noisy_pcd.points = o3d.utility.Vector3dVector(noisy_points)
return noisy_pcd
# ========== 2. 统计离群点移除 (SOR) ==========
def statistical_outlier_removal(pcd, nb_neighbors=20, std_ratio=2.0):
"""
统计离群点移除
"""
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=nb_neighbors, std_ratio=std_ratio)
inlier_cloud = pcd.select_by_index(ind)
outlier_cloud = pcd.select_by_index(ind, invert=True)
metrics = {
'original_points': len(pcd.points),
'filtered_points': len(inlier_cloud.points),
'removed_points': len(outlier_cloud.points),
'keep_ratio': len(inlier_cloud.points) / len(pcd.points) * 100 if len(pcd.points) > 0 else 0,
'method': 'SOR',
'params': {'nb_neighbors': nb_neighbors, 'std_ratio': std_ratio}
}
return inlier_cloud, outlier_cloud, metrics
# ========== 3. 半径离群点移除 (ROR) ==========
def radius_outlier_removal(pcd, nb_points=5, radius=20):
"""
半径离群点移除
"""
cl, ind = pcd.remove_radius_outlier(nb_points=nb_points, radius=radius)
inlier_cloud = pcd.select_by_index(ind)
outlier_cloud = pcd.select_by_index(ind, invert=True)
metrics = {
'original_points': len(pcd.points),
'filtered_points': len(inlier_cloud.points),
'removed_points': len(outlier_cloud.points),
'keep_ratio': len(inlier_cloud.points) / len(pcd.points) * 100 if len(pcd.points) > 0 else 0,
'method': 'ROR',
'params': {'nb_points': nb_points, 'radius': radius}
}
return inlier_cloud, outlier_cloud, metrics
# ========== 4. 直通滤波 (PF) ==========
def pass_through_filter(pcd, axis='z', min_val=-6000.0, max_val=6000.0):
"""
直通滤波
"""
points = np.asarray(pcd.points)
axis_map = {'x': 0, 'y': 1, 'z': 2}
idx = axis_map[axis]
mask = (points[:, idx] >= min_val) & (points[:, idx] <= max_val)
filtered_points = points[mask]
filtered_pcd = o3d.geometry.PointCloud()
filtered_pcd.points = o3d.utility.Vector3dVector(filtered_points)
metrics = {
'original_points': len(pcd.points),
'filtered_points': len(filtered_points),
'removed_points': len(pcd.points) - len(filtered_points),
'keep_ratio': len(filtered_points) / len(pcd.points) * 100 if len(pcd.points) > 0 else 0,
'method': 'PF',
'params': {'axis': axis, 'min_val': min_val, 'max_val': max_val}
}
return filtered_pcd, metrics
def compute_keep_ratio_and_density(pcd, filtered_pcd, sample_size=1000):
"""计算直通滤波的保留率和密度变化"""
n_orig = len(pcd.points)
n_filt = len(filtered_pcd.points)
if n_orig == 0 or n_filt == 0:
return 0, 0
keep_ratio = n_filt / n_orig * 100
points_orig = np.asarray(pcd.points)
points_filt = np.asarray(filtered_pcd.points)
if len(points_orig) > sample_size:
idx_orig = np.random.choice(len(points_orig), sample_size, replace=False)
points_orig_sample = points_orig[idx_orig]
else:
points_orig_sample = points_orig
if len(points_filt) > sample_size:
idx_filt = np.random.choice(len(points_filt), sample_size, replace=False)
points_filt_sample = points_filt[idx_filt]
else:
points_filt_sample = points_filt
if len(points_orig_sample) > 5:
tree_orig = KDTree(points_orig_sample)
dist_orig, _ = tree_orig.query(points_orig_sample, k=2)
avg_dist_orig = np.mean(dist_orig[:, 1])
else:
avg_dist_orig = 0
if len(points_filt_sample) > 5:
tree_filt = KDTree(points_filt_sample)
dist_filt, _ = tree_filt.query(points_filt_sample, k=2)
avg_dist_filt = np.mean(dist_filt[:, 1])
else:
avg_dist_filt = 0
if avg_dist_orig > 0:
density_change = (avg_dist_filt - avg_dist_orig) / avg_dist_orig * 100
else:
density_change = 0
return keep_ratio, density_change
# ========== 5. 体素滤波 (VGF) ==========
def voxel_grid_filter(pcd, leaf_size=0.05):
"""
体素滤波
"""
filtered_pcd = pcd.voxel_down_sample(leaf_size)
n_orig = len(pcd.points)
n_filt = len(filtered_pcd.points)
metrics = {
'original_points': n_orig,
'filtered_points': n_filt,
'removed_points': n_orig - n_filt,
'keep_ratio': n_filt / n_orig * 100 if n_orig > 0 else 0,
'downsample_rate': (n_orig - n_filt) / n_orig * 100 if n_orig > 0 else 0,
'compression_ratio': n_orig / n_filt if n_filt > 0 else 0,
'method': 'VGF',
'params': {'leaf_size': leaf_size}
}
if n_filt > 5:
points_sample = np.asarray(filtered_pcd.points)
sample_size = min(1000, n_filt)
if n_filt > sample_size:
idx = np.random.choice(n_filt, sample_size, replace=False)
points_sample = points_sample[idx]
tree = KDTree(points_sample)
dist, _ = tree.query(points_sample, k=2)
metrics['avg_point_distance'] = np.mean(dist[:, 1])
return filtered_pcd, metrics
# ========== 6. 双边滤波 (BF) ==========
def bilateral_filter(pcd, sigma_s=0.01, sigma_r=0.1):
"""
双边滤波
"""
if len(pcd.points) == 0:
return pcd, {'original_points': 0, 'filtered_points': 0, 'removed_points': 0, 'keep_ratio': 0, 'method': 'BF', 'params': {}}
# 确保有法线
if not pcd.has_normals():
pcd.estimate_normals()
points = np.asarray(pcd.points)
normals = np.asarray(pcd.normals)
tree = KDTree(points)
filtered_points = np.zeros_like(points)
for i, p in enumerate(points):
indices = tree.query_ball_point(p, r=2 * sigma_s)
if len(indices) <= 1:
filtered_points[i] = p
continue
neigh_points = points[indices]
neigh_normals = normals[indices]
spatial_dist = np.linalg.norm(neigh_points - p, axis=1)
w_s = np.exp(-spatial_dist ** 2 / (2 * sigma_s ** 2))
normal_diff = np.linalg.norm(neigh_normals - normals[i], axis=1)
w_r = np.exp(-normal_diff ** 2 / (2 * sigma_r ** 2))
w = w_s * w_r
if w.sum() > 0:
w = w / w.sum()
filtered_points[i] = np.sum(neigh_points.T * w, axis=1)
filtered_pcd = o3d.geometry.PointCloud()
filtered_pcd.points = o3d.utility.Vector3dVector(filtered_points)
filtered_pcd.normals = o3d.utility.Vector3dVector(normals)
metrics = {
'original_points': len(pcd.points),
'filtered_points': len(filtered_points),
'removed_points': 0,
'keep_ratio': 100,
'method': 'BF',
'params': {'sigma_s': sigma_s, 'sigma_r': sigma_r}
}
return filtered_pcd, metrics
# ========== 主函数 ==========
def main():
parser = argparse.ArgumentParser(description='点云去噪算法对比 (SOR, ROR, PF, VGF, BF)')
parser.add_argument('--ply_path', '-p', type=str, default="./High_Speed_Data/PointCloud/11.ply",
help='输入点云文件路径 (.ply)')
parser.add_argument('--noise_std', '-n', type=float, default=4.0,
help='高斯噪声标准差 (默认: 4.0)')
parser.add_argument('--voxel_size', '-v', type=float, default=0.05,
help='体素滤波大小 (默认: 0.05)')
parser.add_argument('--sor_neighbors', type=int, default=20,
help='SOR 邻域点数 (默认: 20)')
parser.add_argument('--sor_std_ratio', type=float, default=2.0,
help='SOR 标准差倍数 (默认: 2.0)')
parser.add_argument('--ror_points', type=int, default=5,
help='ROR 半径内最小点数 (默认: 5)')
parser.add_argument('--ror_radius', type=float, default=20,
help='ROR 搜索半径 (默认: 20)')
parser.add_argument('--bf_sigma_s', type=float, default=0.01,
help='双边滤波空间标准差 (默认: 0.01)')
parser.add_argument('--bf_sigma_r', type=float, default=0.1,
help='双边滤波特征标准差 (默认: 0.1)')
parser.add_argument('--no_visualize', action='store_true',
help='不显示可视化窗口')
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.ply_path):
print(f"错误: 点云文件不存在 - {args.ply_path}")
return
# 1. 读取原始点云
print(f"读取点云: {args.ply_path}")
pcd_clean = o3d.io.read_point_cloud(args.ply_path)
if len(pcd_clean.points) == 0:
print("读取失败,请检查文件路径")
return
print(f"原始无噪点云点数: {len(pcd_clean.points)}")
# ========== 添加高斯噪声 ==========
print("\n" + "=" * 50)
print(f"添加高斯噪声 (std={args.noise_std})")
print("=" * 50)
pcd_noisy = add_gaussian_noise(pcd_clean, args.noise_std)
print(f"加噪后点云点数: {len(pcd_noisy.points)}")
# 计算噪声水平
points_clean = np.asarray(pcd_clean.points)
points_noisy = np.asarray(pcd_noisy.points)
noise_level = np.mean(np.linalg.norm(points_noisy - points_clean, axis=1))
print(f"平均噪声偏移量: {noise_level:.4f}")
# 加噪点云着色
pcd_noisy.paint_uniform_color([1.0, 0.7, 0.5])
# ========== 运行各去噪算法 ==========
results = {}
# 1. SOR
print("\n" + "=" * 50)
print("统计离群点移除 (SOR)")
print("=" * 50)
sor_filtered, sor_outlier, sor_metrics = statistical_outlier_removal(
pcd_noisy, nb_neighbors=args.sor_neighbors, std_ratio=args.sor_std_ratio)
results['SOR'] = sor_metrics
print(f"保留率: {sor_metrics['keep_ratio']:.2f}%")
print(f"移除: {sor_metrics['removed_points']} 点")
# 2. ROR
print("\n" + "=" * 50)
print("半径离群点移除 (ROR)")
print("=" * 50)
ror_filtered, ror_outlier, ror_metrics = radius_outlier_removal(
pcd_noisy, nb_points=args.ror_points, radius=args.ror_radius)
results['ROR'] = ror_metrics
print(f"保留率: {ror_metrics['keep_ratio']:.2f}%")
print(f"移除: {ror_metrics['removed_points']} 点")
# 3. 直通滤波 (PF)
print("\n" + "=" * 50)
print("直通滤波 (PF)")
print("=" * 50)
points = np.asarray(pcd_noisy.points)
z_min, z_max = points[:, 2].min(), points[:, 2].max()
pf_filtered, pf_metrics = pass_through_filter(pcd_noisy, axis='z', min_val=z_min, max_val=z_max)
results['PF'] = pf_metrics
print(f"保留率: {pf_metrics['keep_ratio']:.2f}%")
keep_ratio, density_change = compute_keep_ratio_and_density(pcd_noisy, pf_filtered)
print(f"密度变化: {density_change:+.2f}%")
# 4. 体素滤波 (VGF)
print("\n" + "=" * 50)
print("体素滤波 (VGF)")
print("=" * 50)
vgf_filtered, vgf_metrics = voxel_grid_filter(pcd_noisy, leaf_size=args.voxel_size)
results['VGF'] = vgf_metrics
print(f"保留率: {vgf_metrics['keep_ratio']:.2f}%")
print(f"压缩比: {vgf_metrics['compression_ratio']:.1f}x")
# 5. 双边滤波 (BF)
print("\n" + "=" * 50)
print("双边滤波 (BF)")
print("=" * 50)
bf_filtered, bf_metrics = bilateral_filter(pcd_noisy, sigma_s=args.bf_sigma_s, sigma_r=args.bf_sigma_r)
results['BF'] = bf_metrics
print(f"保留率: {bf_metrics['keep_ratio']:.2f}%")
# Chamfer Distance 计算
print("\n" + "=" * 50)
print("Chamfer Distance 评估 (与干净点云对比)")
print("=" * 50)
for name, metrics in results.items():
if name == 'BF':
filtered = bf_filtered
elif name == 'SOR':
filtered = sor_filtered
elif name == 'ROR':
filtered = ror_filtered
elif name == 'PF':
filtered = pf_filtered
elif name == 'VGF':
filtered = vgf_filtered
else:
continue
if len(filtered.points) > 0:
cd, _, _ = chamfer_distance(pcd_clean, filtered)
print(f"{name}: CD = {cd:.6f} m")
print("-" * 40)
cd_noisy, _, _ = chamfer_distance(pcd_clean, pcd_noisy)
print(f"加噪点云 (未经滤波): CD = {cd_noisy:.6f} m")
# 可视化
if not args.no_visualize:
print("\n可视化对比...")
pcd_clean.paint_uniform_color([0, 0, 1]) # 蓝色 - 原始干净点云
pcd_noisy.paint_uniform_color([1.0, 0.7, 0.5]) # 浅橙色 - 加噪点云
sor_filtered.paint_uniform_color([0, 1, 0]) # 绿色 - SOR 结果
o3d.visualization.draw_geometries(
[pcd_clean, pcd_noisy],
window_name=f"Clean (Blue, {len(pcd_clean.points)}) vs Noisy (Orange, {len(pcd_noisy.points)})"
)
o3d.visualization.draw_geometries(
[pcd_clean, sor_filtered],
window_name=f"SOR: Clean (Blue, {len(pcd_clean.points)}) vs Filtered (Green, {len(sor_filtered.points)})"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,380 @@
# import numpy as np
# import open3d as o3d
# import random
#
#
# def ransac_plane(points, threshold=0.01, max_iterations=1000, min_inliers=100):
# """
# RANSAC 平面拟合(单个)
# """
# best_inliers = []
# best_plane = None
# n_points = len(points)
#
# if n_points < 3:
# return None, []
#
# for _ in range(max_iterations):
# sample_idx = random.sample(range(n_points), 3)
# p1, p2, p3 = points[sample_idx]
#
# v1 = p2 - p1
# v2 = p3 - p1
# normal = np.cross(v1, v2)
# norm = np.linalg.norm(normal)
# if norm < 1e-8:
# continue
# normal = normal / norm
# a, b, c = normal
# d = -np.dot(normal, p1)
#
# distances = np.abs(np.dot(points, normal) + d)
# inliers = np.where(distances < threshold)[0]
#
# if len(inliers) > len(best_inliers) and len(inliers) >= min_inliers:
# best_inliers = inliers
# best_plane = (a, b, c, d)
#
# if len(best_inliers) > 0.9 * n_points:
# break
#
# # 用内点重新拟合
# if best_inliers is not None and len(best_inliers) > 3:
# inlier_points = points[best_inliers]
# centroid = np.mean(inlier_points, axis=0)
# _, _, vh = np.linalg.svd(inlier_points - centroid)
# normal = vh[2]
# normal = normal / np.linalg.norm(normal)
# a, b, c = normal
# d = -np.dot(normal, centroid)
# best_plane = (a, b, c, d)
#
# return best_plane, best_inliers
#
#
# def sequential_ransac_planes(points, threshold=0.01, max_iterations=1000, min_inliers=100, max_planes=5):
# """
# 迭代 RANSAC 拟合多个平面
# """
# remaining_points = points.copy()
# remaining_indices = np.arange(len(points))
#
# planes = []
# inliers_list = []
#
# for i in range(max_planes):
# if len(remaining_points) < min_inliers:
# break
#
# plane, inliers = ransac_plane(remaining_points, threshold, max_iterations, min_inliers)
#
# if plane is None or len(inliers) < min_inliers:
# break
#
# planes.append(plane)
# original_indices = remaining_indices[inliers]
# inliers_list.append(original_indices)
#
# # 移除内点
# mask = np.ones(len(remaining_points), dtype=bool)
# mask[inliers] = False
# remaining_points = remaining_points[mask]
# remaining_indices = remaining_indices[mask]
#
# print(f"平面 {len(planes)}: {len(inliers)} 个内点, 剩余 {len(remaining_points)} 个点")
#
# return planes, inliers_list
#
#
# def create_plane_mesh(plane, bounds):
# """
# 创建平面网格用于可视化
# """
# a, b, c, d = plane
# x_min, x_max = bounds[0]
# y_min, y_max = bounds[1]
#
# x = np.linspace(x_min, x_max, 50)
# y = np.linspace(y_min, y_max, 50)
# X, Y = np.meshgrid(x, y)
#
# if abs(c) > 1e-8:
# Z = -(a * X + b * Y + d) / c
# else:
# Z = np.zeros_like(X)
#
# vertices = []
# triangles = []
# rows, cols = X.shape
#
# for i in range(rows):
# for j in range(cols):
# vertices.append([X[i, j], Y[i, j], Z[i, j]])
#
# for i in range(rows - 1):
# for j in range(cols - 1):
# idx = i * cols + j
# triangles.append([idx, idx + 1, idx + cols])
# triangles.append([idx + 1, idx + cols + 1, idx + cols])
#
# mesh = o3d.geometry.TriangleMesh()
# mesh.vertices = o3d.utility.Vector3dVector(np.array(vertices))
# mesh.triangles = o3d.utility.Vector3iVector(np.array(triangles))
# mesh.compute_vertex_normals()
#
# return mesh
#
#
# def visualize_multiple_planes(points, planes, inliers_list):
# """
# 可视化多个平面(点云 + 平面网格)
#
# Args:
# points: (N, 3) 点云坐标
# planes: 平面方程列表 [(a,b,c,d), ...]
# inliers_list: 每个平面对应的内点索引列表
# """
# # 颜色列表(每个平面不同颜色)
# colors = [
# [1, 0, 0], # 红色
# [0, 1, 0], # 绿色
# [0, 0, 1], # 蓝色
# [1, 1, 0], # 黄色
# [1, 0, 1], # 品红
# [0, 1, 1], # 青色
# [1, 0.5, 0], # 橙色
# [0.5, 0, 1], # 紫色
# ]
#
# geometries = []
#
# # 1. 为每个平面的内点着色
# point_colors = np.zeros((len(points), 3))
# for i, inliers in enumerate(inliers_list):
# color = colors[i % len(colors)]
# point_colors[inliers] = color
#
# # 未分配的点(剩余点)设为灰色
# unassigned = np.where(np.sum(point_colors, axis=1) == 0)[0]
# point_colors[unassigned] = [0.5, 0.5, 0.5]
#
# colored_pcd = o3d.geometry.PointCloud()
# colored_pcd.points = o3d.utility.Vector3dVector(points)
# colored_pcd.colors = o3d.utility.Vector3dVector(point_colors)
# geometries.append(colored_pcd)
#
# # 2. 为每个平面创建网格
# for i, (plane, inliers) in enumerate(zip(planes, inliers_list)):
# # 计算该平面对应点的范围
# inlier_points = points[inliers]
# if len(inlier_points) > 0:
# x_min, x_max = inlier_points[:, 0].min(), inlier_points[:, 0].max()
# y_min, y_max = inlier_points[:, 1].min(), inlier_points[:, 1].max()
#
# # 稍微扩展边界
# x_range = x_max - x_min
# y_range = y_max - y_min
# x_min -= x_range * 0.1
# x_max += x_range * 0.1
# y_min -= y_range * 0.1
# y_max += y_range * 0.1
#
# mesh = create_plane_mesh(plane, [[x_min, x_max], [y_min, y_max]])
# mesh.paint_uniform_color(colors[i % len(colors)])
# # 设置半透明
# mesh.paint_uniform_color([colors[i % len(colors)][0],
# colors[i % len(colors)][1],
# colors[i % len(colors)][2]])
# geometries.append(mesh)
#
# # 可视化
# o3d.visualization.draw_geometries(
# geometries,
# window_name="Multiple Planes - RANSAC",
# width=1024,
# height=768
# )
#
#
# def visualize_single_plane(points, plane, inliers):
# """
# 可视化单个平面(兼容单平面情况)
# """
# visualize_multiple_planes(points, [plane], [inliers])
#
#
# # ========== 主程序 ==========
# if __name__ == "__main__":
# # 读取点云
# file_path = r"E:\point_distance\data\part_car_head.ply"
# pcd = o3d.io.read_point_cloud(file_path)
#
# if len(pcd.points) == 0:
# print("读取失败,请检查文件路径")
# exit()
#
# points = np.asarray(pcd.points)
# print(f"原始点云点数: {len(points)}")
#
# # 计算点云范围
# points_range = points.max(axis=0) - points.min(axis=0)
# print(f"点云范围: X[{points_range[0]:.3f}], Y[{points_range[1]:.3f}], Z[{points_range[2]:.3f}]")
#
# # 设置阈值
# threshold = points_range[2] * 0.02
# print(f"使用阈值: {threshold:.4f}")
#
# # ========== 单平面拟合 ==========
# # print("\n" + "=" * 50)
# # print("单平面拟合")
# # print("=" * 50)
# #
# # plane, inliers = ransac_plane(points, threshold=threshold, max_iterations=1, min_inliers=100)
# #
# # if plane is not None:
# # a, b, c, d = plane
# # print(f"拟合平面: {a:.4f}x + {b:.4f}y + {c:.4f}z + {d:.4f} = 0")
# # print(f"内点数量: {len(inliers)} / {len(points)} ({len(inliers) / len(points) * 100:.1f}%)")
# # visualize_single_plane(points, plane, inliers)
#
# # ========== 多平面拟合 ==========
# print("\n" + "=" * 50)
# print("多平面拟合")
# print("=" * 50)
#
# planes, inliers_list = sequential_ransac_planes(
# points,
# threshold=threshold,
# max_iterations=1,
# min_inliers=100,
# max_planes=1
# )
#
# print(f"共提取 {len(planes)} 个平面")
# for i, (plane, inliers) in enumerate(zip(planes, inliers_list)):
# a, b, c, d = plane
# print(f"平面 {i + 1}: {a:.4f}x + {b:.4f}y + {c:.4f}z + {d:.4f} = 0, 内点数: {len(inliers)}")
#
# if len(planes) > 0:
# visualize_multiple_planes(points, planes, inliers_list)
import numpy as np
import open3d as o3d
import random
def create_square_plane_with_noise(size_mm=1000, noise_std_mm=0.5, num_points=10000):
"""创建正方形平面点云并添加高斯噪声"""
size_m = size_mm / 1000.0
noise_std_m = noise_std_mm / 1000.0
half_size = size_m / 2
x = np.random.uniform(-half_size, half_size, num_points)
y = np.random.uniform(-half_size, half_size, num_points)
z = np.zeros(num_points)
points = np.column_stack([x, y, z])
noise_z = np.random.normal(0, noise_std_m, num_points)
points[:, 2] += noise_z
return points
def estimate_plane_ransac_open3d(points, threshold_mm=0.5, max_iterations=2000):
"""使用 Open3D 内置 RANSAC 平面估计"""
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
threshold_m = threshold_mm / 1000.0
plane_model, inliers = pcd.segment_plane(
distance_threshold=threshold_m,
ransac_n=3,
num_iterations=max_iterations
)
a, b, c, d = plane_model
# 计算误差
ideal_normal = np.array([0, 0, 1])
estimated_normal = np.array([a, b, c])
cos_angle = np.dot(ideal_normal, estimated_normal)
cos_angle = np.clip(cos_angle, -1, 1)
angle_error = np.degrees(np.arccos(cos_angle))
offset_error = abs(d)
inlier_points = points[inliers]
distances = np.abs(np.dot(inlier_points, estimated_normal) + d)
rmse = np.sqrt(np.mean(distances ** 2))
print(f"\nRANSAC 平面拟合结果 (Open3D):")
print(f" 估计平面: {a:.6f}x + {b:.6f}y + {c:.6f}z + {d:.6f} = 0")
print(f" 内点数量: {len(inliers)} / {len(points)} ({len(inliers) / len(points) * 100:.1f}%)")
print(f" 法线角度误差: {angle_error:.4f} 度")
print(f" 偏移误差: {offset_error:.6f} m ({offset_error * 1000:.3f} mm)")
print(f" 平面拟合 RMSE: {rmse:.6f} m ({rmse * 1000:.3f} mm)")
return plane_model, inliers
def visualize_plane_fitting(points, plane):
"""可视化原始点云和拟合平面"""
# 原始点云
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.paint_uniform_color([0.6, 0.8, 1.0])
# 创建平面点云
a, b, c, d = plane
x_min, x_max = points[:, 0].min(), points[:, 0].max()
y_min, y_max = points[:, 1].min(), points[:, 1].max()
x = np.linspace(x_min, x_max, 50)
y = np.linspace(y_min, y_max, 50)
X, Y = np.meshgrid(x, y)
if abs(c) > 1e-8:
Z = -(a * X + b * Y + d) / c
else:
Z = np.zeros_like(X)
plane_points = np.column_stack([X.ravel(), Y.ravel(), Z.ravel()])
pcd_plane = o3d.geometry.PointCloud()
pcd_plane.points = o3d.utility.Vector3dVector(plane_points)
pcd_plane.paint_uniform_color([1, 0, 0]) # 红色
o3d.visualization.draw_geometries(
[pcd, pcd_plane],
window_name="Plane Fitting",
width=1024,
height=768
)
# ========== 主程序 ==========
if __name__ == "__main__":
# 参数设置
size_mm = 1000
noise_std_mm = 0.5
num_points = 100000
print(f"参数:")
print(f" 正方形边长: {size_mm} mm")
print(f" 噪声标准差: {noise_std_mm} mm")
print(f" 点数: {num_points}")
# 生成点云
points = create_square_plane_with_noise(size_mm, noise_std_mm, num_points)
# 检查数据范围
print(f"\n数据范围:")
print(f" X: [{points[:, 0].min():.3f}, {points[:, 0].max():.3f}]")
print(f" Y: [{points[:, 1].min():.3f}, {points[:, 1].max():.3f}]")
print(f" Z: [{points[:, 2].min():.6f}, {points[:, 2].max():.6f}]")
# 使用 Open3D RANSAC
plane, inliers = estimate_plane_ransac_open3d(points, threshold_mm=0.5, max_iterations=2000)
# 可视化
visualize_plane_fitting(points, plane)

View File

@@ -0,0 +1,246 @@
import copy
import argparse
import os
from scipy.spatial.transform import Rotation as R
import trimesh
import numpy as np
import open3d as o3d
import time
def rotation_matrix_scipy(roll_deg=10, pitch_deg=10, yaw_deg=10):
"""
使用 scipy 生成绕 XYZ 轴旋转的矩阵
"""
r = R.from_euler('xyz', [roll_deg, pitch_deg, yaw_deg], degrees=True)
return r.as_matrix()
def random_rotation_matrix():
"""生成随机旋转矩阵"""
angle = np.random.uniform(0, 2 * np.pi)
axis = np.random.randn(3)
axis = axis / np.linalg.norm(axis)
K = np.array([
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]
])
R = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K
return R
def random_translation_vector(range_xyz=0.5):
"""生成随机平移向量"""
return np.random.uniform(-range_xyz, range_xyz, 3)
def transform_point_cloud(pcd, R, t):
"""对点云应用变换"""
transformed_pcd = copy.deepcopy(pcd)
points = np.asarray(transformed_pcd.points)
points_transformed = (R @ points.T).T + t
transformed_pcd.points = o3d.utility.Vector3dVector(points_transformed)
return transformed_pcd
def registration_open3d(source_pcd, target_pcd, voxel_size=0.005):
"""
点云配准(RANSAC + ICP)
Returns:
transformation: 4x4 变换矩阵
fitness: 配准 fitness 分数
rmse: 配准 RMSE
"""
# 1. 下采样
source_down = source_pcd.voxel_down_sample(voxel_size)
target_down = target_pcd.voxel_down_sample(voxel_size)
# 2. 估计法线
radius = voxel_size * 2
source_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=30))
target_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=30))
# 3. 计算 FPFH 特征
radius_feature = voxel_size * 5
source_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
source_down, o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))
target_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
target_down, o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))
# 4. RANSAC 粗配准
distance_threshold = voxel_size * 1.5
result_ransac = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
source_down, target_down, source_fpfh, target_fpfh,
mutual_filter=True,
max_correspondence_distance=distance_threshold,
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(),
ransac_n=3,
checkers=[
o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(distance_threshold)
],
criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999))
print(f"RANSAC 配准完成")
source_pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
target_pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
# 5. ICP 精配准
result_icp = o3d.pipelines.registration.registration_icp(
source_pcd, target_pcd, distance_threshold / 2, result_ransac.transformation,
o3d.pipelines.registration.TransformationEstimationPointToPlane(),
o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=200))
print(f"ICP 精配准完成")
print(f" Fitness: {result_icp.fitness:.4f}")
print(f" RMSE: {result_icp.inlier_rmse:.6f} m")
return result_icp.transformation, result_icp.fitness, result_icp.inlier_rmse
def visualize_registration_result(original_pcd, transformed_pcd, registered_pcd):
"""可视化原始点云、变换后点云和配准后点云"""
source_colored = copy.deepcopy(original_pcd)
target_colored = copy.deepcopy(transformed_pcd)
registered_colored = copy.deepcopy(registered_pcd)
source_colored.paint_uniform_color([0, 0, 1]) # 蓝色 - 原始点云
target_colored.paint_uniform_color([0, 1, 0]) # 绿色 - 目标点云
registered_colored.paint_uniform_color([1, 0, 0]) # 红色 - 配准后点云
o3d.visualization.draw_geometries(
[source_colored, target_colored],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024, height=768
)
o3d.visualization.draw_geometries(
[registered_colored, target_colored],
window_name="After Registration (Red: Registered, Green: Target)",
width=1024, height=768
)
def visualize_registration_comparison(original_pcd, transformed_pcd, registered_pcd):
"""并排可视化配准前后的对比"""
source_before = copy.deepcopy(original_pcd)
source_before.paint_uniform_color([0, 0, 1]) # 蓝色
target_before = copy.deepcopy(transformed_pcd)
target_before.paint_uniform_color([0, 1, 0]) # 绿色
registered_after = copy.deepcopy(registered_pcd)
registered_after.paint_uniform_color([1, 0, 0]) # 红色
o3d.visualization.draw_geometries(
[source_before, target_before],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024, height=768
)
o3d.visualization.draw_geometries(
[registered_after, target_before],
window_name="After Registration (Red: Registered, Green: Target)",
width=1024, height=768
)
def compute_point_to_mesh_distance_trimesh(points, mesh_path):
"""使用 trimesh 计算点云到 Mesh 的距离"""
mesh_trimesh = trimesh.load(mesh_path)
closest_points, distances, triangle_ids = mesh_trimesh.nearest.on_surface(points)
return distances
# ========== 主程序 ==========
def main():
parser = argparse.ArgumentParser(description='点云配准与 Mesh 距离计算')
parser.add_argument('--ply_path', '-p', type=str, default="./High_Speed_Data/Noise_PointCloud/11_noisy.ply",
help='输入点云文件路径 (.ply)')
parser.add_argument('--mesh_path', '-m', type=str, default="./High_Speed_Data/Mesh/11.stl",
help='输入 Mesh 文件路径 (.stl, .ply, .obj 等)')
parser.add_argument('--voxel_size', '-v', type=float, default=10,
help='体素滤波大小 (默认: 10)')
parser.add_argument('--roll', type=float, default=10,
help='绕 X 轴旋转角度 (度, 默认: 10)')
parser.add_argument('--pitch', type=float, default=10,
help='绕 Y 轴旋转角度 (度, 默认: 10)')
parser.add_argument('--yaw', type=float, default=10,
help='绕 Z 轴旋转角度 (度, 默认: 10)')
parser.add_argument('--trans_range', type=float, default=0,
help='平移范围 (米, 默认: 0)')
parser.add_argument('--no_visualize', action='store_true',
help='不显示可视化窗口')
args = parser.parse_args()
# 检查文件是否存在
if not os.path.exists(args.ply_path):
print(f"错误: 点云文件不存在 - {args.ply_path}")
return
if not os.path.exists(args.mesh_path):
print(f"错误: Mesh 文件不存在 - {args.mesh_path}")
return
# 1. 读取原始点云
print(f"读取点云: {args.ply_path}")
source_pcd = o3d.io.read_point_cloud(args.ply_path)
if len(source_pcd.points) == 0:
print("错误: 点云读取失败")
return
print(f"原始点云点数: {len(source_pcd.points)}")
# 2. 生成变换
R = rotation_matrix_scipy(args.roll, args.pitch, args.yaw)
t = random_translation_vector(range_xyz=args.trans_range)
print(f"旋转矩阵:\n{R}")
print(f"平移向量: {t}")
# 3. 创建目标点云(变换后的源点云)
target_pcd = transform_point_cloud(source_pcd, R, t)
# 4. 点云配准
print("\n开始点云配准...")
t0 = time.time()
transformation, fitness, rmse = registration_open3d(source_pcd, target_pcd, voxel_size=args.voxel_size)
# 5. 将目标点云变换回源点云坐标系
inv_transformation = np.linalg.inv(transformation)
target_recovered = copy.deepcopy(target_pcd)
target_recovered.transform(inv_transformation)
t1 = time.time()
print(f"\n配准结果:")
print(f" Fitness: {fitness:.4f}")
print(f" RMSE: {rmse:.6f} m")
print(f" 估计的变换矩阵:\n{transformation}")
print(f"配准耗时: {t1 - t0:.4f} 秒")
# 6. 计算点到 Mesh 的距离
print("\n计算点到 Mesh 距离...")
t2 = time.time()
points = np.asarray(target_recovered.points)
distances = compute_point_to_mesh_distance_trimesh(points, args.mesh_path)
t3 = time.time()
print(f"距离统计:")
print(f" 平均距离: {np.mean(distances):.6f} mm ({np.mean(distances) * 1000:.3f} mm)")
print(f" 最大距离: {np.max(distances):.6f} mm ({np.max(distances) * 1000:.3f} mm)")
print(f" RMSE: {np.sqrt(np.mean(distances ** 2)):.6f} m")
print(f"距离计算耗时: {t3 - t2:.4f} 秒")
# 7. 可视化
if not args.no_visualize:
print("\n开始可视化...")
visualize_registration_comparison(source_pcd, target_pcd, target_recovered)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,280 @@
# 工业零件几何测量工具使用指南
> 支持测量种类:平整度、平面度、轮廓测量、曲率分析、点云去噪、平面拟合、点云配准评估
> 支持测量任务编号:11、13、17、20、25、26、27、31、34、35、36、37、38、39、40、41、42、45
## 一、环境配置
### 基础环境要求
- Python 3.10.8
- CUDA 11.8(建议,无CUDA时自动降级为CPU计算)
- PyTorch 2.2
### 依赖安装
```bash
conda install -f requirements.txt
```
> 注:需确保环境中已安装OCC Core(STEP/STL处理)、trimesh、open3d、numpy、scipy等依赖
---
## 二、CAD转轻量化Mesh(CAD2Mesh.py)
### 功能说明
将STEP格式的CAD模型转换为轻量化STL网格文件,通过调整网格精度控制文件体积(精度值越大,文件越小,精度越低),纯后台处理无界面显示,转换效率高。
### 输入输出
- **输入**:STEP格式CAD文件(路径示例:`High_Speed_Data/CAD/11.step`)
- **输出**:STL格式轻量化网格文件(路径示例:`High_Speed_Data/Mesh/11.stl`)
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|--------------|------|--------|---------------------------------|----------------------------------------|
| --precision | -p | float | 1.0 | 网格精度(单位:mm),值越大文件体积越小 |
| --input | -i | string | High_Speed_Data/CAD/11.step | 输入STEP文件路径 |
| --output | -o | string | High_Speed_Data/Mesh/11.stl | 输出STL文件路径 |
### 运行示例
```bash
# 完整参数形式
python CAD2Mesh.py --precision 1 --input "High_Speed_Data/CAD/11.step" --output "High_Speed_Data/Mesh/11.stl"
# 简写参数形式
python CAD2Mesh.py -p 1 -i "High_Speed_Data/CAD/11.step" -o "High_Speed_Data/Mesh/11.stl"
```
### 运行日志说明
程序会输出三步处理日志及耗时:
1. STEP文件读取阶段
2. 网格离散化(轻量化)阶段
3. STL文件保存阶段
转换完成后会提示总耗时及STL文件打开工具建议(FreeCAD/MeshLab/3D Viewer)。
---
## 三、轮廓-平整度测量(Contour_Flatness.py)
### 功能说明
高精度计算点云到 CAD STEP 模型三角化表面的最近距离,输出平均距离(mean_dist)、最大距离(max_dist)和最小距离(min_dist),用于评估零件的平整度、平面度、轮廓偏差等几何精度指标;支持 CUDA 加速(自动检测),采用 torch.float64 高精度计算。
脚本现在支持两种模式:
- 默认批量模式:按任务编号从 `High_Speed_Data/CAD/` 和点云目录中读取多组数据,并输出 Excel 汇总。
- 单任务模式:添加 `--single` 后,使用 `--CAD` 和 `--pointcloud` 计算一组文件,可选 `--visualize` 显示热力图。批量模式会把所有成功执行的热力图和完整点云按原始坐标合并保存为一个 PLY,默认 `vis/combined_contour.ply`;使用 `--visualize` 时还会合并到一个窗口展示。
批量模式会优先使用 `Mesh/{样本名}.stl`,并按样本名匹配点云。例如 `13_1.stl` 和 `13_2.stl` 都属于任务 `13`,对应点云分别为 `13_1_noisy.ply`、`13_2_noisy.ply`;Excel 会按任务 `13` 汇总并记录样本数。没有匹配到 Mesh 样本时,脚本会退回旧的 `CAD/{任务号}.step` 模式。
距离计算流程已参考 `test8.py` 更新:默认先用点到三角形 AABB 的距离为每个点筛选候选三角面,再对候选三角面计算精确点到三角形距离。可通过 `--candidate_triangle_count` 控制候选数量;设为 `0` 或负数时退回旧的全量三角面分块精确搜索。
### 输入输出
- **输入**:
- CAD STEP 文件:路径示例 `High_Speed_Data/CAD/11.step`
- Mesh 文件:路径示例 `High_Speed_Data/Mesh/13_1.stl`
- 点云 PLY 文件:批量模式默认读取 `High_Speed_Data/Noise_PointCloud/{样本名}_noisy.ply`,也可用 `--pointcloud_kind clean` 读取 `High_Speed_Data/PointCloud/{样本名}.ply`
- **输出**:
- 平均距离(mean_dist):点云中所有点到 CAD 三角面的平均最近距离(单位:mm)
- 最大距离(max_dist):点云中所有点到 CAD 三角面的最大最近距离(单位:mm)
- 最小距离(min_dist):点云中所有点到 CAD 三角面的最小最近距离(单位:mm)
- 批量模式会生成 Excel 汇总文件,默认 `平整度.xlsx`
- 批量模式会保存合并热力图 PLY,默认 `vis/combined_contour.ply`,并默认加入完整点云 `datasets/train/train_2_10M.ply`
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|------|------|------|--------|------|
| --CAD | -c | string | High_Speed_Data/CAD/11.step | 单任务模式 CAD STEP 文件路径,仅 `--single` 时使用 |
| --pointcloud | -p | string | High_Speed_Data/Noise_PointCloud/11_noisy.ply | 单任务模式点云路径,仅 `--single` 时使用 |
| --tasks | - | string | 11,13,17,20,25,26,27,31,34,35,36,37,38,39,40,41,42,45 | 批量任务编号或样本名,多个值用英文逗号分隔;`13` 匹配 `13_1/13_2`,`13_1` 只匹配该样本 |
| --data_root | - | string | High_Speed_Data | 批量任务数据根目录 |
| --pointcloud_kind | - | string | noise | 批量模式点云来源:`noise` 读取 Noise_PointCloud,`clean` 读取 PointCloud |
| --excel_output | - | string | 平整度.xlsx | 批量结果 Excel 输出路径 |
| --combined_output | - | string | vis/combined_contour.ply | 批量合并热力图 PLY 输出 |
| --combined_context_pointcloud | - | string | datasets/train/train_2_10M.ply | 合并热力图时一起加入的完整点云,可切换为 `train_2_100M.ply` 或 `train_2_1T.ply` |
| --single | - | flag | false | 启用单任务模式 |
| --visualize | - | flag | false | 显示距离热力图;批量模式会合并所有成功结果 |
| --mesh_deflection | - | float | 0.1 | CAD 三角化精度,越小越精细但越慢 |
| --point_chunk_size | - | int | 2048 | 点云分块大小,显存/内存不足时可调小 |
| --triangle_chunk_size | - | int | 4096 | 全量精确搜索模式下的三角面分块大小;`candidate_triangle_count <= 0` 时使用 |
| --candidate_triangle_count | - | int | 32 | 每个点按 AABB 预筛的候选三角面数量;设为 0 或负数则使用全量精确搜索 |
### 运行示例
```bash
# 默认批量模式,使用 DEFAULT_TASK_IDS 并输出 平整度.xlsx
python Contour_Flatness.py
# 指定批量任务、点云来源、Excel 输出和候选三角面数量
python Contour_Flatness.py --tasks "13,31,40" --data_root "High_Speed_Data" --pointcloud_kind noise --excel_output "vis/flatness.xlsx" --candidate_triangle_count 32
# 单任务模式
python Contour_Flatness.py --single --CAD "High_Speed_Data/CAD/11.step" --pointcloud "High_Speed_Data/Noise_PointCloud/11_noisy.ply" --candidate_triangle_count 32
# 单任务模式并显示热力图
python Contour_Flatness.py --single -c "High_Speed_Data/CAD/11.step" -p "High_Speed_Data/PointCloud/11.ply" --candidate_triangle_count 32 --visualize
```
### 计算说明
1. CAD 读取:通过 pythonocc-core 读取 STEP,并使用 `mesh_deflection` 三角化。
2. 点云读取:优先使用 open3d(若安装),否则使用 trimesh 兼容读取。
3. 样本匹配:批量模式优先扫描 `Mesh` 文件夹,按样本名匹配 `PointCloud` 或 `Noise_PointCloud`;任务号取样本名下划线前的部分。
4. 距离计算:默认使用 AABB 候选三角面预筛,减少每个点参与精确距离计算的三角面数量;如果 `candidate_triangle_count <= 0`,则使用全量三角面分块精确搜索。
5. 批量结果:每个任务会记录成功/失败状态,成功样本会按任务号汇总并缓存在 `task_distance_data` 中;合并热力图会保存到 `--combined_output`,使用 `--visualize` 时会统一合并展示。
6. 精度保障:采用 torch.float64 高精度计算,支持 GPU/CPU 自动切换。
---
## 四、点云曲率估计(curvature_estimation.py)
### 功能说明
基于Open3D对工业零件点云进行曲率计算,通过邻域协方差矩阵分解获取曲率值,支持曲率彩色可视化,用于识别零件边缘、棱角、曲面等特征区域,为表面缺陷检测提供依据。
### 输入输出
- **输入**:PLY格式点云文件(路径示例:`High_Speed_Data/PointCloud/11.ply`)
- **输出**:曲率统计值、彩色可视化点云、可保存带曲率信息的PLY文件
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|--------------|------|--------|---------------------------------|----------------------------------------|
| --ply_path | — | string | ./High_Speed_Data/PointCloud/11.ply | 输入点云路径 |
| --radius | — | float | 2.0 | 邻域搜索半径 |
| --max_nn | — | int | 30 | 最大邻域点数 |
| --color_scale| — | float | 3.0 | 曲率颜色缩放因子 |
| --save | — | string | None | 保存着色点云路径(可选)|
### 运行示例
```bash
# 基础运行
python curvature_estimation.py --ply_path "High_Speed_Data/PointCloud/11.ply"
# 保存结果
python curvature_estimation.py --ply_path "High_Speed_Data/PointCloud/11.ply" --save "High_Speed_Data/Result/11_curvature.ply"
```
### 计算说明
1. 自动估计法线并构建KD-Tree加速邻域搜索
2. 邻域点不足3个时自动标记为高曲率
3. 颜色映射:红色=高曲率,绿色=低曲率
---
## 五、点云去噪处理(denoise.py)
### 功能说明
集成SOR、ROR、PF、VGF、BF五种去噪与滤波算法,自动添加高斯噪声并对比效果,通过Chamfer Distance量化评估去噪质量,用于扫描点云预处理,提升测量精度。
### 输入输出
- **输入**:PLY格式干净点云(路径示例:`High_Speed_Data/PointCloud/11.ply`)
- **输出**:各算法去噪指标、保留率、移除点数、CD距离、可视化对比
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|--------------|------|--------|---------------------------------|----------------------------------------|
| --ply_path | -p | string | ./High_Speed_Data/PointCloud/11.ply | 输入点云路径 |
| --noise_std | -n | float | 4.0 | 高斯噪声标准差 |
| --voxel_size | -v | float | 0.05 | 体素滤波大小 |
| --no_visualize | — | bool | False | 关闭可视化窗口 |
### 运行示例
```bash
# 基础去噪对比
python denoise.py -p "High_Speed_Data/PointCloud/11.ply" --noise_std 2.0
# 无可视化运行
python denoise.py -p "High_Speed_Data/PointCloud/11.ply" --no_visualize
```
### 计算说明
1. 自动给点云加噪,模拟真实扫描噪声
2. 依次运行SOR/ROR/PF/VGF/BF算法
3. 输出保留率、移除点数、Chamfer Distance评估结果
---
## 六、平面拟合估计(plane_estimation.py)
### 功能说明
基于Open3D内置RANSAC算法自动拟合点云中平面结构,输出平面方程、内点占比、法线误差、拟合RMSE,用于提取零件基准平面。
### 输入输出
- **输入**:可自定义生成带噪平面点云
- **输出**:平面方程、误差统计、拟合可视化
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|----------------|------|--------|--------|--------------------------|
| --threshold_mm | -t | float | 0.5 | 平面距离阈值(mm)|
| --max_iterations | -i | int | 2000 | RANSAC迭代次数 |
### 运行示例
```bash
# 默认运行
python plane_estimation.py
# 自定义参数
python plane_estimation.py --threshold_mm 0.3 --max_iterations 1500
```
### 计算说明
1. 支持生成带噪声标准平面用于测试
2. 输出平面法向量与理想Z轴夹角误差、偏移误差、拟合RMSE
---
## 七、点云配准与适应度评估(point-fitness.py)
### 功能说明
对点云执行旋转/平移变换,通过RANSAC+ICP完成高精度配准,输出配准适应度Fitness、RMSE,并计算配准后点云到参考网格的距离,用于评估配准精度与零件偏差。
### 输入输出
- **输入**:待配准PLY点云、参考STL网格
- **输出**:变换矩阵、配准分数、误差、点云到网格距离统计、配准可视化
### 命令行参数说明
| 参数 | 简写 | 类型 | 默认值 | 说明 |
|--------------|------|--------|---------------------------------------------|--------------------|
| --ply_path | -p | string | ./High_Speed_Data/Noise_PointCloud/11_noisy.ply | 点云路径 |
| --mesh_path | -m | string | ./High_Speed_Data/Mesh/11.stl | 网格路径 |
| --voxel_size | -v | float | 10 | 配准下采样大小 |
| --no_visualize | — | bool | False | 关闭可视化 |
### 运行示例
```bash
# 完整配准+距离计算
python point-fitness.py -p "High_Speed_Data/Noise_PointCloud/11_noisy.ply" -m "High_Speed_Data/Mesh/11.stl" -v 5
# 无可视化
python point-fitness.py -p "High_Speed_Data/Noise_PointCloud/11_noisy.ply" -m "High_Speed_Data/Mesh/11.stl" --no_visualize
```
### 计算说明
1. 支持指定XYZ三轴旋转与随机平移
2. RANSAC粗配准 → ICP精配准
3. 输出Fitness(越高越好)、RMSE(越低越好)
---
## 八、完整工作流程示例(任务11全流程)
1. CAD转轻量化网格
```bash
python CAD2Mesh.py -p 0.5 -i "High_Speed_Data/CAD/11.step" -o "High_Speed_Data/Mesh/11.stl"
```
2. 点云去噪预处理
```bash
python denoise.py -p "High_Speed_Data/PointCloud/11.ply" --noise_std 2.0
```
3. 平面基准拟合
```bash
python plane_estimation.py
```
4. 曲率特征分析
```bash
python curvature_estimation.py --ply_path "High_Speed_Data/PointCloud/11.ply"
```
5. 轮廓-平整度测量
```bash
python Contour_Flatness.py --tasks "13,31,40" --data_root "High_Speed_Data" --excel_output "vis/flatness.xlsx" --candidate_triangle_count 32
```
6. 点云配准评估
```bash
python point-fitness.py -p "High_Speed_Data/Noise_PointCloud/11_noisy.ply" -m "High_Speed_Data/Mesh/11.stl"
```
---
## 九、常见问题
1. STEP文件读取失败:检查路径、文件完整性、OCC Core安装
2. 点云读取报错:安装open3d(`pip install open3d`)
3. CUDA Out of Memory:减小分块大小或降低点云密度
4. 去噪效果差:调小SOR标准差、增大ROR半径
5. 平面拟合失败:提高RANSAC迭代次数、降低距离阈值
6. 配准Fitness低:减小voxel_size,降低旋转/平移幅度
7. 可视化无窗口:服务器环境加`--no_visualize`参数

View File

@@ -0,0 +1,447 @@
import os
import argparse
import numpy as np
import open3d as o3d
from OCC.Core.STEPControl import STEPControl_Reader
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_FACE
from OCC.Core.BRep import BRep_Tool
try:
from OCC.Core.TopoDS import topods
USE_TOPODS = "topods"
except ImportError:
from OCC.Core.TopoDS import TopoDS
USE_TOPODS = "TopoDS"
# ============================================================
# STEP -> Open3D Mesh
# ============================================================
def read_step_file(step_path):
reader = STEPControl_Reader()
status = reader.ReadFile(step_path)
if status != IFSelect_RetDone:
raise RuntimeError(f"STEP 文件读取失败: {step_path}")
reader.TransferRoots()
shape = reader.OneShape()
print("[OK] STEP loaded:", step_path)
return shape
def occ_shape_to_numpy_mesh(shape):
vertices = []
triangles = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_count = 0
tri_count = 0
while explorer.More():
current_shape = explorer.Current()
if USE_TOPODS == "topods":
face = topods.Face(current_shape)
else:
face = TopoDS.Face(current_shape)
location = face.Location()
triangulation = BRep_Tool.Triangulation(face, location)
if triangulation is None:
explorer.Next()
continue
trsf = location.Transformation()
base_index = len(vertices)
for i in range(1, triangulation.NbNodes() + 1):
pnt = triangulation.Node(i)
pnt.Transform(trsf)
vertices.append([
pnt.X(),
pnt.Y(),
pnt.Z()
])
for i in range(1, triangulation.NbTriangles() + 1):
tri = triangulation.Triangle(i)
n1, n2, n3 = tri.Get()
triangles.append([
base_index + n1 - 1,
base_index + n2 - 1,
base_index + n3 - 1
])
tri_count += 1
face_count += 1
explorer.Next()
vertices = np.asarray(vertices, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int32)
print("[OK] CAD mesh extracted")
print(" CAD faces:", face_count)
print(" mesh vertices:", vertices.shape)
print(" mesh triangles:", triangles.shape)
if vertices.shape[0] == 0 or triangles.shape[0] == 0:
raise RuntimeError("CAD 三角网格提取失败")
return vertices, triangles
def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
shape = read_step_file(step_path)
print("[INFO] Meshing CAD...")
mesh_algo = BRepMesh_IncrementalMesh(shape, mesh_deflection)
mesh_algo.Perform()
vertices, triangles = occ_shape_to_numpy_mesh(shape)
mesh = o3d.geometry.TriangleMesh()
mesh.vertices = o3d.utility.Vector3dVector(vertices)
mesh.triangles = o3d.utility.Vector3iVector(triangles)
mesh.remove_duplicated_vertices()
mesh.remove_duplicated_triangles()
mesh.remove_degenerate_triangles()
mesh.compute_vertex_normals()
return mesh
# ============================================================
# 点云处理
# ============================================================
def make_translation_matrix(dx, dy, dz):
T = np.eye(4, dtype=np.float64)
T[0, 3] = dx
T[1, 3] = dy
T[2, 3] = dz
return T
def crop_front_tail_by_x(pcd, ratio=0.08):
"""
按 X 轴裁剪车头和车尾。
front: X 最小端
tail: X 最大端
"""
points = np.asarray(pcd.points)
min_bound = points.min(axis=0)
max_bound = points.max(axis=0)
x_min = min_bound[0]
x_max = max_bound[0]
x_len = x_max - x_min
front_min = min_bound.copy()
front_max = max_bound.copy()
front_min[0] = x_min
front_max[0] = x_min + ratio * x_len
tail_min = min_bound.copy()
tail_max = max_bound.copy()
tail_min[0] = x_max - ratio * x_len
tail_max[0] = x_max
front_box = o3d.geometry.AxisAlignedBoundingBox(
min_bound=front_min,
max_bound=front_max
)
tail_box = o3d.geometry.AxisAlignedBoundingBox(
min_bound=tail_min,
max_bound=tail_max
)
front_pcd = pcd.crop(front_box)
tail_pcd = pcd.crop(tail_box)
print("[OK] Crop front/tail by X")
print(" X range:", x_min, x_max)
print(" front points:", np.asarray(front_pcd.points).shape)
print(" tail points:", np.asarray(tail_pcd.points).shape)
return front_pcd, tail_pcd
def add_gaussian_noise(pcd, noise_std=0.0):
"""
给点云添加高斯噪声。
noise_std=0 表示不加噪声。
"""
if noise_std <= 0:
return pcd
points = np.asarray(pcd.points)
noise = np.random.normal(
loc=0.0,
scale=noise_std,
size=points.shape
)
noisy_points = points + noise
noisy_pcd = o3d.geometry.PointCloud()
noisy_pcd.points = o3d.utility.Vector3dVector(noisy_points)
if pcd.has_colors():
noisy_pcd.colors = pcd.colors
if pcd.has_normals():
noisy_pcd.normals = pcd.normals
return noisy_pcd
def visualize_pair(cad_pcd, shifted_pcd):
cad_vis = o3d.geometry.PointCloud(cad_pcd)
shifted_vis = o3d.geometry.PointCloud(shifted_pcd)
cad_vis.paint_uniform_color([0.0, 0.5, 1.0])
shifted_vis.paint_uniform_color([1.0, 0.0, 0.0])
o3d.visualization.draw_geometries(
[cad_vis, shifted_vis],
window_name="Blue = CAD sampled point cloud, Red = shifted test point cloud"
)
# ============================================================
# 主函数
# ============================================================
def generate_shifted_train_pointcloud(
cad_path,
output_dir,
sample_points=1000000,
mesh_deflection=0.1,
crop_ratio=0.08,
dx=100.0,
dy=20.0,
dz=-10.0,
noise_std=0.0,
visualize=True
):
os.makedirs(output_dir, exist_ok=True)
# 1. CAD -> mesh
cad_mesh = step_to_open3d_mesh(
cad_path,
mesh_deflection=mesh_deflection
)
# 2. mesh -> 点云
print(f"[INFO] Sampling CAD surface: {sample_points} points")
cad_pcd = cad_mesh.sample_points_uniformly(
number_of_points=sample_points
)
cad_pcd_path = os.path.join(output_dir, "cad_sampled_whole.ply")
o3d.io.write_point_cloud(cad_pcd_path, cad_pcd)
print("[OK] Saved:", cad_pcd_path)
# 3. 裁剪原始 CAD 点云的车头 / 车尾
cad_front, cad_tail = crop_front_tail_by_x(
cad_pcd,
ratio=crop_ratio
)
o3d.io.write_point_cloud(
os.path.join(output_dir, "cad_front.ply"),
cad_front
)
o3d.io.write_point_cloud(
os.path.join(output_dir, "cad_tail.ply"),
cad_tail
)
# 4. 生成固定平移矩阵
T = make_translation_matrix(dx, dy, dz)
print("\n========== Fixed Translation ==========")
print("dx:", dx)
print("dy:", dy)
print("dz:", dz)
print("T:")
print(T)
# 5. 对整车点云施加固定平移
shifted_pcd = o3d.geometry.PointCloud(cad_pcd)
shifted_pcd.transform(T)
# 6. 可选:加噪声,模拟真实扫描点云
shifted_pcd = add_gaussian_noise(
shifted_pcd,
noise_std=noise_std
)
shifted_path = os.path.join(output_dir, "shifted_whole_pointcloud.ply")
o3d.io.write_point_cloud(shifted_path, shifted_pcd)
print("[OK] Saved:", shifted_path)
# 7. 裁剪平移后的车头 / 车尾
shifted_front, shifted_tail = crop_front_tail_by_x(
shifted_pcd,
ratio=crop_ratio
)
shifted_front_path = os.path.join(output_dir, "shifted_front.ply")
shifted_tail_path = os.path.join(output_dir, "shifted_tail.ply")
o3d.io.write_point_cloud(shifted_front_path, shifted_front)
o3d.io.write_point_cloud(shifted_tail_path, shifted_tail)
print("[OK] Saved:", shifted_front_path)
print("[OK] Saved:", shifted_tail_path)
# 8. 保存真实位移矩阵
matrix_path = os.path.join(output_dir, "GT_translation_matrix.txt")
np.savetxt(matrix_path, T, fmt="%.10f")
print("[OK] Saved:", matrix_path)
# 9. 保存真实位移向量
translation_path = os.path.join(output_dir, "GT_translation_vector.txt")
np.savetxt(
translation_path,
np.array([dx, dy, dz], dtype=np.float64),
fmt="%.10f"
)
print("[OK] Saved:", translation_path)
# 10. 可视化
if visualize:
# 百万级点云显示会卡,所以这里可视化时降采样
cad_vis = cad_pcd.voxel_down_sample(voxel_size=20.0)
shifted_vis = shifted_pcd.voxel_down_sample(voxel_size=20.0)
visualize_pair(cad_vis, shifted_vis)
return {
"cad_sampled_path": cad_pcd_path,
"shifted_pointcloud_path": shifted_path,
"shifted_front_path": shifted_front_path,
"shifted_tail_path": shifted_tail_path,
"translation_matrix_path": matrix_path,
"translation_vector": np.array([dx, dy, dz], dtype=np.float64)
}
# ============================================================
# 命令行入口
# ============================================================
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="从整车 CAD 生成测试点云,并施加固定前后/整体位移"
)
parser.add_argument(
"--cad",
default=r"datasets/CAD/M02车.stp",
help="整车 CAD STEP/STP 文件路径"
)
parser.add_argument(
"--output_dir",
default=r"datasets\shift_test_output",
help="输出目录"
)
parser.add_argument(
"--sample_points",
type=int,
default=1000000,
help="从 CAD 表面采样的点数,默认 1000000"
)
parser.add_argument(
"--mesh_deflection",
type=float,
default=0.1,
help="CAD 三角化精度,越小越精细,默认 0.1"
)
parser.add_argument(
"--crop_ratio",
type=float,
default=0.08,
help="车头/车尾裁剪比例,默认 0.08"
)
parser.add_argument(
"--dx",
type=float,
default=100.0,
help="X 方向固定平移量"
)
parser.add_argument(
"--dy",
type=float,
default=20.0,
help="Y 方向固定平移量"
)
parser.add_argument(
"--dz",
type=float,
default=-10.0,
help="Z 方向固定平移量"
)
parser.add_argument(
"--noise_std",
type=float,
default=0.0,
help="高斯噪声标准差,默认 0 表示不加噪声"
)
parser.add_argument(
"--no_visualize",
action="store_true",
help="不显示可视化窗口"
)
args = parser.parse_args()
result = generate_shifted_train_pointcloud(
cad_path=args.cad,
output_dir=args.output_dir,
sample_points=args.sample_points,
mesh_deflection=args.mesh_deflection,
crop_ratio=args.crop_ratio,
dx=args.dx,
dy=args.dy,
dz=args.dz,
noise_std=args.noise_std,
visualize=not args.no_visualize
)
print("\n========== Result ==========")
print("CAD sampled point cloud:", result["cad_sampled_path"])
print("Shifted whole point cloud:", result["shifted_pointcloud_path"])
print("Shifted front point cloud:", result["shifted_front_path"])
print("Shifted tail point cloud:", result["shifted_tail_path"])
print("GT translation vector:", result["translation_vector"])
print("GT matrix:", result["translation_matrix_path"])

View File

@@ -0,0 +1,362 @@
import numpy as np
from scipy.spatial import KDTree
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
def point_to_line_distance(point, line_start, line_end):
"""
计算点到三维线段的最短距离
Args:
point: 待计算点 (x, y, z)
line_start: 线段起点 (x, y, z)
line_end: 线段终点 (x, y, z)
Returns:
点到线段的垂直距离
"""
p = np.array(point)
a = np.array(line_start)
b = np.array(line_end)
# 计算向量
ab = b - a
ap = p - a
# 投影参数 t
t = np.dot(ap, ab) / np.dot(ab, ab)
# 找到线段上的最近点
if t <= 0:
closest = a
elif t >= 1:
closest = b
else:
closest = a + t * ab
# 计算距离
distance = np.linalg.norm(p - closest)
return distance
def point_to_plane_distance(point, plane_point1, plane_point2, plane_point3):
"""
计算点到三维平面的距离(三点确定一个平面)
Args:
point: 待计算点 (x, y, z)
plane_point1, plane_point2, plane_point3: 平面上的三个点
Returns:
点到平面的垂直距离
"""
p = np.array(point)
p1 = np.array(plane_point1)
p2 = np.array(plane_point2)
p3 = np.array(plane_point3)
# 计算平面法向量
v1 = p2 - p1
v2 = p3 - p1
normal = np.cross(v1, v2)
normal = normal / np.linalg.norm(normal)
# 计算点到平面的距离
distance = abs(np.dot(normal, p - p1))
return distance
def douglas_peucker_3d(points, epsilon, use_plane=False):
"""
3D Douglas-Peucker 算法
Args:
points: 三维点列表或numpy数组,形状 (N, 3)
epsilon: 容差阈值
use_plane: 是否使用平面距离(True)还是线段距离(False)
Returns:
简化后的点云列表
"""
if len(points) <= 2:
return points.tolist() if isinstance(points, np.ndarray) else points
# 转换为列表格式
if isinstance(points, np.ndarray):
points = points.tolist()
# 首尾点
start = points[0]
end = points[-1]
# 找到距离最大的点
max_dist = 0
max_index = 0
for i in range(1, len(points) - 1):
if use_plane and len(points) > 2:
# 使用平面距离(需要至少3个点确定平面)
if i > 0 and i < len(points) - 1:
# 使用当前点、前一个点和后一个点确定平面
plane_p1 = points[i - 1]
plane_p2 = points[i]
plane_p3 = points[i + 1]
dist = point_to_plane_distance(points[i], plane_p1, plane_p2, plane_p3)
else:
dist = point_to_line_distance(points[i], start, end)
else:
# 使用线段距离
dist = point_to_line_distance(points[i], start, end)
if dist > max_dist:
max_dist = dist
max_index = i
# 递归简化
if max_dist > epsilon:
# 保留该点,递归处理左右两部分
left = douglas_peucker_3d(points[:max_index + 1], epsilon, use_plane)
right = douglas_peucker_3d(points[max_index:], epsilon, use_plane)
# 合并结果(避免重复端点)
return left[:-1] + right
else:
# 只保留首尾点
return [start, end]
class DouglasPeucker3D:
"""
3D Douglas-Peucker 算法的面向对象封装
支持点云简化、轨迹压缩、轮廓提取等
"""
def __init__(self, epsilon=0.01, use_plane=False):
"""
Args:
epsilon: 容差阈值
use_plane: 是否使用平面距离(更适合曲面点云)
"""
self.epsilon = epsilon
self.use_plane = use_plane
def simplify(self, points):
"""
简化点云
Args:
points: (N, 3) 的 numpy 数组或列表
Returns:
简化后的点云 (N', 3) numpy 数组
"""
if isinstance(points, np.ndarray):
points = points.tolist()
simplified = douglas_peucker_3d(points, self.epsilon, self.use_plane)
return np.array(simplified)
def simplify_trajectory(self, trajectory, time_weight=False):
"""
专门用于轨迹简化(考虑时间维度)
Args:
trajectory: (N, 4) 的数组 [x, y, z, t] 或 [x, y, z]
time_weight: 是否加入时间权重
Returns:
简化后的轨迹
"""
if time_weight and trajectory.shape[1] == 4:
# 时间加权:将时间作为第四维参与距离计算
# 需要自定义距离函数
pass
return self.simplify(trajectory[:, :3])
def extract_contour(self, points, return_indices=False):
"""
提取点云轮廓(结合法线估计)
Args:
points: (N, 3) 点云
return_indices: 是否返回原始点索引
Returns:
轮廓点集
"""
# 先进行粗略简化
simplified = self.simplify(points)
# 这里可以结合法线估计进一步提取边界点
# 简化实现:返回简化后的点作为轮廓候选
if return_indices:
# 查找简化点在原始点云中的最近邻索引
from scipy.spatial import KDTree
tree = KDTree(points)
indices = []
for p in simplified:
_, idx = tree.query(p)
indices.append(idx)
return simplified, np.array(indices)
return simplified
# ========== 示例和使用 ==========
def generate_test_curve(n_points=100, noise=0.02):
"""
生成测试用的三维曲线(螺旋线 + 噪声)
"""
t = np.linspace(0, 4 * np.pi, n_points)
x = np.sin(t)
y = np.cos(t)
z = t / (2 * np.pi)
points = np.column_stack([x, y, z])
# 添加噪声
if noise > 0:
points += np.random.randn(n_points, 3) * noise
return points
def visualize_simplification(original, simplified, title="3D Douglas-Peucker Simplification"):
"""
可视化原始点云和简化后的点云
"""
fig = plt.figure(figsize=(12, 6))
# 原始点云
ax1 = fig.add_subplot(121, projection='3d')
ax1.scatter(original[:, 0], original[:, 1], original[:, 2],
c='blue', s=10, alpha=0.6, label='Original')
ax1.plot(original[:, 0], original[:, 1], original[:, 2],
'b-', alpha=0.3)
ax1.set_title(f'Original: {len(original)} points')
ax1.legend()
# 简化后点云
ax2 = fig.add_subplot(122, projection='3d')
ax2.scatter(simplified[:, 0], simplified[:, 1], simplified[:, 2],
c='red', s=30, alpha=0.8, label='Simplified')
ax2.plot(simplified[:, 0], simplified[:, 1], simplified[:, 2],
'r-', linewidth=2)
ax2.set_title(f'Simplified: {len(simplified)} points (ε={epsilon})')
ax2.legend()
plt.suptitle(title)
plt.tight_layout()
plt.show()
def compare_epsilon(points, epsilons):
"""
比较不同 epsilon 值的简化效果
"""
results = {}
for eps in epsilons:
dp = DouglasPeucker3D(epsilon=eps)
simplified = dp.simplify(points)
results[eps] = {
'points': simplified,
'count': len(simplified),
'compression_ratio': len(simplified) / len(points)
}
print(f"ε={eps:.4f}: {len(simplified)} 点 (压缩率: {results[eps]['compression_ratio']:.2%})")
return results
def demo_point_cloud():
"""
点云简化演示(使用螺旋线模拟)
"""
print("=" * 50)
print("3D Douglas-Peucker 算法演示")
print("=" * 50)
# 生成测试数据
points = generate_test_curve(200, noise=0.01)
print(f"\n原始点云: {len(points)} 个点")
# 测试不同 epsilon
epsilons = [0.01, 0.03, 0.05, 0.1, 0.2]
print("\n不同容差阈值的简化效果:")
results = compare_epsilon(points, epsilons)
# 可视化
dp = DouglasPeucker3D(epsilon=0.05)
simplified = dp.simplify(points)
visualize_simplification(points, simplified,
f"3D DP Simplification (ε=0.05, {len(simplified)}/{len(points)} points)")
def demo_trajectory():
"""
轨迹简化演示(模拟移动轨迹)
"""
print("\n" + "=" * 50)
print("3D 轨迹简化演示")
print("=" * 50)
# 生成测试轨迹(随机路径)
np.random.seed(42)
n_points = 100
t = np.linspace(0, 10, n_points)
x = np.sin(t) + np.random.randn(n_points) * 0.02
y = np.cos(t) + np.random.randn(n_points) * 0.02
z = t / 5 + np.random.randn(n_points) * 0.01
trajectory = np.column_stack([x, y, z])
print(f"原始轨迹: {len(trajectory)} 个点")
# 简化轨迹
dp = DouglasPeucker3D(epsilon=0.08)
simplified = dp.simplify_trajectory(trajectory)
print(f"简化后轨迹: {len(simplified)} 个点 (压缩率: {len(simplified) / len(trajectory):.2%})")
# 可视化
visualize_simplification(trajectory, simplified, "3D Trajectory Simplification")
def demo_point_cloud_from_file(pcd_file=None):
"""
从文件加载点云并简化
"""
try:
import open3d as o3d
if pcd_file is None:
# 使用示例:生成随机点云
print("\n生成随机点云进行演示...")
points = np.random.randn(500, 3) * 0.5
else:
pcd = o3d.io.read_point_cloud(pcd_file)
points = np.asarray(pcd.points)
print(f"原始点云: {len(points)} 个点")
# 简化
dp = DouglasPeucker3D(epsilon=0.05)
# 注意:传统 DP 算法需要有序点集,对于无序点云需要先排序或使用其他方法
# 这里演示对有序轨迹/轮廓的应用
print("\n注意: DP算法适用于有序点集(如轮廓线、轨迹),")
print("对于无序点云,建议先进行排序或使用其他简化方法(如体素滤波)")
except ImportError:
print("需要安装 open3d 来读取点云文件: pip install open3d")
if __name__ == "__main__":
# 运行演示
demo_point_cloud()
demo_trajectory()
# 如果需要处理实际点云文件
# demo_point_cloud_from_file("your_pointcloud.pcd")

View File

@@ -0,0 +1,95 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
import numpy as np
from scipy.spatial import KDTree
def chamfer_distance(pcd1, pcd2):
"""
计算两个点云之间的 CD 距离(Chamfer Distance)
Args:
pcd1: Open3D 点云对象1
pcd2: Open3D 点云对象2
Returns:
cd: Chamfer Distance 值
cd1: 点云1 到 点云2 的平均距离
cd2: 点云2 到 点云1 的平均距离
"""
# 获取点坐标
points1 = np.asarray(pcd1.points)
points2 = np.asarray(pcd2.points)
# 构建 KD-Tree 加速最近邻搜索
tree1 = KDTree(points1)
tree2 = KDTree(points2)
# 计算 points1 中每个点到 points2 的最近距离
dist1, _ = tree2.query(points1, k=1)
cd1 = np.mean(dist1 ** 2) # 平方距离的平均值
# 计算 points2 中每个点到 points1 的最近距离
dist2, _ = tree1.query(points2, k=1)
cd2 = np.mean(dist2 ** 2) # 平方距离的平均值
# 总 CD 距离(双向平均)
cd = cd1 + cd2
return np.sqrt(cd), cd1, cd2
def bilateral_filter(pcd, sigma_s=0.01, sigma_r=0.1):
"""
对点云进行双边滤波
Args:
pcd: Open3D点云对象
sigma_s: 空间标准差
sigma_r: 特征(法线)标准差
"""
points = np.asarray(pcd.points)
normals = np.asarray(pcd.normals)
# 构建KD树
tree = KDTree(points)
filtered_points = np.zeros_like(points)
for i, p in enumerate(points):
# 搜索邻域点
indices = tree.query_ball_point(p, r=2 * sigma_s)
if len(indices) <= 1:
filtered_points[i] = p
continue
neigh_points = points[indices]
neigh_normals = normals[indices]
# 计算空间权重
spatial_dist = np.linalg.norm(neigh_points - p, axis=1)
w_s = np.exp(-spatial_dist ** 2 / (2 * sigma_s ** 2))
# 计算特征(法线)权重
normal_diff = np.linalg.norm(neigh_normals - normals[i], axis=1)
w_r = np.exp(-normal_diff ** 2 / (2 * sigma_r ** 2))
# 双边权重
w = w_s * w_r
w = w / w.sum() # 归一化
# 加权平均
filtered_points[i] = np.sum(neigh_points.T * w, axis=1)
# 更新点云
filtered_pcd = o3d.geometry.PointCloud()
filtered_pcd.points = o3d.utility.Vector3dVector(filtered_points)
filtered_pcd.normals = o3d.utility.Vector3dVector(normals)
return filtered_pcd
# 使用示例
pcd = o3d.io.read_point_cloud("E:\point_distance\data\part_car_head.ply")
pcd.estimate_normals()
pcd_filtered = bilateral_filter(pcd, sigma_s=10, sigma_r=10)
cd=chamfer_distance(pcd,pcd_filtered)
print(cd)

View File

@@ -0,0 +1,54 @@
import open3d as o3d
import numpy as np
def compute_curvature_open3d(pcd, radius=0.1, max_nn=30):
"""
使用 Open3D 计算曲率
Args:
pcd: Open3D 点云对象
radius: 搜索半径
max_nn: 最大邻居数
Returns:
curvature: 曲率数组
"""
# 估计法线
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn)
)
# 计算曲率(Open3D 在估计法线时已计算)
# 曲率值存储在 pcd.point.normals 的第三个分量?
# 实际上 Open3D 需要单独计算
# 方法:使用 PCA 重新计算
points = np.asarray(pcd.points)
n_points = len(points)
curvature = np.zeros(n_points)
tree = o3d.geometry.KDTreeFlann(pcd)
for i in range(n_points):
[k, idx, _] = tree.search_radius_vector_3d(pcd.points[i], radius)
if k < 3:
curvature[i] = 1.0
continue
neighbors = points[idx]
centroid = np.mean(neighbors, axis=0)
centered = neighbors - centroid
cov = np.cov(centered.T)
eigenvalues = np.linalg.eigvalsh(cov)
eigenvalues = np.sort(eigenvalues)
curvature[i] = eigenvalues[0] / (eigenvalues.sum() + 1e-8)
return curvature
# 使用示例
pcd = o3d.io.read_point_cloud("pointcloud.pcd")
curvature = compute_curvature_open3d(pcd, radius=0.05)

View File

@@ -0,0 +1,50 @@
import open3d as o3d
import numpy as np
def dbscan_open3d(pcd, eps=0.1, min_points=10):
"""
使用 Open3D 进行 DBSCAN 聚类
Args:
pcd: Open3D 点云对象
eps: 邻域半径
min_points: 最小邻域点数
Returns:
labels: 每个点的簇标签(-1 表示噪声)
clusters: 各簇的点索引列表
"""
# DBSCAN 聚类
labels = np.array(pcd.cluster_dbscan(eps=eps, min_points=min_points))
# 提取各簇索引
unique_labels = np.unique(labels)
clusters = []
for label in unique_labels:
if label == -1:
continue
clusters.append(np.where(labels == label)[0])
return labels, clusters
# 使用示例
pcd = o3d.io.read_point_cloud("E:\point_distance\data\part_car_head.ply")
# 预处理
pcd = pcd.voxel_down_sample(voxel_size=0.05)
pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
# DBSCAN 聚类
labels, clusters = dbscan_open3d(pcd, eps=0.1, min_points=50)
print(f"找到 {len(clusters)} 个聚类")
print(f"噪声点: {np.sum(labels == -1)}")
# 为每个聚类着色
colors = np.random.rand(len(clusters), 3)
colored_points = np.zeros((len(pcd.points), 3))
for i, cluster in enumerate(clusters):
colored_points[cluster] = colors[i]
pcd.colors = o3d.utility.Vector3dVector(colored_points)
o3d.visualization.draw_geometries([pcd])

View File

@@ -0,0 +1,65 @@
import open3d as o3d
import numpy as np
def compute_curvature_open3d(pcd, radius=0.05, max_nn=30):
"""
使用 Open3D 计算点云曲率
Args:
pcd: Open3D 点云对象
radius: 搜索半径
max_nn: 最大邻居数
Returns:
curvatures: (N,) 曲率数组
"""
# 估计法线(同时获取协方差矩阵信息)
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn)
)
# 获取点云数据
points = np.asarray(pcd.points)
n_points = len(points)
curvatures = np.zeros(n_points)
# 构建 KD-Tree
tree = o3d.geometry.KDTreeFlann(pcd)
for i in range(n_points):
# 搜索邻域点
[k, idx, _] = tree.search_radius_vector_3d(pcd.points[i], radius)
if k < 3:
curvatures[i] = 1.0
continue
# 获取邻域点坐标
neighbors = points[idx]
# 计算协方差矩阵
centroid = np.mean(neighbors, axis=0)
centered = neighbors - centroid
cov = np.cov(centered.T)
# 特征值分解
eigenvalues = np.linalg.eigvalsh(cov)
eigenvalues = np.sort(eigenvalues)
# 计算曲率
curvatures[i] = eigenvalues[0] / (eigenvalues.sum() + 1e-8)
return curvatures
# 使用示例
pcd = o3d.io.read_point_cloud("E:\point_distance\data\PointCloud\\38.ply")
curvatures = compute_curvature_open3d(pcd, radius=50, max_nn=30)
# 将曲率作为颜色可视化
colors = np.zeros((len(pcd.points), 3))
colors[:, 0] = curvatures*3 # 红色通道表示曲率
colors[:, 1] = 1 - curvatures*3 # 绿色通道为互补色
pcd.colors = o3d.utility.Vector3dVector(colors)
o3d.visualization.draw_geometries([pcd])

View File

@@ -0,0 +1,47 @@
import open3d as o3d
import numpy as np
def euclidean_clustering_open3d(pcd, radius=0.1, min_cluster_size=50, max_cluster_size=10000):
"""
使用 Open3D 进行欧几里得聚类
Args:
pcd: Open3D 点云对象
radius: 聚类半径
min_cluster_size: 最小簇大小
max_cluster_size: 最大簇大小
Returns:
labels: 每个点的簇标签(-1 表示噪声/无效簇)
clusters: 各簇的点索引列表
"""
# 欧几里得聚类
labels = np.array(pcd.cluster_dbscan(eps=radius, min_points=min_cluster_size))
# 统计簇信息
unique_labels = np.unique(labels)
clusters = []
for label in unique_labels:
if label == -1: # -1 表示噪声点
continue
cluster_indices = np.where(labels == label)[0]
if min_cluster_size <= len(cluster_indices) <= max_cluster_size:
clusters.append(cluster_indices)
return labels, clusters
# 使用示例
pcd = o3d.io.read_point_cloud("E:\point_distance\data\part_car_left_side.ply")
labels, clusters = euclidean_clustering_open3d(pcd, radius=4, min_cluster_size=100)
print(f"找到 {len(clusters)} 个聚类")
# 为每个聚类着色
colors = np.random.rand(len(clusters), 3)
colored_points = np.zeros((len(pcd.points), 3))
for i, cluster in enumerate(clusters):
colored_points[cluster] = colors[i]
pcd.colors = o3d.utility.Vector3dVector(colored_points)
o3d.visualization.draw_geometries([pcd])

View File

@@ -0,0 +1,16 @@
import open3d as o3d
import numpy as np
pcd = o3d.io.read_point_cloud("pointcloud.pcd")
# 1. 计算法线
pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamKNN(k=30))
# 2. 提取FPFH特征
fpfh = o3d.pipelines.registration.compute_fpfh_feature(
pcd,
o3d.geometry.KDTreeSearchParamKNN(k=50)
)
# fpfh.data 是 (33, N) 的矩阵,每列是一个点的33维FPFH描述子
print(fpfh.data.shape) # (33, N)

View File

@@ -0,0 +1,17 @@
import open3d as o3d
import numpy as np
pcd = o3d.io.read_point_cloud("pointcloud.pcd")
# 估计法线
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(
radius=0.1, # 搜索半径
max_nn=30 # 最大邻居数
)
)
# 可视化法线
o3d.visualization.draw_geometries([pcd], point_show_normal=True)
# 获取法线数据
normals = np.asarray(pcd.normals)

View File

@@ -0,0 +1,315 @@
import copy
from scipy.spatial.transform import Rotation as R
import trimesh
import numpy as np
import open3d as o3d
import time
def rotation_matrix_scipy(roll_deg=10, pitch_deg=10, yaw_deg=10):
"""
使用 scipy 生成绕 XYZ 轴旋转的矩阵
"""
r = R.from_euler('xyz', [roll_deg, pitch_deg, yaw_deg], degrees=True)
return r.as_matrix()
def random_rotation_matrix():
"""生成随机旋转矩阵"""
angle = np.random.uniform(0, 2 * np.pi)
axis = np.random.randn(3)
axis = axis / np.linalg.norm(axis)
K = np.array([
[0, -axis[2], axis[1]],
[axis[2], 0, -axis[0]],
[-axis[1], axis[0], 0]
])
R = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * K @ K
return R
def random_translation_vector(range_xyz=0.5):
"""生成随机平移向量"""
return np.random.uniform(-range_xyz, range_xyz, 3)
def transform_point_cloud(pcd, R, t):
"""对点云应用变换"""
transformed_pcd = copy.deepcopy(pcd)
points = np.asarray(transformed_pcd.points)
points_transformed = (R @ points.T).T + t
transformed_pcd.points = o3d.utility.Vector3dVector(points_transformed)
return transformed_pcd
def registration_open3d(source_pcd, target_pcd, voxel_size=0.005):
"""
点云配准(RANSAC + ICP)
Returns:
transformation: 4x4 变换矩阵
fitness: 配准 fitness 分数
rmse: 配准 RMSE
"""
# 1. 下采样
source_down = source_pcd.voxel_down_sample(voxel_size)
target_down = target_pcd.voxel_down_sample(voxel_size)
# 2. 估计法线
radius = voxel_size * 2
source_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=30))
target_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=30))
# 3. 计算 FPFH 特征
radius_feature = voxel_size * 5
source_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
source_down, o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))
target_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
target_down, o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))
# 4. RANSAC 粗配准
distance_threshold = voxel_size * 1.5
result_ransac = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
source_down, target_down, source_fpfh, target_fpfh,
mutual_filter=True,
max_correspondence_distance=distance_threshold,
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(),
ransac_n=3,
checkers=[
o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(distance_threshold)
],
criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999))
print(f"RANSAC 配准完成")
source_pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
target_pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
# 5. ICP 精配准
result_icp = o3d.pipelines.registration.registration_icp(
source_pcd, target_pcd, distance_threshold / 2, result_ransac.transformation,
o3d.pipelines.registration.TransformationEstimationPointToPlane(),
o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=200))
print(f"ICP 精配准完成")
print(f" Fitness: {result_icp.fitness:.4f}")
print(f" RMSE: {result_icp.inlier_rmse:.6f} m")
return result_icp.transformation, result_icp.fitness, result_icp.inlier_rmse
def visualize_registration_result(original_pcd, transformed_pcd, registered_pcd, title="Registration Result"):
"""
可视化原始点云、变换后点云和配准后点云
Args:
original_pcd: 原始点云(源)
transformed_pcd: 应用随机变换后的点云(目标)
registered_pcd: 配准后的点云
title: 窗口标题
"""
# 复制并着色
source_colored = copy.deepcopy(original_pcd)
target_colored = copy.deepcopy(transformed_pcd)
registered_colored = copy.deepcopy(registered_pcd)
# 设置颜色
source_colored.paint_uniform_color([0, 0, 1]) # 蓝色 - 原始点云
target_colored.paint_uniform_color([0, 1, 0]) # 绿色 - 目标点云
registered_colored.paint_uniform_color([1, 0, 0]) # 红色 - 配准后点云
# 创建坐标系(可选)
# coordinate_frame = o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5)
# 可视化
o3d.visualization.draw_geometries(
[source_colored, target_colored],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024,
height=768
)
o3d.visualization.draw_geometries(
[registered_colored, target_colored],
window_name="After Registration (Red: Registered, Green: Target)",
width=1024,
height=768
)
def visualize_registration_comparison(original_pcd, transformed_pcd, registered_pcd):
"""
并排可视化配准前后的对比
Args:
original_pcd: 原始点云
transformed_pcd: 变换后的点云(目标)
registered_pcd: 配准后的点云
"""
# 复制并着色
source_before = copy.deepcopy(original_pcd)
source_before.paint_uniform_color([0, 0, 1]) # 蓝色
target_before = copy.deepcopy(transformed_pcd)
target_before.paint_uniform_color([0, 1, 0]) # 绿色
registered_after = copy.deepcopy(registered_pcd)
registered_after.paint_uniform_color([1, 0, 0]) # 红色
target_after = copy.deepcopy(transformed_pcd)
target_after.paint_uniform_color([0, 1, 0]) # 绿色
# 创建坐标系
coordinate_frame = o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.5)
# 方法1:分别显示两个窗口
o3d.visualization.draw_geometries(
[source_before],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024,
height=768
)
o3d.visualization.draw_geometries(
[target_before],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024,
height=768
)
o3d.visualization.draw_geometries(
[registered_after, source_before],
window_name="After Registration (Red: Registered, Green: Target)",
width=1024,
height=768
)
def compute_point_to_mesh_distance(point_cloud, mesh):
"""
计算点云到 Mesh 的距离
Args:
point_cloud: Open3D 点云对象
mesh: Open3D 网格对象
Returns:
distances: 每个点到最近三角形面的距离
mean_distance: 平均距离
rmse: 均方根误差
max_distance: 最大距离
"""
# 计算点到 mesh 的距离
distances = mesh.compute_point_cloud_distance(point_cloud)
distances = np.asarray(distances)
mean_dist = np.mean(distances)
rmse = np.sqrt(np.mean(distances ** 2))
max_dist = np.max(distances)
std_dist = np.std(distances)
return distances, mean_dist, rmse, max_dist, std_dist
# 使用示例
def demo(pcd,mesh):
# 加载点云和 mesh
# 计算距离
distances, mean_dist, rmse, max_dist, std_dist = compute_point_to_mesh_distance(pcd, mesh)
print(f"平均距离: {mean_dist:.6f} m ({mean_dist * 1000:.3f} mm)")
print(f"RMSE: {rmse:.6f} m ({rmse * 1000:.3f} mm)")
print(f"最大距离: {max_dist:.6f} m ({max_dist * 1000:.3f} mm)")
print(f"标准差: {std_dist:.6f} m ({std_dist * 1000:.3f} mm)")
# 可视化(按距离着色)
pcd.colors = o3d.utility.Vector3dVector(get_color_by_distance(distances))
o3d.visualization.draw_geometries([pcd, mesh])
def get_color_by_distance(distances, max_dist=None):
"""
根据距离生成颜色(红:远,蓝:近)
"""
if max_dist is None:
max_dist = np.percentile(distances, 95) # 使用95分位数
colors = np.zeros((len(distances), 3))
norm_dist = np.clip(distances / (max_dist + 1e-8), 0, 1)
# 红色通道(距离越远越红)
colors[:, 0] = norm_dist
# 蓝色通道(距离越近越蓝)
colors[:, 2] = 1 - norm_dist
return colors
# ========== 主程序 ==========
if __name__ == "__main__":
# 1. 读取原始点云
file_path = "E:\point_distance\data\\40_noisy.ply"
source_pcd = o3d.io.read_point_cloud(file_path)
# points=np.asarray(source_pcd.points)
t0=time.time()
if len(source_pcd.points) == 0:
print("读取失败,请检查文件路径")
exit()
print(f"原始点云点数: {len(source_pcd.points)}")
# 2. 生成随机变换
# R = random_rotation_matrix()
R = rotation_matrix_scipy(10, 10, 10)
t = random_translation_vector(range_xyz=0)
print(f"随机旋转矩阵:\n{R}")
print(f"随机平移向量: {t}")
# 3. 创建目标点云(变换后的源点云)
target_pcd = transform_point_cloud(source_pcd, R, t)
# 4. 点云配准
print("\n开始点云配准...")
transformation, fitness, rmse = registration_open3d(source_pcd, target_pcd, voxel_size=10)
# 5. 应用配准变换
# registered_pcd = copy.deepcopy(source_pcd)
# registered_pcd.transform(transformation)
inv_transformation = np.linalg.inv(transformation)
# 将目标点云变换回源点云坐标系
target_recovered = copy.deepcopy(target_pcd)
target_recovered.transform(inv_transformation)
t1 = time.time()
print(f"\n配准结果:")
print(f" Fitness: {fitness:.4f}")
print(f" RMSE: {rmse:.6f} m")
print(f" 估计的变换矩阵:\n{transformation}")
print(f"配准time:\n{t1-t0}")
t2=time.time()
# 1. 读取数据
points = np.asarray(target_recovered.points)
mesh_trimesh = trimesh.load("E:\point_distance\data\\40.stl")
# ========== 1. 读取数据 ==========
# 2. 使用 trimesh 计算每个点到 Mesh 的最近距离
# trimesh 的 nearest 方法非常高效且精确
closest_points, distances, triangle_ids = mesh_trimesh.nearest.on_surface(points)
t3 = time.time()
# distances 即为所求的距离数组
print(f"平均距离: {np.mean(distances)}")
print(f"最大距离: {np.max(distances)}")
print(f" 距离计算: {t3 - t2:.4f} 秒")
# 如果需要将结果存回 Open3D 点云进行可视化
# 6. 可视化对比
print("\n开始可视化...")
visualize_registration_comparison(source_pcd, target_pcd,target_recovered)

View File

@@ -0,0 +1,94 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
def compute_keep_ratio_and_density(pcd, filtered_pcd, sample_size=1000):
"""
计算直通滤波的保留率和密度变化
Args:
pcd: 原始点云
filtered_pcd: 滤波后点云
sample_size: 密度计算采样点数(加速计算)
Returns:
keep_ratio: 保留率 (%)
density_change: 密度变化 (%)
"""
n_orig = len(pcd.points)
n_filt = len(filtered_pcd.points)
# 1. 保留率
keep_ratio = n_filt / n_orig * 100
# 2. 密度变化(基于平均最近邻距离)
points_orig = np.asarray(pcd.points)
points_filt = np.asarray(filtered_pcd.points)
# 采样(点数太多时加速)
if len(points_orig) > sample_size:
idx_orig = np.random.choice(len(points_orig), sample_size, replace=False)
points_orig_sample = points_orig[idx_orig]
else:
points_orig_sample = points_orig
if len(points_filt) > sample_size:
idx_filt = np.random.choice(len(points_filt), sample_size, replace=False)
points_filt_sample = points_filt[idx_filt]
else:
points_filt_sample = points_filt
# 计算平均最近邻距离(密度指标)
if len(points_orig_sample) > 5:
tree_orig = KDTree(points_orig_sample)
dist_orig, _ = tree_orig.query(points_orig_sample, k=2) # k=2 排除自身
avg_dist_orig = np.mean(dist_orig[:, 1])
else:
avg_dist_orig = 0
if len(points_filt_sample) > 5:
tree_filt = KDTree(points_filt_sample)
dist_filt, _ = tree_filt.query(points_filt_sample, k=2)
avg_dist_filt = np.mean(dist_filt[:, 1])
else:
avg_dist_filt = 0
# 密度变化(距离增大 = 密度减小)
if avg_dist_orig > 0:
density_change = (avg_dist_filt - avg_dist_orig) / avg_dist_orig * 100
else:
density_change = 0
return keep_ratio, density_change
# ========== 使用示例 ==========
if __name__ == "__main__":
# 读取点云
pcd = o3d.io.read_point_cloud(r"E:\point_distance\data\part_car_head.ply")
# 直通滤波(Z轴)
points = np.asarray(pcd.points)
mask = (points[:, 2] >= 0.0) & (points[:, 2] <= 2.0)
filtered_points = points[mask]
filtered_pcd = o3d.geometry.PointCloud()
filtered_pcd.points = o3d.utility.Vector3dVector(filtered_points)
# 计算指标
keep_ratio, density_change = compute_keep_ratio_and_density(pcd, filtered_pcd)
print("=" * 40)
print("直通滤波指标")
print("=" * 40)
print(f"保留率: {keep_ratio:.2f}%")
print(f"密度变化: {density_change:+.2f}%")
# 解释
if density_change > 0:
print(f" → 点云变稀疏了 {density_change:.2f}%")
elif density_change < 0:
print(f" → 点云变密集了 {abs(density_change):.2f}%")
else:
print(f" → 密度基本不变")

View File

@@ -0,0 +1,380 @@
# import numpy as np
# import open3d as o3d
# import random
#
#
# def ransac_plane(points, threshold=0.01, max_iterations=1000, min_inliers=100):
# """
# RANSAC 平面拟合(单个)
# """
# best_inliers = []
# best_plane = None
# n_points = len(points)
#
# if n_points < 3:
# return None, []
#
# for _ in range(max_iterations):
# sample_idx = random.sample(range(n_points), 3)
# p1, p2, p3 = points[sample_idx]
#
# v1 = p2 - p1
# v2 = p3 - p1
# normal = np.cross(v1, v2)
# norm = np.linalg.norm(normal)
# if norm < 1e-8:
# continue
# normal = normal / norm
# a, b, c = normal
# d = -np.dot(normal, p1)
#
# distances = np.abs(np.dot(points, normal) + d)
# inliers = np.where(distances < threshold)[0]
#
# if len(inliers) > len(best_inliers) and len(inliers) >= min_inliers:
# best_inliers = inliers
# best_plane = (a, b, c, d)
#
# if len(best_inliers) > 0.9 * n_points:
# break
#
# # 用内点重新拟合
# if best_inliers is not None and len(best_inliers) > 3:
# inlier_points = points[best_inliers]
# centroid = np.mean(inlier_points, axis=0)
# _, _, vh = np.linalg.svd(inlier_points - centroid)
# normal = vh[2]
# normal = normal / np.linalg.norm(normal)
# a, b, c = normal
# d = -np.dot(normal, centroid)
# best_plane = (a, b, c, d)
#
# return best_plane, best_inliers
#
#
# def sequential_ransac_planes(points, threshold=0.01, max_iterations=1000, min_inliers=100, max_planes=5):
# """
# 迭代 RANSAC 拟合多个平面
# """
# remaining_points = points.copy()
# remaining_indices = np.arange(len(points))
#
# planes = []
# inliers_list = []
#
# for i in range(max_planes):
# if len(remaining_points) < min_inliers:
# break
#
# plane, inliers = ransac_plane(remaining_points, threshold, max_iterations, min_inliers)
#
# if plane is None or len(inliers) < min_inliers:
# break
#
# planes.append(plane)
# original_indices = remaining_indices[inliers]
# inliers_list.append(original_indices)
#
# # 移除内点
# mask = np.ones(len(remaining_points), dtype=bool)
# mask[inliers] = False
# remaining_points = remaining_points[mask]
# remaining_indices = remaining_indices[mask]
#
# print(f"平面 {len(planes)}: {len(inliers)} 个内点, 剩余 {len(remaining_points)} 个点")
#
# return planes, inliers_list
#
#
# def create_plane_mesh(plane, bounds):
# """
# 创建平面网格用于可视化
# """
# a, b, c, d = plane
# x_min, x_max = bounds[0]
# y_min, y_max = bounds[1]
#
# x = np.linspace(x_min, x_max, 50)
# y = np.linspace(y_min, y_max, 50)
# X, Y = np.meshgrid(x, y)
#
# if abs(c) > 1e-8:
# Z = -(a * X + b * Y + d) / c
# else:
# Z = np.zeros_like(X)
#
# vertices = []
# triangles = []
# rows, cols = X.shape
#
# for i in range(rows):
# for j in range(cols):
# vertices.append([X[i, j], Y[i, j], Z[i, j]])
#
# for i in range(rows - 1):
# for j in range(cols - 1):
# idx = i * cols + j
# triangles.append([idx, idx + 1, idx + cols])
# triangles.append([idx + 1, idx + cols + 1, idx + cols])
#
# mesh = o3d.geometry.TriangleMesh()
# mesh.vertices = o3d.utility.Vector3dVector(np.array(vertices))
# mesh.triangles = o3d.utility.Vector3iVector(np.array(triangles))
# mesh.compute_vertex_normals()
#
# return mesh
#
#
# def visualize_multiple_planes(points, planes, inliers_list):
# """
# 可视化多个平面(点云 + 平面网格)
#
# Args:
# points: (N, 3) 点云坐标
# planes: 平面方程列表 [(a,b,c,d), ...]
# inliers_list: 每个平面对应的内点索引列表
# """
# # 颜色列表(每个平面不同颜色)
# colors = [
# [1, 0, 0], # 红色
# [0, 1, 0], # 绿色
# [0, 0, 1], # 蓝色
# [1, 1, 0], # 黄色
# [1, 0, 1], # 品红
# [0, 1, 1], # 青色
# [1, 0.5, 0], # 橙色
# [0.5, 0, 1], # 紫色
# ]
#
# geometries = []
#
# # 1. 为每个平面的内点着色
# point_colors = np.zeros((len(points), 3))
# for i, inliers in enumerate(inliers_list):
# color = colors[i % len(colors)]
# point_colors[inliers] = color
#
# # 未分配的点(剩余点)设为灰色
# unassigned = np.where(np.sum(point_colors, axis=1) == 0)[0]
# point_colors[unassigned] = [0.5, 0.5, 0.5]
#
# colored_pcd = o3d.geometry.PointCloud()
# colored_pcd.points = o3d.utility.Vector3dVector(points)
# colored_pcd.colors = o3d.utility.Vector3dVector(point_colors)
# geometries.append(colored_pcd)
#
# # 2. 为每个平面创建网格
# for i, (plane, inliers) in enumerate(zip(planes, inliers_list)):
# # 计算该平面对应点的范围
# inlier_points = points[inliers]
# if len(inlier_points) > 0:
# x_min, x_max = inlier_points[:, 0].min(), inlier_points[:, 0].max()
# y_min, y_max = inlier_points[:, 1].min(), inlier_points[:, 1].max()
#
# # 稍微扩展边界
# x_range = x_max - x_min
# y_range = y_max - y_min
# x_min -= x_range * 0.1
# x_max += x_range * 0.1
# y_min -= y_range * 0.1
# y_max += y_range * 0.1
#
# mesh = create_plane_mesh(plane, [[x_min, x_max], [y_min, y_max]])
# mesh.paint_uniform_color(colors[i % len(colors)])
# # 设置半透明
# mesh.paint_uniform_color([colors[i % len(colors)][0],
# colors[i % len(colors)][1],
# colors[i % len(colors)][2]])
# geometries.append(mesh)
#
# # 可视化
# o3d.visualization.draw_geometries(
# geometries,
# window_name="Multiple Planes - RANSAC",
# width=1024,
# height=768
# )
#
#
# def visualize_single_plane(points, plane, inliers):
# """
# 可视化单个平面(兼容单平面情况)
# """
# visualize_multiple_planes(points, [plane], [inliers])
#
#
# # ========== 主程序 ==========
# if __name__ == "__main__":
# # 读取点云
# file_path = r"E:\point_distance\data\part_car_head.ply"
# pcd = o3d.io.read_point_cloud(file_path)
#
# if len(pcd.points) == 0:
# print("读取失败,请检查文件路径")
# exit()
#
# points = np.asarray(pcd.points)
# print(f"原始点云点数: {len(points)}")
#
# # 计算点云范围
# points_range = points.max(axis=0) - points.min(axis=0)
# print(f"点云范围: X[{points_range[0]:.3f}], Y[{points_range[1]:.3f}], Z[{points_range[2]:.3f}]")
#
# # 设置阈值
# threshold = points_range[2] * 0.02
# print(f"使用阈值: {threshold:.4f}")
#
# # ========== 单平面拟合 ==========
# # print("\n" + "=" * 50)
# # print("单平面拟合")
# # print("=" * 50)
# #
# # plane, inliers = ransac_plane(points, threshold=threshold, max_iterations=1, min_inliers=100)
# #
# # if plane is not None:
# # a, b, c, d = plane
# # print(f"拟合平面: {a:.4f}x + {b:.4f}y + {c:.4f}z + {d:.4f} = 0")
# # print(f"内点数量: {len(inliers)} / {len(points)} ({len(inliers) / len(points) * 100:.1f}%)")
# # visualize_single_plane(points, plane, inliers)
#
# # ========== 多平面拟合 ==========
# print("\n" + "=" * 50)
# print("多平面拟合")
# print("=" * 50)
#
# planes, inliers_list = sequential_ransac_planes(
# points,
# threshold=threshold,
# max_iterations=1,
# min_inliers=100,
# max_planes=1
# )
#
# print(f"共提取 {len(planes)} 个平面")
# for i, (plane, inliers) in enumerate(zip(planes, inliers_list)):
# a, b, c, d = plane
# print(f"平面 {i + 1}: {a:.4f}x + {b:.4f}y + {c:.4f}z + {d:.4f} = 0, 内点数: {len(inliers)}")
#
# if len(planes) > 0:
# visualize_multiple_planes(points, planes, inliers_list)
import numpy as np
import open3d as o3d
import random
def create_square_plane_with_noise(size_mm=1000, noise_std_mm=0.5, num_points=10000):
"""创建正方形平面点云并添加高斯噪声"""
size_m = size_mm / 1000.0
noise_std_m = noise_std_mm / 1000.0
half_size = size_m / 2
x = np.random.uniform(-half_size, half_size, num_points)
y = np.random.uniform(-half_size, half_size, num_points)
z = np.zeros(num_points)
points = np.column_stack([x, y, z])
noise_z = np.random.normal(0, noise_std_m, num_points)
points[:, 2] += noise_z
return points
def estimate_plane_ransac_open3d(points, threshold_mm=0.5, max_iterations=2000):
"""使用 Open3D 内置 RANSAC 平面估计"""
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
threshold_m = threshold_mm / 1000.0
plane_model, inliers = pcd.segment_plane(
distance_threshold=threshold_m,
ransac_n=3,
num_iterations=max_iterations
)
a, b, c, d = plane_model
# 计算误差
ideal_normal = np.array([0, 0, 1])
estimated_normal = np.array([a, b, c])
cos_angle = np.dot(ideal_normal, estimated_normal)
cos_angle = np.clip(cos_angle, -1, 1)
angle_error = np.degrees(np.arccos(cos_angle))
offset_error = abs(d)
inlier_points = points[inliers]
distances = np.abs(np.dot(inlier_points, estimated_normal) + d)
rmse = np.sqrt(np.mean(distances ** 2))
print(f"\nRANSAC 平面拟合结果 (Open3D):")
print(f" 估计平面: {a:.6f}x + {b:.6f}y + {c:.6f}z + {d:.6f} = 0")
print(f" 内点数量: {len(inliers)} / {len(points)} ({len(inliers) / len(points) * 100:.1f}%)")
print(f" 法线角度误差: {angle_error:.4f} 度")
print(f" 偏移误差: {offset_error:.6f} m ({offset_error * 1000:.3f} mm)")
print(f" 平面拟合 RMSE: {rmse:.6f} m ({rmse * 1000:.3f} mm)")
return plane_model, inliers
def visualize_plane_fitting(points, plane):
"""可视化原始点云和拟合平面"""
# 原始点云
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.paint_uniform_color([0.6, 0.8, 1.0])
# 创建平面点云
a, b, c, d = plane
x_min, x_max = points[:, 0].min(), points[:, 0].max()
y_min, y_max = points[:, 1].min(), points[:, 1].max()
x = np.linspace(x_min, x_max, 50)
y = np.linspace(y_min, y_max, 50)
X, Y = np.meshgrid(x, y)
if abs(c) > 1e-8:
Z = -(a * X + b * Y + d) / c
else:
Z = np.zeros_like(X)
plane_points = np.column_stack([X.ravel(), Y.ravel(), Z.ravel()])
pcd_plane = o3d.geometry.PointCloud()
pcd_plane.points = o3d.utility.Vector3dVector(plane_points)
pcd_plane.paint_uniform_color([1, 0, 0]) # 红色
o3d.visualization.draw_geometries(
[pcd, pcd_plane],
window_name="Plane Fitting",
width=1024,
height=768
)
# ========== 主程序 ==========
if __name__ == "__main__":
# 参数设置
size_mm = 1000
noise_std_mm = 0.5
num_points = 100000
print(f"参数:")
print(f" 正方形边长: {size_mm} mm")
print(f" 噪声标准差: {noise_std_mm} mm")
print(f" 点数: {num_points}")
# 生成点云
points = create_square_plane_with_noise(size_mm, noise_std_mm, num_points)
# 检查数据范围
print(f"\n数据范围:")
print(f" X: [{points[:, 0].min():.3f}, {points[:, 0].max():.3f}]")
print(f" Y: [{points[:, 1].min():.3f}, {points[:, 1].max():.3f}]")
print(f" Z: [{points[:, 2].min():.6f}, {points[:, 2].max():.6f}]")
# 使用 Open3D RANSAC
plane, inliers = estimate_plane_ransac_open3d(points, threshold_mm=0.5, max_iterations=2000)
# 可视化
visualize_plane_fitting(points, plane)

View File

@@ -0,0 +1,147 @@
import numpy as np
from scipy.spatial import KDTree
from sklearn.neighbors import NearestNeighbors
def estimate_normals(points, k=20):
"""用 PCA 估计法线"""
n_points = len(points)
normals = np.zeros_like(points)
nbrs = NearestNeighbors(n_neighbors=k).fit(points)
distances, indices = nbrs.kneighbors(points)
for i in range(n_points):
neighbors = points[indices[i]]
centroid = np.mean(neighbors, axis=0)
cov = np.cov((neighbors - centroid).T)
eig_vals, eig_vecs = np.linalg.eig(cov)
normal = eig_vecs[:, np.argmin(eig_vals)]
# 统一法线方向
if normal[2] < 0:
normal = -normal
normals[i] = normal
return normals
def compute_curvature(points, normals, k=20):
"""计算曲率(特征值比值)"""
curvature = np.zeros(len(points))
nbrs = NearestNeighbors(n_neighbors=k).fit(points)
distances, indices = nbrs.kneighbors(points)
for i in range(len(points)):
neighbors = points[indices[i]]
centroid = np.mean(neighbors, axis=0)
cov = np.cov((neighbors - centroid).T)
eig_vals, _ = np.linalg.eig(cov)
curvature[i] = np.min(eig_vals) / (np.sum(eig_vals) + 1e-8)
return curvature
def region_growing(points, normals, curvature,
angle_threshold=30.0, # 法线夹角阈值(度)
curvature_threshold=0.05, # 曲率阈值
min_cluster_size=100):
"""
区域生长算法
Args:
points: (N, 3) 点云坐标
normals: (N, 3) 法线
curvature: (N,) 曲率
angle_threshold: 法线夹角阈值(度)
curvature_threshold: 曲率阈值
min_cluster_size: 最小簇大小
Returns:
labels: (N,) 每个点的簇标签(-1 表示未分类/噪声)
"""
n_points = len(points)
labels = np.full(n_points, -1, dtype=int)
# 按曲率升序排序(曲率小的作为种子)
sorted_indices = np.argsort(curvature)
# 构建 KD-Tree 用于快速邻域搜索
tree = KDTree(points)
label_id = 0
angle_threshold_rad = np.radians(angle_threshold)
cos_threshold = np.cos(angle_threshold_rad)
for seed_idx in sorted_indices:
if labels[seed_idx] != -1:
continue
# 开始一个新区域
current_region = []
seeds = [seed_idx]
labels[seed_idx] = label_id
while seeds:
current_seed = seeds.pop(0)
current_region.append(current_seed)
# 搜索半径内的邻居(动态半径或固定半径)
neighbors = tree.query_ball_point(points[current_seed], r=0.2)
for neighbor in neighbors:
if labels[neighbor] != -1:
continue
# 检查法线夹角
dot_product = np.abs(np.dot(normals[current_seed], normals[neighbor]))
if dot_product > cos_threshold:
labels[neighbor] = label_id
# 如果曲率足够小,作为新种子
if curvature[neighbor] < curvature_threshold:
seeds.append(neighbor)
# 检查区域大小
region_size = np.sum(labels == label_id)
if region_size < min_cluster_size:
# 区域太小,标记为噪声
labels[labels == label_id] = -1
else:
label_id += 1
return labels
# 使用示例
if __name__ == "__main__":
import open3d as o3d
# 读取点云
pcd = o3d.io.read_point_cloud("E:\point_distance\data\\bell_pepper_6.pcd")
points = np.asarray(pcd.points)
# 预处理:体素下采样 + 去噪
pcd = pcd.voxel_down_sample(0.0002)
pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
points = np.asarray(pcd.points)
# 计算法线和曲率
print("计算法线...")
normals = estimate_normals(points, k=30)
print("计算曲率...")
curvature = compute_curvature(points, normals, k=30)
# 区域生长分割
print("区域生长...")
labels = region_growing(points, normals, curvature,
angle_threshold=25.0,
curvature_threshold=0.05,
min_cluster_size=100)
print(f"分割完成,共 {len(np.unique(labels)) - (1 if -1 in labels else 0)} 个区域")
# 可视化
colors = np.random.rand(len(np.unique(labels)), 3)
point_colors = np.array([colors[label] if label != -1 else [0.5, 0.5, 0.5]
for label in labels])
pcd.colors = o3d.utility.Vector3dVector(point_colors)
o3d.visualization.draw_geometries([pcd])

View File

@@ -0,0 +1,52 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
def chamfer_distance(pcd1, pcd2):
"""
计算两个点云之间的 CD 距离(Chamfer Distance)
Args:
pcd1: Open3D 点云对象1
pcd2: Open3D 点云对象2
Returns:
cd: Chamfer Distance 值
cd1: 点云1 到 点云2 的平均距离
cd2: 点云2 到 点云1 的平均距离
"""
# 获取点坐标
points1 = np.asarray(pcd1.points)
points2 = np.asarray(pcd2.points)
# 构建 KD-Tree 加速最近邻搜索
tree1 = KDTree(points1)
tree2 = KDTree(points2)
# 计算 points1 中每个点到 points2 的最近距离
dist1, _ = tree2.query(points1, k=1)
cd1 = np.mean(dist1 ** 2) # 平方距离的平均值
# 计算 points2 中每个点到 points1 的最近距离
dist2, _ = tree1.query(points2, k=1)
cd2 = np.mean(dist2 ** 2) # 平方距离的平均值
# 总 CD 距离(双向平均)
cd = cd1 + cd2
return np.sqrt(cd), cd1, cd2
pcd = o3d.io.read_point_cloud("E:\point_distance\data\part_car_left_side.ply")
points = np.asarray(pcd.points)
colors = np.tile([0.8, 0.7, 1.0], (len(pcd.points), 1))
pcd.colors = o3d.utility.Vector3dVector(colors)
o3d.visualization.draw_geometries([pcd],window_name="1")
# 半径离群点移除
cl, ind = pcd.remove_radius_outlier(nb_points=5, radius=20)
# 提取内点
inlier_cloud = pcd.select_by_index(ind)
outlier_cloud = pcd.select_by_index(ind, invert=True)
print(f"原始点云: {len(pcd.points)} 点")
print(f"滤波后: {len(inlier_cloud.points)} 点")
cd,cd1,cd2=chamfer_distance(pcd1=pcd,pcd2=inlier_cloud)
print(cd)

View File

@@ -0,0 +1,60 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
from plyfile import PlyData
def chamfer_distance(pcd1, pcd2):
"""
计算两个点云之间的 CD 距离(Chamfer Distance)
Args:
pcd1: Open3D 点云对象1
pcd2: Open3D 点云对象2
Returns:
cd: Chamfer Distance 值
cd1: 点云1 到 点云2 的平均距离
cd2: 点云2 到 点云1 的平均距离
"""
# 获取点坐标
points1 = np.asarray(pcd1.points)
points2 = np.asarray(pcd2.points)
# 构建 KD-Tree 加速最近邻搜索
tree1 = KDTree(points1)
tree2 = KDTree(points2)
# 计算 points1 中每个点到 points2 的最近距离
dist1, _ = tree2.query(points1, k=1)
cd1 = np.mean(dist1 ** 2) # 平方距离的平均值
# 计算 points2 中每个点到 points1 的最近距离
dist2, _ = tree1.query(points2, k=1)
cd2 = np.mean(dist2 ** 2) # 平方距离的平均值
# 总 CD 距离(双向平均)
cd = cd1 + cd2
return cd, cd1, cd2
pcd = o3d.io.read_point_cloud("E:\point_distance\data\part_car_head.ply")
colors = np.tile([1, 0, 0], (len(pcd.points), 1))
pcd.colors = o3d.utility.Vector3dVector(colors)
# ply_data = PlyData.read(r"E:\point_distance\data\part_car_head.ply")
# colors=np.asarray(ply_data.colors)
# with open("E:\point_distance\data\part_car_head.ply", 'r',encoding="utf-8") as f:
# lines = f.readlines()
# for line in lines[:30]: # 打印头部
# print(line.strip())
# o3d.visualization.draw_geometries([pcd])
# points = np.asarray(pcd.points)
# colors = np.asarray(pcd.colors)
# normals = np.asarray(pcd.normals)
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=50.0)
inlier_cloud = pcd.select_by_index(ind)
colors=np.tile([0,1,0],(len(inlier_cloud.points),1))
inlier_cloud.colors=o3d.utility.Vector3dVector(colors)
o3d.visualization.draw_geometries([inlier_cloud])
o3d.visualization.draw_geometries([pcd])
o3d.visualization.draw_geometries()
cd,cd1,cd2=chamfer_distance(pcd1=pcd,pcd2=inlier_cloud)
print(cd)

View File

@@ -0,0 +1,96 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
def evaluate_voxel_filter(pcd, leaf_size=0.05):
"""
评估体素滤波效果,计算各项指标
Args:
pcd: 原始点云
leaf_size: 体素边长
Returns:
metrics: 指标字典
filtered_pcd: 滤波后点云
"""
# 执行体素滤波
filtered_pcd = pcd.voxel_down_sample(leaf_size)
n_orig = len(pcd.points)
n_filt = len(filtered_pcd.points)
# 1. 基础统计指标
keep_ratio = n_filt / n_orig * 100
downsample_rate = (n_orig - n_filt) / n_orig * 100
compression_ratio = n_orig / n_filt if n_filt > 0 else 0
# 2. 密度变化指标
points_orig = np.asarray(pcd.points)
points_filt = np.asarray(filtered_pcd.points)
# 计算平均最近邻距离(采样加速)
sample_size = min(1000, n_filt)
if n_filt > sample_size:
idx = np.random.choice(n_filt, sample_size, replace=False)
points_sample = points_filt[idx]
else:
points_sample = points_filt
if len(points_sample) > 5:
tree = KDTree(points_sample)
dist, _ = tree.query(points_sample, k=2)
avg_distance = np.mean(dist[:, 1])
else:
avg_distance = 0
# 3. 输出指标
metrics = {
'leaf_size': leaf_size,
'n_orig': n_orig,
'n_filt': n_filt,
'keep_ratio': keep_ratio,
'downsample_rate': downsample_rate,
'compression_ratio': compression_ratio,
'avg_point_distance': avg_distance,
'estimated_density': 1 / (avg_distance ** 3) if avg_distance > 0 else 0
}
return metrics, filtered_pcd
# ========== 使用示例 ==========
if __name__ == "__main__":
# 读取点云
pcd = o3d.io.read_point_cloud(r"E:\point_distance\data\part_car_head.ply")
# 测试不同体素大小
leaf_sizes = [2, 15, 20, 2]
print("=" * 60)
print("体素滤波指标评估")
print("=" * 60)
print(f"{'体素边长':<10} {'保留率':<12} {'下采样率':<12} {'压缩比':<10} {'平均间距':<12}")
print("-" * 60)
for leaf_size in leaf_sizes:
metrics, filtered = evaluate_voxel_filter(pcd, leaf_size)
print(f"{leaf_size:<10} {metrics['keep_ratio']:>8.2f}% "
f"{metrics['downsample_rate']:>8.2f}% "
f"{metrics['compression_ratio']:>8.1f}x "
f"{metrics['avg_point_distance']:>8.4f}m")
# 可视化对比
print("\n可视化对比...")
orig_colored = o3d.geometry.PointCloud()
orig_colored.points = pcd.points
orig_colored.paint_uniform_color([0, 0, 1]) # 蓝色
# o3d.visualization.draw_geometries([orig_colored],window_name=f"orignal (blue,{len(pcd.points)}")
metrics, filtered = evaluate_voxel_filter(pcd, leaf_size=20)
filtered.paint_uniform_color([1, 0, 0]) # 红色
# o3d.visualization.draw_geometries([filtered], window_name=f"Voxel (red,{len(filtered.points)}")
o3d.visualization.draw_geometries(
[orig_colored, filtered],
window_name=f"Original (Blue, {len(pcd.points)} pts) vs Voxel (Red, {len(filtered.points)} pts)"
)

View File

@@ -0,0 +1,141 @@
import open3d as o3d
import numpy as np
from scipy.spatial import KDTree
import copy
def compute_point_to_mesh_distance(point_cloud, mesh):
"""
计算点云到 Mesh 的距离
Args:
point_cloud: Open3D 点云对象
mesh: Open3D 网格对象
Returns:
distances: 每个点到最近三角形面的距离
"""
# 方法1:使用 Open3D 内置函数(新版本)
try:
distances = mesh.compute_point_cloud_distance(point_cloud)
return np.asarray(distances)
except AttributeError:
pass
# 方法2:使用 mesh 顶点最近邻(近似)
mesh_points = np.asarray(mesh.vertices)
points = np.asarray(point_cloud.points)
tree = KDTree(mesh_points)
distances, indices = tree.query(points)
return distances
def compute_statistics(distances):
"""计算距离统计信息"""
stats = {
'mean': np.mean(distances),
'rmse': np.sqrt(np.mean(distances ** 2)),
'max': np.max(distances),
'min': np.min(distances),
'std': np.std(distances),
'median': np.median(distances),
}
# 分位数
percentiles = [50, 75, 90, 95, 99]
for p in percentiles:
stats[f'percentile_{p}'] = np.percentile(distances, p)
return stats
def visualize_distance(point_cloud, mesh, distances, max_dist=None):
"""可视化距离(按距离着色)"""
pcd_colored = copy.deepcopy(point_cloud)
if max_dist is None:
max_dist = np.percentile(distances, 95)
# 生成颜色(红:远,蓝:近)
colors = np.zeros((len(distances), 3))
norm_dist = np.clip(distances / (max_dist + 1e-8), 0, 1)
colors[:, 0] = norm_dist # 红色通道(远)
colors[:, 2] = 1 - norm_dist # 蓝色通道(近)
pcd_colored.colors = o3d.utility.Vector3dVector(colors)
# Mesh 着色为灰色
mesh_colored = copy.deepcopy(mesh)
mesh_colored.paint_uniform_color([0.5, 0.5, 0.5])
# 可视化
o3d.visualization.draw_geometries(
[mesh_colored, pcd_colored],
window_name="Distance to Mesh (Blue=Near, Red=Far)",
width=1024,
height=768
)
def print_statistics(stats):
"""打印统计信息"""
print("=" * 60)
print("点云到 Mesh 距离统计")
print("=" * 60)
print(f"平均距离: {stats['mean']:.6f} m ({stats['mean'] * 1000:.3f} mm)")
print(f"RMSE: {stats['rmse']:.6f} m ({stats['rmse'] * 1000:.3f} mm)")
print(f"中位数: {stats['median']:.6f} m ({stats['median'] * 1000:.3f} mm)")
print(f"标准差: {stats['std']:.6f} m ({stats['std'] * 1000:.3f} mm)")
print(f"最大距离: {stats['max']:.6f} m ({stats['max'] * 1000:.3f} mm)")
print(f"最小距离: {stats['min']:.6f} m ({stats['min'] * 1000:.3f} mm)")
print("\n距离分位数:")
print(f" 50%: {stats['percentile_50']:.6f} m ({stats['percentile_50'] * 1000:.3f} mm)")
print(f" 75%: {stats['percentile_75']:.6f} m ({stats['percentile_75'] * 1000:.3f} mm)")
print(f" 90%: {stats['percentile_90']:.6f} m ({stats['percentile_90'] * 1000:.3f} mm)")
print(f" 95%: {stats['percentile_95']:.6f} m ({stats['percentile_95'] * 1000:.3f} mm)")
print(f" 99%: {stats['percentile_99']:.6f} m ({stats['percentile_99'] * 1000:.3f} mm)")
# ========== 主程序 ==========
if __name__ == "__main__":
# 读取文件(使用原始字符串避免转义问题)
file_path = r"E:\point_distance\data\40_noisy.ply"
mesh_path = r"E:\point_distance\data\40.stl"
print("读取点云...")
source_pcd = o3d.io.read_point_cloud(file_path)
print("读取 Mesh...")
mesh = o3d.io.read_triangle_mesh(mesh_path)
# 检查读取是否成功
if len(source_pcd.points) == 0:
print("错误:点云读取失败,请检查文件路径")
exit()
if len(mesh.vertices) == 0:
print("错误:Mesh 读取失败,请检查文件路径")
exit()
print(f"\n点云点数: {len(source_pcd.points)}")
print(f"Mesh 顶点数: {len(mesh.vertices)}")
print(f"Mesh 面数: {len(mesh.triangles)}")
# 可选:Mesh 法线计算(用于更好的可视化)
mesh.compute_vertex_normals()
# 计算距离
print("\n计算点云到 Mesh 的距离...")
distances = compute_point_to_mesh_distance(source_pcd, mesh)
# 计算统计信息
stats = compute_statistics(distances)
# 打印统计信息
print_statistics(stats)
# 可视化
print("\n可视化...")
visualize_distance(source_pcd, mesh, distances)

View File

@@ -0,0 +1,25 @@
import trimesh
import numpy as np
import open3d as o3d
import time
# 1. 读取数据
mesh_trimesh = trimesh.load("E:\point_distance\data\\40.stl")
pcd_o3d = o3d.io.read_point_cloud("E:\point_distance\data\\40_noisy.ply")
points = np.asarray(pcd_o3d.points)
start_time = time.time()
# ========== 1. 读取数据 ==========
t0 = time.time()
# 2. 使用 trimesh 计算每个点到 Mesh 的最近距离
# trimesh 的 nearest 方法非常高效且精确
closest_points, distances, triangle_ids = mesh_trimesh.nearest.on_surface(points)
t1=time.time()
# distances 即为所求的距离数组
print(f"平均距离: {np.mean(distances)}")
print(f"最大距离: {np.max(distances)}")
print(f" 距离计算: {t1 - t0:.4f} 秒")
# 如果需要将结果存回 Open3D 点云进行可视化
dists_o3d = o3d.utility.DoubleVector(distances.tolist())
# 注意:Open3D 点云对象本身不直接存储标量距离字段用于内置可视化着色

View File

@@ -0,0 +1,10 @@
#python 3.10.8
--extra-index-url https://download.pytorch.org/whl/cu118
torch
torchvision
torchaudio
trimesh==4.5.3
numpy==2.2.2
open3d==0.19.0
pythonocc-core==7.5.1 # conda install -c conda-forge pythonocc-core=
git+https://github.com/AutodeskAILab/occwl #pip install git+https://github.com/AutodeskAILab/occwl

View File

@@ -0,0 +1,474 @@
import gc
import time
import zipfile
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
import numpy as np
import open3d as o3d
import torch
MESH_DIR = Path("Mesh")
POINT_CLOUD_DIR = Path("CloudPoint_3000000")
RESULT_DIR = Path("result")
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
DTYPE = torch.float64
QUERY_CHUNK_SIZE = 8192 if DEVICE.type == "cuda" else 4096
CANDIDATE_TRIANGLE_COUNT = 32
SUMMARY_XLSX_PATH = RESULT_DIR / "task_distance_summary.xlsx"
SUMMARY_CSV_PATH = RESULT_DIR / "task_distance_summary.csv"
OPEN_VISUALIZATION_WINDOW = False
COLOR_MAX_PERCENTILE = 99.0
POINT_SIZE = 2.0
def read_triangle_mesh(stl_path):
mesh = o3d.io.read_triangle_mesh(stl_path)
if mesh.is_empty():
raise ValueError(f"Failed to read mesh: {stl_path}")
if not mesh.has_triangles():
raise ValueError(f"Mesh has no triangles: {stl_path}")
return mesh
def read_point_cloud_points(ply_path):
pcd = o3d.io.read_point_cloud(ply_path)
if pcd.is_empty():
raise ValueError(f"Failed to read point cloud: {ply_path}")
return np.asarray(pcd.points, dtype=np.float64).copy()
def point_to_triangle_distance_squared(points, triangles):
p = points[:, None, :]
a = triangles[:, :, 0, :]
b = triangles[:, :, 1, :]
c = triangles[:, :, 2, :]
ab = b - a
ac = c - a
ap = p - a
d1 = (ab * ap).sum(dim=-1)
d2 = (ac * ap).sum(dim=-1)
inf = torch.full_like(d1, torch.inf)
mask = (d1 <= 0.0) & (d2 <= 0.0)
dist2 = torch.where(mask, (ap * ap).sum(dim=-1), inf)
bp = p - b
d3 = (ab * bp).sum(dim=-1)
d4 = (ac * bp).sum(dim=-1)
mask = (d3 >= 0.0) & (d4 <= d3)
dist2 = torch.minimum(dist2, torch.where(mask, (bp * bp).sum(dim=-1), inf))
vc = d1 * d4 - d3 * d2
mask = (vc <= 0.0) & (d1 >= 0.0) & (d3 <= 0.0)
v = d1 / (d1 - d3).clamp_min(torch.finfo(points.dtype).eps)
closest = a + v[..., None] * ab
edge_dist2 = ((p - closest) ** 2).sum(dim=-1)
dist2 = torch.minimum(dist2, torch.where(mask, edge_dist2, inf))
cp = p - c
d5 = (ab * cp).sum(dim=-1)
d6 = (ac * cp).sum(dim=-1)
mask = (d6 >= 0.0) & (d5 <= d6)
dist2 = torch.minimum(dist2, torch.where(mask, (cp * cp).sum(dim=-1), inf))
vb = d5 * d2 - d1 * d6
mask = (vb <= 0.0) & (d2 >= 0.0) & (d6 <= 0.0)
w = d2 / (d2 - d6).clamp_min(torch.finfo(points.dtype).eps)
closest = a + w[..., None] * ac
edge_dist2 = ((p - closest) ** 2).sum(dim=-1)
dist2 = torch.minimum(dist2, torch.where(mask, edge_dist2, inf))
va = d3 * d6 - d5 * d4
mask = (va <= 0.0) & ((d4 - d3) >= 0.0) & ((d5 - d6) >= 0.0)
w = (d4 - d3) / ((d4 - d3) + (d5 - d6)).clamp_min(torch.finfo(points.dtype).eps)
closest = b + w[..., None] * (c - b)
edge_dist2 = ((p - closest) ** 2).sum(dim=-1)
dist2 = torch.minimum(dist2, torch.where(mask, edge_dist2, inf))
denom = (va + vb + vc).clamp_min(torch.finfo(points.dtype).eps)
v = vb / denom
w = vc / denom
closest = a + ab * v[..., None] + ac * w[..., None]
face_dist2 = ((p - closest) ** 2).sum(dim=-1)
return torch.where(torch.isinf(dist2), face_dist2, dist2)
def nearest_aabb_candidate_indices(points, triangle_mins, triangle_maxs, candidate_count):
below = (triangle_mins[None, :, :] - points[:, None, :]).clamp_min(0.0)
above = (points[:, None, :] - triangle_maxs[None, :, :]).clamp_min(0.0)
aabb_dist2 = ((below + above) ** 2).sum(dim=-1)
k = min(candidate_count, triangle_mins.shape[0])
return torch.topk(aabb_dist2, k=k, dim=1, largest=False).indices
def compute_mesh_distances(mesh, points_np, chunk_size, candidate_count):
vertices = np.asarray(mesh.vertices, dtype=np.float64)
triangles_idx = np.asarray(mesh.triangles, dtype=np.int64)
points = torch.as_tensor(points_np, dtype=DTYPE, device=DEVICE)
vertices = torch.as_tensor(vertices, dtype=DTYPE, device=DEVICE)
triangles_idx = torch.as_tensor(triangles_idx, dtype=torch.long, device=DEVICE)
triangles = vertices[triangles_idx]
triangle_mins = triangles.min(dim=1).values
triangle_maxs = triangles.max(dim=1).values
distances = []
with torch.no_grad():
for start in range(0, points.shape[0], chunk_size):
chunk = points[start : start + chunk_size]
candidate_idx = nearest_aabb_candidate_indices(
chunk,
triangle_mins,
triangle_maxs,
candidate_count,
)
candidate_triangles = triangles[candidate_idx]
dist2 = point_to_triangle_distance_squared(chunk, candidate_triangles)
chunk_distances = torch.sqrt(dist2.min(dim=1).values)
distances.append(chunk_distances.cpu())
if DEVICE.type == "cuda":
torch.cuda.synchronize()
return torch.cat(distances, dim=0).numpy()
def task_id_from_mesh_path(mesh_path):
return mesh_path.stem.split("_", 1)[0]
def discover_mesh_tasks(mesh_dir, point_cloud_dir):
mesh_paths = sorted(
mesh_dir.glob("*.stl"),
key=lambda path: (int(task_id_from_mesh_path(path)), path.stem),
)
if not mesh_paths:
raise FileNotFoundError(f"No STL files found in {mesh_dir}")
tasks = {}
for mesh_path in mesh_paths:
point_cloud_path = point_cloud_dir / f"{mesh_path.stem}.ply"
if not point_cloud_path.exists():
raise FileNotFoundError(
f"Missing point cloud for {mesh_path.name}: {point_cloud_path}"
)
task_id = task_id_from_mesh_path(mesh_path)
tasks.setdefault(task_id, []).append((mesh_path, point_cloud_path))
return tasks
def distance_colors(distances):
finite_mask = np.isfinite(distances)
if not finite_mask.any():
raise ValueError("No finite distances to visualize.")
finite_distances = distances[finite_mask]
color_min = float(finite_distances.min())
if COLOR_MAX_PERCENTILE is None:
color_max = float(finite_distances.max())
else:
color_max = float(np.percentile(finite_distances, COLOR_MAX_PERCENTILE))
if color_max <= color_min:
color_max = float(finite_distances.max())
if color_max <= color_min:
normalized = np.zeros_like(distances, dtype=np.float64)
else:
normalized = (distances - color_min) / (color_max - color_min)
normalized = np.clip(normalized, 0.0, 1.0)
palette_positions = np.array([0.0, 0.25, 0.50, 0.75, 1.0], dtype=np.float64)
palette_colors = np.array(
[
[0.05, 0.12, 0.75],
[0.00, 0.65, 1.00],
[0.20, 0.90, 0.25],
[1.00, 0.90, 0.00],
[0.90, 0.05, 0.02],
],
dtype=np.float64,
)
colors = np.column_stack(
[
np.interp(normalized, palette_positions, palette_colors[:, channel])
for channel in range(3)
]
)
return colors, color_min, color_max
def write_visualization_stats(path, mesh_path, point_cloud_path, distances, color_min, color_max, runtime):
stats_path = Path(path)
stats_text = "\n".join(
[
f"mesh: {mesh_path}",
f"point_cloud: {point_cloud_path}",
f"point_count: {len(distances)}",
f"min_distance: {distances.min():.8f}",
f"max_distance: {distances.max():.8f}",
f"mean_distance: {distances.mean():.8f}",
f"median_distance: {np.median(distances):.8f}",
f"p90_distance: {np.percentile(distances, 90):.8f}",
f"p95_distance: {np.percentile(distances, 95):.8f}",
f"p99_distance: {np.percentile(distances, 99):.8f}",
f"color_min: {color_min:.8f}",
f"color_max: {color_max:.8f}",
f"runtime: {runtime:.4f}",
]
)
stats_path.write_text(stats_text + "\n", encoding="utf-8")
def visualize_distances(points_np, distances, output_path):
if len(points_np) != len(distances):
raise ValueError(
f"Point count ({len(points_np)}) and distance count ({len(distances)}) do not match."
)
colors, color_min, color_max = distance_colors(distances)
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points_np)
pcd.colors = o3d.utility.Vector3dVector(colors)
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
if not o3d.io.write_point_cloud(str(output_path), pcd):
raise RuntimeError(f"Failed to write colored point cloud: {output_path}")
if OPEN_VISUALIZATION_WINDOW:
vis = o3d.visualization.Visualizer()
vis.create_window(window_name="Point Distance Visualization")
vis.add_geometry(pcd)
render_option = vis.get_render_option()
render_option.point_size = POINT_SIZE
render_option.background_color = np.asarray([1.0, 1.0, 1.0])
vis.run()
vis.destroy_window()
return color_min, color_max
def excel_column_name(column_index):
name = ""
while column_index:
column_index, remainder = divmod(column_index - 1, 26)
name = chr(65 + remainder) + name
return name
def excel_cell(value, row_index, column_index):
cell_ref = f"{excel_column_name(column_index)}{row_index}"
if value is None:
return f'<c r="{cell_ref}"/>'
if isinstance(value, (int, float, np.integer, np.floating)):
return f'<c r="{cell_ref}"><v>{float(value)}</v></c>'
text = xml_escape(str(value))
return f'<c r="{cell_ref}" t="inlineStr"><is><t>{text}</t></is></c>'
def write_summary_xlsx(path, rows):
headers = [
"Task",
"Mesh count",
"Point count",
"Average distance",
"Max distance",
"Runtime",
]
table_rows = [headers] + [
[
row["task_id"],
row["mesh_count"],
row["point_count"],
row["average_distance"],
row["max_distance"],
row["runtime"],
]
for row in rows
]
sheet_rows = []
for row_index, row_values in enumerate(table_rows, start=1):
cells = "".join(
excel_cell(value, row_index, column_index)
for column_index, value in enumerate(row_values, start=1)
)
sheet_rows.append(f'<row r="{row_index}">{cells}</row>')
worksheet_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<cols>
<col min="1" max="1" width="12" customWidth="1"/>
<col min="2" max="3" width="14" customWidth="1"/>
<col min="4" max="6" width="18" customWidth="1"/>
</cols>
<sheetData>{''.join(sheet_rows)}</sheetData>
</worksheet>"""
workbook_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Summary" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>"""
workbook_rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="worksheets/sheet1.xml"/>
</Relationships>"""
root_rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="xl/workbook.xml"/>
</Relationships>"""
content_types_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml"
ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>"""
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as xlsx:
xlsx.writestr("[Content_Types].xml", content_types_xml)
xlsx.writestr("_rels/.rels", root_rels_xml)
xlsx.writestr("xl/workbook.xml", workbook_xml)
xlsx.writestr("xl/_rels/workbook.xml.rels", workbook_rels_xml)
xlsx.writestr("xl/worksheets/sheet1.xml", worksheet_xml)
def write_summary_csv(path, rows):
headers = [
"Task",
"Mesh count",
"Point count",
"Average distance",
"Max distance",
"Runtime",
]
lines = [",".join(headers)]
for row in rows:
lines.append(
",".join(
[
row["task_id"],
str(row["mesh_count"]),
str(row["point_count"]),
f"{row['average_distance']:.8f}",
f"{row['max_distance']:.8f}",
f"{row['runtime']:.4f}",
]
)
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def compute_one_mesh(mesh_path, point_cloud_path, task_output_dir):
start_time = time.perf_counter()
mesh = read_triangle_mesh(str(mesh_path))
original_points_np = read_point_cloud_points(str(point_cloud_path))
distances = compute_mesh_distances(
mesh,
original_points_np,
QUERY_CHUNK_SIZE,
CANDIDATE_TRIANGLE_COUNT,
)
task_output_dir.mkdir(parents=True, exist_ok=True)
distance_output_path = task_output_dir / f"{mesh_path.stem}_distances.npy"
colored_output_path = task_output_dir / f"{mesh_path.stem}_distance_colored.ply"
stats_output_path = task_output_dir / f"{mesh_path.stem}_distance_stats.txt"
np.save(distance_output_path, distances)
color_min, color_max = visualize_distances(
original_points_np,
distances,
colored_output_path,
)
runtime = time.perf_counter() - start_time
write_visualization_stats(
stats_output_path,
mesh_path,
point_cloud_path,
distances,
color_min,
color_max,
runtime,
)
return {
"mesh_name": mesh_path.name,
"point_count": len(distances),
"distance_sum": float(distances.sum()),
"average_distance": float(distances.mean()),
"max_distance": float(distances.max()),
"runtime": runtime,
"distance_output_path": distance_output_path,
"colored_output_path": colored_output_path,
"stats_output_path": stats_output_path,
}
def main():
tasks = discover_mesh_tasks(MESH_DIR, POINT_CLOUD_DIR)
summary_rows = []
for task_id, mesh_pairs in tasks.items():
task_output_dir = RESULT_DIR / task_id
task_point_count = 0
task_distance_sum = 0.0
task_max_distance = 0.0
task_runtime = 0.0
for mesh_path, point_cloud_path in mesh_pairs:
mesh_result = compute_one_mesh(mesh_path, point_cloud_path, task_output_dir)
task_point_count += mesh_result["point_count"]
task_distance_sum += mesh_result["distance_sum"]
task_max_distance = max(task_max_distance, mesh_result["max_distance"])
task_runtime += mesh_result["runtime"]
del mesh_result
gc.collect()
if DEVICE.type == "cuda":
torch.cuda.empty_cache()
summary_rows.append(
{
"task_id": task_id,
"mesh_count": len(mesh_pairs),
"point_count": task_point_count,
"average_distance": task_distance_sum / task_point_count,
"max_distance": task_max_distance,
"runtime": task_runtime,
}
)
summary_rows.sort(key=lambda row: int(row["task_id"]))
write_summary_xlsx(SUMMARY_XLSX_PATH, summary_rows)
write_summary_csv(SUMMARY_CSV_PATH, summary_rows)
print("Task\tAverage distance\tMax distance\tRuntime")
for row in summary_rows:
print(
f"{row['task_id']}\t"
f"{row['average_distance']:.8f}\t"
f"{row['max_distance']:.8f}\t"
f"{row['runtime']:.4f}"
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import time
from pathlib import Path
import numpy as np
class CADConverter:
"""Convert STEP CAD files to STL meshes."""
def read_step_shape(self, input_step: str | Path):
try:
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.STEPControl import STEPControl_Reader
except ModuleNotFoundError as exc:
raise RuntimeError(
"pythonocc-core is required for CAD conversion. "
"Install it with conda-forge before running CAD workflows."
) from exc
input_step = Path(input_step)
step_reader = STEPControl_Reader()
if step_reader.ReadFile(str(input_step)) != IFSelect_RetDone:
raise ValueError(f"Failed to read STEP file: {input_step}")
step_reader.TransferRoots()
return step_reader.OneShape()
def step_to_numpy_mesh(
self,
input_step: str | Path,
mesh_deflection: float = 0.1,
) -> tuple[np.ndarray, np.ndarray]:
try:
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.TopAbs import TopAbs_FACE
from OCC.Core.TopExp import TopExp_Explorer
try:
from OCC.Core.TopoDS import topods
except ImportError:
from OCC.Core.TopoDS import TopoDS
topods = None
except ModuleNotFoundError as exc:
raise RuntimeError(
"pythonocc-core is required for CAD mesh extraction. "
"Install it with conda-forge before running CAD workflows."
) from exc
shape = self.read_step_shape(input_step)
mesh = BRepMesh_IncrementalMesh(shape, mesh_deflection, False)
mesh.Perform()
if not mesh.IsDone():
raise RuntimeError("CAD mesh discretization failed.")
vertices: list[list[float]] = []
faces: list[list[int]] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
current_shape = explorer.Current()
face = topods.Face(current_shape) if topods is not None else TopoDS.Face(current_shape)
location = face.Location()
triangulation = BRep_Tool.Triangulation(face, location)
if triangulation is None:
explorer.Next()
continue
transform = location.Transformation()
base_index = len(vertices)
for index in range(1, triangulation.NbNodes() + 1):
point = triangulation.Node(index)
point.Transform(transform)
vertices.append([point.X(), point.Y(), point.Z()])
for index in range(1, triangulation.NbTriangles() + 1):
n1, n2, n3 = triangulation.Triangle(index).Get()
faces.append([base_index + n1 - 1, base_index + n2 - 1, base_index + n3 - 1])
explorer.Next()
vertices_np = np.asarray(vertices, dtype=np.float64)
faces_np = np.asarray(faces, dtype=np.int64)
if len(vertices_np) == 0 or len(faces_np) == 0:
raise RuntimeError("CAD triangle mesh extraction failed; try a smaller mesh deflection.")
return vertices_np, faces_np
def convert_step_to_stl(self, input_step: str | Path, output_stl: str | Path, precision: float = 1.0) -> Path:
try:
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.StlAPI import StlAPI_Writer
except ModuleNotFoundError as exc:
raise RuntimeError(
"pythonocc-core is required for CAD conversion. "
"Install it with conda-forge before running cad2mesh."
) from exc
input_step = Path(input_step)
output_stl = Path(output_stl)
output_stl.parent.mkdir(parents=True, exist_ok=True)
start = time.time()
print(f"[1/3] Reading STEP: {input_step}")
shape = self.read_step_shape(input_step)
print(f"[2/3] Meshing with precision {precision} mm")
mesh = BRepMesh_IncrementalMesh(shape, precision, False)
mesh.Perform()
if not mesh.IsDone():
raise RuntimeError("Mesh discretization failed.")
print(f"[3/3] Writing STL: {output_stl}")
writer = StlAPI_Writer()
writer.Write(shape, str(output_stl))
print(f"CAD conversion finished in {time.time() - start:.2f}s")
return output_stl

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
11:
13_1:BBox: [(-3210.0, 1287.999159154, -1679.9494693872527), (12310.0, 3630.218176727, -1415.224622514)], Center: (4550.0, 2459.1086679405, -1547.5870459506264)
13_2:BBox: [(-3210.0, 1287.999159154, 1415.224622514), (12310.0, 3630.217969482988, 1679.9494693872527)], Center: (4550.0, 2459.108564318494, 1547.5870459506264)
20_1:BBox: [(-2701.5, 1180.0, -1682.9628204686855), (-1451.5, 3435.639725861, -1380.0)], Center: (-2076.5, 2307.8198629304998, -1531.4814102343428)
20_2:BBox: [(-2701.5, 1179.999999999994, 1379.9999999999998), (-1451.5, 3435.639725861, 1682.9628204686855)], Center: (-2076.5, 2307.819862930497, 1531.4814102343425)
25:BBox: [(12400.0, 1180.0, -1479.9677957553995), (12580.0, 3899.9999364265, 1479.9703280536996)], Center: (12490.0, 2539.99996821325, 0.0012661491500693955)

View File

@@ -0,0 +1,59 @@
paths:
cad: datasets/CAD/11.step
mesh: datasets/Mesh/11.stl
pointcloud: datasets/PointCloud/11.ply
noisy_pointcloud: datasets/Noise_PointCloud/11_noisy.ply
output_mesh: datasets/Mesh/11.stl
curvature_output:
cad:
enabled: false
precision: 1.0
noise:
enabled: true
std: 4.0
seed:
filters:
enabled: true
methods:
- SOR
sor_neighbors: 20
sor_std_ratio: 2.0
ror_points: 5
ror_radius: 20.0
voxel_size: 0.05
pass_axis: z
pass_min:
pass_max:
bf_sigma_s: 0.01
bf_sigma_r: 0.1
registration:
voxel_size: 10.0
roll: 10.0
pitch: 10.0
yaw: 10.0
trans_range: 0.0
random_seed:
evaluation:
chunk_size: 2048
triangle_chunk_size: 4096
cad_mesh_deflection: 0.1
curvature:
radius: 2.0
max_nn: 30
color_scale: 3.0
plane:
size_mm: 1000.0
noise_std_mm: 0.5
num_points: 10000
threshold_mm: 0.5
max_iterations: 2000
visualization:
enabled: true

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any
import yaml
DEFAULT_CONFIG: dict[str, Any] = {
"paths": {
"cad": "CAD/11.step",
"mesh": "Mesh/11.stl",
"pointcloud": "PointCloud/11.ply",
"noisy_pointcloud": "Noise_PointCloud/11_noisy.ply",
"output_mesh": "Mesh/11.stl",
"curvature_output": None,
},
"cad": {
"precision": 1.0,
"enabled": False,
},
"noise": {
"enabled": True,
"std": 4.0,
"seed": None,
},
"filters": {
"enabled": True,
"methods": ["SOR"],
"sor_neighbors": 20,
"sor_std_ratio": 2.0,
"ror_points": 5,
"ror_radius": 20.0,
"voxel_size": 0.05,
"pass_axis": "z",
"pass_min": None,
"pass_max": None,
"bf_sigma_s": 0.01,
"bf_sigma_r": 0.1,
},
"registration": {
"voxel_size": 10.0,
"roll": 10.0,
"pitch": 10.0,
"yaw": 10.0,
"trans_range": 0.0,
"random_seed": None,
},
"evaluation": {
"chunk_size": 2048,
"triangle_chunk_size": 4096,
"cad_mesh_deflection": 0.1,
},
"curvature": {
"radius": 2.0,
"max_nn": 30,
"color_scale": 3.0,
},
"plane": {
"size_mm": 1000.0,
"noise_std_mm": 0.5,
"num_points": 10000,
"threshold_mm": 0.5,
"max_iterations": 2000,
},
"visualization": {
"enabled": True,
},
}
def deep_update(base: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
result = deepcopy(base)
for key, value in updates.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_update(result[key], value)
else:
result[key] = value
return result
class ConfigLoader:
"""Load YAML config and merge it with project defaults."""
def __init__(self, default_config: dict[str, Any] | None = None) -> None:
self.default_config = default_config or DEFAULT_CONFIG
def load(self, config_path: str | None = None) -> dict[str, Any]:
config = deepcopy(self.default_config)
if not config_path:
return config
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
with path.open("r", encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
if not isinstance(user_config, dict):
raise ValueError("Config file must contain a YAML mapping at the top level.")
return deep_update(config, user_config)

View File

@@ -0,0 +1,7 @@
name: point-porj
channels:
- https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge
dependencies:
- python=3.10
- pip
- pythonocc-core=7.5.1

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
import open3d as o3d
import torch
import trimesh
from scipy.spatial import KDTree
@dataclass
class DistanceStats:
mean: float
max: float
rmse: float
min: float
std: float
class Evaluator:
"""Evaluation metrics for point-cloud workflows."""
def chamfer_distance(
self,
pointcloud_a: o3d.geometry.PointCloud,
pointcloud_b: o3d.geometry.PointCloud,
sqrt: bool = True,
) -> tuple[float, float, float]:
points_a = np.asarray(pointcloud_a.points)
points_b = np.asarray(pointcloud_b.points)
if len(points_a) == 0 or len(points_b) == 0:
return float("inf"), 0.0, 0.0
tree_a = KDTree(points_a)
tree_b = KDTree(points_b)
dist_a, _ = tree_b.query(points_a, k=1)
dist_b, _ = tree_a.query(points_b, k=1)
cd_a = float(np.mean(dist_a ** 2))
cd_b = float(np.mean(dist_b ** 2))
cd = cd_a + cd_b
return (float(np.sqrt(cd)), cd_a, cd_b) if sqrt else (float(cd), cd_a, cd_b)
def point_to_mesh_distance_trimesh(self, points: np.ndarray, mesh_path: str) -> tuple[np.ndarray, DistanceStats]:
mesh = trimesh.load(mesh_path, force="mesh")
_, distances, _ = mesh.nearest.on_surface(points)
distances = np.asarray(distances, dtype=np.float64)
return distances, self.distance_stats(distances)
def point_to_mesh_distance_torch(
self,
points: np.ndarray,
mesh: trimesh.Trimesh,
chunk_size: int = 2048,
triangle_chunk_size: int | None = 4096,
) -> tuple[np.ndarray, DistanceStats]:
vertices, faces = self._mesh_arrays(mesh)
return self.point_to_triangles_distance_torch(
points,
vertices,
faces,
point_chunk_size=chunk_size,
triangle_chunk_size=triangle_chunk_size,
)
def point_to_triangles_distance_torch(
self,
points: np.ndarray,
vertices: np.ndarray,
faces: np.ndarray,
point_chunk_size: int = 2048,
triangle_chunk_size: int | None = 4096,
) -> tuple[np.ndarray, DistanceStats]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dtype = torch.float64
vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device)
faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device)
triangles = vertices_t[faces_t]
points_t = torch.as_tensor(points, dtype=dtype, device=device)
distances = []
with torch.no_grad():
for start in range(0, points_t.shape[0], point_chunk_size):
chunk = points_t[start:start + point_chunk_size]
if triangle_chunk_size is None or triangle_chunk_size <= 0:
dist2 = self._point_to_triangle_distance_squared(chunk, triangles)
min_dist2 = dist2.min(dim=1).values
else:
min_dist2 = torch.full(
(chunk.shape[0],),
torch.inf,
dtype=points_t.dtype,
device=device,
)
for triangle_start in range(0, triangles.shape[0], triangle_chunk_size):
triangle_chunk = triangles[triangle_start:triangle_start + triangle_chunk_size]
dist2 = self._point_to_triangle_distance_squared(chunk, triangle_chunk)
min_dist2 = torch.minimum(min_dist2, dist2.min(dim=1).values)
distances.append(torch.sqrt(min_dist2).cpu().numpy())
all_distances = np.concatenate(distances) if distances else np.array([], dtype=np.float64)
return all_distances, self.distance_stats(all_distances)
def colorize_distances(self, points: np.ndarray, distances: np.ndarray) -> o3d.geometry.PointCloud:
if len(points) != len(distances):
raise ValueError("points and distances must have the same length.")
pointcloud = o3d.geometry.PointCloud()
pointcloud.points = o3d.utility.Vector3dVector(np.asarray(points, dtype=np.float64))
if len(distances) == 0:
pointcloud.colors = o3d.utility.Vector3dVector(np.empty((0, 3), dtype=np.float64))
return pointcloud
lower = float(np.min(distances))
upper = float(np.max(distances))
normalized = (distances - lower) / (upper - lower + 1e-12)
colors = self._blue_to_red_colormap(normalized)
pointcloud.colors = o3d.utility.Vector3dVector(colors)
return pointcloud
def distance_stats(self, distances: np.ndarray) -> DistanceStats:
if len(distances) == 0:
return DistanceStats(0.0, 0.0, 0.0, 0.0, 0.0)
return DistanceStats(
mean=float(np.mean(distances)),
max=float(np.max(distances)),
rmse=float(np.sqrt(np.mean(distances ** 2))),
min=float(np.min(distances)),
std=float(np.std(distances)),
)
def registration_summary(self, transformation: np.ndarray, fitness: float, rmse: float) -> dict[str, Any]:
return {
"fitness": fitness,
"rmse": rmse,
"transformation": transformation,
}
def _mesh_arrays(self, mesh: trimesh.Trimesh) -> tuple[np.ndarray, np.ndarray]:
vertices = np.asarray(mesh.vertices, dtype=np.float64)
faces = np.asarray(mesh.faces, dtype=np.int64)
if len(vertices) == 0 or len(faces) == 0:
raise ValueError("Mesh must contain vertices and faces.")
return vertices, faces
def _blue_to_red_colormap(self, values: np.ndarray) -> np.ndarray:
values = np.clip(np.asarray(values, dtype=np.float64), 0.0, 1.0)
colors = np.zeros((len(values), 3), dtype=np.float64)
colors[:, 0] = np.clip(1.8 * values - 0.2, 0.0, 1.0)
colors[:, 1] = np.clip(1.0 - np.abs(values - 0.5) * 2.0, 0.0, 1.0)
colors[:, 2] = np.clip(1.2 - 1.8 * values, 0.0, 1.0)
return colors
def _point_to_triangle_distance_squared(self, points: torch.Tensor, triangles: torch.Tensor) -> torch.Tensor:
p = points[:, None, :]
a = triangles[None, :, 0, :]
b = triangles[None, :, 1, :]
c = triangles[None, :, 2, :]
ab = b - a
ac = c - a
ap = p - a
d1 = (ab * ap).sum(dim=-1)
d2 = (ac * ap).sum(dim=-1)
inf = torch.full_like(d1, torch.inf)
dist2 = torch.where((d1 <= 0.0) & (d2 <= 0.0), (ap * ap).sum(dim=-1), inf)
bp = p - b
d3 = (ab * bp).sum(dim=-1)
d4 = (ac * bp).sum(dim=-1)
dist2 = torch.minimum(dist2, torch.where((d3 >= 0.0) & (d4 <= d3), (bp * bp).sum(dim=-1), inf))
vc = d1 * d4 - d3 * d2
mask = (vc <= 0.0) & (d1 >= 0.0) & (d3 <= 0.0)
v = d1 / (d1 - d3).clamp_min(torch.finfo(points.dtype).eps)
closest = a + v[..., None] * ab
dist2 = torch.minimum(dist2, torch.where(mask, ((p - closest) ** 2).sum(dim=-1), inf))
cp = p - c
d5 = (ab * cp).sum(dim=-1)
d6 = (ac * cp).sum(dim=-1)
dist2 = torch.minimum(dist2, torch.where((d6 >= 0.0) & (d5 <= d6), (cp * cp).sum(dim=-1), inf))
vb = d5 * d2 - d1 * d6
mask = (vb <= 0.0) & (d2 >= 0.0) & (d6 <= 0.0)
w = d2 / (d2 - d6).clamp_min(torch.finfo(points.dtype).eps)
closest = a + w[..., None] * ac
dist2 = torch.minimum(dist2, torch.where(mask, ((p - closest) ** 2).sum(dim=-1), inf))
va = d3 * d6 - d5 * d4
mask = (va <= 0.0) & ((d4 - d3) >= 0.0) & ((d5 - d6) >= 0.0)
w = (d4 - d3) / ((d4 - d3) + (d5 - d6)).clamp_min(torch.finfo(points.dtype).eps)
closest = b + w[..., None] * (c - b)
dist2 = torch.minimum(dist2, torch.where(mask, ((p - closest) ** 2).sum(dim=-1), inf))
denom = (va + vb + vc).clamp_min(torch.finfo(points.dtype).eps)
v = vb / denom
w = vc / denom
closest = a + ab * v[..., None] + ac * w[..., None]
return torch.minimum(dist2, ((p - closest) ** 2).sum(dim=-1))

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
import open3d as o3d
import trimesh
class IOHandler:
"""Centralized input validation and file loading."""
def resolve_path(self, path: str | Path) -> Path:
return Path(path).expanduser()
def require_file(self, path: str | Path, label: str = "file") -> Path:
resolved = self.resolve_path(path)
if not resolved.exists():
raise FileNotFoundError(f"{label} not found: {resolved}")
if not resolved.is_file():
raise ValueError(f"{label} is not a file: {resolved}")
return resolved
def ensure_parent_dir(self, path: str | Path) -> Path:
resolved = self.resolve_path(path)
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def load_point_cloud(self, pointcloud_path: str | Path) -> o3d.geometry.PointCloud:
path = self.require_file(pointcloud_path, "Point cloud")
pcd = o3d.io.read_point_cloud(str(path))
if len(pcd.points) == 0:
raise ValueError(f"Point cloud is empty or unreadable: {path}")
return pcd
def load_mesh(self, mesh_path: str | Path) -> trimesh.Trimesh:
path = self.require_file(mesh_path, "Mesh")
mesh = trimesh.load(str(path), force="mesh")
if mesh.is_empty:
raise ValueError(f"Mesh is empty or unreadable: {path}")
return mesh
def require_cad(self, cad_path: str | Path) -> Path:
return self.require_file(cad_path, "CAD")
def save_point_cloud(self, pointcloud: o3d.geometry.PointCloud, output_path: str | Path) -> Path:
path = self.ensure_parent_dir(output_path)
ok = o3d.io.write_point_cloud(str(path), pointcloud)
if not ok:
raise IOError(f"Failed to save point cloud: {path}")
return path
def point_cloud_to_numpy(self, pointcloud: o3d.geometry.PointCloud) -> np.ndarray:
points = np.asarray(pointcloud.points, dtype=np.float64)
if points.size == 0:
raise ValueError("Point cloud has no points.")
return points
def mesh_triangles_to_numpy(self, mesh: trimesh.Trimesh) -> tuple[np.ndarray, np.ndarray]:
vertices = np.asarray(mesh.vertices, dtype=np.float64)
faces = np.asarray(mesh.faces, dtype=np.int64)
if len(vertices) == 0 or len(faces) == 0:
raise ValueError("Mesh must contain vertices and faces.")
return vertices, faces
def print_mapping(self, data: dict[str, Any]) -> None:
for key, value in data.items():
print(f"{key}: {value}")

View File

@@ -0,0 +1,206 @@
from __future__ import annotations
import argparse
def build_parser() -> argparse.ArgumentParser:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--config", default=argparse.SUPPRESS, help="YAML config path, default uses built-in settings")
parser = argparse.ArgumentParser(description="Industrial geometry measurement workflow")
parser.add_argument("--config", default=None, help="YAML config path, default uses built-in settings")
subparsers = parser.add_subparsers(dest="command", required=True)
cad = subparsers.add_parser("cad2mesh", parents=[common], help="Convert STEP CAD to STL Mesh")
cad.add_argument("--cad", required=False, help="Input STEP file")
cad.add_argument("--mesh", required=False, help="Output STL file")
cad.add_argument("--precision", type=float, default=None, help="Mesh precision in mm")
denoise = subparsers.add_parser("denoise", parents=[common], help="Add noise and run denoise filters")
denoise.add_argument("--pointcloud", "-p", default=None, help="Input clean PLY point cloud")
denoise.add_argument("--noise-std", type=float, default=None, help="Gaussian noise std")
denoise.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
registration = subparsers.add_parser("registration", parents=[common], help="Run simulated point-cloud registration")
registration.add_argument("--pointcloud", "-p", default=None, help="Input PLY point cloud")
registration.add_argument("--mesh", "-m", default=None, help="Reference STL/OBJ/PLY mesh")
registration.add_argument("--voxel-size", "-v", type=float, default=None, help="Registration voxel size")
registration.add_argument("--roll", type=float, default=None, help="Rotation around X axis in degrees")
registration.add_argument("--pitch", type=float, default=None, help="Rotation around Y axis in degrees")
registration.add_argument("--yaw", type=float, default=None, help="Rotation around Z axis in degrees")
registration.add_argument("--trans-range", type=float, default=None, help="Random translation range")
registration.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
contour = subparsers.add_parser(
"contour",
parents=[common],
help="Quick contour/flatness/planarity evaluation against STEP CAD",
)
contour.add_argument("--pointcloud", "-p", default=None, help="Input PLY point cloud")
contour.add_argument("--cad", "-c", default=None, help="Reference STEP CAD file")
contour.add_argument("--mesh", "-m", default=None, help="Legacy reference STL/OBJ/PLY mesh")
contour.add_argument(
"--tasks",
default=None,
help="Comma-separated task ids or sample names. Example: 13 expands 13_1/13_2; 13_1 runs that sample only.",
)
contour.add_argument("--all", action="store_true", help="Run contour evaluation for all Mesh/CAD ids under data root")
contour.add_argument("--data-root", default="datasets", help="Dataset root for --tasks/--all batch mode")
contour.add_argument(
"--pointcloud-kind",
choices=["noise", "clean"],
default="noise",
help="Point cloud source for batch mode: noise=Noise_PointCloud, clean=PointCloud",
)
contour.add_argument("--mesh-deflection", type=float, default=None, help="CAD meshing deflection")
contour.add_argument("--point-chunk-size", type=int, default=None, help="Point chunk size")
contour.add_argument("--triangle-chunk-size", type=int, default=None, help="Triangle chunk size")
contour.add_argument("--save", default=None, help="Optional colored point-cloud output path")
contour.add_argument(
"--excel-output",
nargs="?",
const="excel/contour.xlsx",
default=None,
help="Optional Excel output path; defaults to excel/contour.xlsx when used without a value",
)
contour.add_argument(
"--result-template",
default="excel/测量结果.xlsx",
help="Measurement-result template used by --excel-output",
)
contour.add_argument(
"--combined-context-pointcloud",
default="datasets/train/train_2_10M.ply",
help="Full point cloud to include in batch combined_contour.ply",
)
contour.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
curvature = subparsers.add_parser("curvature", parents=[common], help="Estimate and visualize point-cloud curvature")
curvature.add_argument("--pointcloud", "-p", default=None, help="Input PLY point cloud")
curvature.add_argument("--radius", type=float, default=None, help="Neighborhood radius")
curvature.add_argument("--max-nn", type=int, default=None, help="Max neighbor count")
curvature.add_argument("--color-scale", type=float, default=None, help="Color scale")
curvature.add_argument("--save", default=None, help="Optional colored point-cloud output path")
curvature.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
plane = subparsers.add_parser("plane", parents=[common], help="Estimate plane with RANSAC")
plane.add_argument("--pointcloud", "-p", default=None, help="Optional input PLY point cloud")
plane.add_argument("--threshold-mm", type=float, default=None, help="RANSAC distance threshold in mm")
plane.add_argument("--max-iterations", type=int, default=None, help="RANSAC max iterations")
plane.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
translate_align = subparsers.add_parser(
"translate-align",
parents=[common],
help="Translate-align point cloud to STEP CAD with front/tail centroids",
)
translate_align.add_argument("--cad", "-c", default="datasets/CAD/M02.stp", help="Reference STEP CAD file")
translate_align.add_argument(
"--pointcloud",
"-p",
default="datasets/shift_test_output/",
help="Input PLY point cloud or a directory containing shifted_whole_pointcloud.ply",
)
translate_align.add_argument("--output-dir", "-o", default="results", help="Output directory")
translate_align.add_argument("--axis", choices=["x", "y", "z"], default="x", help="Train length axis")
translate_align.add_argument("--crop-ratio", type=float, default=0.01, help="Front/tail crop ratio")
translate_align.add_argument("--mesh-deflection", type=float, default=0.1, help="CAD meshing deflection")
translate_align.add_argument("--cad-sample-points", type=int, default=30000, help="Points sampled from CAD mesh")
translate_align.add_argument("--local-sample-points", type=int, default=1000, help="Points sampled from each end")
translate_align.add_argument("--scale-pointcloud", type=float, default=1.0, help="Scale factor applied to point cloud")
translate_align.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
full = subparsers.add_parser("full", parents=[common], help="Run configured full workflow")
full.add_argument("--no-visualize", action="store_true", help="Disable Open3D windows")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
from pipeline import MeasurementPipeline
pipeline = MeasurementPipeline(config_path=args.config)
if args.command == "cad2mesh":
pipeline.run_cad2mesh(cad_path=args.cad, mesh_path=args.mesh, precision=args.precision)
elif args.command == "denoise":
pipeline.run_denoise(pointcloud_path=args.pointcloud, noise_std=args.noise_std, no_visualize=args.no_visualize)
elif args.command == "registration":
pipeline.run_registration(
pointcloud_path=args.pointcloud,
mesh_path=args.mesh,
voxel_size=args.voxel_size,
roll=args.roll,
pitch=args.pitch,
yaw=args.yaw,
trans_range=args.trans_range,
no_visualize=args.no_visualize,
)
elif args.command == "contour":
if args.all or args.tasks:
pipeline.run_contour_batch(
task_ids=args.tasks,
all_tasks=args.all,
data_root=args.data_root,
pointcloud_kind=args.pointcloud_kind,
mesh_deflection=args.mesh_deflection,
point_chunk_size=args.point_chunk_size,
triangle_chunk_size=args.triangle_chunk_size,
save_path=args.save,
excel_output_path=args.excel_output,
result_template_path=args.result_template,
combined_context_pointcloud_path=args.combined_context_pointcloud,
no_visualize=args.no_visualize,
)
else:
pipeline.run_contour(
pointcloud_path=args.pointcloud,
cad_path=args.cad,
mesh_path=args.mesh,
mesh_deflection=args.mesh_deflection,
point_chunk_size=args.point_chunk_size,
triangle_chunk_size=args.triangle_chunk_size,
save_path=args.save,
excel_output_path=args.excel_output,
result_template_path=args.result_template,
no_visualize=args.no_visualize,
)
elif args.command == "curvature":
pipeline.run_curvature(
pointcloud_path=args.pointcloud,
radius=args.radius,
max_nn=args.max_nn,
color_scale=args.color_scale,
save_path=args.save,
no_visualize=args.no_visualize,
)
elif args.command == "plane":
pipeline.run_plane(
pointcloud_path=args.pointcloud,
threshold_mm=args.threshold_mm,
max_iterations=args.max_iterations,
no_visualize=args.no_visualize,
)
elif args.command == "translate-align":
pipeline.run_translate_align_pointcloud_to_cad(
cad_path=args.cad,
pointcloud_path=args.pointcloud,
output_dir=args.output_dir,
axis=args.axis,
crop_ratio=args.crop_ratio,
mesh_deflection=args.mesh_deflection,
cad_sample_points=args.cad_sample_points,
local_sample_points=args.local_sample_points,
scale_pointcloud=args.scale_pointcloud,
no_visualize=args.no_visualize,
)
elif args.command == "full":
pipeline.run_full(no_visualize=args.no_visualize)
else:
parser.error(f"Unsupported command: {args.command}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
build/
dist/
*.egg-info/
__pycache__/
.pytest_cache/

View File

@@ -0,0 +1,6 @@
include README.md
recursive-include geometry_measure *.yaml
global-exclude __pycache__/*
global-exclude *.py[cod]
prune datasets
prune archive

View File

@@ -0,0 +1,139 @@
# geometry-measure 打包说明
这个目录只负责打包,不存放数据集;仅包含已接入统一 CLI 的必要归档工作流。
## 本地开发安装
在项目根目录执行:
```bash
python packaging/build_package.py
pip install -e packaging/build
geometry-measure --help
```
也可以进入 `packaging/` 后执行:
```bash
python build_package.py
pip install -e build
geometry-measure --help
```
## 构建 wheel
需要先安装构建工具:
```bash
pip install build
```
然后执行:
```bash
python packaging/build_package.py
python -m build packaging/build
```
构建产物会出现在:
```text
packaging/build/dist/
```
## 运行示例
从项目根目录运行:
```bash
geometry-measure contour --pointcloud datasets/PointCloud/11.ply --cad datasets/CAD/11.step --no-visualize
geometry-measure contour --cad datasets/CAD/11.step --pointcloud datasets/Noise_PointCloud/11_noisy.ply --save vis/11_contour.ply --excel-output --no-visualize
geometry-measure registration --pointcloud datasets/Noise_PointCloud/11_noisy.ply --mesh datasets/Mesh/11.stl --no-visualize
geometry-measure denoise --pointcloud datasets/PointCloud/11.ply --noise-std 4 --no-visualize
```
`contour` 是轮廓/平整度/平面度评估的统一入口,本质上是点云到 STEP CAD 或 Mesh 的距离计算。需要保存距离热力图时传 `--save`,需要保存 Excel 时传 `--excel-output`。如需单独指定网格,可显式传入 `--mesh datasets/Mesh/11.stl`。
`contour --excel-output` 不带值时会保存 `excel/contour.xlsx`;只传文件名时会自动放到 `excel/` 文件夹。Excel 默认读取 `excel/测量结果.xlsx` 模板,按任务号迁移对应行,表头保持模板一致,只写入本次 `测量结果` 和 `测量结论`。可用 `--result-template` 指定其它模板。
`contour` 也支持批量运行:`--tasks 13,31,40` 执行指定任务,其中 `13` 会自动匹配 `13_1`、`13_2` 等样本;`--tasks 13_1` 只运行单个样本。`--all` 优先执行 `datasets/Mesh` 下全部样本,没有 Mesh 时退回 `datasets/CAD` 下全部 STEP;批量模式用 `--pointcloud-kind noise|clean` 选择加噪点云或干净点云。所有成功热力图会按原始坐标额外保存成一个合并 PLY:未传 `--save` 时为 `vis/combined_contour.ply`,`--save vis/contour` 时为 `vis/contour/combined_contour.ply`。合并点云默认加入 `datasets/train/train_2_10M.ply`,可用 `--combined-context-pointcloud` 切换为 `train_2_100M.ply` 或 `train_2_1T.ply`。未传 `--no-visualize` 时,合并结果也会按原始坐标在一个 Open3D 窗口展示。
使用配置文件:
```bash
geometry-measure --config config.yaml full --no-visualize
```
如果使用打包生成的默认配置,构建脚本会把默认路径改为:
```text
datasets/CAD/11.step
datasets/Mesh/11.stl
datasets/PointCloud/11.ply
datasets/Noise_PointCloud/11_noisy.ply
```
## 数据要求
wheel 不包含 `datasets/`,运行时需要自己提供数据路径。建议保持以下结构:
```text
datasets/
CAD/ # STEP CAD,例如 11.step
Mesh/ # STL/OBJ/PLY Mesh,例如 11.stl
PointCloud/ # 原始点云 PLY,例如 11.ply
Noise_PointCloud/ # 加噪或待配准点云 PLY,例如 11_noisy.ply
```
CAD、Mesh 和点云需要处在同一坐标系和同一单位下。示例数据按毫米量级处理。`contour` 默认认为输入点云已经与参考模型对齐。
大规模点云可能运行较慢。可以通过配置文件调整:
```yaml
evaluation:
chunk_size: 2048
triangle_chunk_size: 4096
cad_mesh_deflection: 0.1
```
`chunk_size` 控制点云分块,`triangle_chunk_size` 控制三角面分块,`cad_mesh_deflection` 控制 STEP 三角化精度。
## 输出建议
建议把日志和可视化产物放到项目根目录的 `vis/`:
```bash
mkdir -p vis
geometry-measure curvature --pointcloud datasets/PointCloud/11.ply --save vis/11_curvature.ply --no-visualize
geometry-measure contour --cad datasets/CAD/11.step --pointcloud datasets/Noise_PointCloud/11_noisy.ply --save vis/11_contour.ply --excel-output 11_contour.xlsx --no-visualize
geometry-measure contour --tasks 13,31,40 --data-root datasets --pointcloud-kind noise --save vis/contour --excel-output selected_contour.xlsx --no-visualize
```
如果需要保存命令行指标,可以使用重定向:
```bash
geometry-measure contour --pointcloud datasets/PointCloud/11.ply --cad datasets/CAD/11.step --no-visualize > vis/contour.log 2>&1
```
## CAD 转换依赖
基础安装不强制安装 CAD 转换依赖。需要使用 `cad2mesh` 时,建议使用项目根目录的 `environment.yml` 创建或更新独立 Python 3.10 环境:
```bash
conda env create -f environment.yml
conda activate point-porj
pip install -r requirements.txt
```
如果环境已存在,则执行:
```bash
conda env update -n point-porj -f environment.yml
pip install git+https://github.com/AutodeskAILab/occwl
```
`pythonocc-core` 在很多平台无法通过 pip 安装,请使用 conda 方式。`pythonocc-core=7.5.1` 不支持 Python 3.12,如果 base 环境被固定为 Python 3.12,请新建 Python 3.10 环境。
## 说明
- `datasets/` 不会打进 wheel,请通过命令行参数或配置文件指定本地数据路径。
- 只有 `translate-align` 依赖的归档脚本会打进 wheel,其他 `archive/` 实验脚本不会打包。
- 根目录源码保持原样;`packaging/build_package.py` 会生成临时标准包 `geometry_measure`。

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import shutil
from pathlib import Path
PACKAGING_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = PACKAGING_DIR.parent
BUILD_DIR = PACKAGING_DIR / "build"
PACKAGE_DIR = BUILD_DIR / "geometry_measure"
SOURCE_FILES = [
"main.py",
"pipeline.py",
"io_handler.py",
"config_loader.py",
"cad_converter.py",
"registration.py",
"evaluation.py",
"visualization.py",
"config.yaml",
]
SOURCE_DIRS = [
"preprocessing",
]
ARCHIVE_FILES = [
"translate_align_pointcloud_to_cad.py",
]
IMPORT_REWRITES = {
"from pipeline import MeasurementPipeline": "from geometry_measure.pipeline import MeasurementPipeline",
"from cad_converter import CADConverter": "from geometry_measure.cad_converter import CADConverter",
"from config_loader import ConfigLoader": "from geometry_measure.config_loader import ConfigLoader",
"from evaluation import Evaluator": "from geometry_measure.evaluation import Evaluator",
"from io_handler import IOHandler": "from geometry_measure.io_handler import IOHandler",
"from preprocessing.curvature import CurvatureEstimator": (
"from geometry_measure.preprocessing.curvature import CurvatureEstimator"
),
"from preprocessing.filters import PreprocessingRunner": (
"from geometry_measure.preprocessing.filters import PreprocessingRunner"
),
"from preprocessing.noise import GaussianNoiseAdder": (
"from geometry_measure.preprocessing.noise import GaussianNoiseAdder"
),
"from preprocessing.plane import PlaneEstimator": "from geometry_measure.preprocessing.plane import PlaneEstimator",
"from preprocessing.plane import PlaneResult": "from geometry_measure.preprocessing.plane import PlaneResult",
"from registration import PointCloudRegistration": "from geometry_measure.registration import PointCloudRegistration",
"from visualization import Visualizer": "from geometry_measure.visualization import Visualizer",
}
PATH_REWRITES = {
"CAD/11.step": "datasets/CAD/11.step",
"Mesh/11.stl": "datasets/Mesh/11.stl",
"PointCloud/11.ply": "datasets/PointCloud/11.ply",
"Noise_PointCloud/11_noisy.ply": "datasets/Noise_PointCloud/11_noisy.ply",
}
def main() -> None:
rebuild_dir()
copy_packaging_metadata()
copy_sources()
write_package_markers()
rewrite_python_imports()
rewrite_default_paths()
print(f"Build package prepared at: {BUILD_DIR}")
print("Install with: pip install -e packaging/build")
def rebuild_dir() -> None:
if BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR)
PACKAGE_DIR.mkdir(parents=True)
def copy_packaging_metadata() -> None:
shutil.copy2(PACKAGING_DIR / "pyproject.toml", BUILD_DIR / "pyproject.toml")
shutil.copy2(PACKAGING_DIR / "MANIFEST.in", BUILD_DIR / "MANIFEST.in")
shutil.copy2(PACKAGING_DIR / "README.md", BUILD_DIR / "README.md")
def copy_sources() -> None:
for relative in SOURCE_FILES:
src = PROJECT_ROOT / relative
if not src.exists():
raise FileNotFoundError(f"Missing source file: {src}")
shutil.copy2(src, PACKAGE_DIR / relative)
for relative in SOURCE_DIRS:
src = PROJECT_ROOT / relative
if not src.exists():
raise FileNotFoundError(f"Missing source directory: {src}")
shutil.copytree(src, PACKAGE_DIR / relative)
archive_dir = PACKAGE_DIR / "archive"
archive_dir.mkdir(parents=True, exist_ok=True)
for relative in ARCHIVE_FILES:
src = PROJECT_ROOT / "archive" / relative
if not src.exists():
raise FileNotFoundError(f"Missing archive file: {src}")
shutil.copy2(src, archive_dir / relative)
def write_package_markers() -> None:
(PACKAGE_DIR / "__init__.py").write_text(
'"""Industrial geometry measurement toolkit."""\n\n__version__ = "0.1.0"\n',
encoding="utf-8",
)
preprocessing_init = PACKAGE_DIR / "preprocessing" / "__init__.py"
preprocessing_init.write_text('"""Preprocessing tools for point-cloud workflows."""\n', encoding="utf-8")
archive_init = PACKAGE_DIR / "archive" / "__init__.py"
archive_init.write_text('"""Archived developer workflows exposed by the CLI."""\n', encoding="utf-8")
def rewrite_python_imports() -> None:
for path in PACKAGE_DIR.rglob("*.py"):
text = path.read_text(encoding="utf-8")
for old, new in IMPORT_REWRITES.items():
text = text.replace(old, new)
path.write_text(text, encoding="utf-8")
def rewrite_default_paths() -> None:
targets = [PACKAGE_DIR / "config.yaml", PACKAGE_DIR / "config_loader.py"]
for path in targets:
text = path.read_text(encoding="utf-8")
for old, new in PATH_REWRITES.items():
text = text.replace(old, new)
path.write_text(text, encoding="utf-8")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "geometry-measure"
version = "0.1.0"
description = "Industrial point cloud and mesh geometry measurement toolkit"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"numpy==2.2.2",
"scipy",
"open3d==0.19.0",
"trimesh==4.5.3",
"torch",
"torchvision",
"torchaudio",
"PyYAML",
]
[project.optional-dependencies]
cad = [
"occwl @ git+https://github.com/AutodeskAILab/occwl",
]
build = [
"build",
]
[project.scripts]
geometry-measure = "geometry_measure.main:main"
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
include = ["geometry_measure*"]
[tool.setuptools.package-data]
geometry_measure = ["config.yaml"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import copy
import numpy as np
import open3d as o3d
class CurvatureEstimator:
"""Estimate local point-cloud curvature using neighborhood covariance."""
def compute(self, pointcloud: o3d.geometry.PointCloud, radius: float = 2.0, max_nn: int = 30) -> np.ndarray:
pointcloud.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn))
points = np.asarray(pointcloud.points)
kdtree = o3d.geometry.KDTreeFlann(pointcloud)
curvatures = np.zeros(len(points), dtype=np.float64)
for i, point in enumerate(points):
_, indices, _ = kdtree.search_hybrid_vector_3d(point, radius, max_nn)
if len(indices) < 3:
curvatures[i] = 1.0
continue
neighbors = points[indices]
cov = np.cov((neighbors - neighbors.mean(axis=0)).T)
eigenvalues = np.linalg.eigvalsh(cov)
denom = eigenvalues.sum()
curvatures[i] = float(eigenvalues[0] / denom) if denom > 0 else 0.0
return curvatures
def colorize(self, pointcloud: o3d.geometry.PointCloud, curvatures: np.ndarray, color_scale: float = 3.0) -> o3d.geometry.PointCloud:
pcd = copy.deepcopy(pointcloud)
normalized = curvatures / (np.max(curvatures) + 1e-12)
normalized = np.clip(normalized * color_scale, 0, 1)
colors = np.zeros((len(normalized), 3))
colors[:, 0] = normalized
colors[:, 1] = 1 - normalized
pcd.colors = o3d.utility.Vector3dVector(colors)
return pcd

View File

@@ -0,0 +1,190 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any
import numpy as np
import open3d as o3d
from scipy.spatial import KDTree
@dataclass
class FilterResult:
name: str
pointcloud: o3d.geometry.PointCloud
metrics: dict[str, Any]
outliers: o3d.geometry.PointCloud | None = None
def _base_metrics(original_count: int, filtered_count: int, method: str, params: dict[str, Any]) -> dict[str, Any]:
removed_count = original_count - filtered_count
return {
"method": method,
"original_points": original_count,
"filtered_points": filtered_count,
"removed_points": removed_count,
"keep_ratio": filtered_count / original_count * 100 if original_count > 0 else 0,
"params": params,
}
class SORFilter:
def apply(self, pointcloud: o3d.geometry.PointCloud, nb_neighbors: int = 20, std_ratio: float = 2.0) -> FilterResult:
_, indices = pointcloud.remove_statistical_outlier(nb_neighbors=nb_neighbors, std_ratio=std_ratio)
inliers = pointcloud.select_by_index(indices)
outliers = pointcloud.select_by_index(indices, invert=True)
metrics = _base_metrics(len(pointcloud.points), len(inliers.points), "SOR", {
"nb_neighbors": nb_neighbors,
"std_ratio": std_ratio,
})
return FilterResult("SOR", inliers, metrics, outliers)
class RORFilter:
def apply(self, pointcloud: o3d.geometry.PointCloud, nb_points: int = 5, radius: float = 20.0) -> FilterResult:
_, indices = pointcloud.remove_radius_outlier(nb_points=nb_points, radius=radius)
inliers = pointcloud.select_by_index(indices)
outliers = pointcloud.select_by_index(indices, invert=True)
metrics = _base_metrics(len(pointcloud.points), len(inliers.points), "ROR", {
"nb_points": nb_points,
"radius": radius,
})
return FilterResult("ROR", inliers, metrics, outliers)
class PassThroughFilter:
def apply(
self,
pointcloud: o3d.geometry.PointCloud,
axis: str = "z",
min_val: float | None = None,
max_val: float | None = None,
) -> FilterResult:
axis_map = {"x": 0, "y": 1, "z": 2}
if axis not in axis_map:
raise ValueError("axis must be one of: x, y, z")
points = np.asarray(pointcloud.points)
idx = axis_map[axis]
if min_val is None:
min_val = float(points[:, idx].min())
if max_val is None:
max_val = float(points[:, idx].max())
mask = (points[:, idx] >= min_val) & (points[:, idx] <= max_val)
filtered = o3d.geometry.PointCloud()
filtered.points = o3d.utility.Vector3dVector(points[mask])
if pointcloud.has_colors():
colors = np.asarray(pointcloud.colors)
filtered.colors = o3d.utility.Vector3dVector(colors[mask])
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "PF", {
"axis": axis,
"min_val": min_val,
"max_val": max_val,
})
return FilterResult("PF", filtered, metrics)
class VoxelGridFilter:
def apply(self, pointcloud: o3d.geometry.PointCloud, leaf_size: float = 0.05) -> FilterResult:
filtered = pointcloud.voxel_down_sample(leaf_size)
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "VGF", {"leaf_size": leaf_size})
metrics["downsample_rate"] = (
(len(pointcloud.points) - len(filtered.points)) / len(pointcloud.points) * 100
if len(pointcloud.points) > 0
else 0
)
metrics["compression_ratio"] = len(pointcloud.points) / len(filtered.points) if len(filtered.points) > 0 else 0
return FilterResult("VGF", filtered, metrics)
class BilateralFilter:
def apply(self, pointcloud: o3d.geometry.PointCloud, sigma_s: float = 0.01, sigma_r: float = 0.1) -> FilterResult:
if len(pointcloud.points) == 0:
metrics = _base_metrics(0, 0, "BF", {"sigma_s": sigma_s, "sigma_r": sigma_r})
return FilterResult("BF", copy.deepcopy(pointcloud), metrics)
pcd = copy.deepcopy(pointcloud)
if not pcd.has_normals():
pcd.estimate_normals()
points = np.asarray(pcd.points)
normals = np.asarray(pcd.normals)
tree = KDTree(points)
filtered_points = np.zeros_like(points)
for i, point in enumerate(points):
indices = tree.query_ball_point(point, r=2 * sigma_s)
if len(indices) <= 1:
filtered_points[i] = point
continue
neighbor_points = points[indices]
neighbor_normals = normals[indices]
spatial_dist = np.linalg.norm(neighbor_points - point, axis=1)
spatial_weight = np.exp(-(spatial_dist ** 2) / (2 * sigma_s ** 2))
normal_dist = np.linalg.norm(neighbor_normals - normals[i], axis=1)
range_weight = np.exp(-(normal_dist ** 2) / (2 * sigma_r ** 2))
weights = spatial_weight * range_weight
if weights.sum() > 0:
weights = weights / weights.sum()
filtered_points[i] = np.sum(neighbor_points.T * weights, axis=1)
filtered = o3d.geometry.PointCloud()
filtered.points = o3d.utility.Vector3dVector(filtered_points)
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "BF", {
"sigma_s": sigma_s,
"sigma_r": sigma_r,
})
return FilterResult("BF", filtered, metrics)
class PreprocessingRunner:
"""Run configured filters in sequence or independently."""
def __init__(self) -> None:
self.filters = {
"SOR": SORFilter(),
"ROR": RORFilter(),
"PF": PassThroughFilter(),
"VGF": VoxelGridFilter(),
"BF": BilateralFilter(),
}
def run(self, pointcloud: o3d.geometry.PointCloud, config: dict[str, Any]) -> dict[str, FilterResult]:
results: dict[str, FilterResult] = {}
for method in config.get("methods", ["SOR"]):
name = method.upper()
if name not in self.filters:
raise ValueError(f"Unsupported filter method: {method}")
if name == "SOR":
result = self.filters[name].apply(
pointcloud,
nb_neighbors=int(config.get("sor_neighbors", 20)),
std_ratio=float(config.get("sor_std_ratio", 2.0)),
)
elif name == "ROR":
result = self.filters[name].apply(
pointcloud,
nb_points=int(config.get("ror_points", 5)),
radius=float(config.get("ror_radius", 20.0)),
)
elif name == "PF":
result = self.filters[name].apply(
pointcloud,
axis=str(config.get("pass_axis", "z")),
min_val=config.get("pass_min"),
max_val=config.get("pass_max"),
)
elif name == "VGF":
result = self.filters[name].apply(pointcloud, leaf_size=float(config.get("voxel_size", 0.05)))
else:
result = self.filters[name].apply(
pointcloud,
sigma_s=float(config.get("bf_sigma_s", 0.01)),
sigma_r=float(config.get("bf_sigma_r", 0.1)),
)
results[name] = result
return results

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import copy
import numpy as np
import open3d as o3d
class GaussianNoiseAdder:
"""Add Gaussian noise to an Open3D point cloud."""
def __init__(self, noise_std: float = 4.0, seed: int | None = None) -> None:
self.noise_std = noise_std
self.seed = seed
def apply(self, pointcloud: o3d.geometry.PointCloud) -> o3d.geometry.PointCloud:
noisy = copy.deepcopy(pointcloud)
points = np.asarray(noisy.points)
rng = np.random.default_rng(self.seed)
noise = rng.normal(0, self.noise_std, points.shape)
noisy.points = o3d.utility.Vector3dVector(points + noise)
return noisy
def noise_level(self, clean: o3d.geometry.PointCloud, noisy: o3d.geometry.PointCloud) -> float:
clean_points = np.asarray(clean.points)
noisy_points = np.asarray(noisy.points)
if clean_points.shape != noisy_points.shape:
raise ValueError("Clean and noisy point clouds must have the same point count.")
return float(np.mean(np.linalg.norm(noisy_points - clean_points, axis=1)))

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import open3d as o3d
@dataclass
class PlaneResult:
model: tuple[float, float, float, float]
inliers: list[int]
inlier_ratio: float
normal_angle_deg: float
rmse: float
pointcloud: o3d.geometry.PointCloud
class PlaneEstimator:
"""Generate noisy planes and estimate planes using Open3D RANSAC."""
def create_square_plane_with_noise(
self,
size_mm: float = 1000.0,
noise_std_mm: float = 0.5,
num_points: int = 10000,
) -> o3d.geometry.PointCloud:
half_size_m = size_mm / 2000.0
noise_std_m = noise_std_mm / 1000.0
xy = np.random.uniform(-half_size_m, half_size_m, size=(num_points, 2))
z = np.random.normal(0, noise_std_m, size=(num_points, 1))
points = np.hstack([xy, z])
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
return pcd
def estimate(
self,
pointcloud: o3d.geometry.PointCloud,
threshold_mm: float = 0.5,
max_iterations: int = 2000,
) -> PlaneResult:
threshold_m = threshold_mm / 1000.0
plane_model, inliers = pointcloud.segment_plane(
distance_threshold=threshold_m,
ransac_n=3,
num_iterations=max_iterations,
)
a, b, c, d = plane_model
normal = np.array([a, b, c], dtype=np.float64)
normal = normal / (np.linalg.norm(normal) + 1e-12)
ideal = np.array([0.0, 0.0, 1.0])
angle = float(np.degrees(np.arccos(np.clip(abs(np.dot(normal, ideal)), -1.0, 1.0))))
points = np.asarray(pointcloud.points)
inlier_points = points[inliers]
distances = np.abs(np.dot(inlier_points, np.array([a, b, c])) + d)
rmse = float(np.sqrt(np.mean(distances ** 2))) if len(distances) else 0.0
ratio = len(inliers) / len(points) * 100 if len(points) else 0.0
return PlaneResult(tuple(float(x) for x in plane_model), list(inliers), ratio, angle, rmse, pointcloud)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import numpy as np
import open3d as o3d
from scipy.spatial.transform import Rotation
@dataclass
class RegistrationResult:
transformation: np.ndarray
fitness: float
rmse: float
target: o3d.geometry.PointCloud
recovered: o3d.geometry.PointCloud
class PointCloudRegistration:
"""RANSAC + ICP point-cloud registration."""
def rotation_matrix(self, roll: float = 10.0, pitch: float = 10.0, yaw: float = 10.0) -> np.ndarray:
return Rotation.from_euler("xyz", [roll, pitch, yaw], degrees=True).as_matrix()
def translation_vector(self, trans_range: float = 0.0, seed: int | None = None) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.uniform(-trans_range, trans_range, 3)
def transform_point_cloud(
self,
pointcloud: o3d.geometry.PointCloud,
rotation: np.ndarray,
translation: np.ndarray,
) -> o3d.geometry.PointCloud:
transformed = copy.deepcopy(pointcloud)
points = np.asarray(transformed.points)
transformed.points = o3d.utility.Vector3dVector((rotation @ points.T).T + translation)
return transformed
def register(
self,
source: o3d.geometry.PointCloud,
target: o3d.geometry.PointCloud,
voxel_size: float = 10.0,
) -> tuple[np.ndarray, float, float]:
source_down = source.voxel_down_sample(voxel_size)
target_down = target.voxel_down_sample(voxel_size)
normal_radius = voxel_size * 2
source_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=normal_radius, max_nn=30))
target_down.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=normal_radius, max_nn=30))
feature_radius = voxel_size * 5
source_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
source_down,
o3d.geometry.KDTreeSearchParamHybrid(radius=feature_radius, max_nn=100),
)
target_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
target_down,
o3d.geometry.KDTreeSearchParamHybrid(radius=feature_radius, max_nn=100),
)
distance_threshold = voxel_size * 1.5
result_ransac = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
source_down,
target_down,
source_fpfh,
target_fpfh,
mutual_filter=True,
max_correspondence_distance=distance_threshold,
estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(),
ransac_n=3,
checkers=[
o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(distance_threshold),
],
criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999),
)
source.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
target.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
result_icp = o3d.pipelines.registration.registration_icp(
source,
target,
distance_threshold / 2,
result_ransac.transformation,
o3d.pipelines.registration.TransformationEstimationPointToPlane(),
o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=200),
)
return result_icp.transformation, float(result_icp.fitness), float(result_icp.inlier_rmse)
def run_simulated_registration(
self,
source: o3d.geometry.PointCloud,
voxel_size: float = 10.0,
roll: float = 10.0,
pitch: float = 10.0,
yaw: float = 10.0,
trans_range: float = 0.0,
random_seed: int | None = None,
) -> RegistrationResult:
rotation = self.rotation_matrix(roll, pitch, yaw)
translation = self.translation_vector(trans_range, random_seed)
target = self.transform_point_cloud(source, rotation, translation)
transformation, fitness, rmse = self.register(source, target, voxel_size)
recovered = copy.deepcopy(target)
recovered.transform(np.linalg.inv(transformation))
return RegistrationResult(transformation, fitness, rmse, target, recovered)

View File

@@ -0,0 +1,16 @@
# Python 3.10 is recommended.
# Use environment.yml for conda-only dependencies such as pythonocc-core.
--extra-index-url https://download.pytorch.org/whl/cu118
numpy==2.2.2
scipy
open3d==0.19.0
trimesh==4.5.3
torch
torchvision
torchaudio
PyYAML
# CAD STEP/STL conversion:
# conda env update -n point-porj -f environment.yml
# pip install git+https://github.com/AutodeskAILab/occwl

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
import copy
import math
import numpy as np
import open3d as o3d
from preprocessing.plane import PlaneResult
class Visualizer:
"""Open3D visualization utilities."""
def show_registration(
self,
source: o3d.geometry.PointCloud,
target: o3d.geometry.PointCloud,
recovered: o3d.geometry.PointCloud,
) -> None:
source_colored = copy.deepcopy(source)
target_colored = copy.deepcopy(target)
recovered_colored = copy.deepcopy(recovered)
source_colored.paint_uniform_color([0, 0, 1])
target_colored.paint_uniform_color([0, 1, 0])
recovered_colored.paint_uniform_color([1, 0, 0])
o3d.visualization.draw_geometries(
[source_colored, target_colored],
window_name="Before Registration (Blue: Source, Green: Target)",
width=1024,
height=768,
)
o3d.visualization.draw_geometries(
[recovered_colored, target_colored],
window_name="After Registration (Red: Recovered, Green: Target)",
width=1024,
height=768,
)
def show_denoise(
self,
clean: o3d.geometry.PointCloud,
noisy: o3d.geometry.PointCloud,
filtered: o3d.geometry.PointCloud | None = None,
filter_name: str = "Filtered",
) -> None:
clean_colored = copy.deepcopy(clean)
noisy_colored = copy.deepcopy(noisy)
clean_colored.paint_uniform_color([0, 0, 1])
noisy_colored.paint_uniform_color([1.0, 0.7, 0.5])
o3d.visualization.draw_geometries(
[clean_colored, noisy_colored],
window_name=f"Clean (Blue, {len(clean.points)}) vs Noisy (Orange, {len(noisy.points)})",
)
if filtered is not None:
filtered_colored = copy.deepcopy(filtered)
filtered_colored.paint_uniform_color([0, 1, 0])
o3d.visualization.draw_geometries(
[clean_colored, filtered_colored],
window_name=f"{filter_name}: Clean (Blue) vs Filtered (Green)",
)
def show_curvature(self, pointcloud: o3d.geometry.PointCloud, window_name: str = "Curvature Visualization") -> None:
o3d.visualization.draw_geometries([pointcloud], window_name=window_name)
def show_combined_pointclouds(
self,
pointclouds: list[o3d.geometry.PointCloud],
labels: list[str] | None = None,
window_name: str = "Combined Visualization",
arrange_grid: bool = False,
) -> None:
combined = self.combine_pointclouds(pointclouds, arrange_grid=arrange_grid)
if len(combined.points) == 0:
return
title = window_name
if labels:
title = f"{window_name} ({len(labels)} results)"
o3d.visualization.draw_geometries([combined], window_name=title, width=1280, height=900)
def combine_pointclouds(
self,
pointclouds: list[o3d.geometry.PointCloud],
arrange_grid: bool = False,
) -> o3d.geometry.PointCloud:
geometries = [
copy.deepcopy(pointcloud)
for pointcloud in pointclouds
if pointcloud is not None and len(pointcloud.points) > 0
]
if not geometries:
return o3d.geometry.PointCloud()
for geometry in geometries:
if not geometry.has_colors():
geometry.paint_uniform_color([0.62, 0.62, 0.62])
if arrange_grid and len(geometries) > 1:
extents = [
np.asarray(geometry.get_max_bound()) - np.asarray(geometry.get_min_bound())
for geometry in geometries
]
max_extent = max(float(np.max(extent)) for extent in extents)
spacing = max(max_extent * 1.35, 1.0)
cols = math.ceil(math.sqrt(len(geometries)))
for index, geometry in enumerate(geometries):
row, col = divmod(index, cols)
center = (np.asarray(geometry.get_min_bound()) + np.asarray(geometry.get_max_bound())) / 2.0
target_center = np.array([col * spacing, -row * spacing, 0.0])
geometry.translate(target_center - center)
combined = o3d.geometry.PointCloud()
for geometry in geometries:
combined += geometry
return combined
def show_plane(self, plane_result: PlaneResult) -> None:
pointcloud = copy.deepcopy(plane_result.pointcloud)
pointcloud.paint_uniform_color([0.2, 0.4, 1.0])
inlier_cloud = pointcloud.select_by_index(plane_result.inliers)
inlier_cloud.paint_uniform_color([1.0, 0.2, 0.2])
plane_mesh = self._create_plane_mesh(plane_result.model)
o3d.visualization.draw_geometries(
[pointcloud, inlier_cloud, plane_mesh],
window_name="Plane Fitting",
width=1024,
height=768,
)
def _create_plane_mesh(self, plane_model: tuple[float, float, float, float]) -> o3d.geometry.TriangleMesh:
a, b, c, d = plane_model
size = 1.0
xx, yy = np.meshgrid(np.linspace(-size, size, 2), np.linspace(-size, size, 2))
if abs(c) < 1e-12:
zz = np.zeros_like(xx)
else:
zz = -(a * xx + b * yy + d) / c
vertices = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()])
triangles = np.array([[0, 1, 2], [1, 3, 2]])
mesh = o3d.geometry.TriangleMesh()
mesh.vertices = o3d.utility.Vector3dVector(vertices)
mesh.triangles = o3d.utility.Vector3iVector(triangles)
mesh.paint_uniform_color([0.1, 0.9, 0.1])
mesh.compute_vertex_normals()
return mesh