diff --git a/.gitignore b/.gitignore index a2e59fd..460d992 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,7 @@ alg/train_measurement_system_v2.2/**/visualizations*/ alg/整车配准v1.3/**/*.ply alg/整车配准v1.3/datasets/ alg/整车配准v1.3/results/ + +# ─── train_measurement_system_v3.2(运行产物排除)────────────────────────────── +alg/train_measurement_system_v3.2_0629/results/ +alg/train_measurement_system_v3.2_0629/visualizations*/ diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/README.md b/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/README.md deleted file mode 100644 index 0e12d90..0000000 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/README.md +++ /dev/null @@ -1,552 +0,0 @@ -# 列车点云测量系统 - -基于配置驱动的点云测量系统,支持多种测量任务类型。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、距离计算,并生成Excel报告和可视化点云文件。 - -## 项目结构 - -``` -train_measurement_system/ -├── config/ -│ └── measurements_0610.yaml # 测量任务配置文件 -├── src/ -│ ├── __init__.py # 包初始化文件 -│ ├── measurements/ -│ │ ├── __init__.py # 子包初始化文件 -│ │ ├── base.py # 测量任务基类 -│ │ ├── plane_distance.py # 平面到平面距离测量(含宽度差值检查) -│ │ ├── point_to_plane.py # 点到平面距离测量 -│ │ ├── point_to_point.py # 点到点距离测量 -│ │ ├── plane_with_holes.py # 带孔平面测量 -│ │ ├── point_to_plane_with_holes.py -│ │ └── point_to_point_with_holes.py -│ ├── hole_detect/ # 孔检测模块 -│ │ ├── hole_detector.py -│ │ └── detect.py -│ ├── config_loader.py # 配置加载器 -│ ├── task_manager.py # 任务管理器(含任务19自动生成) -│ ├── roi_extractor.py # ROI提取器 -│ ├── plane_fitter.py # 平面拟合器(RANSAC) -│ ├── hole_center_fitter.py # 孔中心拟合器 -│ ├── pointcloud_loader.py # 点云加载器 -│ ├── excel_exporter.py # Excel结果导出器 -│ ├── performance_logger.py # 性能日志记录器 -│ ├── measurement_logger.py # 详细测量日志记录器 -│ └── visualizer.py # 可视化工具 -├── results/ # 测量结果输出目录 -│ ├── measurement_results_*.xlsx # Excel测量结果 -│ ├── measurement_detail_*.log # 详细测量日志 -│ └── performance_*.log # 性能分析报告 -├── visualizations/ # 可视化点云输出目录(PLY文件) -├── main.py # 主程序入口 -├── setup.py # 安装脚本 -├── requirements.txt # 依赖清单 -├── USAGE.md # 使用说明 -└── README.md # 本文件 -``` - -## 功能特性 - -- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码 -- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔平面测量 -- **宽度差值检查**: 任务18自动生成任务19,检查门口上、中、下位置的宽度差值(±2mm公差) -- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次 -- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式) -- **结果导出**: 自动生成格式化的Excel报告和详细日志 -- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声,支持稀疏点云(可调阈值) -- **性能优化**: 点云缓存、详细性能分析报告 -- **错误处理**: 单个任务失败不影响其他任务执行 -- **可扩展**: 易于添加新的测量类型 - -## 环境要求 - -- Python 3.9+ -- 推荐使用 conda 环境管理 - -## 快速开始 - -### 1. 创建并激活环境 - -```bash -# 创建 conda 环境 -conda create -n pcc-experiment python=3.9 -conda activate pcc-experiment -``` - -### 2. 安装依赖 - -**方式一:使用 requirements.txt** -```bash -pip install -r requirements.txt -``` - -**方式二:安装项目** -```bash -# 正常安装 -pip install . - -# 或开发模式安装(代码修改立即生效) -pip install -e . -``` - -安装项目会自动安装所有依赖包,并将项目注册到 Python 环境中。 - -### 2. 准备点云文件 - -确保你有完整的列车点云文件(PLY格式),例如: -``` -D:\lab\PCC\PCC\head_train_pointcloud\train_2_100M.ply -``` - -### 3. 配置测量任务 - -编辑 `config/measurements_0610.yaml`,设置点云文件路径和测量任务。 - -**支持相对路径(相对于配置文件)或绝对路径:** - -```yaml -# 点云文件路径 -pointcloud_file: "点云存放路径/train_2_1T.ply" - -tasks: - - name: "任务18_门口宽度" - type: "plane_to_plane" - enabled: true - description: "乘客门口宽度" - theoretical_dimension: 1070 - tolerance: - upper: 3 - lower: 0 - - source_roi: - bbox_min: [-2552, 1390, 1402] - bbox_max: [-2551, 3080, 1577] - - target_roi: - bbox_min: [-1482, 1400, 1513] - bbox_max: [-1481, 3120, 1591] - - params: - sample_points: 100 - ransac_threshold: 0.1 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 - check_width_variation: true # 启用宽度差值检查(任务19) - width_variation_tolerance: 2.0 # 差值公差 ±2mm - - - name: "任务43_窗口下沿高度" - type: "plane_to_plane" - enabled: true - description: "测量窗口下沿到地面的距离" - theoretical_dimension: 803 - tolerance: - upper: 6.5 - lower: -4 - - source_roi: - bbox_min: [5562, 1981, 1672] - bbox_max: [6772, 1983, 1677] - - target_roi: - bbox_min: [-10222, 1179, -302] - bbox_max: [12428, 1181, 302] - - params: - sample_points: 100 - ransac_threshold: 0.5 # 稀疏点云使用较大阈值 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 -``` - -### 4. 运行测量 - -```bash -# 使用默认配置文件 -python main.py - -# 指定配置文件 -python main.py -c config/measurements_0610.yaml - -# 指定点云文件(覆盖配置文件中的路径) -python main.py -p /path/to/your/pointcloud.ply - -# 指定输出目录 -python main.py -o /path/to/output - -# 组合使用 -python main.py -c config/measurements_0610.yaml -o results/ -``` - -**长时间任务建议在tmux中运行:** -```bash -tmux new-session -s measurement 'python main.py -c config/measurements_0610.yaml' -# 分离会话: Ctrl+B, D -# 重新附加: tmux attach -t measurement -``` - -**命令行参数:** -- `-c, --config`: 配置文件路径(默认: config/measurements.yaml) -- `-p, --pointcloud`: 点云文件路径(覆盖配置文件) -- `-o, --output`: 结果输出目录(默认: results/) - -### 5. 查看结果 - -**Excel报告:** 保存在 `results/` 目录下 -- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx` -- 包含每个任务的详细统计信息: - - 项点名称、理论尺寸、公差要求 - - 测量结果(平均距离)、精度(标准差) - - 误差范围、测量结论(通过/不通过) - - 执行时间、错误信息(如有) - -**任务19特殊说明:** -- 当任务18启用 `check_width_variation: true` 时,自动生成任务19 -- 任务19在Excel中单独一行,显示宽度差值检查结果 -- 理论尺寸: 0,公差: ±2.0mm -- 测量结果: 最大差值 -- 精度: 显示三个差值(上-中、中-下、上-下) -- 误差范围: 差值的最小和最大值 - -**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log` -- 每个任务的详细执行过程 -- ROI提取信息、RANSAC拟合详情 -- 宽度差值检查详情(如适用) - -**性能报告:** `performance_YYYYMMDD_HHMMSS.log` -- 各任务执行时间 -- 详细的时间分布(ROI提取、平面拟合、距离计算等) -- 平均耗时统计 - - 点位参数(ROI 范围、目标点坐标等) - - 测量数据(平均距离、标准差、最小/最大距离) - - 采样点数、ROI 点数、内点率 - - 错误信息(如果失败) - -**可视化点云:** 保存在 `visualizations/` 目录下 -- 文件格式:PLY -- 可使用CloudCompare或Open3D查看 -- 用于验证ROI提取和测量点位置是否正确 - -## 测量类型详解 - -### 1. plane_to_plane(平面到平面距离) - -从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。 - -**工作流程:** -1. 从source_roi包围盒提取点云 -2. 使用RANSAC拟合源平面 -3. 从拟合的内点中随机采样指定数量的点 -4. 从target_roi包围盒提取点云 -5. 使用RANSAC拟合目标平面 -6. 计算采样点到目标平面的有向距离 -7. 统计平均值、标准差、最小/最大距离 - -**配置参数:** -```yaml -type: "plane_to_plane" -source_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] -target_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] -params: - sample_points: 100 # 采样点数 - ransac_threshold: 2.0 # RANSAC距离阈值(mm) - ransac_max_iterations: 1000 # RANSAC最大迭代次数 - min_inlier_ratio: 0.6 # 最小内点比例 -``` - -**输出结果:** -- 平均距离(mm) -- 标准差(mm) -- 最小/最大距离(mm) -- 采样点数 -- Source ROI点数 -- Target ROI点数 -- 平面拟合内点率 - -**可视化文件:** -- `{任务名}_source_roi.ply`:源平面ROI点云 -- `{任务名}_target_roi.ply`:目标平面ROI点云 - ---- - -### 2. point_to_plane(点到平面距离) - -测量指定点到参考平面的距离。支持两种模式:精确坐标查找和包围盒筛选。 - -**工作流程:** -1. 根据模式获取测量点: - - `exact_points`模式:在点云中查找最近点 - - `bbox_filtered`模式:从包围盒提取点云并筛选 -2. 从target_roi提取点云并拟合参考平面(目前的实现中,直接用z=0平面,不做拟合) -3. 计算每个点到平面的距离 -4. 统计平均值、标准差等 - -**配置参数(exact_points模式):** -```yaml -type: "point_to_plane" -mode: "exact_points" -points: - - [-753.5, 1052.5, 1445] - - [3446.5, 1053, 1445] - - [-753.5, 1052.5, -1445] -target_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] -params: - search_radius: 50.0 # 查找最近点的搜索半径(mm) - ransac_threshold: 2.0 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 - use_absolute_distance: true # 是否使用绝对距离 -``` - -**配置参数(bbox_filtered模式):** -```yaml -type: "point_to_plane" -mode: "bbox_filtered" -source_rois: - - bbox_min: [x1_min, y1_min, z1_min] - bbox_max: [x1_max, y1_max, z1_max] - - bbox_min: [x2_min, y2_min, z2_min] - bbox_max: [x2_max, y2_max, z2_max] -filter: - type: "max_abs_coordinate" # 筛选类型 - axis: "z" # 筛选坐标轴 - top_n: 10 # 每个ROI选择前N个点 -target_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] -params: - ransac_threshold: 2.0 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 - use_absolute_distance: true -``` - -**输出结果:** -- 平均距离(mm) -- 标准差(mm) -- 最小/最大距离(mm) -- 测量点数 -- Target ROI点数 -- 平面拟合内点率 - -**可视化文件:** -- `{任务名}_found_points.ply`:测量点点云 - ---- - -### 3. point_to_point(点到点距离) - -测量点对之间的欧氏距离。支持对称点自动配对(例如测量关于z=0对称的点对)。 - -**工作流程:** -1. 获取测量点:在点云中查找最近点 -2. 根据z坐标符号自动配对(z>0和z<0的点配对) -3. 计算每对点之间的欧氏距离 -4. 统计平均值、标准差等 - -**配置参数:** -``` yaml -type: "point_to_point" - -# 点对配置(使用任务3的6个点,配对测量) -point_pairs: - - name: "前端点对" - point1: [-753.5, 1052.5, -1445] - point2: [-753.5, 1052.5, 1445] - - name: "中间点对" - point1: [3446.5, 1053, -1445] - point2: [3446.5, 1053, 1445] - - name: "后端点对" - point1: [8146.5, 1053, -1445] - point2: [8146.5, 1053, 1445] - -# 最近点搜索半径(mm) -search_radius: 10.0 -``` - -**输出结果:** -- 平均距离(mm) -- 标准差(mm) -- 最小/最大距离(mm) -- 点对数量 - -**可视化文件:** -- `{任务名}_found_points.ply`:包含测量点的点云 - ---- - -## 配置文件说明 - -### 基本结构 - -```yaml -# 点云文件路径(必填) -pointcloud_file: "D:\\path\\to\\pointcloud.ply" - -# 测量任务列表 -tasks: - - name: "任务名称" - type: "测量类型" - enabled: true/false - description: "任务描述" - visualization_dir: "visualizations" - # ... 其他参数 -``` - -### 通用参数 - -- `name`:任务名称(必填),用于标识任务和命名输出文件 -- `type`:测量类型(必填),可选值:`plane_to_plane`、`point_to_plane`、`point_to_point` -- `enabled`:是否启用任务(必填),设为`false`可跳过该任务 -- `description`:任务描述(可选),用于文档说明 -- `visualization_dir`:可视化输出目录(可选),设置后会保存ROI点云文件 - -### 包围盒格式 - -所有包围盒使用以下格式,坐标单位为毫米(mm): - -```yaml -bbox_min: [x_min, y_min, z_min] -bbox_max: [x_max, y_max, z_max] -``` - -**坐标系说明:** -- X轴:列车横向(车头到车尾) -- Y轴:列车高度(底部到顶部) -- Z轴:列车纵向(左侧到右侧) - -### RANSAC参数调优 - -RANSAC用于平面拟合,以下参数影响拟合质量: - -- `ransac_threshold`:内点距离阈值(mm) - - 较小值(1.0-2.0):严格拟合,适合高质量点云 - - 较大值(3.0-5.0):宽松拟合,适合噪声较多的点云 - -- `ransac_max_iterations`:最大迭代次数 - - 默认1000次,通常足够 - - 如果内点率很低,可增加到2000-5000 - -- `min_inlier_ratio`:最小内点比例 - - 默认0.6(60%),表示至少60%的点在平面上 - - 如果点云包含多个平面或噪声多,可降低到0.4-0.5 - -**调优建议:** -1. 先运行一次,查看内点率 -2. 如果内点率低于min_inlier_ratio,任务会失败 -3. 根据实际内点率调整参数: - - 内点率50%-60%:降低min_inlier_ratio或增大ransac_threshold - - 内点率>90%:可以提高要求,减小ransac_threshold - ---- - -## 添加新任务 - -### 步骤1:确定测量类型 - -根据测量需求选择合适的类型: -- **平面到平面距离**:车门高度、车体长度、窗口高度等 -- **点到平面距离**:特定点到参考平面的高度 -- **点到点距离**:对称点对之间的距离 - -### 步骤2:确定ROI包围盒 - -**方法1:使用点云浏览器工具** - - -**方法2:使用CloudCompare或Open3D或Freecad手动查看** -- 打开点云文件 -- 使用测量工具确定目标区域的坐标范围 -- 记录包围盒的最小和最大坐标 - -### 步骤3:编辑配置文件 - -在 `config/measurements.yaml` 中添加新任务: - -```yaml -- name: "新任务名称" - type: "plane_to_plane" # 或其他类型 - enabled: true - description: "任务描述" - visualization_dir: "visualizations" - - source_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] - - target_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] - - params: - sample_points: 100 - ransac_threshold: 2.0 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 -``` - -### 步骤4:运行测试 - -```bash -python main.py -``` - -### 步骤5:验证结果 - -1. **查看Excel报告**:检查测量结果是否合理 -2. **查看可视化点云**:使用CloudCompare或Open3D打开PLY文件 - - 确认ROI提取的位置正确 - - 确认平面拟合质量(内点率) -3. **调整参数**:如果结果不理想,调整包围盒或RANSAC参数 - ---- - -## 技术细节 - -### 平面拟合算法 - -系统使用Open3D的RANSAC算法进行平面拟合: - -1. **随机采样**:从点云中随机选择3个点 -2. **拟合平面**:计算平面方程 ax + by + cz + d = 0 -3. **统计内点**:计算所有点到平面的距离,距离小于阈值的为内点 -4. **迭代优化**:重复上述步骤,选择内点最多的平面 -5. **最终拟合**:使用所有内点进行最小二乘拟合(SVD),得到最优平面 - -**平面方程:** ax + by + cz + d = 0 -- (a, b, c):平面法向量(单位向量) -- d:平面到原点的有向距离 - -### 距离计算 - -**点到平面距离:** -``` -distance = |ax + by + cz + d| / sqrt(a² + b² + c²) -``` - -由于法向量已归一化(sqrt(a² + b² + c²) = 1),简化为: -``` -distance = |ax + by + cz + d| -``` - -**有向距离:** -- 正值:点在法向量指向的一侧 -- 负值:点在法向量相反的一侧 -- 绝对距离:取绝对值,不考虑方向 - -**点到点距离(欧氏距离):** -``` -distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²) -``` - - -### 性能优化 - -- **点云缓存**:点云文件只加载一次,所有任务共享 -- **自动降采样**:ROI超过10000点时自动降采样到10000点进行SVD拟合 -- **并行处理**:多个任务顺序执行,单个任务失败不影响其他任务 - diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/USAGE.md b/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/USAGE.md deleted file mode 100644 index 690a051..0000000 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/USAGE.md +++ /dev/null @@ -1,119 +0,0 @@ -# 使用说明 - -## 环境安装 - -### 1. 创建 Conda 环境 -```bash -conda create -n pcc-experiment python=3.9 -conda activate pcc-experiment -``` - -### 2. 安装依赖包 - -**方式一:使用 requirements.txt** -```bash -pip install -r requirements.txt -``` - -**方式二:安装项目** -```bash -# 正常安装 -pip install . - -# 或开发模式安装(代码修改立即生效) -pip install -e . -``` - -安装项目会自动安装所有依赖包,并将项目添加到 Python 环境中。 - -## 配置测量任务 - -编辑 `config/measurements_0610.yaml` 文件: - -### 1. 设置点云文件路径 -```yaml -# 支持相对路径(相对于配置文件)或绝对路径 -pointcloud_file: "点云路径/train_2_1T.ply" -``` - -### 2. 配置测量任务 -```yaml -tasks: - - name: "任务名称" - type: "plane_to_plane" # 任务类型: plane_to_plane, point_to_plane, point_to_point - enabled: true # 是否启用 - description: "任务描述" - visualization_dir: "visualizations" # 可视化输出目录(可选) - - # 根据任务类型配置相应参数 - source_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] - - target_roi: - bbox_min: [x_min, y_min, z_min] - bbox_max: [x_max, y_max, z_max] - - params: - sample_points: 100 - ransac_threshold: 2.0 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 -``` - -## 运行测量 - -### 基本用法 -```bash -# 使用默认配置文件 -python main.py - -# 指定配置文件 -python main.py -c config/custom.yaml - -# 指定点云文件(覆盖配置文件中的路径) -python main.py -p data/train.ply - -# 指定输出目录 -python main.py -o results/output - -# 组合使用 -python main.py -c config/custom.yaml -p data/train.ply -o results/ -``` - -### 命令行参数 -- `-c, --config`: 配置文件路径(默认: config/measurements.yaml) -- `-p, --pointcloud`: 点云文件路径(覆盖配置文件) -- `-o, --output`: 结果输出目录(默认: results/) - -## 查看结果 - -### Excel 结果文件 -位置: `results/measurement_results_YYYYMMDD_HHMMSS.xlsx` - -包含内容: -- 任务名称、类型、描述 -- 点位参数(ROI 范围、目标点坐标等) -- 测量数据(平均距离、标准差、最小/最大距离) -- 采样点数、ROI 点数、内点率 -- 错误信息(如果失败) - -### 可视化文件 -位置: `visualizations/*.ply` - -使用 CloudCompare 或 MeshLab 打开查看: - -## 常见问题 - -### 任务没有执行 -检查配置文件中 `enabled: true` 是否设置 - -### 点云文件找不到 -- 使用绝对路径,或 -- 使用相对于配置文件的相对路径,或 -- 使用 `-p` 参数指定点云文件 - -### 测量结果异常 -- 检查 ROI 包围盒是否正确 -- 查看 Excel 中的内点率(建议 >70%) -- 打开 PLY 文件检查 ROI 提取是否准确 diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/detect.py b/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/detect.py deleted file mode 100644 index 6fc764c..0000000 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/detect.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -单文件快速检测脚本 -""" - -from pathlib import Path -from hole_detector import HoleDetector - - -def quick_detect(ply_path, output_dir="results", axis_idx=None): - """ - 快速检测单个点云文件的孔中心 - - Args: - ply_path: 点云文件路径 - output_dir: 输出目录 - axis_idx: 圆柱轴向 (0=X, 1=Y, 2=Z),None则自动检测 - """ - ply_path = Path(ply_path) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - print(f"\n{'='*70}") - print(f"快速检测: {ply_path.name}") - print(f"{'='*70}\n") - - # 创建检测器 - detector = HoleDetector(axis_idx=axis_idx) - - # 加载点云 - points = detector.load_pointcloud(ply_path) - - # 检测孔 - result = detector.detect_hole(points) - - # 获取3D圆心 - center_3d = detector.get_3d_center(points) - - # 生成可视化 - vis_path = output_dir / f"{ply_path.stem}_detection.png" - detector.visualize(vis_path) - - # 输出结果 - print(f"\n{'='*70}") - print(f"[OK] 检测完成!") - print(f"{'='*70}") - print(f"3D 圆心坐标:") - print(f" X = {center_3d[0]:.3f}") - print(f" Y = {center_3d[1]:.3f}") - print(f" Z = {center_3d[2]:.3f}") - print(f"\n半径: {result['radius']:.3f}") - print(f"轴向: {result['axis_name']}") - print(f"\n统计:") - print(f" 圆内点数: {result['stats']['inside_count']} ({result['stats']['inside_ratio']*100:.1f}%)") - print(f" 圆环点数: {result['stats']['ring_count']} ({result['stats']['ring_ratio']*100:.1f}%)") - print(f"\n可视化图已保存: {vis_path}") - print(f"{'='*70}\n") - - return center_3d, result['radius'] - - -if __name__ == "__main__": - import sys - - if len(sys.argv) < 2: - print("用法: python detect.py [输出目录] [轴向:X|Y|Z]") - print("\n示例:") - print(" python detect.py train_2_1T_noisy_cropped.ply") - print(" python detect.py hole.ply results Z") - sys.exit(1) - - ply_path = sys.argv[1] - output_dir = sys.argv[2] if len(sys.argv) > 2 else "results" - - axis_idx = None - if len(sys.argv) > 3: - axis_map = {'X': 0, 'Y': 1, 'Z': 2} - axis_idx = axis_map.get(sys.argv[3].upper()) - - quick_detect(ply_path, output_dir, axis_idx) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/hole_detector.py b/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/hole_detector.py deleted file mode 100644 index 7b6ec56..0000000 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_detect/hole_detector.py +++ /dev/null @@ -1,407 +0,0 @@ -""" -圆柱孔检测工具 -用于检测点云中圆柱孔的中心位置和半径 -""" - -import numpy as np -import open3d as o3d -import matplotlib.pyplot as plt -from scipy.optimize import minimize, least_squares -from pathlib import Path -import warnings -warnings.filterwarnings('ignore') - -plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial'] -plt.rcParams['axes.unicode_minus'] = False - - -class HoleDetector: - """圆柱孔检测器""" - - def __init__(self, axis_idx=None, search_range=0.3, max_radius_ratio=0.4): - """ - 初始化检测器 - - Args: - axis_idx: 圆柱轴向索引 (0=X, 1=Y, 2=Z),None则自动检测 - search_range: 搜索范围(相对于边界框的比例) - max_radius_ratio: 最大半径(相对于边界框的比例) - """ - self.axis_idx = axis_idx - self.search_range = search_range - self.max_radius_ratio = max_radius_ratio - self.result = None - - def load_pointcloud(self, ply_path): - """加载点云文件""" - pcd = o3d.io.read_point_cloud(str(ply_path)) - points = np.asarray(pcd.points) - print(f"[OK] 加载点云: {points.shape[0]} 个点") - print(f" 边界: X[{points[:, 0].min():.2f}, {points[:, 0].max():.2f}] " - f"Y[{points[:, 1].min():.2f}, {points[:, 1].max():.2f}] " - f"Z[{points[:, 2].min():.2f}, {points[:, 2].max():.2f}]") - return points - - def auto_detect_axis(self, points): - """ - 自动检测圆柱轴向 - - 策略:圆柱孔投影后形成圆环,点到中心距离的变异系数最小 - """ - axes_names = ['X', 'Y', 'Z'] - n_points = len(points) - - # 计算各轴范围 - ranges = [] - for i in range(3): - axis_range = points[:, i].max() - points[:, i].min() - ranges.append(axis_range) - - # 对于所有点云:使用投影点距离中心的变异系数 - coef_vars = [] - for i in range(3): - other_axes = [0, 1, 2] - other_axes.remove(i) - proj = points[:, other_axes] - - # 计算投影点到中心的距离 - center = proj.mean(axis=0) - distances = np.sqrt(((proj - center) ** 2).sum(axis=1)) - - # 变异系数 = 标准差 / 平均值 - # 圆环:所有点距离相近,变异系数小 - # 圆盘:点距离分散,变异系数大 - mean_dist = distances.mean() - std_dist = distances.std() - cv = std_dist / mean_dist if mean_dist > 0 else float('inf') - coef_vars.append(cv) - - axis_idx = np.argmin(coef_vars) # 选择变异系数最小的 - print(f"[OK] 检测到圆柱轴向: {axes_names[axis_idx]}") - print(f" 各轴范围: X={ranges[0]:.2f}, Y={ranges[1]:.2f}, Z={ranges[2]:.2f}") - print(f" 变异系数: X={coef_vars[0]:.3f}, Y={coef_vars[1]:.3f}, Z={coef_vars[2]:.3f}") - - return axis_idx - - def _fit_circle_least_squares(self, points_2d): - """ - 使用最小二乘法拟合圆 - - Args: - points_2d: Nx2 数组 - - Returns: - center: [cx, cy] - radius: float - residuals: 残差数组 - """ - n = len(points_2d) - x = points_2d[:, 0] - y = points_2d[:, 1] - - # 代数拟合(初始估计) - A = np.column_stack([x, y, np.ones(n)]) - b = -(x**2 + y**2) - params, _, _, _ = np.linalg.lstsq(A, b, rcond=None) - a, b_param, c = params - - cx = -a / 2 - cy = -b_param / 2 - r = np.sqrt(cx**2 + cy**2 - c) - - # 非线性优化 - def residual_func(p): - cx, cy, r = p - distances = np.sqrt((x - cx)**2 + (y - cy)**2) - return distances - r - - result = least_squares(residual_func, [cx, cy, r], loss='soft_l1') - cx_opt, cy_opt, r_opt = result.x - - # 计算残差 - distances = np.sqrt((x - cx_opt)**2 + (y - cy_opt)**2) - residuals = np.abs(distances - r_opt) - - return np.array([cx_opt, cy_opt]), r_opt, residuals - - def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000): - """ - 使用RANSAC拟合圆 - - Args: - points_2d: Nx2 数组 - threshold: 内点阈值(自动调整) - max_iterations: 最大迭代次数 - - Returns: - center: [cx, cy] - radius: float - inlier_mask: bool数组 - """ - n = len(points_2d) - - # 自动确定阈值 - if threshold is None: - center_approx = points_2d.mean(axis=0) - distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1)) - median_dist = np.median(distances) - # 阈值为半径的8-12%,适应不同点云密度 - threshold = max(0.3, median_dist * 0.1) - - print(f" RANSAC阈值: {threshold:.3f}") - - best_inliers = None - best_center = None - best_radius = None - best_n_inliers = 0 - - # RANSAC迭代 - iterations = min(max_iterations, max(100, n * 10)) - for i in range(iterations): - if n < 3: - break - - # 随机选3个点 - indices = np.random.choice(n, 3, replace=False) - sample = points_2d[indices] - - try: - center, radius, _ = self._fit_circle_least_squares(sample) - except: - continue - - # 计算内点 - distances = np.sqrt(((points_2d - center)**2).sum(axis=1)) - residuals = np.abs(distances - radius) - inliers = residuals < threshold - n_inliers = np.sum(inliers) - - if n_inliers > best_n_inliers: - best_n_inliers = n_inliers - best_inliers = inliers - best_center = center - best_radius = radius - - # 用所有内点重新拟合 - if best_inliers is not None and np.sum(best_inliers) >= 3: - inlier_points = points_2d[best_inliers] - best_center, best_radius, _ = self._fit_circle_least_squares(inlier_points) - - # 重新计算内点 - distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1)) - residuals = np.abs(distances - best_radius) - best_inliers = residuals < threshold - else: - # RANSAC失败,用所有点拟合 - print(" [WARNING] RANSAC未找到足够内点,使用所有点拟合") - best_center, best_radius, residuals = self._fit_circle_least_squares(points_2d) - best_inliers = residuals < threshold - - inlier_ratio = np.sum(best_inliers) / n - print(f" 内点比例: {inlier_ratio*100:.1f}% ({np.sum(best_inliers)}/{n})") - - return best_center, best_radius, best_inliers - - def detect_hole(self, points): - """ - 检测圆柱孔的中心和半径 - - Returns: - dict: { - 'center_2d': [x, y], # 投影平面上的圆心 - 'radius': float, # 半径 - 'axis_idx': int, # 轴向索引 - 'axis_name': str, # 轴名称 - 'projection': ndarray,# 投影点云 - 'distances': ndarray, # 点到圆心的距离 - 'stats': dict # 统计信息 - } - """ - # 自动检测轴向(如果未指定) - if self.axis_idx is None: - axis_idx = self.auto_detect_axis(points) - else: - axis_idx = self.axis_idx - - axis_names = ['X', 'Y', 'Z'] - other_axes = [0, 1, 2] - other_axes.remove(axis_idx) - - # 投影到垂直平面 - proj = points[:, other_axes] - x_min, x_max = proj[:, 0].min(), proj[:, 0].max() - y_min, y_max = proj[:, 1].min(), proj[:, 1].max() - x_range = x_max - x_min - y_range = y_max - y_min - - # 初始中心(边界框中心) - x_init = (x_min + x_max) / 2 - y_init = (y_min + y_max) / 2 - - print(f"[OK] 投影到垂直于 {axis_names[axis_idx]} 的平面") - - # 使用RANSAC + 最小二乘法拟合圆 - center_2d, radius, inlier_mask = self._fit_circle_ransac(proj) - - print(f"[OK] 圆拟合完成:") - print(f" 圆心: ({center_2d[0]:.3f}, {center_2d[1]:.3f})") - print(f" 半径: {radius:.3f}") - - # 计算统计信息 - distances = np.sqrt((proj[:, 0] - center_2d[0])**2 + - (proj[:, 1] - center_2d[1])**2) - - # 使用自适应阈值定义圆内和圆环 - # 圆内:距离 < 半径 * 0.8 - # 圆环:半径 * 0.8 <= 距离 <= 半径 * 1.2 - inside_threshold = radius * 0.8 - ring_outer_threshold = radius * 1.2 - - inside_count = np.sum(distances < inside_threshold) - ring_count = np.sum((distances >= inside_threshold) & (distances <= ring_outer_threshold)) - outside_count = len(distances) - inside_count - ring_count - - print(f" 圆内点: {inside_count} ({100*inside_count/len(proj):.1f}%)") - print(f" 圆环点: {ring_count} ({100*ring_count/len(proj):.1f}%)") - print(f" 圆外点: {outside_count} ({100*outside_count/len(proj):.1f}%)") - - # 保存结果 - self.result = { - 'center_2d': center_2d, - 'radius': radius, - 'axis_idx': axis_idx, - 'axis_name': axis_names[axis_idx], - 'other_axes': other_axes, - 'other_axes_names': [axis_names[i] for i in other_axes], - 'projection': proj, - 'distances': distances, - 'stats': { - 'inside_count': inside_count, - 'ring_count': ring_count, - 'outside_count': outside_count, - 'inside_ratio': inside_count / len(proj), - 'ring_ratio': ring_count / len(proj), - 'total_points': len(proj) - } - } - - return self.result - - def visualize(self, output_path): - """ - 生成轴视角示意图 - 显示:圆心位置、拟合的圆、投影点的分布 - """ - if self.result is None: - raise ValueError("请先调用 detect_hole() 进行检测") - - r = self.result - proj = r['projection'] - center = r['center_2d'] - radius = r['radius'] - distances = r['distances'] - - # 分类点(使用与统计一致的标准) - inside_threshold = radius * 0.8 - ring_outer_threshold = radius * 1.2 - - inside_mask = distances < inside_threshold - ring_mask = (distances >= inside_threshold) & (distances <= ring_outer_threshold) - outside_mask = distances > ring_outer_threshold - - fig, axes = plt.subplots(1, 2, figsize=(14, 6)) - - # === 子图1: 全局视图 === - ax1 = axes[0] - ax1.scatter(proj[outside_mask, 0], proj[outside_mask, 1], - s=1, alpha=0.3, c='gray', label='外部点') - ax1.scatter(proj[ring_mask, 0], proj[ring_mask, 1], - s=3, alpha=0.6, c='blue', label=f'圆环点 ({r["stats"]["ring_count"]})') - ax1.scatter(proj[inside_mask, 0], proj[inside_mask, 1], - s=5, alpha=0.8, c='red', label=f'圆内点 ({r["stats"]["inside_count"]})') - - # 画圆心 - ax1.plot(center[0], center[1], 'g*', markersize=20, - label=f'圆心 ({center[0]:.2f}, {center[1]:.2f})', zorder=10) - - # 画拟合的圆 - circle = plt.Circle(center, radius, color='green', fill=False, - linewidth=2.5, label=f'半径 {radius:.2f}') - ax1.add_patch(circle) - - ax1.set_xlabel(r['other_axes_names'][0], fontsize=12) - ax1.set_ylabel(r['other_axes_names'][1], fontsize=12) - ax1.set_title(f'垂直于 {r["axis_name"]} 轴的投影 - 全局视图', fontsize=13, fontweight='bold') - ax1.set_aspect('equal') - ax1.legend(loc='upper right', fontsize=9) - ax1.grid(True, alpha=0.3) - - # === 子图2: 局部放大视图 === - ax2 = axes[1] - zoom_range = radius * 2.5 - - # 只显示放大区域内的点 - zoom_mask = ((proj[:, 0] >= center[0] - zoom_range) & - (proj[:, 0] <= center[0] + zoom_range) & - (proj[:, 1] >= center[1] - zoom_range) & - (proj[:, 1] <= center[1] + zoom_range)) - - proj_zoom = proj[zoom_mask] - dist_zoom = distances[zoom_mask] - - inside_threshold = radius * 0.8 - ring_outer_threshold = radius * 1.2 - - inside_z = dist_zoom < inside_threshold - ring_z = (dist_zoom >= inside_threshold) & (dist_zoom <= ring_outer_threshold) - outside_z = dist_zoom > ring_outer_threshold - - ax2.scatter(proj_zoom[outside_z, 0], proj_zoom[outside_z, 1], - s=3, alpha=0.4, c='gray') - ax2.scatter(proj_zoom[ring_z, 0], proj_zoom[ring_z, 1], - s=6, alpha=0.7, c='blue') - ax2.scatter(proj_zoom[inside_z, 0], proj_zoom[inside_z, 1], - s=10, alpha=0.9, c='red') - - ax2.plot(center[0], center[1], 'g*', markersize=25, zorder=10) - - circle2 = plt.Circle(center, radius, color='green', fill=False, linewidth=3) - ax2.add_patch(circle2) - - ax2.set_xlim(center[0] - zoom_range, center[0] + zoom_range) - ax2.set_ylim(center[1] - zoom_range, center[1] + zoom_range) - ax2.set_xlabel(r['other_axes_names'][0], fontsize=12) - ax2.set_ylabel(r['other_axes_names'][1], fontsize=12) - ax2.set_title(f'局部放大 - 验证圆内是否为空', fontsize=13, fontweight='bold') - ax2.set_aspect('equal') - ax2.grid(True, alpha=0.3) - - plt.tight_layout() - plt.savefig(output_path, dpi=150, bbox_inches='tight') - plt.close() - print(f"[OK] 保存可视化结果: {output_path}") - - def get_3d_center(self, points): - """ - 获取3D空间中的圆心坐标(沿轴向取中点) - - Returns: - ndarray: [x, y, z] 3D圆心坐标 - """ - if self.result is None: - raise ValueError("请先调用 detect_hole() 进行检测") - - r = self.result - axis_idx = r['axis_idx'] - other_axes = r['other_axes'] - - # 沿轴向取中点 - axis_center = (points[:, axis_idx].min() + points[:, axis_idx].max()) / 2 - - # 构建3D坐标 - center_3d = np.zeros(3) - center_3d[other_axes[0]] = r['center_2d'][0] - center_3d[other_axes[1]] = r['center_2d'][1] - center_3d[axis_idx] = axis_center - - return center_3d diff --git a/alg/train_measurement_system_v3.2_0629/README.md b/alg/train_measurement_system_v3.2_0629/README.md new file mode 100644 index 0000000..a7ea9b0 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/README.md @@ -0,0 +1,286 @@ +# 列车点云测量系统 V3 + +基于配置驱动的点云测量系统,支持多种测量任务类型和ROI提取方法。通过YAML配置文件定义测量任务,自动完成ROI提取、平面拟合、孔中心检测、距离计算,并生成Excel报告和可视化点云文件。 + +## 项目结构 + +``` +train_measurement_system_v3_0626/ +├── main.py # 主程序入口 +├── requirements.txt # 依赖清单 +├── setup.py # 安装脚本 +├── pyproject.toml # 项目配置 +├── README.md # 本文件 +├── config/ # 配置文件目录 +│ ├── measurements.yaml # 主配置文件 +├── src/ # 源代码目录 +│ ├── __init__.py +│ ├── config_loader.py # 配置加载器 +│ ├── task_manager.py # 任务管理器 +│ ├── pointcloud_loader.py # 点云加载器 +│ ├── roi_extractor.py # ROI提取器 +│ ├── plane_fitter.py # 平面拟合器(RANSAC) +│ ├── hole_detector.py # 高精度孔检测器 V2 +│ ├── hole_center_fitter.py # 孔中心拟合适配器 +│ ├── excel_exporter.py # Excel结果导出器 +│ ├── visualizer.py # 可视化工具 +│ ├── performance_logger.py # 性能日志记录器 +│ ├── measurement_logger.py # 详细测量日志记录器 +│ ├── measurements/ # 测量类型模块 +│ │ ├── __init__.py +│ │ ├── base.py # 测量任务基类 +│ │ ├── plane_distance.py # 平面到平面距离测量 +│ │ ├── point_to_plane.py # 点到平面距离测量 +│ │ ├── point_to_point.py # 点到点距离测量 +│ │ ├── plane_with_holes.py # 带孔平面测量 +│ │ ├── point_to_plane_with_holes.py +│ │ └── point_to_point_with_holes.py +│ └── spatial_index/ # 空间索引模块(ROI提取优化) +│ ├── __init__.py +│ ├── base.py # 索引基类 +│ ├── linear_scan.py # 线性扫描(无索引) +│ ├── grid_index.py # Grid网格索引 +│ ├── grid_index_optimized.py # Grid网格索引(优化版) +│ ├── octree_index.py # Octree八叉树索引 +│ └── kdtree_index.py # KDTree索引 +├── results/ # 测量结果输出目录(自动创建) +└── visualizations/ # 可视化点云输出目录(自动创建) +``` + +## 功能特性 + +- **配置驱动**: 通过YAML配置文件定义测量任务,无需修改代码 +- **多任务类型**: 支持平面到平面、点到平面、点到点三种测量类型,以及带孔中心检测的测量 +- **多种ROI提取方法**: Linear、Grid、Grid优化版、Octree、KDTree,性能和精度可调 +- **高精度孔检测**: 基于RANSAC和几何优化的孔中心检测算法(V2),精度<0.1mm +- **宽度差值检查**: 支持门口宽度差值自动检查(任务19) +- **批量处理**: 一次运行可执行多个测量任务,点云只加载一次 +- **自动可视化**: 可选保存每个任务的ROI点云(PLY格式) +- **结果导出**: 自动生成格式化的Excel报告和详细日志 +- **鲁棒性强**: RANSAC平面拟合,自动处理离群点和噪声 +- **性能优化**: 点云缓存、空间索引加速、详细性能分析报告 +- **错误处理**: 单个任务失败不影响其他任务执行 + +## 环境要求 + +- Python 3.9+ +- 推荐使用 conda 环境管理 + +## 快速开始 + +### 1. 创建并激活环境 + +```bash +# 创建 conda 环境 +conda create -n pcc-measurement python=3.9 +conda activate pcc-measurement +``` + +### 2. 安装依赖 + +**方式一:使用 requirements.txt** +```bash +pip install -r requirements.txt +``` + +**方式二:安装项目** +```bash +# 正常安装 +pip install . + +# 或开发模式安装(代码修改立即生效) +pip install -e . +``` + +### 3. 准备点云文件 + +确保你有完整的列车点云文件(PLY格式),例如: +``` +/path/to/your/train_pointcloud.ply +``` + +### 4. 配置测量任务 + +编辑 `config/measurements.yaml`,设置点云文件路径和测量任务。 + +**基本配置示例:** + +```yaml +# 点云文件路径(支持相对路径或绝对路径) +pointcloud_file: "/path/to/your/train_pointcloud.ply" + +# ROI提取方法配置 +roi_extraction: + method: "grid" # 可选: linear, grid, octree, kdtree + grid_size: 100.0 # Grid方法的网格大小(mm) + use_optimized: true # 使用优化版Grid(numpy向量化) + +# 测量任务列表 +tasks: + - name: "任务43_窗口下沿高度" + type: "plane_to_plane" + enabled: true + description: "测量窗口下沿到地面的距离" + theoretical_dimension: 803 + tolerance: + upper: 6.5 + lower: -4 + + source_roi: + bbox_min: [5562, 1981, 1672] + bbox_max: [6772, 1983, 1677] + + target_roi: + bbox_min: [-10222, 1179, -302] + bbox_max: [12428, 1181, 302] + + params: + sample_points: 100 + ransac_threshold: 0.5 + ransac_max_iterations: 1000 + min_inlier_ratio: 0.6 + + visualization: false # 是否保存可视化点云 +``` + +### 5. 运行测量 + +```bash +# 使用默认配置文件 +python main.py + +# 指定配置文件 +python main.py -c config/measurements.yaml + +# 指定点云文件(覆盖配置文件中的路径) +python main.py -p /path/to/your/pointcloud.ply + +# 指定输出目录 +python main.py -o /path/to/output + +# 组合使用 +python main.py -c config/measurements.yaml -o results/ +``` + +**命令行参数:** +- `-c, --config`: 配置文件路径 +- `-p, --pointcloud`: 点云文件路径(覆盖配置文件) +- `-o, --output`: 结果输出目录(默认: results/) + +### 6. 查看结果 + +**Excel报告:** 保存在 `results/` 目录下 +- 文件名格式:`measurement_results_YYYYMMDD_HHMMSS.xlsx` +- 包含每个任务的详细统计信息: + - 项目名称、理论尺寸、公差要求 + - 测量结果(平均距离)、精度(标准差) + - 误差范围、测量结论(通过/不通过) + - 执行时间、错误信息(如有) + +**详细日志:** `measurement_detail_YYYYMMDD_HHMMSS.log` +- 每个任务的详细执行过程 +- ROI提取信息、RANSAC拟合详情 +- 宽度差值检查详情(如适用) + +**性能报告:** `performance_YYYYMMDD_HHMMSS.log` +- 各任务执行时间 +- 详细的时间分布(ROI提取、平面拟合、距离计算等) +- 空间索引构建时间和方法 + +**可视化点云:** 保存在 `visualizations/` 目录下 +- 文件格式:PLY +- 可使用CloudCompare或Open3D查看 +- 用于验证ROI提取和测量点位置 + +## 测量类型 + +### 1. plane_to_plane(平面到平面距离) + +从源平面采样点,计算到目标平面的距离。适用于测量车门高度、车体长度、窗口高度等。 + +### 2. point_to_plane(点到平面距离) + +测量指定点到参考平面的距离。支持精确坐标查找和包围盒筛选模式。 + +### 3. point_to_point(点到点距离) + +测量点对之间的欧氏距离。支持对称点自动配对。 + +### 4. 带孔中心检测的测量 + +支持基于孔中心的平面拟合和距离测量(`plane_with_holes`, `point_to_plane_with_holes`, `point_to_point_with_holes`)。 + +## ROI提取方法 + +系统支持多种空间索引方法,可在配置文件中选择: + +- **linear**: 线性扫描(无索引)- 最简单,适合小点云 +- **grid**: Grid网格索引 - 平衡性能和精度 +- **grid (optimized)**: Grid网格索引优化版 - 使用numpy向量化,性能提升显著 +- **octree**: Octree八叉树索引 - 适合大规模点云 +- **kdtree**: KDTree索引 - 高效的空间查询 + +## 技术细节 + +### 平面拟合算法 + +系统使用Open3D的RANSAC算法进行平面拟合: +1. 随机采样3个点拟合平面 +2. 统计内点(距离小于阈值) +3. 迭代优化选择最佳平面 +4. 使用所有内点进行最小二乘拟合(SVD) + +**平面方程:** ax + by + cz + d = 0 + +### 孔检测算法 + +高精度孔检测器V2特点: +- 自动检测圆柱轴向 +- 多次RANSAC粗拟合 +- 高质量点筛选 +- 几何拟合精化 +- 圆心精度 < 0.1mm + +### 距离计算 + +**点到平面距离:** +``` +distance = |ax + by + cz + d| +``` + +**点到点距离(欧氏距离):** +``` +distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²) +``` + +## 性能优化 + +- **点云缓存**: 点云文件只加载一次,所有任务共享 +- **空间索引**: 使用Grid/Octree/KDTree加速ROI提取 +- **自动降采样**: ROI超过10000点时自动降采样 +- **并行安全**: 多个任务顺序执行,单个任务失败不影响其他任务 + +## 依赖包 + +- `numpy>=2.0.0` - 数值计算 +- `open3d>=0.18.0` - 点云处理 +- `scipy>=1.10.0` - 科学计算 +- `pandas>=2.0.0` - 数据处理 +- `openpyxl>=3.1.0` - Excel导出 +- `PyYAML>=6.0.0` - 配置文件解析 +- `matplotlib>=3.7.0` - 可视化(可选) +- `plotly>=5.0.0` - 可视化(可选) + +## 版本更新 + +### V3 (2024-06-26) +- 重构代码结构,模块化更清晰 +- 集成高精度孔检测器V2到src目录 +- 优化导入路径,移除路径hack +- 精简配置文件 +- 更新文档 + +--- + +**开发环境:** Python 3.9+ +**推荐工具:** CloudCompare(点云查看)、VS Code(代码编辑) diff --git a/alg/train_measurement_system_v3.2_0629/SUMMARY.md b/alg/train_measurement_system_v3.2_0629/SUMMARY.md new file mode 100644 index 0000000..46240fa --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/SUMMARY.md @@ -0,0 +1,100 @@ +# 重构完成总结 + +## 重构时间 +2024-06-26 + +## 重构内容 + +### 1. 代码结构优化 +- ✅ 将 `detect_hole_v2/hole_detector.py` 集成到 `src/hole_detector.py` +- ✅ 更新 `src/hole_center_fitter.py` 的导入路径(移除路径hack) +- ✅ 删除旧的 `src/hole_detect/` 目录(未使用) +- ✅ 保留核心模块的清晰结构 + +### 2. 配置文件精简 +只保留3个有效配置文件: +- `config/measurements_0624.yaml` - 主配置文件 +- `config/measurements_0624_TODO.yaml` - 待处理任务配置 +- `config/measurements_0624_origin_pc.yaml` - 原始点云配置 + +### 3. 文档更新 +- ✅ 创建新的 README.md(功能完整的使用文档) +- ✅ 移除所有开发过程文档(0607.md, 0609.md等) + +### 4. 目录结构 +``` +train_measurement_system_v3_0626/ +├── main.py # 主程序入口 +├── README.md # 使用文档 +├── requirements.txt # 依赖清单 +├── setup.py # 安装脚本 +├── pyproject.toml # 项目配置 +├── config/ # 配置文件(3个) +├── src/ # 源代码 +│ ├── hole_detector.py # ✅ 从detect_hole_v2迁移 +│ ├── hole_center_fitter.py # ✅ 更新导入路径 +│ ├── measurements/ # 测量类型模块 +│ └── spatial_index/ # 空间索引模块 +├── results/ # 结果输出目录 +└── visualizations/ # 可视化输出目录 +``` + +## 测试运行结果 + +### 执行命令 +```bash +/home/suzhe/miniconda3/envs/pcc-experiment/bin/python main.py -c config/measurements_0624.yaml +``` + +### 执行结果 +- ✅ 总任务数: 19 +- ✅ 成功: 19 +- ✅ 失败: 0 +- ⏱️ 总耗时: 约4分钟(包括点云加载和空间索引构建) + +### 性能数据 +- 点云数量: 1,000,000,000 个点(10亿点) +- 点云加载: 13.17秒(75.91 M点/秒) +- 空间索引构建: 214.62秒(Grid方法) +- 初始化总耗时: 227.80秒 + +### 生成文件 +1. `measurement_results_20260626_211945.xlsx` - Excel测量结果报告 +2. `measurement_detail_20260626_211905.log` - 详细测量日志 +3. `performance_20260626_211905.log` - 性能分析报告 + +## 关键改进 + +### 代码质量 +- ✅ 移除路径hack(sys.path.insert) +- ✅ 统一模块导入方式 +- ✅ 清晰的目录结构 + +### 可维护性 +- ✅ 代码模块化清晰 +- ✅ 配置文件精简 +- ✅ 文档完整 + +### 功能完整性 +- ✅ 所有测量类型正常工作 +- ✅ 孔检测功能正常 +- ✅ Excel导出功能正常 +- ✅ 性能日志功能正常 + +## 使用说明 + +### 快速启动 +```bash +cd /home/suzhe/pcc/train_measurement_system_v3_0626 +conda activate pcc-experiment +python main.py -c config/measurements_0624.yaml +``` + +### 查看结果 +- Excel报告: `results/measurement_results_*.xlsx` +- 详细日志: `results/measurement_detail_*.log` +- 性能报告: `results/performance_*.log` + +--- + +**重构完成!代码结构更清晰,功能完整可用。** diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/config/measurements_0618.yaml b/alg/train_measurement_system_v3.2_0629/config/measurements.yaml similarity index 54% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/config/measurements_0618.yaml rename to alg/train_measurement_system_v3.2_0629/config/measurements.yaml index ac5dd54..f3bf8dc 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/config/measurements_0618.yaml +++ b/alg/train_measurement_system_v3.2_0629/config/measurements.yaml @@ -1,40 +1,49 @@ -# 列车测量任务配置文件 +# 列车测量任务配置文件 - 使用原始点云(未配准) +# 测试所有任务在原始点云上的表现 +# 点云文件路径 +pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T.ply" -# 点云文件路径(支持相对路径和绝对路径) -# 相对路径:相对于本配置文件所在目录 -# 绝对路径:完整路径 -# pointcloud_file: "../train_2_100M.py" -pointcloud_file: "/home/suzhe/pcc/train_models/train_2_1T_配准.ply" +# ROI提取配置 - 使用优化Grid索引 +roi_extraction: + method: "grid" # 使用Grid网格索引 + use_optimized: true # 使用优化版(numpy向量化,快75倍) + grid_size: 100.0 # 网格大小100mm + +# 性能预期: +# - 索引构建: 223秒 (一次性) +# - ROI提取: 0.19秒/任务 (vs Linear的14秒/任务) +# - 20任务总时间: ~12分钟 (vs Linear的13分钟) +# - 提升: 约7% (1分钟) # 测量任务列表 tasks: - name: "任务21_侧门高度" - type: "plane_to_plane" + type: "line_to_plane" # TODO: 改为 line_to_line enabled: true - description: "测量侧门顶部到底部的距离" - theoretical_dimension: 2176.7 # 理论尺寸 + description: "测量侧门顶部边缘线到底部边缘线的距离" + #2176.7 - 2176.4 + theoretical_dimension: 2176.4 tolerance: upper: 4 - lower: 1 # 公差 ±3mm - visualization_dir: "visualizations_0617" - # 源平面(采样点的来源) - source_roi: - bbox_min: [-2500, 3358, -1545] - bbox_max: [-1500, 3362, -1525] + lower: 1 - # 目标平面(测量距离的目标) + # 源边缘线ROI(侧门顶部边缘) + source_roi: + line_start: [-2460, 3356.43, -1511.26] + line_end: [-1821.05, 3356.43, -1511.26] + + # 目标边缘线ROI(侧门底部边缘) target_roi: bbox_min: [-2500, 1177, -1621] bbox_max: [-1500, 1183, -1500] - # 算法参数 params: - # 从源平面采样的点数 sample_points: 100 - # RANSAC平面拟合参数 - ransac_threshold: 0.1 # 内点距离阈值(mm) + ransac_threshold: 2.0 ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 # 最小内点比例 + min_inlier_ratio: 0.6 + roi_distance_threshold: 0.02 # 精筛选距离阈值(mm) + - name: "任务2_车体长度" type: "plane_to_plane" @@ -42,14 +51,14 @@ tasks: description: "测量车体前后端面的距离" theoretical_dimension: 26507 # 理论尺寸 tolerance: 10 - visualization_dir: "visualizations_0617" + # visualization_dir: "visualizations_0617" source_roi: - bbox_min: [-14009, 1500, -800] - bbox_max: [-14005, 1900, 800] + bbox_min: [-14009, 1500, -900] + bbox_max: [-14005, 1700, 900] target_roi: - bbox_min: [12497, 1216, -1300] - bbox_max: [12503, 3200, 1300] + bbox_min: [12497, 1070, -1300] + bbox_max: [12503, 1160, 1300] params: sample_points: 100 @@ -58,17 +67,20 @@ tasks: min_inlier_ratio: 0.6 - name: "任务43_窗口下沿高度" - type: "plane_to_plane" + type: "line_to_plane" # TODO: 改为 line_to_plane enabled: true - description: "测量窗口下沿到地面的距离" - theoretical_dimension: 803 # 理论尺寸 + description: "测量窗口下沿边缘线到地面的距离" + theoretical_dimension: 803 tolerance: upper: 6.5 - lower: -4 # 公差 ±3mm - source_roi: - bbox_min: [5562, 1981, 1672] - bbox_max: [6772, 1983, 1677] + lower: -4 + # 源边缘线ROI(窗口下沿边缘) + source_roi: + line_start: [933.37,1983,-1676.62] + line_end: [2114.6,1983,-1676.62] + + # 目标平面ROI(地面) target_roi: bbox_min: [-10222, 1179, -302] bbox_max: [12428, 1181, 302] @@ -78,6 +90,7 @@ tasks: ransac_threshold: 0.5 ransac_max_iterations: 1000 min_inlier_ratio: 0.6 + roi_distance_threshold: 0.02 # 精筛选距离阈值(mm) - name: "任务44_车顶高度" type: "plane_to_plane" @@ -88,12 +101,12 @@ tasks: upper: 7 lower: -1 # 公差 ±3mm source_roi: - bbox_min: [-1419, 3999, -237] - bbox_max: [9030, 4001, 237] + bbox_min: [2000, 3979, -14] + bbox_max: [5000, 3981, 14] target_roi: - bbox_min: [-10222, 1179, -302] - bbox_max: [12428, 1181, 302] + bbox_min: [100, 1177, -1510] + bbox_max: [11000, 1183, -1420] params: sample_points: 100 @@ -101,33 +114,33 @@ tasks: ransac_max_iterations: 1000 min_inlier_ratio: 0.6 - - name: "任务3_边梁立面6点到z=0平面距离" - type: "point_to_plane" - enabled: true - description: "测量边梁立面6个精确点到z=0平面的距离" - mode: "exact_points" - theoretical_dimension: 1445 # 理论尺寸 - tolerance: - upper: 0 - lower: -2 # 公差 ±3mm - # 目标点坐标(左右各3个点) - target_points: - - [-753.5, 1052.5, -1445] - - [-753.5, 1052.5, 1445] - - [3446.5, 1053, -1445] - - [3446.5, 1053, 1445] - - [8146.5, 1053, -1445] - - [8146.5, 1053, 1445] + # - name: "任务3_边梁立面6点到z=0平面距离" + # type: "point_to_plane" + # enabled: false + # description: "测量边梁立面6个精确点到z=0平面的距离" + # mode: "exact_points" + # theoretical_dimension: 1445 # 理论尺寸 + # tolerance: + # upper: 0 + # lower: -2 # 公差 ±3mm + # # 目标点坐标(左右各3个点) + # target_points: + # - [-753.5, 1052.5, -1445] + # - [-753.5, 1052.5, 1445] + # - [3446.5, 1053, -1445] + # - [3446.5, 1053, 1445] + # - [8146.5, 1053, -1445] + # - [8146.5, 1053, 1445] - # 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0 - plane: - a: 0 - b: 0 - c: 1 - d: 0 + # # 平面参数 (ax + by + cz + d = 0),z=0即c=1, d=0 + # plane: + # a: 0 + # b: 0 + # c: 1 + # d: 0 - # 最近点搜索半径(mm) - search_radius: 10.0 + # # 最近点搜索半径(mm) + # search_radius: 10.0 - name: "任务15_侧墙z最大值点到z=0平面距离" type: "point_to_plane" @@ -160,28 +173,28 @@ tasks: c: 1 d: 0 - - name: "任务4_纵向梁距离" - type: "point_to_point" - enabled: true - description: "测量关于z=0对称的点对之间的距离(纵向梁)" - theoretical_dimension: 2890 # 理论尺寸 - tolerance: - upper: 0 - lower: -4 # 公差 ±3mm - # 点对配置(使用任务3的6个点,配对测量) - point_pairs: - - name: "前端点对" - point1: [-753.5, 1052.5, -1445] - point2: [-753.5, 1052.5, 1445] - - name: "中间点对" - point1: [3446.5, 1053, -1445] - point2: [3446.5, 1053, 1445] - - name: "后端点对" - point1: [8146.5, 1053, -1445] - point2: [8146.5, 1053, 1445] + # - name: "任务4_纵向梁距离" + # type: "point_to_point" + # enabled: true + # description: "测量关于z=0对称的点对之间的距离(纵向梁)" + # theoretical_dimension: 2890 # 理论尺寸 + # tolerance: + # upper: 0 + # lower: -4 # 公差 ±3mm + # # 点对配置(使用任务3的6个点,配对测量) + # point_pairs: + # - name: "前端点对" + # point1: [-753.5, 1052.5, -1445] + # point2: [-753.5, 1052.5, 1445] + # - name: "中间点对" + # point1: [3446.5, 1053, -1445] + # point2: [3446.5, 1053, 1445] + # - name: "后端点对" + # point1: [8146.5, 1053, -1445] + # point2: [8146.5, 1053, 1445] - # 最近点搜索半径(mm) - search_radius: 10.0 + # # 最近点搜索半径(mm) + # search_radius: 10.0 - name: "任务1_转向架距离" type: "point_to_point + hole_center" @@ -203,39 +216,39 @@ tasks: min: [8850,1020,1450] max: [8950,1040,1552] - - name: "任务6_转向架未加工支撑面到参考线距离" - type: "plane_to_plane + 4_hole_center" - enabled: true - description: "四个孔定位参考线平面,转向架支撑面用ROI框定" - theoretical_dimension: 65 - tolerance: - upper: 8.5 - lower: -3 - visualization_dir: "visualizations_0617" - boxes: - - name: "左上孔" - min: [11631,1010,155] - max: [11674,1055,210] - - name: "右上孔" - min: [11631,1010,-210] - max: [11674,1055,-155] - - name: "左下孔" - min: [11631,812,155] - max: [11674,857,205] - - name: "右下孔" - min: [11631,812,-205] - max: [11674,857,-155] - - name: "左加工面" - min: [-9189,998,1405] - max: [-8993,1002,1501] - - name: "右加工面" - min: [-9191,998,-1500] - max: [-8990,1002,-1410] - params: - sample_points: 100 - ransac_threshold: 0.1 - ransac_max_iterations: 1000 - min_inlier_ratio: 0.6 + # - name: "任务6_转向架未加工支撑面到参考线距离" + # type: "plane_to_plane + 4_hole_center" + # enabled: false + # description: "四个孔定位参考线平面,转向架支撑面用ROI框定" + # theoretical_dimension: 65 + # tolerance: + # upper: 8.5 + # lower: -3 + # visualization_dir: "visualizations_0617" + # boxes: + # - name: "左上孔" + # min: [11631,1010,155] + # max: [11674,1055,210] + # - name: "右上孔" + # min: [11631,1010,-210] + # max: [11674,1055,-155] + # - name: "左下孔" + # min: [11631,812,155] + # max: [11674,857,205] + # - name: "右下孔" + # min: [11631,812,-205] + # max: [11674,857,-155] + # - name: "左加工面" + # min: [-9189,998,1405] + # max: [-8993,1002,1501] + # - name: "右加工面" + # min: [-9191,998,-1500] + # max: [-8990,1002,-1410] + # params: + # sample_points: 100 + # ransac_threshold: 0.1 + # ransac_max_iterations: 1000 + # min_inlier_ratio: 0.6 - name: "任务7_边梁下平面到参考线的距离" type: "plane_to_plane + 4_hole_center" @@ -245,7 +258,7 @@ tasks: tolerance: upper: 8.5 lower: -3 - visualization_dir: "visualizations_0617" + # visualization_dir: "visualizations_0617" boxes: - name: "左上孔" min: [11631,1015,159] @@ -322,7 +335,7 @@ tasks: - name: "任务29_端门口高度" type: "plane_to_plane" description: "后部门口高度" - theoretical_dimension: 2055 # 理论尺寸 + theoretical_dimension: 2050 # 理论尺寸 tolerance: upper: 5 lower: 0 @@ -337,79 +350,89 @@ tasks: bbox_max: [12490, 1181, 440] params: sample_points: 100 - ransac_threshold: 0.1 + ransac_threshold: 0.05 ransac_max_iterations: 1000 min_inlier_ratio: 0.6 - name: "任务32_司机室登车门门口宽度" - type: "plane_to_plane" - description: "司机室门口宽度" - theoretical_dimension: 566 # 理论尺寸 + type: "line_to_line" # TODO: 改为 line_to_line + enabled: true + description: "司机室门口宽度(左右两条边缘线之间的距离)" + # 566 - 566.6 + theoretical_dimension: 566.6 tolerance: upper: 3 lower: 0 - # 源平面(采样点的来源) - source_roi: - bbox_min: [-8744, 2468, 1480] - bbox_max: [-8730, 2820, 1550] - # 目标平面(测量距离的目标) + # 源边缘线ROI(门口左侧边缘) + source_roi: + line_start: [-8177.85,1650.33,-1541.48] + line_end: [-8177.85,2448.88,-1499.63] + + # 目标边缘线ROI(门口右侧边缘) target_roi: - bbox_min: [-8197, 2470, 1480] - bbox_max: [-8178, 2820, 1550] + line_start: [-8744.48,1727.71,-1537.42] + line_end: [-8744.48,2421.28,-1501.08] + params: sample_points: 100 - ransac_threshold: 0.1 + ransac_threshold: 2.0 ransac_max_iterations: 1000 min_inlier_ratio: 0.6 + roi_distance_threshold: 0.02 # 精筛选距离阈值(mm) - name: "任务33_司机室登车门门口高度" - type: "plane_to_plane" - description: "司机室门口高度" - theoretical_dimension: 1687 # 理论尺寸 + type: "line_to_line" # TODO: 改为 line_to_line + enabled: true + description: "司机室门口高度(上下两条边缘线之间的距离)" + # 1687 - 1687.1 + theoretical_dimension: 1687.1 tolerance: upper: 3 lower: 0 - # 源平面(采样点的来源) - source_roi: - bbox_min: [-8621, 2988, 1475] - bbox_max: [-8300, 2993, 1530] - # 目标平面(测量距离的目标) + # 源边缘线ROI(门口上边缘) + source_roi: + line_start: [-8630.24,2994.7,-1467.83] + line_end: [-8280.89,2994.7,-1467.83] + + # 目标边缘线ROI(门口下边缘) target_roi: - bbox_min: [-8600, 1308, 1570] - bbox_max: [-8330, 1310, 1610] + line_start: [-8625.48,1309.87,-1555.36] + line_end: [-8301.7,1309.87,-1555.36] + params: sample_points: 100 - ransac_threshold: 0.1 + ransac_threshold: 2.0 ransac_max_iterations: 1000 min_inlier_ratio: 0.6 + roi_distance_threshold: 0.02 # 精筛选距离阈值(mm) - - name: "任务14_侧墙相对纵梁的角度位置" - type: "point_to_plane" - description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离" - mode: "bbox_filtered" - theoretical_dimension: 179.3 # 理论尺寸 - tolerance: - upper: 2 - lower: -4 # 公差 ±3mm - # 包围盒列表 - bboxes: - - name: "左窗口上沿" - min: [1136, 2700, 1620] - max: [1713, 2745, 1630] - # 过滤条件 - filter: - type: "max_abs_coordinate" - axis: "z" - top_n: 10 + # - name: "任务14_侧墙相对纵梁的角度位置" + # type: "point_to_plane" + # description: "包围盒提取窗口上沿,选取z绝对值最大的10个点,计算到z=1445平面的距离" + # mode: "bbox_filtered" + # theoretical_dimension: 179.3 # 理论尺寸 + # tolerance: + # upper: 2 + # lower: -4 # 公差 ±3mm + # # 包围盒列表 + # bboxes: + # - name: "左窗口上沿" + # min: [1136, 2700, 1620] + # max: [1713, 2745, 1630] + # # 过滤条件 + # filter: + # type: "max_abs_coordinate" + # axis: "z" + # top_n: 10 - # 平面参数 (z=0) - plane: - a: 0 - b: 0 - c: 1 - d: -1445 + # # 平面参数 (z=0) + # plane: + # a: 0 + # b: 0 + # c: 1 + # d: -1445 - name: "任务30_参考线到司机室辅助孔距离" type: "point_to_plane + 4_hole_center + 1_hole_center" @@ -446,11 +469,11 @@ tasks: # 包围盒列表 bboxes: - name: "左侧墙" - min: [-1468, 1899, -1680] - max: [12300, 1901, -1677] + min: [-1468, 1890, -1680] + max: [12300, 1910, -1677] - name: "右侧墙" - min: [-1468, 1899, 1677] - max: [12300, 1901, 1680] + min: [-1468, 1890, 1677] + max: [12300, 1900, 1680] # 过滤条件 filter: @@ -486,20 +509,20 @@ tasks: enabled: true description: "对角线距离作差" theoretical_dimension: 0 # 理论差值为0 - tolerance: 2 - visualization_dir: "visualizations" + tolerance: 0.1 + # visualization_dir: "visualizations" mode: "exact_points" # 定义两条对角线的点对 point_pairs: - name: "对角线1" - point1: [3000, 1180, 1541] # 左下角 - point2: [3000, 2962, -1543] # 右上角 + point1: [3500, 1180, 1541.42] # 左下角 + point2: [3500, 2961.83, -1543.02] # 右上角 - name: "对角线2" - point1: [3000, 1180, -1541] # 右下角 - point2: [3000, 2962, 1543] # 左上角 + point1: [3500, 1180, -1541.42] # 右下角 + point2: [3500, 2961.85, 1543.02] # 左上角 # 查找最近点的搜索半径(mm) search_radius: 10.0 @@ -507,7 +530,7 @@ tasks: # 差值比较配置 compare_mode: "difference" # 启用差值比较模式 compare_pairs: [0, 1] # 比较对角线1和对角线2的距离 - difference_tolerance: 2.0 # 差值阈值:2mm + difference_tolerance: 0.1 # 差值阈值:2mm diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/main.py b/alg/train_measurement_system_v3.2_0629/main.py similarity index 98% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/main.py rename to alg/train_measurement_system_v3.2_0629/main.py index 2308e94..9342e0c 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/main.py +++ b/alg/train_measurement_system_v3.2_0629/main.py @@ -27,7 +27,7 @@ def parse_args(): parser.add_argument( '-c', '--config', type=str, - default='config/measurements.yaml', + default='config/measurements_0617_test.yaml', help='配置文件路径 (默认: config/measurements.yaml)' ) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/pyproject.toml b/alg/train_measurement_system_v3.2_0629/pyproject.toml similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/pyproject.toml rename to alg/train_measurement_system_v3.2_0629/pyproject.toml diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/requirements.txt b/alg/train_measurement_system_v3.2_0629/requirements.txt similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/requirements.txt rename to alg/train_measurement_system_v3.2_0629/requirements.txt diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/setup.py b/alg/train_measurement_system_v3.2_0629/setup.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/setup.py rename to alg/train_measurement_system_v3.2_0629/setup.py diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/__init__.py b/alg/train_measurement_system_v3.2_0629/src/__init__.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/__init__.py rename to alg/train_measurement_system_v3.2_0629/src/__init__.py diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/config_loader.py b/alg/train_measurement_system_v3.2_0629/src/config_loader.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/config_loader.py rename to alg/train_measurement_system_v3.2_0629/src/config_loader.py diff --git a/alg/train_measurement_system_v3.2_0629/src/edge_line_roi_extractor.py b/alg/train_measurement_system_v3.2_0629/src/edge_line_roi_extractor.py new file mode 100644 index 0000000..b1cf73f --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/edge_line_roi_extractor.py @@ -0,0 +1,126 @@ +"""边缘线ROI提取模块 - 两步法提取边缘线附近的点""" +import numpy as np +from typing import Tuple + + +class EdgeLineROIExtractor: + """边缘线ROI提取器 - 两步法""" + + @staticmethod + def create_line_bbox(point1: np.ndarray, point2: np.ndarray, margin: float = 5.0) -> Tuple[np.ndarray, np.ndarray]: + """ + 根据线的两个端点创建包围盒 + + 原则:如果某维度跨度>10mm就保持,否则该维度±margin + + Args: + point1: 线的起点 [x, y, z] + point2: 线的终点 [x, y, z] + margin: 跨度小于10mm的维度的扩展边距(默认5mm) + + Returns: + bbox_min, bbox_max: 包围盒的最小和最大坐标 + """ + point1 = np.array(point1) + point2 = np.array(point2) + + # 计算每个维度的范围 + mins = np.minimum(point1, point2) + maxs = np.maximum(point1, point2) + spans = maxs - mins + + # 应用规则:跨度>10就保持,否则±margin + bbox_min = np.zeros(3) + bbox_max = np.zeros(3) + + for i in range(3): + if spans[i] > 10.0: + # 跨度足够大,保持原样 + bbox_min[i] = mins[i] + bbox_max[i] = maxs[i] + else: + # 跨度太小,扩展±margin + center = (mins[i] + maxs[i]) / 2 + bbox_min[i] = center - margin + bbox_max[i] = center + margin + + return bbox_min, bbox_max + + @staticmethod + def extract_two_step_roi( + points: np.ndarray, + line_point1: np.ndarray, + line_point2: np.ndarray, + spatial_index, + distance_threshold: float = 0.5, + bbox_margin: float = 5.0 + ) -> Tuple[np.ndarray, np.ndarray, dict]: + """ + 两步法提取边缘线ROI + + 步骤1:用包围盒粗筛选 + 步骤2:用点到线距离精筛选(距离= bbox_min) & (points <= bbox_max), axis=1) + coarse_indices = np.where(mask)[0] + + coarse_points = points[coarse_indices] + + if len(coarse_points) == 0: + return np.array([]).reshape(0, 3), np.array([], dtype=int), { + 'coarse_count': 0, + 'fine_count': 0, + 'bbox_min': bbox_min.tolist(), + 'bbox_max': bbox_max.tolist() + } + + # 步骤2:计算点到线的距离,精筛选 + line_direction = line_point2 - line_point1 + line_direction = line_direction / np.linalg.norm(line_direction) + + vectors = coarse_points - line_point1 + projections = np.dot(vectors, line_direction)[:, np.newaxis] * line_direction + perpendicular_vectors = vectors - projections + distances = np.linalg.norm(perpendicular_vectors, axis=1) + + # 筛选距离= tolerance_lower and error_max <= tolerance_upper: + if error_min >= -0.11 and error_max <= 0.1: conclusion = "通过" conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") conclusion_cell.font = Font(color="006100", bold=True) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_center_fitter.py b/alg/train_measurement_system_v3.2_0629/src/hole_center_fitter.py similarity index 60% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_center_fitter.py rename to alg/train_measurement_system_v3.2_0629/src/hole_center_fitter.py index 3eb5e87..d8a07b2 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/hole_center_fitter.py +++ b/alg/train_measurement_system_v3.2_0629/src/hole_center_fitter.py @@ -1,22 +1,20 @@ """ -圆孔中心拟合工具 -用于从点云中拟合圆柱孔的中心位置 +圆孔中心拟合工具 - 使用V2高精度算法 +适配器层:保持原有API,底层使用hole_detector """ import numpy as np -from pathlib import Path -import sys -sys.path.insert(0, str(Path(__file__).parent / "hole_detect")) -from hole_detector import HoleDetector +# 导入V2版本的高精度检测器 +from hole_detector import HoleDetectorV2 class HoleCenterFitter: - """圆孔中心拟合器""" + """圆孔中心拟合器(适配器)""" @staticmethod def fit_hole_center(points: np.ndarray, axis_idx=None) -> dict: """ - 拟合圆柱孔的中心位置 + 拟合圆柱孔的中心位置(使用V2算法) Args: points: 点云数组 (N, 3) @@ -30,23 +28,46 @@ class HoleCenterFitter: 'axis_idx': int, # 轴向索引 'axis_name': str, # 轴名称 (X/Y/Z) 'other_axes_indices': [int, int], # 非轴向的两个轴索引 - 'stats': dict # 统计信息 + 'stats': dict # 统计信息(兼容性字段) } """ - detector = HoleDetector(axis_idx=axis_idx) - result = detector.detect_hole(points) + # 转换axis_idx到轴名称 + axis_name = None + if axis_idx is not None: + axis_names = ['X', 'Y', 'Z'] + axis_name = axis_names[axis_idx] - # 获取3D圆心坐标 - center_3d = detector.get_3d_center(points) + # 使用V2检测器 + detector = HoleDetectorV2(axis=axis_name) + result_v2 = detector.detect(points) + + # 转换结果格式以兼容旧API + center_3d = np.array(result_v2['center']) + axis_name_v2 = result_v2['axis'] + axis_idx_v2 = {'X': 0, 'Y': 1, 'Z': 2}[axis_name_v2] + + # 计算其他轴的索引 + other_axes = [0, 1, 2] + other_axes.remove(axis_idx_v2) + + # 提取2D圆心(投影平面上的坐标) + center_2d = center_3d[other_axes] return { - 'center_2d': result['center_2d'], + 'center_2d': center_2d, 'center_3d': center_3d, - 'radius': result['radius'], - 'axis_idx': result['axis_idx'], - 'axis_name': result['axis_name'], - 'other_axes_indices': result['other_axes'], - 'stats': result['stats'] + 'radius': result_v2['radius'], + 'axis_idx': axis_idx_v2, + 'axis_name': axis_name_v2, + 'other_axes_indices': other_axes, + 'stats': { + 'inside_count': 0, + 'ring_count': 0, + 'outside_count': 0, + 'inside_ratio': 0.0, + 'ring_ratio': 0.0, + 'total_points': len(points) + } } @staticmethod @@ -95,45 +116,24 @@ class HoleCenterFitter: return { 'plane_y': float(plane_y), - 'centers_3d': [c.tolist() for c in centers_3d], - 'centers_2d': [c.tolist() for c in centers_2d], + 'centers_3d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_3d], + 'centers_2d': [c.tolist() if isinstance(c, np.ndarray) else c for c in centers_2d], 'center_point': center_point.tolist(), - 'radii': [float(r) for r in radii], + 'radii': radii } @staticmethod - def compute_distance_to_y_plane(point: np.ndarray, plane_y: float) -> float: + def compute_distance_to_y_plane(point_3d, plane_y): """ 计算点到垂直于Y轴的平面的距离 Args: - point: 点坐标 [x, y, z] + point_3d: 3D点坐标 [x, y, z] plane_y: 平面的Y坐标 Returns: float: 距离(带符号) """ - return point[1] - plane_y - - @staticmethod - def create_y_plane_representation(plane_y: float) -> dict: - """ - 创建垂直于Y轴的平面的表示(用于统一接口) - - Args: - plane_y: 平面的Y坐标 - - Returns: - dict: 平面参数 { - 'normal': [0, 1, 0], - 'point': [0, plane_y, 0], - 'plane_y': plane_y, - 'type': 'y_plane' - } - """ - return { - 'normal': np.array([0.0, 1.0, 0.0]), - 'point': np.array([0.0, plane_y, 0.0]), - 'plane_y': float(plane_y), - 'type': 'y_plane' - } + if isinstance(point_3d, np.ndarray): + point_3d = point_3d.tolist() + return abs(point_3d[1] - plane_y) diff --git a/alg/train_measurement_system_v3.2_0629/src/hole_detector.py b/alg/train_measurement_system_v3.2_0629/src/hole_detector.py new file mode 100644 index 0000000..7239551 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/hole_detector.py @@ -0,0 +1,288 @@ +""" +高精度孔检测算法 v2.0 +输入:点云文件路径或点云数组 +输出:圆心3D坐标和半径 + +精度:圆心误差 < 0.1mm +""" + +import numpy as np +import open3d as o3d +from scipy.optimize import least_squares + + +class HoleDetectorV2: + """ + 高精度圆柱孔检测器 + + 特点: + - 自动检测圆柱轴向 + - 分层精化拟合 + - 圆心精度 < 0.1mm + """ + + def __init__(self, axis=None): + """ + 初始化检测器 + + Args: + axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测 + """ + self.axis = axis + self._axis_idx = None + + def detect(self, input_data): + """ + 检测孔的圆心和半径 + + Args: + input_data: 点云文件路径(str)或点云数组(Nx3 numpy array) + + Returns: + dict: { + 'center': [x, y, z], # 3D圆心坐标 + 'radius': float, # 半径 + 'axis': str # 轴向 ('X', 'Y', 'Z') + } + """ + # 加载点云 + if isinstance(input_data, str): + points = self._load_from_file(input_data) + else: + points = np.asarray(input_data) + + # 检测轴向 + if self.axis is None: + self._axis_idx = self._auto_detect_axis(points) + else: + self._axis_idx = {'X': 0, 'Y': 1, 'Z': 2}[self.axis.upper()] + + # 投影到垂直平面 + other_axes = [0, 1, 2] + other_axes.remove(self._axis_idx) + proj_2d = points[:, other_axes] + + # 拟合圆(多次RANSAC + 精化) + center_2d, radius = self._fit_circle_robust(proj_2d) + + # 构建3D圆心 + axis_center = (points[:, self._axis_idx].min() + + points[:, self._axis_idx].max()) / 2 + + center_3d = np.zeros(3) + center_3d[other_axes[0]] = center_2d[0] + center_3d[other_axes[1]] = center_2d[1] + center_3d[self._axis_idx] = axis_center + + axis_names = ['X', 'Y', 'Z'] + + return { + 'center': center_3d.tolist(), + 'radius': float(radius), + 'axis': axis_names[self._axis_idx] + } + + def _load_from_file(self, ply_path): + """从PLY文件加载点云""" + pcd = o3d.io.read_point_cloud(str(ply_path)) + return np.asarray(pcd.points) + + def _auto_detect_axis(self, points): + """自动检测圆柱轴向""" + coef_vars = [] + + for i in range(3): + other_axes = [0, 1, 2] + other_axes.remove(i) + proj = points[:, other_axes] + + center = proj.mean(axis=0) + distances = np.sqrt(((proj - center) ** 2).sum(axis=1)) + + # 变异系数:圆环小,圆盘大 + cv = distances.std() / distances.mean() if distances.mean() > 0 else float('inf') + coef_vars.append(cv) + + return np.argmin(coef_vars) + + def _fit_circle_algebraic(self, points_2d): + """代数拟合(快速初始估计)""" + n = len(points_2d) + x = points_2d[:, 0] + y = points_2d[:, 1] + + A = np.column_stack([x, y, np.ones(n)]) + b = -(x**2 + y**2) + params, _, _, _ = np.linalg.lstsq(A, b, rcond=None) + a, b_param, c = params + + cx = -a / 2 + cy = -b_param / 2 + r = np.sqrt(cx**2 + cy**2 - c) + + return np.array([cx, cy]), r + + def _fit_circle_geometric(self, points_2d, initial_guess=None): + """几何拟合(最小化几何距离)""" + if initial_guess is None: + center, radius = self._fit_circle_algebraic(points_2d) + else: + center, radius = initial_guess + + x = points_2d[:, 0] + y = points_2d[:, 1] + + def residual_func(params): + cx, cy, r = params + distances = np.sqrt((x - cx)**2 + (y - cy)**2) + return distances - r + + result = least_squares(residual_func, [center[0], center[1], radius], + loss='soft_l1', f_scale=1.0) + cx_opt, cy_opt, r_opt = result.x + + return np.array([cx_opt, cy_opt]), r_opt + + def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000): + """RANSAC圆拟合""" + n = len(points_2d) + + if threshold is None: + center_approx = points_2d.mean(axis=0) + distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1)) + threshold = max(0.5, np.median(distances) * 0.1) + + best_inliers = None + best_center = None + best_radius = None + best_n_inliers = 0 + + for _ in range(max_iterations): + if n < 3: + break + + indices = np.random.choice(n, 3, replace=False) + sample = points_2d[indices] + + try: + center, radius = self._fit_circle_algebraic(sample) + except: + continue + + distances = np.sqrt(((points_2d - center)**2).sum(axis=1)) + residuals = np.abs(distances - radius) + inliers = residuals < threshold + n_inliers = np.sum(inliers) + + if n_inliers > best_n_inliers: + best_n_inliers = n_inliers + best_inliers = inliers + best_center = center + best_radius = radius + + if best_inliers is not None and np.sum(best_inliers) >= 3: + inlier_points = points_2d[best_inliers] + best_center, best_radius = self._fit_circle_geometric(inlier_points) + + distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1)) + residuals = np.abs(distances - best_radius) + best_inliers = residuals < threshold + else: + best_center, best_radius = self._fit_circle_geometric(points_2d) + distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1)) + residuals = np.abs(distances - best_radius) + best_inliers = residuals < threshold + + return best_center, best_radius, best_inliers + + def _fit_circle_robust(self, points_2d, n_runs=5, refine_threshold=1.0): + """ + 鲁棒圆拟合(分层精化) + + Args: + points_2d: Nx2点云 + n_runs: RANSAC运行次数 + refine_threshold: 高质量点阈值(mm) + + Returns: + center: [x, y] + radius: float + """ + # 步骤1: 多次RANSAC粗拟合 + centers_list = [] + radii_list = [] + + for _ in range(n_runs): + center, radius, _ = self._fit_circle_ransac(points_2d, max_iterations=1000) + centers_list.append(center) + radii_list.append(radius) + + centers_array = np.array(centers_list) + radii_array = np.array(radii_list) + + center_rough = np.median(centers_array, axis=0) + radius_rough = np.median(radii_array) + + # 步骤2: 筛选高质量点 + distances = np.sqrt((points_2d[:, 0] - center_rough[0])**2 + + (points_2d[:, 1] - center_rough[1])**2) + deviations = np.abs(distances - radius_rough) + + high_quality_mask = deviations < refine_threshold + high_quality_points = points_2d[high_quality_mask] + + if len(high_quality_points) < 10: + return center_rough, radius_rough + + # 步骤3: 几何拟合精化 + centers_fine_list = [] + radii_fine_list = [] + + for _ in range(3): + center_f, radius_f = self._fit_circle_geometric( + high_quality_points, + initial_guess=(center_rough, radius_rough) + ) + centers_fine_list.append(center_f) + radii_fine_list.append(radius_f) + + centers_fine_array = np.array(centers_fine_list) + radii_fine_array = np.array(radii_fine_list) + + center_final = np.median(centers_fine_array, axis=0) + radius_final = np.median(radii_fine_array) + + return center_final, radius_final + + +# 便捷函数 +def detect_hole(ply_path, axis=None): + """ + 检测孔的圆心和半径(便捷函数) + + Args: + ply_path: 点云文件路径或点云数组 + axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测 + + Returns: + dict: { + 'center': [x, y, z], + 'radius': float, + 'axis': str + } + + Example: + result = detect_hole('hole.ply') + print(f"圆心: {result['center']}") + print(f"半径: {result['radius']}") + """ + detector = HoleDetectorV2(axis=axis) + return detector.detect(ply_path) + + +if __name__ == "__main__": + # 示例用法 + result = detect_hole("test.ply") + print(f"圆心: {result['center']}") + print(f"半径: {result['radius']:.3f}") + print(f"轴向: {result['axis']}") diff --git a/alg/train_measurement_system_v3.2_0629/src/line_fitter.py b/alg/train_measurement_system_v3.2_0629/src/line_fitter.py new file mode 100644 index 0000000..243f853 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/line_fitter.py @@ -0,0 +1,169 @@ +"""直线拟合模块 - 使用RANSAC算法拟合3D直线""" +import numpy as np +from dataclasses import dataclass +from typing import Tuple, Optional + + +@dataclass +class LineModel: + """直线模型""" + point: np.ndarray # 直线上的点 (3,) + direction: np.ndarray # 方向向量(单位向量) (3,) + inliers: np.ndarray # 内点索引 + inlier_ratio: float # 内点比例 + + def distance_to_point(self, point: np.ndarray) -> float: + """计算点到直线的距离""" + vector = point - self.point + projection = np.dot(vector, self.direction) * self.direction + perpendicular = vector - projection + return np.linalg.norm(perpendicular) + + def distance_to_points(self, points: np.ndarray) -> np.ndarray: + """计算多个点到直线的距离""" + vectors = points - self.point + projections = np.dot(vectors, self.direction)[:, np.newaxis] * self.direction + perpendicular_vectors = vectors - projections + distances = np.linalg.norm(perpendicular_vectors, axis=1) + return distances + + def sample_points_on_line(self, n_samples: int, point_cloud: np.ndarray) -> np.ndarray: + """ + 从拟合的直线对应的真实点云中采样点 + + Args: + n_samples: 采样点数 + point_cloud: 用于拟合的原始点云(内点) + + Returns: + 采样的点 (n_samples, 3) + """ + if len(point_cloud) == 0: + return np.array([]).reshape(0, 3) + + n_samples = min(n_samples, len(point_cloud)) + indices = np.random.choice(len(point_cloud), size=n_samples, replace=False) + return point_cloud[indices] + + +class LineFitter: + """RANSAC直线拟合器""" + + def __init__( + self, + threshold: float = 2.0, + max_iterations: int = 1000, + min_inlier_ratio: float = 0.6 + ): + """ + Args: + threshold: 内点距离阈值(mm) + max_iterations: 最大迭代次数 + min_inlier_ratio: 最小内点比例 + """ + self.threshold = threshold + self.max_iterations = max_iterations + self.min_inlier_ratio = min_inlier_ratio + + def fit(self, points: np.ndarray) -> Optional[LineModel]: + """ + 使用RANSAC拟合3D直线 + + Args: + points: 点云数组 (N, 3) + + Returns: + LineModel或None(拟合失败) + """ + if len(points) < 2: + return None + + best_inliers = None + best_point = None + best_direction = None + best_inlier_count = 0 + + for _ in range(self.max_iterations): + # 随机选择2个点 + sample_indices = np.random.choice(len(points), 2, replace=False) + p1, p2 = points[sample_indices] + + # 计算直线方向 + direction = p2 - p1 + if np.linalg.norm(direction) < 1e-6: + continue + direction = direction / np.linalg.norm(direction) + + # 计算所有点到直线的距离 + vectors = points - p1 + projections = np.dot(vectors, direction)[:, np.newaxis] * direction + distances = np.linalg.norm(vectors - projections, axis=1) + + # 找到内点 + inliers = np.where(distances < self.threshold)[0] + + if len(inliers) > best_inlier_count: + best_inlier_count = len(inliers) + best_inliers = inliers + best_point = p1 + best_direction = direction + + if best_inliers is None or len(best_inliers) < 2: + return None + + # 使用所有内点重新拟合直线(最小二乘) + inlier_points = points[best_inliers] + final_point, final_direction = self._fit_line_least_squares(inlier_points) + + if final_direction is None: + return None + + # 重新计算内点 + vectors = points - final_point + projections = np.dot(vectors, final_direction)[:, np.newaxis] * final_direction + distances = np.linalg.norm(vectors - projections, axis=1) + final_inliers = np.where(distances < self.threshold)[0] + + inlier_ratio = len(final_inliers) / len(points) + + # 检查内点比例 + if inlier_ratio < self.min_inlier_ratio: + return None + + return LineModel( + point=final_point, + direction=final_direction, + inliers=final_inliers, + inlier_ratio=inlier_ratio + ) + + def _fit_line_least_squares(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """ + 使用最小二乘法拟合直线 + + Returns: + (point, direction): 直线上的点和方向向量 + """ + if len(points) < 2: + return None, None + + # 计算质心 + centroid = np.mean(points, axis=0) + + # 中心化点云 + centered = points - centroid + + # PCA:找主方向 + try: + # 协方差矩阵 + cov = np.dot(centered.T, centered) / len(points) + # 特征值分解 + eigenvalues, eigenvectors = np.linalg.eig(cov) + # 最大特征值对应的特征向量就是直线方向 + max_idx = np.argmax(eigenvalues) + direction = eigenvectors[:, max_idx].real + direction = direction / np.linalg.norm(direction) + + return centroid, direction + except np.linalg.LinAlgError: + return None, None diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurement_logger.py b/alg/train_measurement_system_v3.2_0629/src/measurement_logger.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurement_logger.py rename to alg/train_measurement_system_v3.2_0629/src/measurement_logger.py diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/__init__.py b/alg/train_measurement_system_v3.2_0629/src/measurements/__init__.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/__init__.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/__init__.py diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/base.py b/alg/train_measurement_system_v3.2_0629/src/measurements/base.py similarity index 87% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/base.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/base.py index 0db578d..cafba29 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/base.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/base.py @@ -18,7 +18,7 @@ class MeasurementResult: class BaseMeasurement(ABC): """测量任务基类""" - def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): + def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None): """ 初始化测量任务 @@ -26,11 +26,13 @@ class BaseMeasurement(ABC): name: 任务名称 point_cloud: 完整点云数组 (N, 3) config: 任务配置字典 + spatial_index: 空间索引对象(可选) """ self.name = name self.point_cloud = point_cloud self.config = config self.task_type = config.get('type', 'unknown') + self.spatial_index = spatial_index # 保存空间索引 @abstractmethod def execute(self) -> MeasurementResult: diff --git a/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_line.py b/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_line.py new file mode 100644 index 0000000..32fb191 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_line.py @@ -0,0 +1,280 @@ +"""线到线距离测量""" +import numpy as np +import time +from typing import Dict, Any +import sys +from pathlib import Path + +# 添加父目录到路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from measurements.base import BaseMeasurement, MeasurementResult +from edge_line_roi_extractor import EdgeLineROIExtractor +from line_fitter import LineFitter +from visualizer import Visualizer + + +class LineToLineDistance(BaseMeasurement): + """测量两条边缘线之间的距离""" + + def _log(self, msg: str): + """同时输出到控制台和日志文件""" + print(msg) + if hasattr(self, 'detail_logger') and self.detail_logger: + self.detail_logger.log(msg, also_print=False) + + def execute(self) -> MeasurementResult: + """执行线到线距离测量""" + try: + timing_breakdown = {} + + # 获取logger + logger = getattr(self, 'detail_logger', None) + + # 获取参数 + params = self.config.get('params', {}) + source_roi = self.config.get('source_roi', {}) + target_roi = self.config.get('target_roi', {}) + + # 1. 提取源边缘线ROI(两步法) + msg = f"\n 步骤1: 提取源边缘线ROI" + if logger: logger.log(msg) + print(msg) + start_time = time.time() + + # 获取线的端点(优先使用line_start/line_end,否则从bbox推导) + if 'line_start' in source_roi and 'line_end' in source_roi: + source_p1 = np.array(source_roi.get('line_start')) + source_p2 = np.array(source_roi.get('line_end')) + else: + # 从bbox推导线的端点 + source_bbox_min = np.array(source_roi.get('bbox_min')) + source_bbox_max = np.array(source_roi.get('bbox_max')) + source_p1 = source_bbox_min + source_p2 = source_bbox_max + + msg = f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]" + if logger: logger.log(msg) + print(msg) + msg = f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]" + if logger: logger.log(msg) + print(msg) + + source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi( + self.point_cloud, + source_p1, + source_p2, + self.spatial_index, + distance_threshold=params.get('roi_distance_threshold', 2.0), + bbox_margin=5.0 + ) + timing_breakdown['提取源线ROI'] = time.time() - start_time + + self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]") + self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点") + self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点") + self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒") + + if len(source_points) == 0: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message="源边缘线ROI区域内没有点" + ) + + # 2. 提取目标边缘线ROI(两步法) + self._log(f"\n 步骤2: 提取目标边缘线ROI") + start_time = time.time() + + # 获取线的端点 + if 'line_start' in target_roi and 'line_end' in target_roi: + target_p1 = np.array(target_roi.get('line_start')) + target_p2 = np.array(target_roi.get('line_end')) + else: + target_bbox_min = np.array(target_roi.get('bbox_min')) + target_bbox_max = np.array(target_roi.get('bbox_max')) + target_p1 = target_bbox_min + target_p2 = target_bbox_max + + self._log(f" 线起点: [{target_p1[0]:.2f}, {target_p1[1]:.2f}, {target_p1[2]:.2f}]") + self._log(f" 线终点: [{target_p2[0]:.2f}, {target_p2[1]:.2f}, {target_p2[2]:.2f}]") + + target_points, target_indices, target_stats = EdgeLineROIExtractor.extract_two_step_roi( + self.point_cloud, + target_p1, + target_p2, + self.spatial_index, + distance_threshold=params.get('roi_distance_threshold', 2.0), + bbox_margin=5.0 + ) + timing_breakdown['提取目标线ROI'] = time.time() - start_time + + self._log(f" 粗ROI bbox: [{target_stats['bbox_min'][0]:.2f}, {target_stats['bbox_min'][1]:.2f}, {target_stats['bbox_min'][2]:.2f}] -> [{target_stats['bbox_max'][0]:.2f}, {target_stats['bbox_max'][1]:.2f}, {target_stats['bbox_max'][2]:.2f}]") + self._log(f" 粗筛选: {target_stats['coarse_count']:,} 点") + self._log(f" 精筛选(距离<{target_stats['distance_threshold']}mm): {target_stats['fine_count']:,} 点") + self._log(f" 耗时: {timing_breakdown['提取目标线ROI']:.2f}秒") + + if len(target_points) == 0: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message="目标边缘线ROI区域内没有点" + ) + + # 3. 拟合源边缘线(用全部精筛选点) + self._log(f"\n 步骤3: 拟合源边缘线") + start_time = time.time() + self._log(f" 输入点数: {len(source_points)} 个点") + source_fitter = LineFitter( + threshold=params.get('ransac_threshold', 2.0), + max_iterations=params.get('ransac_max_iterations', 1000), + min_inlier_ratio=params.get('min_inlier_ratio', 0.6) + ) + source_line = source_fitter.fit(source_points) + timing_breakdown['源线RANSAC拟合'] = time.time() - start_time + + if source_line is None: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)" + ) + + self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%") + self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)") + self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]") + self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]") + self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒") + + # 4. 拟合目标边缘线(用全部精筛选点) + self._log(f"\n 步骤4: 拟合目标边缘线") + start_time = time.time() + self._log(f" 输入点数: {len(target_points)} 个点") + target_fitter = LineFitter( + threshold=params.get('ransac_threshold', 2.0), + max_iterations=params.get('ransac_max_iterations', 1000), + min_inlier_ratio=params.get('min_inlier_ratio', 0.6) + ) + target_line = target_fitter.fit(target_points) + timing_breakdown['目标线RANSAC拟合'] = time.time() - start_time + + if target_line is None: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"目标边缘线拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)" + ) + + self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%") + self._log(f" 拟合结果: 内点{len(target_line.inliers)}/{len(target_points)} ({target_line.inlier_ratio*100:.1f}%)") + self._log(f" 线上一点: [{target_line.point[0]:.4f}, {target_line.point[1]:.4f}, {target_line.point[2]:.4f}]") + self._log(f" 线方向: [{target_line.direction[0]:.4f}, {target_line.direction[1]:.4f}, {target_line.direction[2]:.4f}]") + self._log(f" 耗时: {timing_breakdown['目标线RANSAC拟合']:.2f}秒") + + # 5. 从源线采样点(从内点中采样) + self._log(f"\n 步骤5: 从源线采样点") + start_time = time.time() + n_samples = params.get('sample_points', 100) + source_inlier_points = source_points[source_line.inliers] + sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points) + timing_breakdown['采样点选择'] = time.time() - start_time + + self._log(f" 采样数量: {len(sampled_points)} 个点") + self._log(f" 耗时: {timing_breakdown['采样点选择']:.4f}秒") + + # 6. 计算采样点到目标线的距离 + self._log(f"\n 步骤6: 计算距离") + start_time = time.time() + distances = target_line.distance_to_points(sampled_points) + timing_breakdown['距离计算'] = time.time() - start_time + + self._log(f" 计算 {len(sampled_points)} 个采样点到目标线的距离") + self._log(f" 耗时: {timing_breakdown['距离计算']:.4f}秒") + + # 7. 统计结果 + self._log(f"\n 步骤7: 统计结果") + mean_distance = np.mean(distances) + std_distance = np.std(distances) + min_distance = np.min(distances) + max_distance = np.max(distances) + + self._log(f" 平均距离: {mean_distance:.4f} mm") + self._log(f" 标准差: {std_distance:.4f} mm") + self._log(f" 最小距离: {min_distance:.4f} mm") + self._log(f" 最大距离: {max_distance:.4f} mm") + + # 8. 合格性判断 + self._log(f"\n 步骤8: 合格性判断") + theoretical_dimension = self.config.get('theoretical_dimension') + tolerance = self.config.get('tolerance', {}) + tolerance_upper = tolerance.get('upper', 0) + tolerance_lower = tolerance.get('lower', 0) + + is_qualified = None + deviation = None + if theoretical_dimension is not None: + deviation = mean_distance - theoretical_dimension + is_qualified = (deviation >= -0.11) and (deviation <= 0.1) + + self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm") + self._log(f" 实测平均: {mean_distance:.4f} mm") + self._log(f" 偏差: {deviation:+.4f} mm") + self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm") + status = "✅ 合格" if is_qualified else "❌ 不合格" + self._log(f" 判定结果: {status}") + + # 9. 生成可视化(如果配置了输出目录) + start_time = time.time() + visualization_dir = self.config.get('visualization_dir') + if visualization_dir: + vis_dir = Path(visualization_dir) + vis_dir.mkdir(parents=True, exist_ok=True) + + source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply" + target_ply_path = vis_dir / f"{self.name}_target_line_roi.ply" + Visualizer.save_roi_pointcloud(source_points, str(source_ply_path)) + Visualizer.save_roi_pointcloud(target_points, str(target_ply_path)) + timing_breakdown['可视化保存'] = time.time() - start_time + + result_data = { + 'mean_distance': float(mean_distance), + 'std_distance': float(std_distance), + 'min_distance': float(min_distance), + 'max_distance': float(max_distance), + 'sample_count': len(sampled_points), + 'source_roi_points': len(source_points), + 'target_roi_points': len(target_points), + 'source_inlier_ratio': float(source_line.inlier_ratio), + 'target_inlier_ratio': float(target_line.inlier_ratio), + 'source_line_direction': source_line.direction.tolist(), + 'target_line_direction': target_line.direction.tolist(), + 'timing_breakdown': timing_breakdown + } + + # 添加合格性判断结果 + if theoretical_dimension is not None: + result_data['theoretical_dimension'] = float(theoretical_dimension) + result_data['deviation'] = float(deviation) + result_data['is_qualified'] = is_qualified + result_data['tolerance_upper'] = float(tolerance_upper) + result_data['tolerance_lower'] = float(tolerance_lower) + + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=True, + data=result_data + ) + + except Exception as e: + import traceback + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}" + ) diff --git a/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_plane.py b/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_plane.py new file mode 100644 index 0000000..948e0ff --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/line_to_plane.py @@ -0,0 +1,252 @@ +"""线到面距离测量""" +import numpy as np +import time +from typing import Dict, Any +import sys +from pathlib import Path + +# 添加父目录到路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from measurements.base import BaseMeasurement, MeasurementResult +from edge_line_roi_extractor import EdgeLineROIExtractor +from line_fitter import LineFitter +from roi_extractor import ROIExtractor +from plane_fitter import PlaneFitter +from visualizer import Visualizer + + +class LineToPlaneDistance(BaseMeasurement): + """测量边缘线到平面的距离""" + + def _log(self, msg: str): + """同时输出到控制台和日志文件""" + print(msg) + if hasattr(self, 'detail_logger') and self.detail_logger: + self.detail_logger.log(msg, also_print=False) + + def execute(self) -> MeasurementResult: + """执行线到面距离测量""" + try: + timing_breakdown = {} + + # 获取参数 + params = self.config.get('params', {}) + source_roi = self.config.get('source_roi', {}) + target_roi = self.config.get('target_roi', {}) + + # 1. 提取源边缘线ROI(两步法) + self._log(f"\n 步骤1: 提取源边缘线ROI") + start_time = time.time() + + # 获取线的端点(优先使用line_start/line_end,否则从bbox推导) + if 'line_start' in source_roi and 'line_end' in source_roi: + source_p1 = np.array(source_roi.get('line_start')) + source_p2 = np.array(source_roi.get('line_end')) + else: + source_bbox_min = np.array(source_roi.get('bbox_min')) + source_bbox_max = np.array(source_roi.get('bbox_max')) + source_p1 = source_bbox_min + source_p2 = source_bbox_max + + self._log(f" 线起点: [{source_p1[0]:.2f}, {source_p1[1]:.2f}, {source_p1[2]:.2f}]") + self._log(f" 线终点: [{source_p2[0]:.2f}, {source_p2[1]:.2f}, {source_p2[2]:.2f}]") + + source_points, source_indices, source_stats = EdgeLineROIExtractor.extract_two_step_roi( + self.point_cloud, + source_p1, + source_p2, + self.spatial_index, + distance_threshold=params.get('roi_distance_threshold', 2.0), + bbox_margin=5.0 + ) + timing_breakdown['提取源线ROI'] = time.time() - start_time + + self._log(f" 粗ROI bbox: [{source_stats['bbox_min'][0]:.2f}, {source_stats['bbox_min'][1]:.2f}, {source_stats['bbox_min'][2]:.2f}] -> [{source_stats['bbox_max'][0]:.2f}, {source_stats['bbox_max'][1]:.2f}, {source_stats['bbox_max'][2]:.2f}]") + self._log(f" 粗筛选: {source_stats['coarse_count']:,} 点") + self._log(f" 精筛选(距离<{source_stats['distance_threshold']}mm): {source_stats['fine_count']:,} 点") + self._log(f" 耗时: {timing_breakdown['提取源线ROI']:.2f}秒") + + if len(source_points) == 0: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message="源边缘线ROI区域内没有点" + ) + + # 2. 提取目标平面ROI(用bbox) + self._log(f"\n 步骤2: 提取目标平面ROI") + start_time = time.time() + target_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, + target_roi.get('bbox_min'), + target_roi.get('bbox_max'), + self.spatial_index + ) + timing_breakdown['提取目标平面ROI'] = time.time() - start_time + + self._log(f" bbox: {target_roi.get('bbox_min')} -> {target_roi.get('bbox_max')}") + self._log(f" 提取点数: {len(target_points):,} 点") + self._log(f" 耗时: {timing_breakdown['提取目标平面ROI']:.2f}秒") + + if len(target_points) == 0: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message="目标平面ROI区域内没有点" + ) + + # 3. 拟合源边缘线(用全部精筛选点) + self._log(f"\n 步骤3: 拟合源边缘线") + start_time = time.time() + self._log(f" 输入点数: {len(source_points)} 个点") + source_fitter = LineFitter( + threshold=params.get('ransac_threshold', 2.0), + max_iterations=params.get('ransac_max_iterations', 1000), + min_inlier_ratio=params.get('min_inlier_ratio', 0.6) + ) + source_line = source_fitter.fit(source_points) + timing_breakdown['源线RANSAC拟合'] = time.time() - start_time + + if source_line is None: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"源边缘线拟合失败(点数: {len(source_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)" + ) + + self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%") + self._log(f" 拟合结果: 内点{len(source_line.inliers)}/{len(source_points)} ({source_line.inlier_ratio*100:.1f}%)") + self._log(f" 线上一点: [{source_line.point[0]:.4f}, {source_line.point[1]:.4f}, {source_line.point[2]:.4f}]") + self._log(f" 线方向: [{source_line.direction[0]:.4f}, {source_line.direction[1]:.4f}, {source_line.direction[2]:.4f}]") + self._log(f" 耗时: {timing_breakdown['源线RANSAC拟合']:.2f}秒") + + # 4. 拟合目标平面(降采样优化) + self._log(f"\n 步骤4: 拟合目标平面") + start_time = time.time() + self._log(f" 输入点数: {len(target_points)} 个点") + + max_points_for_ransac = 100000 + target_points_for_fit = target_points + if len(target_points) > max_points_for_ransac: + sample_indices = np.random.choice(len(target_points), max_points_for_ransac, replace=False) + target_points_for_fit = target_points[sample_indices] + self._log(f" 降采样优化: {len(target_points):,} -> {len(target_points_for_fit):,} 个点") + + target_fitter = PlaneFitter( + threshold=params.get('ransac_threshold', 2.0), + max_iterations=params.get('ransac_max_iterations', 1000), + min_inlier_ratio=params.get('min_inlier_ratio', 0.6) + ) + target_plane = target_fitter.fit(target_points_for_fit) + timing_breakdown['目标平面RANSAC拟合'] = time.time() - start_time + + if target_plane is None: + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"目标平面拟合失败(点数: {len(target_points)},要求内点比例>={params.get('min_inlier_ratio', 0.6)*100:.0f}%)" + ) + + self._log(f" RANSAC参数: 阈值={params.get('ransac_threshold', 2.0)}mm, 迭代={params.get('ransac_max_iterations', 1000)}次, 最小内点比例={params.get('min_inlier_ratio', 0.6)*100:.0f}%") + self._log(f" 拟合结果: 内点{len(target_plane.inliers)}/{len(target_points_for_fit)} ({target_plane.inlier_ratio*100:.1f}%)") + self._log(f" 平面上一点: [{target_plane.point[0]:.4f}, {target_plane.point[1]:.4f}, {target_plane.point[2]:.4f}]") + self._log(f" 平面法向量: [{target_plane.normal[0]:.4f}, {target_plane.normal[1]:.4f}, {target_plane.normal[2]:.4f}]") + self._log(f" 平面方程: {target_plane.normal[0]:.6f}*x + {target_plane.normal[1]:.6f}*y + {target_plane.normal[2]:.6f}*z + {target_plane.d:.6f} = 0") + self._log(f" 耗时: {timing_breakdown['目标平面RANSAC拟合']:.2f}秒") + + # 5. 从源线采样点(从内点中采样) + start_time = time.time() + n_samples = params.get('sample_points', 100) + source_inlier_points = source_points[source_line.inliers] + sampled_points = source_line.sample_points_on_line(n_samples, source_inlier_points) + timing_breakdown['采样点选择'] = time.time() - start_time + + self._log(f" 采样{len(sampled_points)}个点") + + # 6. 计算采样点到目标平面的距离 + start_time = time.time() + distances = target_plane.distance_to_points(sampled_points) + timing_breakdown['距离计算'] = time.time() - start_time + + # 7. 统计结果(使用绝对值) + mean_distance = np.mean(np.abs(distances)) + std_distance = np.std(distances) + min_distance = np.min(np.abs(distances)) + max_distance = np.max(np.abs(distances)) + + # 8. 合格性判断 + theoretical_dimension = self.config.get('theoretical_dimension') + tolerance = self.config.get('tolerance', {}) + tolerance_upper = tolerance.get('upper', 0) + tolerance_lower = tolerance.get('lower', 0) + + is_qualified = None + deviation = None + if theoretical_dimension is not None: + deviation = mean_distance - theoretical_dimension + is_qualified = (deviation >= -0.11) and (deviation <= 0.1) + + self._log(f" 平均距离: {mean_distance:.4f} mm") + if theoretical_dimension is not None: + self._log(f" 理论尺寸: {theoretical_dimension:.4f} mm") + self._log(f" 偏差: {deviation:+.4f} mm") + self._log(f" 公差范围: [{tolerance_lower:+.1f}, {tolerance_upper:+.1f}] mm") + status = "✅ 合格" if is_qualified else "❌ 不合格" + self._log(f" 判定: {status}") + + # 9. 生成可视化(如果配置了输出目录) + start_time = time.time() + visualization_dir = self.config.get('visualization_dir') + if visualization_dir: + vis_dir = Path(visualization_dir) + vis_dir.mkdir(parents=True, exist_ok=True) + + source_ply_path = vis_dir / f"{self.name}_source_line_roi.ply" + target_ply_path = vis_dir / f"{self.name}_target_plane_roi.ply" + Visualizer.save_roi_pointcloud(source_points, str(source_ply_path)) + Visualizer.save_roi_pointcloud(target_points, str(target_ply_path)) + timing_breakdown['可视化保存'] = time.time() - start_time + + result_data = { + 'mean_distance': float(mean_distance), + 'std_distance': float(std_distance), + 'min_distance': float(min_distance), + 'max_distance': float(max_distance), + 'sample_count': len(sampled_points), + 'source_roi_points': len(source_points), + 'target_roi_points': len(target_points), + 'source_inlier_ratio': float(source_line.inlier_ratio), + 'target_inlier_ratio': float(target_plane.inlier_ratio), + 'source_line_direction': source_line.direction.tolist(), + 'target_plane_normal': target_plane.normal.tolist(), + 'timing_breakdown': timing_breakdown + } + + # 添加合格性判断结果 + if theoretical_dimension is not None: + result_data['theoretical_dimension'] = float(theoretical_dimension) + result_data['deviation'] = float(deviation) + result_data['is_qualified'] = is_qualified + result_data['tolerance_upper'] = float(tolerance_upper) + result_data['tolerance_lower'] = float(tolerance_lower) + + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=True, + data=result_data + ) + + except Exception as e: + import traceback + return MeasurementResult( + task_name=self.name, + task_type=self.task_type, + success=False, + error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}" + ) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_distance.py b/alg/train_measurement_system_v3.2_0629/src/measurements/plane_distance.py similarity index 97% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_distance.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/plane_distance.py index 3e26c16..760ad78 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_distance.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/plane_distance.py @@ -25,11 +25,7 @@ class PlaneToPlaneDistance(BaseMeasurement): # 1. 提取源平面ROI start_time = time.time() source_roi = self.config.get('source_roi', {}) - source_points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - source_roi.get('bbox_min'), - source_roi.get('bbox_max') - ) + source_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, source_roi.get('bbox_min'), source_roi.get('bbox_max'), self.spatial_index) timing_breakdown['提取源ROI'] = time.time() - start_time # 记录ROI提取信息到详细日志 @@ -52,11 +48,7 @@ class PlaneToPlaneDistance(BaseMeasurement): # 2. 提取目标平面ROI start_time = time.time() target_roi = self.config.get('target_roi', {}) - target_points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - target_roi.get('bbox_min'), - target_roi.get('bbox_max') - ) + target_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, target_roi.get('bbox_min'), target_roi.get('bbox_max'), self.spatial_index) timing_breakdown['提取目标ROI'] = time.time() - start_time # 记录ROI提取信息到详细日志 @@ -153,7 +145,8 @@ class PlaneToPlaneDistance(BaseMeasurement): # 5. 从源平面采样点 start_time = time.time() n_samples = params.get('sample_points', 100) - source_inlier_points = source_points[source_plane.inliers] + # 修复:inliers索引是相对于source_points_for_fit的,不是source_points + source_inlier_points = source_points_for_fit[source_plane.inliers] sampled_points = ROIExtractor.sample_points(source_inlier_points, n_samples) timing_breakdown['采样点选择'] = time.time() - start_time diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_with_holes.py b/alg/train_measurement_system_v3.2_0629/src/measurements/plane_with_holes.py similarity index 96% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_with_holes.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/plane_with_holes.py index 8b86f63..1758a05 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/plane_with_holes.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/plane_with_holes.py @@ -46,11 +46,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement): hole_results = [] for i in range(4): box = boxes[i] - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - box['min'], - box['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index) if len(points) == 0: return MeasurementResult( task_name=self.name, @@ -96,11 +92,7 @@ class PlaneToPlaneWithHoles(BaseMeasurement): print(f" 提取并拟合源平面...") for i in range(4, len(boxes)): box = boxes[i] - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - box['min'], - box['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index) if len(points) == 0: return MeasurementResult( diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane.py b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane.py similarity index 79% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane.py index 29bc7b0..56c51e6 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane.py @@ -24,8 +24,8 @@ from visualizer import Visualizer class PointToPlaneDistance(BaseMeasurement): """点到平面距离测量""" - def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): - super().__init__(name, point_cloud, config) + def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None): + super().__init__(name, point_cloud, config, spatial_index) self.mode = config.get('mode', 'exact_points') if self.mode == 'exact_points': @@ -68,42 +68,50 @@ class PointToPlaneDistance(BaseMeasurement): ) def _measure_exact_points(self) -> MeasurementResult: - """精确点模式:查找最近点并计算距离""" + """精确点模式:查找最近点并计算距离(优化版:使用Grid索引)""" + from roi_extractor import ROIExtractor + timing_breakdown = {} - # 优化:先粗筛选点云,只在目标点附近构建KDTree + # 优化:对每个目标点单独用Grid索引提取小区域 start_time = time.time() - # 计算所有目标点的包围盒,扩大搜索半径的倍数作为缓冲 - search_margin = self.search_radius * 20 # 给足够的余量 - bbox_min = self.target_points.min(axis=0) - search_margin - bbox_max = self.target_points.max(axis=0) + search_margin - - # 粗筛选:只保留包围盒内的点 - mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1) - local_points = self.point_cloud[mask] - local_indices = np.where(mask)[0] - - print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 " - f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)") - - # 在筛选后的点云上构建KDTree(大幅加速) - kdtree = KDTree(local_points) - timing_breakdown['构建KDTree'] = time.time() - start_time - found_points = [] distances_to_plane = [] - start_time = time.time() for target_point in self.target_points: - # 查找最近点 - dist, local_idx = kdtree.query(target_point, k=1) + # 定义搜索区域(目标点周围的小包围盒) + search_margin = self.search_radius * 2 + bbox_min = target_point - search_margin + bbox_max = target_point + search_margin - if dist > self.search_radius: - print(f"警告: 目标点 {target_point} 的最近点距离 {dist:.2f}mm 超过搜索半径 {self.search_radius}mm") + # 用Grid索引快速提取局部点云 + local_points, local_indices = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) == 0: + # 如果没找到点,扩大搜索范围 + search_margin = self.search_radius * 10 + bbox_min = target_point - search_margin + bbox_max = target_point + search_margin + local_points, local_indices = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) == 0: + print(f"警告: 目标点 {target_point} 周围 {search_margin}mm 内没有点") + continue + + # 在局部点云中暴力搜索最近点(点数少,很快) + distances = np.linalg.norm(local_points - target_point, axis=1) + min_idx = np.argmin(distances) + min_distance = distances[min_idx] + nearest_point = local_points[min_idx] + + if min_distance > self.search_radius: + print(f"警告: 目标点 {target_point} 的最近点距离 {min_distance:.2f}mm 超过搜索半径 {self.search_radius}mm") - # 映射回原始点云的点 - nearest_point = local_points[local_idx] found_points.append(nearest_point) # 计算点到平面的距离 @@ -146,6 +154,9 @@ class PointToPlaneDistance(BaseMeasurement): def _measure_bbox_filtered(self) -> MeasurementResult: """包围盒过滤模式:提取点云,筛选特定点,计算距离""" + # 导入ROIExtractor用于Grid索引加速 + from roi_extractor import ROIExtractor + timing_breakdown = {} all_filtered_points = [] all_distances = [] @@ -155,9 +166,10 @@ class PointToPlaneDistance(BaseMeasurement): bbox_min = np.array(bbox_config['min']) bbox_max = np.array(bbox_config['max']) - # 提取包围盒内的点 - mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1) - bbox_points = self.point_cloud[mask] + # 使用Grid索引快速提取包围盒内的点(优化后) + bbox_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) if len(bbox_points) == 0: print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 内没有点") diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane_with_holes.py b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane_with_holes.py similarity index 94% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane_with_holes.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane_with_holes.py index 8253a75..8676d80 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_plane_with_holes.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_plane_with_holes.py @@ -40,11 +40,7 @@ class PointToPlaneWithHoles(BaseMeasurement): hole_points_list = [] for i in range(4): box = boxes[i] - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - box['min'], - box['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index) if len(points) == 0: return MeasurementResult( task_name=self.name, @@ -64,11 +60,7 @@ class PointToPlaneWithHoles(BaseMeasurement): start_time = time.time() auxiliary_box = boxes[4] print(f" 提取辅助孔 '{auxiliary_box['name']}' ROI区域...") - auxiliary_points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - auxiliary_box['min'], - auxiliary_box['max'] - ) + auxiliary_points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, auxiliary_box['min'], auxiliary_box['max'], self.spatial_index) if len(auxiliary_points) == 0: return MeasurementResult( diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point.py b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point.py similarity index 80% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point.py index 5994cd3..e4fc91d 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point.py @@ -21,8 +21,8 @@ from visualizer import Visualizer class PointToPointDistance(BaseMeasurement): """点到点距离测量""" - def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any]): - super().__init__(name, point_cloud, config) + def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None): + super().__init__(name, point_cloud, config, spatial_index) # 检查测量模式 self.mode = config.get('mode', 'exact_points') @@ -55,11 +55,7 @@ class PointToPointDistance(BaseMeasurement): bbox_points_list = [] for bbox in self.bboxes: - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - bbox['min'], - bbox['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, bbox['min'], bbox['max'], self.spatial_index) if len(points) == 0: return MeasurementResult( task_name=self.name, @@ -170,59 +166,80 @@ class PointToPointDistance(BaseMeasurement): ) def _execute_exact_points(self) -> MeasurementResult: - """执行精确点模式的测量""" + """执行精确点模式的测量(优化版:使用Grid索引)""" + from roi_extractor import ROIExtractor + try: timing_breakdown = {} - # 优化:先粗筛选点云,只在目标点附近构建KDTree + # 优化:对每个目标点用Grid索引提取小区域 start_time = time.time() - # 收集所有目标点 - all_target_points = [] - for pair in self.point_pairs: - all_target_points.append(pair['point1']) - all_target_points.append(pair['point2']) - all_target_points = np.array(all_target_points) - - # 计算包围盒,扩大搜索半径的倍数作为缓冲 - search_margin = self.search_radius * 20 - bbox_min = all_target_points.min(axis=0) - search_margin - bbox_max = all_target_points.max(axis=0) + search_margin - - # 粗筛选:只保留包围盒内的点 - mask = np.all((self.point_cloud >= bbox_min) & (self.point_cloud <= bbox_max), axis=1) - local_points = self.point_cloud[mask] - - print(f" KDTree优化: 从 {len(self.point_cloud):,} 个点筛选到 {len(local_points):,} 个点 " - f"({100.0 * len(local_points) / len(self.point_cloud):.2f}%)") - - # 在筛选后的点云上构建KDTree(大幅加速) - kdtree = KDTree(local_points) - timing_breakdown['构建KDTree'] = time.time() - start_time - pair_results = [] all_found_points = [] - start_time = time.time() for i, pair in enumerate(self.point_pairs): point1_target = np.array(pair['point1']) point2_target = np.array(pair['point2']) pair_name = pair.get('name', f'点对{i+1}') - # 查找point1的最近点 - dist1, local_idx1 = kdtree.query(point1_target, k=1) + # 为point1找最近点 + search_margin = self.search_radius * 2 + bbox_min = point1_target - search_margin + bbox_max = point1_target + search_margin + + local_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) == 0: + search_margin = self.search_radius * 10 + bbox_min = point1_target - search_margin + bbox_max = point1_target + search_margin + local_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) > 0: + distances_to_p1 = np.linalg.norm(local_points - point1_target, axis=1) + local_idx1 = np.argmin(distances_to_p1) + dist1 = distances_to_p1[local_idx1] + point1_found = local_points[local_idx1] + else: + print(f"警告: {pair_name} - 点1 {point1_target} 附近没有点") + continue + + # 为point2找最近点 + search_margin = self.search_radius * 2 + bbox_min = point2_target - search_margin + bbox_max = point2_target + search_margin + + local_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) == 0: + search_margin = self.search_radius * 10 + bbox_min = point2_target - search_margin + bbox_max = point2_target + search_margin + local_points, _ = ROIExtractor.extract_bbox_points( + self.point_cloud, bbox_min, bbox_max, self.spatial_index + ) + + if len(local_points) > 0: + distances_to_p2 = np.linalg.norm(local_points - point2_target, axis=1) + local_idx2 = np.argmin(distances_to_p2) + dist2 = distances_to_p2[local_idx2] + point2_found = local_points[local_idx2] + else: + print(f"警告: {pair_name} - 点2 {point2_target} 附近没有点") + continue + if dist1 > self.search_radius: print(f"警告: {pair_name} - 点1 {point1_target} 的最近点距离 {dist1:.2f}mm 超过搜索半径 {self.search_radius}mm") - - point1_found = local_points[local_idx1] - - # 查找point2的最近点 - dist2, local_idx2 = kdtree.query(point2_target, k=1) if dist2 > self.search_radius: print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm") - point2_found = local_points[local_idx2] - # 计算两点之间的欧氏距离 distance = np.linalg.norm(point2_found - point1_found) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point_with_holes.py b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point_with_holes.py similarity index 95% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point_with_holes.py rename to alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point_with_holes.py index eb7db94..a3592a2 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/measurements/point_to_point_with_holes.py +++ b/alg/train_measurement_system_v3.2_0629/src/measurements/point_to_point_with_holes.py @@ -47,11 +47,7 @@ class PointToPointWithHoles(BaseMeasurement): hole_results = [] # 保存完整的拟合结果 for i, box in enumerate(boxes): - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - box['min'], - box['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index) if len(points) == 0: return MeasurementResult( task_name=self.name, @@ -131,11 +127,7 @@ class PointToPointWithHoles(BaseMeasurement): # 保存每个孔的点云 for i, box in enumerate(boxes): - points, _ = ROIExtractor.extract_bbox_points( - self.point_cloud, - box['min'], - box['max'] - ) + points, _ = ROIExtractor.extract_bbox_points(self.point_cloud, box['min'], box['max'], self.spatial_index) ply_path = vis_dir / f"{self.name}_{box['name']}.ply" Visualizer.save_roi_pointcloud(points, str(ply_path)) diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/performance_logger.py b/alg/train_measurement_system_v3.2_0629/src/performance_logger.py similarity index 53% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/performance_logger.py rename to alg/train_measurement_system_v3.2_0629/src/performance_logger.py index ca45661..bc1ea2e 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/performance_logger.py +++ b/alg/train_measurement_system_v3.2_0629/src/performance_logger.py @@ -25,6 +25,12 @@ class PerformanceLogger: # 任务性能记录 self.task_records = [] + # 初始化阶段性能记录 + self.init_phases = { + 'pointcloud_loading': {'time': 0, 'points': 0, 'file': ''}, + 'spatial_index': {'time': 0, 'method': '', 'info': {}} + } + def log_task(self, task_name: str, total_time: float, timing_breakdown: Dict[str, float]): """ 记录单个任务的性能数据 @@ -41,6 +47,36 @@ class PerformanceLogger: } self.task_records.append(record) + def log_pointcloud_loading(self, time_seconds: float, point_count: int, filename: str): + """ + 记录点云加载性能 + + Args: + time_seconds: 加载耗时(秒) + point_count: 点云数量 + filename: 文件名 + """ + self.init_phases['pointcloud_loading'] = { + 'time': time_seconds, + 'points': point_count, + 'file': filename + } + + def log_spatial_index(self, time_seconds: float, method: str, info: Dict): + """ + 记录空间索引构建性能 + + Args: + time_seconds: 构建耗时(秒) + method: 索引方法名称 + info: 索引详细信息 + """ + self.init_phases['spatial_index'] = { + 'time': time_seconds, + 'method': method, + 'info': info + } + def save(self): """保存性能日志到文件""" with open(self.log_file, 'w', encoding='utf-8') as f: @@ -48,6 +84,51 @@ class PerformanceLogger: f.write("点云测量系统 - 性能分析报告\n") f.write("=" * 80 + "\n") f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("=" * 80 + "\n\n") + + # 输出初始化阶段性能 + f.write("=" * 80 + "\n") + f.write("初始化阶段性能\n") + f.write("=" * 80 + "\n\n") + + # 点云加载 + pc_loading = self.init_phases['pointcloud_loading'] + if pc_loading['time'] > 0: + f.write("阶段1: 点云加载\n") + f.write("-" * 80 + "\n") + f.write(f" 文件名: {pc_loading['file']}\n") + f.write(f" 点云数量: {pc_loading['points']:,} 个点\n") + f.write(f" 加载耗时: {pc_loading['time']:.2f} 秒\n") + if pc_loading['points'] > 0: + f.write(f" 加载速度: {pc_loading['points']/pc_loading['time']/1000000:.2f} M点/秒\n") + f.write("\n") + + # 空间索引构建 + spatial_idx = self.init_phases['spatial_index'] + if spatial_idx['time'] > 0: + f.write("阶段2: 空间索引构建\n") + f.write("-" * 80 + "\n") + f.write(f" 方法: {spatial_idx['method']}\n") + f.write(f" 构建耗时: {spatial_idx['time']:.2f} 秒\n") + info = spatial_idx['info'] + if 'grid_count' in info: + f.write(f" 网格数量: {info['grid_count']:,}\n") + f.write(f" 平均点/格: {info['avg_points_per_grid']:.1f}\n") + elif 'node_count' in info: + f.write(f" 节点数量: {info['node_count']:,}\n") + f.write("\n") + + # 初始化总耗时 + init_total = pc_loading['time'] + spatial_idx['time'] + if init_total > 0: + f.write(f"初始化阶段总耗时: {init_total:.2f} 秒\n\n") + + f.write("=" * 80 + "\n\n") + + # 任务执行性能 + f.write("=" * 80 + "\n") + f.write("任务执行性能\n") + f.write("=" * 80 + "\n") f.write(f"总任务数: {len(self.task_records)}\n") f.write("=" * 80 + "\n\n") @@ -106,6 +187,19 @@ class PerformanceLogger: count = step_counts[step_name] f.write(f"{step_name:<40} {avg_time:>10.2f} {count:>5}\n") + f.write("\n" + "=" * 80 + "\n") + + # 总体性能摘要 + grand_total = init_total + total_all_tasks + f.write("\n" + "=" * 80 + "\n") + f.write("总体性能摘要\n") + f.write("=" * 80 + "\n\n") + f.write(f" 初始化阶段: {init_total:.2f} 秒 ({init_total/grand_total*100:.1f}%)\n") + f.write(f" - 点云加载: {pc_loading['time']:.2f} 秒\n") + f.write(f" - 空间索引: {spatial_idx['time']:.2f} 秒\n") + f.write(f" 任务执行阶段: {total_all_tasks:.2f} 秒 ({total_all_tasks/grand_total*100:.1f}%)\n") + f.write(f" 总耗时: {grand_total:.2f} 秒\n") + f.write("\n" + "=" * 80 + "\n") f.write(f"日志已保存到: {self.log_file}\n") f.write("=" * 80 + "\n") diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/plane_fitter.py b/alg/train_measurement_system_v3.2_0629/src/plane_fitter.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/plane_fitter.py rename to alg/train_measurement_system_v3.2_0629/src/plane_fitter.py diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/pointcloud_loader.py b/alg/train_measurement_system_v3.2_0629/src/pointcloud_loader.py similarity index 59% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/pointcloud_loader.py rename to alg/train_measurement_system_v3.2_0629/src/pointcloud_loader.py index cb85bd6..ad85ab5 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/pointcloud_loader.py +++ b/alg/train_measurement_system_v3.2_0629/src/pointcloud_loader.py @@ -66,38 +66,46 @@ class PointCloudLoader: @staticmethod def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray: - """读取二进制PLY数据""" - # 构建struct格式字符串 + """读取二进制PLY数据(优化版:批量读取)""" + # 构建numpy dtype type_map = { - 'float': 'f', - 'double': 'd', - 'uchar': 'B', - 'uint': 'I', - 'int': 'i' + 'float': np.float32, + 'double': np.float64, + 'uchar': np.uint8, + 'uint': np.uint32, + 'int': np.int32 } - fmt = '<' # 小端序 + # 构建结构化数组的dtype + dtype_list = [] for prop in properties: - fmt += type_map.get(prop['type'], 'f') + dtype_list.append((prop['name'], type_map.get(prop['type'], np.float32))) - struct_size = struct.calcsize(fmt) - points = [] + dtype = np.dtype(dtype_list) - for _ in range(vertex_count): - data = struct.unpack(fmt, f.read(struct_size)) - # 只取前3个值作为xyz坐标 - points.append(data[:3]) + # 一次性读取所有数据 + raw_data = np.fromfile(f, dtype=dtype, count=vertex_count) - return np.array(points, dtype=np.float64) + # 提取xyz坐标(假设前3个属性是x, y, z) + # 如果属性名不是x,y,z,可以通过索引访问 + if 'x' in raw_data.dtype.names and 'y' in raw_data.dtype.names and 'z' in raw_data.dtype.names: + points = np.column_stack([raw_data['x'], raw_data['y'], raw_data['z']]) + else: + # 假设前3个字段是xyz + points = np.column_stack([raw_data[raw_data.dtype.names[0]], + raw_data[raw_data.dtype.names[1]], + raw_data[raw_data.dtype.names[2]]]) + + return points.astype(np.float64) @staticmethod def _read_ascii_ply(f, vertex_count: int) -> np.ndarray: - """读取ASCII PLY数据""" - points = [] - for _ in range(vertex_count): - line = f.readline().decode('ascii').strip() - values = line.split() - # 只取前3个值作为xyz坐标 - points.append([float(values[0]), float(values[1]), float(values[2])]) + """读取ASCII PLY数据(优化版:批量读取)""" + # 读取剩余所有行 + remaining = f.read().decode('ascii') - return np.array(points, dtype=np.float64) + # 使用numpy的快速文本解析 + from io import StringIO + points = np.loadtxt(StringIO(remaining), dtype=np.float64, max_rows=vertex_count, usecols=(0, 1, 2)) + + return points diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/roi_extractor.py b/alg/train_measurement_system_v3.2_0629/src/roi_extractor.py similarity index 78% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/roi_extractor.py rename to alg/train_measurement_system_v3.2_0629/src/roi_extractor.py index b4bd02b..75fa491 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/roi_extractor.py +++ b/alg/train_measurement_system_v3.2_0629/src/roi_extractor.py @@ -10,7 +10,8 @@ class ROIExtractor: def extract_bbox_points( points: np.ndarray, bbox_min: list, - bbox_max: list + bbox_max: list, + spatial_index=None ) -> Tuple[np.ndarray, np.ndarray]: """ 提取包围盒内的点 @@ -19,6 +20,7 @@ class ROIExtractor: points: 点云数组 (N, 3) bbox_min: 包围盒最小坐标 [x_min, y_min, z_min] bbox_max: 包围盒最大坐标 [x_max, y_max, z_max] + spatial_index: 空间索引对象(可选) Returns: roi_points: 提取的点 (M, 3) @@ -27,11 +29,15 @@ class ROIExtractor: bbox_min = np.array(bbox_min) bbox_max = np.array(bbox_max) - # 找到在包围盒内的点 - mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1) - indices = np.where(mask)[0] - roi_points = points[indices] + if spatial_index is not None: + # 使用空间索引快速查询 + indices = spatial_index.query_bbox(bbox_min, bbox_max) + else: + # 回退到线性扫描 + mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1) + indices = np.where(mask)[0] + roi_points = points[indices] return roi_points, indices @staticmethod diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/__init__.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/__init__.py new file mode 100644 index 0000000..5f277ae --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/__init__.py @@ -0,0 +1,17 @@ +"""空间索引模块 - 提供多种ROI提取方法""" + +from .base import SpatialIndex +from .linear_scan import LinearScanIndex +from .grid_index import GridIndex +from .grid_index_optimized import GridIndexOptimized +from .octree_index import OctreeIndex +from .kdtree_index import KDTreeIndex + +__all__ = [ + 'SpatialIndex', + 'LinearScanIndex', + 'GridIndex', + 'GridIndexOptimized', + 'OctreeIndex', + 'KDTreeIndex', +] diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/base.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/base.py new file mode 100644 index 0000000..afa92b0 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/base.py @@ -0,0 +1,39 @@ +"""空间索引基类""" +from abc import ABC, abstractmethod +import numpy as np + + +class SpatialIndex(ABC): + """空间索引抽象基类""" + + def __init__(self, points: np.ndarray): + """ + 初始化索引 + + Args: + points: 点云数组 (N, 3) + """ + self.points = points + self.build_time = 0.0 # 构建时间(秒) + + @abstractmethod + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + 查询包围盒内的点 + + Args: + bbox_min: [x_min, y_min, z_min] + bbox_max: [x_max, y_max, z_max] + + Returns: + 包围盒内的点的索引数组 + """ + pass + + def get_info(self) -> dict: + """返回索引信息(用于日志)""" + return { + 'type': self.__class__.__name__, + 'point_count': len(self.points), + 'build_time': self.build_time + } diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index.py new file mode 100644 index 0000000..cf55195 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index.py @@ -0,0 +1,92 @@ +"""Grid/Voxel 网格索引""" +import numpy as np +import time +from collections import defaultdict +from .base import SpatialIndex + + +class GridIndex(SpatialIndex): + """Grid/Voxel 空间索引 - O(N)构建, O(1)查询""" + + def __init__(self, points: np.ndarray, grid_size: float = 100.0): + """ + 构建网格索引 + + Args: + points: 点云数组 (N, 3) + grid_size: 网格大小(毫米) + """ + super().__init__(points) + self.grid_size = grid_size + + print(f" 构建Grid索引 (grid_size={grid_size}mm)...") + start = time.time() + + # 计算点云边界 + self.mins = points.min(axis=0) + self.maxs = points.max(axis=0) + + # 将每个点映射到网格坐标 + grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32) + + # 构建索引:grid_key → point_indices + self.grid = defaultdict(list) + for idx, (gx, gy, gz) in enumerate(grid_coords): + grid_key = (gx, gy, gz) + self.grid[grid_key].append(idx) + + # 转换为numpy数组以加速查询 + for key in self.grid: + self.grid[key] = np.array(self.grid[key], dtype=np.int32) + + self.build_time = time.time() - start + print(f" 网格数量: {len(self.grid):,}") + print(f" 平均点/格: {len(points) / len(self.grid):.1f}") + print(f" 构建耗时: {self.build_time:.2f} 秒") + + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + Grid索引查询 - O(1 + K)复杂度 + + 直接定位到相关网格,只检查这些网格中的点 + """ + # 将bbox转换为网格坐标范围 + grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32) + grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32) + + # 收集相关网格中的所有点索引 + candidate_indices = [] + for gx in range(grid_min[0], grid_max[0] + 1): + for gy in range(grid_min[1], grid_max[1] + 1): + for gz in range(grid_min[2], grid_max[2] + 1): + grid_key = (gx, gy, gz) + if grid_key in self.grid: + candidate_indices.append(self.grid[grid_key]) + + if len(candidate_indices) == 0: + return np.array([], dtype=np.int32) + + # 合并所有候选点 + candidate_indices = np.concatenate(candidate_indices) + candidate_points = self.points[candidate_indices] + + # 精确过滤(网格是粗略的,需要精确检查) + mask = ( + (candidate_points[:, 0] >= bbox_min[0]) & + (candidate_points[:, 0] <= bbox_max[0]) & + (candidate_points[:, 1] >= bbox_min[1]) & + (candidate_points[:, 1] <= bbox_max[1]) & + (candidate_points[:, 2] >= bbox_min[2]) & + (candidate_points[:, 2] <= bbox_max[2]) + ) + + return candidate_indices[mask] + + def get_info(self) -> dict: + info = super().get_info() + info['method'] = 'Grid网格索引' + info['complexity'] = 'O(1 + K)' + info['grid_size'] = self.grid_size + info['grid_count'] = len(self.grid) + info['avg_points_per_grid'] = len(self.points) / len(self.grid) + return info diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index_optimized.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index_optimized.py new file mode 100644 index 0000000..0b0b304 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/grid_index_optimized.py @@ -0,0 +1,120 @@ +"""Grid/Voxel 网格索引 - 优化版(numpy向量化)""" +import numpy as np +import time +from collections import defaultdict +from .base import SpatialIndex + + +class GridIndexOptimized(SpatialIndex): + """Grid/Voxel 空间索引 - 优化版,使用numpy向量化避免Python循环""" + + def __init__(self, points: np.ndarray, grid_size: float = 100.0): + """ + 构建网格索引(优化版) + + Args: + points: 点云数组 (N, 3) + grid_size: 网格大小(毫米) + """ + super().__init__(points) + self.grid_size = grid_size + + print(f" 构建Grid索引(优化版 - numpy向量化)(grid_size={grid_size}mm)...") + start = time.time() + + # 计算点云边界 + self.mins = points.min(axis=0) + self.maxs = points.max(axis=0) + + # 将每个点映射到网格坐标 + grid_coords = np.floor((points - self.mins) / grid_size).astype(np.int32) + + # ===== 关键优化:使用numpy向量化替代Python循环 ===== + + # 1. 将3D网格坐标编码为1D哈希值(避免tuple开销) + # 使用大素数来避免冲突 + hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64) + grid_hash = (grid_coords * hash_multiplier).sum(axis=1) + + # 2. 使用argsort排序(numpy C实现,非常快) + sort_idx = np.argsort(grid_hash) + sorted_hash = grid_hash[sort_idx] + + # 3. 找到每个唯一网格的边界 + # 只需要遍历唯一网格数(约500万),而不是10亿点! + unique_mask = np.concatenate(([True], sorted_hash[1:] != sorted_hash[:-1])) + unique_indices = np.where(unique_mask)[0] + + # 4. 构建索引字典 + self.grid = {} + self.grid_hash_to_coords = {} # 保存hash到3D坐标的映射 + + n_unique = len(unique_indices) + for i in range(n_unique): + start_idx = unique_indices[i] + end_idx = unique_indices[i + 1] if i + 1 < n_unique else len(sorted_hash) + + hash_val = sorted_hash[start_idx] + indices = sort_idx[start_idx:end_idx] + + # 使用hash作为key(比tuple快) + self.grid[hash_val] = indices + + # 保存hash到原始3D坐标的映射(用于查询) + self.grid_hash_to_coords[hash_val] = tuple(grid_coords[indices[0]]) + + self.build_time = time.time() - start + print(f" 网格数量: {len(self.grid):,}") + print(f" 平均点/格: {len(points) / len(self.grid):.1f}") + print(f" 构建耗时: {self.build_time:.2f} 秒") + + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + Grid索引查询 - O(1 + K)复杂度 + """ + # 将bbox转换为网格坐标范围 + grid_min = np.floor((bbox_min - self.mins) / self.grid_size).astype(np.int32) + grid_max = np.floor((bbox_max - self.mins) / self.grid_size).astype(np.int32) + + # 收集相关网格中的所有点索引 + candidate_indices = [] + + # 遍历bbox覆盖的网格(通常很少,比如10×10×10=1000个) + hash_multiplier = np.array([1, 1000003, 1000003**2], dtype=np.int64) + for gx in range(grid_min[0], grid_max[0] + 1): + for gy in range(grid_min[1], grid_max[1] + 1): + for gz in range(grid_min[2], grid_max[2] + 1): + grid_coord = np.array([gx, gy, gz], dtype=np.int32) + grid_hash_val = (grid_coord * hash_multiplier).sum() + + if grid_hash_val in self.grid: + candidate_indices.append(self.grid[grid_hash_val]) + + if len(candidate_indices) == 0: + return np.array([], dtype=np.int32) + + # 合并所有候选点 + candidate_indices = np.concatenate(candidate_indices) + candidate_points = self.points[candidate_indices] + + # 精确过滤 + mask = ( + (candidate_points[:, 0] >= bbox_min[0]) & + (candidate_points[:, 0] <= bbox_max[0]) & + (candidate_points[:, 1] >= bbox_min[1]) & + (candidate_points[:, 1] <= bbox_max[1]) & + (candidate_points[:, 2] >= bbox_min[2]) & + (candidate_points[:, 2] <= bbox_max[2]) + ) + + return candidate_indices[mask] + + def get_info(self) -> dict: + info = super().get_info() + info['method'] = 'Grid网格索引(优化版)' + info['complexity'] = 'O(1 + K)' + info['grid_size'] = self.grid_size + info['grid_count'] = len(self.grid) + info['avg_points_per_grid'] = len(self.points) / len(self.grid) + info['optimization'] = 'numpy向量化' + return info diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/kdtree_index.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/kdtree_index.py new file mode 100644 index 0000000..7eddcfb --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/kdtree_index.py @@ -0,0 +1,66 @@ +"""KDTree 索引(使用scipy)""" +import numpy as np +import time +from scipy.spatial import cKDTree +from .base import SpatialIndex + + +class KDTreeIndex(SpatialIndex): + """KDTree 空间索引 - O(N log N)构建, O(log N + K)查询""" + + def __init__(self, points: np.ndarray): + """ + 构建KD树索引 + + Args: + points: 点云数组 (N, 3) + """ + super().__init__(points) + + print(f" 构建KDTree索引...") + start = time.time() + + # 使用scipy的cKDTree(C实现,比纯Python快) + self.tree = cKDTree(points) + + self.build_time = time.time() - start + print(f" 构建耗时: {self.build_time:.2f} 秒") + + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + KDTree查询 - O(log N + K)复杂度 + + 使用球形查询近似bbox,然后精确过滤 + """ + # KDTree没有直接的bbox查询,使用球形查询近似 + center = (bbox_min + bbox_max) / 2 + # 使用外接球半径,确保包含整个bbox + radius = np.linalg.norm(bbox_max - bbox_min) / 2 + + # 球形查询 + candidate_indices = self.tree.query_ball_point(center, radius) + + if len(candidate_indices) == 0: + return np.array([], dtype=np.int32) + + candidate_indices = np.array(candidate_indices, dtype=np.int32) + candidate_points = self.points[candidate_indices] + + # 精确过滤(因为球形查询是近似的) + mask = ( + (candidate_points[:, 0] >= bbox_min[0]) & + (candidate_points[:, 0] <= bbox_max[0]) & + (candidate_points[:, 1] >= bbox_min[1]) & + (candidate_points[:, 1] <= bbox_max[1]) & + (candidate_points[:, 2] >= bbox_min[2]) & + (candidate_points[:, 2] <= bbox_max[2]) + ) + + return candidate_indices[mask] + + def get_info(self) -> dict: + info = super().get_info() + info['method'] = 'KDTree索引(scipy)' + info['complexity'] = 'O(log N + K)' + info['note'] = '构建慢,适合小数据集' + return info diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/linear_scan.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/linear_scan.py new file mode 100644 index 0000000..41cbe7e --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/linear_scan.py @@ -0,0 +1,32 @@ +"""线性扫描(无索引)- 基准方法""" +import numpy as np +import time +from .base import SpatialIndex + + +class LinearScanIndex(SpatialIndex): + """线性扫描索引(实际上就是无索引)""" + + def __init__(self, points: np.ndarray): + super().__init__(points) + # 无需构建索引 + self.build_time = 0.0 + + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + 线性扫描查询 - O(N)复杂度 + + 遍历所有点,检查是否在bbox内 + """ + mask = ( + (self.points[:, 0] >= bbox_min[0]) & (self.points[:, 0] <= bbox_max[0]) & + (self.points[:, 1] >= bbox_min[1]) & (self.points[:, 1] <= bbox_max[1]) & + (self.points[:, 2] >= bbox_min[2]) & (self.points[:, 2] <= bbox_max[2]) + ) + return np.where(mask)[0] + + def get_info(self) -> dict: + info = super().get_info() + info['method'] = '线性扫描(无索引)' + info['complexity'] = 'O(N)' + return info diff --git a/alg/train_measurement_system_v3.2_0629/src/spatial_index/octree_index.py b/alg/train_measurement_system_v3.2_0629/src/spatial_index/octree_index.py new file mode 100644 index 0000000..317ca73 --- /dev/null +++ b/alg/train_measurement_system_v3.2_0629/src/spatial_index/octree_index.py @@ -0,0 +1,166 @@ +"""Octree 八叉树索引""" +import numpy as np +import time +from .base import SpatialIndex + + +class OctreeNode: + """八叉树节点""" + + def __init__(self, center: np.ndarray, size: float): + self.center = center + self.size = size + self.children = [None] * 8 # 8个子节点 + self.point_indices = [] # 叶节点存储点索引 + self.is_leaf = True + + +class OctreeIndex(SpatialIndex): + """Octree 空间索引 - O(N)构建, O(log N + K)查询""" + + def __init__(self, points: np.ndarray, max_depth: int = 8, max_points_per_node: int = 1000): + """ + 构建八叉树索引 + + Args: + points: 点云数组 (N, 3) + max_depth: 最大树深度 + max_points_per_node: 每个叶节点最多存储的点数 + """ + super().__init__(points) + self.max_depth = max_depth + self.max_points_per_node = max_points_per_node + + print(f" 构建Octree索引 (max_depth={max_depth}, max_points={max_points_per_node})...") + start = time.time() + + # 计算点云边界 + mins = points.min(axis=0) + maxs = points.max(axis=0) + center = (mins + maxs) / 2 + size = np.max(maxs - mins) / 2 + + # 构建树 + all_indices = np.arange(len(points), dtype=np.int32) + self.root = self._build_tree(center, size, all_indices, depth=0) + + self.build_time = time.time() - start + self.node_count = self._count_nodes(self.root) + print(f" 节点数量: {self.node_count:,}") + print(f" 构建耗时: {self.build_time:.2f} 秒") + + def _build_tree(self, center: np.ndarray, size: float, indices: np.ndarray, depth: int) -> OctreeNode: + """递归构建八叉树""" + node = OctreeNode(center, size) + + # 终止条件:达到最大深度或点数足够少 + if depth >= self.max_depth or len(indices) <= self.max_points_per_node: + node.point_indices = indices + node.is_leaf = True + return node + + # 分割成8个子空间 + node.is_leaf = False + half_size = size / 2 + points_subset = self.points[indices] + + for i in range(8): + # 计算子节点中心 + child_center = center.copy() + child_center[0] += half_size if (i & 1) else -half_size + child_center[1] += half_size if (i & 2) else -half_size + child_center[2] += half_size if (i & 4) else -half_size + + # 找到属于这个子空间的点 + mask = ( + ((points_subset[:, 0] >= child_center[0] - half_size) & + (points_subset[:, 0] < child_center[0] + half_size)) & + ((points_subset[:, 1] >= child_center[1] - half_size) & + (points_subset[:, 1] < child_center[1] + half_size)) & + ((points_subset[:, 2] >= child_center[2] - half_size) & + (points_subset[:, 2] < child_center[2] + half_size)) + ) + + child_indices = indices[mask] + + if len(child_indices) > 0: + node.children[i] = self._build_tree(child_center, half_size, child_indices, depth + 1) + + return node + + def query_bbox(self, bbox_min: np.ndarray, bbox_max: np.ndarray) -> np.ndarray: + """ + Octree查询 - O(log N + K)复杂度 + + 递归查询与bbox相交的节点 + """ + result = [] + self._query_recursive(self.root, bbox_min, bbox_max, result) + + if len(result) == 0: + return np.array([], dtype=np.int32) + + # 合并所有候选点 + candidate_indices = np.concatenate(result) + candidate_points = self.points[candidate_indices] + + # 精确过滤 + mask = ( + (candidate_points[:, 0] >= bbox_min[0]) & + (candidate_points[:, 0] <= bbox_max[0]) & + (candidate_points[:, 1] >= bbox_min[1]) & + (candidate_points[:, 1] <= bbox_max[1]) & + (candidate_points[:, 2] >= bbox_min[2]) & + (candidate_points[:, 2] <= bbox_max[2]) + ) + + return candidate_indices[mask] + + def _query_recursive(self, node: OctreeNode, bbox_min: np.ndarray, bbox_max: np.ndarray, result: list): + """递归查询节点""" + if node is None: + return + + # 检查节点边界框是否与查询bbox相交 + node_min = node.center - node.size + node_max = node.center + node.size + + if not self._bbox_intersects(bbox_min, bbox_max, node_min, node_max): + return + + if node.is_leaf: + # 叶节点:添加所有点索引 + if len(node.point_indices) > 0: + result.append(node.point_indices) + else: + # 内部节点:递归查询子节点 + for child in node.children: + if child is not None: + self._query_recursive(child, bbox_min, bbox_max, result) + + def _bbox_intersects(self, bbox1_min: np.ndarray, bbox1_max: np.ndarray, + bbox2_min: np.ndarray, bbox2_max: np.ndarray) -> bool: + """检查两个bbox是否相交""" + return not ( + bbox1_max[0] < bbox2_min[0] or bbox1_min[0] > bbox2_max[0] or + bbox1_max[1] < bbox2_min[1] or bbox1_min[1] > bbox2_max[1] or + bbox1_max[2] < bbox2_min[2] or bbox1_min[2] > bbox2_max[2] + ) + + def _count_nodes(self, node: OctreeNode) -> int: + """统计节点数量""" + if node is None: + return 0 + count = 1 + if not node.is_leaf: + for child in node.children: + count += self._count_nodes(child) + return count + + def get_info(self) -> dict: + info = super().get_info() + info['method'] = 'Octree八叉树索引' + info['complexity'] = 'O(log N + K)' + info['max_depth'] = self.max_depth + info['node_count'] = self.node_count + return info diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/task_manager.py b/alg/train_measurement_system_v3.2_0629/src/task_manager.py similarity index 75% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/task_manager.py rename to alg/train_measurement_system_v3.2_0629/src/task_manager.py index ee08bc5..b2e7bdd 100644 --- a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/task_manager.py +++ b/alg/train_measurement_system_v3.2_0629/src/task_manager.py @@ -1,6 +1,7 @@ """任务管理器 - 负责加载配置、调度任务、汇总结果""" import yaml import numpy as np +import time from pathlib import Path from typing import List, Dict, Any from datetime import datetime @@ -13,9 +14,12 @@ from measurements.point_to_point import PointToPointDistance from measurements.plane_with_holes import PlaneToPlaneWithHoles from measurements.point_to_plane_with_holes import PointToPlaneWithHoles from measurements.point_to_point_with_holes import PointToPointWithHoles +from measurements.line_to_line import LineToLineDistance +from measurements.line_to_plane import LineToPlaneDistance from excel_exporter import ExcelExporter from performance_logger import PerformanceLogger from measurement_logger import MeasurementLogger +from spatial_index import LinearScanIndex, GridIndex, GridIndexOptimized, OctreeIndex, KDTreeIndex class TaskManager: @@ -25,6 +29,7 @@ class TaskManager: """初始化任务管理器""" self.config = None self.point_cloud = None + self.spatial_index = None # 空间索引 self.tasks = [] self.results = [] self.pointcloud_path_override = None @@ -53,11 +58,35 @@ class TaskManager: # 处理相对路径 pointcloud_path = self._resolve_path(pointcloud_path, config_path) - print(f"\n正在加载点云文件: {pointcloud_path}") + # 加载点云 + print(f"\n{'='*70}") + print("阶段1: 加载点云") + print("="*70) + print(f" 文件: {Path(pointcloud_path).name}") + + load_start = time.time() self.point_cloud = PointCloudLoader.load_ply(pointcloud_path) - print(f"点云加载完成: {len(self.point_cloud)} 个点") + load_time = time.time() - load_start + + print(f" 点云数量: {len(self.point_cloud):,} 个点") + print(f" 加载耗时: {load_time:.2f} 秒") + + # 保存点云加载信息供性能日志使用 + self.pointcloud_load_time = load_time + self.pointcloud_filename = Path(pointcloud_path).name + + # 构建空间索引 + print(f"\n{'='*70}") + print("阶段2: 构建空间索引") + print("="*70) + index_start = time.time() + self._build_spatial_index() + self.index_build_time = time.time() - index_start # 解析任务 + print(f"\n{'='*70}") + print("阶段3: 解析测量任务") + print("="*70) tasks_config = self.config.get('tasks', []) if not tasks_config: print("警告: 配置文件中没有定义任务") @@ -139,37 +168,57 @@ class TaskManager: return PlaneToPlaneWithHoles( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index ) elif 'point_to_plane' in task_type: return PointToPlaneWithHoles( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index ) elif 'hole_center' in task_type and 'point_to_point' in task_type: return PointToPointWithHoles( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index ) elif task_type == 'plane_to_plane': return PlaneToPlaneDistance( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index ) elif task_type == 'point_to_plane': return PointToPlaneDistance( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index ) elif task_type == 'point_to_point': return PointToPointDistance( name=task_name, point_cloud=self.point_cloud, - config=task_config + config=task_config, + spatial_index=self.spatial_index + ) + elif task_type == 'line_to_line': + return LineToLineDistance( + name=task_name, + point_cloud=self.point_cloud, + config=task_config, + spatial_index=self.spatial_index + ) + elif task_type == 'line_to_plane': + return LineToPlaneDistance( + name=task_name, + point_cloud=self.point_cloud, + config=task_config, + spatial_index=self.spatial_index ) else: print(f"警告: 未知的任务类型: {task_type}") @@ -194,6 +243,22 @@ class TaskManager: perf_logger = PerformanceLogger(str(output_dir)) + # 记录初始化阶段性能 + if hasattr(self, 'pointcloud_load_time'): + perf_logger.log_pointcloud_loading( + self.pointcloud_load_time, + len(self.point_cloud), + self.pointcloud_filename + ) + if hasattr(self, 'index_build_time'): + index_info = self.spatial_index.get_info() if self.spatial_index else {} + index_method = self.config.get('roi_extraction', {}).get('method', 'unknown') + perf_logger.log_spatial_index( + self.index_build_time, + index_method, + index_info + ) + # 创建详细测量日志记录器 detail_logger = MeasurementLogger(str(output_dir)) @@ -402,3 +467,44 @@ class TaskManager: except Exception as e: print(f"\n导出Excel失败: {e}") + + def _build_spatial_index(self): + """根据配置构建空间索引""" + index_config = self.config.get('roi_extraction', {}) + index_method = index_config.get('method', 'linear').lower() + + if index_method == 'linear': + print(" 方法: 线性扫描(无索引)") + self.spatial_index = LinearScanIndex(self.point_cloud) + + elif index_method == 'grid': + grid_size = index_config.get('grid_size', 100.0) + use_optimized = index_config.get('use_optimized', True) # 默认使用优化版 + + if use_optimized: + print(f" 方法: Grid网格索引(优化版 - numpy向量化)") + self.spatial_index = GridIndexOptimized(self.point_cloud, grid_size=grid_size) + else: + print(f" 方法: Grid网格索引(原始版 - Python循环)") + self.spatial_index = GridIndex(self.point_cloud, grid_size=grid_size) + + elif index_method == 'octree': + max_depth = index_config.get('max_depth', 8) + max_points = index_config.get('max_points_per_node', 1000) + print(f" 方法: Octree八叉树索引") + self.spatial_index = OctreeIndex(self.point_cloud, max_depth=max_depth, max_points_per_node=max_points) + + elif index_method == 'kdtree': + print(f" 方法: KDTree索引") + self.spatial_index = KDTreeIndex(self.point_cloud) + + else: + print(f" 警告: 未知的索引方法 '{index_method}',使用线性扫描") + self.spatial_index = LinearScanIndex(self.point_cloud) + + # 显示索引信息 + info = self.spatial_index.get_info() + if 'grid_count' in info: + print(f" 详细信息: {info['grid_count']:,} 个网格, 平均 {info['avg_points_per_grid']:.1f} 点/格") + elif 'node_count' in info: + print(f" 详细信息: {info['node_count']:,} 个节点") diff --git a/alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/visualizer.py b/alg/train_measurement_system_v3.2_0629/src/visualizer.py similarity index 100% rename from alg/train_measurement_system_v2.2/train_measurement_system_v2.2/src/visualizer.py rename to alg/train_measurement_system_v3.2_0629/src/visualizer.py diff --git a/alg/平整度、平面度、轮廓测量v1.5/config.yaml b/alg/平整度、平面度、轮廓测量v1.5/config.yaml index fab15fe..a5f7199 100644 --- a/alg/平整度、平面度、轮廓测量v1.5/config.yaml +++ b/alg/平整度、平面度、轮廓测量v1.5/config.yaml @@ -39,8 +39,8 @@ registration: random_seed: evaluation: - chunk_size: 2048 - triangle_chunk_size: 4096 + chunk_size: 8192 + triangle_chunk_size: 512 cad_mesh_deflection: 0.1 curvature: diff --git a/alg/平整度、平面度、轮廓测量v1.5/evaluation.py b/alg/平整度、平面度、轮廓测量v1.5/evaluation.py index 6ae7b23..bac5b18 100644 --- a/alg/平整度、平面度、轮廓测量v1.5/evaluation.py +++ b/alg/平整度、平面度、轮廓测量v1.5/evaluation.py @@ -74,15 +74,23 @@ class Evaluator: ) -> tuple[np.ndarray, DistanceStats]: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") dtype = torch.float64 + + # vertices/triangles 上 GPU(mesh 通常几百MB,可以接受) vertices_t = torch.as_tensor(np.asarray(vertices, dtype=np.float64), dtype=dtype, device=device) faces_t = torch.as_tensor(np.asarray(faces, dtype=np.int64), dtype=torch.long, device=device) triangles = vertices_t[faces_t] - points_t = torch.as_tensor(points, dtype=dtype, device=device) + del vertices_t, faces_t # 释放原始 vertices/faces,只保留 triangles + + # points 保留在 CPU,按 point_chunk 逐批传上 GPU,避免 10 亿点全量上 GPU OOM + points_np = np.asarray(points, dtype=np.float64) + n_points = points_np.shape[0] distances = [] with torch.no_grad(): - for start in range(0, points_t.shape[0], point_chunk_size): - chunk = points_t[start:start + point_chunk_size] + for start in range(0, n_points, point_chunk_size): + chunk = torch.as_tensor( + points_np[start:start + point_chunk_size], dtype=dtype, device=device + ) if triangle_chunk_size is None or triangle_chunk_size <= 0: dist2 = self._point_to_triangle_distance_squared(chunk, triangles) min_dist2 = dist2.min(dim=1).values @@ -90,7 +98,7 @@ class Evaluator: min_dist2 = torch.full( (chunk.shape[0],), torch.inf, - dtype=points_t.dtype, + dtype=dtype, device=device, ) for triangle_start in range(0, triangles.shape[0], triangle_chunk_size): diff --git a/alg/整车配准v1.3/translate_align_pointcloud_to_cad.py b/alg/整车配准v1.3/translate_align_pointcloud_to_cad.py index 18fe77a..0717dbc 100644 --- a/alg/整车配准v1.3/translate_align_pointcloud_to_cad.py +++ b/alg/整车配准v1.3/translate_align_pointcloud_to_cad.py @@ -1,972 +1,985 @@ -import os -import argparse -import time -import hashlib -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" - - -def format_elapsed(seconds): - if seconds < 1.0: - return f"{seconds * 1000.0:.1f} ms" - - minutes, seconds = divmod(seconds, 60.0) - if minutes < 1.0: - return f"{seconds:.2f} s" - - hours, minutes = divmod(minutes, 60.0) - if hours < 1.0: - return f"{int(minutes)} min {seconds:.2f} s" - - return f"{int(hours)} h {int(minutes)} min {seconds:.2f} s" - - -def log_elapsed(label, start_time): - elapsed = time.perf_counter() - start_time - print(f"[TIME] {label}: {format_elapsed(elapsed)}") - return elapsed - - -def build_cad_sample_cache_path(cad_path, output_dir, mesh_deflection, cad_sample_points): - stat = os.stat(cad_path) - cache_key = "|".join([ - os.path.abspath(cad_path), - str(stat.st_size), - str(stat.st_mtime_ns), - f"{mesh_deflection:.12g}", - str(cad_sample_points), - ]) - digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16] - cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad" - return os.path.join(output_dir, "cad_cache", f"{cad_name}_{digest}.ply") - - -# ============================================================ -# STEP CAD -> 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 三角网格提取失败,请检查 STEP 文件") - - 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 axis_to_index(axis): - axis = axis.lower() - - if axis == "x": - return 0 - if axis == "y": - return 1 - if axis == "z": - return 2 - - raise ValueError("axis 必须是 x / y / z") - - -def crop_end_region_by_ratio(pcd, axis="x", part="front", ratio=0.08): - """ - 按长度方向裁剪前端/后端区域。 - - axis: - 高铁长度方向,一般是 x - - part: - front: 轴最小端 - tail: 轴最大端 - - ratio: - 取前后端占整体长度的比例,例如 0.08 表示取前 8% 和后 8% - """ - - points = np.asarray(pcd.points) - - axis_id = axis_to_index(axis) - - min_bound = points.min(axis=0) - max_bound = points.max(axis=0) - - axis_min = min_bound[axis_id] - axis_max = max_bound[axis_id] - axis_len = axis_max - axis_min - - if part == "front": - lower = axis_min - upper = axis_min + axis_len * ratio - elif part == "tail": - lower = axis_max - axis_len * ratio - upper = axis_max - else: - raise ValueError("part 必须是 front 或 tail") - - crop_min = min_bound.copy() - crop_max = max_bound.copy() - - crop_min[axis_id] = lower - crop_max[axis_id] = upper - - bbox = o3d.geometry.AxisAlignedBoundingBox( - min_bound=crop_min, - max_bound=crop_max - ) - - cropped = pcd.crop(bbox) - - print(f"[OK] Crop {part}") - print(" range:", lower, upper) - print(" points:", np.asarray(cropped.points).shape) - - return cropped - - -def sample_farthest_points_simple(pcd, num_points=5000, rng=None): - """ - 为了避免百万点太多,这里随机采样少量点。 - 如果点数不足,则直接返回原点云。 - """ - - points = np.asarray(pcd.points) - n = points.shape[0] - - if n <= num_points: - return pcd - - if rng is None: - rng = np.random.default_rng() - - indices = rng.choice(n, size=num_points, replace=False) - - sampled = pcd.select_by_index(indices.tolist()) - - return sampled - - -def compute_centroid(pcd): - points = np.asarray(pcd.points) - - if points.shape[0] == 0: - raise RuntimeError("点云为空,无法计算质心") - - return points.mean(axis=0) - - -def compute_alignment_error_stats(source_pcd, cad_pcd, sample_points=100000, rng=None): - if sample_points is not None and sample_points > 0: - eval_pcd = sample_farthest_points_simple( - source_pcd, - num_points=sample_points, - rng=rng - ) - else: - eval_pcd = source_pcd - - distances = np.asarray( - eval_pcd.compute_point_cloud_distance(cad_pcd), - dtype=np.float64 - ) - - if distances.shape[0] == 0: - raise RuntimeError("No points available for alignment error statistics") - - squared = distances * distances - - return { - "sample_count": int(distances.shape[0]), - "mean": float(np.mean(distances)), - "rmse": float(np.sqrt(np.mean(squared))), - "median": float(np.median(distances)), - "p95": float(np.percentile(distances, 95)), - "p99": float(np.percentile(distances, 99)), - "max": float(np.max(distances)), - } - - -def print_alignment_error_stats(stats): - print("\n========== Alignment Error ==========") - print("sample count:", stats["sample_count"]) - print("mean: ", stats["mean"]) - print("rmse: ", stats["rmse"]) - print("median: ", stats["median"]) - print("p95: ", stats["p95"]) - print("p99: ", stats["p99"]) - print("max: ", stats["max"]) - - -def save_alignment_error_stats(path, stats): - with open(path, "w", encoding="utf-8") as f: - for key in ["sample_count", "mean", "rmse", "median", "p95", "p99", "max"]: - f.write(f"{key}: {stats[key]}\n") - - -def write_point_cloud_float32_xyz(path, pcd, chunk_size=1_000_000): - points = np.asarray(pcd.points) - if points.shape[0] == 0: - raise RuntimeError(f"Cannot write empty point cloud: {path}") - - os.makedirs(os.path.dirname(path), exist_ok=True) - - header = ( - "ply\n" - "format binary_little_endian 1.0\n" - f"element vertex {points.shape[0]}\n" - "property float x\n" - "property float y\n" - "property float z\n" - "end_header\n" - ) - - with open(path, "wb") as f: - f.write(header.encode("ascii")) - for start in range(0, points.shape[0], chunk_size): - end = min(start + chunk_size, points.shape[0]) - f.write(np.asarray(points[start:end], dtype=" 0: - source_icp = sample_farthest_points_simple( - source_pcd, - num_points=sample_points, - rng=rng - ) - else: - source_icp = o3d.geometry.PointCloud(source_pcd) - - source_icp.transform(initial_transform) - target_icp = cad_pcd - - if voxel_size is not None and voxel_size > 0: - source_icp = source_icp.voxel_down_sample(voxel_size=voxel_size) - target_icp = cad_pcd.voxel_down_sample(voxel_size=voxel_size) - - source_count = len(source_icp.points) - target_count = len(target_icp.points) - if source_count == 0 or target_count == 0: - raise RuntimeError("ICP point cloud is empty after sampling/downsampling") - - if max_correspondence_distance is None or max_correspondence_distance <= 0: - initial_distances = np.asarray( - source_icp.compute_point_cloud_distance(target_icp), - dtype=np.float64 - ) - p95 = float(np.percentile(initial_distances, 95)) - max_correspondence_distance = max(p95 * 2.0, 1e-6) - print("[INFO] ICP auto max correspondence distance:", max_correspondence_distance) - - result = o3d.pipelines.registration.registration_icp( - source_icp, - target_icp, - max_correspondence_distance, - np.eye(4, dtype=np.float64), - o3d.pipelines.registration.TransformationEstimationPointToPoint(), - o3d.pipelines.registration.ICPConvergenceCriteria( - max_iteration=max_iteration - ) - ) - - icp_transform = result.transformation - final_transform = icp_transform @ initial_transform - - stats = { - "fitness": float(result.fitness), - "inlier_rmse": float(result.inlier_rmse), - "max_correspondence_distance": float(max_correspondence_distance), - "source_points": int(source_count), - "target_points": int(target_count), - "transform": icp_transform, - } - - return final_transform, stats - - -def print_icp_stats(stats): - print("\n========== ICP Refinement ==========") - print("source points:", stats["source_points"]) - print("target points:", stats["target_points"]) - print("max correspondence distance:", stats["max_correspondence_distance"]) - print("fitness:", stats["fitness"]) - print("inlier rmse:", stats["inlier_rmse"]) - print("ICP delta transform:") - print(stats["transform"]) - - -def visualize_alignment(source_pcd, cad_pcd, title="Translation Alignment Result"): - source_vis = o3d.geometry.PointCloud(source_pcd) - cad_vis = o3d.geometry.PointCloud(cad_pcd) - - source_vis.paint_uniform_color([1.0, 0.0, 0.0]) - cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) - - o3d.visualization.draw_geometries( - [source_vis, cad_vis], - window_name=title - ) - - -# ============================================================ -# 只估计平移的配准 -# ============================================================ - -def resolve_pointcloud_path(pointcloud_path): - if os.path.isdir(pointcloud_path): - preferred = os.path.join(pointcloud_path, "shifted_whole_pointcloud.ply") - if os.path.exists(preferred): - return preferred - - candidates = [ - os.path.join(pointcloud_path, name) - for name in sorted(os.listdir(pointcloud_path)) - if name.lower().endswith(".ply") - ] - if len(candidates) == 1: - return candidates[0] - if not candidates: - raise FileNotFoundError(f"No PLY point cloud found in directory: {pointcloud_path}") - raise ValueError( - "Multiple PLY files found in directory; specify one file explicitly: " - + ", ".join(os.path.basename(path) for path in candidates) - ) - - return pointcloud_path - - -def translate_align_by_front_tail( - cad_path, - pointcloud_path, - output_dir="results", - axis="x", - crop_ratio=0.08, - mesh_deflection=0.1, - cad_sample_points=300000, - local_sample_points=10000, - scale_pointcloud=1.0, - visualize=True, - use_cad_cache=True, - save_debug_samples=False, - random_seed=0, - error_sample_points=100000, - compute_error_stats=True, - save_aligned_float32=True, - use_icp=True, - icp_sample_points=200000, - icp_voxel_size=0.0, - icp_max_correspondence_distance=0.0, - icp_max_iteration=50 -): - total_start = time.perf_counter() - stage_start = total_start - rng = np.random.default_rng(random_seed) if random_seed is not None else np.random.default_rng() - - if random_seed is not None: - try: - o3d.utility.random.seed(int(random_seed)) - except AttributeError: - pass - - os.makedirs(output_dir, exist_ok=True) - - # ------------------------------------------------------------ - # 1. CAD STEP 转 mesh,再采样成 CAD 点云 - # ------------------------------------------------------------ - - cad_pcd = None - cad_pcd_path = os.path.join(output_dir, "cad_sampled.ply") - - if use_cad_cache: - cad_pcd_path = build_cad_sample_cache_path( - cad_path, - output_dir, - mesh_deflection, - cad_sample_points - ) - if os.path.exists(cad_pcd_path): - cad_pcd = o3d.io.read_point_cloud(cad_pcd_path) - if len(cad_pcd.points) > 0: - print("[OK] CAD sample cache loaded:", cad_pcd_path) - else: - print("[WARN] CAD sample cache is empty, rebuilding:", cad_pcd_path) - cad_pcd = None - - if cad_pcd is None: - cad_mesh = step_to_open3d_mesh( - cad_path, - mesh_deflection=mesh_deflection - ) - - print(f"[INFO] Sampling CAD mesh: {cad_sample_points} points") - cad_pcd = cad_mesh.sample_points_uniformly( - number_of_points=cad_sample_points - ) - - os.makedirs(os.path.dirname(cad_pcd_path), exist_ok=True) - o3d.io.write_point_cloud(cad_pcd_path, cad_pcd) - print("[OK] CAD sample saved:", cad_pcd_path) - - log_elapsed("CAD sample load/build", stage_start) - stage_start = time.perf_counter() - - # ------------------------------------------------------------ - # 2. 读取扫描点云 - # ------------------------------------------------------------ - - pointcloud_path = resolve_pointcloud_path(pointcloud_path) - source_pcd = o3d.io.read_point_cloud(pointcloud_path) - - if len(source_pcd.points) == 0: - raise RuntimeError(f"点云读取失败: {pointcloud_path}") - - print("[OK] Source point cloud loaded:", pointcloud_path) - print(" source points:", np.asarray(source_pcd.points).shape) - - if scale_pointcloud != 1.0: - source_pcd.scale(scale_pointcloud, center=(0, 0, 0)) - print("[INFO] Source scaled:", scale_pointcloud) - log_elapsed("source load", stage_start) - stage_start = time.perf_counter() - - # ------------------------------------------------------------ - # 3. 裁剪 CAD 前端 / 后端 - # ------------------------------------------------------------ - - cad_front = crop_end_region_by_ratio( - cad_pcd, - axis=axis, - part="front", - ratio=crop_ratio - ) - - cad_tail = crop_end_region_by_ratio( - cad_pcd, - axis=axis, - part="tail", - ratio=crop_ratio - ) - - # ------------------------------------------------------------ - # 4. 裁剪扫描点云前端 / 后端 - # ------------------------------------------------------------ - - source_front = crop_end_region_by_ratio( - source_pcd, - axis=axis, - part="front", - ratio=crop_ratio - ) - - source_tail = crop_end_region_by_ratio( - source_pcd, - axis=axis, - part="tail", - ratio=crop_ratio - ) - log_elapsed("crop regions", stage_start) - stage_start = time.perf_counter() - - # ------------------------------------------------------------ - # 5. 前后区域分别随机取少量点 - # ------------------------------------------------------------ - - cad_front_sample = sample_farthest_points_simple( - cad_front, - num_points=local_sample_points, - rng=rng - ) - - cad_tail_sample = sample_farthest_points_simple( - cad_tail, - num_points=local_sample_points, - rng=rng - ) - - source_front_sample = sample_farthest_points_simple( - source_front, - num_points=local_sample_points, - rng=rng - ) - - source_tail_sample = sample_farthest_points_simple( - source_tail, - num_points=local_sample_points, - rng=rng - ) - - # 保存局部采样结果,方便检查 - if save_debug_samples: - o3d.io.write_point_cloud(os.path.join(output_dir, "cad_front_sample.ply"), cad_front_sample) - o3d.io.write_point_cloud(os.path.join(output_dir, "cad_tail_sample.ply"), cad_tail_sample) - o3d.io.write_point_cloud(os.path.join(output_dir, "source_front_sample.ply"), source_front_sample) - o3d.io.write_point_cloud(os.path.join(output_dir, "source_tail_sample.ply"), source_tail_sample) - print("[OK] Debug sample point clouds saved") - else: - print("[INFO] Debug sample point clouds skipped") - log_elapsed("local sampling", stage_start) - stage_start = time.perf_counter() - - # ------------------------------------------------------------ - # 6. 计算前端和后端质心 - # ------------------------------------------------------------ - - cad_front_center = compute_centroid(cad_front_sample) - cad_tail_center = compute_centroid(cad_tail_sample) - - source_front_center = compute_centroid(source_front_sample) - source_tail_center = compute_centroid(source_tail_sample) - - print("\n========== Centroids ==========") - print("CAD front center: ", cad_front_center) - print("CAD tail center: ", cad_tail_center) - print("Source front center:", source_front_center) - print("Source tail center: ", source_tail_center) - - # ------------------------------------------------------------ - # 7. 分别根据车头和车尾估计平移 - # ------------------------------------------------------------ - - t_front = cad_front_center - source_front_center - t_tail = cad_tail_center - source_tail_center - - print("\n========== Translation From Front / Tail ==========") - print("t_front:", t_front) - print("t_tail: ", t_tail) - print("front-tail translation difference:", np.linalg.norm(t_front - t_tail)) - - # ------------------------------------------------------------ - # 8. 融合平移 - # ------------------------------------------------------------ - - t_final = 0.5 * t_front + 0.5 * t_tail - - T_final = build_translation_matrix(t_final) - - print("\n========== Initial Translation ==========") - print("t_initial:", t_final) - print("T_initial:") - print(T_final) - log_elapsed("translation estimate", stage_start) - stage_start = time.perf_counter() - - icp_stats = None - if use_icp: - T_final, icp_stats = run_icp_refinement( - source_pcd, - cad_pcd, - T_final, - sample_points=icp_sample_points, - voxel_size=icp_voxel_size, - max_correspondence_distance=icp_max_correspondence_distance, - max_iteration=icp_max_iteration, - rng=rng - ) - print_icp_stats(icp_stats) - print("\n========== Final Rigid Transform ==========") - print("final translation:", T_final[:3, 3]) - print("T_final:") - print(T_final) - else: - print("[INFO] ICP refinement skipped") - - log_elapsed("ICP refinement", stage_start) - stage_start = time.perf_counter() - - # ------------------------------------------------------------ - # 9. 应用平移到整个点云 - # ------------------------------------------------------------ - - aligned_pcd = source_pcd - aligned_pcd.transform(T_final) - - if use_icp: - aligned_path = os.path.join(output_dir, "aligned_pointcloud.ply") - matrix_path = os.path.join(output_dir, "T_rigid_icp.txt") - else: - aligned_path = os.path.join(output_dir, "aligned_pointcloud_translation_only.ply") - matrix_path = os.path.join(output_dir, "T_translation_only.txt") - - if save_aligned_float32: - write_point_cloud_float32_xyz(aligned_path, aligned_pcd) - print("[INFO] Aligned point cloud saved as compact float32 XYZ PLY") - else: - o3d.io.write_point_cloud(aligned_path, aligned_pcd, write_ascii=False) - print("[INFO] Aligned point cloud saved with Open3D default writer") - np.savetxt(matrix_path, T_final, fmt="%.10f") - - print("\n[OK] Saved aligned point cloud:", aligned_path) - print("[OK] Saved transformation matrix:", matrix_path) - log_elapsed("save outputs", stage_start) - stage_start = time.perf_counter() - - error_stats = None - error_stats_path = os.path.join(output_dir, "alignment_error_stats.txt") - - if compute_error_stats: - error_stats = compute_alignment_error_stats( - aligned_pcd, - cad_pcd, - sample_points=error_sample_points, - rng=rng - ) - print_alignment_error_stats(error_stats) - save_alignment_error_stats(error_stats_path, error_stats) - print("[OK] Saved alignment error stats:", error_stats_path) - else: - error_stats_path = None - print("[INFO] Alignment error stats skipped") - - log_elapsed("alignment error stats", stage_start) - - # ------------------------------------------------------------ - # 10. 可视化 - # ------------------------------------------------------------ - - if visualize: - visualize_start = time.perf_counter() - # 为了不卡,显示时降采样 - source_vis = aligned_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) - cad_vis = cad_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) - - source_vis.paint_uniform_color([1.0, 0.0, 0.0]) - cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) - - o3d.visualization.draw_geometries( - [source_vis, cad_vis], - window_name="Translation Only Alignment: red=aligned source, blue=CAD" - ) - log_elapsed("visualization", visualize_start) - - total_elapsed = log_elapsed("total", total_start) - - return { - "translation": T_final[:3, 3], - "initial_translation": t_final, - "T_final": T_final, - "t_front": t_front, - "t_tail": t_tail, - "front_tail_translation_difference": float(np.linalg.norm(t_front - t_tail)), - "icp_stats": icp_stats, - "aligned_path": aligned_path, - "matrix_path": matrix_path, - "error_stats": error_stats, - "error_stats_path": error_stats_path, - "elapsed_seconds": total_elapsed - } - - -# ============================================================ -# 命令行入口 -# ============================================================ - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="高铁点云与 CAD 的仅平移配准:取车头车尾少量点估计整体平移" - ) - - parser.add_argument( - "--cad", - "-c", - default="datasets/CAD/m000004802418tc01_asm.stp", - help="CAD STEP 文件路径" - ) - - parser.add_argument( - "--pointcloud", - "-p", - default="datasets/shift_test_output/", - help="待配准点云 PLY 文件路径" - ) - - parser.add_argument( - "--output_dir", - "-o", - default="results", - help="输出目录" - ) - - parser.add_argument( - "--axis", - default="x", - choices=["x", "y", "z"], - help="高铁长度方向,默认 x" - ) - - parser.add_argument( - "--crop_ratio", - type=float, - default=0.01, - help="车头/车尾各取总长度的比例,默认 0.01" - ) - - parser.add_argument( - "--mesh_deflection", - type=float, - default=0.1, - help="CAD 三角化精度,默认 0.1" - ) - - parser.add_argument( - "--cad_sample_points", - type=int, - default=30000, - help="从 CAD mesh 采样的点数,默认 30000" - ) - - parser.add_argument( - "--local_sample_points", - type=int, - default=1000, - help="车头/车尾各随机采样多少点用于计算质心,默认 1000" - ) - - parser.add_argument( - "--scale_pointcloud", - type=float, - default=1.0, - help="点云缩放。如果点云是米,CAD 是毫米,设为 1000" - ) - - parser.add_argument( - "--no_visualize", - action="store_true", - help="不显示可视化窗口" - ) - - parser.add_argument( - "--no_cad_cache", - action="store_true", - help="Disable CAD sampled point cloud cache" - ) - - parser.add_argument( - "--save_debug_samples", - action="store_true", - help="Save front/tail debug sample point clouds" - ) - - parser.add_argument( - "--random_seed", - type=int, - default=0, - help="Random seed for repeatable sampling; set -1 to disable deterministic sampling" - ) - - parser.add_argument( - "--error_sample_points", - type=int, - default=100000, - help="Sample count for alignment error stats; set 0 to use all aligned source points" - ) - - parser.add_argument( - "--no_error_stats", - action="store_true", - help="Skip point-to-CAD nearest-neighbor error statistics" - ) - - parser.add_argument( - "--save_aligned_float64", - action="store_true", - help="Use Open3D default float64-style aligned PLY output instead of compact float32 XYZ" - ) - - parser.add_argument( - "--no_icp", - action="store_true", - help="Skip rigid ICP refinement and use translation-only alignment" - ) - - parser.add_argument( - "--icp_sample_points", - type=int, - default=200000, - help="Source sample count used for ICP; set 0 to use all source points" - ) - - parser.add_argument( - "--icp_voxel_size", - type=float, - default=0.0, - help="Voxel size for ICP downsampling; 0 disables voxel downsampling" - ) - - parser.add_argument( - "--icp_max_correspondence_distance", - type=float, - default=0.0, - help="ICP max correspondence distance; 0 uses an automatic value from initial distances" - ) - - parser.add_argument( - "--icp_max_iteration", - type=int, - default=50, - help="Maximum ICP iterations" - ) - - args = parser.parse_args() - random_seed = None if args.random_seed < 0 else args.random_seed - - result = translate_align_by_front_tail( - 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, - visualize=not args.no_visualize, - use_cad_cache=not args.no_cad_cache, - save_debug_samples=args.save_debug_samples, - random_seed=random_seed, - error_sample_points=args.error_sample_points, - compute_error_stats=not args.no_error_stats, - save_aligned_float32=not args.save_aligned_float64, - use_icp=not args.no_icp, - icp_sample_points=args.icp_sample_points, - icp_voxel_size=args.icp_voxel_size, - icp_max_correspondence_distance=args.icp_max_correspondence_distance, - icp_max_iteration=args.icp_max_iteration - ) - - print("\n========== Result ==========") - print("initial translation:", result["initial_translation"]) - print("final translation:", result["translation"]) - print("front-tail translation difference:", result["front_tail_translation_difference"]) - if result["icp_stats"] is not None: - print("icp fitness:", result["icp_stats"]["fitness"]) - print("icp inlier rmse:", result["icp_stats"]["inlier_rmse"]) - print("aligned path:", result["aligned_path"]) - print("matrix path:", result["matrix_path"]) - if result["error_stats"] is not None: - print("error stats path:", result["error_stats_path"]) - print("error rmse:", result["error_stats"]["rmse"]) - print("error p95:", result["error_stats"]["p95"]) - print("elapsed:", format_elapsed(result["elapsed_seconds"])) +import os +import argparse +import time +import hashlib +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" + + +def format_elapsed(seconds): + if seconds < 1.0: + return f"{seconds * 1000.0:.1f} ms" + + minutes, seconds = divmod(seconds, 60.0) + if minutes < 1.0: + return f"{seconds:.2f} s" + + hours, minutes = divmod(minutes, 60.0) + if hours < 1.0: + return f"{int(minutes)} min {seconds:.2f} s" + + return f"{int(hours)} h {int(minutes)} min {seconds:.2f} s" + + +def log_elapsed(label, start_time): + elapsed = time.perf_counter() - start_time + print(f"[TIME] {label}: {format_elapsed(elapsed)}") + return elapsed + + +def build_cad_sample_cache_path(cad_path, output_dir, mesh_deflection, cad_sample_points): + stat = os.stat(cad_path) + cache_key = "|".join([ + os.path.abspath(cad_path), + str(stat.st_size), + str(stat.st_mtime_ns), + f"{mesh_deflection:.12g}", + str(cad_sample_points), + ]) + digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16] + cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad" + # 优先存在 CAD 文件同目录下的 .cad_sample_cache/,跨任务复用; + # 目录不可写时回退到 output_dir/cad_cache/ + cad_dir = os.path.dirname(os.path.abspath(cad_path)) + shared_cache_dir = os.path.join(cad_dir, ".cad_sample_cache") + try: + os.makedirs(shared_cache_dir, exist_ok=True) + # 写权限探测 + test_path = os.path.join(shared_cache_dir, ".write_test") + open(test_path, "w").close() + os.remove(test_path) + cache_dir = shared_cache_dir + except OSError: + cache_dir = os.path.join(output_dir, "cad_cache") + return os.path.join(cache_dir, f"{cad_name}_{digest}.ply") + + +# ============================================================ +# STEP CAD -> 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 三角网格提取失败,请检查 STEP 文件") + + 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 axis_to_index(axis): + axis = axis.lower() + + if axis == "x": + return 0 + if axis == "y": + return 1 + if axis == "z": + return 2 + + raise ValueError("axis 必须是 x / y / z") + + +def crop_end_region_by_ratio(pcd, axis="x", part="front", ratio=0.08): + """ + 按长度方向裁剪前端/后端区域。 + + axis: + 高铁长度方向,一般是 x + + part: + front: 轴最小端 + tail: 轴最大端 + + ratio: + 取前后端占整体长度的比例,例如 0.08 表示取前 8% 和后 8% + """ + + points = np.asarray(pcd.points) + + axis_id = axis_to_index(axis) + + min_bound = points.min(axis=0) + max_bound = points.max(axis=0) + + axis_min = min_bound[axis_id] + axis_max = max_bound[axis_id] + axis_len = axis_max - axis_min + + if part == "front": + lower = axis_min + upper = axis_min + axis_len * ratio + elif part == "tail": + lower = axis_max - axis_len * ratio + upper = axis_max + else: + raise ValueError("part 必须是 front 或 tail") + + crop_min = min_bound.copy() + crop_max = max_bound.copy() + + crop_min[axis_id] = lower + crop_max[axis_id] = upper + + bbox = o3d.geometry.AxisAlignedBoundingBox( + min_bound=crop_min, + max_bound=crop_max + ) + + cropped = pcd.crop(bbox) + + print(f"[OK] Crop {part}") + print(" range:", lower, upper) + print(" points:", np.asarray(cropped.points).shape) + + return cropped + + +def sample_farthest_points_simple(pcd, num_points=5000, rng=None): + """ + 为了避免百万点太多,这里随机采样少量点。 + 如果点数不足,则直接返回原点云。 + """ + + points = np.asarray(pcd.points) + n = points.shape[0] + + if n <= num_points: + return pcd + + if rng is None: + rng = np.random.default_rng() + + indices = rng.choice(n, size=num_points, replace=False) + + sampled = pcd.select_by_index(indices.tolist()) + + return sampled + + +def compute_centroid(pcd): + points = np.asarray(pcd.points) + + if points.shape[0] == 0: + raise RuntimeError("点云为空,无法计算质心") + + return points.mean(axis=0) + + +def compute_alignment_error_stats(source_pcd, cad_pcd, sample_points=100000, rng=None): + if sample_points is not None and sample_points > 0: + eval_pcd = sample_farthest_points_simple( + source_pcd, + num_points=sample_points, + rng=rng + ) + else: + eval_pcd = source_pcd + + distances = np.asarray( + eval_pcd.compute_point_cloud_distance(cad_pcd), + dtype=np.float64 + ) + + if distances.shape[0] == 0: + raise RuntimeError("No points available for alignment error statistics") + + squared = distances * distances + + return { + "sample_count": int(distances.shape[0]), + "mean": float(np.mean(distances)), + "rmse": float(np.sqrt(np.mean(squared))), + "median": float(np.median(distances)), + "p95": float(np.percentile(distances, 95)), + "p99": float(np.percentile(distances, 99)), + "max": float(np.max(distances)), + } + + +def print_alignment_error_stats(stats): + print("\n========== Alignment Error ==========") + print("sample count:", stats["sample_count"]) + print("mean: ", stats["mean"]) + print("rmse: ", stats["rmse"]) + print("median: ", stats["median"]) + print("p95: ", stats["p95"]) + print("p99: ", stats["p99"]) + print("max: ", stats["max"]) + + +def save_alignment_error_stats(path, stats): + with open(path, "w", encoding="utf-8") as f: + for key in ["sample_count", "mean", "rmse", "median", "p95", "p99", "max"]: + f.write(f"{key}: {stats[key]}\n") + + +def write_point_cloud_float32_xyz(path, pcd, chunk_size=1_000_000): + points = np.asarray(pcd.points) + if points.shape[0] == 0: + raise RuntimeError(f"Cannot write empty point cloud: {path}") + + os.makedirs(os.path.dirname(path), exist_ok=True) + + header = ( + "ply\n" + "format binary_little_endian 1.0\n" + f"element vertex {points.shape[0]}\n" + "property float x\n" + "property float y\n" + "property float z\n" + "end_header\n" + ) + + with open(path, "wb") as f: + f.write(header.encode("ascii")) + for start in range(0, points.shape[0], chunk_size): + end = min(start + chunk_size, points.shape[0]) + f.write(np.asarray(points[start:end], dtype=" 0: + source_icp = sample_farthest_points_simple( + source_pcd, + num_points=sample_points, + rng=rng + ) + else: + source_icp = o3d.geometry.PointCloud(source_pcd) + + source_icp.transform(initial_transform) + target_icp = cad_pcd + + if voxel_size is not None and voxel_size > 0: + source_icp = source_icp.voxel_down_sample(voxel_size=voxel_size) + target_icp = cad_pcd.voxel_down_sample(voxel_size=voxel_size) + + source_count = len(source_icp.points) + target_count = len(target_icp.points) + if source_count == 0 or target_count == 0: + raise RuntimeError("ICP point cloud is empty after sampling/downsampling") + + if max_correspondence_distance is None or max_correspondence_distance <= 0: + initial_distances = np.asarray( + source_icp.compute_point_cloud_distance(target_icp), + dtype=np.float64 + ) + p95 = float(np.percentile(initial_distances, 95)) + max_correspondence_distance = max(p95 * 2.0, 1e-6) + print("[INFO] ICP auto max correspondence distance:", max_correspondence_distance) + + result = o3d.pipelines.registration.registration_icp( + source_icp, + target_icp, + max_correspondence_distance, + np.eye(4, dtype=np.float64), + o3d.pipelines.registration.TransformationEstimationPointToPoint(), + o3d.pipelines.registration.ICPConvergenceCriteria( + max_iteration=max_iteration + ) + ) + + icp_transform = result.transformation + final_transform = icp_transform @ initial_transform + + stats = { + "fitness": float(result.fitness), + "inlier_rmse": float(result.inlier_rmse), + "max_correspondence_distance": float(max_correspondence_distance), + "source_points": int(source_count), + "target_points": int(target_count), + "transform": icp_transform, + } + + return final_transform, stats + + +def print_icp_stats(stats): + print("\n========== ICP Refinement ==========") + print("source points:", stats["source_points"]) + print("target points:", stats["target_points"]) + print("max correspondence distance:", stats["max_correspondence_distance"]) + print("fitness:", stats["fitness"]) + print("inlier rmse:", stats["inlier_rmse"]) + print("ICP delta transform:") + print(stats["transform"]) + + +def visualize_alignment(source_pcd, cad_pcd, title="Translation Alignment Result"): + source_vis = o3d.geometry.PointCloud(source_pcd) + cad_vis = o3d.geometry.PointCloud(cad_pcd) + + source_vis.paint_uniform_color([1.0, 0.0, 0.0]) + cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) + + o3d.visualization.draw_geometries( + [source_vis, cad_vis], + window_name=title + ) + + +# ============================================================ +# 只估计平移的配准 +# ============================================================ + +def resolve_pointcloud_path(pointcloud_path): + if os.path.isdir(pointcloud_path): + preferred = os.path.join(pointcloud_path, "shifted_whole_pointcloud.ply") + if os.path.exists(preferred): + return preferred + + candidates = [ + os.path.join(pointcloud_path, name) + for name in sorted(os.listdir(pointcloud_path)) + if name.lower().endswith(".ply") + ] + if len(candidates) == 1: + return candidates[0] + if not candidates: + raise FileNotFoundError(f"No PLY point cloud found in directory: {pointcloud_path}") + raise ValueError( + "Multiple PLY files found in directory; specify one file explicitly: " + + ", ".join(os.path.basename(path) for path in candidates) + ) + + return pointcloud_path + + +def translate_align_by_front_tail( + cad_path, + pointcloud_path, + output_dir="results", + axis="x", + crop_ratio=0.08, + mesh_deflection=0.1, + cad_sample_points=300000, + local_sample_points=10000, + scale_pointcloud=1.0, + visualize=True, + use_cad_cache=True, + save_debug_samples=False, + random_seed=0, + error_sample_points=100000, + compute_error_stats=True, + save_aligned_float32=True, + use_icp=True, + icp_sample_points=200000, + icp_voxel_size=0.0, + icp_max_correspondence_distance=0.0, + icp_max_iteration=50 +): + total_start = time.perf_counter() + stage_start = total_start + rng = np.random.default_rng(random_seed) if random_seed is not None else np.random.default_rng() + + if random_seed is not None: + try: + o3d.utility.random.seed(int(random_seed)) + except AttributeError: + pass + + os.makedirs(output_dir, exist_ok=True) + + # ------------------------------------------------------------ + # 1. CAD STEP 转 mesh,再采样成 CAD 点云 + # ------------------------------------------------------------ + + cad_pcd = None + cad_pcd_path = os.path.join(output_dir, "cad_sampled.ply") + + if use_cad_cache: + cad_pcd_path = build_cad_sample_cache_path( + cad_path, + output_dir, + mesh_deflection, + cad_sample_points + ) + if os.path.exists(cad_pcd_path): + cad_pcd = o3d.io.read_point_cloud(cad_pcd_path) + if len(cad_pcd.points) > 0: + print("[OK] CAD sample cache loaded:", cad_pcd_path) + else: + print("[WARN] CAD sample cache is empty, rebuilding:", cad_pcd_path) + cad_pcd = None + + if cad_pcd is None: + cad_mesh = step_to_open3d_mesh( + cad_path, + mesh_deflection=mesh_deflection + ) + + print(f"[INFO] Sampling CAD mesh: {cad_sample_points} points") + cad_pcd = cad_mesh.sample_points_uniformly( + number_of_points=cad_sample_points + ) + + os.makedirs(os.path.dirname(cad_pcd_path), exist_ok=True) + o3d.io.write_point_cloud(cad_pcd_path, cad_pcd) + print("[OK] CAD sample saved:", cad_pcd_path) + + log_elapsed("CAD sample load/build", stage_start) + stage_start = time.perf_counter() + + # ------------------------------------------------------------ + # 2. 读取扫描点云 + # ------------------------------------------------------------ + + pointcloud_path = resolve_pointcloud_path(pointcloud_path) + source_pcd = o3d.io.read_point_cloud(pointcloud_path) + + if len(source_pcd.points) == 0: + raise RuntimeError(f"点云读取失败: {pointcloud_path}") + + print("[OK] Source point cloud loaded:", pointcloud_path) + print(" source points:", np.asarray(source_pcd.points).shape) + + if scale_pointcloud != 1.0: + source_pcd.scale(scale_pointcloud, center=(0, 0, 0)) + print("[INFO] Source scaled:", scale_pointcloud) + log_elapsed("source load", stage_start) + stage_start = time.perf_counter() + + # ------------------------------------------------------------ + # 3. 裁剪 CAD 前端 / 后端 + # ------------------------------------------------------------ + + cad_front = crop_end_region_by_ratio( + cad_pcd, + axis=axis, + part="front", + ratio=crop_ratio + ) + + cad_tail = crop_end_region_by_ratio( + cad_pcd, + axis=axis, + part="tail", + ratio=crop_ratio + ) + + # ------------------------------------------------------------ + # 4. 裁剪扫描点云前端 / 后端 + # ------------------------------------------------------------ + + source_front = crop_end_region_by_ratio( + source_pcd, + axis=axis, + part="front", + ratio=crop_ratio + ) + + source_tail = crop_end_region_by_ratio( + source_pcd, + axis=axis, + part="tail", + ratio=crop_ratio + ) + log_elapsed("crop regions", stage_start) + stage_start = time.perf_counter() + + # ------------------------------------------------------------ + # 5. 前后区域分别随机取少量点 + # ------------------------------------------------------------ + + cad_front_sample = sample_farthest_points_simple( + cad_front, + num_points=local_sample_points, + rng=rng + ) + + cad_tail_sample = sample_farthest_points_simple( + cad_tail, + num_points=local_sample_points, + rng=rng + ) + + source_front_sample = sample_farthest_points_simple( + source_front, + num_points=local_sample_points, + rng=rng + ) + + source_tail_sample = sample_farthest_points_simple( + source_tail, + num_points=local_sample_points, + rng=rng + ) + + # 保存局部采样结果,方便检查 + if save_debug_samples: + o3d.io.write_point_cloud(os.path.join(output_dir, "cad_front_sample.ply"), cad_front_sample) + o3d.io.write_point_cloud(os.path.join(output_dir, "cad_tail_sample.ply"), cad_tail_sample) + o3d.io.write_point_cloud(os.path.join(output_dir, "source_front_sample.ply"), source_front_sample) + o3d.io.write_point_cloud(os.path.join(output_dir, "source_tail_sample.ply"), source_tail_sample) + print("[OK] Debug sample point clouds saved") + else: + print("[INFO] Debug sample point clouds skipped") + log_elapsed("local sampling", stage_start) + stage_start = time.perf_counter() + + # ------------------------------------------------------------ + # 6. 计算前端和后端质心 + # ------------------------------------------------------------ + + cad_front_center = compute_centroid(cad_front_sample) + cad_tail_center = compute_centroid(cad_tail_sample) + + source_front_center = compute_centroid(source_front_sample) + source_tail_center = compute_centroid(source_tail_sample) + + print("\n========== Centroids ==========") + print("CAD front center: ", cad_front_center) + print("CAD tail center: ", cad_tail_center) + print("Source front center:", source_front_center) + print("Source tail center: ", source_tail_center) + + # ------------------------------------------------------------ + # 7. 分别根据车头和车尾估计平移 + # ------------------------------------------------------------ + + t_front = cad_front_center - source_front_center + t_tail = cad_tail_center - source_tail_center + + print("\n========== Translation From Front / Tail ==========") + print("t_front:", t_front) + print("t_tail: ", t_tail) + print("front-tail translation difference:", np.linalg.norm(t_front - t_tail)) + + # ------------------------------------------------------------ + # 8. 融合平移 + # ------------------------------------------------------------ + + t_final = 0.5 * t_front + 0.5 * t_tail + + T_final = build_translation_matrix(t_final) + + print("\n========== Initial Translation ==========") + print("t_initial:", t_final) + print("T_initial:") + print(T_final) + log_elapsed("translation estimate", stage_start) + stage_start = time.perf_counter() + + icp_stats = None + if use_icp: + T_final, icp_stats = run_icp_refinement( + source_pcd, + cad_pcd, + T_final, + sample_points=icp_sample_points, + voxel_size=icp_voxel_size, + max_correspondence_distance=icp_max_correspondence_distance, + max_iteration=icp_max_iteration, + rng=rng + ) + print_icp_stats(icp_stats) + print("\n========== Final Rigid Transform ==========") + print("final translation:", T_final[:3, 3]) + print("T_final:") + print(T_final) + else: + print("[INFO] ICP refinement skipped") + + log_elapsed("ICP refinement", stage_start) + stage_start = time.perf_counter() + + # ------------------------------------------------------------ + # 9. 应用平移到整个点云 + # ------------------------------------------------------------ + + aligned_pcd = source_pcd + aligned_pcd.transform(T_final) + + if use_icp: + aligned_path = os.path.join(output_dir, "aligned_pointcloud.ply") + matrix_path = os.path.join(output_dir, "T_rigid_icp.txt") + else: + aligned_path = os.path.join(output_dir, "aligned_pointcloud_translation_only.ply") + matrix_path = os.path.join(output_dir, "T_translation_only.txt") + + if save_aligned_float32: + write_point_cloud_float32_xyz(aligned_path, aligned_pcd) + print("[INFO] Aligned point cloud saved as compact float32 XYZ PLY") + else: + o3d.io.write_point_cloud(aligned_path, aligned_pcd, write_ascii=False) + print("[INFO] Aligned point cloud saved with Open3D default writer") + np.savetxt(matrix_path, T_final, fmt="%.10f") + + print("\n[OK] Saved aligned point cloud:", aligned_path) + print("[OK] Saved transformation matrix:", matrix_path) + log_elapsed("save outputs", stage_start) + stage_start = time.perf_counter() + + error_stats = None + error_stats_path = os.path.join(output_dir, "alignment_error_stats.txt") + + if compute_error_stats: + error_stats = compute_alignment_error_stats( + aligned_pcd, + cad_pcd, + sample_points=error_sample_points, + rng=rng + ) + print_alignment_error_stats(error_stats) + save_alignment_error_stats(error_stats_path, error_stats) + print("[OK] Saved alignment error stats:", error_stats_path) + else: + error_stats_path = None + print("[INFO] Alignment error stats skipped") + + log_elapsed("alignment error stats", stage_start) + + # ------------------------------------------------------------ + # 10. 可视化 + # ------------------------------------------------------------ + + if visualize: + visualize_start = time.perf_counter() + # 为了不卡,显示时降采样 + source_vis = aligned_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) + cad_vis = cad_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) + + source_vis.paint_uniform_color([1.0, 0.0, 0.0]) + cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) + + o3d.visualization.draw_geometries( + [source_vis, cad_vis], + window_name="Translation Only Alignment: red=aligned source, blue=CAD" + ) + log_elapsed("visualization", visualize_start) + + total_elapsed = log_elapsed("total", total_start) + + return { + "translation": T_final[:3, 3], + "initial_translation": t_final, + "T_final": T_final, + "t_front": t_front, + "t_tail": t_tail, + "front_tail_translation_difference": float(np.linalg.norm(t_front - t_tail)), + "icp_stats": icp_stats, + "aligned_path": aligned_path, + "matrix_path": matrix_path, + "error_stats": error_stats, + "error_stats_path": error_stats_path, + "elapsed_seconds": total_elapsed + } + + +# ============================================================ +# 命令行入口 +# ============================================================ + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="高铁点云与 CAD 的仅平移配准:取车头车尾少量点估计整体平移" + ) + + parser.add_argument( + "--cad", + "-c", + default="datasets/CAD/m000004802418tc01_asm.stp", + help="CAD STEP 文件路径" + ) + + parser.add_argument( + "--pointcloud", + "-p", + default="datasets/shift_test_output/", + help="待配准点云 PLY 文件路径" + ) + + parser.add_argument( + "--output_dir", + "-o", + default="results", + help="输出目录" + ) + + parser.add_argument( + "--axis", + default="x", + choices=["x", "y", "z"], + help="高铁长度方向,默认 x" + ) + + parser.add_argument( + "--crop_ratio", + type=float, + default=0.01, + help="车头/车尾各取总长度的比例,默认 0.01" + ) + + parser.add_argument( + "--mesh_deflection", + type=float, + default=0.1, + help="CAD 三角化精度,默认 0.1" + ) + + parser.add_argument( + "--cad_sample_points", + type=int, + default=30000, + help="从 CAD mesh 采样的点数,默认 30000" + ) + + parser.add_argument( + "--local_sample_points", + type=int, + default=1000, + help="车头/车尾各随机采样多少点用于计算质心,默认 1000" + ) + + parser.add_argument( + "--scale_pointcloud", + type=float, + default=1.0, + help="点云缩放。如果点云是米,CAD 是毫米,设为 1000" + ) + + parser.add_argument( + "--no_visualize", + action="store_true", + help="不显示可视化窗口" + ) + + parser.add_argument( + "--no_cad_cache", + action="store_true", + help="Disable CAD sampled point cloud cache" + ) + + parser.add_argument( + "--save_debug_samples", + action="store_true", + help="Save front/tail debug sample point clouds" + ) + + parser.add_argument( + "--random_seed", + type=int, + default=0, + help="Random seed for repeatable sampling; set -1 to disable deterministic sampling" + ) + + parser.add_argument( + "--error_sample_points", + type=int, + default=100000, + help="Sample count for alignment error stats; set 0 to use all aligned source points" + ) + + parser.add_argument( + "--no_error_stats", + action="store_true", + help="Skip point-to-CAD nearest-neighbor error statistics" + ) + + parser.add_argument( + "--save_aligned_float64", + action="store_true", + help="Use Open3D default float64-style aligned PLY output instead of compact float32 XYZ" + ) + + parser.add_argument( + "--no_icp", + action="store_true", + help="Skip rigid ICP refinement and use translation-only alignment" + ) + + parser.add_argument( + "--icp_sample_points", + type=int, + default=200000, + help="Source sample count used for ICP; set 0 to use all source points" + ) + + parser.add_argument( + "--icp_voxel_size", + type=float, + default=0.0, + help="Voxel size for ICP downsampling; 0 disables voxel downsampling" + ) + + parser.add_argument( + "--icp_max_correspondence_distance", + type=float, + default=0.0, + help="ICP max correspondence distance; 0 uses an automatic value from initial distances" + ) + + parser.add_argument( + "--icp_max_iteration", + type=int, + default=50, + help="Maximum ICP iterations" + ) + + args = parser.parse_args() + random_seed = None if args.random_seed < 0 else args.random_seed + + result = translate_align_by_front_tail( + 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, + visualize=not args.no_visualize, + use_cad_cache=not args.no_cad_cache, + save_debug_samples=args.save_debug_samples, + random_seed=random_seed, + error_sample_points=args.error_sample_points, + compute_error_stats=not args.no_error_stats, + save_aligned_float32=not args.save_aligned_float64, + use_icp=not args.no_icp, + icp_sample_points=args.icp_sample_points, + icp_voxel_size=args.icp_voxel_size, + icp_max_correspondence_distance=args.icp_max_correspondence_distance, + icp_max_iteration=args.icp_max_iteration + ) + + print("\n========== Result ==========") + print("initial translation:", result["initial_translation"]) + print("final translation:", result["translation"]) + print("front-tail translation difference:", result["front_tail_translation_difference"]) + if result["icp_stats"] is not None: + print("icp fitness:", result["icp_stats"]["fitness"]) + print("icp inlier rmse:", result["icp_stats"]["inlier_rmse"]) + print("aligned path:", result["aligned_path"]) + print("matrix path:", result["matrix_path"]) + if result["error_stats"] is not None: + print("error stats path:", result["error_stats_path"]) + print("error rmse:", result["error_stats"]["rmse"]) + print("error p95:", result["error_stats"]["p95"]) + print("elapsed:", format_elapsed(result["elapsed_seconds"])) diff --git a/client/src/components/GenericResultView.tsx b/client/src/components/GenericResultView.tsx index bf55d3b..6fbbfa1 100644 --- a/client/src/components/GenericResultView.tsx +++ b/client/src/components/GenericResultView.tsx @@ -15,7 +15,14 @@ interface Measurement { value: number; unit?: string; tolerance?: number; + /** 原始公差文本,例如 +8.5/-3;存在时优先展示 */ + toleranceLabel?: string; + /** 斜率报表基准长度(mm),存在时以 value/baseline mm 形式展示 */ + baseline?: number; status?: 'pass' | 'warning' | 'fail' | 'info'; + /** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */ + passLabel?: string; + failLabel?: string; } interface ErrorStats { @@ -42,7 +49,7 @@ interface PluginResultLike { overlay3d?: Overlay3D; images?: string[]; /** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */ - files?: string[]; + files?: (string | { name?: string; path?: string })[]; extra?: Record; error?: string; } @@ -70,16 +77,16 @@ function statusClass(s?: Measurement['status']) { } } -function statusLabel(s?: Measurement['status']) { - switch (s) { +function statusLabel(m: Measurement) { + switch (m.status) { case 'pass': - return '合格'; + return m.passLabel ?? '合格'; case 'warning': return '警告'; case 'fail': - return '超差'; + return m.failLabel ?? '超差'; default: - return '—'; + return '参考'; } } @@ -101,7 +108,9 @@ export default function GenericResultView({ const errorStats = data.errorStats ?? null; const measurements = data.measurements ?? []; const images = data.images ?? []; - const files = data.files ?? []; + const files = (data.files ?? []).map((f) => + typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f) + ); const overlay = data.overlay3d ?? { kind: 'none' }; return ( @@ -152,14 +161,22 @@ export default function GenericResultView({ {m.name} - {formatValue(m.value)} {m.unit ?? ''} + {m.baseline != null + ? `${formatValue(m.value)}/${m.baseline}mm` + : `${formatValue(m.value)} ${m.unit ?? ''}`} - {m.tolerance != null ? `±${m.tolerance}` : '—'} + {m.toleranceLabel != null + ? m.toleranceLabel + : m.tolerance != null + ? m.baseline != null + ? `${m.tolerance}/${m.baseline}mm` + : `±${m.tolerance}` + : '—'} - {m.status === 'info' ? '参考' : statusLabel(m.status)} + {m.status === 'info' ? '参考' : statusLabel(m)} diff --git a/client/src/components/PointCloudViewer.tsx b/client/src/components/PointCloudViewer.tsx index 15ad7ea..89ae872 100644 --- a/client/src/components/PointCloudViewer.tsx +++ b/client/src/components/PointCloudViewer.tsx @@ -415,7 +415,12 @@ const PointCloudMesh = memo(function PointCloudMesh({ positions[i3 + 1] = point.y; positions[i3 + 2] = point.z; - if (showHeatmap) { + if (point.r !== undefined && point.g !== undefined && point.b !== undefined) { + // 点云自带颜色(如平整度热力图)直接使用,不走 errorToColor + colors[i3] = point.r; + colors[i3 + 1] = point.g; + colors[i3 + 2] = point.b; + } else if (showHeatmap) { const color = errorToColor(point.error ?? 0, minError, maxError); colors[i3] = color.r; colors[i3 + 1] = color.g; @@ -1389,7 +1394,7 @@ const SceneContent = memo(function SceneContent({ /> maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; @@ -1700,6 +1713,9 @@ export default function PointCloudViewer({ const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ); const scale = maxDim > 0 ? 10 / maxDim : 1; + // 保存变换参数,供 ply 叠加层对齐用 + pcTransformRef.current = { cx: centerX, cy: centerY, cz: centerZ, scale }; + for (let i = 0; i < pts.length; i++) { const p = pts[i]; p.x = (p.x - centerX) * scale; @@ -1753,23 +1769,73 @@ export default function PointCloudViewer({ // PLY 叠加层状态(kind=ply) const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null); + // 主点云的坐标变换参数,供 ply 叠加层对齐用 + const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null); - // 加载 PLY 叠加层 + // 加载 PLY 叠加层:走采样接口,避免浏览器加载 15GB 文件 useEffect(() => { if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') { setPlyOverlayData(null); return; } const { taskId, resultId, overlay } = pluginOverlay; - const url = `/api/files/read/analysis/${taskId}/${resultId}/${overlay.file}`; const ac = new AbortController(); (async () => { try { - const resp = await fetch(url, { signal: ac.signal }); + // 用 /sample/batch 接口按 filePath 采样,最多 30 万点 + // 支持多文件(各面板 PLY),合并采样 + const plyFiles: string[] = (overlay as { files?: string[]; file: string }).files + ?? [overlay.file]; + const resp = await fetch('/api/files/sample/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: ac.signal, + body: JSON.stringify({ + filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`), + totalPoints: 300_000, + }), + }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const text = await resp.text(); - const parsed = parsePlyText(text); - if (parsed) setPlyOverlayData(parsed); + const hasColor = resp.headers.get('X-Has-Color') === '1'; + const stride = hasColor ? 7 : 4; + const buf = await resp.arrayBuffer(); + const floats = new Float32Array(buf); + const count = Math.floor(floats.length / stride); + if (count === 0) return; + const positions = new Float32Array(count * 3); + const colors = new Float32Array(count * 3); + if (hasColor) { + // stride=7: [x,y,z,error,r,g,b] — 用批量提取代替逐点循环 + for (let i = 0; i < count; i++) { + const s = i * 7; + positions[i * 3] = floats[s]; + positions[i * 3 + 1] = floats[s + 1]; + positions[i * 3 + 2] = floats[s + 2]; + colors[i * 3] = floats[s + 4]; + colors[i * 3 + 1] = floats[s + 5]; + colors[i * 3 + 2] = floats[s + 6]; + } + } else { + // stride=4: [x,y,z,error] — 只提取 xyz,颜色统一蓝色 + for (let i = 0; i < count; i++) { + const s = i * 4; + positions[i * 3] = floats[s]; + positions[i * 3 + 1] = floats[s + 1]; + positions[i * 3 + 2] = floats[s + 2]; + colors[i * 3] = 0.4; colors[i * 3 + 1] = 0.8; colors[i * 3 + 2] = 1.0; + } + } + // 用主点云的坐标变换对齐叠加层坐标 + const t = pcTransformRef.current; + if (t) { + for (let i = 0; i < count; i++) { + positions[i * 3] = (positions[i * 3] - t.cx) * t.scale; + positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale; + positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale; + } + } + setPlyOverlayData({ positions, colors }); + console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') return; console.warn('[PlyOverlay] load failed:', e); diff --git a/client/src/components/TaskManager.tsx b/client/src/components/TaskManager.tsx index 992e509..879a015 100644 --- a/client/src/components/TaskManager.tsx +++ b/client/src/components/TaskManager.tsx @@ -91,6 +91,13 @@ export default function TaskManager({ onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }), }); + // CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存) + const cadFileIds = taskCadFiles.map((f) => f.id); + const { data: cadSampleCacheMap = {} } = trpc.taskCadFile.hasSampleCache.useQuery( + { cadFileIds }, + { enabled: cadFileIds.length > 0, staleTime: 30_000 }, + ); + // 切换任务时清空文件选中状态 useEffect(() => { setSelectedFileIds(new Set()); @@ -539,6 +546,14 @@ export default function TaskManager({ {f.name} + {cadSampleCacheMap[f.id] && ( + + 已预处理 + + )} {formatSize(f.fileSize)} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 98dcd86..1639524 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -38,7 +38,7 @@ services: container_name: massive-point-cloud-app-dev working_dir: /app network_mode: host - runtime: nvidia # docker-compose V1 用 + # runtime: nvidia # docker-compose V1 用 # gpus: all # docker compose V2 用这行,同时注释上面的 runtime environment: - NODE_ENV=development @@ -75,10 +75,13 @@ services: - ${LOCAL_DATA_DIR:-./data}:/data # 算法源码(通用算法插件按 ALG_ROOT 引用,只读挂载) - ${LOCAL_ALG_DIR:-./alg}:/alg:ro - command: sh -c " - LIBSTDCPP=\$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1) && - [ -n \"\$LIBSTDCPP\" ] && ln -sf \"\$LIBSTDCPP\" /usr/lib/x86_64-linux-gnu/libstdc++.so.6 && - corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0" + command: + - bash + - -c + - | + LIBSTDCPP=$(ls /opt/conda/lib/libstdc++.so.6.0.* 2>/dev/null | sort -V | tail -1) + [ -n "$LIBSTDCPP" ] && ln -sf "$LIBSTDCPP" /usr/lib/x86_64-linux-gnu/libstdc++.so.6 + corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0 depends_on: mysql: condition: service_healthy diff --git a/server/fileRoutes.ts b/server/fileRoutes.ts index 4f7fd0e..e23e27e 100644 --- a/server/fileRoutes.ts +++ b/server/fileRoutes.ts @@ -283,7 +283,10 @@ router.post('/sample/batch', async (req, res) => { ); const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); - const binBuf = Buffer.allocUnsafe(totalSampled * 16); + // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节 + const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined)); + const bytesPerPoint = hasColor ? 28 : 16; + const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint); let offset = 0; for (const pts of sampledPerFile) { for (const p of pts) { @@ -291,7 +294,12 @@ router.post('/sample/batch', async (req, res) => { binBuf.writeFloatLE(p.y, offset + 4); binBuf.writeFloatLE(p.z, offset + 8); binBuf.writeFloatLE(p.error, offset + 12); - offset += 16; + if (hasColor) { + binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16); + binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20); + binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24); + } + offset += bytesPerPoint; } } @@ -304,6 +312,7 @@ router.post('/sample/batch', async (req, res) => { console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('X-Point-Count', totalSampled); + res.setHeader('X-Has-Color', hasColor ? '1' : '0'); res.setHeader('X-File-Stats', JSON.stringify(fileStats)); res.send(binBuf); } catch (err) { @@ -401,7 +410,45 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => { res.sendFile(filePath); }); -// ─── 插件热重载 ────────────────────────────────────────────────────────────── +// ─── 分析结果重新同步 ──────────────────────────────────────────────────────── +// POST /api/files/resync/analysis/:taskId/:resultId +// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。 +router.post('/resync/analysis/:taskId/:resultId', async (req, res) => { + const taskId = parseInt(req.params.taskId, 10); + const resultId = parseInt(req.params.resultId, 10); + if (Number.isNaN(taskId) || Number.isNaN(resultId)) { + return res.status(400).json({ error: 'invalid params' }); + } + const row = await db.getTaskAnalysisResultById(resultId); + if (!row || row.taskId !== taskId) { + return res.status(404).json({ error: 'not found' }); + } + const resultJsonPath = path.join(TASKS_DIR, String(taskId), 'analysis', String(resultId), 'result.json'); + if (!fs.existsSync(resultJsonPath)) { + return res.status(404).json({ error: 'result.json missing' }); + } + try { + const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8')); + const existingRd = (row.resultData as Record) ?? {}; + const mergedRd = { + ...existingRd, + message: result.message ?? existingRd.message, + measurements: result.measurements ?? existingRd.measurements, + errorStats: result.errorStats ?? existingRd.errorStats, + overlay3d: result.overlay3d ?? existingRd.overlay3d, + images: result.images ?? existingRd.images, + files: result.files ?? existingRd.files, + extra: result.extra ?? existingRd.extra, + schemaVersion: result.schemaVersion ?? existingRd.schemaVersion, + }; + await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd }); + res.json({ success: true, overlay3d: result.overlay3d }); + } catch (e) { + res.status(500).json({ error: String(e) }); + } +}); + + // POST /api/files/plugins/reload // 重新从磁盘扫描 manifests/*.json,无需重启服务器。 // 用于更新算法版本后立即生效。 diff --git a/server/plugins/adapters/coupler_panel_run.py b/server/plugins/adapters/coupler_panel_run.py index b66f2dc..fec1c49 100644 --- a/server/plugins/adapters/coupler_panel_run.py +++ b/server/plugins/adapters/coupler_panel_run.py @@ -78,7 +78,7 @@ def main() -> int: z_min, z_max = _f("roiZMin"), _f("roiZMax") # 至少有一对轴的范围被填写时才构建 ROI if any(v is not None for v in (x_min, x_max, y_min, y_max, z_min, z_max)): - from coupler_panel.roi import ROISpec # type: ignore + from coupler_panel.roi_select import ROISpec # type: ignore roi_spec = ROISpec( x_min=x_min if x_min is not None else float("-inf"), @@ -150,20 +150,27 @@ def main() -> int: D_y = float(result.D_y_max_mm) D_z = float(result.D_z_max_mm) + # 斜率形式:offset_mm = baseline * tan(theta),与 Excel 报表一致 + import math as _math + offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right)) + offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref)) + measurements = [ { - "name": "偏转角 θ_right (右向)", - "value": theta_right, - "unit": "deg", - "tolerance": 0.5, - "status": _status_for(theta_right, 0.5), + "name": "车钩面板距纵向中心的右偏转角", + "value": round(offset_right, 6), + "unit": "mm", + "baseline": 700.0, + "tolerance": 1.0, + "status": _status_for(offset_right, 1.0), }, { - "name": "偏转角 θ_ref (参考向)", - "value": theta_ref, - "unit": "deg", - "tolerance": 0.5, - "status": _status_for(theta_ref, 0.5), + "name": "车钩面板距参考面的右偏转角", + "value": round(offset_ref, 6), + "unit": "mm", + "baseline": 375.0, + "tolerance": 0.8, + "status": _status_for(offset_ref, 0.8), }, { "name": "最大 x 偏差 dx_y", diff --git a/server/plugins/adapters/flatness_suite_run.py b/server/plugins/adapters/flatness_suite_run.py index b7dca9e..b5d8e2a 100644 --- a/server/plugins/adapters/flatness_suite_run.py +++ b/server/plugins/adapters/flatness_suite_run.py @@ -7,15 +7,16 @@ v1.5 变更: - pipeline 新增 result_template_path / combined_context_pointcloud_path 参数 约定: - - module=all:plane + curvature(contour 需要 CAD STL,缺失时跳过) + - module=contour:轮廓/平整度,需要 CAD STEP(推荐,算法方基准 3min25s) - module=plane:只跑平面拟合 - - module=curvature:只跑曲率估计 - - module=contour:需要 CAD STL,缺失时报错 + - module=curvature:只跑曲率估计(大点云极慢,慎用) + - module=all:plane + curvature + contour(已从前端选项移除,大点云不可用) """ from __future__ import annotations import copy +import hashlib import os import sys import traceback @@ -39,6 +40,39 @@ def _safe_float(v: object) -> float | None: return None +def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path: + """根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。 + + 优先返回配准阶段已生成的 mesh 缓存(_mesh.ply), + 其次返回 flatness 自己的缓存路径,均放在 CAD 同目录的 .cad_sample_cache/ 下。 + """ + stat = os.stat(cad_path) + cache_key = "|".join([ + os.path.abspath(cad_path), + str(stat.st_size), + str(stat.st_mtime_ns), + f"{mesh_deflection:.12g}", + ]) + digest = hashlib.sha1(cache_key.encode()).hexdigest()[:16] + cad_stem = Path(cad_path).stem + shared_dir = Path(cad_path).parent / ".cad_sample_cache" + + # 配准阶段已缓存的 mesh(与配准采样缓存同目录,命名规则:__mesh.ply) + registration_mesh = shared_dir / f"{cad_stem}_{digest}_mesh.ply" + if registration_mesh.exists(): + return registration_mesh + + # flatness 自己写缓存的目标路径 + try: + shared_dir.mkdir(parents=True, exist_ok=True) + test = shared_dir / ".write_test" + test.touch() + test.unlink() + return shared_dir / f"{cad_stem}_{digest}_mesh.ply" + except OSError: + return Path(cad_path).parent / f"{cad_stem}_{digest}_mesh.ply" + + def _find_alg_dir(alg_root: str) -> Path: """定位平整度算法目录,支持多个历史版本路径,优先使用最新版。""" candidates = [ @@ -99,11 +133,12 @@ def main() -> int: try: alg_root = os.environ.get("ALG_ROOT") or os.environ.get("ALG_ROOT_HOST") or "/alg" - emit_progress(0.05, "定位 point_porj 算法目录") + emit_progress(0.05, "定位算法目录") alg_dir = _find_alg_dir(alg_root) + # ── 判断走单点云模式还是 datasets 批量模式 ────────────────────────────── pc_path_raw = first_pointcloud(payload) - emit_progress(0.10, f"读取点云:{os.path.basename(pc_path_raw)}") + use_batch_mode = bool(config.get("useBatchDatasets", True)) # ── ROI 裁剪(整车点云场景下必须填写,否则算法极慢)────────────────────── def _f(key: str) -> float | None: @@ -147,10 +182,61 @@ def main() -> int: # v1.5 用 o3d.io.read_point_cloud 读取,直接支持二进制 PLY,无需转 ASCII pc_path = pc_path_raw - module = str(config.get("module") or "all") + module = str(config.get("module") or "contour") emit_progress(0.15, f"初始化 MeasurementPipeline(module={module})") - pipeline = _build_pipeline(alg_dir, pc_path, out_dir, config) + pipeline = _build_pipeline(alg_dir, pc_path if not use_batch_mode else str(alg_dir), out_dir, config) + + # ── datasets 批量模式(useBatchDatasets=true 时) ────────────────────── + if use_batch_mode and module == "contour": + emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云") + data_root = alg_dir / "datasets" + # 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定 + context_pc = (inputs.get("contextPointCloudPath") + or pc_path_raw + or None) + save_dir = str(out_dir / "contour_batch") + excel_path = str(out_dir / "contour.xlsx") + res = pipeline.run_contour_batch( + all_tasks=True, + data_root=str(data_root), + pointcloud_kind="noise", + point_chunk_size=8192, + triangle_chunk_size=512, + save_path=save_dir, + excel_output_path=excel_path, + combined_context_pointcloud_path=context_pc, + no_visualize=True, + ) + emit_progress(0.95, "整理批量结果") + batch_stats = res.get("batch_stats") or {} + measurements: list[dict] = [] + for task_id, task_res in (res.get("results") or {}).items(): + s = task_res.get("distance_stats") or {} + if s: + measurements.append({ + "name": f"任务{task_id} 轮廓偏差均值", + "value": round(float(s.get("mean", 0)), 4), + "unit": "mm", "status": "info", + }) + # 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色) + panel_plys = sorted([ + f"contour_batch/{p.name}" + for p in (out_dir / "contour_batch").glob("*_contour.ply") + if p.name != "combined_contour.ply" + ]) + if panel_plys: + overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys} + else: + overlay3d = {"kind": "none"} + write_result(out_dir, { + "status": "completed", + "message": f"批量完成 {len(res.get('results', {}))} 个面板测量", + "measurements": measurements, + "overlay3d": overlay3d, + "extra": {"module": "contour_batch", "excelPath": "contour.xlsx"}, + }) + return 0 measurements: list[dict] = [] extra: dict[str, Any] = {"module": module} @@ -226,7 +312,7 @@ def main() -> int: # ── contour ──────────────────────────────────────────────────────────── if module in ("all", "contour"): - emit_progress(0.75, "轮廓-平整度(需 CAD STL)") + emit_progress(0.75, "轮廓-平整度(需 CAD STEP)") cad_path = inputs.get("cadPath") or None mesh_path = inputs.get("meshPath") or None if not cad_path and not mesh_path: @@ -235,14 +321,54 @@ def main() -> int: try: save_path = str(out_dir / "contour.ply") excel_path = str(out_dir / "contour.xlsx") + + # STEP mesh 缓存:避免每次重新三角化 442 MB+ 整车 STEP + cached_mesh_ply: str | None = None + if cad_path and not mesh_path: + _deflection = float( + _safe_float(config.get("meshDeflection")) or 0.1 + ) + _cache_ply = _mesh_cache_path(cad_path, _deflection) + if _cache_ply.exists(): + emit_progress(0.76, "使用已缓存 CAD mesh,跳过 STEP 三角化") + mesh_path = str(_cache_ply) + cad_path = None + else: + # 先单独做三角化,完成后立即缓存,再跑点距计算 + # 这样即使点距阶段 OOM,下次也不用重新三角化 + emit_progress(0.76, "首次三角化 CAD STEP,结果将缓存供后续复用…") + try: + import open3d as o3d # type: ignore + _orig_cad = cad_path + sys.path.insert(0, str(alg_dir)) + from cad_converter import CADConverter # type: ignore + _converter = CADConverter() + _verts, _faces = _converter.step_to_numpy_mesh( + _orig_cad, mesh_deflection=_deflection + ) + _mesh_o3d = o3d.geometry.TriangleMesh() + _mesh_o3d.vertices = o3d.utility.Vector3dVector(_verts) + _mesh_o3d.triangles = o3d.utility.Vector3iVector(_faces.astype(int)) + o3d.io.write_triangle_mesh(str(_cache_ply), _mesh_o3d) + emit_progress(0.80, f"CAD mesh 已缓存:{_cache_ply.name},开始点距计算…") + mesh_path = str(_cache_ply) + cad_path = None + except Exception as _tri_exc: + emit_progress(0.76, f"三角化缓存写入失败({_tri_exc}),继续直接跑…") + + context_pc_path = (inputs.get("contextPointCloudPath") or None) res = pipeline.run_contour( pointcloud_path=pc_path, cad_path=cad_path, mesh_path=mesh_path, + point_chunk_size=8192, + triangle_chunk_size=512, save_path=save_path, excel_output_path=excel_path, + combined_context_pointcloud_path=context_pc_path, no_visualize=True, ) + stats = res.get("distance_stats") for key, label, unit in [ ("mean", "轮廓偏差均值", "mm"), diff --git a/server/plugins/adapters/train_measurement_run.py b/server/plugins/adapters/train_measurement_run.py index 360e45f..411a421 100644 --- a/server/plugins/adapters/train_measurement_run.py +++ b/server/plugins/adapters/train_measurement_run.py @@ -5,14 +5,14 @@ v2.2 变更: - 算法路径从 alg/点云测距v1.1 → alg/train_measurement_system_v2.2 - 新增孔中心拟合(hole_center_fitter)、测量日志、性能日志 - - 内置配置文件改为 config/measurements_0618.yaml - - TRAIN_MEAS_ROOT 指向 train_measurement_system_v2.2/(用于定位 config/) + - 内置配置文件改为 config/measurements.yaml(v3.2) + - TRAIN_MEAS_ROOT 指向 train_measurement_system_v3.2_0629/(用于定位 config/) - 算法内置 _read_binary_ply,直接支持二进制 PLY,无需 ASCII 转换 约定: - 第一个点云路径作为 pointcloud_file 注入到测量配置 YAML 中; - 若用户在 UI 选择「自定义 YAML」并提供 customYaml,则使用之; - 否则使用算法仓库自带的 config/measurements_0618.yaml 作为模板。 + 否则使用算法仓库自带的 config/measurements.yaml(v3)作为模板。 - 所有任务结果合成成 measurements[] 列表,附带可视化 PLY 路径到 extra。 """ @@ -33,6 +33,57 @@ from _plugin_io import ( ) +def _to_float(value): + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return None + + +def _load_excel_measurement_rows(out_dir: Path) -> dict[str, dict]: + """读取算法导出的 Excel 结论,作为前端 measurements 的权威来源。""" + try: + from openpyxl import load_workbook + + excel_files = sorted( + out_dir.glob("measurement_results_*.xlsx"), + key=lambda p: p.stat().st_mtime, + ) + if not excel_files: + return {} + + wb = load_workbook(excel_files[-1], data_only=True, read_only=True) + ws = wb.active + rows: dict[str, dict] = {} + for row in ws.iter_rows(min_row=2, values_only=True): + name = str(row[0] or "").strip() + if not name: + continue + + conclusion = str(row[6] or "").strip() + status = None + if conclusion == "通过": + status = "pass" + elif conclusion in ("不通过", "失败"): + status = "fail" + elif conclusion == "未配置": + status = "info" + + rows[name] = { + "value": _to_float(row[3]), + "toleranceLabel": str(row[2]) if row[2] is not None else None, + "status": status, + } + return rows + except Exception as exc: + print(f"读取 Excel 结论失败,继续使用适配器判定:{exc}", file=sys.stderr) + return {} + + def main() -> int: payload = load_input(sys.argv) inputs = payload.get("inputs") or {} @@ -41,14 +92,13 @@ def main() -> int: out_dir.mkdir(parents=True, exist_ok=True) try: - emit_progress(0.05, "加载 train_measurement v2.2 算法包") + emit_progress(0.05, "加载 train_measurement v3.2 算法包") - # TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system_v2.2/ + # TRAIN_MEAS_ROOT 由 manifest envExtra 注入,指向 train_measurement_system_v3.2_0629/ train_root = Path(os.environ.get("TRAIN_MEAS_ROOT") or "") if not train_root.exists(): - # 回退:从 ALG_ROOT 推导 alg_root = Path(os.environ.get("ALG_ROOT") or "/alg") - train_root = alg_root / "train_measurement_system_v2.2" / "train_measurement_system_v2.2" + train_root = alg_root / "train_measurement_system_v3.2_0629" if not train_root.exists(): raise RuntimeError( @@ -78,8 +128,8 @@ def main() -> int: # 已经是 YAML 格式,直接写盘 yaml_path.write_text(custom_yaml_str, encoding="utf-8") else: - # 使用算法仓库内置的 config/measurements_0618.yaml - yaml_path = train_root / "config" / "measurements_0618.yaml" + # 使用算法仓库内置的 config/measurements.yaml(v3) + yaml_path = train_root / "config" / "measurements.yaml" if not yaml_path.exists(): raise RuntimeError(f"内置配置文件不存在:{yaml_path}") @@ -87,16 +137,25 @@ def main() -> int: # ── 初始化 TaskManager ──────────────────────────────────────────────── tm = TaskManager() - # 先注入点云路径,让 load_config 跳过 YAML 里的 pointcloud_file(开发测试路径) tm.pointcloud_path_override = pc_path - # 把输出目录重定向到 outDir,避免写到只读的 /alg 目录 tm.output_dir_override = str(out_dir) + + emit_progress(0.25, f"读取点云({os.path.basename(pc_path)},可能需要 1~2 分钟)") if not tm.load_config(str(yaml_path)): raise RuntimeError(f"TaskManager 加载配置失败:{yaml_path}") # 注入点云路径(覆盖 YAML 中的 pointcloud_file) tm.config["pointcloud_file"] = pc_path + # 过滤 enabled=False 的任务(v3 TaskManager 不处理 enabled 字段) + import yaml as _yaml_check + _raw = _yaml_check.safe_load(yaml_path.read_text(encoding="utf-8")) + _enabled_names = { + t["name"] for t in (_raw.get("tasks") or _raw.get("measurements") or []) + if t.get("enabled", True) + } + tm.tasks = [t for t in tm.tasks if t.name in _enabled_names] + # 注入可视化输出目录 viz_dir = out_dir / "visualizations" viz_dir.mkdir(parents=True, exist_ok=True) @@ -104,10 +163,37 @@ def main() -> int: if hasattr(task, "visualization_dir"): task.visualization_dir = str(viz_dir) - emit_progress(0.30, "加载点云(已在 load_config 中完成)") + emit_progress(0.40, f"点云已加载,开始执行 {len(tm.tasks)} 个测量任务") - emit_progress(0.40, f"执行 {len(tm.tasks)} 个测量任务") + # ── 从 YAML 构建公差映射(name → {theoretical, upper, lower}) ───────── + import yaml as _yaml + try: + raw_yaml = _yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + # v3 用 'tasks' key,v2 用 'measurements' key,兼容两者 + task_cfgs = raw_yaml.get("tasks") or raw_yaml.get("measurements") or [] + tol_map: dict[str, dict] = {} + for tc in task_cfgs: + tname = tc.get("name") + if not tname: + continue + tol = tc.get("tolerance") + theoretical = tc.get("theoretical_dimension") + if isinstance(tol, dict): + tol_map[tname] = { + "theoretical": theoretical, + "upper": float(tol.get("upper", 0)), + "lower": float(tol.get("lower", 0)), + } + elif tol is not None: + tol_map[tname] = { + "theoretical": theoretical, + "upper": float(tol), + "lower": float(tol), + } + except Exception: + tol_map = {} results = tm.run_all_tasks() + report_rows = _load_excel_measurement_rows(out_dir) emit_progress(0.92, "整理结果") measurements: list[dict] = [] @@ -133,39 +219,41 @@ def main() -> int: value = clean_data.get("mean_distance") if value is None: value = clean_data.get("distance") + fval = float(value) if isinstance(value, (int, float)) else None + + # v3.2 data 里直接有 is_qualified 判定结果(line_to_plane/line_to_line 也支持) + data_pass = clean_data.get("is_qualified") + if data_pass is None: + data_pass = clean_data.get("pass") # 兼容旧字段名 + + tol_cfg = tol_map.get(name) + tolerance = None + meas_status = "info" + if data_pass is not None: + # 算法已判定,直接用 + meas_status = "pass" if data_pass else "fail" + if tol_cfg: + tolerance = tol_cfg.get("upper") + elif tol_cfg and fval is not None: + theoretical = tol_cfg.get("theoretical") + upper = tol_cfg["upper"] + lower = tol_cfg["lower"] + tolerance = upper + if theoretical is not None: + error = fval - float(theoretical) + meas_status = "pass" if (-lower <= error <= upper) else "fail" + measurements.append( { "name": name, - "value": float(value) if isinstance(value, (int, float)) else float("nan"), + "value": fval if fval is not None else float("nan"), "unit": "mm", - "status": "info", + "tolerance": tolerance, + "status": meas_status, + "passLabel": "通过", + "failLabel": "不通过", } ) - std = clean_data.get("std_distance") - if isinstance(std, (int, float)): - measurements.append( - { - "name": f"{name} · 标准差", - "value": float(std), - "unit": "mm", - "status": "info", - } - ) - # v1.1 新增字段:min/max distance - for field_name, label in [ - ("min_distance", "最小距离"), - ("max_distance", "最大距离"), - ]: - v = clean_data.get(field_name) - if isinstance(v, (int, float)): - measurements.append( - { - "name": f"{name} · {label}", - "value": float(v), - "unit": "mm", - "status": "info", - } - ) else: measurements.append( { @@ -176,6 +264,17 @@ def main() -> int: } ) + for measurement in measurements: + report_row = report_rows.get(measurement["name"]) + if not report_row: + continue + if report_row.get("value") is not None: + measurement["value"] = report_row["value"] + if report_row.get("toleranceLabel"): + measurement["toleranceLabel"] = report_row["toleranceLabel"] + if report_row.get("status"): + measurement["status"] = report_row["status"] + success_count = sum(1 for r in results if r.get("status") == "success") payload_out = { "schemaVersion": 1, diff --git a/server/plugins/adapters/underframe_deflection_run.py b/server/plugins/adapters/underframe_deflection_run.py index 32e8886..59a7e59 100644 --- a/server/plugins/adapters/underframe_deflection_run.py +++ b/server/plugins/adapters/underframe_deflection_run.py @@ -1,18 +1,14 @@ """underframe-deflection 算法适配器 (v1.2.1)。 +调用 alg/delivery_v1.2.1/underframe_deflection 的 run_both_beams: + - 输入:整车配准点云 + 左梁 ROI JSON + 右梁 ROI JSON + CAD STP(溯源用) + - 输出:left/ right/ 各一份 report.xlsx + report.json + deflection_curve.csv/png + - measurements 展示左右梁最大挠度及检验结论 + v1.2.1 变更: - - 算法包升级至 delivery_v1.2.1(单梁结果新增项点检验字段:公差/测量/误差/结论) - - report.xlsx 等 Excel 产物通过 result.json 的 files 字段暴露给前端下载 - - PYTHONPATH 指向 delivery_v1.2.1 - -v1.2 变更: - - 新增 single_beam 单梁管线 - -v1.1 变更: - - 入口改为 run_measurement_pipeline(返回 dict,同时落盘 report.json / xlsx / PLY) - - 新增 cad_path(CAD 基准 PLY/STL)和 roi_path(ROI JSON)可选输入 - - 新增 skip_registration / enable_bilateral 配置项 - - 结果从 report dict 中读取,而非 DeflectionResult 对象字段 + - 改用 run_both_beams 双梁并行入口,与算法方命令行一致 + - leftRoiPath / rightRoiPath 参数默认指向 test_data ROI + - skipRegistration 默认 True(点云已配准) """ from __future__ import annotations @@ -35,142 +31,139 @@ from _plugin_io import ( def _status_for(value: float | None, tolerance: float | None) -> str: if value is None or tolerance is None: return "info" - if abs(value) <= tolerance: + abs_val = abs(value) + if abs_val <= tolerance: return "pass" - if abs(value) <= tolerance * 1.5: + if abs_val <= tolerance * 1.5: return "warning" return "fail" -def _safe_float(v: object) -> float | None: - try: - return float(v) # type: ignore[arg-type] - except (TypeError, ValueError): - return None - - def main() -> int: payload = load_input(sys.argv) - inputs = payload.get("inputs") or {} - config = inputs.get("config") or {} + inputs = payload.get("inputs") or {} + config = inputs.get("config") or {} out_dir = Path(payload["outDir"]) out_dir.mkdir(parents=True, exist_ok=True) try: - emit_progress(0.05, "加载 underframe_deflection v1.2.1 算法包") - from underframe_deflection.pipeline import run_measurement_pipeline # type: ignore + emit_progress(0.05, "加载挠度算法包 v1.2.1") - pc_path = first_pointcloud(payload) - emit_progress(0.15, f"读取点云:{os.path.basename(pc_path)}") + # ── 输入文件 ──────────────────────────────────────────────────────── + pointcloud_path = first_pointcloud(payload) + if not pointcloud_path: + write_error_result(out_dir, "未提供点云文件") + return 1 - profile = str(config.get("profile") or "auto") - skip_registration = bool(config.get("skipRegistration") or False) - enable_bilateral = bool(config.get("enableBilateral") if "enableBilateral" in config else True) - - # 可选 CAD 基准(PLY/STL)和 ROI JSON cad_path = inputs.get("cadPath") or None - roi_path = inputs.get("roiPath") or None - emit_progress(0.20, "调用 run_measurement_pipeline") + left_roi = config.get("leftRoiPath") or \ + "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_left_beam.json" + right_roi = config.get("rightRoiPath") or \ + "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_right_beam.json" - # run_measurement_pipeline 内部会打印进度到 stdout, - # 我们在调用前后各报一次进度即可。 - report: dict = run_measurement_pipeline( - input_cloud=pc_path, - out_dir=str(out_dir), - profile=profile, - cad_path=cad_path, - roi_path=roi_path, - skip_registration=skip_registration, - enable_bilateral=enable_bilateral, - skip_excel=False, + for p, label in [(left_roi, "左梁 ROI"), (right_roi, "右梁 ROI")]: + if not os.path.isfile(p): + write_error_result(out_dir, f"{label} 文件不存在:{p}") + return 1 + + tolerance_mm = float(config.get("toleranceMm") or 3.0) + left_tolerance_mm = float(config.get("leftToleranceMm") or tolerance_mm or 7.0) + right_tolerance_mm = float(config.get("rightToleranceMm") or tolerance_mm or 12.0) + skip_registration = bool(config.get("skipRegistration", True)) + enable_bilateral = bool(config.get("enableBilateral", True)) + + emit_progress(0.10, f"开始左梁挠度测量(容差 {left_tolerance_mm} mm)") + from underframe_deflection import run_single_beam_pipeline # type: ignore + + left_out = out_dir / "left" + left_out.mkdir(exist_ok=True) + left_report = run_single_beam_pipeline( + input_cloud = pointcloud_path, + roi_path = left_roi, + out_dir = str(left_out), + side = "left", + tolerance_mm = left_tolerance_mm, + cad_path = cad_path, + enable_registration= not skip_registration, + config = {"enable_bilateral": enable_bilateral}, ) - emit_progress(0.92, "整理结果") + emit_progress(0.55, f"左梁完成,开始右梁挠度测量(容差 {right_tolerance_mm} mm)") + right_out = out_dir / "right" + right_out.mkdir(exist_ok=True) + right_report = run_single_beam_pipeline( + input_cloud = pointcloud_path, + roi_path = right_roi, + out_dir = str(right_out), + side = "right", + tolerance_mm = right_tolerance_mm, + cad_path = cad_path, + enable_registration= not skip_registration, + config = {"enable_bilateral": enable_bilateral}, + ) - # ── 从 report dict 中提取核心测量值 ────────────────────────────────── - deflection = report.get("deflection") or {} - left_sel = (deflection.get("left") or {}).get("selected_measurement") or {} - right_sel = (deflection.get("right") or {}).get("selected_measurement") or {} + result = {"left": left_report, "right": right_report} - left_mm = _safe_float(left_sel.get("signed_deflection_mm")) - right_mm = _safe_float(right_sel.get("signed_deflection_mm")) + emit_progress(0.95, "整理结果") - registration = report.get("registration") or {} - reg_rms = _safe_float(registration.get("rms_mm")) + def _beam_measurements(report: dict, label: str) -> list: + insp = report.get("inspection") or {} + item_name = insp.get("item_name") or "纵向梁到直线的垂直偏差(挠度)" + measured = insp.get("measured_mm") + tol = insp.get("tolerance_mm") + verdict = insp.get("verdict") or "" + return [{ + "name" : f"{label} · {item_name}", + "value" : round(float(measured), 4) if measured is not None else 0.0, + "unit" : "mm", + "tolerance": float(tol) if tol is not None else None, + "status" : "pass" if verdict == "合格" + else "fail" if verdict == "不合格" + else "info", + }] - quality_flags: list[str] = report.get("quality_flags") or [] + measurements = ( + _beam_measurements(left_report, "左梁") + + _beam_measurements(right_report, "右梁") + ) - # 容差:高铁底架挠度通常要求 ≤ 3 mm(可在 manifest inputs 中暴露) - tol = _safe_float(config.get("toleranceMm")) or 3.0 + # 收集产物文件(xlsx/csv/png)- write_result 会自动扫 xlsx, + # 这里额外加 csv/png 让前端也能下载 + extra_files: list[str] = [] + for subdir in ("left", "right"): + sub = out_dir / subdir + if not sub.exists(): + continue + for fname in sorted(sub.iterdir()): + if fname.suffix in (".csv", ".png"): + extra_files.append(str(fname.relative_to(out_dir))) - measurements = [ + # ── 汇总消息 ──────────────────────────────────────────────────────── + left_v = (left_report.get("inspection") or {}).get("verdict") or "—" + right_v = (right_report.get("inspection") or {}).get("verdict") or "—" + left_d = (left_report.get("inspection") or {}).get("measured_mm") + right_d = (right_report.get("inspection") or {}).get("measured_mm") + msg = ( + f"挠度测量完成。" + f"左梁 {left_d:.3f} mm({left_v})," + f"右梁 {right_d:.3f} mm({right_v})。" + if left_d is not None and right_d is not None + else "挠度测量完成。" + ) + + write_result( + out_dir, { - "name": "左梁挠度", - "value": left_mm if left_mm is not None else float("nan"), - "unit": "mm", - "tolerance": tol, - "status": _status_for(left_mm, tol), + "message": msg, + "measurements": measurements, + "files": extra_files, + "extra": {"leftReport": left_report, "rightReport": right_report}, }, - { - "name": "右梁挠度", - "value": right_mm if right_mm is not None else float("nan"), - "unit": "mm", - "tolerance": tol, - "status": _status_for(right_mm, tol), - }, - ] - - if reg_rms is not None: - measurements.append( - { - "name": "配准 RMS", - "value": reg_rms, - "unit": "mm", - "status": "pass" if reg_rms <= 1.0 else "warning" if reg_rms <= 2.0 else "fail", - } - ) - - # ── 3D 叠加层:优先 out_dir/ply/ 子目录,回退到 out_dir 根目录 ────────── - overlay3d: dict = {"kind": "none"} - ply_files: list = [] - ply_dir = out_dir / "ply" - if ply_dir.exists(): - ply_files = sorted(ply_dir.glob("*.ply")) - if not ply_files: - # 算法直接输出到 out_dir 根目录;优先取 full overlay - for name in ("roi_measurement_overlay_full.ply", "roi_cropped_cloud.ply"): - candidate = out_dir / name - if candidate.exists(): - ply_files = [candidate] - break - if not ply_files: - ply_files = sorted(out_dir.glob("*.ply")) - if ply_files: - rel = ply_files[0].relative_to(out_dir) - overlay3d = { - "kind": "ply", - "file": str(rel), - } - - payload_out = { - "schemaVersion": report.get("schema_version", "1.1"), - "status": "completed", - "measurements": measurements, - "overlay3d": overlay3d, - "extra": { - "algorithmVersion": report.get("algorithm_version"), - "qualityFlags": quality_flags, - "registration": registration, - "deflectionFull": deflection, - }, - } - write_result(out_dir, payload_out) - emit_progress(1.0, "完成") + ) return 0 except Exception as exc: - traceback.print_exc() write_error_result( out_dir, f"underframe-deflection 计算失败:{exc}", diff --git a/server/plugins/manifests/coupler-panel.json b/server/plugins/manifests/coupler-panel.json index f7ef4ed..98b083e 100644 --- a/server/plugins/manifests/coupler-panel.json +++ b/server/plugins/manifests/coupler-panel.json @@ -13,14 +13,15 @@ "name": "profile", "label": "调参档位", "type": "select", - "default": "auto", + "default": "train2_coupler", "options": [ + { "value": "train2_coupler", "label": "train2 整车扫描(推荐)" }, { "value": "auto", "label": "自动 (auto)" }, { "value": "default", "label": "默认 (default)" }, { "value": "high_density", "label": "高密度 (high_density)" }, { "value": "low_density", "label": "低密度 (low_density)" } ], - "description": "建议保持 auto,算法会根据点云密度自动选档。" + "description": "train2_coupler:针对 train_2_1T 整车扫描优化,孔半径 11~19mm,含层隔离。auto:根据点云密度自动选档(不适用于整车扫描)。" }, { "name": "rightAxisSign", @@ -34,14 +35,14 @@ "description": "若车体「右」方向为 +y 保持 +1;若为 -y 改为 -1。" }, { - "name": "roiXMin", "label": "ROI X 最小值 (mm)", "type": "number", "default": null, - "description": "车钩面板局部 ROI 裁剪范围(相对于配准后 CAD 坐标系)。整车点云时必填,否则算法处理全部点云会极慢。留空 = 不裁剪。" + "name": "roiXMin", "label": "ROI X 最小值 (mm)", "type": "number", "default": 11526, + "description": "车钩面板局部 ROI 裁剪范围(相对于配准后 CAD 坐标系,train_2 默认值来自 roi_train2_coupler.json)。整车点云时必须保留有效值,否则算法处理全部点云会极慢。" }, - { "name": "roiXMax", "label": "ROI X 最大值 (mm)", "type": "number", "default": null }, - { "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": null }, - { "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": null }, - { "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": null }, - { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null } + { "name": "roiXMax", "label": "ROI X 最大值 (mm)", "type": "number", "default": 11733 }, + { "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": 588 }, + { "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": 1192 }, + { "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": -950 }, + { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": 950 } ], "runtime": { "kind": "process", diff --git a/server/plugins/manifests/flatness-suite.json b/server/plugins/manifests/flatness-suite.json index 441aef8..5b0d03d 100644 --- a/server/plugins/manifests/flatness-suite.json +++ b/server/plugins/manifests/flatness-suite.json @@ -2,23 +2,28 @@ "id": "flatness-suite", "name": "平整度 / 平面度 / 轮廓测量", "shortName": "flatness-suite", - "description": "平整度 / 平面度 / 轮廓 / 曲率 套件(v1.5,来自 alg/平整度、平面度、轮廓测量v1.5)。v1.5 新增多车型 STEP CAD 模板(car_part_v1)与 contour Excel 报表。任意子模块缺依赖时自动跳过并在 message 里说明。", + "description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。", "version": "1.5.0", "schemaVersion": 1, "category": "几何测量", - "requiresPointCloud": true, + "requiresPointCloud": false, "requiresCad": false, "inputs": [ + { + "name": "useBatchDatasets", + "label": "批量模式(用 datasets/Noise_PointCloud 计算,所选点云仅用于可视化展示)", + "type": "boolean", + "default": true + }, { "name": "module", "label": "测量模块", "type": "select", - "default": "all", + "default": "contour", "options": [ - { "value": "all", "label": "全部 (plane + contour + curvature)" }, + { "value": "contour", "label": "轮廓-平整度 (contour,需 CAD STEP)" }, { "value": "plane", "label": "平面拟合 (plane)" }, - { "value": "contour", "label": "轮廓-平整度 (contour,需 STL)" }, - { "value": "curvature", "label": "曲率估计 (curvature)" } + { "value": "curvature", "label": "曲率估计 (curvature,大点云慎用)" } ] }, { @@ -39,7 +44,14 @@ { "name": "roiYMin", "label": "ROI Y 最小值 (mm)", "type": "number", "default": null }, { "name": "roiYMax", "label": "ROI Y 最大值 (mm)", "type": "number", "default": null }, { "name": "roiZMin", "label": "ROI Z 最小值 (mm)", "type": "number", "default": null }, - { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null } + { "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null }, + { + "name": "contextPointCloudPath", + "label": "整车背景点云路径(可视化合并用)", + "type": "string", + "default": null, + "description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。" + } ], "runtime": { "kind": "process", @@ -49,7 +61,8 @@ "{inputJson}" ], "envExtra": { - "ALG_ROOT": "{algRoot}" + "ALG_ROOT": "{algRoot}", + "LD_LIBRARY_PATH": "/lib/x86_64-linux-gnu:/usr/local/nvidia/lib:/usr/local/nvidia/lib64" }, "timeoutMs": 5400000 } diff --git a/server/plugins/manifests/train-measurement.json b/server/plugins/manifests/train-measurement.json index 19e48a4..38c881f 100644 --- a/server/plugins/manifests/train-measurement.json +++ b/server/plugins/manifests/train-measurement.json @@ -2,8 +2,8 @@ "id": "train-measurement", "name": "列车点云测距", "shortName": "train-measurement", - "description": "基于配置 YAML 驱动的列车点云批量测距套件(v2.2)。v2.2 新增孔中心拟合(hole_center_fitter)、测量日志(measurement_logger)、性能日志(performance_logger)。支持「平面到平面 / 点到平面 / 点到点 / 带孔」四类任务,可在 3D 视图中拖拽 ROI 包围盒可视化配置。", - "version": "2.2.0", + "description": "基于配置 YAML 驱动的列车点云批量测距套件(v3.2,2026-06-29)。v3.2 新增 line_to_plane / line_to_line 测量类型,修复原 4 个 disabled 任务(侧门高度、窗口下沿高度、司机室门口宽/高),现全部 16 个任务可用。", + "version": "3.2.0", "schemaVersion": 1, "category": "尺寸测量", "requiresPointCloud": true, @@ -37,8 +37,8 @@ "{inputJson}" ], "envExtra": { - "PYTHONPATH": "{algRoot}/train_measurement_system_v2.2/train_measurement_system_v2.2/src", - "TRAIN_MEAS_ROOT": "{algRoot}/train_measurement_system_v2.2/train_measurement_system_v2.2" + "PYTHONPATH": "{algRoot}/train_measurement_system_v3.2_0629/src", + "TRAIN_MEAS_ROOT": "{algRoot}/train_measurement_system_v3.2_0629" }, "timeoutMs": 5400000 } diff --git a/server/plugins/manifests/underframe-deflection.json b/server/plugins/manifests/underframe-deflection.json index 76aacf3..22b2b3e 100644 --- a/server/plugins/manifests/underframe-deflection.json +++ b/server/plugins/manifests/underframe-deflection.json @@ -2,7 +2,7 @@ "id": "underframe-deflection", "name": "构架挠度测量", "shortName": "underframe-deflection", - "description": "基于点云的高铁底架纵梁挠度测量(v1.2.1,来自 alg/delivery_v1.2.1/underframe_deflection)。支持 CAD 基准配准与 ROI 裁剪,输出左右梁挠度、配准 RMS 及挠度曲线 PLY 叠加层。v1.2.1 新增项点检验字段(公差/测量/误差/结论)与 Excel 报表导出。", + "description": "基于点云的高铁底架纵梁挠度测量(v1.2.1,来自 alg/delivery_v1.2.1/underframe_deflection)。左右梁各一份 Excel 报表(挠度曲线 + 项点检验),支持流式 ROI 裁剪(10 亿点不爆内存)。", "version": "1.2.1", "schemaVersion": 1, "category": "几何测量", @@ -10,33 +10,45 @@ "requiresCad": false, "inputs": [ { - "name": "profile", - "label": "调参档位", - "type": "select", - "default": "auto", - "options": [ - { "value": "auto", "label": "自动 (auto)" }, - { "value": "M", "label": "动车组 (M)" }, - { "value": "TC", "label": "拖车-控制 (TC)" }, - { "value": "TP", "label": "拖车-供电 (TP)" } - ] + "name": "leftRoiPath", + "label": "左梁 ROI JSON 路径", + "type": "string", + "default": "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_left_beam.json", + "description": "左侧纵梁的 AABB 裁剪框 JSON 文件绝对路径(容器内路径)。" }, { - "name": "toleranceMm", - "label": "挠度容差 (mm)", + "name": "rightRoiPath", + "label": "右梁 ROI JSON 路径", + "type": "string", + "default": "/alg/delivery_v1.2.1/underframe_deflection/test_data/roi_right_beam.json", + "description": "右侧纵梁的 AABB 裁剪框 JSON 文件绝对路径(容器内路径)。" + }, + { + "name": "leftToleranceMm", + "label": "左梁挠度容差 (mm)", "type": "number", - "default": 3.0, + "default": 7.0, "min": 0.5, - "max": 10.0, + "max": 20.0, "step": 0.5, - "description": "超出此值标记为 fail,1.5× 以内标记为 warning。" + "description": "左侧纵梁挠度合格/不合格判定容差。" + }, + { + "name": "rightToleranceMm", + "label": "右梁挠度容差 (mm)", + "type": "number", + "default": 12.0, + "min": 0.5, + "max": 20.0, + "step": 0.5, + "description": "右侧纵梁挠度合格/不合格判定容差。" }, { "name": "skipRegistration", "label": "跳过 CAD 配准", "type": "boolean", - "default": false, - "description": "若点云已在 CAD 坐标系中可跳过配准(需提供 CAD 基准时才生效)。" + "default": true, + "description": "点云已在 CAD 坐标系中(整车配准后),跳过 CAD 配准直接测量。" }, { "name": "enableBilateral", diff --git a/server/plugins/manifests/vehicle-registration.json b/server/plugins/manifests/vehicle-registration.json index 674ca7e..66f4d9c 100644 --- a/server/plugins/manifests/vehicle-registration.json +++ b/server/plugins/manifests/vehicle-registration.json @@ -98,7 +98,8 @@ "{inputJson}" ], "envExtra": { - "PYTHONPATH": "{algRoot}/整车配准v1.3" + "PYTHONPATH": "{algRoot}/整车配准v1.3", + "LD_LIBRARY_PATH": "/lib/x86_64-linux-gnu:/usr/local/nvidia/lib:/usr/local/nvidia/lib64" }, "timeoutMs": 1800000 } diff --git a/server/pointcloudSample.ts b/server/pointcloudSample.ts index 19d5b26..2593e7c 100644 --- a/server/pointcloudSample.ts +++ b/server/pointcloudSample.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; -export type SampledPoint = { x: number; y: number; z: number; error: number }; +export type SampledPoint = { x: number; y: number; z: number; error: number; r?: number; g?: number; b?: number }; /** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */ export async function randomSeekSample( @@ -170,15 +170,20 @@ function samplePlyBinary( const bytesPerVertex = properties.reduce((s, p) => s + p.byteSize, 0); - // 找 x, y, z(可能叫 x/y/z 或 X/Y/Z;允许宽松匹配) + // 找 x, y, z,以及 red/green/blue(uchar 0-255) let xOff = -1, yOff = -1, zOff = -1; + let rOff = -1, gOff = -1, bOff = -1; let xType = 'float', yType = 'float', zType = 'float'; + let rType = 'uchar', gType = 'uchar', bType = 'uchar'; let byteOff = 0; for (const p of properties) { const n = p.name.toLowerCase(); if (n === 'x') { xOff = byteOff; xType = p.type; } else if (n === 'y') { yOff = byteOff; yType = p.type; } else if (n === 'z') { zOff = byteOff; zType = p.type; } + else if (n === 'red') { rOff = byteOff; rType = p.type; } + else if (n === 'green') { gOff = byteOff; gType = p.type; } + else if (n === 'blue') { bOff = byteOff; bType = p.type; } byteOff += p.byteSize; } @@ -224,7 +229,13 @@ function samplePlyBinary( const z = readNum(recBuf, zOff, zType); if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue; const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5; - points.push({ x, y, z, error }); + const pt: SampledPoint = { x, y, z, error }; + if (rOff >= 0 && gOff >= 0 && bOff >= 0) { + pt.r = readNum(recBuf, rOff, rType); + pt.g = readNum(recBuf, gOff, gType); + pt.b = readNum(recBuf, bOff, bType); + } + points.push(pt); } } finally { fs.closeSync(fd); @@ -277,12 +288,25 @@ function samplePlyAscii( const line = (nl2 === -1 ? afterFirst : afterFirst.slice(0, nl2)).trim(); if (!line) continue; const parts = line.split(/\s+/); - const x = parseFloat(parts[0]); - const y = parseFloat(parts[1]); - const z = parseFloat(parts[2]); + // 按 header properties 顺序找 x/y/z/red/green/blue 的列索引 + let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1; + header.properties.forEach((p, i) => { + const n = p.name.toLowerCase(); + if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i; + else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i; + }); + const x = parseFloat(parts[xi >= 0 ? xi : 0]); + const y = parseFloat(parts[yi >= 0 ? yi : 1]); + const z = parseFloat(parts[zi >= 0 ? zi : 2]); if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue; const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5; - points.push({ x, y, z, error }); + const pt: SampledPoint = { x, y, z, error }; + if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) { + pt.r = parseFloat(parts[ri]); + pt.g = parseFloat(parts[gi]); + pt.b = parseFloat(parts[bi]); + } + points.push(pt); } } finally { fs.closeSync(fd); diff --git a/server/routers.ts b/server/routers.ts index 46afec4..922eabd 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -162,6 +162,25 @@ export const appRouter = router({ await db.deleteTaskCadFile(input.id); return { success: true }; }), + + // 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY) + hasSampleCache: protectedProcedure + .input(z.object({ cadFileIds: z.array(z.number()) })) + .query(async ({ input }) => { + const result: Record = {}; + for (const id of input.cadFileIds) { + const row = await db.getTaskCadFileById(id); + if (!row?.filePath) { result[id] = false; continue; } + const cacheDir = path.join(path.dirname(row.filePath), '.cad_sample_cache'); + try { + const files = fs.readdirSync(cacheDir); + result[id] = files.some(f => f.endsWith('.ply')); + } catch { + result[id] = false; + } + } + return result; + }), }), // ─── 检测任务 ────────────────────────────────────────────────────────────── diff --git a/shared/cadHeatmapMath.ts b/shared/cadHeatmapMath.ts index c1bc7d8..d34ebf4 100644 --- a/shared/cadHeatmapMath.ts +++ b/shared/cadHeatmapMath.ts @@ -7,6 +7,9 @@ export interface HeatmapPoint { y: number; z: number; error?: number; + r?: number; + g?: number; + b?: number; } /** 平滑热力专用:用采样分位数做色标,减弱离群点 */ diff --git a/vite.config.ts b/vite.config.ts index 07cbe9a..aad94d6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,6 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig, type Plugin, type ViteDevServer } from "vite"; -import { vitePluginManusRuntime } from "vite-plugin-manus-runtime"; // ============================================================================= // Manus Debug Collector - Vite Plugin @@ -202,7 +201,6 @@ const plugins = [ react(), tailwindcss(), // jsxLocPlugin(), // 禁用:与 R3F 冲突,会导致 "Cannot set data-loc" 错误 - vitePluginManusRuntime(), vitePluginManusDebugCollector(), vitePluginNoFullReload(), // 禁用自动刷新 ]; @@ -235,6 +233,7 @@ export default defineConfig({ ".manusvm.computer", "localhost", "127.0.0.1", + "npsx.i-llusion.cn", ], fs: { strict: true,