118 lines
3.2 KiB
Python
118 lines
3.2 KiB
Python
"""Shared contracts for the LiDAR–IMU calibration pipeline."""
|
||||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from enum import Enum
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TransformConvention(str, Enum):
|
|||
|
|
"""The only transform convention used by this project."""
|
|||
|
|
|
|||
|
|
T_A_B = "T_A_B maps points from frame B into frame A"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CalibrationMode(str, Enum):
|
|||
|
|
ROTATION_ONLY = "rotation_only"
|
|||
|
|
FULL_SE3 = "full_se3"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CalibrationStatus(str, Enum):
|
|||
|
|
NOT_RUN = "not_run"
|
|||
|
|
BLOCKED = "blocked"
|
|||
|
|
ROTATION_ONLY_ACCEPTED = "rotation_only_accepted"
|
|||
|
|
FULL_SE3_ACCEPTED = "full_se3_accepted"
|
|||
|
|
FULL_SE3_REJECTED = "full_se3_rejected_due_to_observability"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class SessionInput:
|
|||
|
|
"""Input paths for one independently recorded session."""
|
|||
|
|
|
|||
|
|
session_id: str
|
|||
|
|
imu_source: Path
|
|||
|
|
lidar_source: Path
|
|||
|
|
board_configuration_id: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class CalibrationRequest:
|
|||
|
|
"""Top-level calibration request."""
|
|||
|
|
|
|||
|
|
vehicle_config: Path | None
|
|||
|
|
sessions: tuple[SessionInput, ...] = ()
|
|||
|
|
requested_mode: CalibrationMode = CalibrationMode.ROTATION_ONLY
|
|||
|
|
output_directory: Path | None = None
|
|||
|
|
max_iterations: int = 2
|
|||
|
|
min_pair_rotation_deg: float = 3.0
|
|||
|
|
min_pair_translation_m: float = 0.3
|
|||
|
|
time_offset_search_s: float = 1.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class CalibrationResult:
|
|||
|
|
"""Result envelope written by finalize after pipeline gates."""
|
|||
|
|
|
|||
|
|
status: CalibrationStatus = CalibrationStatus.NOT_RUN
|
|||
|
|
message: str = "Calibration has not been executed."
|
|||
|
|
details: dict[str, Any] = field(default_factory=dict)
|
|||
|
|
T_IMU_lidar: np.ndarray | None = None
|
|||
|
|
time_offset_s: float | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class ImuSeries:
|
|||
|
|
"""Normalized IMU samples.
|
|||
|
|
|
|||
|
|
``t_s`` is the native IMU clock in seconds (need not match LiDAR epoch).
|
|||
|
|
Gyro must be rad/s; accelerometer must be m/s^2.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
t_s: np.ndarray
|
|||
|
|
gyro_rad_s: np.ndarray
|
|||
|
|
acc_m_s2: np.ndarray
|
|||
|
|
|
|||
|
|
def __post_init__(self) -> None:
|
|||
|
|
object.__setattr__(self, "t_s", np.asarray(self.t_s, dtype=float).reshape(-1))
|
|||
|
|
object.__setattr__(self, "gyro_rad_s", np.asarray(self.gyro_rad_s, dtype=float).reshape(-1, 3))
|
|||
|
|
object.__setattr__(self, "acc_m_s2", np.asarray(self.acc_m_s2, dtype=float).reshape(-1, 3))
|
|||
|
|
n = self.t_s.size
|
|||
|
|
if self.gyro_rad_s.shape != (n, 3) or self.acc_m_s2.shape != (n, 3):
|
|||
|
|
raise ValueError("IMU arrays must share the same length and have shape (N, 3)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class LidarFrame:
|
|||
|
|
"""One LiDAR sweep in Cartesian sensor coordinates."""
|
|||
|
|
|
|||
|
|
frame_id: str
|
|||
|
|
t_start_s: float
|
|||
|
|
t_end_s: float
|
|||
|
|
points_xyz: np.ndarray
|
|||
|
|
path: Path | None = None
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def t_mid_s(self) -> float:
|
|||
|
|
return 0.5 * (self.t_start_s + self.t_end_s)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class MotionPair:
|
|||
|
|
"""One relative-motion observation between keyframes i and j."""
|
|||
|
|
|
|||
|
|
session_id: str
|
|||
|
|
i: int
|
|||
|
|
j: int
|
|||
|
|
t_i_s: float
|
|||
|
|
t_j_s: float
|
|||
|
|
R_A: np.ndarray
|
|||
|
|
R_B: np.ndarray
|
|||
|
|
t_A_m: np.ndarray | None = None
|
|||
|
|
t_B_m: np.ndarray | None = None
|
|||
|
|
fitness: float = 0.0
|
|||
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|