Files

942 lines
34 KiB
Python
Raw Permalink Normal View History

"""Executable LiDARIMU calibration pipeline (V1)."""
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
from .contracts import (
CalibrationMode,
CalibrationRequest,
CalibrationResult,
CalibrationStatus,
MotionPair,
SessionInput,
)
from .finalize import finalize_result
from .imu_audit import audit_imu
from .imu_io import load_imu_samples
from .joint_optimizer import solve_joint_extrinsic
from .keyframes import build_keyframes
from .lidar_deskew import deskew_lidar_frames
from .lidar_io import load_lidar_frames
from .motion_pairs import build_motion_pairs
from .motion_pairs_io import build_motion_pairs_payload
from .rotation_handeye import solve_rotation_handeye
from .time_offset import TimeOffsetResult, estimate_time_offset, refine_time_offset_signed
from .timestamp_audit import audit_timestamps
from .vehicle_config import load_vehicle_config, prior_enabled
# Remap keyframe indices so multi-session Phase-C graphs do not collide.
_SESSION_INDEX_OFFSET = 1_000_000
def _merge_time_offset(previous: TimeOffsetResult, refined: TimeOffsetResult) -> TimeOffsetResult:
return TimeOffsetResult(
delta_t_s=refined.delta_t_s,
correlation_peak=refined.correlation_peak,
search_s=previous.search_s,
notes=tuple(list(previous.notes) + list(refined.notes)),
ok=True,
)
@dataclass(frozen=True)
class PipelineStage:
name: str
responsibility: str
STAGES = (
PipelineStage("vehicle_config", "加载并校验当前车辆安装配置"),
PipelineStage("timestamp_audit", "审查 IMU 与 LiDAR 时间域"),
PipelineStage("imu_audit", "审查单位、轴向启发与静止零偏"),
PipelineStage("time_offset", "各会话独立粗估/精修 δt"),
PipelineStage("lidar_motion", "各会话关键帧、可选去畸变与 LiDAR 相对运动"),
PipelineStage("motion_pairs", "各会话构造运动对,再合并"),
PipelineStage("rotation_handeye", "用全部会话运动对联合求解旋转外参"),
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."""
return STAGES
def _build_pairs_and_handeye(
*,
session_id: str,
working_frames,
imu,
delta_t_s: float,
gyro_bias_rad_s: np.ndarray,
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),
keyframe_indices=keyframes.indices,
imu=imu,
delta_t_s=delta_t_s,
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,
R_prior=R_prior,
prior_sigma_deg=prior_sigma_deg,
)
return keyframes, pair_set, handeye
def _translation_prior_from_config(
vehicle_config: dict[str, Any] | None,
) -> tuple[np.ndarray | None, np.ndarray | float | None]:
if vehicle_config is None or not prior_enabled(vehicle_config, "translation_prior"):
return None, None
init_cfg = vehicle_config.get("initialization") or {}
tp = init_cfg.get("translation_prior") or {}
if tp.get("t_IMU_lidar_m") is None:
return None, None
return np.asarray(tp["t_IMU_lidar_m"], dtype=float).reshape(3), tp.get("sigma_m", [0.05, 0.05, 0.05])
def _rotation_prior_from_config(
vehicle_config: dict[str, Any] | None,
) -> tuple[np.ndarray | None, float | None]:
if vehicle_config is None or not prior_enabled(vehicle_config, "rotation_prior"):
return None, None
init_cfg = vehicle_config.get("initialization") or {}
rp = init_cfg.get("rotation_prior") or {}
if rp.get("R_IMU_lidar") is None:
return None, None
return np.asarray(rp["R_IMU_lidar"], dtype=float).reshape(3, 3), float(rp.get("sigma_deg", 15.0))
def _prepare_session_pairs(
session: SessionInput,
request: CalibrationRequest,
*,
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)}
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(fixed_time_offset_s),
correlation_peak=1.0,
search_s=0.0,
notes=(
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,
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
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)
handeye = None
pair_set = None
keyframes = None
pairs_notes: list[str] = []
pair_count = 0
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,
delta_t_s=offset.delta_t_s,
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,
imu=imu,
delta_t_s=offset.delta_t_s,
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
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",
"session_id": session.session_id,
"iteration": iteration,
"time_offset": asdict(offset),
"imu_audit": asdict(imu_report),
"timestamp_audit": asdict(ts),
"keyframes": 0 if keyframes is None else len(keyframes.indices),
"pair_notes": pairs_notes,
"handeye": asdict(handeye),
}
r_x = handeye.R_IMU_lidar
if not request.enable_signed_time_refine:
continue
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,
delta_t_s=offset.delta_t_s,
R_IMU_lidar=r_x,
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
search_s=min(0.12, max(0.04, 0.25 * request.time_offset_search_s)),
max_shift_s=request.max_signed_refine_shift_s,
)
# Also bound total walk away from the original coarse estimate.
if abs(refined.delta_t_s - coarse_delta_t) > request.max_signed_refine_shift_s:
refined = TimeOffsetResult(
delta_t_s=float(offset.delta_t_s),
correlation_peak=refined.correlation_peak,
search_s=refined.search_s,
notes=tuple(
list(refined.notes)
+ [
f"signed refine clamped: |δt-coarse| would exceed "
f"{request.max_signed_refine_shift_s:.3f}s"
]
),
ok=True,
)
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,
imu=imu,
delta_t_s=offset.delta_t_s,
gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
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",
"session_id": session.session_id,
"iteration": iteration,
"time_offset": asdict(offset),
"imu_audit": asdict(imu_report),
"timestamp_audit": asdict(ts),
"keyframes": 0 if keyframes is None else len(keyframes.indices),
"pair_notes": pairs_notes,
"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))
if acc_n > 1e-6:
gravity_init = -acc_mean * (9.80665 / acc_n)
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,
"pairs": tuple(pair_set.pairs),
"gyro_bias_rad_s": np.asarray(imu_report.gyro_bias_rad_s, dtype=float).reshape(3),
"gravity_init_m_s2": gravity_init,
"timestamp_audit": asdict(ts),
"imu_audit": {
**asdict(imu_report),
"gyro_bias_rad_s": imu_report.gyro_bias_rad_s.tolist(),
"static_acc_mean_m_s2": imu_report.static_acc_mean_m_s2.tolist(),
},
"time_offset": asdict(offset),
"time_offset_s": float(offset.delta_t_s),
"keyframes": len(keyframes.indices),
"pair_count": pair_count,
"pair_notes": pairs_notes,
"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,
"R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
},
}
def _remap_pairs_for_joint(prepared: list[dict[str, Any]]) -> list[MotionPair]:
merged: list[MotionPair] = []
for index, prep in enumerate(prepared):
id_offset = (index + 1) * _SESSION_INDEX_OFFSET
for pair in prep["pairs"]:
merged.append(
replace(
pair,
i=int(pair.i) + id_offset,
j=int(pair.j) + id_offset,
)
)
return merged
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 finish(
status=CalibrationStatus.BLOCKED,
message="no sessions provided",
details={},
)
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
_emit_progress(
progress_callback,
1,
"blocked",
reason="vehicle_config",
error=str(exc),
)
return finish(
status=CalibrationStatus.BLOCKED,
message=f"vehicle config failed: {exc}",
details={},
)
_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]] = []
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 finish(
status=CalibrationStatus.BLOCKED,
message=f"blocked at stage {prep.get('stage')} ({prep.get('session_id')})",
details={"sessions": [prep]},
)
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,
)
_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={
"sessions": [_public_session(p) for p in prepared],
"joint_handeye": asdict(handeye),
"merged_pair_count": len(all_pairs),
},
)
force_rotation_only = request.requested_mode == CalibrationMode.ROTATION_ONLY
t_prior, t_prior_sigma = _translation_prior_from_config(vehicle_config)
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,
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),
"vehicle_config_loaded": vehicle_config is not None,
"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"]),
"R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
},
"joint": {
"translation_accepted": joint.translation_accepted,
"residual_rms_rot_deg": joint.residual_rms_rot_deg,
"residual_rms_trans_m": joint.residual_rms_trans_m,
"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 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(),
"gravity_m_s2": None
if joint.gravity_m_s2 is None
else np.asarray(joint.gravity_m_s2, dtype=float).tolist(),
},
"translation_accepted": joint.translation_accepted,
"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)
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)"
else:
status = CalibrationStatus.FULL_SE3_REJECTED
message = (
f"rotation accepted jointly ({len(prepared)} sessions); "
"translation deferred until Phase-B/C session-state redesign"
)
elif joint.observability.rotation_observable:
status = CalibrationStatus.ROTATION_ONLY_ACCEPTED
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 finish(
status=status,
message=message,
details={
"sessions": session_results,
"joint": {
"session_count": len(prepared),
"merged_pair_count": len(all_pairs),
"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=None if status == CalibrationStatus.BLOCKED else T,
time_offset_s=delta_t,
motion_pairs_payload=build_motion_pairs_payload(prepared_sessions=prepared),
)
def _public_session(session_result: dict[str, Any]) -> dict[str, Any]:
payload = dict(session_result)
payload.pop("T_IMU_lidar", None)
payload.pop("pairs", None)
payload.pop("gyro_bias_rad_s", None)
payload.pop("gravity_init_m_s2", None)
return payload