完善Phase-A会话级联合优化并修正雷达相位中心高度先验

This commit is contained in:
lichun.qu
2026-08-19 09:41:39 +08:00
parent 5ac50ad71f
commit 1233f8aafd
20 changed files with 3621 additions and 140 deletions
+10 -8
View File
@@ -18,7 +18,8 @@ installation:
HI13R4 + H32 DLogCapture. Body +X forward: LiDAR and IMU at positive X.
CAD sheet may draw +X rearward; numbers below are body-frame.
Z is height above ground = CAD height at axle + 0.294 m (axle AGL).
LiDAR phase-center AGL is the measured 1.9165 m (not CAD dZ+63.5).
LiDAR CAD dZ is 1637.499879 mm relative to the axle reference. Phase-center
AGL adds rear-axle height 294 mm and the 63.5 mm phase-center offset.
IMU axes: HI13R4 manual §2.4 RFU (X right, Y forward, Z up).
LiDAR Cartesian in NPZ assumed body-aligned (X forward).
@@ -45,11 +46,12 @@ sensors:
axes: "X forward, Y left, Z up (Cartesian metres in NPZ points)"
driver_axis_remapped: false
mount_in_body:
# X/Y:后轮轴中心 → 雷达;Z:相位中心真实离地 1.9165 m
translation_m: [2.522276859, 0.000020526, 1.9165]
# X/Y:后轮轴中心 → 雷达
# Z离地 = CAD dZ 1.637499879 + 后轮轴离地 0.294 + 相位中心偏移 0.0635
translation_m: [2.522276859, 0.000020526, 1.994999879]
rotation_matrix_body_lidar: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
rotation_quaternion_xyzw: null
source: "CAD X/Y in body (+X forward); Z = measured phase-center AGL 1.9165 m; attitude = body"
source: "CAD X/Y in body (+X forward); Z AGL = CAD dZ 1.637499879 + axle AGL 0.294 + phase-center offset 0.0635; attitude = body"
rtk:
frame_definition: ""
@@ -67,17 +69,17 @@ time:
# t_IMU_lidar = R_IMU_body * t_body, R_IMU_lidar = R_IMU_body * R_body_lidar
derived_T_IMU_lidar_prior:
R_IMU_lidar: [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.730]
t_lidar_from_imu_in_body_m: [-0.051849396, -0.036479474, 0.730]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.808499879]
t_lidar_from_imu_in_body_m: [-0.051849396, -0.036479474, 0.808499879]
notes: >
Rotation prior ~90 deg yaw (body/lidar X-fwd vs IMU Y-fwd).
Relative Z = 1.9165 - 1.1865 = 0.730 m (was 0.8085 m with old CAD+63.5mm Z).
Relative Z = 1.994999879 - 1.1865 = 0.808499879 m.
initialization:
translation_prior:
enabled: true
sigma_m: [0.05, 0.05, 0.05]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.730]
t_IMU_lidar_m: [0.036479474, -0.051849396, 0.808499879]
rotation_prior:
enabled: true
sigma_deg: 15.0
@@ -96,16 +96,16 @@ t = [0, 0, 0]
| 传感器 | X / Y(后轮轴中心) | Z(离地) |
|--------|---------------------|-----------|
| IMU | 2.574 / 0.0365 m | 0.8925 + 0.294 = **1.1865 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心实测 **1.9165 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心离地 **1.994999879 m** |
- 后轮轴中心离地:**294 mm**(Z 用离地高时加在 CAD 轴心高上)。
- 雷达相位中心以实测 1.9165 m 为准(不再用「CAD dZ + 63.5 mm」作为最终离地高)
- 雷达 CAD `dZ=1.637499879 m`;相位中心离地还需加后轮轴离地 `0.294 m` 和相位中心偏移 `0.0635 m`,最终为 `1.994999879 m`
### 3.3 导出外参先验
- `R_IMU_lidar` ≈ yaw 90°:`[[0,-1,0],[1,0,0],[0,0,1]]`(软约束 σ=15°)。
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.730]` m**(相对 Z = 1.9165 1.1865)。
- **旋转先验因 Z 修正改变**平移先验 Z 从旧值 0.8085 改为 0.730
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.8085]` m**(相对 Z = 1.994999879 1.1865 = 0.808499879 m)。
- **旋转先验因 Z 修正改变**;平移先验 Z 更新为 0.808499879 m
---
+4 -4
View File
@@ -96,16 +96,16 @@ t = [0, 0, 0]
| 传感器 | X / Y(后轮轴中心) | Z(离地) |
|--------|---------------------|-----------|
| IMU | 2.574 / 0.0365 m | 0.8925 + 0.294 = **1.1865 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心实测 **1.9165 m** |
| 雷达 | 2.522 / 0.00002 m | 相位中心离地 **1.994999879 m** |
- 后轮轴中心离地:**294 mm**(Z 用离地高时加在 CAD 轴心高上)。
- 雷达相位中心以实测 1.9165 m 为准(不再用「CAD dZ + 63.5 mm」作为最终离地高)
- 雷达 CAD `dZ=1.637499879 m`;相位中心离地还需加后轮轴离地 `0.294 m` 和相位中心偏移 `0.0635 m`,最终为 `1.994999879 m`
### 3.3 导出外参先验
- `R_IMU_lidar` ≈ yaw 90°:`[[0,-1,0],[1,0,0],[0,0,1]]`(软约束 σ=15°)。
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.730]` m**(相对 Z = 1.9165 1.1865)。
- **旋转先验因 Z 修正改变**平移先验 Z 从旧值 0.8085 改为 0.730
- `t_IMU_lidar`**`[0.0365, -0.0518, 0.8085]` m**(相对 Z = 1.994999879 1.1865 = 0.808499879 m)。
- **旋转先验因 Z 修正改变**;平移先验 Z 更新为 0.808499879 m
---
+202 -3
View File
@@ -3,12 +3,82 @@
from __future__ import annotations
import argparse
from datetime import datetime
from pathlib import Path
from typing import Any
from .contracts import CalibrationMode, CalibrationRequest, CalibrationStatus, SessionInput
from .phase_a_replay import run_phase_a_replay
from .pipeline import describe_pipeline, run_calibration
def _format_progress_value(value: Any) -> str:
if isinstance(value, float):
return f"{value:.3f}"
if isinstance(value, (list, tuple, set)):
return "[" + ",".join(str(item) for item in value) + "]"
return str(value)
def _print_progress(event: dict[str, Any]) -> None:
"""Print one compact, immediately flushed progress line."""
timestamp = datetime.now().strftime("%H:%M:%S")
stage_index = event.get("stage_index", "?")
stage_total = event.get("stage_total", "?")
stage_name = event.get("stage", "unknown")
message = event.get("event", "progress")
fields = " ".join(
f"{key}={_format_progress_value(value)}"
for key, value in event.items()
if key not in {"stage_index", "stage_total", "stage", "event"}
and value is not None
)
suffix = f" | {fields}" if fields else ""
print(
f"[{timestamp}] [stage {stage_index}/{stage_total} {stage_name}] {message}{suffix}",
flush=True,
)
def _parse_session_imu_specs(
specs: list[str] | None,
) -> dict[str, Path]:
result: dict[str, Path] = {}
for spec in specs or []:
if "=" not in spec:
raise SystemExit(
"--session-imu must use SESSION_ID=PATH syntax"
)
session_id, raw_path = spec.split("=", 1)
session_id = session_id.strip()
if not session_id or not raw_path.strip():
raise SystemExit(
"--session-imu must use non-empty SESSION_ID=PATH"
)
if session_id in result:
raise SystemExit(
f"duplicate --session-imu for {session_id}"
)
result[session_id] = Path(raw_path.strip())
return result
def _print_phase_a_progress(
event: str,
fields: dict[str, Any],
) -> None:
_print_progress(
{
"stage_index": "A",
"stage_total": "A",
"stage": "phase_a_replay",
"event": event,
**fields,
}
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="LiDARIMU extrinsic calibration (V1)")
subcommands = parser.add_subparsers(dest="command", required=True)
@@ -58,6 +128,13 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="Skip |ω| δt search and use this constant (use 0 after host-UTC bridge)",
)
run.add_argument(
"--session-time-offset-s",
action="append",
type=float,
default=None,
help="Per-session fixed time offset; repeat once per --imu/--lidar input",
)
run.add_argument(
"--no-signed-time-refine",
action="store_true",
@@ -71,6 +148,69 @@ def build_parser() -> argparse.ArgumentParser:
)
run.add_argument("--min-pair-rotation-deg", type=float, default=3.0)
run.add_argument("--min-pair-translation-m", type=float, default=0.3)
run.add_argument("--min-registration-fitness", type=float, default=0.5)
run.add_argument("--max-imu-gap-s", type=float, default=0.05)
run.add_argument("--max-lidar-gap-s", type=float, default=1.0)
replay = subcommands.add_parser(
"phase-a-replay",
help="Replay Phase-A from cached motion pairs without rerunning GICP",
)
replay.add_argument("--motion-pairs", type=Path, required=True)
replay.add_argument("--vehicle-config", type=Path, required=True)
replay.add_argument("--output", type=Path, required=True)
replay.add_argument(
"--session-imu",
action="append",
default=None,
metavar="SESSION_ID=PATH",
help="Raw IMU mapping used only when cache lacks J_bg/cov",
)
replay.add_argument(
"--exclude-session",
action="append",
default=None,
help="Session ID to exclude; may be repeated",
)
replay.add_argument(
"--strong-rotation-min-deg",
type=float,
default=1.0,
)
replay.add_argument(
"--decorrelation-block-s",
type=float,
default=3.0,
help="Per-session time-block length used to decorrelate factors",
)
replay.add_argument(
"--max-pairs-per-block",
type=int,
default=1,
help="Maximum factors kept in each decorrelation block",
)
replay.add_argument(
"--bias-prior-sigma-rad-s",
type=float,
default=0.002,
)
replay.add_argument(
"--yaw-std-max-deg",
type=float,
default=0.5,
)
replay.add_argument(
"--loo-yaw-range-max-deg",
type=float,
default=1.0,
)
replay.add_argument(
"--data-prior-difference-max-deg",
type=float,
default=1.0,
)
replay.add_argument("--max-nfev", type=int, default=200)
return parser
@@ -87,9 +227,23 @@ def _build_sessions(args: argparse.Namespace) -> tuple[SessionInput, ...]:
raise SystemExit(
f"--session-id count ({len(session_ids)}) must match --imu/--lidar ({len(imus)})"
)
if args.session_time_offset_s is None:
session_offsets: list[float | None] = [None] * len(imus)
else:
session_offsets = list(args.session_time_offset_s)
if len(session_offsets) != len(imus):
raise SystemExit(
f"--session-time-offset-s count ({len(session_offsets)}) must match "
f"--imu/--lidar ({len(imus)})"
)
return tuple(
SessionInput(session_id=sid, imu_source=imu, lidar_source=lidar)
for sid, imu, lidar in zip(session_ids, imus, lidars)
SessionInput(
session_id=sid,
imu_source=imu,
lidar_source=lidar,
fixed_time_offset_s=offset,
)
for sid, imu, lidar, offset in zip(session_ids, imus, lidars, session_offsets)
)
@@ -108,6 +262,48 @@ def main(argv: list[str] | None = None) -> int:
print(f"{index}. {stage.name}: {stage.responsibility}")
return 0
if args.command == "phase-a-replay":
summary = run_phase_a_replay(
motion_pairs_path=args.motion_pairs,
vehicle_config_path=args.vehicle_config,
output_directory=args.output,
imu_paths_by_session=_parse_session_imu_specs(
args.session_imu
),
excluded_sessions=set(args.exclude_session or []),
strong_rotation_min_deg=args.strong_rotation_min_deg,
decorrelation_block_s=args.decorrelation_block_s,
max_pairs_per_block=args.max_pairs_per_block,
bias_prior_sigma_rad_s=args.bias_prior_sigma_rad_s,
yaw_std_max_deg=args.yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
args.loo_yaw_range_max_deg
),
data_prior_difference_max_deg=(
args.data_prior_difference_max_deg
),
max_nfev=args.max_nfev,
progress_callback=_print_phase_a_progress,
)
print(f"status: {summary['status']}")
print(f"acceptance_checks: {summary['acceptance_checks']}")
for name, variant in summary["variants"].items():
print(
f"{name}: rpy_deg_xyz={variant['rpy_deg_xyz']} "
f"RMS={variant['residual_rms_deg']:.6f} "
f"P95={variant['residual_p95_deg']:.6f}"
)
print(
"A1 marginalized yaw_std_deg: "
f"{summary['marginal_observability_A1']['yaw_std_deg']}"
)
print(
"leave_one_out_yaw_range_deg: "
f"{summary['leave_one_out_yaw_range_deg']}"
)
print(f"report directory: {args.output}")
return 0 if (summary["accepted"] or summary.get("partial_accepted")) else 2
if args.command == "run":
sessions = _build_sessions(args)
request = CalibrationRequest(
@@ -118,12 +314,15 @@ def main(argv: list[str] | None = None) -> int:
max_iterations=args.max_iterations,
min_pair_rotation_deg=args.min_pair_rotation_deg,
min_pair_translation_m=args.min_pair_translation_m,
min_registration_fitness=args.min_registration_fitness,
max_imu_gap_s=args.max_imu_gap_s,
max_lidar_gap_s=args.max_lidar_gap_s,
time_offset_search_s=args.time_offset_search_s,
fixed_time_offset_s=args.fixed_time_offset_s,
enable_signed_time_refine=not args.no_signed_time_refine,
max_signed_refine_shift_s=args.max_signed_refine_shift_s,
)
result = run_calibration(request)
result = run_calibration(request, progress_callback=_print_progress)
print(f"status: {result.status.value}")
print(f"message: {result.message}")
if result.time_offset_s is not None:
+7
View File
@@ -25,6 +25,7 @@ class CalibrationStatus(str, Enum):
NOT_RUN = "not_run"
BLOCKED = "blocked"
ROTATION_ONLY_ACCEPTED = "rotation_only_accepted"
ROTATION_ONLY_PRIOR_CONSTRAINED = "rotation_only_prior_constrained"
FULL_SE3_ACCEPTED = "full_se3_accepted"
FULL_SE3_REJECTED = "full_se3_rejected_due_to_observability"
@@ -37,6 +38,9 @@ class SessionInput:
imu_source: Path
lidar_source: Path
board_configuration_id: str | None = None
# 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
@dataclass(frozen=True)
@@ -50,6 +54,9 @@ class CalibrationRequest:
max_iterations: int = 2
min_pair_rotation_deg: float = 3.0
min_pair_translation_m: float = 0.3
min_registration_fitness: float = 0.5
max_imu_gap_s: float = 0.05
max_lidar_gap_s: float = 1.0
time_offset_search_s: float = 1.0
# If set, skip |ω| search and use this constant (host-UTC-bridged sessions: 0).
fixed_time_offset_s: float | None = None
+451 -19
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from scipy.optimize import least_squares
@@ -17,10 +19,26 @@ from .imu_preintegration import (
residual_whiten_matrix,
)
from .observability import ObservabilityReport, analyze_observability
from .phase_a import phase_a_comparison_to_dict, solve_phase_a_comparison
from .rotation_handeye import select_strong_rotation_pairs
G_NORM = 9.80665
@dataclass(frozen=True)
class PhaseASessionResult:
session_id: str
pair_count: int
gyro_bias0_rad_s: np.ndarray
gyro_bias_rad_s: np.ndarray
residual_rms_deg: float
residual_median_deg: float
residual_p95_deg: float
outlier_fraction_gt_5deg: float
accepted: bool
included_in_final: bool
@dataclass(frozen=True)
class JointExtrinsicResult:
T_IMU_lidar: np.ndarray
@@ -31,6 +49,10 @@ class JointExtrinsicResult:
gyro_bias_rad_s: np.ndarray | None = None
accel_bias_m_s2: np.ndarray | None = None
gravity_m_s2: np.ndarray | None = None
gyro_bias_rad_s_per_session: dict[str, np.ndarray] = field(default_factory=dict)
phase_a_sessions: tuple[PhaseASessionResult, ...] = ()
phase_a_accepted: bool = False
phase_a_comparison: dict[str, Any] = field(default_factory=dict)
notes: tuple[str, ...] = ()
@@ -174,7 +196,8 @@ def _solve_phase_c_se3(
pairs: list[MotionPair],
r_x: np.ndarray,
*,
gyro_bias0: np.ndarray,
gyro_bias_linearization: np.ndarray,
gyro_bias_init: np.ndarray,
gravity_init: np.ndarray,
sigma_bg_rw: float = 1.0e-5,
sigma_ba_rw: float = 1.0e-3,
@@ -189,7 +212,7 @@ def _solve_phase_c_se3(
if len(usable) < 3:
notes.append("phase-C skipped: need pairs with full preintegration metadata")
t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
return r_x, t0, gravity_init, gyro_bias0, np.zeros(3), 1e9, 1e9, notes
return r_x, t0, gravity_init, gyro_bias_init, np.zeros(3), 1e9, 1e9, notes
# Keyframes: group by session, sort each session by IMU time (no cross-session chain).
stamp: dict[int, float] = {}
@@ -225,7 +248,8 @@ def _solve_phase_c_se3(
g0 = g0 * (G_NORM / max(np.linalg.norm(g0), 1e-9))
basis = _gravity_basis(g0)
ba0 = np.zeros(3)
bg0 = np.asarray(gyro_bias0, dtype=float).reshape(3)
bg0 = np.asarray(gyro_bias_linearization, dtype=float).reshape(3)
bg_init = np.asarray(gyro_bias_init, dtype=float).reshape(3)
# State: dθ(3), t(3), g_xy(2), v(3K), bg(3K), ba(3K)
n_v = 3 * k_count
@@ -243,7 +267,7 @@ def _solve_phase_c_se3(
t_sigma = np.full(3, float(t_sigma[0]), dtype=float)
# velocities start at 0; biases at prior
for idx in range(k_count):
x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg0
x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg_init
whitened = []
for pair in usable:
@@ -312,7 +336,7 @@ def _solve_phase_c_se3(
for sid in session_ids:
first = next(kid for kid in keyframe_ids if kf_session[kid] == sid)
idx0 = id_to_idx[first]
out.append(50.0 * (bgs[idx0] - bg0))
out.append(50.0 * (bgs[idx0] - bg_init))
out.append(20.0 * bas[idx0])
if t_prior_vec is not None:
out.append((t_opt - t_prior_vec) / np.maximum(t_sigma, 1e-3))
@@ -357,7 +381,197 @@ def _solve_phase_c_se3(
return r_opt, t_opt, g_opt, bg_mean, ba_mean, rot_rms, trans_rms, notes
def solve_joint_extrinsic(
def _pair_gyro_bias0(pair: MotionPair, fallback: np.ndarray) -> np.ndarray:
raw = pair.metadata.get("gyro_bias0_rad_s")
if raw is None:
return np.asarray(fallback, dtype=float).reshape(3)
return np.asarray(raw, dtype=float).reshape(3)
def _phase_a_bias_bases(
pairs: list[MotionPair],
*,
gyro_bias_rad_s: np.ndarray | None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None,
) -> dict[str, np.ndarray]:
session_ids = sorted({pair.session_id for pair in pairs})
scalar = None
if gyro_bias_rad_s is not None:
scalar = np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
supplied = {} if gyro_bias_rad_s_by_session is None else gyro_bias_rad_s_by_session
bases: dict[str, np.ndarray] = {}
for sid in session_ids:
if sid in supplied:
bases[sid] = np.asarray(supplied[sid], dtype=float).reshape(3)
continue
pair = next(
(
item
for item in pairs
if item.session_id == sid and "gyro_bias0_rad_s" in item.metadata
),
None,
)
if pair is not None:
bases[sid] = np.asarray(pair.metadata["gyro_bias0_rad_s"], dtype=float).reshape(3)
elif scalar is not None:
bases[sid] = scalar.copy()
else:
bases[sid] = np.zeros(3)
return bases
def _rotation_distribution(errs_deg: list[float]) -> tuple[float, float, float, float, bool]:
if not errs_deg:
return 1e9, 1e9, 1e9, 1.0, False
errs = np.asarray(errs_deg, dtype=float)
rms = float(np.sqrt(np.mean(errs**2)))
median = float(np.median(errs))
p95 = float(np.percentile(errs, 95.0))
outlier_fraction = float(np.mean(errs > 5.0))
accepted = (
len(errs) >= 3
and rms < 1.5
and median < 0.5
and p95 < 1.5
and outlier_fraction <= 0.005
)
return rms, median, p95, outlier_fraction, accepted
def _solve_phase_a_rotation(
pairs: list[MotionPair],
r_seed: np.ndarray,
*,
bias_bases: Mapping[str, np.ndarray],
imu: ImuSeries | None,
bias_prior_sigma_rad_s: float,
preexcluded_session_ids: set[str] | None = None,
) -> tuple[
np.ndarray,
dict[str, np.ndarray],
tuple[PhaseASessionResult, ...],
list[MotionPair],
float,
bool,
list[str],
]:
notes: list[str] = []
all_session_ids = sorted({pair.session_id for pair in pairs})
prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)
def optimize(
active_pairs: list[MotionPair],
r0: np.ndarray,
bias_seed: Mapping[str, np.ndarray],
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
session_ids = sorted({pair.session_id for pair in active_pairs})
session_index = {sid: index for index, sid in enumerate(session_ids)}
whiten = [residual_whiten_matrix(_pair_cov(pair)) for pair in active_pairs]
x0 = np.zeros(3 + 3 * len(session_ids))
for sid, index in session_index.items():
x0[3 + 3 * index : 6 + 3 * index] = np.asarray(bias_seed[sid], dtype=float)
def residual(vec: np.ndarray) -> np.ndarray:
r_opt = orthonormalize_rotation(so3_exp(vec[:3]) @ r0)
out: list[np.ndarray] = []
for pair, sqrt_info in zip(active_pairs, whiten):
index = session_index[pair.session_id]
bias = vec[3 + 3 * index : 6 + 3 * index]
base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
delta_r = _corrected_delta_r(
pair, bias - base, imu=imu, bias0=base
)
out.append(
sqrt_info
@ preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
)
for sid, index in session_index.items():
bias = vec[3 + 3 * index : 6 + 3 * index]
out.append(prior_w * (bias - bias_bases[sid]))
return np.concatenate(out)
opt = least_squares(residual, x0, loss="huber", f_scale=1.0, max_nfev=200)
r_opt = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r0)
biases = {
sid: opt.x[3 + 3 * index : 6 + 3 * index].copy()
for sid, index in session_index.items()
}
return r_opt, biases
def summarize(
r_opt: np.ndarray,
biases: Mapping[str, np.ndarray],
included: set[str],
) -> tuple[PhaseASessionResult, ...]:
results: list[PhaseASessionResult] = []
for sid in all_session_ids:
local_pairs = [pair for pair in pairs if pair.session_id == sid]
bias = np.asarray(biases.get(sid, bias_bases[sid]), dtype=float).reshape(3)
errs: list[float] = []
for pair in local_pairs:
base = _pair_gyro_bias0(pair, bias_bases[sid])
delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
errs.append(float(np.degrees(np.linalg.norm(err))))
rms, median, p95, outlier, accepted = _rotation_distribution(errs)
results.append(
PhaseASessionResult(
session_id=sid,
pair_count=len(local_pairs),
gyro_bias0_rad_s=np.asarray(bias_bases[sid], dtype=float),
gyro_bias_rad_s=bias,
residual_rms_deg=rms,
residual_median_deg=median,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier,
accepted=accepted,
included_in_final=sid in included,
)
)
return tuple(results)
if not pairs:
return r_seed, dict(bias_bases), (), [], 1e9, False, ["no pairs for phase-A"]
r_first, biases_first = optimize(pairs, r_seed, bias_bases)
first = summarize(r_first, biases_first, set(all_session_ids))
accepted_ids = {item.session_id for item in first if item.accepted}
preexcluded = set() if preexcluded_session_ids is None else set(preexcluded_session_ids)
accepted_ids -= preexcluded
active_ids = set(all_session_ids)
r_final = r_first
biases_final = dict(biases_first)
if preexcluded and not accepted_ids:
active_ids = set()
notes.append(f"phase-A pre-gate excluded all sessions: {sorted(preexcluded)}")
elif accepted_ids and accepted_ids != active_ids:
active_ids = accepted_ids
active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
r_final, active_biases = optimize(active_pairs, r_first, biases_first)
biases_final.update(active_biases)
excluded = sorted(set(all_session_ids) - active_ids)
notes.append(f"phase-A excluded sessions after local/pre residual gate: {excluded}")
active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
final = summarize(r_final, biases_final, active_ids)
active_results = [item for item in final if item.included_in_final]
global_errs: list[float] = []
for pair in active_pairs:
bias = biases_final[pair.session_id]
base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
err = preintegration_rotation_residual(delta_r, r_final, pair.R_B)
global_errs.append(float(np.degrees(np.linalg.norm(err))))
rot_rms, _, _, _, global_ok = _rotation_distribution(global_errs)
accepted = bool(active_results and global_ok and all(item.accepted for item in active_results))
notes.append(
f"phase-A session-local bias refine: sessions={len(active_ids)}/{len(all_session_ids)}, "
f"pairs={len(active_pairs)}, rms={rot_rms:.3f} deg"
)
return r_final, biases_final, final, active_pairs, rot_rms, accepted, notes
def _solve_joint_extrinsic_legacy(
pairs: list[MotionPair] | tuple[MotionPair, ...],
r_x: np.ndarray,
*,
@@ -366,6 +580,8 @@ def solve_joint_extrinsic(
delta_t_s: float = 0.0,
gyro_bias_rad_s: np.ndarray | None = None,
gravity_init_m_s2: np.ndarray | None = None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
time_offset_s_by_session: Mapping[str, float] | None = None,
bias_prior_sigma_rad_s: float = 0.02,
enable_phase_c: bool | None = None,
t_init_m: np.ndarray | None = None,
@@ -391,10 +607,10 @@ def solve_joint_extrinsic(
def rotation_residuals(r_opt: np.ndarray, delta_bias: np.ndarray) -> np.ndarray:
residuals = []
for pair, weight, whiten in zip(usable, weights, whitens):
for pair, whiten in zip(usable, whitens):
delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
residuals.append(np.sqrt(weight) * (whiten @ err))
residuals.append(whiten @ err)
residuals.append(prior_w * delta_bias)
return np.concatenate(residuals) if residuals else np.zeros(0)
@@ -407,16 +623,16 @@ def solve_joint_extrinsic(
residual_rot_bias,
np.zeros(6),
loss="huber",
f_scale=np.deg2rad(1.0),
f_scale=1.0,
max_nfev=200,
)
r = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r)
delta_bias = opt.x[3:]
bias_out = bias0 + delta_bias
notes.append(
"phase-A joint refine (Σ-whitened + J_bg): "
"phase-A joint refine (single Σ whitening + J_bg): "
f"|δb|={float(np.linalg.norm(delta_bias)):.3e} rad/s, "
f"weighted pairs={len(usable)}"
f"pairs={len(usable)}"
)
else:
bias_out = bias0
@@ -457,7 +673,8 @@ def solve_joint_extrinsic(
r, t, gravity_out, bias_out, accel_bias_out, rot_rms, trans_rms, c_notes = _solve_phase_c_se3(
usable,
r,
gyro_bias0=bias_out,
gyro_bias_linearization=bias0,
gyro_bias_init=bias_out,
gravity_init=gravity_init,
t_init=t_seed if t_seed is not None else t_prior_m,
t_prior=t_prior_m,
@@ -469,9 +686,9 @@ def solve_joint_extrinsic(
# Prefer CAD prior over silent zero when motion SE3 is rejected.
if t_prior_m is not None:
t = np.asarray(t_prior_m, dtype=float).reshape(3)
translation_accepted = True
notes.append(
"phase-C translation residual/gate failed; keeping CAD translation prior"
"phase-C translation residual/gate failed; CAD translation is reported "
"as a prior only and is not accepted as calibration"
)
else:
notes.append("phase-C translation residual/gate failed; keeping translation at zero")
@@ -520,15 +737,18 @@ def solve_joint_extrinsic(
trans_errs.append(np.linalg.norm(pred - meas))
rot_rms = float(np.sqrt(np.mean(np.square(rot_errs))))
trans_rms = float(np.sqrt(np.mean(np.square(trans_errs))))
translation_accepted = trans_rms < 0.5 or t_prior_m is not None
translation_accepted = trans_rms < 0.5
notes.append(f"legacy translation refine rms={trans_rms:.3f} m")
if not translation_accepted:
notes.append("translation residual too large; keeping translation at zero")
t = np.zeros(3)
elif not force_rotation_only and t_prior_m is not None:
t = np.asarray(t_prior_m, dtype=float).reshape(3)
translation_accepted = True
notes.append("SE3 motion solve gated off; using CAD translation prior with refined rotation")
translation_accepted = False
notes.append(
"SE3 motion solve gated off; CAD translation is reported as a prior only "
"and is not accepted as calibration"
)
else:
notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)")
@@ -536,10 +756,222 @@ def solve_joint_extrinsic(
T_IMU_lidar=make_transform(t, r),
translation_accepted=bool(translation_accepted and np.linalg.norm(t) > 0),
residual_rms_rot_deg=rot_rms,
residual_rms_trans_m=0.0 if not translation_accepted else trans_rms,
residual_rms_trans_m=trans_rms,
observability=observability,
gyro_bias_rad_s=np.asarray(bias_out, dtype=float),
accel_bias_m_s2=None if accel_bias_out is None else np.asarray(accel_bias_out, dtype=float),
gravity_m_s2=None if gravity_out is None else np.asarray(gravity_out, dtype=float),
notes=tuple(notes),
)
def solve_joint_extrinsic(
pairs: list[MotionPair] | tuple[MotionPair, ...],
r_x: np.ndarray,
*,
force_rotation_only: bool = False,
imu: ImuSeries | None = None,
delta_t_s: float = 0.0,
gyro_bias_rad_s: np.ndarray | None = None,
gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
time_offset_s_by_session: Mapping[str, float] | None = None,
preexcluded_session_ids: set[str] | None = None,
gravity_init_m_s2: np.ndarray | None = None,
bias_prior_sigma_rad_s: float = 0.002,
rotation_prior: np.ndarray | None = None,
rotation_prior_sigma_deg: float = 15.0,
phase_a_yaw_std_max_deg: float = 0.5,
phase_a_loo_yaw_range_max_deg: float = 1.0,
phase_a_data_prior_difference_max_deg: float = 1.0,
run_phase_a_leave_one_out: bool = True,
phase_a_progress_callback: (
Callable[[str, dict[str, Any]], None] | None
) = None,
enable_phase_c: bool | None = None,
t_init_m: np.ndarray | None = None,
t_prior_m: np.ndarray | None = None,
t_prior_sigma_m: np.ndarray | float | None = None,
) -> JointExtrinsicResult:
"""Run the corrected session-aware Phase-A and gate unfinished SE(3) stages."""
del gravity_init_m_s2, t_init_m, t_prior_sigma_m, imu, r_x
usable_input = [pair for pair in pairs if pair.t_B_m is not None]
bias_bases = _phase_a_bias_bases(
usable_input,
gyro_bias_rad_s=gyro_bias_rad_s,
gyro_bias_rad_s_by_session=gyro_bias_rad_s_by_session,
)
comparison = solve_phase_a_comparison(
usable_input,
gyro_bias_rad_s_by_session=bias_bases,
rotation_prior=rotation_prior,
rotation_prior_sigma_deg=rotation_prior_sigma_deg,
preexcluded_session_ids=preexcluded_session_ids,
bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
yaw_std_max_deg=phase_a_yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
phase_a_loo_yaw_range_max_deg
),
data_prior_difference_max_deg=(
phase_a_data_prior_difference_max_deg
),
run_leave_one_out=run_phase_a_leave_one_out,
progress_callback=phase_a_progress_callback,
)
primary = comparison.session_bg_data_only
r = primary.R_IMU_lidar
biases = primary.gyro_bias_rad_s_per_session
rot_rms = primary.residual_rms_deg
phase_a_accepted = comparison.accepted
notes = list(comparison.notes)
notes.append(
"phase-A primary=A1_session_bg_data_only; "
f"A0 RPY={comparison.fixed_bg_data_only.rpy_deg_xyz.tolist()}, "
f"A1 RPY={primary.rpy_deg_xyz.tolist()}, "
"A2 RPY="
f"{comparison.session_bg_with_rotation_prior.rpy_deg_xyz.tolist()}"
)
notes.append(
f"phase-A marginal yaw_std={comparison.marginal_observability.yaw_std_deg:.3f} deg, "
f"LOO yaw range={comparison.leave_one_out_yaw_range_deg:.3f} deg"
)
session_results_list: list[PhaseASessionResult] = [
PhaseASessionResult(
session_id=item.session_id,
pair_count=item.pair_count,
gyro_bias0_rad_s=item.gyro_bias0_rad_s,
gyro_bias_rad_s=item.gyro_bias_rad_s,
residual_rms_deg=item.residual_rms_deg,
residual_median_deg=item.residual_median_deg,
residual_p95_deg=item.residual_p95_deg,
outlier_fraction_gt_5deg=item.outlier_fraction_gt_5deg,
accepted=item.accepted,
included_in_final=True,
)
for item in primary.sessions
]
preexcluded = (
set()
if preexcluded_session_ids is None
else set(preexcluded_session_ids)
)
strong_all = select_strong_rotation_pairs(usable_input)
for session_id in sorted(preexcluded):
local_pairs = [
pair for pair in strong_all if pair.session_id == session_id
]
errors = [
float(
np.degrees(
np.linalg.norm(
preintegration_rotation_residual(
pair.R_A, r, pair.R_B
)
)
)
)
for pair in local_pairs
]
rms, median, p95, outlier, accepted = _rotation_distribution(
errors
)
base = np.asarray(
bias_bases.get(session_id, np.zeros(3)), dtype=float
).reshape(3)
session_results_list.append(
PhaseASessionResult(
session_id=session_id,
pair_count=len(local_pairs),
gyro_bias0_rad_s=base,
gyro_bias_rad_s=base,
residual_rms_deg=rms,
residual_median_deg=median,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier,
accepted=accepted,
included_in_final=False,
)
)
session_results = tuple(
sorted(session_results_list, key=lambda item: item.session_id)
)
usable = [
pair
for pair in strong_all
if pair.session_id not in preexcluded
]
base_observability = analyze_observability(usable, r)
marginal = comparison.marginal_observability
observability = ObservabilityReport(
rotation_observable=bool(
marginal.rank == 3
and marginal.yaw_std_deg <= phase_a_yaw_std_max_deg
),
translation_observable=base_observability.translation_observable,
condition_rotation=marginal.condition,
condition_translation=base_observability.condition_translation,
notes=tuple(
list(marginal.notes)
+ list(base_observability.notes)
),
)
notes.extend(observability.notes)
if time_offset_s_by_session is None:
notes.append(
f"legacy scalar time offset fixed during pair construction: {float(delta_t_s):.6f}s"
)
else:
fixed_offsets = {
str(sid): float(value) for sid, value in time_offset_s_by_session.items()
}
notes.append(
f"time offsets fixed during pair construction (not optimized): {fixed_offsets}"
)
for item in session_results:
notes.append(
f"phase-A session {item.session_id}: included={item.included_in_final}, "
f"pairs={item.pair_count}, rms={item.residual_rms_deg:.3f} deg, "
f"p95={item.residual_p95_deg:.3f} deg, "
f"|bias-bias0|={float(np.linalg.norm(item.gyro_bias_rad_s - item.gyro_bias0_rad_s)):.3e}"
)
phase_c_requested = (not force_rotation_only) if enable_phase_c is None else bool(enable_phase_c)
t = np.zeros(3)
if not force_rotation_only:
if phase_c_requested:
notes.append(
"phase-B/C gated off: session-aware translation/gravity/navigation "
"states are not implemented yet"
)
else:
notes.append("phase-C disabled; translation is not accepted")
if t_prior_m is not None:
t = np.asarray(t_prior_m, dtype=float).reshape(3)
notes.append(
"CAD translation is reported as a prior only and is not accepted as calibration"
)
else:
notes.append("rotation-only extrinsic returned after corrected phase-A")
single_bias = None
if len(biases) == 1:
single_bias = np.asarray(next(iter(biases.values())), dtype=float)
return JointExtrinsicResult(
T_IMU_lidar=make_transform(t, r),
translation_accepted=False,
residual_rms_rot_deg=rot_rms,
residual_rms_trans_m=1e9,
observability=observability,
gyro_bias_rad_s=single_bias,
accel_bias_m_s2=None,
gravity_m_s2=None,
gyro_bias_rad_s_per_session={
sid: np.asarray(value, dtype=float) for sid, value in biases.items()
},
phase_a_sessions=session_results,
phase_a_accepted=phase_a_accepted,
phase_a_comparison=phase_a_comparison_to_dict(comparison),
notes=tuple(notes),
)
+2 -1
View File
@@ -21,6 +21,7 @@ def build_keyframes(
*,
min_translation_m: float = 0.3,
min_rotation_deg: float = 3.0,
min_registration_fitness: float = 0.5,
max_frame_gap: int = 8,
) -> KeyframeSet:
"""Select keyframes with enough relative motion for hand-eye pairs."""
@@ -36,7 +37,7 @@ def build_keyframes(
last = index
continue
result = register_lidar_pair(frames[index].points_xyz, frames[last].points_xyz)
if not result.ok:
if not result.ok or result.fitness < min_registration_fitness:
continue
if result.translation_m >= min_translation_m or result.rotation_deg >= min_rotation_deg:
selected.append(index)
+84
View File
@@ -2,7 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from time import perf_counter
from typing import Any
import numpy as np
@@ -30,7 +33,12 @@ def build_motion_pairs(
acc_bias_m_s2: np.ndarray | None = None,
min_rotation_deg: float = 3.0,
min_translation_m: float = 0.3,
min_registration_fitness: float = 0.5,
max_imu_gap_s: float = 0.05,
max_lidar_gap_s: float = 1.0,
all_frame_times_s: np.ndarray | None = None,
max_index_span: int = 4,
progress_callback: Callable[[dict[str, Any]], None] | None = None,
) -> MotionPairSet:
"""Create A/B motion pairs between nearby keyframes.
@@ -40,21 +48,74 @@ def build_motion_pairs(
notes: list[str] = []
pairs: list[MotionPair] = []
rejected_fitness = 0
rejected_imu_gap = 0
rejected_lidar_gap = 0
frame_times = (
None
if all_frame_times_s is None
else np.asarray(all_frame_times_s, dtype=float).reshape(-1)
)
bias_g = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float)
bias_a = np.zeros(3) if acc_bias_m_s2 is None else np.asarray(acc_bias_m_s2, dtype=float)
n = len(keyframes)
if n < 2:
return MotionPairSet((), ("need at least two keyframes",))
total_candidates = sum(max(n - span, 0) for span in range(1, max_index_span + 1))
processed_candidates = 0
started_at = perf_counter()
last_progress_at = started_at
def report_progress(*, event: str, span: int, force: bool = False) -> None:
nonlocal last_progress_at
if progress_callback is None:
return
now = perf_counter()
if not force and processed_candidates > 1 and now - last_progress_at < 10.0:
return
last_progress_at = now
progress_callback(
{
"event": event,
"processed_candidates": processed_candidates,
"total_candidates": total_candidates,
"progress_pct": 100.0 * processed_candidates / max(total_candidates, 1),
"current_span": span,
"max_span": max_index_span,
"accepted_pairs": len(pairs),
"rejected_fitness": rejected_fitness,
"rejected_imu_gap": rejected_imu_gap,
"rejected_lidar_gap": rejected_lidar_gap,
"elapsed_s": now - started_at,
}
)
report_progress(event="start", span=1, force=True)
for span in range(1, max_index_span + 1):
for start in range(0, n - span):
processed_candidates += 1
report_progress(event="running", span=span)
i = start
j = start + span
frame_i = keyframes[i]
frame_j = keyframes[j]
source_i = int(keyframe_indices[i])
source_j = int(keyframe_indices[j])
if frame_times is not None:
lo = min(source_i, source_j)
hi = max(source_i, source_j)
local_times = frame_times[lo : hi + 1]
if local_times.size >= 2 and np.any(np.diff(local_times) > max_lidar_gap_s):
rejected_lidar_gap += 1
continue
reg = register_lidar_pair(frame_j.points_xyz, frame_i.points_xyz)
if not reg.ok:
continue
if reg.fitness < min_registration_fitness:
rejected_fitness += 1
continue
if reg.rotation_deg < min_rotation_deg and reg.translation_m < min_translation_m:
continue
@@ -64,6 +125,16 @@ def build_motion_pairs(
continue
if t_i_imu < imu.t_s[0] or t_j_imu > imu.t_s[-1]:
continue
imu_lo = max(int(np.searchsorted(imu.t_s, t_i_imu, side="right")) - 1, 0)
imu_hi = min(
int(np.searchsorted(imu.t_s, t_j_imu, side="left")) + 1,
imu.t_s.size,
)
if imu_hi - imu_lo >= 2 and np.any(
np.diff(imu.t_s[imu_lo:imu_hi]) > max_imu_gap_s
):
rejected_imu_gap += 1
continue
preint = preintegrate_imu(
imu.t_s,
@@ -111,14 +182,27 @@ def build_motion_pairs(
"delta_p": preint.delta_p.tolist(),
"t_i_imu_s": t_i_imu,
"t_j_imu_s": t_j_imu,
"gyro_bias0_rad_s": bias_g.tolist(),
"accel_bias0_m_s2": bias_a.tolist(),
"time_offset_s": float(delta_t_s),
"keyframe_span": int(span),
"is_consecutive": bool(span == 1),
"modeling": "imu_preintegration_factor_phase_c",
},
)
)
report_progress(event="complete", span=max_index_span, force=True)
notes.append(
f"built {len(pairs)} motion pairs (Phase-C preintegration: ΔR/Δv/Δp, Σ9, J_bg/J_ba)"
)
notes.append(
"quality rejects: "
f"fitness<{min_registration_fitness:.2f}: {rejected_fitness}, "
f"IMU gap>{max_imu_gap_s:.3f}s: {rejected_imu_gap}, "
f"LiDAR gap>{max_lidar_gap_s:.3f}s: {rejected_lidar_gap}"
)
return MotionPairSet(pairs=tuple(pairs), notes=tuple(notes))
+17 -5
View File
@@ -10,9 +10,11 @@ import numpy as np
from .contracts import MotionPair
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
# Keep viz-relevant fields; drop large cov / Jacobians.
# Keep visualization fields plus the compact 3x3 rotation metadata needed to
# rerun Phase-A without repeating LiDAR registration. Full 9x9 Phase-C matrices
# remain excluded from this cache.
_METADATA_KEEP = frozenset(
{
"backend",
@@ -23,8 +25,17 @@ _METADATA_KEEP = frozenset(
"duration_s",
"mean_gyro_norm",
"preint_sigma_rad",
"cov",
"J_bg",
"phase_a_metadata_rehydrated",
"rehydrated_R_A_error_deg",
"t_i_imu_s",
"t_j_imu_s",
"gyro_bias0_rad_s",
"accel_bias0_m_s2",
"time_offset_s",
"keyframe_span",
"is_consecutive",
"modeling",
}
)
@@ -113,10 +124,11 @@ def save_motion_pairs(path: Path | str, payload: dict[str, Any]) -> Path:
def load_motion_pairs(path: Path | str) -> dict[str, Any]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if int(payload.get("schema_version", 0)) != SCHEMA_VERSION:
version = int(payload.get("schema_version", 0))
if version not in {1, SCHEMA_VERSION}:
raise ValueError(
f"unsupported motion_pairs schema_version={payload.get('schema_version')}; "
f"expected {SCHEMA_VERSION}"
f"unsupported motion_pairs schema_version={version}; "
f"expected 1 or {SCHEMA_VERSION}"
)
return payload
+40 -30
View File
@@ -57,43 +57,53 @@ def analyze_observability(
if j_r.size == 0:
return ObservabilityReport(False, False, 1e9, 1e9, ("empty rotation jacobian",))
# Normalize columns.
col_norm = np.linalg.norm(j_r, axis=0) + 1e-12
j_r_n = j_r / col_norm
singular = np.linalg.svd(j_r_n, compute_uv=False)
singular = np.linalg.svd(j_r, compute_uv=False)
cond_r = float(singular[0] / max(singular[-1], 1e-12))
rotation_ok = cond_r < condition_threshold and singular[-1] > 1e-3
rotation_information = float(singular[-1] / np.sqrt(max(len(usable), 1)))
rotation_ok = (
cond_r < condition_threshold
and rotation_information > 1e-3
and singular[-1] > 1e-6
)
# Translation observability proxy: diversity of rotation axes and presence of translation in B.
axes = []
translations = []
for pair in usable:
axis = so3_log(pair.R_B)
n = np.linalg.norm(axis)
if n > 1e-8:
axes.append(axis / n)
if pair.t_B_m is not None:
translations.append(pair.t_B_m)
axis_rank = 0
if axes:
axis_mat = np.asarray(axes, dtype=float)
axis_rank = int(np.linalg.matrix_rank(axis_mat, tol=0.1))
trans_span = 0.0
if translations:
tmat = np.asarray(translations, dtype=float)
trans_span = float(np.linalg.norm(np.std(tmat, axis=0)))
# For planar yaw-mostly motion, translation z is typically weak.
translation_ok = axis_rank >= 2 and trans_span > 0.2 and len(translations) >= 5
cond_t = 1e9 if not translation_ok else float(max(3, 10 - axis_rank * 2) * (0.5 / max(trans_span, 1e-3)))
# Translation lever arm is observable through stacked (R_A - I). Pure
# planar yaw leaves its vertical column in the nullspace and must fail.
translation_rows = [
np.asarray(pair.R_A, dtype=float).reshape(3, 3) - np.eye(3)
for pair in usable
if pair.t_B_m is not None
]
if translation_rows:
j_t = np.vstack(translation_rows)
singular_t = np.linalg.svd(j_t, compute_uv=False)
cond_t = float(singular_t[0] / max(singular_t[-1], 1e-12))
translation_information = float(
singular_t[-1] / np.sqrt(max(len(translation_rows), 1))
)
else:
cond_t = 1e9
translation_information = 0.0
translation_ok = (
len(translation_rows) >= 5
and cond_t < condition_threshold
and translation_information > 0.02
)
if not rotation_ok:
notes.append(f"rotation condition {cond_r:.1f} exceeds threshold {condition_threshold}")
notes.append(
f"rotation not observable: condition={cond_r:.1f}, "
f"min_information={rotation_information:.3e}"
)
else:
notes.append(f"rotation condition {cond_r:.1f}")
notes.append(
f"rotation observable: condition={cond_r:.1f}, "
f"min_information={rotation_information:.3e}"
)
if not translation_ok:
notes.append(
f"translation not observable (axis_rank={axis_rank}, trans_span={trans_span:.3f} m); "
"V1 will reject full SE3 without strong priors"
f"translation not observable: condition={cond_t:.1f}, "
f"min_information={translation_information:.3e}; "
"full SE3 will be rejected"
)
return ObservabilityReport(
rotation_observable=rotation_ok,
+1435
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
"""Cached Phase-A replay: rehydrate gyro factors, compare variants, write reports."""
from __future__ import annotations
import json
from collections import defaultdict
from pathlib import Path
from typing import Any
import numpy as np
from .imu_io import load_imu_samples
from .motion_pairs_io import (
build_motion_pairs_payload,
load_motion_pairs,
pair_from_dict,
save_motion_pairs,
)
from .phase_a import (
ProgressCallback,
phase_a_comparison_to_dict,
phase_a_metadata_complete,
rehydrate_phase_a_pairs,
solve_phase_a_comparison,
)
from .vehicle_config import load_vehicle_config, prior_enabled
def _rotation_prior(
vehicle_config_path: Path,
) -> tuple[np.ndarray | None, float]:
config = load_vehicle_config(vehicle_config_path)
if not prior_enabled(config, "rotation_prior"):
return None, 15.0
prior = (config.get("initialization") or {}).get("rotation_prior") or {}
matrix = prior.get("R_IMU_lidar")
if matrix is None:
return None, float(prior.get("sigma_deg", 15.0))
return (
np.asarray(matrix, dtype=float).reshape(3, 3),
float(prior.get("sigma_deg", 15.0)),
)
def _sanitize_json(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _sanitize_json(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_sanitize_json(item) for item in value]
if isinstance(value, np.ndarray):
return _sanitize_json(value.tolist())
if isinstance(value, (np.floating, float)):
number = float(value)
return number if np.isfinite(number) else None
if isinstance(value, (np.integer, np.bool_)):
return value.item()
return value
def _write_json(path: Path, payload: Any) -> None:
path.write_text(
json.dumps(_sanitize_json(payload), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _load_cached_sessions(
motion_pairs_path: Path,
) -> tuple[
dict[str, Any],
list,
dict[str, np.ndarray],
dict[str, float],
]:
payload = load_motion_pairs(motion_pairs_path)
pairs = []
biases: dict[str, np.ndarray] = {}
offsets: dict[str, float] = {}
for session in payload.get("sessions") or []:
session_id = str(session["session_id"])
biases[session_id] = np.asarray(
session.get("gyro_bias_rad_s", np.zeros(3)),
dtype=float,
).reshape(3)
offsets[session_id] = float(session.get("delta_t_s", 0.0))
pairs.extend(
pair_from_dict(item)
for item in session.get("pairs") or []
)
if not pairs:
raise ValueError(f"motion-pair cache is empty: {motion_pairs_path}")
return payload, pairs, biases, offsets
def run_phase_a_replay(
*,
motion_pairs_path: Path,
vehicle_config_path: Path,
output_directory: Path,
imu_paths_by_session: dict[str, Path] | None = None,
excluded_sessions: set[str] | None = None,
strong_rotation_min_deg: float = 1.0,
decorrelation_block_s: float = 3.0,
max_pairs_per_block: int = 1,
bias_prior_sigma_rad_s: float = 0.002,
yaw_std_max_deg: float = 0.5,
leave_one_out_yaw_range_max_deg: float = 1.0,
data_prior_difference_max_deg: float = 1.0,
max_nfev: int = 200,
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
"""Run Phase-A only. Existing LiDAR relative motions are never recomputed."""
output_directory.mkdir(parents=True, exist_ok=True)
source_payload, pairs, bias0, offsets = _load_cached_sessions(
motion_pairs_path
)
session_ids = sorted(bias0)
if progress_callback is not None:
progress_callback(
"cache_loaded",
{
"schema_version": source_payload.get("schema_version"),
"sessions": len(session_ids),
"pairs": len(pairs),
},
)
rehydration_report: dict[str, Any] = {
"required": not phase_a_metadata_complete(pairs),
"pair_count": len(pairs),
}
if not phase_a_metadata_complete(pairs):
supplied_paths = {} if imu_paths_by_session is None else imu_paths_by_session
missing = [sid for sid in session_ids if sid not in supplied_paths]
if missing:
raise ValueError(
"v1 cache lacks J_bg/cov; provide --session-imu for: "
+ ", ".join(missing)
)
imu_by_session = {
sid: load_imu_samples(supplied_paths[sid])
for sid in session_ids
}
pairs, details = rehydrate_phase_a_pairs(
pairs,
imu_by_session=imu_by_session,
bias0_by_session=bias0,
progress_callback=progress_callback,
)
rehydration_report.update(details)
if float(details["max_R_A_error_deg"]) > 0.05:
raise ValueError(
"rehydrated IMU rotations do not match cached R_A: "
f"max error={details['max_R_A_error_deg']:.6f} deg; "
"check session-to-IMU path mapping"
)
grouped: dict[str, list] = defaultdict(list)
for pair in pairs:
grouped[pair.session_id].append(pair)
enriched_payload = build_motion_pairs_payload(
prepared_sessions=[
{
"session_id": sid,
"time_offset_s": offsets[sid],
"gyro_bias_rad_s": bias0[sid],
"pairs": tuple(grouped[sid]),
}
for sid in session_ids
]
)
enriched_cache_path = save_motion_pairs(
output_directory / "motion_pairs_phase_a_v2.json",
enriched_payload,
)
rotation_prior, rotation_prior_sigma_deg = _rotation_prior(
vehicle_config_path
)
comparison = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=bias0,
rotation_prior=rotation_prior,
rotation_prior_sigma_deg=rotation_prior_sigma_deg,
preexcluded_session_ids=excluded_sessions,
strong_rotation_min_deg=strong_rotation_min_deg,
decorrelation_block_s=decorrelation_block_s,
max_pairs_per_block=max_pairs_per_block,
bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
yaw_std_max_deg=yaw_std_max_deg,
leave_one_out_yaw_range_max_deg=(
leave_one_out_yaw_range_max_deg
),
data_prior_difference_max_deg=data_prior_difference_max_deg,
run_leave_one_out=True,
max_nfev=max_nfev,
progress_callback=progress_callback,
)
full = phase_a_comparison_to_dict(comparison)
full["input"] = {
"motion_pairs": str(motion_pairs_path),
"source_schema_version": source_payload.get("schema_version"),
"vehicle_config": str(vehicle_config_path),
"session_imu_paths": {
sid: str(path)
for sid, path in (imu_paths_by_session or {}).items()
},
"excluded_sessions": sorted(excluded_sessions or set()),
}
full["rehydration"] = rehydration_report
full["enriched_cache"] = str(enriched_cache_path)
full["parameters"] = {
"strong_rotation_min_deg": strong_rotation_min_deg,
"decorrelation_block_s": decorrelation_block_s,
"max_pairs_per_block": max_pairs_per_block,
"bias_prior_sigma_rad_s": bias_prior_sigma_rad_s,
"rotation_prior_sigma_deg": rotation_prior_sigma_deg,
"yaw_std_max_deg": yaw_std_max_deg,
"leave_one_out_yaw_range_max_deg": (
leave_one_out_yaw_range_max_deg
),
"data_prior_difference_max_deg": (
data_prior_difference_max_deg
),
"max_nfev": max_nfev,
}
variants = full["variants"]
summary = {
"status": comparison.solution_status,
"accepted": comparison.accepted,
"partial_accepted": comparison.partial_accepted,
"acceptance_checks": comparison.acceptance_checks,
"primary_result": comparison.recommended_result,
"variants": {
name: {
"rpy_deg_xyz": item["rpy_deg_xyz"],
"R_IMU_lidar": item["R_IMU_lidar"],
"residual_rms_deg": item["residual_rms_deg"],
"residual_p95_deg": item["residual_p95_deg"],
"accepted": item["accepted"],
"gyro_bias_rad_s_per_session": item[
"gyro_bias_rad_s_per_session"
],
}
for name, item in variants.items()
if item is not None
},
"marginal_observability_A1": full[
"marginal_observability_A1"
],
"data_vs_prior_yaw_diff_deg": (
comparison.data_vs_prior_yaw_diff_deg
),
"data_vs_prior_geodesic_deg": (
comparison.data_vs_prior_geodesic_deg
),
"leave_one_out_yaw_range_deg": (
comparison.leave_one_out_yaw_range_deg
),
"leave_one_out_observable_max_deg": (
comparison.leave_one_out_observable_max_deg
),
"strong_pair_candidate_count": (
comparison.strong_pair_candidate_count
),
"decorrelated_pair_count": comparison.decorrelated_pair_count,
"strong_pair_counts_per_session": (
comparison.strong_pair_counts_per_session
),
"excluded_sessions": list(comparison.excluded_sessions),
"rehydration": rehydration_report,
"comparison_file": "phase_a_comparison.json",
"observability_file": "phase_a_observability.json",
"leave_one_out_file": "phase_a_leave_one_out.json",
"enriched_cache_file": enriched_cache_path.name,
}
_write_json(output_directory / "phase_a_comparison.json", full)
_write_json(
output_directory / "phase_a_observability.json",
full["marginal_observability_A1"],
)
_write_json(
output_directory / "phase_a_leave_one_out.json",
full["leave_one_out"],
)
_write_json(output_directory / "phase_a_summary.json", summary)
return summary
+485 -38
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from time import perf_counter
from typing import Any
import numpy as np
@@ -58,11 +60,33 @@ STAGES = (
PipelineStage("lidar_motion", "各会话关键帧、可选去畸变与 LiDAR 相对运动"),
PipelineStage("motion_pairs", "各会话构造运动对,再合并"),
PipelineStage("rotation_handeye", "用全部会话运动对联合求解旋转外参"),
PipelineStage("joint_optimizer", "用全部会话运动对联合精修;完整模式估平移"),
PipelineStage("joint_optimizer", "Phase-A 会话级零偏联合精修;Phase-B/C 暂时门控"),
PipelineStage("finalize", "写出结果与质量报告"),
)
ProgressCallback = Callable[[dict[str, Any]], None]
def _emit_progress(
callback: ProgressCallback | None,
stage_index: int,
event: str,
**fields: Any,
) -> None:
if callback is None:
return
callback(
{
"stage_index": stage_index,
"stage_total": len(STAGES),
"stage": STAGES[stage_index - 1].name,
"event": event,
**fields,
}
)
def describe_pipeline(_: CalibrationRequest) -> tuple[PipelineStage, ...]:
"""Return the planned stages."""
@@ -79,12 +103,22 @@ def _build_pairs_and_handeye(
request: CalibrationRequest,
R_prior: np.ndarray | None = None,
prior_sigma_deg: float | None = None,
progress_callback: ProgressCallback | None = None,
):
keyframes = build_keyframes(
working_frames,
min_translation_m=request.min_pair_translation_m,
min_rotation_deg=request.min_pair_rotation_deg,
min_registration_fitness=request.min_registration_fitness,
)
if progress_callback is not None:
progress_callback(
{
"event": "keyframes_ready",
"keyframe_count": len(keyframes.indices),
"lidar_frame_count": len(working_frames),
}
)
pair_set = build_motion_pairs(
session_id=session_id,
keyframes=list(keyframes.frames),
@@ -94,6 +128,11 @@ def _build_pairs_and_handeye(
gyro_bias_rad_s=gyro_bias_rad_s,
min_rotation_deg=request.min_pair_rotation_deg,
min_translation_m=request.min_pair_translation_m,
min_registration_fitness=request.min_registration_fitness,
max_imu_gap_s=request.max_imu_gap_s,
max_lidar_gap_s=request.max_lidar_gap_s,
all_frame_times_s=np.asarray([frame.t_mid_s for frame in working_frames], dtype=float),
progress_callback=progress_callback,
)
handeye = solve_rotation_handeye(
pair_set.pairs,
@@ -133,32 +172,81 @@ def _prepare_session_pairs(
*,
R_prior: np.ndarray | None = None,
prior_sigma_deg: float | None = None,
progress_callback: ProgressCallback | None = None,
session_index: int = 1,
session_total: int = 1,
) -> dict[str, Any]:
"""Per-session: audit, δt, keyframes/pairs. No joint extrinsic yet."""
started_at = perf_counter()
def emit(stage_index: int, event: str, **fields: Any) -> None:
_emit_progress(
progress_callback,
stage_index,
event,
session=session.session_id,
session_index=session_index,
session_total=session_total,
**fields,
)
emit(
2,
"session_start",
imu_source=str(session.imu_source),
lidar_source=str(session.lidar_source),
)
imu = load_imu_samples(session.imu_source)
frames = load_lidar_frames(session.lidar_source)
emit(
2,
"data_loaded",
imu_samples=int(imu.t_s.size),
lidar_frames=len(frames),
imu_span_s=float(imu.t_s[-1] - imu.t_s[0]) if imu.t_s.size >= 2 else 0.0,
lidar_span_s=(
float(frames[-1].t_mid_s - frames[0].t_mid_s) if len(frames) >= 2 else 0.0
),
elapsed_s=perf_counter() - started_at,
)
ts = audit_timestamps(imu, frames)
emit(2, "audit_complete", ok=ts.ok)
if not ts.ok:
emit(2, "blocked", reason="timestamp_audit")
return {"ok": False, "stage": "timestamp_audit", "session_id": session.session_id, "report": asdict(ts)}
imu_report = audit_imu(imu)
emit(
3,
"audit_complete",
ok=imu_report.ok,
gyro_bias_norm_rad_s=float(np.linalg.norm(imu_report.gyro_bias_rad_s)),
)
if not imu_report.ok:
emit(3, "blocked", reason="imu_audit")
return {"ok": False, "stage": "imu_audit", "session_id": session.session_id, "report": asdict(imu_report)}
if request.fixed_time_offset_s is not None:
fixed_time_offset_s = (
session.fixed_time_offset_s
if session.fixed_time_offset_s is not None
else request.fixed_time_offset_s
)
if fixed_time_offset_s is not None:
offset_source = "fixed"
offset = TimeOffsetResult(
delta_t_s=float(request.fixed_time_offset_s),
delta_t_s=float(fixed_time_offset_s),
correlation_peak=1.0,
search_s=0.0,
notes=(
f"fixed_time_offset_s={float(request.fixed_time_offset_s):.6f} "
f"fixed_time_offset_s={float(fixed_time_offset_s):.6f} "
"(skip |ω| search; intended for host-UTC-bridged sessions)",
),
ok=True,
)
else:
offset_source = "estimated"
offset = estimate_time_offset(
imu,
frames,
@@ -166,8 +254,22 @@ def _prepare_session_pairs(
search_s=request.time_offset_search_s,
)
if not offset.ok:
emit(
4,
"blocked",
reason="time_offset",
time_offset_s=float(offset.delta_t_s),
correlation_peak=float(offset.correlation_peak),
)
return {"ok": False, "stage": "time_offset", "session_id": session.session_id, "report": asdict(offset)}
emit(
4,
"offset_ready",
source=offset_source,
time_offset_s=float(offset.delta_t_s),
correlation_peak=float(offset.correlation_peak),
)
coarse_delta_t = float(offset.delta_t_s)
working_frames = frames
r_x = np.eye(3) if R_prior is None else np.asarray(R_prior, dtype=float).reshape(3, 3)
@@ -177,8 +279,35 @@ def _prepare_session_pairs(
pairs_notes: list[str] = []
pair_count = 0
for iteration in range(max(1, request.max_iterations)):
iterations_total = max(1, request.max_iterations)
build_pass = "outer"
def on_build_progress(payload: dict[str, Any]) -> None:
event = str(payload.get("event", "running"))
stage_index = 5 if event == "keyframes_ready" else 6
fields = {key: value for key, value in payload.items() if key != "event"}
emit(
stage_index,
event,
iteration=iteration + 1,
iterations_total=iterations_total,
build_pass=build_pass,
**fields,
)
for iteration in range(iterations_total):
build_pass = "outer"
emit(
5,
"iteration_start",
iteration=iteration + 1,
iterations_total=iterations_total,
deskew=iteration > 0,
time_offset_s=float(offset.delta_t_s),
)
if iteration > 0:
deskew_started_at = perf_counter()
emit(5, "deskew_start", iteration=iteration + 1)
working_frames = deskew_lidar_frames(
frames,
imu,
@@ -186,6 +315,13 @@ def _prepare_session_pairs(
R_IMU_lidar=r_x,
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
)
emit(
5,
"deskew_complete",
iteration=iteration + 1,
lidar_frames=len(working_frames),
elapsed_s=perf_counter() - deskew_started_at,
)
keyframes, pair_set, handeye = _build_pairs_and_handeye(
session_id=session.session_id,
working_frames=working_frames,
@@ -195,10 +331,31 @@ def _prepare_session_pairs(
request=request,
R_prior=R_prior,
prior_sigma_deg=prior_sigma_deg,
progress_callback=on_build_progress,
)
pairs_notes = list(pair_set.notes)
pair_count = len(pair_set.pairs)
emit(
7,
"local_handeye",
iteration=iteration + 1,
build_pass=build_pass,
keyframes=len(keyframes.indices),
pair_count=pair_count,
rms_deg=float(handeye.residual_rms_deg),
p95_deg=float(handeye.residual_p95_deg),
outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
ok=handeye.ok,
)
if pair_count < 3:
emit(
6,
"blocked",
reason="insufficient_motion_pairs",
iteration=iteration + 1,
keyframes=len(keyframes.indices),
pair_count=pair_count,
)
return {
"ok": False,
"stage": "motion_pairs",
@@ -216,7 +373,14 @@ def _prepare_session_pairs(
if not request.enable_signed_time_refine:
continue
for _ in range(2):
for refine_step in range(1, 3):
emit(
4,
"signed_refine_start",
iteration=iteration + 1,
refine_step=refine_step,
time_offset_s=float(offset.delta_t_s),
)
refined = refine_time_offset_signed(
imu,
frames,
@@ -243,8 +407,18 @@ def _prepare_session_pairs(
)
delta_shift = abs(refined.delta_t_s - offset.delta_t_s)
offset = _merge_time_offset(offset, refined)
emit(
4,
"signed_refine_complete",
iteration=iteration + 1,
refine_step=refine_step,
time_offset_s=float(offset.delta_t_s),
shift_s=float(delta_shift),
correlation_peak=float(refined.correlation_peak),
)
if delta_shift < 1e-3:
break
build_pass = f"signed_refine_{refine_step}"
keyframes, pair_set, handeye = _build_pairs_and_handeye(
session_id=session.session_id,
working_frames=working_frames,
@@ -254,10 +428,31 @@ def _prepare_session_pairs(
request=request,
R_prior=R_prior,
prior_sigma_deg=prior_sigma_deg,
progress_callback=on_build_progress,
)
pairs_notes = list(pair_set.notes)
pair_count = len(pair_set.pairs)
emit(
7,
"local_handeye",
iteration=iteration + 1,
build_pass=build_pass,
keyframes=len(keyframes.indices),
pair_count=pair_count,
rms_deg=float(handeye.residual_rms_deg),
p95_deg=float(handeye.residual_p95_deg),
outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
ok=handeye.ok,
)
if pair_count < 3:
emit(
6,
"blocked",
reason="insufficient_motion_pairs_after_signed_refine",
iteration=iteration + 1,
keyframes=len(keyframes.indices),
pair_count=pair_count,
)
return {
"ok": False,
"stage": "motion_pairs",
@@ -271,7 +466,6 @@ def _prepare_session_pairs(
"handeye": asdict(handeye),
}
r_x = handeye.R_IMU_lidar
assert handeye is not None and pair_set is not None and keyframes is not None
acc_mean = np.asarray(imu_report.static_acc_mean_m_s2, dtype=float).reshape(3)
acc_n = float(np.linalg.norm(acc_mean))
@@ -280,6 +474,15 @@ def _prepare_session_pairs(
else:
gravity_init = np.array([0.0, 0.0, -9.80665])
emit(
7,
"session_complete",
keyframes=len(keyframes.indices),
pair_count=pair_count,
time_offset_s=float(offset.delta_t_s),
local_handeye_ok=handeye.ok,
elapsed_s=perf_counter() - started_at,
)
return {
"ok": True,
"session_id": session.session_id,
@@ -300,6 +503,8 @@ def _prepare_session_pairs(
"handeye_local": {
"residual_rms_deg": handeye.residual_rms_deg,
"residual_median_deg": handeye.residual_median_deg,
"residual_p95_deg": handeye.residual_p95_deg,
"outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
"pair_count": handeye.pair_count,
"ok": handeye.ok,
"notes": handeye.notes,
@@ -323,60 +528,152 @@ def _remap_pairs_for_joint(prepared: list[dict[str, Any]]) -> list[MotionPair]:
return merged
def run_calibration(request: CalibrationRequest) -> CalibrationResult:
def run_calibration(
request: CalibrationRequest,
*,
progress_callback: ProgressCallback | None = None,
) -> CalibrationResult:
"""Run the V1 calibration pipeline for one or more sessions.
Multi-session: each session estimates its own δt and builds motion pairs;
rotation hand-eye and joint SE3 are solved once on the merged pair set.
"""
overall_started_at = perf_counter()
def finish(
*,
status: CalibrationStatus,
message: str,
details: dict[str, Any],
T_IMU_lidar: np.ndarray | None = None,
time_offset_s: float | None = None,
motion_pairs_payload: dict[str, Any] | None = None,
) -> CalibrationResult:
_emit_progress(
progress_callback,
9,
"writing_result",
status=status.value,
output_directory=str(request.output_directory),
)
result = finalize_result(
status=status,
message=message,
details=details,
T_IMU_lidar=T_IMU_lidar,
time_offset_s=time_offset_s,
output_directory=request.output_directory,
motion_pairs_payload=motion_pairs_payload,
)
_emit_progress(
progress_callback,
9,
"complete",
status=result.status.value,
elapsed_s=perf_counter() - overall_started_at,
)
return result
_emit_progress(
progress_callback,
1,
"pipeline_start",
mode=request.requested_mode.value,
session_count=len(request.sessions),
max_iterations=max(1, request.max_iterations),
output_directory=str(request.output_directory),
)
if not request.sessions:
return finalize_result(
return finish(
status=CalibrationStatus.BLOCKED,
message="no sessions provided",
details={},
output_directory=request.output_directory,
)
vehicle_config = None
if request.vehicle_config is not None:
_emit_progress(
progress_callback,
1,
"loading_vehicle_config",
path=str(request.vehicle_config),
)
try:
vehicle_config = load_vehicle_config(request.vehicle_config)
except Exception as exc: # noqa: BLE001 - surface config problems as blocked
return finalize_result(
_emit_progress(
progress_callback,
1,
"blocked",
reason="vehicle_config",
error=str(exc),
)
return finish(
status=CalibrationStatus.BLOCKED,
message=f"vehicle config failed: {exc}",
details={},
output_directory=request.output_directory,
)
_emit_progress(
progress_callback,
1,
"vehicle_config_ready",
loaded=vehicle_config is not None,
)
r_prior, prior_sigma_deg = _rotation_prior_from_config(vehicle_config)
prepared: list[dict[str, Any]] = []
for session in request.sessions:
session_total = len(request.sessions)
for session_index, session in enumerate(request.sessions, start=1):
prep = _prepare_session_pairs(
session,
request,
R_prior=r_prior,
prior_sigma_deg=prior_sigma_deg,
progress_callback=progress_callback,
session_index=session_index,
session_total=session_total,
)
if not prep.get("ok"):
return finalize_result(
return finish(
status=CalibrationStatus.BLOCKED,
message=f"blocked at stage {prep.get('stage')} ({prep.get('session_id')})",
details={"sessions": [prep]},
output_directory=request.output_directory,
)
prepared.append(prep)
all_pairs = _remap_pairs_for_joint(prepared)
pair_counts_per_session = {
p["session_id"]: int(p["pair_count"]) for p in prepared
}
_emit_progress(
progress_callback,
7,
"joint_handeye_start",
session_count=len(prepared),
merged_pair_count=len(all_pairs),
pair_counts_per_session=pair_counts_per_session,
)
handeye_started_at = perf_counter()
handeye = solve_rotation_handeye(
all_pairs,
R_prior=r_prior,
prior_sigma_deg=prior_sigma_deg,
)
if not handeye.ok:
return finalize_result(
_emit_progress(
progress_callback,
7,
"joint_handeye_complete",
pair_count=handeye.pair_count,
rms_deg=float(handeye.residual_rms_deg),
p95_deg=float(handeye.residual_p95_deg),
outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
ok=handeye.ok,
elapsed_s=perf_counter() - handeye_started_at,
)
if handeye.pair_count < 3:
return finish(
status=CalibrationStatus.BLOCKED,
message="blocked at stage rotation_handeye (joint)",
details={
@@ -384,33 +681,124 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
"joint_handeye": asdict(handeye),
"merged_pair_count": len(all_pairs),
},
output_directory=request.output_directory,
)
force_rotation_only = request.requested_mode == CalibrationMode.ROTATION_ONLY
t_prior, t_prior_sigma = _translation_prior_from_config(vehicle_config)
gyro_bias = np.mean(np.stack([p["gyro_bias_rad_s"] for p in prepared], axis=0), axis=0)
gravity_init = np.mean(np.stack([p["gravity_init_m_s2"] for p in prepared], axis=0), axis=0)
g_n = float(np.linalg.norm(gravity_init))
if g_n > 1e-6:
gravity_init = gravity_init * (9.80665 / g_n)
gyro_bias_by_session = {
p["session_id"]: np.asarray(p["gyro_bias_rad_s"], dtype=float) for p in prepared
}
time_offset_by_session = {
p["session_id"]: float(p["time_offset_s"]) for p in prepared
}
preexcluded_session_ids = {
p["session_id"] for p in prepared if not p["handeye_local"]["ok"]
}
if len(preexcluded_session_ids) == len(prepared):
_emit_progress(
progress_callback,
8,
"phase_a_complete",
accepted=False,
reason="all_sessions_failed_local_handeye_gate",
excluded_sessions=sorted(preexcluded_session_ids),
)
return finish(
status=CalibrationStatus.BLOCKED,
message=(
"Phase-A blocked: all sessions failed the local "
"rotation residual gate"
),
details={
"sessions": [_public_session(p) for p in prepared],
"joint_handeye": asdict(handeye),
"merged_pair_count": len(all_pairs),
"excluded_sessions": sorted(
preexcluded_session_ids
),
},
)
_emit_progress(
progress_callback,
8,
"phase_a_start",
session_count=len(prepared),
merged_pair_count=len(all_pairs),
preexcluded_sessions=sorted(preexcluded_session_ids),
)
phase_a_started_at = perf_counter()
def on_phase_a_progress(
event: str,
fields: dict[str, Any],
) -> None:
_emit_progress(
progress_callback,
8,
event,
**fields,
)
joint = solve_joint_extrinsic(
all_pairs,
handeye.R_IMU_lidar,
force_rotation_only=force_rotation_only,
imu=None,
delta_t_s=0.0,
gyro_bias_rad_s=gyro_bias,
gravity_init_m_s2=gravity_init,
gyro_bias_rad_s_by_session=gyro_bias_by_session,
time_offset_s_by_session=time_offset_by_session,
preexcluded_session_ids=preexcluded_session_ids,
rotation_prior=r_prior,
rotation_prior_sigma_deg=(
15.0 if prior_sigma_deg is None else prior_sigma_deg
),
phase_a_progress_callback=on_phase_a_progress,
enable_phase_c=not force_rotation_only,
t_init_m=t_prior,
t_prior_m=t_prior,
t_prior_sigma_m=t_prior_sigma,
)
included_sessions = [
item.session_id for item in joint.phase_a_sessions if item.included_in_final
]
excluded_sessions = [
item.session_id for item in joint.phase_a_sessions if not item.included_in_final
]
_emit_progress(
progress_callback,
8,
"phase_a_complete",
accepted=joint.phase_a_accepted,
joint_rms_deg=float(joint.residual_rms_rot_deg),
rotation_observable=joint.observability.rotation_observable,
included_sessions=included_sessions,
excluded_sessions=excluded_sessions,
elapsed_s=perf_counter() - phase_a_started_at,
)
for item in joint.phase_a_sessions:
_emit_progress(
progress_callback,
8,
"phase_a_session",
session=item.session_id,
included=item.included_in_final,
accepted=item.accepted,
pair_count=item.pair_count,
rms_deg=float(item.residual_rms_deg),
p95_deg=float(item.residual_p95_deg),
bias_delta_norm_rad_s=float(
np.linalg.norm(item.gyro_bias_rad_s - item.gyro_bias0_rad_s)
),
gyro_bias_rad_s=np.asarray(item.gyro_bias_rad_s, dtype=float).round(8).tolist(),
)
phase_a_by_session = {
item.session_id: item for item in joint.phase_a_sessions
}
session_results = []
for prep in prepared:
phase_a = phase_a_by_session.get(prep["session_id"])
session_bias = joint.gyro_bias_rad_s_per_session.get(prep["session_id"])
session_results.append(
{
**_public_session(prep),
@@ -418,6 +806,8 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
"handeye": {
"residual_rms_deg": handeye.residual_rms_deg,
"residual_median_deg": handeye.residual_median_deg,
"residual_p95_deg": handeye.residual_p95_deg,
"outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
"pair_count": handeye.pair_count,
"ok": handeye.ok,
"notes": tuple(list(handeye.notes) + [f"joint over {len(request.sessions)} sessions"]),
@@ -430,9 +820,10 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
"observability": asdict(joint.observability),
"notes": joint.notes,
"T_IMU_lidar": joint.T_IMU_lidar.tolist(),
"phase_a": None if phase_a is None else asdict(phase_a),
"gyro_bias_rad_s": None
if joint.gyro_bias_rad_s is None
else np.asarray(joint.gyro_bias_rad_s, dtype=float).tolist(),
if session_bias is None
else np.asarray(session_bias, dtype=float).tolist(),
"accel_bias_m_s2": None
if joint.accel_bias_m_s2 is None
else np.asarray(joint.accel_bias_m_s2, dtype=float).tolist(),
@@ -441,14 +832,40 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
else np.asarray(joint.gravity_m_s2, dtype=float).tolist(),
},
"translation_accepted": joint.translation_accepted,
"rotation_ok": handeye.ok and joint.observability.rotation_observable,
"rotation_ok": (
phase_a is not None
and phase_a.included_in_final
and phase_a.accepted
and joint.phase_a_accepted
and joint.observability.rotation_observable
),
"rotation_prior_constrained": (
phase_a is not None
and phase_a.included_in_final
and phase_a.accepted
and joint.phase_a_accepted
and not joint.observability.rotation_observable
and r_prior is not None
),
}
)
T = np.asarray(joint.T_IMU_lidar, dtype=float)
# Report per-session δt list; keep first as scalar for backward-compatible field.
delta_t = float(prepared[0]["time_offset_s"])
if request.requested_mode == CalibrationMode.FULL_SE3:
if request.requested_mode == CalibrationMode.ROTATION_ONLY:
# A rotation-only result must never expose a seed/prior translation,
# including when the rotation itself is rejected by a later gate.
T = T.copy()
T[:3, 3] = 0.0
# Multi-session offsets stay in details; the legacy scalar is single-session only.
delta_t = float(prepared[0]["time_offset_s"]) if len(prepared) == 1 else None
joint_rotation_ok = joint.phase_a_accepted
if not joint_rotation_ok:
status = CalibrationStatus.BLOCKED
message = (
f"joint rotation rejected: RMS={joint.residual_rms_rot_deg:.3f} deg "
"or a retained session failed the Phase-A residual gates"
)
elif request.requested_mode == CalibrationMode.FULL_SE3:
if joint.translation_accepted:
status = CalibrationStatus.FULL_SE3_ACCEPTED
message = f"full SE3 accepted (joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
@@ -456,15 +873,31 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
status = CalibrationStatus.FULL_SE3_REJECTED
message = (
f"rotation accepted jointly ({len(prepared)} sessions); "
"translation rejected by observability/residual gates"
"translation deferred until Phase-B/C session-state redesign"
)
else:
elif joint.observability.rotation_observable:
status = CalibrationStatus.ROTATION_ONLY_ACCEPTED
message = f"rotation-only calibration accepted (joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
message = (
f"rotation-only calibration accepted "
f"(joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
)
T = T.copy()
T[:3, 3] = 0.0
elif r_prior is not None:
status = CalibrationStatus.ROTATION_ONLY_PRIOR_CONSTRAINED
message = (
"rotation residuals passed, but motion does not independently observe all "
"rotation axes; result remains constrained by the installation prior"
)
T = T.copy()
T[:3, 3] = 0.0
else:
status = CalibrationStatus.BLOCKED
message = "rotation residuals passed but rotation observability failed without a prior"
T = T.copy()
T[:3, 3] = 0.0
return finalize_result(
return finish(
status=status,
message=message,
details={
@@ -475,12 +908,26 @@ def run_calibration(request: CalibrationRequest) -> CalibrationResult:
"pair_counts_per_session": {p["session_id"]: p["pair_count"] for p in prepared},
"time_offset_s_per_session": {p["session_id"]: p["time_offset_s"] for p in prepared},
"handeye_rms_deg": handeye.residual_rms_deg,
"handeye_p95_deg": handeye.residual_p95_deg,
"handeye_outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
"phase_a_accepted": joint.phase_a_accepted,
"phase_a_comparison": joint.phase_a_comparison,
"phase_a_sessions": [asdict(item) for item in joint.phase_a_sessions],
"gyro_bias_rad_s_per_session": {
sid: np.asarray(value, dtype=float).tolist()
for sid, value in joint.gyro_bias_rad_s_per_session.items()
},
"excluded_sessions": [
item.session_id for item in joint.phase_a_sessions if not item.included_in_final
],
"joint_rotation_rms_deg": joint.residual_rms_rot_deg,
"rotation_observable": joint.observability.rotation_observable,
"translation_accepted": joint.translation_accepted,
},
"joint_handeye": asdict(handeye),
},
T_IMU_lidar=T,
T_IMU_lidar=None if status == CalibrationStatus.BLOCKED else T,
time_offset_s=delta_t,
output_directory=request.output_directory,
motion_pairs_payload=build_motion_pairs_payload(prepared_sessions=prepared),
)
+74 -10
View File
@@ -16,6 +16,8 @@ class RotationHandeyeResult:
R_IMU_lidar: np.ndarray
residual_rms_deg: float
residual_median_deg: float
residual_p95_deg: float
outlier_fraction_gt_5deg: float
pair_count: int
ok: bool
notes: tuple[str, ...] = ()
@@ -28,17 +30,21 @@ def _pair_weight(pair: MotionPair) -> float:
return weight
def _tsai_rotation_initial(pairs: list[MotionPair]) -> np.ndarray:
def _tsai_rotation_initial(
pairs: list[MotionPair],
pair_weights: np.ndarray | None = None,
) -> np.ndarray:
"""Closed-form rotation hand-eye initial guess (Tsai-style linear solve)."""
rows: list[np.ndarray] = []
rhs: list[np.ndarray] = []
for pair in pairs:
weights = np.ones(len(pairs)) if pair_weights is None else np.asarray(pair_weights, dtype=float)
for pair, pair_weight in zip(pairs, weights):
alpha = so3_log(pair.R_A)
beta = so3_log(pair.R_B)
if np.linalg.norm(alpha) < 1e-6 or np.linalg.norm(beta) < 1e-6:
continue
w = np.sqrt(_pair_weight(pair))
w = np.sqrt(float(pair_weight))
rows.append(w * skew(alpha + beta))
rhs.append(w * (beta - alpha))
if len(rows) < 2:
@@ -64,6 +70,44 @@ def _rms_deg(r_x: np.ndarray, pairs: list[MotionPair]) -> float:
return float(np.sqrt(np.mean(errs**2)))
def select_strong_rotation_pairs(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
min_rotation_deg: float = 1.0,
) -> list[MotionPair]:
"""Return pairs that independently excite rotation on both sensor sides."""
threshold = float(min_rotation_deg)
return [
pair
for pair in pairs
if rotation_angle_deg(pair.R_A) > threshold
and rotation_angle_deg(pair.R_B) > threshold
]
def estimate_rotation_handeye_initial(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
min_rotation_deg: float = 1.0,
) -> np.ndarray:
"""Return the fast data-only Tsai initialization without nonlinear refine."""
usable = select_strong_rotation_pairs(
pairs,
min_rotation_deg=min_rotation_deg,
)
if not usable:
return np.eye(3)
raw_weights = np.asarray(
[_pair_weight(pair) for pair in usable],
dtype=float,
)
median = max(float(np.median(raw_weights)), 1e-12)
weights = np.clip(raw_weights / median, 0.1, 10.0)
return _tsai_rotation_initial(usable, weights)
def solve_rotation_handeye(
pairs: list[MotionPair] | tuple[MotionPair, ...],
*,
@@ -76,19 +120,24 @@ def solve_rotation_handeye(
is weakly observable under near-planar motion.
"""
usable = [pair for pair in pairs if rotation_angle_deg(pair.R_A) > 1.0 and rotation_angle_deg(pair.R_B) > 1.0]
usable = select_strong_rotation_pairs(pairs)
notes: list[str] = []
if len(usable) < 3:
return RotationHandeyeResult(
R_IMU_lidar=np.eye(3),
residual_rms_deg=1e9,
residual_median_deg=1e9,
residual_p95_deg=1e9,
outlier_fraction_gt_5deg=1.0,
pair_count=len(usable),
ok=False,
notes=("need at least 3 motion pairs with meaningful rotation",),
)
r0 = _tsai_rotation_initial(usable)
raw_weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
median_raw_weight = max(float(np.median(raw_weights)), 1e-12)
weights = np.clip(raw_weights / median_raw_weight, 0.1, 10.0)
r0 = _tsai_rotation_initial(usable, weights)
r_prior = None
if R_prior is not None:
r_prior = orthonormalize_rotation(np.asarray(R_prior, dtype=float).reshape(3, 3))
@@ -104,10 +153,11 @@ def solve_rotation_handeye(
f"init from Tsai (rms={rms_tsai:.3f} deg; prior {rms_prior:.3f} deg kept as soft constraint)"
)
weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
notes.append(
f"weighted hand-eye: weight median={float(np.median(weights)):.3g}, "
f"min={float(np.min(weights)):.3g}, max={float(np.max(weights)):.3g}"
"weighted hand-eye: normalized/clipped IMU confidence "
f"raw_median={median_raw_weight:.3g}, "
f"normalized_min={float(np.min(weights)):.3g}, "
f"normalized_max={float(np.max(weights)):.3g}"
)
def pack(r: np.ndarray) -> np.ndarray:
@@ -139,14 +189,28 @@ def solve_rotation_handeye(
# Report unweighted RMS/median for interpretability.
rms = float(np.sqrt(np.mean(errs**2)))
med = float(np.median(errs))
p95 = float(np.percentile(errs, 95.0))
outlier_fraction = float(np.mean(errs > 5.0))
notes.append(f"optimized over {len(usable)} pairs")
ok = rms < 5.0 and len(usable) >= 3
notes.append(
f"rotation residual quality: rms={rms:.3f} deg, median={med:.3f} deg, "
f"p95={p95:.3f} deg, >5deg={100.0 * outlier_fraction:.2f}%"
)
ok = (
len(usable) >= 3
and rms < 1.5
and med < 0.5
and p95 < 1.5
and outlier_fraction <= 0.005
)
if not ok:
notes.append("rotation residual RMS too high or too few pairs")
notes.append("rotation residual distribution failed acceptance gates")
return RotationHandeyeResult(
R_IMU_lidar=r_x,
residual_rms_deg=rms,
residual_median_deg=med,
residual_p95_deg=p95,
outlier_fraction_gt_5deg=outlier_fraction,
pair_count=len(usable),
ok=ok,
notes=tuple(notes),
+1 -1
View File
@@ -57,7 +57,7 @@ python -m imu_lidar.cli run --vehicle-config ... --imu ... --lidar ... --output
- **运动对**始终计算完整预积分量(旋转、速度增量、位移增量及不确定度)。
- `--mode rotation_only`:只精修旋转与常值陀螺零偏,交付旋转与时间偏置。
- `--mode full_se3`在可观时再估计重力、关键帧速度、时变零偏与平移;结果写入 `summary.json` 的 joint 字段
- `--mode full_se3`当前完成 Phase-A 后明确拒绝平移;待 Phase-B/C 会话状态重构完成后再恢复完整 SE(3) 交付
---
+8 -2
View File
@@ -28,8 +28,14 @@ python -m pytest -q
| `test_preintegration_bias_jacobian_matches_finite_difference` | 随机陀螺序列 | 旋转预积分 `J_bg` | 与有限差分一致(松阈值) |
| `test_imu_preintegration_recovers_constant_accel_translation` | 常值加速度 | 完整预积分 Δv/Δp | 接近解析值 |
| `test_imu_preintegration_bias_jacobian_finite_difference` | 随机 IMU | `J_bg`/`J_ba` 一阶修正 | 与重积分接近 |
| `test_synthetic_pipeline_rotation_and_time_offset` | 端到端合成会话 | `rotation_only` 全流程 | `rotation_only_accepted`;δt 准;手眼 RMS < 5° |
| `test_synthetic_pipeline_full_se3_smoke` | 同上 | `full_se3` 不崩溃 | 状态为 accepted / rejected / rotation_only 之一 |
| `test_synthetic_pipeline_rejects_noisy_icp_but_keeps_time_audit` | synthetic end-to-end | strict rotation quality gate + time audit | noisy ICP is blocked; delta-t remains accurate |
| `test_synthetic_pipeline_full_se3_smoke` | synthetic end-to-end | full-SE(3) smoke test | returns an explicit accepted/rejected/blocked status |
| `test_planar_yaw_is_not_full_rotation_or_translation_observable` | pure-yaw motion pairs | degeneracy detection | full rotation/translation observability is rejected |
| `test_multi_axis_motion_is_rotation_and_translation_observable` | multi-axis motion pairs | positive observability case | rotation and translation pass |
| `test_translation_prior_is_reported_but_not_accepted_when_unobservable` | planar motion + CAD prior | prior semantics | prior is reported but not accepted as calibration |
| `test_handeye_rejects_a_small_fraction_of_gross_rotation_outliers` | motion pairs with a gross outlier | residual-distribution gate | solve is rejected |
| `test_motion_pairs_reject_low_fitness` | low-fitness registration | fitness gate | no motion pair is emitted |
| `test_motion_pairs_reject_imu_and_lidar_discontinuities` | timestamp gaps | continuity gates | cross-gap pairs are rejected |
合成数据由 `tools/generate_synthetic_session.py` 生成(墙面点云 + 已知外参 yaw 与 δt)。
+24 -1
View File
@@ -29,14 +29,29 @@ def test_pair_roundtrip(tmp_path: Path) -> None:
t_A_m=np.array([0.1, 0.0, 0.0]),
t_B_m=np.array([0.1, 0.0, 0.0]),
fitness=0.8,
metadata={"weight": 12.0, "cov9": [[0.0] * 9] * 9, "backend": "test"},
metadata={
"weight": 12.0,
"cov": (np.eye(3) * 1e-4).tolist(),
"J_bg": (-np.eye(3)).tolist(),
"cov9": [[0.0] * 9] * 9,
"backend": "test",
"gyro_bias0_rad_s": [0.01, -0.02, 0.03],
"accel_bias0_m_s2": [0.1, 0.2, -0.1],
"time_offset_s": 0.004,
"keyframe_span": 3,
"is_consecutive": False,
},
)
encoded = pair_to_dict(pair)
assert "cov9" not in encoded["metadata"]
assert "cov" in encoded["metadata"]
assert "J_bg" in encoded["metadata"]
assert encoded["metadata"]["weight"] == 12.0
restored = pair_from_dict(encoded)
assert restored.i == 1 and restored.j == 4
np.testing.assert_allclose(restored.t_A_m, [0.1, 0.0, 0.0])
np.testing.assert_allclose(restored.metadata["gyro_bias0_rad_s"], [0.01, -0.02, 0.03])
assert restored.metadata["keyframe_span"] == 3
payload = build_motion_pairs_payload(
prepared_sessions=[
@@ -50,6 +65,14 @@ def test_pair_roundtrip(tmp_path: Path) -> None:
)
path = save_motion_pairs(tmp_path / "motion_pairs.json", payload)
loaded = load_motion_pairs(path)
assert loaded["schema_version"] == 2
pairs = pairs_for_session(loaded, "s0")
assert len(pairs) == 1
assert pairs[0].session_id == "s0"
payload["schema_version"] = 1
legacy_path = save_motion_pairs(
tmp_path / "motion_pairs_v1.json", payload
)
legacy = load_motion_pairs(legacy_path)
assert legacy["schema_version"] == 1
+194
View File
@@ -0,0 +1,194 @@
"""Tests for cached, session-balanced Phase-A comparison."""
from __future__ import annotations
import numpy as np
from imu_lidar.contracts import ImuSeries, MotionPair
from imu_lidar.geometry import so3_exp, so3_log
from imu_lidar.imu_preintegration import preintegrate_gyro
from imu_lidar.phase_a import (
rehydrate_phase_a_pairs,
select_decorrelated_phase_a_pairs,
solve_phase_a_comparison,
)
def _phase_a_pair(
session_id: str,
index: int,
r_true: np.ndarray,
vector_deg: tuple[float, float, float],
bias0: np.ndarray,
) -> MotionPair:
r_b = so3_exp(np.deg2rad(np.asarray(vector_deg, dtype=float)))
return MotionPair(
session_id=session_id,
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=r_true @ r_b @ r_true.T,
R_B=r_b,
t_A_m=np.zeros(3),
t_B_m=np.zeros(3),
metadata={
"J_bg": (-np.eye(3)).tolist(),
"cov": (np.eye(3) * 1e-5).tolist(),
"gyro_bias0_rad_s": bias0.tolist(),
},
)
def test_phase_a_reports_three_variants_and_leave_one_session_out() -> None:
r_true = so3_exp(np.deg2rad(np.array([3.0, -2.0, 25.0])))
prior = so3_exp(np.deg2rad(np.array([0.0, 0.0, 0.2]))) @ r_true
vectors = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
(9.0, -5.0, 6.0),
(-6.0, 8.0, 11.0),
(5.0, 7.0, -9.0),
)
biases = {
"s0": np.array([0.001, -0.0005, 0.0002]),
"s1": np.array([-0.0004, 0.0008, -0.0001]),
"s2": np.array([0.0002, 0.0001, -0.0006]),
}
pairs: list[MotionPair] = []
index = 0
for sid, count in (("s0", 18), ("s1", 9), ("s2", 6)):
for local_index in range(count):
pairs.append(
_phase_a_pair(
sid,
index,
r_true,
vectors[local_index % len(vectors)],
biases[sid],
)
)
index += 1
result = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=biases,
rotation_prior=prior,
rotation_prior_sigma_deg=15.0,
yaw_std_max_deg=1.0,
leave_one_out_yaw_range_max_deg=1.0,
data_prior_difference_max_deg=1.0,
decorrelation_block_s=0.0,
max_nfev=80,
)
assert result.accepted
assert result.strong_pair_counts_per_session == {
"s0": 18,
"s1": 9,
"s2": 6,
}
assert len(result.leave_one_out) == 3
assert result.marginal_observability.rank == 3
assert result.leave_one_out_yaw_range_deg < 0.1
for variant in (
result.fixed_bg_data_only,
result.session_bg_data_only,
result.session_bg_with_rotation_prior,
):
error_deg = np.degrees(
np.linalg.norm(
so3_log(r_true.T @ variant.R_IMU_lidar)
)
)
assert error_deg < 0.1
def test_rehydrate_phase_a_pairs_recovers_jacobian_without_lidar() -> None:
t_s = np.linspace(0.0, 1.0, 201)
gyro = np.tile(np.array([0.12, -0.04, 0.2]), (t_s.size, 1))
bias0 = np.array([0.01, -0.005, 0.002])
imu = ImuSeries(
t_s=t_s,
gyro_rad_s=gyro,
acc_m_s2=np.zeros((t_s.size, 3)),
)
preint = preintegrate_gyro(t_s, gyro, 0.1, 0.8, bias0)
pair = MotionPair(
session_id="s0",
i=0,
j=1,
t_i_s=0.1,
t_j_s=0.8,
R_A=preint.delta_R,
R_B=preint.delta_R,
metadata={
"t_i_imu_s": 0.1,
"t_j_imu_s": 0.8,
"gyro_bias0_rad_s": bias0.tolist(),
"preint_sigma_rad": preint.sigma_rad,
},
)
enriched, report = rehydrate_phase_a_pairs(
[pair],
imu_by_session={"s0": imu},
bias0_by_session={"s0": bias0},
)
assert "J_bg" in enriched[0].metadata
assert "cov" in enriched[0].metadata
assert report["max_R_A_error_deg"] < 1e-8
def test_phase_a_time_blocks_do_not_count_overlapping_pairs_as_independent() -> None:
r_true = so3_exp(np.deg2rad(np.array([1.0, -2.0, 20.0])))
bias = np.zeros(3)
pairs = [
_phase_a_pair("s0", index, r_true, (5.0 + index, 2.0, 1.0), bias)
for index in range(9)
]
selected = select_decorrelated_phase_a_pairs(
pairs,
block_s=3.0,
max_pairs_per_block=1,
)
assert len(selected) == 3
assert all(pair in pairs for pair in selected)
def test_phase_a_planar_motion_is_partial_and_keeps_weak_direction_from_prior() -> None:
r_true = so3_exp(np.deg2rad(np.array([4.0, -3.0, 31.0])))
prior = so3_exp(np.deg2rad(np.array([0.2, -0.1, 0.4]))) @ r_true
biases = {"s0": np.zeros(3), "s1": np.zeros(3)}
pairs: list[MotionPair] = []
for session_index, sid in enumerate(biases):
for index in range(12):
pairs.append(
_phase_a_pair(
sid,
session_index * 100 + index,
r_true,
(0.0, 0.0, 8.0 + index),
biases[sid],
)
)
result = solve_phase_a_comparison(
pairs,
gyro_bias_rad_s_by_session=biases,
rotation_prior=prior,
decorrelation_block_s=0.0,
yaw_std_max_deg=0.5,
run_leave_one_out=False,
max_nfev=80,
)
assert not result.accepted
assert result.partial_accepted
assert result.solution_status == "phase_a_partial_accepted"
assert result.marginal_observability.precision_rank == 2
assert result.observable_subspace_with_prior is not None
assert np.isinf(result.marginal_observability.direction_std_deg[0])
+260
View File
@@ -0,0 +1,260 @@
"""Regression tests for calibration quality, continuity, and observability gates."""
from __future__ import annotations
import numpy as np
from imu_lidar.contracts import ImuSeries, LidarFrame, MotionPair
from imu_lidar.geometry import make_transform, so3_exp, so3_log
from imu_lidar.joint_optimizer import solve_joint_extrinsic
from imu_lidar.motion_pairs import build_motion_pairs
from imu_lidar.observability import analyze_observability
from imu_lidar.registration import RegistrationResult
from imu_lidar.rotation_handeye import solve_rotation_handeye
def _motion_pair(index: int, rotation_vector: np.ndarray) -> MotionPair:
rotation = so3_exp(np.asarray(rotation_vector, dtype=float))
return MotionPair(
session_id="synthetic",
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=rotation,
R_B=rotation,
t_A_m=np.zeros(3),
t_B_m=np.array([0.1, -0.03, 0.0]),
fitness=0.9,
metadata={
"J_bg": (-np.eye(3)).tolist(),
"cov": (np.eye(3) * 1e-4).tolist(),
"gyro_bias0_rad_s": [0.0, 0.0, 0.0],
},
)
def _frame(frame_id: str, mid_s: float) -> LidarFrame:
return LidarFrame(
frame_id=frame_id,
t_start_s=mid_s - 0.01,
t_end_s=mid_s + 0.01,
points_xyz=np.zeros((64, 3)),
)
def _registration(*, fitness: float = 0.9) -> RegistrationResult:
rotation = so3_exp(np.deg2rad(np.array([0.0, 0.0, 10.0])))
return RegistrationResult(
transform=make_transform(np.array([0.4, 0.0, 0.0]), rotation),
fitness=fitness,
rotation_deg=10.0,
translation_m=0.4,
backend="test",
ok=True,
)
def test_planar_yaw_is_not_full_rotation_or_translation_observable():
pairs = [
_motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
]
report = analyze_observability(pairs, np.eye(3))
assert not report.rotation_observable
assert not report.translation_observable
def test_multi_axis_motion_is_rotation_and_translation_observable():
vectors_deg = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
)
pairs = [
_motion_pair(i, np.deg2rad(np.asarray(vector_deg)))
for i, vector_deg in enumerate(vectors_deg)
]
report = analyze_observability(pairs, np.eye(3))
assert report.rotation_observable
assert report.translation_observable
def test_translation_prior_is_reported_but_not_accepted_when_unobservable():
pairs = [
_motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
]
prior = np.array([0.3, -0.2, 0.5])
result = solve_joint_extrinsic(
pairs,
np.eye(3),
force_rotation_only=False,
enable_phase_c=False,
t_prior_m=prior,
)
assert not result.translation_accepted
np.testing.assert_allclose(result.T_IMU_lidar[:3, 3], prior)
assert any("prior only" in note for note in result.notes)
def test_handeye_rejects_a_small_fraction_of_gross_rotation_outliers():
rng = np.random.default_rng(7)
r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
pairs: list[MotionPair] = []
for index in range(100):
axis = rng.normal(size=3)
axis /= np.linalg.norm(axis)
r_b = so3_exp(axis * np.deg2rad(rng.uniform(8.0, 30.0)))
r_a = r_true @ r_b @ r_true.T
if index == 0:
r_a = so3_exp(np.deg2rad(np.array([18.0, 0.0, 0.0]))) @ r_a
pairs.append(
MotionPair(
session_id="outlier",
i=index,
j=index + 1,
t_i_s=float(index),
t_j_s=float(index + 1),
R_A=r_a,
R_B=r_b,
)
)
result = solve_rotation_handeye(pairs)
assert not result.ok
assert result.outlier_fraction_gt_5deg > 0.005
def test_motion_pairs_reject_low_fitness(monkeypatch):
monkeypatch.setattr(
"imu_lidar.motion_pairs.register_lidar_pair",
lambda *_args, **_kwargs: _registration(fitness=0.3),
)
imu = ImuSeries(
t_s=np.linspace(0.0, 1.2, 121),
gyro_rad_s=np.zeros((121, 3)),
acc_m_s2=np.zeros((121, 3)),
)
result = build_motion_pairs(
session_id="fitness",
keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
keyframe_indices=[0, 1],
imu=imu,
delta_t_s=0.0,
min_registration_fitness=0.5,
)
assert not result.pairs
assert any("fitness<0.50: 1" in note for note in result.notes)
def test_motion_pairs_reject_imu_and_lidar_discontinuities(monkeypatch):
monkeypatch.setattr(
"imu_lidar.motion_pairs.register_lidar_pair",
lambda *_args, **_kwargs: _registration(),
)
imu_with_gap = ImuSeries(
t_s=np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.8, 0.9, 1.0, 1.1, 1.2]),
gyro_rad_s=np.zeros((10, 3)),
acc_m_s2=np.zeros((10, 3)),
)
imu_result = build_motion_pairs(
session_id="imu-gap",
keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
keyframe_indices=[0, 1],
imu=imu_with_gap,
delta_t_s=0.0,
max_imu_gap_s=0.2,
)
assert not imu_result.pairs
assert any("IMU gap>0.200s: 1" in note for note in imu_result.notes)
continuous_imu = ImuSeries(
t_s=np.linspace(0.0, 2.2, 221),
gyro_rad_s=np.zeros((221, 3)),
acc_m_s2=np.zeros((221, 3)),
)
lidar_result = build_motion_pairs(
session_id="lidar-gap",
keyframes=[_frame("0", 0.1), _frame("2", 2.1)],
keyframe_indices=[0, 2],
imu=continuous_imu,
delta_t_s=0.0,
all_frame_times_s=np.array([0.1, 0.2, 2.1]),
max_lidar_gap_s=0.5,
)
assert not lidar_result.pairs
assert any("LiDAR gap>0.500s: 1" in note for note in lidar_result.notes)
def test_phase_a_keeps_session_bias_linearization_points_independent():
r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
bias0_by_session = {
"s0": np.array([0.010, -0.004, 0.002]),
"s1": np.array([-0.006, 0.008, -0.003]),
}
vectors_deg = (
(12.0, 0.0, 0.0),
(0.0, 15.0, 0.0),
(0.0, 0.0, 18.0),
(10.0, 8.0, 0.0),
(0.0, 11.0, 9.0),
(7.0, 0.0, 13.0),
)
pairs: list[MotionPair] = []
for session_index, (session_id, bias0) in enumerate(bias0_by_session.items()):
for pair_index, vector_deg in enumerate(vectors_deg):
r_b = so3_exp(np.deg2rad(np.asarray(vector_deg)))
r_a = r_true @ r_b @ r_true.T
index = session_index * 100 + pair_index
pairs.append(
MotionPair(
session_id=session_id,
i=index,
j=index + 1,
t_i_s=float(pair_index),
t_j_s=float(pair_index + 1),
R_A=r_a,
R_B=r_b,
t_A_m=np.zeros(3),
t_B_m=np.zeros(3),
metadata={
"J_bg": np.eye(3).tolist(),
"cov": (np.eye(3) * 1e-4).tolist(),
"gyro_bias0_rad_s": bias0.tolist(),
},
)
)
result = solve_joint_extrinsic(
pairs,
r_true,
force_rotation_only=True,
gyro_bias_rad_s_by_session=bias0_by_session,
)
assert result.phase_a_accepted
assert set(result.phase_a_comparison["variants"]) == {
"A0_fixed_bg_data_only",
"A1_session_bg_data_only",
"A2_session_bg_with_rotation_prior",
}
assert set(result.gyro_bias_rad_s_per_session) == {"s0", "s1"}
for session_id, bias0 in bias0_by_session.items():
np.testing.assert_allclose(
result.gyro_bias_rad_s_per_session[session_id], bias0, atol=1e-8
)
assert np.linalg.norm(so3_log(r_true.T @ result.T_IMU_lidar[:3, :3])) < 1e-8
+29 -14
View File
@@ -54,7 +54,7 @@ def _log(rotation: np.ndarray) -> np.ndarray:
return so3_log(rotation)
def test_synthetic_pipeline_rotation_and_time_offset(tmp_path: Path):
def test_synthetic_pipeline_rejects_noisy_icp_but_keeps_time_audit(tmp_path: Path):
meta = generate_synthetic_session(tmp_path, delta_t_s=0.17, yaw_extrinsic_deg=25.0)
config = Path(__file__).resolve().parents[1] / "config" / "vehicle_installation.template.yaml"
out = tmp_path / "out"
@@ -74,17 +74,28 @@ def test_synthetic_pipeline_rotation_and_time_offset(tmp_path: Path):
min_pair_rotation_deg=2.0,
min_pair_translation_m=0.05,
)
result = run_calibration(request)
assert result.status.value == "rotation_only_accepted"
assert result.time_offset_s is not None
assert abs(result.time_offset_s - meta["delta_t_s"]) < 0.05
assert result.T_IMU_lidar is not None
# End-to-end uses approximate ICP; allow moderate absolute error but require consistency.
r_true = so3_exp(np.deg2rad(np.array([2.0, -1.5, meta["yaw_extrinsic_deg"]])))
err_deg = np.degrees(np.linalg.norm(_log(r_true.T @ result.T_IMU_lidar[:3, :3])))
assert err_deg < 15.0
progress_events: list[dict] = []
result = run_calibration(request, progress_callback=progress_events.append)
# The lightweight synthetic point cloud uses approximate ICP and has a
# roughly 3-degree P95 residual. The production gate must reject it rather
# than expose a plausible-looking extrinsic.
assert result.status.value == "blocked"
assert result.T_IMU_lidar is None
session0 = result.details["sessions"][0]
assert session0["handeye"]["residual_rms_deg"] < 5.0
assert abs(session0["time_offset_s"] - meta["delta_t_s"]) < 0.05
assert result.details["joint_handeye"]["residual_p95_deg"] > 1.5
assert not result.details["joint_handeye"]["ok"]
assert progress_events[0]["event"] == "pipeline_start"
assert any(
event["stage"] == "motion_pairs" and event["event"] == "complete"
for event in progress_events
)
assert any(
event["stage"] == "joint_optimizer" and event["event"] == "phase_a_complete"
for event in progress_events
)
assert progress_events[-1]["stage"] == "finalize"
assert progress_events[-1]["event"] == "complete"
def test_time_offset_on_synthetic(tmp_path: Path):
@@ -176,13 +187,17 @@ def test_synthetic_pipeline_full_se3_smoke(tmp_path: Path):
"full_se3_accepted",
"full_se3_rejected_due_to_observability",
"rotation_only_accepted",
"blocked",
}
assert result.T_IMU_lidar is not None
session0 = result.details["sessions"][0]
assert "delta_v" in session0.get("pair_notes", []) or session0.get("pair_count", 0) >= 0
# Phase-C fields appear when joint ran successfully on pairs.
if session0.get("ok"):
# Phase-C fields appear only when the strict rotation gate passed.
if result.status.value != "blocked":
assert result.T_IMU_lidar is not None
assert "gyro_bias_rad_s" in session0["joint"]
else:
assert result.T_IMU_lidar is None
assert not result.details["joint_handeye"]["ok"]
def test_signed_time_offset_refine_improves_or_keeps(tmp_path: Path):