Files
lipan 29f1d45d66 feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
2026-06-25 08:22:20 +00:00

1594 lines
50 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import argparse
import posixpath
import time
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
from xml.sax.saxutils import escape
from xml.etree import ElementTree as ET
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
]
DEFAULT_CANDIDATE_TRIANGLE_COUNT = 32
DEFAULT_RESULT_TEMPLATE = Path("excel/测量结果.xlsx")
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 read_mesh_file(mesh_path):
mesh_path = Path(mesh_path)
if o3d is not None:
mesh = o3d.io.read_triangle_mesh(str(mesh_path))
if mesh.has_vertices() and mesh.has_triangles():
vertices_np = np.asarray(mesh.vertices, dtype=np.float64)
faces_np = np.asarray(mesh.triangles, dtype=np.int64)
print("Mesh 文件读取完成")
print(f"Mesh 顶点: {vertices_np.shape}")
print(f"Mesh 三角面: {faces_np.shape}")
return vertices_np, faces_np
mesh = trimesh.load(str(mesh_path), force="mesh")
vertices_np = np.asarray(mesh.vertices, dtype=np.float64)
faces_np = np.asarray(mesh.faces, dtype=np.int64)
if vertices_np.shape[0] == 0 or faces_np.shape[0] == 0:
raise RuntimeError(f"Mesh 文件读取失败或为空: {mesh_path}")
print("Mesh 文件读取完成")
print(f"Mesh 顶点: {vertices_np.shape}")
print(f"Mesh 三角面: {faces_np.shape}")
return vertices_np, faces_np
def read_model_mesh(model_path, mesh_deflection):
model_path = Path(model_path)
suffix = model_path.suffix.lower()
if suffix in {".step", ".stp"}:
shape = read_step_file(model_path)
print("正在对 CAD 模型进行三角化...")
mesh = BRepMesh_IncrementalMesh(shape, mesh_deflection)
mesh.Perform()
return occ_shape_to_numpy_mesh(shape)
if suffix in {".stl", ".obj", ".ply"}:
return read_mesh_file(model_path)
raise ValueError(f"不支持的模型文件格式: {model_path}")
# ============================================================
# 点到三角形距离
# ============================================================
def point_to_triangle_distance_squared(points, triangles):
"""
points:
[P, 3]
triangles:
[T, 3, 3] or [P, K, 3, 3]
返回:
dist2: [P, T] or [P, K]
"""
p = points[:, None, :]
if triangles.dim() == 3:
a = triangles[None, :, 0, :]
b = triangles[None, :, 1, :]
c = triangles[None, :, 2, :]
elif triangles.dim() == 4:
a = triangles[:, :, 0, :]
b = triangles[:, :, 1, :]
c = triangles[:, :, 2, :]
else:
raise ValueError(
f"triangles must have shape [T, 3, 3] or [P, K, 3, 3], got {tuple(triangles.shape)}"
)
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)
return torch.where(torch.isinf(dist2), face_dist2, dist2)
# ============================================================
# 分块计算最近距离
# ============================================================
def nearest_aabb_candidate_indices(points, triangle_mins, triangle_maxs, candidate_count):
"""
参考 test8.py,用点到三角形 AABB 的距离先筛出每个点附近的候选三角形。
points:
[P, 3]
triangle_mins / triangle_maxs:
[M, 3]
返回:
candidate_indices: [P, K]
"""
below = (triangle_mins[None, :, :] - points[:, None, :]).clamp_min(0.0)
above = (points[:, None, :] - triangle_maxs[None, :, :]).clamp_min(0.0)
aabb_dist2 = ((below + above) ** 2).sum(dim=-1)
k = min(candidate_count, triangle_mins.shape[0])
return torch.topk(aabb_dist2, k=k, dim=1, largest=False).indices
def nearest_mesh_distances(
points,
triangles,
point_chunk_size=2048,
triangle_chunk_size=4096,
candidate_triangle_count=DEFAULT_CANDIDATE_TRIANGLE_COUNT
):
"""
分块计算点云到三角网格的最近距离。
默认使用 test8.py 的 AABB 候选三角形筛选方法:
1. 先按点到三角形包围盒的距离选出 K 个候选三角形;
2. 再对候选三角形计算精确点到三角形距离。
如果 candidate_triangle_count <= 0,则退回旧的全量三角面分块精确搜索。
points:
[N, 3]
triangles:
[M, 3, 3]
返回:
distances: [N]
"""
all_distances = []
num_points = points.shape[0]
num_triangles = triangles.shape[0]
if candidate_triangle_count > 0:
triangle_mins = triangles.min(dim=1).values
triangle_maxs = triangles.max(dim=1).values
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]
candidate_idx = nearest_aabb_candidate_indices(
point_chunk,
triangle_mins,
triangle_maxs,
candidate_triangle_count
)
candidate_triangles = triangles[candidate_idx]
dist2 = point_to_triangle_distance_squared(
point_chunk,
candidate_triangles
)
all_distances.append(torch.sqrt(dist2.min(dim=1).values))
return torch.cat(all_distances, dim=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))
return torch.cat(all_distances, dim=0)
# ============================================================
# 热力图显示
# ============================================================
def visualize_point_cloud_heatmap(points_np, distances_np):
if o3d is None:
print("未安装 open3d,跳过可视化。")
return
pcd_show = build_heatmap_point_cloud(points_np, distances_np)
print("色阶规则:深蓝/紫色 = 最近,红色 = 最远")
o3d.visualization.draw_geometries(
[pcd_show],
window_name="点云到 CAD 距离热力图"
)
def build_heatmap_point_cloud(points_np, distances_np):
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)
return pcd_show
def count_task_result_points(task_results):
total = 0
for task_id in sorted(task_results, key=task_sort_key):
result = task_results[task_id]
for sample_result in result.get("sample_results", []):
total += int(len(sample_result["points_np"]))
return total
def read_binary_xyz_ply_header(ply_path):
with open(ply_path, "rb") as file:
header_lines = []
while True:
line = file.readline()
if not line:
raise ValueError(f"Invalid PLY header: {ply_path}")
header_lines.append(line)
if line.strip() == b"end_header":
break
header_size = file.tell()
text_lines = [
line.decode("ascii", errors="replace").strip()
for line in header_lines
]
if len(text_lines) < 3 or text_lines[0] != "ply":
raise ValueError(f"Not a PLY file: {ply_path}")
if text_lines[1] != "format binary_little_endian 1.0":
raise ValueError(
"Only binary_little_endian PLY context point clouds can be streamed."
)
vertex_count = None
vertex_properties = []
in_vertex = False
for line in text_lines[2:]:
parts = line.split()
if not parts:
continue
if parts[0] == "element":
in_vertex = parts[1] == "vertex"
if in_vertex:
vertex_count = int(parts[2])
continue
if in_vertex and parts[0] == "property":
if len(parts) >= 2 and parts[1] == "list":
raise ValueError("List properties are not supported in streamed PLY.")
if len(parts) >= 3:
vertex_properties.append((parts[1], parts[2]))
expected = [("float", "x"), ("float", "y"), ("float", "z")]
if vertex_count is None or vertex_properties[:3] != expected:
raise ValueError(
"Context PLY must start with vertex properties: float x, float y, float z."
)
if len(vertex_properties) != 3:
raise ValueError(
"Context PLY must contain only float x/y/z vertices for streamed merge."
)
return vertex_count, header_size
def write_xyz_rgb_chunk(file, points, colors):
points = np.asarray(points)
colors = np.asarray(colors)
count = len(points)
if count == 0:
return
vertex_dtype = np.dtype([
("x", "<f4"),
("y", "<f4"),
("z", "<f4"),
("red", "u1"),
("green", "u1"),
("blue", "u1"),
])
vertices = np.empty(count, dtype=vertex_dtype)
vertices["x"] = points[:, 0].astype(np.float32, copy=False)
vertices["y"] = points[:, 1].astype(np.float32, copy=False)
vertices["z"] = points[:, 2].astype(np.float32, copy=False)
if colors.ndim == 1:
vertices["red"] = int(colors[0])
vertices["green"] = int(colors[1])
vertices["blue"] = int(colors[2])
else:
colors = colors.astype(np.uint8, copy=False)
vertices["red"] = colors[:, 0]
vertices["green"] = colors[:, 1]
vertices["blue"] = colors[:, 2]
file.write(vertices.tobytes())
def write_context_xyz_ply_stream(input_path, output_file, header_size, point_count):
record_size = 12
chunk_points = 1_000_000
gray = np.array([158, 158, 158], dtype=np.uint8)
with open(input_path, "rb") as input_file:
input_file.seek(header_size)
remaining = point_count
while remaining > 0:
current = min(chunk_points, remaining)
data = input_file.read(current * record_size)
if len(data) != current * record_size:
raise IOError(f"Unexpected end of context PLY: {input_path}")
points = np.frombuffer(data, dtype="<f4").reshape(-1, 3)
write_xyz_rgb_chunk(output_file, points, gray)
remaining -= current
def write_task_results_stream(task_results, output_file):
chunk_points = 1_000_000
for task_id in sorted(task_results, key=task_sort_key):
result = task_results[task_id]
for sample_result in result.get("sample_results", []):
points_np = np.asarray(sample_result["points_np"])
distances_np = np.asarray(sample_result["distances_np"])
if len(points_np) == 0:
continue
d_min = float(distances_np.min())
d_max = float(distances_np.max())
denom = d_max - d_min + 1e-12
for start in range(0, len(points_np), chunk_points):
end = min(start + chunk_points, len(points_np))
dist_norm = (distances_np[start:end] - d_min) / denom
colors = (plt.cm.turbo(dist_norm)[:, :3] * 255.0 + 0.5).astype(np.uint8)
write_xyz_rgb_chunk(output_file, points_np[start:end], colors)
def save_combined_successful_results_stream(
task_results,
output_path,
context_pointcloud_path=None
):
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
context_point_count = 0
context_header_size = None
context_path = Path(context_pointcloud_path) if context_pointcloud_path else None
if context_path:
if context_path.exists():
context_point_count, context_header_size = read_binary_xyz_ply_header(context_path)
print(f"完整点云将以流式方式加入合并结果: {context_path}")
else:
print(f"完整点云不存在,跳过: {context_path}")
context_path = None
task_point_count = count_task_result_points(task_results)
total_point_count = context_point_count + task_point_count
if total_point_count == 0:
print("没有成功执行的可视化结果,跳过合并点云保存。")
return None
header = (
"ply\n"
"format binary_little_endian 1.0\n"
f"element vertex {total_point_count}\n"
"property float x\n"
"property float y\n"
"property float z\n"
"property uchar red\n"
"property uchar green\n"
"property uchar blue\n"
"end_header\n"
)
with open(output_path, "wb") as output_file:
output_file.write(header.encode("ascii"))
if context_path:
write_context_xyz_ply_stream(
context_path,
output_file,
context_header_size,
context_point_count
)
write_task_results_stream(task_results, output_file)
print(f"合并点云已保存: {output_path}")
return output_path
def build_combined_successful_point_cloud(
task_results,
context_pointcloud_path="datasets/train/train_2_10M.ply",
arrange_grid=False
):
if o3d is None:
return None
task_geometries = []
for task_id in sorted(task_results, key=task_sort_key):
result = task_results[task_id]
for sample_result in result.get("sample_results", []):
pcd_show = build_heatmap_point_cloud(
sample_result["points_np"],
sample_result["distances_np"]
)
task_geometries.append(pcd_show)
if not task_geometries:
return None
geometries = []
if context_pointcloud_path:
context_path = Path(context_pointcloud_path)
if context_path.exists():
context = o3d.io.read_point_cloud(str(context_path))
if len(context.points) > 0:
if not context.has_colors():
context.paint_uniform_color([0.62, 0.62, 0.62])
geometries.append(context)
print(f"完整点云已加入合并结果: {context_path}")
else:
print(f"完整点云不存在,跳过: {context_path}")
geometries.extend(task_geometries)
if arrange_grid and len(geometries) > 1:
extents = [
np.asarray(geometry.get_max_bound()) - np.asarray(geometry.get_min_bound())
for geometry in geometries
]
max_extent = max(float(np.max(extent)) for extent in extents)
spacing = max(max_extent * 1.35, 1.0)
cols = int(np.ceil(np.sqrt(len(geometries))))
for index, geometry in enumerate(geometries):
row, col = divmod(index, cols)
center = (
np.asarray(geometry.get_min_bound())
+ np.asarray(geometry.get_max_bound())
) / 2.0
target_center = np.array([col * spacing, -row * spacing, 0.0])
geometry.translate(target_center - center)
combined = o3d.geometry.PointCloud()
for geometry in geometries:
if not geometry.has_colors():
geometry.paint_uniform_color([0.62, 0.62, 0.62])
combined += geometry
return combined
def save_all_successful_results(
task_results,
output_path="vis/combined_contour.ply",
context_pointcloud_path="datasets/train/train_2_10M.ply"
):
try:
return save_combined_successful_results_stream(
task_results,
output_path=output_path,
context_pointcloud_path=context_pointcloud_path
)
except Exception as exc:
context_path = Path(context_pointcloud_path) if context_pointcloud_path else None
if context_path and context_path.exists() and context_path.stat().st_size > 1024 ** 3:
raise RuntimeError(
f"大文件流式合并失败,无法回退到 Open3D 整体加载: {exc}"
) from exc
print(f"流式合并失败,回退到 Open3D 合并: {exc}")
if o3d is None:
print("未安装 open3d,跳过合并点云保存。")
return None
combined = build_combined_successful_point_cloud(
task_results,
context_pointcloud_path=context_pointcloud_path
)
if combined is None or len(combined.points) == 0:
print("没有成功执行的可视化结果,跳过合并点云保存。")
return None
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
ok = o3d.io.write_point_cloud(str(output_path), combined)
if not ok:
raise IOError(f"合并点云保存失败: {output_path}")
print(f"合并点云已保存: {output_path}")
return output_path
def visualize_all_successful_results(
task_results,
context_pointcloud_path="datasets/train/train_2_10M.ply"
):
if o3d is None:
print("未安装 open3d,跳过可视化。")
return
combined = build_combined_successful_point_cloud(
task_results,
context_pointcloud_path=context_pointcloud_path
)
if combined is None or len(combined.points) == 0:
print("没有成功执行的可视化结果。")
return
print("色阶规则:深蓝/紫色 = 最近,红色 = 最远")
o3d.visualization.draw_geometries(
[combined],
window_name="批量点云到 CAD 距离热力图",
width=1280,
height=900
)
def visualize_task_result(task_results, task_id, sample_id=None):
"""
task_results: run_batch_tasks 返回的字典,key 为任务号。
task_id: 需要可视化的任务号。
sample_id: 可选样本名,例如 13_2。不传时显示该任务第一个成功样本。
"""
result = task_results.get(str(task_id)) or task_results.get(int(task_id))
if result is None:
raise KeyError(f"未找到任务 {task_id} 的计算结果。")
if "sample_results" in result:
sample_results = result["sample_results"]
if not sample_results:
raise KeyError(f"任务 {task_id} 没有可视化数据。")
if sample_id is None:
sample_result = sample_results[0]
else:
sample_result = next(
(
item for item in sample_results
if item.get("sample_id") == str(sample_id)
),
None
)
if sample_result is None:
raise KeyError(f"任务 {task_id} 中未找到样本 {sample_id}。")
visualize_point_cloud_heatmap(
sample_result["points_np"],
sample_result["distances_np"]
)
return
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'<c r="{cell_ref}"><v>{value}</v></c>'
text = escape("" if value is None else str(value))
return f'<c r="{cell_ref}" t="inlineStr"><is><t>{text}</t></is></c>'
def _xlsx_col_to_index(cell_ref):
letters = "".join(ch for ch in cell_ref if ch.isalpha())
index = 0
for letter in letters:
index = index * 26 + ord(letter.upper()) - ord("A") + 1
return index
def _xlsx_rel_target_to_path(target):
target = target.replace("\\", "/").lstrip("/")
if target.startswith("xl/"):
return target
return posixpath.normpath(posixpath.join("xl", target))
def _xlsx_cell_value(cell, shared_strings, ns):
value_node = cell.find("a:v", ns)
inline_node = cell.find("a:is/a:t", ns)
cell_type = cell.attrib.get("t")
if cell_type == "s" and value_node is not None:
return shared_strings[int(value_node.text)]
if cell_type == "inlineStr" and inline_node is not None:
return inline_node.text or ""
if value_node is not None:
return value_node.text or ""
return ""
def read_measurement_template(template_path):
template_path = Path(template_path)
ns = {
"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
with ZipFile(template_path) as xlsx:
names = xlsx.namelist()
shared_strings = []
if "xl/sharedStrings.xml" in names:
shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml"))
for item in shared_root.findall("a:si", ns):
shared_strings.append(
"".join((text.text or "") for text in item.findall(".//a:t", ns))
)
workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml"))
first_sheet = workbook_root.find("a:sheets/a:sheet", ns)
if first_sheet is None:
raise RuntimeError(f"模板中没有工作表: {template_path}")
sheet_name = first_sheet.attrib.get("name", "Sheet1")
relation_id = first_sheet.attrib[
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
]
rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels"))
relation_targets = {
rel.attrib["Id"]: rel.attrib["Target"]
for rel in rels_root
}
sheet_path = _xlsx_rel_target_to_path(relation_targets[relation_id])
sheet_root = ET.fromstring(xlsx.read(sheet_path))
parsed_rows = []
max_col_count = 0
for row in sheet_root.findall("a:sheetData/a:row", ns):
values_by_col = {}
for cell in row.findall("a:c", ns):
col_index = _xlsx_col_to_index(cell.attrib["r"])
values_by_col[col_index] = _xlsx_cell_value(cell, shared_strings, ns)
max_col_count = max(max_col_count, col_index)
if values_by_col:
parsed_rows.append(values_by_col)
table = []
for values_by_col in parsed_rows:
table.append([
values_by_col.get(col_index, "")
for col_index in range(1, max_col_count + 1)
])
if not table:
raise RuntimeError(f"模板为空: {template_path}")
return sheet_name, table[0], table[1:]
def _normalize_template_task_id(value):
text = str(value).strip()
if text.endswith(".0") and text[:-2].isdigit():
return text[:-2]
return text
def _find_header_index(headers, candidates):
normalized = [str(header).strip() for header in headers]
for candidate in candidates:
if candidate in normalized:
return normalized.index(candidate)
raise ValueError(f"模板缺少列: {'/'.join(candidates)}")
def _row_measurement_conclusion(row):
mean_dist = row.get("mean_dist")
if mean_dist is None:
return "失败"
try:
if not np.isfinite(float(mean_dist)):
return "失败"
except (TypeError, ValueError):
return "失败"
return "成功"
def _build_measurement_table_from_template(rows, template_path):
template_sheet_name, headers, template_rows = read_measurement_template(template_path)
task_col = _find_header_index(headers, ["任务号", "任务编号", "task_id"])
result_col = _find_header_index(headers, ["测量结果"])
conclusion_col = _find_header_index(headers, ["测量结论", "结论", "状态"])
result_by_task = {
_normalize_template_task_id(row.get("task_id")): row
for row in rows
}
used_task_ids = set()
table = [headers]
for template_row in template_rows:
output_row = list(template_row)
if len(output_row) < len(headers):
output_row.extend([""] * (len(headers) - len(output_row)))
task_id = _normalize_template_task_id(output_row[task_col])
result_row = result_by_task.get(task_id)
if result_row is None:
continue
output_row[result_col] = result_row.get("mean_dist")
output_row[conclusion_col] = _row_measurement_conclusion(result_row)
table.append(output_row)
used_task_ids.add(task_id)
for task_id, result_row in result_by_task.items():
if task_id in used_task_ids:
continue
output_row = [""] * len(headers)
output_row[task_col] = task_id
output_row[result_col] = result_row.get("mean_dist")
output_row[conclusion_col] = _row_measurement_conclusion(result_row)
table.append(output_row)
return template_sheet_name, table
def write_flatness_excel(
rows,
output_path,
sheet_name="平整度",
result_template_path=DEFAULT_RESULT_TEMPLATE
):
"""
使用 Python 标准库生成 .xlsx,避免额外依赖。
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
if result_template_path and Path(result_template_path).exists():
sheet_name, table = _build_measurement_table_from_template(
rows,
result_template_path
)
else:
headers = ["任务号", "样本数", "平均距离(mm)", "最大距离(mm)", "状态", "说明"]
table = [headers]
for row in rows:
table.append([
row.get("task_id"),
row.get("sample_count"),
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'<row r="{row_index}">{"".join(cells)}</row>')
safe_sheet_name = escape(sheet_name[:31])
workbook_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
f'<sheets><sheet name="{safe_sheet_name}" sheetId="1" r:id="rId1"/></sheets>'
'</workbook>'
)
worksheet_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<sheetData>'
f'{"".join(sheet_rows)}'
'</sheetData>'
'</worksheet>'
)
content_types_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'</Types>'
)
root_rels_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.xml"/>'
'</Relationships>'
)
workbook_rels_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
'Target="worksheets/sheet1.xml"/>'
'</Relationships>'
)
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,
candidate_triangle_count=DEFAULT_CANDIDATE_TRIANGLE_COUNT,
visualize=False
):
print("计算设备:", device)
print("数据精度:", dtype)
# 读取 STEP CAD 或 STL/OBJ/PLY Mesh,并统一转换为 numpy 三角网格。
vertices_np, faces_np = read_model_mesh(CAD_path, mesh_deflection)
# 读取点云
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("开始计算点云到模型表面的最近距离...")
print("点云张量:", points.shape)
print("三角面张量:", triangles.shape)
print("候选三角面数量:", candidate_triangle_count)
with torch.no_grad():
distances_torch = nearest_mesh_distances(
points,
triangles,
point_chunk_size=point_chunk_size,
triangle_chunk_size=triangle_chunk_size,
candidate_triangle_count=candidate_triangle_count
)
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 task_id_from_sample_name(sample_name):
return str(sample_name).split("_", 1)[0]
def normalize_task_id(task_id):
return str(task_id).strip()
def task_sort_key(value):
base = task_id_from_sample_name(value)
if str(base).isdigit():
return (0, int(base), str(value))
return (1, str(base), str(value))
def pointcloud_path_for_sample(data_root, sample_id, pointcloud_kind):
data_root = Path(data_root)
if pointcloud_kind == "clean":
return data_root / "PointCloud" / f"{sample_id}.ply"
if pointcloud_kind == "noise":
return data_root / "Noise_PointCloud" / f"{sample_id}_noisy.ply"
raise ValueError("pointcloud_kind must be 'noise' or 'clean'.")
def mesh_sample_paths_for_task(data_root, requested_task):
data_root = Path(data_root)
mesh_dir = data_root / "Mesh"
requested_task = normalize_task_id(requested_task)
if not mesh_dir.exists():
return []
mesh_paths = []
for pattern in ("*.stl", "*.obj", "*.ply"):
mesh_paths.extend(mesh_dir.glob(pattern))
if "_" in requested_task:
matched = [
path for path in mesh_paths
if path.stem == requested_task
]
else:
matched = [
path for path in mesh_paths
if task_id_from_sample_name(path.stem) == requested_task
]
return sorted(
matched,
key=lambda path: task_sort_key(path.stem)
)
def get_task_paths(task_id, data_root="High_Speed_Data", pointcloud_kind="noise"):
data_root = Path(data_root)
task_id = normalize_task_id(task_id)
mesh_paths = mesh_sample_paths_for_task(data_root, task_id)
if mesh_paths:
return [
{
"task_id": task_id_from_sample_name(mesh_path.stem),
"sample_id": mesh_path.stem,
"model_path": mesh_path,
"pointcloud_path": pointcloud_path_for_sample(
data_root,
mesh_path.stem,
pointcloud_kind
),
}
for mesh_path in mesh_paths
]
sample_id = task_id
return [
{
"task_id": task_id_from_sample_name(sample_id),
"sample_id": sample_id,
"model_path": data_root / "CAD" / f"{sample_id}.step",
"pointcloud_path": pointcloud_path_for_sample(
data_root,
sample_id,
pointcloud_kind
),
}
]
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,
candidate_triangle_count=DEFAULT_CANDIDATE_TRIANGLE_COUNT,
result_template_path=DEFAULT_RESULT_TEMPLATE
):
global task_distance_data
task_summaries = {}
task_distance_data = {}
for task_id in task_ids:
task_inputs = get_task_paths(
task_id,
data_root=data_root,
pointcloud_kind=pointcloud_kind
)
for task_input in task_inputs:
base_task_id = task_input["task_id"]
sample_id = task_input["sample_id"]
model_path = task_input["model_path"]
pointcloud_path = task_input["pointcloud_path"]
summary = task_summaries.setdefault(
base_task_id,
{
"task_id": base_task_id,
"sample_ids": [],
"point_count": 0,
"distance_sum": 0.0,
"max_dist": None,
"failures": [],
"sample_results": [],
}
)
summary["sample_ids"].append(sample_id)
print(f"===== 任务 {base_task_id} / 样本 {sample_id} =====")
missing_paths = [
str(path)
for path in [model_path, pointcloud_path]
if not path.exists()
]
if missing_paths:
message = f"{sample_id} 缺少文件: " + "; ".join(missing_paths)
print(message)
summary["failures"].append(message)
continue
try:
result = compute_point_CAD_distance(
CAD_path=str(model_path),
pointcloud_path=str(pointcloud_path),
mesh_deflection=mesh_deflection,
point_chunk_size=point_chunk_size,
triangle_chunk_size=triangle_chunk_size,
candidate_triangle_count=candidate_triangle_count,
visualize=False
)
except Exception as exc:
message = f"{sample_id} 失败: {exc}"
print(message)
summary["failures"].append(message)
continue
distances_np = result["distances_np"]
point_count = len(distances_np)
summary["point_count"] += point_count
summary["distance_sum"] += float(distances_np.sum())
summary["max_dist"] = (
result["max_dist"]
if summary["max_dist"] is None
else max(summary["max_dist"], result["max_dist"])
)
summary["sample_results"].append({
"sample_id": sample_id,
"model_path": str(model_path),
"pointcloud_path": str(pointcloud_path),
"points_np": result["points_np"],
"distances_np": distances_np,
"mean_dist": result["mean_dist"],
"max_dist": result["max_dist"],
"min_dist": result["min_dist"],
})
result_rows = []
for task_id in sorted(
task_summaries,
key=task_sort_key
):
summary = task_summaries[task_id]
success_count = len(summary["sample_results"])
failure_count = len(summary["failures"])
if success_count:
mean_dist = summary["distance_sum"] / summary["point_count"]
max_dist = summary["max_dist"]
status = "完成" if failure_count == 0 else "部分完成"
else:
mean_dist = None
max_dist = None
status = "失败"
message_parts = []
if summary["sample_ids"]:
message_parts.append("样本: " + ",".join(summary["sample_ids"]))
if summary["failures"]:
message_parts.append("失败: " + " | ".join(summary["failures"]))
result_rows.append({
"task_id": task_id,
"sample_count": success_count,
"mean_dist": mean_dist,
"max_dist": max_dist,
"status": status,
"message": "; ".join(message_parts)
})
task_distance_data[task_id] = {
"sample_results": summary["sample_results"],
"sample_ids": [item["sample_id"] for item in summary["sample_results"]],
}
excel_path = write_flatness_excel(
result_rows,
excel_output,
sheet_name="平整度",
result_template_path=result_template_path
)
print(f"Excel 已保存: {excel_path}")
return result_rows, task_distance_data
# ============================================================
# 命令行入口
# ============================================================
if __name__ == "__main__":
start_time = time.perf_counter()
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="批量任务号或样本名,多个值用英文逗号分隔。例:13 会匹配 13_1/13_2;13_1 只匹配该样本。"
)
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(
"--result_template",
default=str(DEFAULT_RESULT_TEMPLATE),
help="测量结果模板 Excel 路径。新表会按任务号复制模板对应行,并写入测量结果和测量结论。"
)
parser.add_argument(
"--combined_output",
default="vis/combined_contour.ply",
help="批量模式下所有成功热力图合并后的 PLY 输出路径。"
)
parser.add_argument(
"--combined_context_pointcloud",
default="datasets/train/train_2_10M.ply",
help="批量合并热力图时一起加入的完整点云路径,可切换为 train_2_1T.ply 或 train_2_100M.ply。"
)
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="全量精确搜索模式下的三角面分块大小;candidate_triangle_count <= 0 时使用。"
)
parser.add_argument(
"--candidate_triangle_count",
default=DEFAULT_CANDIDATE_TRIANGLE_COUNT,
type=int,
help="每个点按 AABB 预筛的候选三角面数量;设为 0 或负数则使用全量精确搜索。"
)
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,
candidate_triangle_count=args.candidate_triangle_count,
visualize=args.visualize
)
print("===== 计算结果 =====")
print(f"平均距离: {result['mean_dist']:.8f} mm")
print(f"最大距离: {result['max_dist']:.8f} mm")
elapsed_time = time.perf_counter() - start_time
print(f"运行时间: {elapsed_time:.2f} 秒")
else:
task_ids = [
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,
candidate_triangle_count=args.candidate_triangle_count,
result_template_path=args.result_template
)
print("===== 批量计算结果 =====")
for row in result_rows:
if row["status"] in {"完成", "部分完成"}:
print(
f"{row['task_id']}: "
f"样本数={row['sample_count']}, "
f"平均距离={row['mean_dist']:.8f} mm, "
f"最大距离={row['max_dist']:.8f} mm"
)
else:
print(f"{row['task_id']}: {row['status']} - {row['message']}")
elapsed_time = time.perf_counter() - start_time
print(f"运行时间: {elapsed_time:.2f} 秒")
print("task_distance_data 已按任务号保存 sample_results。")
save_all_successful_results(
task_distance_data,
output_path=args.combined_output,
context_pointcloud_path=args.combined_context_pointcloud
)
if args.visualize:
visualize_all_successful_results(
task_distance_data,
context_pointcloud_path=args.combined_context_pointcloud
)