import os import argparse import time import hashlib import numpy as np import open3d as o3d from OCC.Core.STEPControl import STEPControl_Reader from OCC.Core.IFSelect import IFSelect_RetDone from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopAbs import TopAbs_FACE from OCC.Core.BRep import BRep_Tool try: from OCC.Core.TopoDS import topods USE_TOPODS = "topods" except ImportError: from OCC.Core.TopoDS import TopoDS USE_TOPODS = "TopoDS" def format_elapsed(seconds): if seconds < 1.0: return f"{seconds * 1000.0:.1f} ms" minutes, seconds = divmod(seconds, 60.0) if minutes < 1.0: return f"{seconds:.2f} s" hours, minutes = divmod(minutes, 60.0) if hours < 1.0: return f"{int(minutes)} min {seconds:.2f} s" return f"{int(hours)} h {int(minutes)} min {seconds:.2f} s" def log_elapsed(label, start_time): elapsed = time.perf_counter() - start_time print(f"[TIME] {label}: {format_elapsed(elapsed)}") return elapsed def build_cad_sample_cache_path(cad_path, output_dir, mesh_deflection, cad_sample_points): stat = os.stat(cad_path) cache_key = "|".join([ os.path.abspath(cad_path), str(stat.st_size), str(stat.st_mtime_ns), f"{mesh_deflection:.12g}", str(cad_sample_points), ]) digest = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:16] cad_name = os.path.splitext(os.path.basename(cad_path))[0] or "cad" return os.path.join(output_dir, "cad_cache", f"{cad_name}_{digest}.ply") # ============================================================ # STEP CAD -> Open3D Mesh # ============================================================ def read_step_file(step_path): reader = STEPControl_Reader() status = reader.ReadFile(step_path) if status != IFSelect_RetDone: raise RuntimeError(f"STEP 文件读取失败: {step_path}") reader.TransferRoots() shape = reader.OneShape() print("[OK] STEP loaded:", step_path) return shape def occ_shape_to_numpy_mesh(shape): vertices = [] triangles = [] explorer = TopExp_Explorer(shape, TopAbs_FACE) face_count = 0 tri_count = 0 while explorer.More(): current_shape = explorer.Current() if USE_TOPODS == "topods": face = topods.Face(current_shape) else: face = TopoDS.Face(current_shape) location = face.Location() triangulation = BRep_Tool.Triangulation(face, location) if triangulation is None: explorer.Next() continue trsf = location.Transformation() base_index = len(vertices) for i in range(1, triangulation.NbNodes() + 1): pnt = triangulation.Node(i) pnt.Transform(trsf) vertices.append([ pnt.X(), pnt.Y(), pnt.Z() ]) for i in range(1, triangulation.NbTriangles() + 1): tri = triangulation.Triangle(i) n1, n2, n3 = tri.Get() triangles.append([ base_index + n1 - 1, base_index + n2 - 1, base_index + n3 - 1 ]) tri_count += 1 face_count += 1 explorer.Next() vertices = np.asarray(vertices, dtype=np.float64) triangles = np.asarray(triangles, dtype=np.int32) print("[OK] CAD mesh extracted") print(" CAD faces:", face_count) print(" mesh vertices:", vertices.shape) print(" mesh triangles:", triangles.shape) if vertices.shape[0] == 0 or triangles.shape[0] == 0: raise RuntimeError("CAD 三角网格提取失败,请检查 STEP 文件") return vertices, triangles def step_to_open3d_mesh(step_path, mesh_deflection=0.1): shape = read_step_file(step_path) print("[INFO] Meshing CAD...") mesh_algo = BRepMesh_IncrementalMesh(shape, mesh_deflection) mesh_algo.Perform() vertices, triangles = occ_shape_to_numpy_mesh(shape) mesh = o3d.geometry.TriangleMesh() mesh.vertices = o3d.utility.Vector3dVector(vertices) mesh.triangles = o3d.utility.Vector3iVector(triangles) mesh.remove_duplicated_vertices() mesh.remove_duplicated_triangles() mesh.remove_degenerate_triangles() mesh.compute_vertex_normals() return mesh # ============================================================ # 工具函数 # ============================================================ def axis_to_index(axis): axis = axis.lower() if axis == "x": return 0 if axis == "y": return 1 if axis == "z": return 2 raise ValueError("axis 必须是 x / y / z") def crop_end_region_by_ratio(pcd, axis="x", part="front", ratio=0.08): """ 按长度方向裁剪前端/后端区域。 axis: 高铁长度方向,一般是 x part: front: 轴最小端 tail: 轴最大端 ratio: 取前后端占整体长度的比例,例如 0.08 表示取前 8% 和后 8% """ points = np.asarray(pcd.points) axis_id = axis_to_index(axis) min_bound = points.min(axis=0) max_bound = points.max(axis=0) axis_min = min_bound[axis_id] axis_max = max_bound[axis_id] axis_len = axis_max - axis_min if part == "front": lower = axis_min upper = axis_min + axis_len * ratio elif part == "tail": lower = axis_max - axis_len * ratio upper = axis_max else: raise ValueError("part 必须是 front 或 tail") crop_min = min_bound.copy() crop_max = max_bound.copy() crop_min[axis_id] = lower crop_max[axis_id] = upper bbox = o3d.geometry.AxisAlignedBoundingBox( min_bound=crop_min, max_bound=crop_max ) cropped = pcd.crop(bbox) print(f"[OK] Crop {part}") print(" range:", lower, upper) print(" points:", np.asarray(cropped.points).shape) return cropped def sample_farthest_points_simple(pcd, num_points=5000, rng=None): """ 为了避免百万点太多,这里随机采样少量点。 如果点数不足,则直接返回原点云。 """ points = np.asarray(pcd.points) n = points.shape[0] if n <= num_points: return pcd if rng is None: rng = np.random.default_rng() indices = rng.choice(n, size=num_points, replace=False) sampled = pcd.select_by_index(indices.tolist()) return sampled def compute_centroid(pcd): points = np.asarray(pcd.points) if points.shape[0] == 0: raise RuntimeError("点云为空,无法计算质心") return points.mean(axis=0) def compute_alignment_error_stats(source_pcd, cad_pcd, sample_points=100000, rng=None): if sample_points is not None and sample_points > 0: eval_pcd = sample_farthest_points_simple( source_pcd, num_points=sample_points, rng=rng ) else: eval_pcd = source_pcd distances = np.asarray( eval_pcd.compute_point_cloud_distance(cad_pcd), dtype=np.float64 ) if distances.shape[0] == 0: raise RuntimeError("No points available for alignment error statistics") squared = distances * distances return { "sample_count": int(distances.shape[0]), "mean": float(np.mean(distances)), "rmse": float(np.sqrt(np.mean(squared))), "median": float(np.median(distances)), "p95": float(np.percentile(distances, 95)), "p99": float(np.percentile(distances, 99)), "max": float(np.max(distances)), } def print_alignment_error_stats(stats): print("\n========== Alignment Error ==========") print("sample count:", stats["sample_count"]) print("mean: ", stats["mean"]) print("rmse: ", stats["rmse"]) print("median: ", stats["median"]) print("p95: ", stats["p95"]) print("p99: ", stats["p99"]) print("max: ", stats["max"]) def save_alignment_error_stats(path, stats): with open(path, "w", encoding="utf-8") as f: for key in ["sample_count", "mean", "rmse", "median", "p95", "p99", "max"]: f.write(f"{key}: {stats[key]}\n") def write_point_cloud_float32_xyz(path, pcd, chunk_size=1_000_000): points = np.asarray(pcd.points) if points.shape[0] == 0: raise RuntimeError(f"Cannot write empty point cloud: {path}") os.makedirs(os.path.dirname(path), exist_ok=True) header = ( "ply\n" "format binary_little_endian 1.0\n" f"element vertex {points.shape[0]}\n" "property float x\n" "property float y\n" "property float z\n" "end_header\n" ) with open(path, "wb") as f: f.write(header.encode("ascii")) for start in range(0, points.shape[0], chunk_size): end = min(start + chunk_size, points.shape[0]) f.write(np.asarray(points[start:end], dtype=" 0: source_icp = sample_farthest_points_simple( source_pcd, num_points=sample_points, rng=rng ) else: source_icp = o3d.geometry.PointCloud(source_pcd) source_icp.transform(initial_transform) target_icp = cad_pcd if voxel_size is not None and voxel_size > 0: source_icp = source_icp.voxel_down_sample(voxel_size=voxel_size) target_icp = cad_pcd.voxel_down_sample(voxel_size=voxel_size) source_count = len(source_icp.points) target_count = len(target_icp.points) if source_count == 0 or target_count == 0: raise RuntimeError("ICP point cloud is empty after sampling/downsampling") if max_correspondence_distance is None or max_correspondence_distance <= 0: initial_distances = np.asarray( source_icp.compute_point_cloud_distance(target_icp), dtype=np.float64 ) p95 = float(np.percentile(initial_distances, 95)) max_correspondence_distance = max(p95 * 2.0, 1e-6) print("[INFO] ICP auto max correspondence distance:", max_correspondence_distance) result = o3d.pipelines.registration.registration_icp( source_icp, target_icp, max_correspondence_distance, np.eye(4, dtype=np.float64), o3d.pipelines.registration.TransformationEstimationPointToPoint(), o3d.pipelines.registration.ICPConvergenceCriteria( max_iteration=max_iteration ) ) icp_transform = result.transformation final_transform = icp_transform @ initial_transform stats = { "fitness": float(result.fitness), "inlier_rmse": float(result.inlier_rmse), "max_correspondence_distance": float(max_correspondence_distance), "source_points": int(source_count), "target_points": int(target_count), "transform": icp_transform, } return final_transform, stats def print_icp_stats(stats): print("\n========== ICP Refinement ==========") print("source points:", stats["source_points"]) print("target points:", stats["target_points"]) print("max correspondence distance:", stats["max_correspondence_distance"]) print("fitness:", stats["fitness"]) print("inlier rmse:", stats["inlier_rmse"]) print("ICP delta transform:") print(stats["transform"]) def visualize_alignment(source_pcd, cad_pcd, title="Translation Alignment Result"): source_vis = o3d.geometry.PointCloud(source_pcd) cad_vis = o3d.geometry.PointCloud(cad_pcd) source_vis.paint_uniform_color([1.0, 0.0, 0.0]) cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) o3d.visualization.draw_geometries( [source_vis, cad_vis], window_name=title ) # ============================================================ # 只估计平移的配准 # ============================================================ def resolve_pointcloud_path(pointcloud_path): if os.path.isdir(pointcloud_path): preferred = os.path.join(pointcloud_path, "shifted_whole_pointcloud.ply") if os.path.exists(preferred): return preferred candidates = [ os.path.join(pointcloud_path, name) for name in sorted(os.listdir(pointcloud_path)) if name.lower().endswith(".ply") ] if len(candidates) == 1: return candidates[0] if not candidates: raise FileNotFoundError(f"No PLY point cloud found in directory: {pointcloud_path}") raise ValueError( "Multiple PLY files found in directory; specify one file explicitly: " + ", ".join(os.path.basename(path) for path in candidates) ) return pointcloud_path def translate_align_by_front_tail( cad_path, pointcloud_path, output_dir="results", axis="x", crop_ratio=0.08, mesh_deflection=0.1, cad_sample_points=300000, local_sample_points=10000, scale_pointcloud=1.0, visualize=True, use_cad_cache=True, save_debug_samples=False, random_seed=0, error_sample_points=100000, compute_error_stats=True, save_aligned_float32=True, use_icp=True, icp_sample_points=200000, icp_voxel_size=0.0, icp_max_correspondence_distance=0.0, icp_max_iteration=50 ): total_start = time.perf_counter() stage_start = total_start rng = np.random.default_rng(random_seed) if random_seed is not None else np.random.default_rng() if random_seed is not None: try: o3d.utility.random.seed(int(random_seed)) except AttributeError: pass os.makedirs(output_dir, exist_ok=True) # ------------------------------------------------------------ # 1. CAD STEP 转 mesh,再采样成 CAD 点云 # ------------------------------------------------------------ cad_pcd = None cad_pcd_path = os.path.join(output_dir, "cad_sampled.ply") if use_cad_cache: cad_pcd_path = build_cad_sample_cache_path( cad_path, output_dir, mesh_deflection, cad_sample_points ) if os.path.exists(cad_pcd_path): cad_pcd = o3d.io.read_point_cloud(cad_pcd_path) if len(cad_pcd.points) > 0: print("[OK] CAD sample cache loaded:", cad_pcd_path) else: print("[WARN] CAD sample cache is empty, rebuilding:", cad_pcd_path) cad_pcd = None if cad_pcd is None: cad_mesh = step_to_open3d_mesh( cad_path, mesh_deflection=mesh_deflection ) print(f"[INFO] Sampling CAD mesh: {cad_sample_points} points") cad_pcd = cad_mesh.sample_points_uniformly( number_of_points=cad_sample_points ) os.makedirs(os.path.dirname(cad_pcd_path), exist_ok=True) o3d.io.write_point_cloud(cad_pcd_path, cad_pcd) print("[OK] CAD sample saved:", cad_pcd_path) log_elapsed("CAD sample load/build", stage_start) stage_start = time.perf_counter() # ------------------------------------------------------------ # 2. 读取扫描点云 # ------------------------------------------------------------ pointcloud_path = resolve_pointcloud_path(pointcloud_path) source_pcd = o3d.io.read_point_cloud(pointcloud_path) if len(source_pcd.points) == 0: raise RuntimeError(f"点云读取失败: {pointcloud_path}") print("[OK] Source point cloud loaded:", pointcloud_path) print(" source points:", np.asarray(source_pcd.points).shape) if scale_pointcloud != 1.0: source_pcd.scale(scale_pointcloud, center=(0, 0, 0)) print("[INFO] Source scaled:", scale_pointcloud) log_elapsed("source load", stage_start) stage_start = time.perf_counter() # ------------------------------------------------------------ # 3. 裁剪 CAD 前端 / 后端 # ------------------------------------------------------------ cad_front = crop_end_region_by_ratio( cad_pcd, axis=axis, part="front", ratio=crop_ratio ) cad_tail = crop_end_region_by_ratio( cad_pcd, axis=axis, part="tail", ratio=crop_ratio ) # ------------------------------------------------------------ # 4. 裁剪扫描点云前端 / 后端 # ------------------------------------------------------------ source_front = crop_end_region_by_ratio( source_pcd, axis=axis, part="front", ratio=crop_ratio ) source_tail = crop_end_region_by_ratio( source_pcd, axis=axis, part="tail", ratio=crop_ratio ) log_elapsed("crop regions", stage_start) stage_start = time.perf_counter() # ------------------------------------------------------------ # 5. 前后区域分别随机取少量点 # ------------------------------------------------------------ cad_front_sample = sample_farthest_points_simple( cad_front, num_points=local_sample_points, rng=rng ) cad_tail_sample = sample_farthest_points_simple( cad_tail, num_points=local_sample_points, rng=rng ) source_front_sample = sample_farthest_points_simple( source_front, num_points=local_sample_points, rng=rng ) source_tail_sample = sample_farthest_points_simple( source_tail, num_points=local_sample_points, rng=rng ) # 保存局部采样结果,方便检查 if save_debug_samples: o3d.io.write_point_cloud(os.path.join(output_dir, "cad_front_sample.ply"), cad_front_sample) o3d.io.write_point_cloud(os.path.join(output_dir, "cad_tail_sample.ply"), cad_tail_sample) o3d.io.write_point_cloud(os.path.join(output_dir, "source_front_sample.ply"), source_front_sample) o3d.io.write_point_cloud(os.path.join(output_dir, "source_tail_sample.ply"), source_tail_sample) print("[OK] Debug sample point clouds saved") else: print("[INFO] Debug sample point clouds skipped") log_elapsed("local sampling", stage_start) stage_start = time.perf_counter() # ------------------------------------------------------------ # 6. 计算前端和后端质心 # ------------------------------------------------------------ cad_front_center = compute_centroid(cad_front_sample) cad_tail_center = compute_centroid(cad_tail_sample) source_front_center = compute_centroid(source_front_sample) source_tail_center = compute_centroid(source_tail_sample) print("\n========== Centroids ==========") print("CAD front center: ", cad_front_center) print("CAD tail center: ", cad_tail_center) print("Source front center:", source_front_center) print("Source tail center: ", source_tail_center) # ------------------------------------------------------------ # 7. 分别根据车头和车尾估计平移 # ------------------------------------------------------------ t_front = cad_front_center - source_front_center t_tail = cad_tail_center - source_tail_center print("\n========== Translation From Front / Tail ==========") print("t_front:", t_front) print("t_tail: ", t_tail) print("front-tail translation difference:", np.linalg.norm(t_front - t_tail)) # ------------------------------------------------------------ # 8. 融合平移 # ------------------------------------------------------------ t_final = 0.5 * t_front + 0.5 * t_tail T_final = build_translation_matrix(t_final) print("\n========== Initial Translation ==========") print("t_initial:", t_final) print("T_initial:") print(T_final) log_elapsed("translation estimate", stage_start) stage_start = time.perf_counter() icp_stats = None if use_icp: T_final, icp_stats = run_icp_refinement( source_pcd, cad_pcd, T_final, sample_points=icp_sample_points, voxel_size=icp_voxel_size, max_correspondence_distance=icp_max_correspondence_distance, max_iteration=icp_max_iteration, rng=rng ) print_icp_stats(icp_stats) print("\n========== Final Rigid Transform ==========") print("final translation:", T_final[:3, 3]) print("T_final:") print(T_final) else: print("[INFO] ICP refinement skipped") log_elapsed("ICP refinement", stage_start) stage_start = time.perf_counter() # ------------------------------------------------------------ # 9. 应用平移到整个点云 # ------------------------------------------------------------ aligned_pcd = source_pcd aligned_pcd.transform(T_final) if use_icp: aligned_path = os.path.join(output_dir, "aligned_pointcloud.ply") matrix_path = os.path.join(output_dir, "T_rigid_icp.txt") else: aligned_path = os.path.join(output_dir, "aligned_pointcloud_translation_only.ply") matrix_path = os.path.join(output_dir, "T_translation_only.txt") if save_aligned_float32: write_point_cloud_float32_xyz(aligned_path, aligned_pcd) print("[INFO] Aligned point cloud saved as compact float32 XYZ PLY") else: o3d.io.write_point_cloud(aligned_path, aligned_pcd, write_ascii=False) print("[INFO] Aligned point cloud saved with Open3D default writer") np.savetxt(matrix_path, T_final, fmt="%.10f") print("\n[OK] Saved aligned point cloud:", aligned_path) print("[OK] Saved transformation matrix:", matrix_path) log_elapsed("save outputs", stage_start) stage_start = time.perf_counter() error_stats = None error_stats_path = os.path.join(output_dir, "alignment_error_stats.txt") if compute_error_stats: error_stats = compute_alignment_error_stats( aligned_pcd, cad_pcd, sample_points=error_sample_points, rng=rng ) print_alignment_error_stats(error_stats) save_alignment_error_stats(error_stats_path, error_stats) print("[OK] Saved alignment error stats:", error_stats_path) else: error_stats_path = None print("[INFO] Alignment error stats skipped") log_elapsed("alignment error stats", stage_start) # ------------------------------------------------------------ # 10. 可视化 # ------------------------------------------------------------ if visualize: visualize_start = time.perf_counter() # 为了不卡,显示时降采样 source_vis = aligned_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) cad_vis = cad_pcd.voxel_down_sample(voxel_size=max(crop_ratio, 1.0)) source_vis.paint_uniform_color([1.0, 0.0, 0.0]) cad_vis.paint_uniform_color([0.0, 0.5, 1.0]) o3d.visualization.draw_geometries( [source_vis, cad_vis], window_name="Translation Only Alignment: red=aligned source, blue=CAD" ) log_elapsed("visualization", visualize_start) total_elapsed = log_elapsed("total", total_start) return { "translation": T_final[:3, 3], "initial_translation": t_final, "T_final": T_final, "t_front": t_front, "t_tail": t_tail, "front_tail_translation_difference": float(np.linalg.norm(t_front - t_tail)), "icp_stats": icp_stats, "aligned_path": aligned_path, "matrix_path": matrix_path, "error_stats": error_stats, "error_stats_path": error_stats_path, "elapsed_seconds": total_elapsed } # ============================================================ # 命令行入口 # ============================================================ if __name__ == "__main__": parser = argparse.ArgumentParser( description="高铁点云与 CAD 的仅平移配准:取车头车尾少量点估计整体平移" ) parser.add_argument( "--cad", "-c", default="datasets/CAD/m000004802418tc01_asm.stp", help="CAD STEP 文件路径" ) parser.add_argument( "--pointcloud", "-p", default="datasets/shift_test_output/", help="待配准点云 PLY 文件路径" ) parser.add_argument( "--output_dir", "-o", default="results", help="输出目录" ) parser.add_argument( "--axis", default="x", choices=["x", "y", "z"], help="高铁长度方向,默认 x" ) parser.add_argument( "--crop_ratio", type=float, default=0.01, help="车头/车尾各取总长度的比例,默认 0.01" ) parser.add_argument( "--mesh_deflection", type=float, default=0.1, help="CAD 三角化精度,默认 0.1" ) parser.add_argument( "--cad_sample_points", type=int, default=30000, help="从 CAD mesh 采样的点数,默认 30000" ) parser.add_argument( "--local_sample_points", type=int, default=1000, help="车头/车尾各随机采样多少点用于计算质心,默认 1000" ) parser.add_argument( "--scale_pointcloud", type=float, default=1.0, help="点云缩放。如果点云是米,CAD 是毫米,设为 1000" ) parser.add_argument( "--no_visualize", action="store_true", help="不显示可视化窗口" ) parser.add_argument( "--no_cad_cache", action="store_true", help="Disable CAD sampled point cloud cache" ) parser.add_argument( "--save_debug_samples", action="store_true", help="Save front/tail debug sample point clouds" ) parser.add_argument( "--random_seed", type=int, default=0, help="Random seed for repeatable sampling; set -1 to disable deterministic sampling" ) parser.add_argument( "--error_sample_points", type=int, default=100000, help="Sample count for alignment error stats; set 0 to use all aligned source points" ) parser.add_argument( "--no_error_stats", action="store_true", help="Skip point-to-CAD nearest-neighbor error statistics" ) parser.add_argument( "--save_aligned_float64", action="store_true", help="Use Open3D default float64-style aligned PLY output instead of compact float32 XYZ" ) parser.add_argument( "--no_icp", action="store_true", help="Skip rigid ICP refinement and use translation-only alignment" ) parser.add_argument( "--icp_sample_points", type=int, default=200000, help="Source sample count used for ICP; set 0 to use all source points" ) parser.add_argument( "--icp_voxel_size", type=float, default=0.0, help="Voxel size for ICP downsampling; 0 disables voxel downsampling" ) parser.add_argument( "--icp_max_correspondence_distance", type=float, default=0.0, help="ICP max correspondence distance; 0 uses an automatic value from initial distances" ) parser.add_argument( "--icp_max_iteration", type=int, default=50, help="Maximum ICP iterations" ) args = parser.parse_args() random_seed = None if args.random_seed < 0 else args.random_seed result = translate_align_by_front_tail( cad_path=args.cad, pointcloud_path=args.pointcloud, output_dir=args.output_dir, axis=args.axis, crop_ratio=args.crop_ratio, mesh_deflection=args.mesh_deflection, cad_sample_points=args.cad_sample_points, local_sample_points=args.local_sample_points, scale_pointcloud=args.scale_pointcloud, visualize=not args.no_visualize, use_cad_cache=not args.no_cad_cache, save_debug_samples=args.save_debug_samples, random_seed=random_seed, error_sample_points=args.error_sample_points, compute_error_stats=not args.no_error_stats, save_aligned_float32=not args.save_aligned_float64, use_icp=not args.no_icp, icp_sample_points=args.icp_sample_points, icp_voxel_size=args.icp_voxel_size, icp_max_correspondence_distance=args.icp_max_correspondence_distance, icp_max_iteration=args.icp_max_iteration ) print("\n========== Result ==========") print("initial translation:", result["initial_translation"]) print("final translation:", result["translation"]) print("front-tail translation difference:", result["front_tail_translation_difference"]) if result["icp_stats"] is not None: print("icp fitness:", result["icp_stats"]["fitness"]) print("icp inlier rmse:", result["icp_stats"]["inlier_rmse"]) print("aligned path:", result["aligned_path"]) print("matrix path:", result["matrix_path"]) if result["error_stats"] is not None: print("error stats path:", result["error_stats_path"]) print("error rmse:", result["error_stats"]["rmse"]) print("error p95:", result["error_stats"]["p95"]) print("elapsed:", format_elapsed(result["elapsed_seconds"]))