import argparse from pathlib import Path from zipfile import ZipFile, ZIP_DEFLATED from xml.sax.saxutils import escape import numpy as np import torch import trimesh import matplotlib.pyplot as plt from OCC.Core.STEPControl import STEPControl_Reader from OCC.Core.IFSelect import IFSelect_RetDone from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopAbs import TopAbs_FACE from OCC.Core.BRep import BRep_Tool # 兼容不同 pythonocc 版本 try: from OCC.Core.TopoDS import topods USE_TOPODS = "topods" except ImportError: from OCC.Core.TopoDS import TopoDS USE_TOPODS = "TopoDS" try: import open3d as o3d except ModuleNotFoundError: o3d = None # ============================================================ # 设备与精度 # ============================================================ device = torch.device("cuda" if torch.cuda.is_available() else "cpu") dtype = torch.float64 DEFAULT_TASK_IDS = [ 11, 13, 17, 20, 25, 26, 27, 31, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45 ] task_distance_data = {} # ============================================================ # STEP 读取 # ============================================================ def read_step_file(step_path): reader = STEPControl_Reader() status = reader.ReadFile(step_path) if status != IFSelect_RetDone: raise RuntimeError(f"STEP 文件读取失败: {step_path}") reader.TransferRoots() shape = reader.OneShape() print("STEP 文件读取完成") return shape # ============================================================ # OCC Shape -> numpy mesh # ============================================================ def occ_shape_to_numpy_mesh(shape): """ 从 OCC Shape 中提取三角网格。 返回: vertices_np: [N, 3] faces_np: [M, 3] """ vertices = [] faces = [] explorer = TopExp_Explorer(shape, TopAbs_FACE) face_count = 0 triangle_count = 0 while explorer.More(): current_shape = explorer.Current() if USE_TOPODS == "topods": face = topods.Face(current_shape) else: face = TopoDS.Face(current_shape) location = face.Location() triangulation = BRep_Tool.Triangulation(face, location) if triangulation is None: explorer.Next() continue trsf = location.Transformation() base_index = len(vertices) # 读取当前 Face 的顶点 for i in range(1, triangulation.NbNodes() + 1): pnt = triangulation.Node(i) # 应用局部变换 pnt.Transform(trsf) vertices.append([ pnt.X(), pnt.Y(), pnt.Z() ]) # 读取当前 Face 的三角形 for i in range(1, triangulation.NbTriangles() + 1): tri = triangulation.Triangle(i) n1, n2, n3 = tri.Get() # OCC 索引从 1 开始,numpy / torch 索引从 0 开始 faces.append([ base_index + n1 - 1, base_index + n2 - 1, base_index + n3 - 1 ]) triangle_count += 1 face_count += 1 explorer.Next() vertices_np = np.asarray(vertices, dtype=np.float64) faces_np = np.asarray(faces, dtype=np.int64) print("CAD 网格提取完成") print(f"CAD 面数量: {face_count}") print(f"网格顶点: {vertices_np.shape}") print(f"网格三角面: {faces_np.shape}") if vertices_np.shape[0] == 0 or faces_np.shape[0] == 0: raise RuntimeError( "CAD 三角网格提取失败。可以尝试减小 MESH_DEFLECTION,或者检查 STEP 文件是否正常。" ) return vertices_np, faces_np # ============================================================ # 读取点云 # ============================================================ def read_point_cloud(pointcloud_path): if o3d is not None: pcd = o3d.io.read_point_cloud(pointcloud_path) points_np = np.asarray(pcd.points, dtype=np.float64) else: pcd = trimesh.load(pointcloud_path) points_np = np.asarray(pcd.vertices, dtype=np.float64) if points_np.shape[0] == 0: raise RuntimeError(f"点云读取失败或点数为 0: {pointcloud_path}") print("点云读取完成") print(f"点云点数: {points_np.shape}") return points_np # ============================================================ # 点到三角形距离 # ============================================================ def point_to_triangle_distance_squared(points, triangles): """ points: [P, 3] triangles: [T, 3, 3] 返回: dist2: [P, T] """ p = points[:, None, :] a = triangles[None, :, 0, :] b = triangles[None, :, 1, :] c = triangles[None, :, 2, :] ab = b - a ac = c - a ap = p - a d1 = (ab * ap).sum(dim=-1) d2 = (ac * ap).sum(dim=-1) inf = torch.full_like(d1, torch.inf) # 区域 A mask = (d1 <= 0.0) & (d2 <= 0.0) dist2 = torch.where(mask, (ap * ap).sum(dim=-1), inf) # 区域 B bp = p - b d3 = (ab * bp).sum(dim=-1) d4 = (ac * bp).sum(dim=-1) mask = (d3 >= 0.0) & (d4 <= d3) dist2 = torch.minimum( dist2, torch.where(mask, (bp * bp).sum(dim=-1), inf) ) # 边 AB vc = d1 * d4 - d3 * d2 mask = (vc <= 0.0) & (d1 >= 0.0) & (d3 <= 0.0) v = d1 / (d1 - d3).clamp_min(torch.finfo(points.dtype).eps) closest = a + v[..., None] * ab edge_dist2 = ((p - closest) ** 2).sum(dim=-1) dist2 = torch.minimum( dist2, torch.where(mask, edge_dist2, inf) ) # 区域 C cp = p - c d5 = (ab * cp).sum(dim=-1) d6 = (ac * cp).sum(dim=-1) mask = (d6 >= 0.0) & (d5 <= d6) dist2 = torch.minimum( dist2, torch.where(mask, (cp * cp).sum(dim=-1), inf) ) # 边 AC vb = d5 * d2 - d1 * d6 mask = (vb <= 0.0) & (d2 >= 0.0) & (d6 <= 0.0) w = d2 / (d2 - d6).clamp_min(torch.finfo(points.dtype).eps) closest = a + w[..., None] * ac edge_dist2 = ((p - closest) ** 2).sum(dim=-1) dist2 = torch.minimum( dist2, torch.where(mask, edge_dist2, inf) ) # 边 BC va = d3 * d6 - d5 * d4 mask = (va <= 0.0) & ((d4 - d3) >= 0.0) & ((d5 - d6) >= 0.0) w = (d4 - d3) / ((d4 - d3) + (d5 - d6)).clamp_min( torch.finfo(points.dtype).eps ) closest = b + w[..., None] * (c - b) edge_dist2 = ((p - closest) ** 2).sum(dim=-1) dist2 = torch.minimum( dist2, torch.where(mask, edge_dist2, inf) ) # 三角形内部区域 denom = (va + vb + vc).clamp_min(torch.finfo(points.dtype).eps) v = vb / denom w = vc / denom closest = a + ab * v[..., None] + ac * w[..., None] face_dist2 = ((p - closest) ** 2).sum(dim=-1) dist2 = torch.minimum(dist2, face_dist2) return dist2 # ============================================================ # 分块计算最近距离 # ============================================================ def nearest_mesh_distances( points, triangles, point_chunk_size=2048, triangle_chunk_size=4096 ): """ 为了避免显存爆炸,点和三角面都分块计算。 points: [N, 3] triangles: [M, 3, 3] 返回: distances: [N] """ all_distances = [] num_points = points.shape[0] num_triangles = triangles.shape[0] for p_start in range(0, num_points, point_chunk_size): p_end = min(p_start + point_chunk_size, num_points) point_chunk = points[p_start:p_end] min_dist2 = torch.full( (point_chunk.shape[0],), torch.inf, dtype=points.dtype, device=points.device ) for t_start in range(0, num_triangles, triangle_chunk_size): t_end = min(t_start + triangle_chunk_size, num_triangles) triangle_chunk = triangles[t_start:t_end] dist2 = point_to_triangle_distance_squared( point_chunk, triangle_chunk ) min_dist2 = torch.minimum( min_dist2, dist2.min(dim=1).values ) all_distances.append(torch.sqrt(min_dist2)) # print(f"processed points: {p_end}/{num_points}") distances = torch.cat(all_distances, dim=0) return distances # ============================================================ # 热力图显示 # ============================================================ def visualize_point_cloud_heatmap(points_np, distances_np): if o3d is None: print("未安装 open3d,跳过可视化。") return d_min = distances_np.min() d_max = distances_np.max() dist_norm = (distances_np - d_min) / (d_max - d_min + 1e-12) colors = plt.cm.turbo(dist_norm)[:, :3] pcd_show = o3d.geometry.PointCloud() pcd_show.points = o3d.utility.Vector3dVector(points_np) pcd_show.colors = o3d.utility.Vector3dVector(colors) print("色阶规则:深蓝/紫色 = 最近,红色 = 最远") o3d.visualization.draw_geometries( [pcd_show], window_name="点云到 CAD 距离热力图" ) def visualize_task_result(task_results, task_id): """ task_results: run_batch_tasks 返回的字典,key 为任务号。 task_id: 需要可视化的任务号。 """ result = task_results.get(int(task_id)) if result is None: raise KeyError(f"未找到任务 {task_id} 的计算结果。") visualize_point_cloud_heatmap( result["points_np"], result["distances_np"] ) def _xlsx_cell_ref(row_index, col_index): letters = "" col = col_index while col: col, remainder = divmod(col - 1, 26) letters = chr(65 + remainder) + letters return f"{letters}{row_index}" def _xlsx_cell_xml(row_index, col_index, value): cell_ref = _xlsx_cell_ref(row_index, col_index) if isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(value, bool): return f'{value}' text = escape("" if value is None else str(value)) return f'{text}' def write_flatness_excel(rows, output_path, sheet_name="平整度"): """ 使用 Python 标准库生成 .xlsx,避免额外依赖。 """ output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) headers = ["任务号", "平均距离(mm)", "最大距离(mm)", "状态", "说明"] table = [headers] for row in rows: table.append([ row.get("task_id"), row.get("mean_dist"), row.get("max_dist"), row.get("status", ""), row.get("message", "") ]) sheet_rows = [] for row_index, row_values in enumerate(table, start=1): cells = [ _xlsx_cell_xml(row_index, col_index, value) for col_index, value in enumerate(row_values, start=1) ] sheet_rows.append(f'{"".join(cells)}') safe_sheet_name = escape(sheet_name[:31]) workbook_xml = ( '' '' f'' '' ) worksheet_xml = ( '' '' '' f'{"".join(sheet_rows)}' '' '' ) content_types_xml = ( '' '' '' '' '' '' '' ) root_rels_xml = ( '' '' '' '' ) workbook_rels_xml = ( '' '' '' '' ) with ZipFile(output_path, "w", ZIP_DEFLATED) as xlsx: xlsx.writestr("[Content_Types].xml", content_types_xml) xlsx.writestr("_rels/.rels", root_rels_xml) xlsx.writestr("xl/workbook.xml", workbook_xml) xlsx.writestr("xl/_rels/workbook.xml.rels", workbook_rels_xml) xlsx.writestr("xl/worksheets/sheet1.xml", worksheet_xml) return output_path # ============================================================ # 主函数 # ============================================================ def compute_point_CAD_distance( CAD_path, pointcloud_path, mesh_deflection=0.1, point_chunk_size=2048, triangle_chunk_size=4096, visualize=False ): print("计算设备:", device) print("数据精度:", dtype) # STEP -> OCC Shape shape = read_step_file(CAD_path) # OCC Shape 三角化 print("正在对 CAD 模型进行三角化...") mesh = BRepMesh_IncrementalMesh(shape, mesh_deflection) mesh.Perform() # 提取 numpy 网格 vertices_np, faces_np = occ_shape_to_numpy_mesh(shape) # 读取点云 points_np = read_point_cloud(pointcloud_path) # numpy -> torch points = torch.as_tensor(points_np, dtype=dtype, device=device) vertices = torch.as_tensor(vertices_np, dtype=dtype, device=device) faces = torch.as_tensor(faces_np, dtype=torch.long, device=device) triangles = vertices[faces] print("开始计算点云到 CAD 的最近距离...") print("点云张量:", points.shape) print("三角面张量:", triangles.shape) with torch.no_grad(): distances_torch = nearest_mesh_distances( points, triangles, point_chunk_size=point_chunk_size, triangle_chunk_size=triangle_chunk_size ) distances_np = distances_torch.detach().cpu().numpy() mean_dist = float(distances_np.mean()) max_dist = float(distances_np.max()) min_dist = float(distances_np.min()) # print("Distance computation finished") print(f"最小距离: {min_dist:.8f}") print(f"平均距离: {mean_dist:.8f}") print(f"最大距离: {max_dist:.8f}") if visualize: visualize_point_cloud_heatmap(points_np, distances_np) return { "mean_dist": mean_dist, "max_dist": max_dist, "min_dist": min_dist, "points_np": points_np, "distances_np": distances_np } def get_task_paths(task_id, data_root="High_Speed_Data", pointcloud_kind="noise"): data_root = Path(data_root) task_id = int(task_id) cad_path = data_root / "CAD" / f"{task_id}.step" if pointcloud_kind == "clean": pointcloud_path = data_root / "PointCloud" / f"{task_id}.ply" else: pointcloud_path = data_root / "Noise_PointCloud" / f"{task_id}_noisy.ply" return cad_path, pointcloud_path def run_batch_tasks( task_ids=DEFAULT_TASK_IDS, data_root="High_Speed_Data", pointcloud_kind="noise", excel_output="平整度.xlsx", mesh_deflection=0.1, point_chunk_size=2048, triangle_chunk_size=4096 ): global task_distance_data result_rows = [] task_distance_data = {} for task_id in task_ids: task_id = int(task_id) cad_path, pointcloud_path = get_task_paths( task_id, data_root=data_root, pointcloud_kind=pointcloud_kind ) print(f"===== 任务 {task_id} =====") missing_paths = [ str(path) for path in [cad_path, pointcloud_path] if not path.exists() ] if missing_paths: message = "缺少文件: " + "; ".join(missing_paths) print(message) result_rows.append({ "task_id": task_id, "mean_dist": None, "max_dist": None, "status": "失败", "message": message }) continue try: result = compute_point_CAD_distance( CAD_path=str(cad_path), pointcloud_path=str(pointcloud_path), mesh_deflection=mesh_deflection, point_chunk_size=point_chunk_size, triangle_chunk_size=triangle_chunk_size, visualize=False ) except Exception as exc: message = str(exc) print(f"任务 {task_id} 失败: {message}") result_rows.append({ "task_id": task_id, "mean_dist": None, "max_dist": None, "status": "失败", "message": message }) continue task_distance_data[task_id] = { "points_np": result["points_np"], "distances_np": result["distances_np"] } result_rows.append({ "task_id": task_id, "mean_dist": result["mean_dist"], "max_dist": result["max_dist"], "status": "完成", "message": "" }) excel_path = write_flatness_excel( result_rows, excel_output, sheet_name="平整度" ) print(f"Excel 已保存: {excel_path}") return result_rows, task_distance_data # ============================================================ # 命令行入口 # ============================================================ if __name__ == "__main__": parser = argparse.ArgumentParser( description="计算点云到 CAD STEP 模型的最近距离,支持单任务或批量任务。" ) parser.add_argument( "--CAD", "-c", default="High_Speed_Data/CAD/11.step", help="CAD STEP 文件路径,仅在 --single 单任务模式下使用。" ) parser.add_argument( "--pointcloud", "-p", default="High_Speed_Data/Noise_PointCloud/11_noisy.ply", help="点云 PLY 文件路径,仅在 --single 单任务模式下使用。" ) parser.add_argument( "--tasks", default=",".join(str(task_id) for task_id in DEFAULT_TASK_IDS), help="批量任务号,多个任务用英文逗号分隔。" ) parser.add_argument( "--data_root", default="High_Speed_Data", help="批量任务的数据根目录。" ) parser.add_argument( "--pointcloud_kind", choices=["noise", "clean"], default="noise", help="批量任务点云来源:noise=Noise_PointCloud,clean=PointCloud。" ) parser.add_argument( "--excel_output", default="平整度.xlsx", help="批量结果 Excel 输出路径。" ) parser.add_argument( "--single", action="store_true", help="使用 --CAD 和 --pointcloud 执行单任务,而不是默认批量任务。" ) parser.add_argument( "--visualize", action="store_true", help="显示单任务热力图。批量模式不会自动可视化。" ) parser.add_argument( "--mesh_deflection", default=0.1, type=float, help="CAD 三角化精度,越小越精细,但计算越慢。" ) parser.add_argument( "--point_chunk_size", default=2048, type=int, help="点云分块大小,显存不够时可调小。" ) parser.add_argument( "--triangle_chunk_size", default=4096, type=int, help="三角面分块大小,显存不够时可调小。" ) args = parser.parse_args() if args.single: result = compute_point_CAD_distance( CAD_path=args.CAD, pointcloud_path=args.pointcloud, mesh_deflection=args.mesh_deflection, point_chunk_size=args.point_chunk_size, triangle_chunk_size=args.triangle_chunk_size, visualize=args.visualize ) print("===== 计算结果 =====") print(f"平均距离: {result['mean_dist']:.8f} mm") print(f"最大距离: {result['max_dist']:.8f} mm") else: task_ids = [ int(task_id.strip()) for task_id in args.tasks.split(",") if task_id.strip() ] result_rows, task_distance_data = run_batch_tasks( task_ids=task_ids, data_root=args.data_root, pointcloud_kind=args.pointcloud_kind, excel_output=args.excel_output, mesh_deflection=args.mesh_deflection, point_chunk_size=args.point_chunk_size, triangle_chunk_size=args.triangle_chunk_size ) print("===== 批量计算结果 =====") for row in result_rows: if row["status"] == "完成": print( f"{row['task_id']}: " f"平均距离={row['mean_dist']:.8f} mm, " f"最大距离={row['max_dist']:.8f} mm" ) else: print(f"{row['task_id']}: {row['status']} - {row['message']}") print("task_distance_data 已按任务号保存 points_np 和 distances_np。")