"""Command-line entry point for LiDAR–IMU calibration.""" 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="LiDAR–IMU extrinsic calibration (V1)") subcommands = parser.add_subparsers(dest="command", required=True) plan = subcommands.add_parser("plan", help="显示标定阶段,不读取数据") plan.add_argument("--vehicle-config", help="车辆配置路径(仅展示,plan 不读取)") plan.add_argument( "--mode", choices=[mode.value for mode in CalibrationMode], default=CalibrationMode.ROTATION_ONLY.value, ) run = subcommands.add_parser( "run", help="执行 V1 标定流水线(可重复 --imu/--lidar/--session-id 做多会话联合)", ) run.add_argument( "--session-id", action="append", default=None, help="会话 ID(可重复;与 --imu/--lidar 一一对应)", ) run.add_argument( "--imu", action="append", required=True, help="IMU CSV/NPZ 路径(可重复)", ) run.add_argument( "--lidar", action="append", required=True, help="LiDAR 会话目录(可重复)", ) run.add_argument("--vehicle-config", required=True, help="车辆配置 YAML") run.add_argument("--output", required=True, help="输出目录") run.add_argument( "--mode", choices=[mode.value for mode in CalibrationMode], default=CalibrationMode.ROTATION_ONLY.value, ) run.add_argument("--max-iterations", type=int, default=2) run.add_argument("--time-offset-search-s", type=float, default=1.0) run.add_argument( "--fixed-time-offset-s", type=float, 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", help="Disable signed 3-axis δt refine after hand-eye (recommended for host-bridged data)", ) run.add_argument( "--max-signed-refine-shift-s", type=float, default=0.05, help="Max |Δδt| accepted by signed refine from the coarse estimate", ) 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 def _build_sessions(args: argparse.Namespace) -> tuple[SessionInput, ...]: imus = [Path(p) for p in args.imu] lidars = [Path(p) for p in args.lidar] if len(imus) != len(lidars): raise SystemExit(f"--imu count ({len(imus)}) must match --lidar count ({len(lidars)})") if args.session_id is None: session_ids = [f"session{i}" for i in range(len(imus))] else: session_ids = list(args.session_id) if len(session_ids) != len(imus): 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, fixed_time_offset_s=offset, ) for sid, imu, lidar, offset in zip(session_ids, imus, lidars, session_offsets) ) def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.command == "plan": request = CalibrationRequest( vehicle_config=Path(args.vehicle_config) if args.vehicle_config else None, requested_mode=CalibrationMode(args.mode), ) print("LiDAR–IMU calibration stages:") print(f"requested mode: {request.requested_mode.value}") for index, stage in enumerate(describe_pipeline(request), start=1): 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( vehicle_config=Path(args.vehicle_config), sessions=sessions, requested_mode=CalibrationMode(args.mode), output_directory=Path(args.output), 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, progress_callback=_print_progress) print(f"status: {result.status.value}") print(f"message: {result.message}") if result.time_offset_s is not None: print(f"time_offset_s (first session; t_imu = t_lidar + dt): {result.time_offset_s:.6f}") joint = (result.details or {}).get("joint") or {} if joint: print(f"merged_pair_count: {joint.get('merged_pair_count')}") print(f"pair_counts_per_session: {joint.get('pair_counts_per_session')}") if result.T_IMU_lidar is not None: print("T_IMU_lidar:") print(result.T_IMU_lidar) print(f"report directory: {args.output}") return 0 if result.status != CalibrationStatus.BLOCKED else 2 parser.error(f"unknown command {args.command}") return 2 if __name__ == "__main__": raise SystemExit(main())