"""SE(3)/SO(3) utilities for LiDAR–IMU calibration.""" from __future__ import annotations import math import numpy as np def skew(vector: np.ndarray) -> np.ndarray: """Return the skew-symmetric matrix such that ``skew(v) @ w == v x w``.""" x, y, z = np.asarray(vector, dtype=float).reshape(3) return np.array([[0.0, -z, y], [z, 0.0, -x], [-y, x, 0.0]], dtype=float) def so3_exp(rotation_vector: np.ndarray) -> np.ndarray: """Map a rotation vector in radians onto SO(3).""" vector = np.asarray(rotation_vector, dtype=float).reshape(3) angle = float(np.linalg.norm(vector)) if angle < 1e-12: return np.eye(3) + skew(vector) axis_cross = skew(vector / angle) return np.eye(3) + math.sin(angle) * axis_cross + (1.0 - math.cos(angle)) * axis_cross @ axis_cross def so3_log(rotation: np.ndarray) -> np.ndarray: """Map an SO(3) matrix to a rotation vector in radians.""" rotation = np.asarray(rotation, dtype=float).reshape(3, 3) cos_angle = float(np.clip((np.trace(rotation) - 1.0) * 0.5, -1.0, 1.0)) angle = math.acos(cos_angle) if angle < 1e-12: return 0.5 * np.array( [ rotation[2, 1] - rotation[1, 2], rotation[0, 2] - rotation[2, 0], rotation[1, 0] - rotation[0, 1], ], dtype=float, ) if abs(angle - math.pi) < 1e-6: # Near 180°: use eigenvector of the +1 eigenvalue. eigvals, eigvecs = np.linalg.eigh(0.5 * (rotation + rotation.T)) axis = eigvecs[:, int(np.argmax(eigvals))] return axis * angle return ( 0.5 * angle / math.sin(angle) * np.array( [ rotation[2, 1] - rotation[1, 2], rotation[0, 2] - rotation[2, 0], rotation[1, 0] - rotation[0, 1], ], dtype=float, ) ) def rotation_angle_deg(rotation: np.ndarray) -> float: """Return the rotation angle in degrees.""" return float(np.degrees(np.linalg.norm(so3_log(rotation)))) def inverse_transform(transform: np.ndarray) -> np.ndarray: """Return the inverse of a rigid 4x4 transform.""" transform = np.asarray(transform, dtype=float) if transform.shape != (4, 4): raise ValueError("a rigid transform must have shape (4, 4)") result = np.eye(4) result[:3, :3] = transform[:3, :3].T result[:3, 3] = -result[:3, :3] @ transform[:3, 3] return result def make_transform(translation_m: np.ndarray, rotation: np.ndarray) -> np.ndarray: """Build ``T_A_B`` from its translation and rotation components.""" translation_m = np.asarray(translation_m, dtype=float).reshape(3) rotation = np.asarray(rotation, dtype=float) if rotation.shape != (3, 3): raise ValueError("a rotation matrix must have shape (3, 3)") result = np.eye(4) result[:3, :3] = rotation result[:3, 3] = translation_m return result def transform_points(points: np.ndarray, transform: np.ndarray) -> np.ndarray: """Apply ``T_A_B`` to an ``(N, 3)`` point array expressed in frame B.""" points = np.asarray(points, dtype=float) if points.ndim != 2 or points.shape[1] != 3: raise ValueError("points must have shape (N, 3)") return points @ transform[:3, :3].T + transform[:3, 3] def orthonormalize_rotation(rotation: np.ndarray) -> np.ndarray: """Project a near-rotation matrix onto SO(3).""" u, _, vt = np.linalg.svd(np.asarray(rotation, dtype=float).reshape(3, 3)) result = u @ vt if np.linalg.det(result) < 0: u[:, -1] *= -1 result = u @ vt return result def integrate_gyro_rotation( times_s: np.ndarray, gyro_rad_s: np.ndarray, t0: float, t1: float, bias_rad_s: np.ndarray | None = None, ) -> np.ndarray: """Integrate gyroscope samples on ``[t0, t1]`` and return ``R(t0<-t1)`` wait. Returns ``R_i_j`` that maps vectors from the IMU frame at ``t1`` into the IMU frame at ``t0`` using right-invariant discrete integration: R <- R @ Exp(omega * dt) """ times_s = np.asarray(times_s, dtype=float).reshape(-1) gyro_rad_s = np.asarray(gyro_rad_s, dtype=float).reshape(-1, 3) if times_s.size < 2: return np.eye(3) bias = np.zeros(3) if bias_rad_s is None else np.asarray(bias_rad_s, dtype=float).reshape(3) if t1 < t0: raise ValueError("t1 must be >= t0") # Include one sample before t0 and after t1 when possible for interpolation. left = int(np.searchsorted(times_s, t0, side="left") - 1) right = int(np.searchsorted(times_s, t1, side="right")) left = max(left, 0) right = min(right, times_s.size - 1) if right <= left: return np.eye(3) rotation = np.eye(3) for index in range(left, right): t_a = float(times_s[index]) t_b = float(times_s[index + 1]) if t_b <= t0 or t_a >= t1: continue seg0 = max(t_a, t0) seg1 = min(t_b, t1) dt = seg1 - seg0 if dt <= 0: continue omega = 0.5 * (gyro_rad_s[index] + gyro_rad_s[index + 1]) - bias rotation = rotation @ so3_exp(omega * dt) return orthonormalize_rotation(rotation) def rotation_matrix_to_quaternion_xyzw(rotation: np.ndarray) -> np.ndarray: """Convert SO(3) to quaternion ``[x, y, z, w]``.""" rotation = orthonormalize_rotation(rotation) trace = float(np.trace(rotation)) if trace > 0: s = math.sqrt(trace + 1.0) * 2.0 w = 0.25 * s x = (rotation[2, 1] - rotation[1, 2]) / s y = (rotation[0, 2] - rotation[2, 0]) / s z = (rotation[1, 0] - rotation[0, 1]) / s elif rotation[0, 0] > rotation[1, 1] and rotation[0, 0] > rotation[2, 2]: s = math.sqrt(1.0 + rotation[0, 0] - rotation[1, 1] - rotation[2, 2]) * 2.0 w = (rotation[2, 1] - rotation[1, 2]) / s x = 0.25 * s y = (rotation[0, 1] + rotation[1, 0]) / s z = (rotation[0, 2] + rotation[2, 0]) / s elif rotation[1, 1] > rotation[2, 2]: s = math.sqrt(1.0 + rotation[1, 1] - rotation[0, 0] - rotation[2, 2]) * 2.0 w = (rotation[0, 2] - rotation[2, 0]) / s x = (rotation[0, 1] + rotation[1, 0]) / s y = 0.25 * s z = (rotation[1, 2] + rotation[2, 1]) / s else: s = math.sqrt(1.0 + rotation[2, 2] - rotation[0, 0] - rotation[1, 1]) * 2.0 w = (rotation[1, 0] - rotation[0, 1]) / s x = (rotation[0, 2] + rotation[2, 0]) / s y = (rotation[1, 2] + rotation[2, 1]) / s z = 0.25 * s return np.array([x, y, z, w], dtype=float) def rpy_deg_xyz(rotation: np.ndarray) -> np.ndarray: """Intrinsic XYZ Euler angles in degrees from a rotation matrix.""" rotation = orthonormalize_rotation(rotation) sy = math.sqrt(rotation[0, 0] ** 2 + rotation[1, 0] ** 2) if sy > 1e-8: roll = math.atan2(rotation[2, 1], rotation[2, 2]) pitch = math.atan2(-rotation[2, 0], sy) yaw = math.atan2(rotation[1, 0], rotation[0, 0]) else: roll = math.atan2(-rotation[1, 2], rotation[1, 1]) pitch = math.atan2(-rotation[2, 0], sy) yaw = 0.0 return np.degrees(np.array([roll, pitch, yaw], dtype=float))