"""LiDAR relative-motion registration. Uses Open3D Generalized ICP when available; otherwise a NumPy point-to-point ICP. """ from __future__ import annotations from dataclasses import dataclass import numpy as np from .contracts import LidarFrame from .geometry import make_transform, orthonormalize_rotation, rotation_angle_deg, so3_log @dataclass(frozen=True) class RegistrationResult: transform: np.ndarray fitness: float rotation_deg: float translation_m: float backend: str ok: bool def _voxel_downsample(points: np.ndarray, voxel: float) -> np.ndarray: if points.shape[0] == 0: return points quantized = np.floor(points / voxel).astype(np.int64) _, unique_indices = np.unique(quantized, axis=0, return_index=True) return points[np.sort(unique_indices)] def _numpy_icp( source: np.ndarray, target: np.ndarray, *, max_iterations: int = 30, max_correspondence: float = 1.0, ) -> RegistrationResult: src = _voxel_downsample(source, 0.2) tgt = _voxel_downsample(target, 0.2) if src.shape[0] < 50 or tgt.shape[0] < 50: return RegistrationResult(np.eye(4), 0.0, 0.0, 0.0, "numpy_icp", False) # Subsample for speed. rng = np.random.default_rng(0) if src.shape[0] > 4000: src = src[rng.choice(src.shape[0], 4000, replace=False)] if tgt.shape[0] > 8000: tgt = tgt[rng.choice(tgt.shape[0], 8000, replace=False)] r = np.eye(3) t = np.zeros(3) last_error = 1e9 inlier_ratio = 0.0 for _ in range(max_iterations): transformed = src @ r.T + t # Nearest neighbour in target via brute force on chunks. diff = transformed[:, None, :] - tgt[None, :, :] dist2 = np.sum(diff * diff, axis=2) nn = np.argmin(dist2, axis=1) dist = np.sqrt(dist2[np.arange(src.shape[0]), nn]) mask = dist < max_correspondence inlier_ratio = float(np.mean(mask)) if np.count_nonzero(mask) < 30: break p = transformed[mask] q = tgt[nn[mask]] mu_p = p.mean(axis=0) mu_q = q.mean(axis=0) h = (p - mu_p).T @ (q - mu_q) u, _, vt = np.linalg.svd(h) r_delta = vt.T @ u.T if np.linalg.det(r_delta) < 0: vt[-1, :] *= -1 r_delta = vt.T @ u.T t_delta = mu_q - r_delta @ mu_p # Update global transform: x' = r_delta (r x + t) + t_delta r = orthonormalize_rotation(r_delta @ r) t = r_delta @ t + t_delta mean_err = float(np.mean(dist[mask])) if abs(last_error - mean_err) < 1e-4: break last_error = mean_err transform = make_transform(t, r) return RegistrationResult( transform=transform, fitness=inlier_ratio, rotation_deg=rotation_angle_deg(r), translation_m=float(np.linalg.norm(t)), backend="numpy_icp", ok=inlier_ratio > 0.15, ) def _open3d_gicp(source: np.ndarray, target: np.ndarray) -> RegistrationResult | None: try: import open3d as o3d except ImportError: return None src = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(source)) tgt = o3d.geometry.PointCloud(o3d.utility.Vector3dVector(target)) src = src.voxel_down_sample(0.2) tgt = tgt.voxel_down_sample(0.2) if len(src.points) < 50 or len(tgt.points) < 50: return RegistrationResult(np.eye(4), 0.0, 0.0, 0.0, "open3d_gicp", False) src.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=1.0, max_nn=30)) tgt.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=1.0, max_nn=30)) result = o3d.pipelines.registration.registration_generalized_icp( src, tgt, 1.0, np.eye(4), o3d.pipelines.registration.TransformationEstimationForGeneralizedICP(), o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=50), ) transform = np.asarray(result.transformation, dtype=float) return RegistrationResult( transform=transform, fitness=float(result.fitness), rotation_deg=rotation_angle_deg(transform[:3, :3]), translation_m=float(np.linalg.norm(transform[:3, 3])), backend="open3d_gicp", ok=float(result.fitness) > 0.15, ) def register_lidar_pair(source_points: np.ndarray, target_points: np.ndarray) -> RegistrationResult: """Register source -> target and return ``T_target_source``.""" source = np.asarray(source_points, dtype=float).reshape(-1, 3) target = np.asarray(target_points, dtype=float).reshape(-1, 3) open3d_result = _open3d_gicp(source, target) if open3d_result is not None: return open3d_result return _numpy_icp(source, target) def estimate_frame_rotations( frames: list[LidarFrame], *, stride: int = 1, ) -> tuple[list[np.ndarray], list[tuple[float, float]]]: """Estimate consecutive (or strided) LiDAR relative rotations for time sync.""" rotations: list[np.ndarray] = [] pair_times: list[tuple[float, float]] = [] for index in range(0, len(frames) - stride, max(stride, 1)): a = frames[index] b = frames[index + stride] result = register_lidar_pair(b.points_xyz, a.points_xyz) if not result.ok: continue rotations.append(result.transform[:3, :3]) pair_times.append((a.t_mid_s, b.t_mid_s)) return rotations, pair_times