160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
"""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
|