feat: update algorithm integrations and point cloud overlays

This commit is contained in:
2026-06-30 16:46:36 +08:00
parent ddca7e5436
commit 7e2e56a50d
64 changed files with 4356 additions and 2781 deletions

View File

@@ -0,0 +1,25 @@
"""
Train Measurement System
配置驱动的列车点云测量系统
"""
__version__ = "1.1.0"
from .config_loader import ConfigLoader
from .pointcloud_loader import PointCloudLoader
from .roi_extractor import ROIExtractor
from .plane_fitter import PlaneFitter
from .task_manager import TaskManager
from .excel_exporter import ExcelExporter
from .visualizer import Visualizer
__all__ = [
"ConfigLoader",
"PointCloudLoader",
"ROIExtractor",
"PlaneFitter",
"TaskManager",
"ExcelExporter",
"Visualizer",
]

View File

@@ -0,0 +1,82 @@
"""配置加载模块"""
import yaml
from pathlib import Path
from typing import Dict, Any, List
class ConfigLoader:
"""加载和验证YAML配置文件"""
def __init__(self, config_path: str):
"""
Args:
config_path: 配置文件路径
"""
self.config_path = Path(config_path)
self.config = None
def load(self) -> Dict[str, Any]:
"""加载配置文件"""
if not self.config_path.exists():
raise FileNotFoundError(f"配置文件不存在: {self.config_path}")
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
self._validate()
return self.config
def _validate(self):
"""验证配置文件格式"""
if not isinstance(self.config, dict):
raise ValueError("配置文件格式错误:根节点必须是字典")
# 检查必需字段
if 'point_cloud' not in self.config:
raise ValueError("配置文件缺少 'point_cloud' 字段")
if 'tasks' not in self.config:
raise ValueError("配置文件缺少 'tasks' 字段")
if not isinstance(self.config['tasks'], list):
raise ValueError("'tasks' 必须是列表")
# 验证每个任务
for i, task in enumerate(self.config['tasks']):
self._validate_task(task, i)
def _validate_task(self, task: Dict[str, Any], index: int):
"""验证单个任务配置"""
required_fields = ['name', 'type']
for field in required_fields:
if field not in task:
raise ValueError(f"任务 {index} 缺少必需字段: {field}")
# 验证任务类型
valid_types = ['plane_to_plane', 'point_to_plane', 'point_to_point']
if task['type'] not in valid_types:
raise ValueError(f"任务 {index} 类型无效: {task['type']}, 必须是 {valid_types} 之一")
def get_point_cloud_path(self) -> str:
"""获取点云文件路径"""
return self.config['point_cloud']
def get_tasks(self) -> List[Dict[str, Any]]:
"""获取所有任务配置"""
return self.config['tasks']
def get_global_settings(self) -> Dict[str, Any]:
"""获取全局设置"""
return self.config.get('global_settings', {})
def is_visualization_enabled(self) -> bool:
"""是否启用可视化"""
return self.get_global_settings().get('enable_visualization', False)
def get_visualization_dir(self) -> str:
"""获取可视化输出目录"""
return self.get_global_settings().get('visualization_dir', 'visualizations')
def get_results_dir(self) -> str:
"""获取结果输出目录"""
return self.get_global_settings().get('results_dir', 'results')

View File

@@ -0,0 +1,126 @@
"""边缘线ROI提取模块 - 两步法提取边缘线附近的点"""
import numpy as np
from typing import Tuple
class EdgeLineROIExtractor:
"""边缘线ROI提取器 - 两步法"""
@staticmethod
def create_line_bbox(point1: np.ndarray, point2: np.ndarray, margin: float = 5.0) -> Tuple[np.ndarray, np.ndarray]:
"""
根据线的两个端点创建包围盒
原则:如果某维度跨度>10mm就保持,否则该维度±margin
Args:
point1: 线的起点 [x, y, z]
point2: 线的终点 [x, y, z]
margin: 跨度小于10mm的维度的扩展边距(默认5mm)
Returns:
bbox_min, bbox_max: 包围盒的最小和最大坐标
"""
point1 = np.array(point1)
point2 = np.array(point2)
# 计算每个维度的范围
mins = np.minimum(point1, point2)
maxs = np.maximum(point1, point2)
spans = maxs - mins
# 应用规则:跨度>10就保持,否则±margin
bbox_min = np.zeros(3)
bbox_max = np.zeros(3)
for i in range(3):
if spans[i] > 10.0:
# 跨度足够大,保持原样
bbox_min[i] = mins[i]
bbox_max[i] = maxs[i]
else:
# 跨度太小,扩展±margin
center = (mins[i] + maxs[i]) / 2
bbox_min[i] = center - margin
bbox_max[i] = center + margin
return bbox_min, bbox_max
@staticmethod
def extract_two_step_roi(
points: np.ndarray,
line_point1: np.ndarray,
line_point2: np.ndarray,
spatial_index,
distance_threshold: float = 0.5,
bbox_margin: float = 5.0
) -> Tuple[np.ndarray, np.ndarray, dict]:
"""
两步法提取边缘线ROI
步骤1:用包围盒粗筛选
步骤2:用点到线距离精筛选(距离<threshold)
Args:
points: 完整点云 (N, 3)
line_point1: 线的起点 [x, y, z]
line_point2: 线的终点 [x, y, z]
spatial_index: 空间索引对象
distance_threshold: 点到线距离阈值(默认2mm)
bbox_margin: 包围盒扩展边距(默认5mm)
Returns:
fine_points: 精筛选后的点 (M, 3)
fine_indices: 精筛选点在原始点云中的索引
stats: 统计信息字典
"""
line_point1 = np.array(line_point1)
line_point2 = np.array(line_point2)
# 步骤1:创建包围盒并粗筛选
bbox_min, bbox_max = EdgeLineROIExtractor.create_line_bbox(
line_point1, line_point2, margin=bbox_margin
)
if spatial_index is not None:
coarse_indices = spatial_index.query_bbox(bbox_min, bbox_max)
else:
# 回退到线性扫描
mask = np.all((points >= bbox_min) & (points <= bbox_max), axis=1)
coarse_indices = np.where(mask)[0]
coarse_points = points[coarse_indices]
if len(coarse_points) == 0:
return np.array([]).reshape(0, 3), np.array([], dtype=int), {
'coarse_count': 0,
'fine_count': 0,
'bbox_min': bbox_min.tolist(),
'bbox_max': bbox_max.tolist()
}
# 步骤2:计算点到线的距离,精筛选
line_direction = line_point2 - line_point1
line_direction = line_direction / np.linalg.norm(line_direction)
vectors = coarse_points - line_point1
projections = np.dot(vectors, line_direction)[:, np.newaxis] * line_direction
perpendicular_vectors = vectors - projections
distances = np.linalg.norm(perpendicular_vectors, axis=1)
# 筛选距离<threshold的点
fine_mask = distances < distance_threshold
fine_indices_in_coarse = np.where(fine_mask)[0]
fine_indices = coarse_indices[fine_indices_in_coarse]
fine_points = coarse_points[fine_indices_in_coarse]
stats = {
'coarse_count': len(coarse_points),
'fine_count': len(fine_points),
'bbox_min': bbox_min.tolist(),
'bbox_max': bbox_max.tolist(),
'distance_threshold': distance_threshold,
'bbox_margin': bbox_margin
}
return fine_points, fine_indices, stats

View File

@@ -0,0 +1,380 @@
"""Excel结果导出器"""
from pathlib import Path
from typing import List, Dict, Any
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from measurements.base import MeasurementResult
class ExcelExporter:
"""将测量结果导出为Excel文件"""
def __init__(self, output_dir: str):
"""
初始化导出器
Args:
output_dir: 输出目录路径
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
def export(self, results: List[MeasurementResult], filename: str = None, task_configs: List[Dict[str, Any]] = None) -> str:
"""
导出测量结果到Excel
Args:
results: 测量结果列表
filename: 输出文件名(不含扩展名),默认使用时间戳
task_configs: 任务配置列表(用于提取描述和参数)
Returns:
str: 导出的Excel文件路径
"""
if filename is None:
filename = f"measurement_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
output_path = self.output_dir / f"{filename}.xlsx"
# 创建任务名称到配置的映射
task_config_map = {}
if task_configs:
for config in task_configs:
task_config_map[config.get('name')] = config
# 创建工作簿
wb = Workbook()
ws = wb.active
ws.title = "测量结果"
# 写入标题
self._write_header(ws)
# 写入数据
row = 2
for result in results:
task_config = task_config_map.get(result.task_name, {})
row = self._write_result(ws, result, row, task_config)
# 调整列宽
self._adjust_column_widths(ws)
# 保存文件
wb.save(output_path)
return str(output_path)
def _write_header(self, ws):
"""写入表头"""
headers = [
"项点名称",
"理论尺寸 (mm)",
"公差要求",
"测量结果 (mm)",
"精度 (mm)",
"误差范围 (mm)",
"测量结论",
"执行时间 (秒)",
"错误信息"
]
# 样式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
def _format_task_params(self, task_config: Dict[str, Any]) -> str:
"""
格式化任务参数为字符串
Args:
task_config: 任务配置字典
Returns:
str: 格式化后的参数字符串
"""
task_type = task_config.get('type', '')
params_parts = []
if task_type == 'plane_to_plane':
# 平面到平面:显示源ROI和目标ROI
source_roi = task_config.get('source_roi', {})
target_roi = task_config.get('target_roi', {})
if source_roi:
params_parts.append(f"源ROI: min{source_roi.get('bbox_min', [])} max{source_roi.get('bbox_max', [])}")
if target_roi:
params_parts.append(f"目标ROI: min{target_roi.get('bbox_min', [])} max{target_roi.get('bbox_max', [])}")
elif task_type == 'point_to_plane':
mode = task_config.get('mode', '')
if mode == 'exact_points':
# 精确点模式:显示目标点坐标
target_points = task_config.get('target_points', [])
if target_points:
params_parts.append(f"目标点({len(target_points)}个): {target_points}")
plane = task_config.get('plane', {})
if plane:
params_parts.append(f"平面: {plane.get('a', 0)}x+{plane.get('b', 0)}y+{plane.get('c', 0)}z+{plane.get('d', 0)}=0")
elif mode == 'bbox_filtered':
# 包围盒过滤模式:显示包围盒
bboxes = task_config.get('bboxes', [])
if bboxes:
bbox_strs = [f"{bbox.get('name', '')}: min{bbox.get('min', [])} max{bbox.get('max', [])}" for bbox in bboxes]
params_parts.append(f"包围盒: {'; '.join(bbox_strs)}")
filter_info = task_config.get('filter', {})
if filter_info:
params_parts.append(f"过滤: {filter_info.get('type', '')} axis={filter_info.get('axis', '')} top_n={filter_info.get('top_n', '')}")
elif task_type == 'point_to_point':
# 点到点:显示点对
point_pairs = task_config.get('point_pairs', [])
if point_pairs:
pair_strs = [f"{pair.get('name', '')}: {pair.get('point1', [])} <-> {pair.get('point2', [])}" for pair in point_pairs]
params_parts.append(f"点对({len(point_pairs)}对): {'; '.join(pair_strs)}")
return '\n'.join(params_parts) if params_parts else "无"
def _write_result(self, ws, result: MeasurementResult, row: int, task_config: Dict[str, Any]) -> int:
"""
写入单个测量结果
Args:
ws: 工作表
result: 测量结果
row: 起始行号
task_config: 任务配置
Returns:
int: 下一行号
"""
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# 1. 项点名称
ws.cell(row=row, column=1).value = result.task_name
# 检查是否是任务19(宽度差值检查任务)
is_task19 = result.success and result.data and 'diff_upper_middle' in result.data
# 检查是否是点对差值比较任务
is_comparison_task = result.success and result.data and 'comparison' in result.data
# 2. 理论尺寸
if is_task19:
# 任务19的理论尺寸为0
ws.cell(row=row, column=2).value = 0
elif is_comparison_task:
# 点对差值比较任务的理论尺寸为0(期望差值为0)
ws.cell(row=row, column=2).value = 0
else:
theoretical_dim = task_config.get('theoretical_dimension', None)
if theoretical_dim is not None:
ws.cell(row=row, column=2).value = round(theoretical_dim, 2)
else:
ws.cell(row=row, column=2).value = "未配置"
# 3. 公差要求
if is_task19:
# 任务19的公差从数据中获取
tolerance_val = result.data.get('tolerance', 2.0)
tolerance_str = f"±{tolerance_val}"
elif is_comparison_task:
# 点对差值比较任务的公差
comparison = result.data.get('comparison', {})
tolerance_val = comparison.get('tolerance', 0)
tolerance_str = f"+{tolerance_val}/-{tolerance_val}"
else:
tolerance = task_config.get('tolerance', None)
tolerance_str = "未配置"
if tolerance is not None:
if isinstance(tolerance, dict):
# 非对称公差
tolerance_upper = tolerance.get('upper', 0)
tolerance_lower = tolerance.get('lower', 0)
tolerance_str = f"+{tolerance_upper}/{tolerance_lower}"
else:
# 对称公差
tolerance_str = f"±{tolerance}"
ws.cell(row=row, column=3).value = tolerance_str
# 4. 测量结果(均值)
# 5. 精度(标准差)
# 6. 误差范围
# 7. 测量结论
if result.success and result.data:
data = result.data
# 检查是否是宽度差值检查任务(任务19)
if 'diff_upper_middle' in data:
# 任务19:宽度差值检查
max_diff = data.get('max_diff', 0)
tolerance = data.get('tolerance', 2.0)
pass_check = data.get('pass', False)
execution_time = data.get('execution_time', 0)
# 测量结果:最大差值
ws.cell(row=row, column=4).value = round(max_diff, 4)
# 精度:显示三个差值
diff_str = f"上-中:{data.get('diff_upper_middle', 0):.4f}, 中-下:{data.get('diff_middle_lower', 0):.4f}, 上-下:{data.get('diff_upper_lower', 0):.4f}"
ws.cell(row=row, column=5).value = diff_str
# 误差范围:显示三个差值的最小和最大值
all_diffs = [data.get('diff_upper_middle', 0), data.get('diff_middle_lower', 0), data.get('diff_upper_lower', 0)]
min_diff = min(all_diffs)
max_diff_val = max(all_diffs)
diff_range_str = f"[{min_diff:.4f}, {max_diff_val:.4f}]"
ws.cell(row=row, column=6).value = diff_range_str
# 测量结论
conclusion_cell = ws.cell(row=row, column=7)
if pass_check:
conclusion_cell.value = "通过"
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
conclusion_cell.font = Font(color="006100", bold=True)
else:
conclusion_cell.value = "不通过"
conclusion_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
conclusion_cell.font = Font(color="9C0006", bold=True)
# 执行时间
ws.cell(row=row, column=8).value = round(execution_time, 2) if execution_time else 0
elif 'comparison' in data:
# 点对差值比较任务
comparison = data.get('comparison', {})
difference = comparison.get('difference', 0)
tolerance = comparison.get('tolerance', 0)
passed = comparison.get('passed', False)
execution_time = data.get('execution_time', 0)
pair1_name = comparison.get('pair1_name', '点对1')
pair2_name = comparison.get('pair2_name', '点对2')
distance1 = comparison.get('pair1_distance', 0)
distance2 = comparison.get('pair2_distance', 0)
# 测量结果:差值
ws.cell(row=row, column=4).value = round(difference, 4)
# 精度:显示两个距离值之差(简洁格式)
ws.cell(row=row, column=5).value = round(abs(distance1 - distance2), 4)
# 误差范围:只显示数字
ws.cell(row=row, column=6).value = round(difference, 4)
# 测量结论
conclusion_cell = ws.cell(row=row, column=7)
if passed:
conclusion_cell.value = "通过"
conclusion_cell.fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
conclusion_cell.font = Font(color="006100", bold=True)
else:
conclusion_cell.value = "不通过"
conclusion_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
conclusion_cell.font = Font(color="9C0006", bold=True)
# 执行时间
ws.cell(row=row, column=8).value = round(execution_time, 2) if execution_time else 0
else:
# 普通测量任务
mean_distance = data.get('mean_distance', 0)
std_distance = data.get('std_distance', 0)
min_distance = data.get('min_distance', 0)
max_distance = data.get('max_distance', 0)
execution_time = data.get('execution_time', 0)
# 测量结果
ws.cell(row=row, column=4).value = round(mean_distance, 2)
# 精度(标准差)
ws.cell(row=row, column=5).value = round(std_distance, 2)
# 误差范围(相对于理论值)
if theoretical_dim is not None:
error_min = min_distance - theoretical_dim
error_max = max_distance - theoretical_dim
error_range_str = f"[{error_min:.2f}, {error_max:.2f}]"
else:
error_range_str = "无理论值"
ws.cell(row=row, column=6).value = error_range_str
# 测量结论
conclusion = "未配置"
conclusion_cell = ws.cell(row=row, column=7)
if theoretical_dim is not None:
# 判断是否通过:误差范围必须在 [-0.11, +0.1] mm 内
error_min = min_distance - theoretical_dim
error_max = max_distance - theoretical_dim
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)
else:
conclusion = "不通过"
conclusion_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
conclusion_cell.font = Font(color="9C0006", bold=True)
conclusion_cell.value = conclusion
# 8. 执行时间
ws.cell(row=row, column=8).value = round(execution_time, 2) if execution_time else 0
else:
# 失败时显示错误信息
ws.cell(row=row, column=9).value = result.error_message
# 标记为失败
conclusion_cell = ws.cell(row=row, column=7)
conclusion_cell.value = "失败"
conclusion_cell.fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
conclusion_cell.font = Font(color="9C0006", bold=True)
# 应用边框和对齐
for col in range(1, 10):
cell = ws.cell(row=row, column=col)
cell.border = border
cell.alignment = Alignment(horizontal="center", vertical="center")
return row + 1
def _adjust_column_widths(self, ws):
"""自动调整列宽"""
column_widths = {
1: 30, # 项点名称
2: 15, # 理论尺寸
3: 20, # 公差要求
4: 15, # 测量结果
5: 12, # 精度
6: 20, # 误差范围
7: 12, # 测量结论
8: 15, # 执行时间
9: 40 # 错误信息
}
for col, width in column_widths.items():
ws.column_dimensions[get_column_letter(col)].width = width

View File

@@ -0,0 +1,139 @@
"""
圆孔中心拟合工具 - 使用V2高精度算法
适配器层:保持原有API,底层使用hole_detector
"""
import numpy as np
# 导入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)
axis_idx: 圆柱轴向索引 (0=X, 1=Y, 2=Z),None则自动检测
Returns:
dict: {
'center_2d': [x, y], # 投影平面上的圆心(缺少轴向坐标)
'center_3d': [x, y, z], # 3D圆心坐标(轴向坐标为该轴的中点)
'radius': float, # 半径
'axis_idx': int, # 轴向索引
'axis_name': str, # 轴名称 (X/Y/Z)
'other_axes_indices': [int, int], # 非轴向的两个轴索引
'stats': dict # 统计信息(兼容性字段)
}
"""
# 转换axis_idx到轴名称
axis_name = None
if axis_idx is not None:
axis_names = ['X', 'Y', 'Z']
axis_name = axis_names[axis_idx]
# 使用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': center_2d,
'center_3d': center_3d,
'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
def fit_4_holes_reference_plane(hole_points_list: list) -> dict:
"""
使用4个孔的圆心拟合参考平面(垂直于Y轴)
Args:
hole_points_list: 4个孔的点云列表,每个元素是一个点云数组 (N, 3)
顺序:[左上孔, 右上孔, 左下孔, 右下孔]
Returns:
dict: {
'plane_y': float, # 参考平面的Y坐标(垂直于Y轴的平面方程:y = plane_y)
'centers_3d': list, # 4个圆心的3D坐标
'centers_2d': list, # 4个圆心的2D坐标(投影平面)
'center_point': [x, y, z], # 对角线交点(4个圆心的中心)
'radii': list, # 4个孔的半径
}
"""
if len(hole_points_list) != 4:
raise ValueError(f"需要4个孔的点云,但收到了{len(hole_points_list)}个")
centers_3d = []
centers_2d = []
radii = []
print(f" 正在拟合4个孔的圆心...")
for i, points in enumerate(hole_points_list):
result = HoleCenterFitter.fit_hole_center(points)
centers_3d.append(result['center_3d'])
centers_2d.append(result['center_2d'])
radii.append(result['radius'])
print(f" 孔{i+1}: 圆心3D={result['center_3d']}, 半径={result['radius']:.2f}")
# 计算4个圆心的中心点(对角线交点)
centers_array = np.array(centers_3d)
center_point = np.mean(centers_array, axis=0)
# 取Y坐标作为参考平面的位置
plane_y = center_point[1]
print(f" 4孔中心点: {center_point}")
print(f" 参考平面: y = {plane_y:.3f}")
return {
'plane_y': float(plane_y),
'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': radii
}
@staticmethod
def compute_distance_to_y_plane(point_3d, plane_y):
"""
计算点到垂直于Y轴的平面的距离
Args:
point_3d: 3D点坐标 [x, y, z]
plane_y: 平面的Y坐标
Returns:
float: 距离(带符号)
"""
if isinstance(point_3d, np.ndarray):
point_3d = point_3d.tolist()
return abs(point_3d[1] - plane_y)

View File

@@ -0,0 +1,288 @@
"""
高精度孔检测算法 v2.0
输入:点云文件路径或点云数组
输出:圆心3D坐标和半径
精度:圆心误差 < 0.1mm
"""
import numpy as np
import open3d as o3d
from scipy.optimize import least_squares
class HoleDetectorV2:
"""
高精度圆柱孔检测器
特点:
- 自动检测圆柱轴向
- 分层精化拟合
- 圆心精度 < 0.1mm
"""
def __init__(self, axis=None):
"""
初始化检测器
Args:
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
"""
self.axis = axis
self._axis_idx = None
def detect(self, input_data):
"""
检测孔的圆心和半径
Args:
input_data: 点云文件路径(str)或点云数组(Nx3 numpy array)
Returns:
dict: {
'center': [x, y, z], # 3D圆心坐标
'radius': float, # 半径
'axis': str # 轴向 ('X', 'Y', 'Z')
}
"""
# 加载点云
if isinstance(input_data, str):
points = self._load_from_file(input_data)
else:
points = np.asarray(input_data)
# 检测轴向
if self.axis is None:
self._axis_idx = self._auto_detect_axis(points)
else:
self._axis_idx = {'X': 0, 'Y': 1, 'Z': 2}[self.axis.upper()]
# 投影到垂直平面
other_axes = [0, 1, 2]
other_axes.remove(self._axis_idx)
proj_2d = points[:, other_axes]
# 拟合圆(多次RANSAC + 精化)
center_2d, radius = self._fit_circle_robust(proj_2d)
# 构建3D圆心
axis_center = (points[:, self._axis_idx].min() +
points[:, self._axis_idx].max()) / 2
center_3d = np.zeros(3)
center_3d[other_axes[0]] = center_2d[0]
center_3d[other_axes[1]] = center_2d[1]
center_3d[self._axis_idx] = axis_center
axis_names = ['X', 'Y', 'Z']
return {
'center': center_3d.tolist(),
'radius': float(radius),
'axis': axis_names[self._axis_idx]
}
def _load_from_file(self, ply_path):
"""从PLY文件加载点云"""
pcd = o3d.io.read_point_cloud(str(ply_path))
return np.asarray(pcd.points)
def _auto_detect_axis(self, points):
"""自动检测圆柱轴向"""
coef_vars = []
for i in range(3):
other_axes = [0, 1, 2]
other_axes.remove(i)
proj = points[:, other_axes]
center = proj.mean(axis=0)
distances = np.sqrt(((proj - center) ** 2).sum(axis=1))
# 变异系数:圆环小,圆盘大
cv = distances.std() / distances.mean() if distances.mean() > 0 else float('inf')
coef_vars.append(cv)
return np.argmin(coef_vars)
def _fit_circle_algebraic(self, points_2d):
"""代数拟合(快速初始估计)"""
n = len(points_2d)
x = points_2d[:, 0]
y = points_2d[:, 1]
A = np.column_stack([x, y, np.ones(n)])
b = -(x**2 + y**2)
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
a, b_param, c = params
cx = -a / 2
cy = -b_param / 2
r = np.sqrt(cx**2 + cy**2 - c)
return np.array([cx, cy]), r
def _fit_circle_geometric(self, points_2d, initial_guess=None):
"""几何拟合(最小化几何距离)"""
if initial_guess is None:
center, radius = self._fit_circle_algebraic(points_2d)
else:
center, radius = initial_guess
x = points_2d[:, 0]
y = points_2d[:, 1]
def residual_func(params):
cx, cy, r = params
distances = np.sqrt((x - cx)**2 + (y - cy)**2)
return distances - r
result = least_squares(residual_func, [center[0], center[1], radius],
loss='soft_l1', f_scale=1.0)
cx_opt, cy_opt, r_opt = result.x
return np.array([cx_opt, cy_opt]), r_opt
def _fit_circle_ransac(self, points_2d, threshold=None, max_iterations=1000):
"""RANSAC圆拟合"""
n = len(points_2d)
if threshold is None:
center_approx = points_2d.mean(axis=0)
distances = np.sqrt(((points_2d - center_approx)**2).sum(axis=1))
threshold = max(0.5, np.median(distances) * 0.1)
best_inliers = None
best_center = None
best_radius = None
best_n_inliers = 0
for _ in range(max_iterations):
if n < 3:
break
indices = np.random.choice(n, 3, replace=False)
sample = points_2d[indices]
try:
center, radius = self._fit_circle_algebraic(sample)
except:
continue
distances = np.sqrt(((points_2d - center)**2).sum(axis=1))
residuals = np.abs(distances - radius)
inliers = residuals < threshold
n_inliers = np.sum(inliers)
if n_inliers > best_n_inliers:
best_n_inliers = n_inliers
best_inliers = inliers
best_center = center
best_radius = radius
if best_inliers is not None and np.sum(best_inliers) >= 3:
inlier_points = points_2d[best_inliers]
best_center, best_radius = self._fit_circle_geometric(inlier_points)
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
residuals = np.abs(distances - best_radius)
best_inliers = residuals < threshold
else:
best_center, best_radius = self._fit_circle_geometric(points_2d)
distances = np.sqrt(((points_2d - best_center)**2).sum(axis=1))
residuals = np.abs(distances - best_radius)
best_inliers = residuals < threshold
return best_center, best_radius, best_inliers
def _fit_circle_robust(self, points_2d, n_runs=5, refine_threshold=1.0):
"""
鲁棒圆拟合(分层精化)
Args:
points_2d: Nx2点云
n_runs: RANSAC运行次数
refine_threshold: 高质量点阈值(mm)
Returns:
center: [x, y]
radius: float
"""
# 步骤1: 多次RANSAC粗拟合
centers_list = []
radii_list = []
for _ in range(n_runs):
center, radius, _ = self._fit_circle_ransac(points_2d, max_iterations=1000)
centers_list.append(center)
radii_list.append(radius)
centers_array = np.array(centers_list)
radii_array = np.array(radii_list)
center_rough = np.median(centers_array, axis=0)
radius_rough = np.median(radii_array)
# 步骤2: 筛选高质量点
distances = np.sqrt((points_2d[:, 0] - center_rough[0])**2 +
(points_2d[:, 1] - center_rough[1])**2)
deviations = np.abs(distances - radius_rough)
high_quality_mask = deviations < refine_threshold
high_quality_points = points_2d[high_quality_mask]
if len(high_quality_points) < 10:
return center_rough, radius_rough
# 步骤3: 几何拟合精化
centers_fine_list = []
radii_fine_list = []
for _ in range(3):
center_f, radius_f = self._fit_circle_geometric(
high_quality_points,
initial_guess=(center_rough, radius_rough)
)
centers_fine_list.append(center_f)
radii_fine_list.append(radius_f)
centers_fine_array = np.array(centers_fine_list)
radii_fine_array = np.array(radii_fine_list)
center_final = np.median(centers_fine_array, axis=0)
radius_final = np.median(radii_fine_array)
return center_final, radius_final
# 便捷函数
def detect_hole(ply_path, axis=None):
"""
检测孔的圆心和半径(便捷函数)
Args:
ply_path: 点云文件路径或点云数组
axis: 圆柱轴向 ('X', 'Y', 'Z'),None则自动检测
Returns:
dict: {
'center': [x, y, z],
'radius': float,
'axis': str
}
Example:
result = detect_hole('hole.ply')
print(f"圆心: {result['center']}")
print(f"半径: {result['radius']}")
"""
detector = HoleDetectorV2(axis=axis)
return detector.detect(ply_path)
if __name__ == "__main__":
# 示例用法
result = detect_hole("test.ply")
print(f"圆心: {result['center']}")
print(f"半径: {result['radius']:.3f}")
print(f"轴向: {result['axis']}")

View File

@@ -0,0 +1,169 @@
"""直线拟合模块 - 使用RANSAC算法拟合3D直线"""
import numpy as np
from dataclasses import dataclass
from typing import Tuple, Optional
@dataclass
class LineModel:
"""直线模型"""
point: np.ndarray # 直线上的点 (3,)
direction: np.ndarray # 方向向量(单位向量) (3,)
inliers: np.ndarray # 内点索引
inlier_ratio: float # 内点比例
def distance_to_point(self, point: np.ndarray) -> float:
"""计算点到直线的距离"""
vector = point - self.point
projection = np.dot(vector, self.direction) * self.direction
perpendicular = vector - projection
return np.linalg.norm(perpendicular)
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
"""计算多个点到直线的距离"""
vectors = points - self.point
projections = np.dot(vectors, self.direction)[:, np.newaxis] * self.direction
perpendicular_vectors = vectors - projections
distances = np.linalg.norm(perpendicular_vectors, axis=1)
return distances
def sample_points_on_line(self, n_samples: int, point_cloud: np.ndarray) -> np.ndarray:
"""
从拟合的直线对应的真实点云中采样点
Args:
n_samples: 采样点数
point_cloud: 用于拟合的原始点云(内点)
Returns:
采样的点 (n_samples, 3)
"""
if len(point_cloud) == 0:
return np.array([]).reshape(0, 3)
n_samples = min(n_samples, len(point_cloud))
indices = np.random.choice(len(point_cloud), size=n_samples, replace=False)
return point_cloud[indices]
class LineFitter:
"""RANSAC直线拟合器"""
def __init__(
self,
threshold: float = 2.0,
max_iterations: int = 1000,
min_inlier_ratio: float = 0.6
):
"""
Args:
threshold: 内点距离阈值(mm)
max_iterations: 最大迭代次数
min_inlier_ratio: 最小内点比例
"""
self.threshold = threshold
self.max_iterations = max_iterations
self.min_inlier_ratio = min_inlier_ratio
def fit(self, points: np.ndarray) -> Optional[LineModel]:
"""
使用RANSAC拟合3D直线
Args:
points: 点云数组 (N, 3)
Returns:
LineModel或None(拟合失败)
"""
if len(points) < 2:
return None
best_inliers = None
best_point = None
best_direction = None
best_inlier_count = 0
for _ in range(self.max_iterations):
# 随机选择2个点
sample_indices = np.random.choice(len(points), 2, replace=False)
p1, p2 = points[sample_indices]
# 计算直线方向
direction = p2 - p1
if np.linalg.norm(direction) < 1e-6:
continue
direction = direction / np.linalg.norm(direction)
# 计算所有点到直线的距离
vectors = points - p1
projections = np.dot(vectors, direction)[:, np.newaxis] * direction
distances = np.linalg.norm(vectors - projections, axis=1)
# 找到内点
inliers = np.where(distances < self.threshold)[0]
if len(inliers) > best_inlier_count:
best_inlier_count = len(inliers)
best_inliers = inliers
best_point = p1
best_direction = direction
if best_inliers is None or len(best_inliers) < 2:
return None
# 使用所有内点重新拟合直线(最小二乘)
inlier_points = points[best_inliers]
final_point, final_direction = self._fit_line_least_squares(inlier_points)
if final_direction is None:
return None
# 重新计算内点
vectors = points - final_point
projections = np.dot(vectors, final_direction)[:, np.newaxis] * final_direction
distances = np.linalg.norm(vectors - projections, axis=1)
final_inliers = np.where(distances < self.threshold)[0]
inlier_ratio = len(final_inliers) / len(points)
# 检查内点比例
if inlier_ratio < self.min_inlier_ratio:
return None
return LineModel(
point=final_point,
direction=final_direction,
inliers=final_inliers,
inlier_ratio=inlier_ratio
)
def _fit_line_least_squares(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""
使用最小二乘法拟合直线
Returns:
(point, direction): 直线上的点和方向向量
"""
if len(points) < 2:
return None, None
# 计算质心
centroid = np.mean(points, axis=0)
# 中心化点云
centered = points - centroid
# PCA:找主方向
try:
# 协方差矩阵
cov = np.dot(centered.T, centered) / len(points)
# 特征值分解
eigenvalues, eigenvectors = np.linalg.eig(cov)
# 最大特征值对应的特征向量就是直线方向
max_idx = np.argmax(eigenvalues)
direction = eigenvectors[:, max_idx].real
direction = direction / np.linalg.norm(direction)
return centroid, direction
except np.linalg.LinAlgError:
return None, None

View File

@@ -0,0 +1,192 @@
"""测量详细日志记录器 - 记录测量过程的所有详细信息"""
import sys
from pathlib import Path
from datetime import datetime
from typing import Optional
class MeasurementLogger:
"""测量过程详细日志记录器"""
def __init__(self, output_dir: str = "results"):
"""
初始化测量日志记录器
Args:
output_dir: 输出目录路径
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
# 生成日志文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
self.log_file = self.output_dir / f"measurement_detail_{timestamp}.log"
# 打开日志文件
self.file_handle = open(self.log_file, 'w', encoding='utf-8')
# 写入头部
self._write_header()
def _write_header(self):
"""写入日志头部"""
self.log("=" * 80)
self.log("点云测量系统 - 详细测量日志")
self.log("=" * 80)
self.log(f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
self.log("=" * 80)
self.log("")
def log(self, message: str, also_print: bool = True):
"""
记录日志信息
Args:
message: 日志消息
also_print: 是否同时输出到控制台
"""
# 写入文件
self.file_handle.write(message + "\n")
self.file_handle.flush() # 立即刷新到文件
# 输出到控制台
if also_print:
print(message)
def log_task_start(self, task_index: int, total_tasks: int, task_name: str):
"""记录任务开始"""
self.log("")
self.log("=" * 80)
self.log(f"[{task_index}/{total_tasks}] 开始执行任务: {task_name}")
self.log("=" * 80)
def log_roi_extraction(self, roi_name: str, point_count: int, bbox_min: list, bbox_max: list):
"""
记录ROI提取信息
Args:
roi_name: ROI名称(如"源平面ROI"、"目标平面ROI")
point_count: 提取到的点数
bbox_min: ROI最小边界 [x, y, z]
bbox_max: ROI最大边界 [x, y, z]
"""
self.log(f" {roi_name}:")
self.log(f" 边界: X[{bbox_min[0]:.2f}, {bbox_max[0]:.2f}], "
f"Y[{bbox_min[1]:.2f}, {bbox_max[1]:.2f}], "
f"Z[{bbox_min[2]:.2f}, {bbox_max[2]:.2f}]")
self.log(f" 提取点数: {point_count:,} 个点")
def log_ransac_fitting(self, plane_name: str, total_points: int,
inlier_count: int, inlier_ratio: float,
plane_equation: Optional[tuple] = None):
"""
记录RANSAC平面拟合信息
Args:
plane_name: 平面名称(如"源平面"、"目标平面")
total_points: 参与拟合的总点数
inlier_count: 内点数量
inlier_ratio: 内点率(0-1之间的浮点数)
plane_equation: 平面方程 (a, b, c, d),满足 ax + by + cz + d = 0
"""
self.log(f" {plane_name} RANSAC拟合:")
self.log(f" 拟合点数: {total_points:,} 个点")
self.log(f" 内点数量: {inlier_count:,} 个点")
self.log(f" 内点率: {inlier_ratio:.2%}")
if plane_equation:
a, b, c, d = plane_equation
self.log(f" 平面方程: {a:.6f}x + {b:.6f}y + {c:.6f}z + {d:.6f} = 0")
# 计算法向量
import numpy as np
normal = np.array([a, b, c])
norm = np.linalg.norm(normal)
if norm > 0:
normal = normal / norm
self.log(f" 法向量: ({normal[0]:.6f}, {normal[1]:.6f}, {normal[2]:.6f})")
def log_sampling(self, sample_count: int, method: str = "均匀采样"):
"""
记录采样信息
Args:
sample_count: 采样点数
method: 采样方法
"""
self.log(f" 采样策略: {method}")
self.log(f" 采样点数: {sample_count} 个点")
def log_distance_result(self, mean_distance: float, std_distance: float,
min_distance: float, max_distance: float):
"""
记录距离测量结果
Args:
mean_distance: 平均距离
std_distance: 标准差
min_distance: 最小距离
max_distance: 最大距离
"""
self.log(f" 测量结果:")
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")
self.log(f" 距离范围: [{min_distance:.4f}, {max_distance:.4f}] mm")
def log_task_success(self, execution_time: float):
"""记录任务成功"""
self.log(f" 任务状态: ✅ 成功")
self.log(f" 执行时间: {execution_time:.2f} 秒")
def log_task_failure(self, error_message: str, execution_time: float):
"""记录任务失败"""
self.log(f" 任务状态: ❌ 失败")
self.log(f" 错误信息: {error_message}")
self.log(f" 执行时间: {execution_time:.2f} 秒")
def log_circle_fitting(self, circle_name: str, center: tuple, radius: float,
point_count: int):
"""
记录圆拟合信息
Args:
circle_name: 圆的名称
center: 圆心坐标 (x, y, z)
radius: 半径
point_count: 拟合点数
"""
self.log(f" {circle_name}:")
self.log(f" 圆心: ({center[0]:.4f}, {center[1]:.4f}, {center[2]:.4f})")
self.log(f" 半径: {radius:.4f} mm")
self.log(f" 拟合点数: {point_count:,} 个点")
def log_kdtree_optimization(self, original_points: int, filtered_points: int,
percentage: float):
"""
记录KDTree优化信息
Args:
original_points: 原始点数
filtered_points: 筛选后点数
percentage: 保留百分比
"""
self.log(f" KDTree优化:")
self.log(f" 原始点数: {original_points:,} 个点")
self.log(f" 筛选后: {filtered_points:,} 个点 ({percentage:.2f}%)")
def close(self):
"""关闭日志文件"""
self.log("")
self.log("=" * 80)
self.log(f"结束时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
self.log("=" * 80)
if self.file_handle:
self.file_handle.close()
print(f"\n详细测量日志已保存到: {self.log_file}")
def __del__(self):
"""析构函数,确保文件被关闭"""
if hasattr(self, 'file_handle') and self.file_handle:
self.file_handle.close()

View File

@@ -0,0 +1,7 @@
"""measurements包初始化"""
from .base import BaseMeasurement, MeasurementResult
from .plane_distance import PlaneToPlaneDistance
from .point_to_plane import PointToPlaneDistance
from .point_to_point import PointToPointDistance
__all__ = ['BaseMeasurement', 'MeasurementResult', 'PlaneToPlaneDistance', 'PointToPlaneDistance', 'PointToPointDistance']

View File

@@ -0,0 +1,49 @@
"""测量任务基类"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from dataclasses import dataclass
import numpy as np
@dataclass
class MeasurementResult:
"""测量结果数据类"""
task_name: str
task_type: str
success: bool
data: Optional[Dict[str, Any]] = None
error_message: Optional[str] = None
class BaseMeasurement(ABC):
"""测量任务基类"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
"""
初始化测量任务
Args:
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:
"""
执行测量任务
Returns:
MeasurementResult: 测量结果
"""
pass
def is_enabled(self) -> bool:
"""检查任务是否启用"""
return self.config.get('enabled', True)

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
"""平面到平面距离测量"""
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 roi_extractor import ROIExtractor
from plane_fitter import PlaneFitter
from visualizer import Visualizer
class PlaneToPlaneDistance(BaseMeasurement):
"""测量两个平面之间的距离(单向采样)"""
def execute(self) -> MeasurementResult:
"""执行平面到平面距离测量"""
try:
timing_breakdown = {}
# 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'), self.spatial_index)
timing_breakdown['提取源ROI'] = time.time() - start_time
# 记录ROI提取信息到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_roi_extraction(
"源平面ROI",
len(source_points),
source_roi.get('bbox_min'),
source_roi.get('bbox_max')
)
if len(source_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="源平面ROI区域内没有点"
)
# 2. 提取目标平面ROI
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'), self.spatial_index)
timing_breakdown['提取目标ROI'] = time.time() - start_time
# 记录ROI提取信息到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_roi_extraction(
"目标平面ROI",
len(target_points),
target_roi.get('bbox_min'),
target_roi.get('bbox_max')
)
if len(target_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message="目标平面ROI区域内没有点"
)
# 3. 拟合源平面(优化:点过多时降采样)
start_time = time.time()
params = self.config.get('params', {})
# 降采样优化:如果点数过多,先降采样再RANSAC
max_points_for_ransac = 100000 # 10万个点足够拟合平面
source_points_for_fit = source_points
if len(source_points) > max_points_for_ransac:
sample_indices = np.random.choice(len(source_points), max_points_for_ransac, replace=False)
source_points_for_fit = source_points[sample_indices]
print(f" 源平面RANSAC优化: 从 {len(source_points):,} 降采样到 {len(source_points_for_fit):,} 个点")
source_fitter = PlaneFitter(
threshold=params.get('ransac_threshold', 2.0),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
source_plane = source_fitter.fit(source_points_for_fit)
timing_breakdown['源平面RANSAC拟合'] = time.time() - start_time
if source_plane is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"源平面拟合失败(点数: {len(source_points)})"
)
# 记录源平面RANSAC拟合信息到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_ransac_fitting(
"源平面",
len(source_points_for_fit),
len(source_plane.inliers),
source_plane.inlier_ratio,
(source_plane.normal[0], source_plane.normal[1], source_plane.normal[2], source_plane.d)
)
# 4. 拟合目标平面(优化:点过多时降采样)
start_time = time.time()
# 降采样优化:如果点数过多,先降采样再RANSAC
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]
print(f" 目标平面RANSAC优化: 从 {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)})"
)
# 记录目标平面RANSAC拟合信息到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_ransac_fitting(
"目标平面",
len(target_points_for_fit),
len(target_plane.inliers),
target_plane.inlier_ratio,
(target_plane.normal[0], target_plane.normal[1], target_plane.normal[2], target_plane.d)
)
# 5. 从源平面采样点
start_time = time.time()
n_samples = params.get('sample_points', 100)
# 修复: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
# 记录采样信息到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_sampling(len(sampled_points), "从源平面内点均匀采样")
# 6. 计算采样点到目标平面的距离
start_time = time.time()
distances = target_plane.distance_to_points(sampled_points)
timing_breakdown['距离计算'] = time.time() - start_time
# 6.5. 可选:检查不同位置的宽度差值(用于门口类测量)
check_width_variation = params.get('check_width_variation', False)
width_variation_result = None
if check_width_variation:
# 按Y坐标分成3组:上部、中部、下部
y_coords = sampled_points[:, 1]
y_min, y_max = y_coords.min(), y_coords.max()
y_range = y_max - y_min
# 分组:上部(Y最大的1/3)、中部(中间1/3)、下部(Y最小的1/3)
upper_mask = y_coords >= (y_max - y_range / 3)
lower_mask = y_coords <= (y_min + y_range / 3)
middle_mask = ~upper_mask & ~lower_mask
upper_distances = np.abs(distances[upper_mask])
middle_distances = np.abs(distances[middle_mask])
lower_distances = np.abs(distances[lower_mask])
upper_width = np.mean(upper_distances)
middle_width = np.mean(middle_distances)
lower_width = np.mean(lower_distances)
# 计算差值
diff_upper_middle = upper_width - middle_width
diff_middle_lower = middle_width - lower_width
diff_upper_lower = upper_width - lower_width
# 检查公差(默认±2mm)
width_tolerance = params.get('width_variation_tolerance', 2.0)
all_diffs = [diff_upper_middle, diff_middle_lower, diff_upper_lower]
width_variation_pass = all(abs(d) <= width_tolerance for d in all_diffs)
width_variation_result = {
'upper_width': float(upper_width),
'middle_width': float(middle_width),
'lower_width': float(lower_width),
'diff_upper_middle': float(diff_upper_middle),
'diff_middle_lower': float(diff_middle_lower),
'diff_upper_lower': float(diff_upper_lower),
'tolerance': float(width_tolerance),
'pass': width_variation_pass,
'upper_point_count': int(upper_mask.sum()),
'middle_point_count': int(middle_mask.sum()),
'lower_point_count': int(lower_mask.sum())
}
# 记录到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log("")
self.detail_logger.log(" 宽度差值检查:")
self.detail_logger.log(f" 上部宽度 (Y:{y_max-y_range/3:.1f}~{y_max:.1f}): {upper_width:.4f} mm ({upper_mask.sum()}点)")
self.detail_logger.log(f" 中部宽度 (Y:{y_min+y_range/3:.1f}~{y_max-y_range/3:.1f}): {middle_width:.4f} mm ({middle_mask.sum()}点)")
self.detail_logger.log(f" 下部宽度 (Y:{y_min:.1f}~{y_min+y_range/3:.1f}): {lower_width:.4f} mm ({lower_mask.sum()}点)")
self.detail_logger.log("")
self.detail_logger.log(f" 差值 (上-中): {diff_upper_middle:+.4f} mm")
self.detail_logger.log(f" 差值 (中-下): {diff_middle_lower:+.4f} mm")
self.detail_logger.log(f" 差值 (上-下): {diff_upper_lower:+.4f} mm")
self.detail_logger.log(f" 公差要求: ±{width_tolerance:.1f} mm")
status = "✅ 通过" if width_variation_pass else "❌ 超差"
self.detail_logger.log(f" 检查结果: {status}")
# 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. 生成可视化(如果配置了输出目录)
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)
# 只保存ROI点云文件
source_ply_path = vis_dir / f"{self.name}_source_roi.ply"
target_ply_path = vis_dir / f"{self.name}_target_roi.ply"
Visualizer.save_roi_pointcloud(source_points, str(source_ply_path))
Visualizer.save_roi_pointcloud(target_points, str(target_ply_path))
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_plane.inlier_ratio),
'target_inlier_ratio': float(target_plane.inlier_ratio),
'source_plane_normal': source_plane.normal.tolist(),
'target_plane_normal': target_plane.normal.tolist(),
'timing_breakdown': timing_breakdown
}
# 添加宽度差值结果(如果有)
if width_variation_result:
result_data['width_variation'] = width_variation_result
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()}"
)

View File

@@ -0,0 +1,241 @@
"""
带有孔中心检测的平面到平面距离测量
支持 4_hole_center 方式定义参考平面
"""
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 roi_extractor import ROIExtractor
from plane_fitter import PlaneFitter
from visualizer import Visualizer
from hole_center_fitter import HoleCenterFitter
class PlaneToPlaneWithHoles(BaseMeasurement):
"""平面到平面距离测量(支持4孔定位参考平面)"""
def execute(self) -> MeasurementResult:
"""执行测量"""
try:
timing_breakdown = {}
# 获取boxes配置
boxes = self.config.get('boxes', [])
if len(boxes) < 5:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"需要至少5个box(4个孔+至少1个平面),但只有{len(boxes)}个"
)
# 1. 提取前4个孔的点云并拟合参考平面
start_time = time.time()
print(f" 提取4个孔的ROI区域...")
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log(f" 提取4个定位孔...")
hole_points_list = []
hole_results = []
for i in range(4):
box = boxes[i]
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,
task_type=self.task_type,
success=False,
error_message=f"孔 '{box['name']}' ROI区域内没有点"
)
hole_points_list.append(points)
print(f" {box['name']}: {len(points)} 个点")
# 拟合圆心(用于详细日志)
result = HoleCenterFitter.fit_hole_center(points)
hole_results.append(result)
# 拟合4孔参考平面
reference_plane_result = HoleCenterFitter.fit_4_holes_reference_plane(hole_points_list)
plane_y = reference_plane_result['plane_y']
# 记录到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
for i, (box, result) in enumerate(zip(boxes[:4], hole_results)):
self.detail_logger.log_circle_fitting(
f"定位孔 '{box['name']}'",
tuple(result['center_3d']),
result['radius'],
len(hole_points_list[i])
)
self.detail_logger.log("")
self.detail_logger.log(f" 4孔中心点: {reference_plane_result['center_point']}")
self.detail_logger.log(f" 参考平面: Y = {plane_y:.4f} mm")
self.detail_logger.log("")
timing_breakdown['4孔拟合参考平面'] = time.time() - start_time
# 2. 提取并拟合源平面(剩余的boxes)
start_time = time.time()
params = self.config.get('params', {})
max_points_for_ransac = 100000
source_planes = []
source_plane_names = []
print(f" 提取并拟合源平面...")
for i in range(4, len(boxes)):
box = boxes[i]
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,
task_type=self.task_type,
success=False,
error_message=f"源平面 '{box['name']}' ROI区域内没有点"
)
print(f" {box['name']}: {len(points)} 个点")
# 降采样优化
points_for_fit = points
if len(points) > max_points_for_ransac:
sample_indices = np.random.choice(len(points), max_points_for_ransac, replace=False)
points_for_fit = points[sample_indices]
print(f" RANSAC优化: 降采样到 {len(points_for_fit):,} 个点")
# RANSAC拟合平面
fitter = PlaneFitter(
threshold=params.get('ransac_threshold', 0.1),
max_iterations=params.get('ransac_max_iterations', 1000),
min_inlier_ratio=params.get('min_inlier_ratio', 0.6)
)
plane = fitter.fit(points_for_fit)
if plane is None:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"源平面 '{box['name']}' 拟合失败"
)
source_planes.append({
'plane': plane,
'points': points,
'name': box['name']
})
source_plane_names.append(box['name'])
print(f" 拟合成功,内点率: {plane.inlier_ratio:.1%}")
# 记录源平面RANSAC拟合到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_ransac_fitting(
f"源平面 '{box['name']}'",
len(points_for_fit),
len(plane.inliers),
plane.inlier_ratio,
(plane.normal[0], plane.normal[1], plane.normal[2], plane.d)
)
timing_breakdown['源平面RANSAC拟合'] = time.time() - start_time
# 3. 从每个源平面采样点并计算到参考平面的距离
start_time = time.time()
n_samples = params.get('sample_points', 100)
all_distances = []
plane_results = []
print(f" 计算距离...")
for plane_info in source_planes:
plane = plane_info['plane']
points = plane_info['points']
name = plane_info['name']
# 从内点中采样
inlier_points = points[plane.inliers]
sampled_points = ROIExtractor.sample_points(inlier_points, n_samples)
# 计算到参考平面的距离(y方向)
distances = sampled_points[:, 1] - plane_y
mean_dist = np.mean(np.abs(distances))
print(f" {name}: 平均距离 = {mean_dist:.2f} mm")
all_distances.extend(distances)
plane_results.append({
'name': name,
'mean_distance': float(mean_dist),
'distances': distances.tolist()
})
# 记录到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log("")
self.detail_logger.log(f" 平面 '{name}' 到参考平面的距离:")
self.detail_logger.log(f" 采样点数: {len(sampled_points)} 个点")
self.detail_logger.log(f" 平均距离: {mean_dist:.4f} mm")
self.detail_logger.log(f" 距离范围: [{np.min(np.abs(distances)):.4f}, {np.max(np.abs(distances)):.4f}] mm")
timing_breakdown['距离计算'] = time.time() - start_time
# 4. 统计所有距离
all_distances = np.array(all_distances)
mean_distance = np.mean(np.abs(all_distances))
std_distance = np.std(all_distances)
min_distance = np.min(np.abs(all_distances))
max_distance = np.max(np.abs(all_distances))
# 5. 可视化
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)
# 保存孔点云
for i, points in enumerate(hole_points_list):
ply_path = vis_dir / f"{self.name}_hole_{i+1}.ply"
Visualizer.save_roi_pointcloud(points, str(ply_path))
# 保存源平面点云
for plane_info in source_planes:
ply_path = vis_dir / f"{self.name}_{plane_info['name']}.ply"
Visualizer.save_roi_pointcloud(plane_info['points'], str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(mean_distance),
'std_distance': float(std_distance),
'min_distance': float(min_distance),
'max_distance': float(max_distance),
'sample_count': len(all_distances),
'reference_plane_y': float(plane_y),
'reference_plane_centers': reference_plane_result['centers_3d'],
'source_plane_names': source_plane_names,
'plane_results': plane_results,
'timing_breakdown': timing_breakdown
}
)
except Exception as e:
import traceback
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
)

View File

@@ -0,0 +1,255 @@
"""
点到平面距离测量模块
支持两种模式:
1. exact_points: 在点云中查找给定坐标的最近点,计算到平面的距离
2. bbox_filtered: 从包围盒提取点云,筛选特定条件的点,计算到平面的距离
"""
import numpy as np
import time
import open3d as o3d
import sys
from pathlib import Path
from typing import Dict, Any, List, Tuple
from scipy.spatial import KDTree
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from measurements.base import BaseMeasurement, MeasurementResult
from visualizer import Visualizer
class PointToPlaneDistance(BaseMeasurement):
"""点到平面距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config, spatial_index)
self.mode = config.get('mode', 'exact_points')
if self.mode == 'exact_points':
self.target_points = np.array(config['target_points'])
self.search_radius = config.get('search_radius', 10.0)
elif self.mode == 'bbox_filtered':
self.bboxes = config['bboxes']
self.filter_config = config['filter']
else:
raise ValueError(f"Unknown mode: {self.mode}")
# 平面参数 (ax + by + cz + d = 0)
plane_config = config['plane']
self.plane_normal = np.array([
plane_config.get('a', 0),
plane_config.get('b', 0),
plane_config.get('c', 1)
])
self.plane_d = plane_config.get('d', 0)
# 归一化法向量
norm = np.linalg.norm(self.plane_normal)
if norm > 0:
self.plane_normal = self.plane_normal / norm
self.plane_d = self.plane_d / norm
def execute(self) -> MeasurementResult:
"""执行测量"""
try:
if self.mode == 'exact_points':
return self._measure_exact_points()
elif self.mode == 'bbox_filtered':
return self._measure_bbox_filtered()
except Exception as e:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=str(e)
)
def _measure_exact_points(self) -> MeasurementResult:
"""精确点模式:查找最近点并计算距离(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
timing_breakdown = {}
# 优化:对每个目标点单独用Grid索引提取小区域
start_time = time.time()
found_points = []
distances_to_plane = []
for target_point in self.target_points:
# 定义搜索区域(目标点周围的小包围盒)
search_margin = self.search_radius * 2
bbox_min = target_point - search_margin
bbox_max = target_point + search_margin
# 用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")
found_points.append(nearest_point)
# 计算点到平面的距离
distance = self._point_to_plane_distance(nearest_point)
distances_to_plane.append(distance)
timing_breakdown['查找最近点'] = time.time() - start_time
start_time = time.time()
distances_array = np.array(distances_to_plane)
found_points_array = np.array(found_points)
timing_breakdown['数据整理'] = time.time() - start_time
# 保存找到的点为点云文件
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)
ply_path = vis_dir / f"{self.name}_found_points.ply"
Visualizer.save_roi_pointcloud(found_points_array, str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(np.mean(np.abs(distances_array))),
'std_distance': float(np.std(np.abs(distances_array))),
'min_distance': float(np.min(np.abs(distances_array))),
'max_distance': float(np.max(np.abs(distances_array))),
'point_count': len(distances_array),
'mode': 'exact_points',
'target_points': self.target_points.tolist(),
'found_points': [p.tolist() for p in found_points],
'distances': distances_array.tolist(),
'timing_breakdown': timing_breakdown
}
)
def _measure_bbox_filtered(self) -> MeasurementResult:
"""包围盒过滤模式:提取点云,筛选特定点,计算距离"""
# 导入ROIExtractor用于Grid索引加速
from roi_extractor import ROIExtractor
timing_breakdown = {}
all_filtered_points = []
all_distances = []
start_time = time.time()
for bbox_config in self.bboxes:
bbox_min = np.array(bbox_config['min'])
bbox_max = np.array(bbox_config['max'])
# 使用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} 内没有点")
continue
# 应用过滤条件
filtered_points = self._apply_filter(bbox_points)
if len(filtered_points) == 0:
print(f"警告: 包围盒 {bbox_min} 到 {bbox_max} 过滤后没有点")
continue
all_filtered_points.extend(filtered_points)
# 计算每个点到平面的距离
for point in filtered_points:
distance = self._point_to_plane_distance(point)
all_distances.append(distance)
timing_breakdown['提取和过滤点云+距离计算'] = time.time() - start_time
if len(all_distances) == 0:
raise ValueError("没有找到符合条件的点")
start_time = time.time()
distances_array = np.array(all_distances)
filtered_points_array = np.array(all_filtered_points)
timing_breakdown['数据整理'] = time.time() - start_time
# 保存筛选后的点为点云文件
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)
ply_path = vis_dir / f"{self.name}_filtered_points.ply"
Visualizer.save_roi_pointcloud(filtered_points_array, str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(np.mean(np.abs(distances_array))),
'std_distance': float(np.std(np.abs(distances_array))),
'min_distance': float(np.min(np.abs(distances_array))),
'max_distance': float(np.max(np.abs(distances_array))),
'point_count': len(distances_array),
'mode': 'bbox_filtered',
'num_bboxes': len(self.bboxes),
'filtered_points': all_filtered_points,
'distances': distances_array.tolist(),
'timing_breakdown': timing_breakdown
}
)
def _apply_filter(self, points: np.ndarray) -> List[np.ndarray]:
"""应用过滤条件"""
filter_type = self.filter_config['type']
if filter_type == 'max_abs_coordinate':
axis = self.filter_config['axis']
top_n = self.filter_config['top_n']
axis_map = {'x': 0, 'y': 1, 'z': 2}
axis_idx = axis_map[axis.lower()]
# 计算绝对值
abs_values = np.abs(points[:, axis_idx])
# 获取top_n个最大值的索引
if len(points) <= top_n:
return points.tolist()
top_indices = np.argpartition(abs_values, -top_n)[-top_n:]
return points[top_indices].tolist()
else:
raise ValueError(f"Unknown filter type: {filter_type}")
def _point_to_plane_distance(self, point: np.ndarray) -> float:
"""计算点到平面的有符号距离"""
return np.dot(self.plane_normal, point) + self.plane_d

View File

@@ -0,0 +1,136 @@
"""
点到平面距离测量(支持孔中心检测)
支持 4_hole_center + 1_hole_center 方式
"""
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 roi_extractor import ROIExtractor
from visualizer import Visualizer
from hole_center_fitter import HoleCenterFitter
class PointToPlaneWithHoles(BaseMeasurement):
"""点到平面距离测量(支持孔中心定位)"""
def execute(self) -> MeasurementResult:
"""执行测量"""
try:
timing_breakdown = {}
# 获取boxes配置
boxes = self.config.get('boxes', [])
if len(boxes) < 5:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"需要至少5个box(4个定位孔+1个辅助孔),但只有{len(boxes)}个"
)
# 1. 提取前4个孔的点云并拟合参考平面
start_time = time.time()
print(f" 提取4个定位孔的ROI区域...")
hole_points_list = []
for i in range(4):
box = boxes[i]
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,
task_type=self.task_type,
success=False,
error_message=f"定位孔 '{box['name']}' ROI区域内没有点"
)
hole_points_list.append(points)
print(f" {box['name']}: {len(points)} 个点")
# 拟合4孔参考平面
reference_plane_result = HoleCenterFitter.fit_4_holes_reference_plane(hole_points_list)
plane_y = reference_plane_result['plane_y']
timing_breakdown['4孔拟合参考平面'] = time.time() - start_time
# 2. 提取第5个辅助孔的点云并拟合圆心
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'], self.spatial_index)
if len(auxiliary_points) == 0:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"辅助孔 '{auxiliary_box['name']}' ROI区域内没有点"
)
print(f" {auxiliary_box['name']}: {len(auxiliary_points)} 个点")
# 拟合辅助孔圆心
auxiliary_result = HoleCenterFitter.fit_hole_center(auxiliary_points)
auxiliary_center = auxiliary_result['center_3d']
print(f" 辅助孔圆心: {auxiliary_center}")
timing_breakdown['辅助孔拟合'] = time.time() - start_time
# 3. 计算辅助孔圆心到参考平面的距离
start_time = time.time()
distance = HoleCenterFitter.compute_distance_to_y_plane(
np.array(auxiliary_center),
plane_y
)
timing_breakdown['距离计算'] = time.time() - start_time
print(f" 辅助孔圆心到参考平面的距离: {abs(distance):.2f} mm")
# 4. 可视化
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)
# 保存定位孔点云
for i, points in enumerate(hole_points_list):
ply_path = vis_dir / f"{self.name}_locating_hole_{i+1}.ply"
Visualizer.save_roi_pointcloud(points, str(ply_path))
# 保存辅助孔点云
ply_path = vis_dir / f"{self.name}_auxiliary_hole.ply"
Visualizer.save_roi_pointcloud(auxiliary_points, str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(abs(distance)),
'std_distance': 0.0, # 单点测量,无标准差
'min_distance': float(abs(distance)),
'max_distance': float(abs(distance)),
'point_count': 1,
'distance': float(distance), # 带符号的距离
'reference_plane_y': float(plane_y),
'reference_plane_centers': reference_plane_result['centers_3d'],
'auxiliary_center': auxiliary_center,
'auxiliary_radius': float(auxiliary_result['radius']),
'timing_breakdown': timing_breakdown
}
)
except Exception as e:
import traceback
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
)

View File

@@ -0,0 +1,354 @@
"""
点到点距离测量模块
用于测量点云中两个指定点之间的距离
"""
import numpy as np
import time
import sys
from pathlib import Path
from typing import Dict, Any, List, Tuple
from scipy.spatial import KDTree
# 添加父目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from measurements.base import BaseMeasurement, MeasurementResult
from visualizer import Visualizer
class PointToPointDistance(BaseMeasurement):
"""点到点距离测量"""
def __init__(self, name: str, point_cloud: np.ndarray, config: Dict[str, Any], spatial_index=None):
super().__init__(name, point_cloud, config, spatial_index)
# 检查测量模式
self.mode = config.get('mode', 'exact_points')
if self.mode == 'exact_points':
# 点对列表,每对包含两个点的坐标
self.point_pairs = config['point_pairs']
self.search_radius = config.get('search_radius', 10.0)
elif self.mode == 'bbox_filtered':
# bbox过滤模式
self.bboxes = config.get('bboxes', [])
self.filter_config = config.get('filter', {})
def execute(self) -> MeasurementResult:
"""执行测量"""
if self.mode == 'bbox_filtered':
return self._execute_bbox_filtered()
else:
return self._execute_exact_points()
def _execute_bbox_filtered(self) -> MeasurementResult:
"""执行bbox过滤模式的测量"""
try:
from roi_extractor import ROIExtractor
timing_breakdown = {}
# 1. 从每个bbox提取点云
start_time = time.time()
bbox_points_list = []
for bbox in self.bboxes:
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,
task_type=self.task_type,
success=False,
error_message=f"bbox '{bbox['name']}' 区域内没有点"
)
bbox_points_list.append({
'name': bbox['name'],
'points': points
})
print(f" {bbox['name']}: {len(points)} 个点")
timing_breakdown['提取bbox点云'] = time.time() - start_time
# 2. 根据filter配置过滤点
start_time = time.time()
filter_type = self.filter_config.get('type')
axis = self.filter_config.get('axis', 'z')
top_n = self.filter_config.get('top_n', 10)
axis_idx = {'x': 0, 'y': 1, 'z': 2}[axis.lower()]
filtered_points_list = []
if filter_type == 'max_abs_coordinate':
for bbox_info in bbox_points_list:
points = bbox_info['points']
# 按绝对值排序
abs_values = np.abs(points[:, axis_idx])
top_indices = np.argsort(abs_values)[-top_n:]
top_points = points[top_indices]
filtered_points_list.append({
'name': bbox_info['name'],
'points': top_points,
'values': points[top_indices, axis_idx]
})
print(f" {bbox_info['name']}: 选取 {axis} 绝对值最大的 {len(top_points)} 个点")
print(f" {axis} 范围: [{top_points[:, axis_idx].min():.2f}, {top_points[:, axis_idx].max():.2f}]")
timing_breakdown['过滤点'] = time.time() - start_time
# 3. 计算距离(两组点的坐标差)
start_time = time.time()
if len(filtered_points_list) != 2:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"需要2个bbox,但有{len(filtered_points_list)}个"
)
# 计算两组点在指定轴上的平均坐标
group1_values = filtered_points_list[0]['values']
group2_values = filtered_points_list[1]['values']
mean1 = np.mean(group1_values)
mean2 = np.mean(group2_values)
distance = abs(mean2 - mean1)
print(f" 组1平均{axis}值: {mean1:.2f}")
print(f" 组2平均{axis}值: {mean2:.2f}")
print(f" 距离: {distance:.2f} mm")
timing_breakdown['距离计算'] = time.time() - start_time
# 4. 可视化
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)
for filtered_info in filtered_points_list:
ply_path = vis_dir / f"{self.name}_{filtered_info['name']}_filtered.ply"
Visualizer.save_roi_pointcloud(filtered_info['points'], str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(distance),
'std_distance': 0.0,
'min_distance': float(distance),
'max_distance': float(distance),
'point_count': top_n * 2,
'group1_mean': float(mean1),
'group2_mean': float(mean2),
'timing_breakdown': timing_breakdown
}
)
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()}"
)
def _execute_exact_points(self) -> MeasurementResult:
"""执行精确点模式的测量(优化版:使用Grid索引)"""
from roi_extractor import ROIExtractor
try:
timing_breakdown = {}
# 优化:对每个目标点用Grid索引提取小区域
start_time = time.time()
pair_results = []
all_found_points = []
for i, pair in enumerate(self.point_pairs):
point1_target = np.array(pair['point1'])
point2_target = np.array(pair['point2'])
pair_name = pair.get('name', f'点对{i+1}')
# 为point1找最近点
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")
if dist2 > self.search_radius:
print(f"警告: {pair_name} - 点2 {point2_target} 的最近点距离 {dist2:.2f}mm 超过搜索半径 {self.search_radius}mm")
# 计算两点之间的欧氏距离
distance = np.linalg.norm(point2_found - point1_found)
pair_results.append({
'name': pair_name,
'point1_target': point1_target.tolist(),
'point2_target': point2_target.tolist(),
'point1_found': point1_found.tolist(),
'point2_found': point2_found.tolist(),
'point1_search_distance': float(dist1),
'point2_search_distance': float(dist2),
'distance': float(distance)
})
all_found_points.extend([point1_found, point2_found])
timing_breakdown['查找点对+距离计算'] = time.time() - start_time
# 统计所有距离
start_time = time.time()
distances = np.array([pr['distance'] for pr in pair_results])
mean_distance = float(np.mean(distances))
std_distance = float(np.std(distances))
min_distance = float(np.min(distances))
max_distance = float(np.max(distances))
timing_breakdown['数据整理'] = time.time() - start_time
# 检查是否启用差值比较模式
compare_mode = self.config.get('compare_mode')
comparison_result = None
if compare_mode == 'difference':
compare_pairs = self.config.get('compare_pairs', [])
difference_tolerance = self.config.get('difference_tolerance', 0)
if len(compare_pairs) != 2:
print(f"警告: compare_mode='difference' 需要指定2个点对索引,但得到 {len(compare_pairs)} 个")
elif max(compare_pairs) >= len(pair_results):
print(f"警告: compare_pairs 索引超出范围(最大索引: {len(pair_results)-1})")
else:
# 获取两组点对的距离
idx1, idx2 = compare_pairs[0], compare_pairs[1]
distance1 = pair_results[idx1]['distance']
distance2 = pair_results[idx2]['distance']
# 计算差值
difference = abs(distance1 - distance2)
# 判断是否通过
passed = difference <= difference_tolerance
comparison_result = {
'pair1_index': idx1,
'pair1_name': pair_results[idx1]['name'],
'pair1_distance': distance1,
'pair2_index': idx2,
'pair2_name': pair_results[idx2]['name'],
'pair2_distance': distance2,
'difference': float(difference),
'tolerance': float(difference_tolerance),
'passed': passed
}
print(f"\n 差值比较结果:")
print(f" {pair_results[idx1]['name']}: {distance1:.4f} mm")
print(f" {pair_results[idx2]['name']}: {distance2:.4f} mm")
print(f" 差值: {difference:.4f} mm")
print(f" 公差: ≤{difference_tolerance:.2f} mm")
status = "✅ 通过" if passed else "❌ 不通过"
print(f" 判定: {status}")
# 保存找到的点为点云文件
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)
# 保存所有找到的点
all_points_array = np.array(all_found_points)
ply_path = vis_dir / f"{self.name}_found_points.ply"
Visualizer.save_roi_pointcloud(all_points_array, str(ply_path))
timing_breakdown['可视化保存'] = time.time() - start_time
result_data = {
'mean_distance': mean_distance,
'std_distance': std_distance,
'min_distance': min_distance,
'max_distance': max_distance,
'pair_count': len(pair_results),
'pair_results': pair_results,
'timing_breakdown': timing_breakdown
}
# 添加差值比较结果(如果有)
if comparison_result:
result_data['comparison'] = comparison_result
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()}"
)

View File

@@ -0,0 +1,161 @@
"""
点到点距离测量(支持孔中心检测)
支持 hole_center 方式计算同侧孔中心的距离
"""
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 roi_extractor import ROIExtractor
from visualizer import Visualizer
from hole_center_fitter import HoleCenterFitter
class PointToPointWithHoles(BaseMeasurement):
"""点到点距离测量(支持孔中心定位)"""
def execute(self) -> MeasurementResult:
"""执行测量"""
try:
timing_breakdown = {}
# 获取boxes配置
boxes = self.config.get('boxes', [])
if len(boxes) != 4:
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"需要4个box(左前、右前、左后、右后),但有{len(boxes)}个"
)
# 1. 提取4个孔的点云并拟合圆心
start_time = time.time()
print(f" 提取4个孔的ROI区域并拟合圆心...")
# 记录到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log(f" 提取4个孔的ROI区域并拟合圆心...")
hole_centers = []
hole_names = []
hole_results = [] # 保存完整的拟合结果
for i, box in enumerate(boxes):
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,
task_type=self.task_type,
success=False,
error_message=f"孔 '{box['name']}' ROI区域内没有点"
)
print(f" {box['name']}: {len(points)} 个点")
# 拟合圆心
result = HoleCenterFitter.fit_hole_center(points)
hole_centers.append(result['center_3d'])
hole_names.append(box['name'])
hole_results.append(result)
print(f" 圆心: {result['center_3d']}, 半径: {result['radius']:.2f} mm")
# 记录圆孔拟合结果到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log_circle_fitting(
f"孔 '{box['name']}'",
tuple(result['center_3d']),
result['radius'],
len(points)
)
timing_breakdown['4孔拟合'] = time.time() - start_time
# 2. 计算同侧孔中心的X差值
# 假设顺序:左前、右前、左后、右后
# 左侧距离:左前(0) - 左后(2) 的X差值
# 右侧距离:右前(1) - 右后(3) 的X差值
start_time = time.time()
left_front = np.array(hole_centers[0])
right_front = np.array(hole_centers[1])
left_rear = np.array(hole_centers[2])
right_rear = np.array(hole_centers[3])
# 计算X方向距离(取绝对值)
left_distance = abs(left_front[0] - left_rear[0])
right_distance = abs(right_front[0] - right_rear[0])
print(f" 左侧距离({hole_names[0]} - {hole_names[2]}): {left_distance:.2f} mm")
print(f" 右侧距离({hole_names[1]} - {hole_names[3]}): {right_distance:.2f} mm")
# 计算平均值
mean_distance = (left_distance + right_distance) / 2.0
min_distance = min(left_distance, right_distance)
max_distance = max(left_distance, right_distance)
std_distance = np.std([left_distance, right_distance])
print(f" 平均距离: {mean_distance:.2f} mm")
# 记录距离计算结果到详细日志
if hasattr(self, 'detail_logger') and self.detail_logger:
self.detail_logger.log("")
self.detail_logger.log(f" 孔中心距离计算:")
self.detail_logger.log(f" {hole_names[0]} X坐标: {left_front[0]:.4f} mm")
self.detail_logger.log(f" {hole_names[2]} X坐标: {left_rear[0]:.4f} mm")
self.detail_logger.log(f" 左侧距离: {left_distance:.4f} mm")
self.detail_logger.log("")
self.detail_logger.log(f" {hole_names[1]} X坐标: {right_front[0]:.4f} mm")
self.detail_logger.log(f" {hole_names[3]} X坐标: {right_rear[0]:.4f} mm")
self.detail_logger.log(f" 右侧距离: {right_distance:.4f} mm")
self.detail_logger.log("")
self.detail_logger.log_distance_result(mean_distance, std_distance, min_distance, max_distance)
timing_breakdown['距离计算'] = time.time() - start_time
# 3. 可视化
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)
# 保存每个孔的点云
for i, box in enumerate(boxes):
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))
timing_breakdown['可视化保存'] = time.time() - start_time
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=True,
data={
'mean_distance': float(mean_distance),
'std_distance': float(std_distance),
'min_distance': float(min_distance),
'max_distance': float(max_distance),
'point_count': 2, # 测量了2个距离值
'left_distance': float(left_distance),
'right_distance': float(right_distance),
'hole_centers': [c.tolist() for c in hole_centers],
'hole_names': hole_names,
'timing_breakdown': timing_breakdown
}
)
except Exception as e:
import traceback
return MeasurementResult(
task_name=self.name,
task_type=self.task_type,
success=False,
error_message=f"执行过程中发生错误: {str(e)}\n{traceback.format_exc()}"
)

View File

@@ -0,0 +1,208 @@
"""性能日志记录器 - 用于分析优化"""
import time
from pathlib import Path
from typing import Dict, List
from datetime import datetime
class PerformanceLogger:
"""性能日志记录器"""
def __init__(self, output_dir: str = "results"):
"""
初始化性能日志记录器
Args:
output_dir: 输出目录路径
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
# 生成日志文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
self.log_file = self.output_dir / f"performance_{timestamp}.log"
# 任务性能记录
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]):
"""
记录单个任务的性能数据
Args:
task_name: 任务名称
total_time: 总执行时间(秒)
timing_breakdown: 各步骤耗时字典 {步骤名: 耗时(秒)}
"""
record = {
'task_name': task_name,
'total_time': total_time,
'breakdown': timing_breakdown
}
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:
f.write("=" * 80 + "\n")
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")
# 计算总时间
total_all_tasks = sum(r['total_time'] for r in self.task_records)
f.write(f"所有任务总耗时: {total_all_tasks:.2f} 秒\n\n")
f.write("=" * 80 + "\n\n")
# 逐个任务输出
for i, record in enumerate(self.task_records, 1):
task_name = record['task_name']
total_time = record['total_time']
breakdown = record['breakdown']
f.write(f"[{i}/{len(self.task_records)}] {task_name}\n")
f.write("-" * 80 + "\n")
f.write(f"总耗时: {total_time:.2f} 秒 ({total_time/total_all_tasks*100:.1f}% 占比)\n\n")
if breakdown:
f.write("详细时间分布:\n")
f.write(f"{'步骤':<40} {'耗时 (秒)':<12} {'占比':<10}\n")
f.write("-" * 80 + "\n")
for step_name, step_time in breakdown.items():
percentage = (step_time / total_time * 100) if total_time > 0 else 0
f.write(f" {step_name:<38} {step_time:>8.2f} {percentage:>6.1f}%\n")
f.write("\n" + "=" * 80 + "\n\n")
# 汇总统计
f.write("\n" + "=" * 80 + "\n")
f.write("步骤耗时汇总(所有任务平均)\n")
f.write("=" * 80 + "\n\n")
# 收集所有步骤的耗时
step_totals = {}
step_counts = {}
for record in self.task_records:
for step_name, step_time in record['breakdown'].items():
if step_name not in step_totals:
step_totals[step_name] = 0
step_counts[step_name] = 0
step_totals[step_name] += step_time
step_counts[step_name] += 1
# 计算平均值并排序
step_averages = [(name, step_totals[name] / step_counts[name])
for name in step_totals.keys()]
step_averages.sort(key=lambda x: x[1], reverse=True)
f.write(f"{'步骤':<40} {'平均耗时 (秒)':<15} {'出现次数':<10}\n")
f.write("-" * 80 + "\n")
for step_name, avg_time in step_averages:
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")
print(f"\n性能日志已保存到: {self.log_file}")
return str(self.log_file)

View File

@@ -0,0 +1,169 @@
"""平面拟合模块 - 使用RANSAC算法拟合平面"""
import numpy as np
from dataclasses import dataclass
from typing import Tuple, Optional
@dataclass
class PlaneModel:
"""平面模型"""
normal: np.ndarray # 法向量 (3,)
point: np.ndarray # 平面上的点 (3,)
d: float # 平面方程 ax+by+cz+d=0 中的d
inliers: np.ndarray # 内点索引
inlier_ratio: float # 内点比例
def distance_to_point(self, point: np.ndarray) -> float:
"""计算点到平面的有向距离"""
return np.dot(self.normal, point) + self.d
def distance_to_points(self, points: np.ndarray) -> np.ndarray:
"""计算多个点到平面的有向距离"""
return np.dot(points, self.normal) + self.d
class PlaneFitter:
"""RANSAC平面拟合器"""
def __init__(
self,
threshold: float = 2.0,
max_iterations: int = 1000,
min_inlier_ratio: float = 0.6
):
"""
Args:
threshold: 内点距离阈值(mm)
max_iterations: 最大迭代次数
min_inlier_ratio: 最小内点比例
"""
self.threshold = threshold
self.max_iterations = max_iterations
self.min_inlier_ratio = min_inlier_ratio
def fit(self, points: np.ndarray) -> Optional[PlaneModel]:
"""
使用RANSAC拟合平面
Args:
points: 点云数组 (N, 3)
Returns:
PlaneModel或None(拟合失败)
"""
if len(points) < 3:
return None
best_inliers = None
best_model = None
best_inlier_count = 0
for _ in range(self.max_iterations):
# 随机选择3个点
sample_indices = np.random.choice(len(points), 3, replace=False)
sample_points = points[sample_indices]
# 计算平面参数
model = self._fit_plane_from_points(sample_points)
if model is None:
continue
# 计算所有点到平面的距离
distances = np.abs(model.distance_to_points(points))
# 找到内点
inliers = np.where(distances < self.threshold)[0]
if len(inliers) > best_inlier_count:
best_inlier_count = len(inliers)
best_inliers = inliers
best_model = model
if best_inliers is None or len(best_inliers) < 3:
return None
# 使用所有内点重新拟合平面
inlier_points = points[best_inliers]
final_model = self._fit_plane_least_squares(inlier_points)
if final_model is None:
return None
final_model.inliers = best_inliers
final_model.inlier_ratio = len(best_inliers) / len(points)
# 检查内点比例
if final_model.inlier_ratio < self.min_inlier_ratio:
return None
return final_model
def _fit_plane_from_points(self, points: np.ndarray) -> Optional[PlaneModel]:
"""从3个点拟合平面"""
if len(points) != 3:
return None
# 计算两个向量
v1 = points[1] - points[0]
v2 = points[2] - points[0]
# 法向量 = v1 × v2
normal = np.cross(v1, v2)
norm = np.linalg.norm(normal)
if norm < 1e-6:
return None
normal = normal / norm
# 平面方程: n·(p - p0) = 0 => n·p = n·p0 => ax+by+cz+d=0, d=-n·p0
d = -np.dot(normal, points[0])
return PlaneModel(
normal=normal,
point=points[0],
d=d,
inliers=np.array([]),
inlier_ratio=0.0
)
def _fit_plane_least_squares(self, points: np.ndarray) -> Optional[PlaneModel]:
"""使用最小二乘法拟合平面"""
if len(points) < 3:
return None
# 如果点数过多,降采样以避免内存爆炸
# SVD需要O(n^2)内存,超过10000个点会导致内存问题
max_points_for_svd = 10000
if len(points) > max_points_for_svd:
indices = np.random.choice(len(points), max_points_for_svd, replace=False)
points_for_fit = points[indices]
else:
points_for_fit = points
# 计算质心
centroid = np.mean(points_for_fit, axis=0)
# 中心化点云
centered = points_for_fit - centroid
# SVD分解
try:
_, _, vh = np.linalg.svd(centered)
normal = vh[2] # 最小奇异值对应的向量
# 确保法向量指向正方向(可选)
if normal[2] < 0:
normal = -normal
d = -np.dot(normal, centroid)
return PlaneModel(
normal=normal,
point=centroid,
d=d,
inliers=np.array([]),
inlier_ratio=0.0
)
except np.linalg.LinAlgError:
return None

View File

@@ -0,0 +1,111 @@
"""点云加载模块"""
import numpy as np
from pathlib import Path
import struct
class PointCloudLoader:
"""加载点云文件"""
@staticmethod
def load_ply(file_path: str) -> np.ndarray:
"""
加载PLY格式点云
Args:
file_path: PLY文件路径
Returns:
np.ndarray: 点云数组 (N, 3)
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"点云文件不存在: {file_path}")
print(f"正在加载点云: {file_path}")
with open(path, 'rb') as f:
# 读取头部
header_lines = []
while True:
line = f.readline().decode('ascii').strip()
header_lines.append(line)
if line == 'end_header':
break
# 解析头部
vertex_count = 0
format_type = None
properties = []
for line in header_lines:
if line.startswith('element vertex'):
vertex_count = int(line.split()[-1])
elif line.startswith('format'):
format_type = line.split()[1]
elif line.startswith('property'):
parts = line.split()
properties.append({
'type': parts[1],
'name': parts[2]
})
if vertex_count == 0:
raise ValueError("PLY文件中没有顶点数据")
# 读取顶点数据
if format_type == 'binary_little_endian':
points = PointCloudLoader._read_binary_ply(f, vertex_count, properties)
elif format_type == 'ascii':
points = PointCloudLoader._read_ascii_ply(f, vertex_count)
else:
raise ValueError(f"不支持的PLY格式: {format_type}")
print(f" 加载完成: {len(points)} 个点")
return points
@staticmethod
def _read_binary_ply(f, vertex_count: int, properties: list) -> np.ndarray:
"""读取二进制PLY数据(优化版:批量读取)"""
# 构建numpy dtype
type_map = {
'float': np.float32,
'double': np.float64,
'uchar': np.uint8,
'uint': np.uint32,
'int': np.int32
}
# 构建结构化数组的dtype
dtype_list = []
for prop in properties:
dtype_list.append((prop['name'], type_map.get(prop['type'], np.float32)))
dtype = np.dtype(dtype_list)
# 一次性读取所有数据
raw_data = np.fromfile(f, dtype=dtype, count=vertex_count)
# 提取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数据(优化版:批量读取)"""
# 读取剩余所有行
remaining = f.read().decode('ascii')
# 使用numpy的快速文本解析
from io import StringIO
points = np.loadtxt(StringIO(remaining), dtype=np.float64, max_rows=vertex_count, usecols=(0, 1, 2))
return points

View File

@@ -0,0 +1,65 @@
"""ROI提取模块 - 从点云中提取包围盒区域的点"""
import numpy as np
from typing import Tuple, Optional
class ROIExtractor:
"""从点云中提取指定包围盒区域的点"""
@staticmethod
def extract_bbox_points(
points: np.ndarray,
bbox_min: list,
bbox_max: list,
spatial_index=None
) -> Tuple[np.ndarray, np.ndarray]:
"""
提取包围盒内的点
Args:
points: 点云数组 (N, 3)
bbox_min: 包围盒最小坐标 [x_min, y_min, z_min]
bbox_max: 包围盒最大坐标 [x_max, y_max, z_max]
spatial_index: 空间索引对象(可选)
Returns:
roi_points: 提取的点 (M, 3)
indices: 原始点云中的索引 (M,)
"""
bbox_min = np.array(bbox_min)
bbox_max = np.array(bbox_max)
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
def sample_points(points: np.ndarray, n_samples: int, seed: Optional[int] = 42) -> np.ndarray:
"""
从点云中随机采样点
Args:
points: 点云数组 (N, 3)
n_samples: 采样点数
seed: 随机种子
Returns:
sampled_points: 采样后的点 (n_samples, 3)
"""
if len(points) == 0:
return np.array([]).reshape(0, 3)
n_samples = min(n_samples, len(points))
if seed is not None:
np.random.seed(seed)
indices = np.random.choice(len(points), size=n_samples, replace=False)
return points[indices]

View File

@@ -0,0 +1,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',
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,510 @@
"""任务管理器 - 负责加载配置、调度任务、汇总结果"""
import yaml
import numpy as np
import time
from pathlib import Path
from typing import List, Dict, Any
from datetime import datetime
from pointcloud_loader import PointCloudLoader
from measurements.base import MeasurementResult
from measurements.plane_distance import PlaneToPlaneDistance
from measurements.point_to_plane import PointToPlaneDistance
from measurements.point_to_point import PointToPointDistance
from 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:
"""测量任务管理器"""
def __init__(self):
"""初始化任务管理器"""
self.config = None
self.point_cloud = None
self.spatial_index = None # 空间索引
self.tasks = []
self.results = []
self.pointcloud_path_override = None
self.output_dir_override = None
def load_config(self, config_path: str) -> bool:
"""
加载配置文件
Args:
config_path: 配置文件路径
Returns:
bool: 是否加载成功
"""
try:
with open(config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
# 加载点云(支持相对路径)
pointcloud_path = self.pointcloud_path_override or self.config.get('pointcloud_file')
if not pointcloud_path:
print("错误: 配置文件中未指定点云文件路径")
return False
# 处理相对路径
pointcloud_path = self._resolve_path(pointcloud_path, config_path)
# 加载点云
print(f"\n{'='*70}")
print("阶段1: 加载点云")
print("="*70)
print(f" 文件: {Path(pointcloud_path).name}")
load_start = time.time()
self.point_cloud = PointCloudLoader.load_ply(pointcloud_path)
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("警告: 配置文件中没有定义任务")
return True
print(f"\n找到 {len(tasks_config)} 个测量任务")
for task_config in tasks_config:
task = self._create_task(task_config)
if task:
self.tasks.append(task)
print(f" - {task.name} ({task.task_type})")
return True
except FileNotFoundError:
print(f"错误: 配置文件不存在: {config_path}")
return False
except yaml.YAMLError as e:
print(f"错误: 配置文件格式错误: {e}")
return False
except Exception as e:
print(f"错误: 加载配置失败: {e}")
return False
def _resolve_path(self, path: str, config_path: str) -> str:
"""
解析路径(支持相对路径和绝对路径)
Args:
path: 待解析的路径
config_path: 配置文件路径(用于相对路径的基准)
Returns:
str: 解析后的绝对路径
"""
path_obj = Path(path)
if path_obj.is_absolute():
return str(path_obj)
else:
# 相对于配置文件所在目录
config_dir = Path(config_path).parent
return str(config_dir / path_obj)
def override_pointcloud_path(self, path: str):
"""
覆盖点云文件路径(用于命令行参数)
Args:
path: 点云文件路径
"""
self.pointcloud_path_override = path
def override_output_dir(self, output_dir: str):
"""
覆盖输出目录(用于命令行参数)
Args:
output_dir: 输出目录路径
"""
self.output_dir_override = output_dir
def _create_task(self, task_config: Dict[str, Any]):
"""
根据配置创建测量任务
Args:
task_config: 任务配置字典
Returns:
BaseMeasurement: 测量任务实例
"""
task_type = task_config.get('type')
task_name = task_config.get('name', 'Unnamed Task')
# 处理带有孔中心检测的任务类型
if '4_hole_center' in task_type:
if 'plane_to_plane' in task_type:
return PlaneToPlaneWithHoles(
name=task_name,
point_cloud=self.point_cloud,
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,
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,
spatial_index=self.spatial_index
)
elif task_type == 'plane_to_plane':
return PlaneToPlaneDistance(
name=task_name,
point_cloud=self.point_cloud,
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,
spatial_index=self.spatial_index
)
elif task_type == 'point_to_point':
return PointToPointDistance(
name=task_name,
point_cloud=self.point_cloud,
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}")
return None
def run_all_tasks(self) -> List[Dict[str, Any]]:
"""
执行所有测量任务
Returns:
List[Dict]: 任务结果列表
"""
import time
self.results = []
# 创建性能日志记录器
if self.output_dir_override:
output_dir = Path(self.output_dir_override)
else:
output_dir = Path(__file__).parent.parent / "results"
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))
for i, task in enumerate(self.tasks, 1):
print(f"\n[{i}/{len(self.tasks)}] 执行任务: {task.name}")
print("-" * 60)
# 记录任务开始到详细日志
detail_logger.log_task_start(i, len(self.tasks), task.name)
# 记录开始时间
start_time = time.time()
# 将详细日志记录器传递给任务
task.detail_logger = detail_logger
# 执行任务
result = task.execute()
# 记录结束时间
end_time = time.time()
execution_time = end_time - start_time
# 将执行时间添加到结果数据中
if result.data is None:
result.data = {}
result.data['execution_time'] = execution_time
# 获取详细的时间分布
timing_breakdown = result.data.get('timing_breakdown', {})
# 记录到性能日志
perf_logger.log_task(task.name, execution_time, timing_breakdown)
# 记录结果
result_dict = {
'name': result.task_name,
'type': result.task_type,
'status': 'success' if result.success else 'failed',
'data': result.data if result.success else None,
'error': result.error_message if not result.success else None,
'execution_time': execution_time
}
self.results.append(result_dict)
# 检查是否有宽度差值检查结果,如果有则生成额外的任务19结果
if result.success and result.data and 'width_variation' in result.data:
width_var = result.data['width_variation']
# 创建任务19的结果
task19_name = task.name.replace("任务18", "任务19").replace("门口宽度", "门口宽度差值")
task19_result_dict = {
'name': task19_name,
'type': result.task_type,
'status': 'success',
'data': {
'upper_width': width_var['upper_width'],
'middle_width': width_var['middle_width'],
'lower_width': width_var['lower_width'],
'diff_upper_middle': width_var['diff_upper_middle'],
'diff_middle_lower': width_var['diff_middle_lower'],
'diff_upper_lower': width_var['diff_upper_lower'],
'max_diff': max(abs(width_var['diff_upper_middle']),
abs(width_var['diff_middle_lower']),
abs(width_var['diff_upper_lower'])),
'tolerance': width_var['tolerance'],
'pass': width_var['pass'],
'execution_time': execution_time
},
'error': None,
'execution_time': execution_time
}
self.results.append(task19_result_dict)
# 打印任务19结果
print(f"\n [自动生成] {task19_name}")
print(f" 上部宽度: {width_var['upper_width']:.4f} mm")
print(f" 中部宽度: {width_var['middle_width']:.4f} mm")
print(f" 下部宽度: {width_var['lower_width']:.4f} mm")
print(f" 最大差值: {task19_result_dict['data']['max_diff']:.4f} mm")
print(f" 公差要求: ±{width_var['tolerance']:.1f} mm")
status = "✅ 通过" if width_var['pass'] else "❌ 超差"
print(f" 检查结果: {status}")
# 打印结果
if result.success:
print(f"[成功] 任务完成 (用时: {execution_time:.2f} 秒)")
if result.data:
print(f" 平均距离: {result.data.get('mean_distance', 0):.2f} mm")
print(f" 标准差: {result.data.get('std_distance', 0):.2f} mm")
print(f" 范围: [{result.data.get('min_distance', 0):.2f}, {result.data.get('max_distance', 0):.2f}] mm")
print(f" 测量点数: {result.data.get('point_count', result.data.get('sample_count', 0))}")
if 'source_inlier_ratio' in result.data:
print(f" 源平面内点率: {result.data.get('source_inlier_ratio', 0):.1%}")
if 'target_inlier_ratio' in result.data:
print(f" 目标平面内点率: {result.data.get('target_inlier_ratio', 0):.1%}")
if 'plane_inlier_ratio' in result.data:
print(f" 平面内点率: {result.data.get('plane_inlier_ratio', 0):.1%}")
# 记录成功到详细日志
detail_logger.log_task_success(execution_time)
if result.data:
# 记录测量结果到详细日志
if 'mean_distance' in result.data:
detail_logger.log_distance_result(
result.data.get('mean_distance', 0),
result.data.get('std_distance', 0),
result.data.get('min_distance', 0),
result.data.get('max_distance', 0)
)
else:
print(f"[失败] {result.error_message} (用时: {execution_time:.2f} 秒)")
# 记录失败到详细日志
detail_logger.log_task_failure(result.error_message, execution_time)
# 可视化(如果配置中启用)
if result.success and task.config.get('visualization', False):
self._visualize_result(task, result)
# 保存性能日志
perf_logger.save()
# 关闭详细测量日志
detail_logger.close()
# 导出Excel结果
self._export_results()
return self.results
def _visualize_result(self, task, result: MeasurementResult):
"""
可视化测量结果
Args:
task: 测量任务
result: 测量结果
"""
try:
import open3d as o3d
import matplotlib.pyplot as plt
print(" 生成可视化...")
data = result.data
if not data:
return
# 创建可视化窗口
vis = o3d.visualization.Visualizer()
vis.create_window(window_name=f"测量结果: {task.name}", width=1200, height=800)
# 显示源平面点云(蓝色)
source_pcd = o3d.geometry.PointCloud()
source_pcd.points = o3d.utility.Vector3dVector(data['_source_points'])
source_pcd.paint_uniform_color([0.2, 0.4, 0.8])
vis.add_geometry(source_pcd)
# 显示目标平面点云(绿色)
target_pcd = o3d.geometry.PointCloud()
target_pcd.points = o3d.utility.Vector3dVector(data['_target_points'])
target_pcd.paint_uniform_color([0.2, 0.8, 0.4])
vis.add_geometry(target_pcd)
# 显示采样点(红色)
sampled_pcd = o3d.geometry.PointCloud()
sampled_pcd.points = o3d.utility.Vector3dVector(data['_sampled_points'])
sampled_pcd.paint_uniform_color([0.9, 0.2, 0.2])
vis.add_geometry(sampled_pcd)
# 运行可视化
vis.run()
vis.destroy_window()
except Exception as e:
print(f" 可视化失败: {e}")
def _export_results(self):
"""导出结果到Excel"""
try:
# 转换为MeasurementResult对象列表
measurement_results = []
for result_dict in self.results:
measurement_results.append(MeasurementResult(
task_name=result_dict['name'],
task_type=result_dict['type'],
success=result_dict['status'] == 'success',
data=result_dict.get('data'),
error_message=result_dict.get('error')
))
# 导出Excel(支持命令行覆盖输出目录)
if self.output_dir_override:
output_dir = Path(self.output_dir_override)
else:
output_dir = Path(__file__).parent.parent / "results"
exporter = ExcelExporter(str(output_dir))
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"measurement_results_{timestamp}"
# 传递任务配置信息到导出器
excel_path = exporter.export(measurement_results, filename, self.config.get('tasks', []))
print(f"\n结果已导出到: {excel_path}")
except Exception as e:
print(f"\n导出Excel失败: {e}")
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']:,} 个节点")

View File

@@ -0,0 +1,195 @@
"""可视化工具 - 用于显示点云、平面、ROI等"""
import numpy as np
import open3d as o3d
from pathlib import Path
from typing import List, Tuple, Optional
class Visualizer:
"""点云和测量结果可视化工具"""
@staticmethod
def visualize_roi_in_context(
full_cloud: np.ndarray,
roi_cloud: np.ndarray,
bbox_min: List[float],
bbox_max: List[float],
title: str = "ROI in Context",
downsample_full: int = 10,
save_path: Optional[str] = None
):
"""
在完整点云上下文中可视化ROI区域
Args:
full_cloud: 完整点云 (N, 3)
roi_cloud: ROI区域点云 (M, 3)
bbox_min: 包围盒最小坐标 [x, y, z]
bbox_max: 包围盒最大坐标 [x, y, z]
title: 窗口标题
downsample_full: 完整点云降采样倍数(每N个点取1个)
save_path: 保存截图路径(可选)
"""
# 降采样完整点云以提高显示性能
full_cloud_downsampled = full_cloud[::downsample_full]
# 创建完整点云对象(灰色)
pcd_full = o3d.geometry.PointCloud()
pcd_full.points = o3d.utility.Vector3dVector(full_cloud_downsampled)
pcd_full.paint_uniform_color([0.7, 0.7, 0.7]) # 灰色
# 创建ROI点云对象(红色)
pcd_roi = o3d.geometry.PointCloud()
pcd_roi.points = o3d.utility.Vector3dVector(roi_cloud)
pcd_roi.paint_uniform_color([1.0, 0.0, 0.0]) # 红色
# 创建包围盒线框
bbox = Visualizer._create_bbox_lineset(bbox_min, bbox_max, color=[0, 1, 0])
# 可视化
geometries = [pcd_full, pcd_roi, bbox]
o3d.visualization.draw_geometries(
geometries,
window_name=title,
width=1200,
height=800,
point_show_normal=False
)
# 保存截图(如果指定)
if save_path:
vis = o3d.visualization.Visualizer()
vis.create_window(window_name=title, width=1200, height=800, visible=False)
for geom in geometries:
vis.add_geometry(geom)
vis.update_renderer()
vis.capture_screen_image(save_path)
vis.destroy_window()
print(f"截图已保存: {save_path}")
@staticmethod
def visualize_plane_measurement(
source_cloud: np.ndarray,
target_cloud: np.ndarray,
source_plane_normal: np.ndarray,
source_plane_point: np.ndarray,
target_plane_normal: np.ndarray,
target_plane_point: np.ndarray,
sample_points: np.ndarray,
distances: np.ndarray,
title: str = "Plane Measurement",
save_path: Optional[str] = None
):
"""
可视化平面到平面的距离测量
Args:
source_cloud: 源平面点云
target_cloud: 目标平面点云
source_plane_normal: 源平面法向量
source_plane_point: 源平面上的点
target_plane_normal: 目标平面法向量
target_plane_point: 目标平面上的点
sample_points: 采样点
distances: 每个采样点到目标平面的距离
title: 窗口标题
save_path: 保存截图路径
"""
# 创建源平面点云(蓝色)
pcd_source = o3d.geometry.PointCloud()
pcd_source.points = o3d.utility.Vector3dVector(source_cloud)
pcd_source.paint_uniform_color([0.0, 0.5, 1.0]) # 蓝色
# 创建目标平面点云(绿色)
pcd_target = o3d.geometry.PointCloud()
pcd_target.points = o3d.utility.Vector3dVector(target_cloud)
pcd_target.paint_uniform_color([0.0, 1.0, 0.0]) # 绿色
# 创建采样点(红色)
pcd_samples = o3d.geometry.PointCloud()
pcd_samples.points = o3d.utility.Vector3dVector(sample_points)
pcd_samples.paint_uniform_color([1.0, 0.0, 0.0]) # 红色
# 创建距离线(从采样点到目标平面的投影)
lines = []
for i, (point, dist) in enumerate(zip(sample_points, distances)):
# 计算投影点
projection = point - dist * target_plane_normal
lines.append([point, projection])
# 创建线集
line_points = []
line_indices = []
for i, (start, end) in enumerate(lines):
line_points.append(start)
line_points.append(end)
line_indices.append([2*i, 2*i+1])
lineset = o3d.geometry.LineSet()
lineset.points = o3d.utility.Vector3dVector(line_points)
lineset.lines = o3d.utility.Vector2iVector(line_indices)
lineset.paint_uniform_color([1.0, 0.5, 0.0]) # 橙色
# 可视化
geometries = [pcd_source, pcd_target, pcd_samples, lineset]
o3d.visualization.draw_geometries(
geometries,
window_name=title,
width=1200,
height=800,
point_show_normal=False
)
if save_path:
vis = o3d.visualization.Visualizer()
vis.create_window(window_name=title, width=1200, height=800, visible=False)
for geom in geometries:
vis.add_geometry(geom)
vis.update_renderer()
vis.capture_screen_image(save_path)
vis.destroy_window()
print(f"截图已保存: {save_path}")
@staticmethod
def _create_bbox_lineset(
bbox_min: List[float],
bbox_max: List[float],
color: List[float] = [0, 1, 0]
) -> o3d.geometry.LineSet:
"""创建包围盒线框"""
# 8个顶点
points = [
[bbox_min[0], bbox_min[1], bbox_min[2]], # 0
[bbox_max[0], bbox_min[1], bbox_min[2]], # 1
[bbox_max[0], bbox_max[1], bbox_min[2]], # 2
[bbox_min[0], bbox_max[1], bbox_min[2]], # 3
[bbox_min[0], bbox_min[1], bbox_max[2]], # 4
[bbox_max[0], bbox_min[1], bbox_max[2]], # 5
[bbox_max[0], bbox_max[1], bbox_max[2]], # 6
[bbox_min[0], bbox_max[1], bbox_max[2]], # 7
]
# 12条边
lines = [
[0, 1], [1, 2], [2, 3], [3, 0], # 底面
[4, 5], [5, 6], [6, 7], [7, 4], # 顶面
[0, 4], [1, 5], [2, 6], [3, 7], # 竖边
]
lineset = o3d.geometry.LineSet()
lineset.points = o3d.utility.Vector3dVector(points)
lineset.lines = o3d.utility.Vector2iVector(lines)
lineset.paint_uniform_color(color)
return lineset
@staticmethod
def save_roi_pointcloud(
roi_cloud: np.ndarray,
save_path: str
):
"""保存ROI点云为PLY文件"""
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(roi_cloud)
o3d.io.write_point_cloud(save_path, pcd)
print(f"ROI点云已保存: {save_path}")