2026-08-01 12:02:37 +08:00
|
|
|
|
"""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"
|
2026-08-19 09:41:39 +08:00
|
|
|
|
ROTATION_ONLY_PRIOR_CONSTRAINED = "rotation_only_prior_constrained"
|
2026-08-01 12:02:37 +08:00
|
|
|
|
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
|
2026-08-19 09:41:39 +08:00
|
|
|
|
# Optional session-local override. The request-level value remains a
|
|
|
|
|
|
# backward-compatible fallback for batches whose timelines are all aligned.
|
|
|
|
|
|
fixed_time_offset_s: float | None = None
|
2026-08-01 12:02:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-08-19 09:41:39 +08:00
|
|
|
|
min_registration_fitness: float = 0.5
|
|
|
|
|
|
max_imu_gap_s: float = 0.05
|
|
|
|
|
|
max_lidar_gap_s: float = 1.0
|
2026-08-01 12:02:37 +08:00
|
|
|
|
time_offset_search_s: float = 1.0
|
2026-08-11 10:57:11 +08:00
|
|
|
|
# If set, skip |ω| search and use this constant (host-UTC-bridged sessions: 0).
|
|
|
|
|
|
fixed_time_offset_s: float | None = None
|
|
|
|
|
|
# Signed 3-axis refine after hand-eye; disable for already-bridged timelines.
|
|
|
|
|
|
enable_signed_time_refine: bool = True
|
|
|
|
|
|
# Reject signed refine steps that walk farther than this from the coarse δt.
|
|
|
|
|
|
max_signed_refine_shift_s: float = 0.05
|
2026-08-01 12:02:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|