"""Serialize / deserialize motion pairs for fast visualization.""" from __future__ import annotations import json from pathlib import Path from typing import Any import numpy as np from .contracts import MotionPair SCHEMA_VERSION = 2 # 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", "rotation_deg_A", "rotation_deg_B", "translation_m_B", "weight", "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", } ) def _to_list(value: Any) -> Any: if isinstance(value, np.ndarray): return value.tolist() if isinstance(value, (np.floating, np.integer, np.bool_)): return value.item() return value def pair_to_dict(pair: MotionPair) -> dict[str, Any]: meta = { str(k): _to_list(v) for k, v in (pair.metadata or {}).items() if str(k) in _METADATA_KEEP } return { "session_id": pair.session_id, "i": int(pair.i), "j": int(pair.j), "t_i_s": float(pair.t_i_s), "t_j_s": float(pair.t_j_s), "R_A": np.asarray(pair.R_A, dtype=float).reshape(3, 3).tolist(), "R_B": np.asarray(pair.R_B, dtype=float).reshape(3, 3).tolist(), "t_A_m": None if pair.t_A_m is None else np.asarray(pair.t_A_m, dtype=float).reshape(3).tolist(), "t_B_m": None if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float).reshape(3).tolist(), "fitness": float(pair.fitness), "metadata": meta, } def pair_from_dict(payload: dict[str, Any]) -> MotionPair: t_a = payload.get("t_A_m") t_b = payload.get("t_B_m") return MotionPair( session_id=str(payload.get("session_id", "")), i=int(payload["i"]), j=int(payload["j"]), t_i_s=float(payload["t_i_s"]), t_j_s=float(payload["t_j_s"]), R_A=np.asarray(payload["R_A"], dtype=float).reshape(3, 3), R_B=np.asarray(payload["R_B"], dtype=float).reshape(3, 3), t_A_m=None if t_a is None else np.asarray(t_a, dtype=float).reshape(3), t_B_m=None if t_b is None else np.asarray(t_b, dtype=float).reshape(3), fitness=float(payload.get("fitness", 0.0)), metadata=dict(payload.get("metadata") or {}), ) def build_motion_pairs_payload( *, prepared_sessions: list[dict[str, Any]], ) -> dict[str, Any]: """Build a JSON-serializable cache from pipeline ``prepared`` session dicts.""" sessions_out: list[dict[str, Any]] = [] for prep in prepared_sessions: pairs = prep.get("pairs") or () sessions_out.append( { "session_id": prep.get("session_id"), "delta_t_s": float(prep.get("time_offset_s", 0.0)), "gyro_bias_rad_s": np.asarray(prep.get("gyro_bias_rad_s", np.zeros(3)), dtype=float) .reshape(3) .tolist(), "pair_count": len(pairs), "pairs": [pair_to_dict(pair) for pair in pairs], } ) return { "schema_version": SCHEMA_VERSION, "sessions": sessions_out, "note": "Cached motion pairs for visualization; A=IMU preintegration, B=LiDAR registration", } def save_motion_pairs(path: Path | str, payload: dict[str, Any]) -> Path: destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(json.dumps(payload, indent=2), encoding="utf-8") return destination def load_motion_pairs(path: Path | str) -> dict[str, Any]: payload = json.loads(Path(path).read_text(encoding="utf-8")) version = int(payload.get("schema_version", 0)) if version not in {1, SCHEMA_VERSION}: raise ValueError( f"unsupported motion_pairs schema_version={version}; " f"expected 1 or {SCHEMA_VERSION}" ) return payload def pairs_for_session(payload: dict[str, Any], session_id: str | None = None) -> list[MotionPair]: sessions = payload.get("sessions") or [] if not sessions: return [] if session_id is None: chosen = sessions[0] else: chosen = next((s for s in sessions if s.get("session_id") == session_id), None) if chosen is None: raise KeyError(f"session_id {session_id!r} not found in motion_pairs cache") return [pair_from_dict(item) for item in chosen.get("pairs") or []] def resolve_motion_pairs_path(summary_path: Path | str) -> Path | None: """Return ``motion_pairs.json`` next to a summary if it exists.""" summary = Path(summary_path) candidate = summary.parent / "motion_pairs.json" return candidate if candidate.is_file() else None