#!/usr/bin/env python3 """Align HI13/H32 via host-UTC bridge, then run rotation_only. Device clocks (HI13 boot ms vs H32 absolute) must NOT be forced to share a first-sample epoch. Instead map each LiDAR frame onto the IMU device timeline by interpolating IMU device time at the frame's MSOP HostReceiveUtcTicks. Optional |ω| correlation then refines residual host/path delay. """ from __future__ import annotations import argparse import csv import json import shutil import sys from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from imu_lidar.cli import main as cli_main from imu_lidar.geometry import so3_log from imu_lidar.imu_io import load_imu_samples from imu_lidar.lidar_io import load_lidar_frames from imu_lidar.registration import estimate_frame_rotations from imu_lidar.time_offset import _correlate_offset, _magnitude_series SESSIONS = [ "priority_174005_174515", "priority_174905_175450", "priority_175910_180530", ] def _read_imu_host_table(imu_csv: Path) -> tuple[np.ndarray, np.ndarray]: rows = list(csv.DictReader(imu_csv.open(encoding="utf-8"))) if not rows: raise RuntimeError(f"empty IMU csv: {imu_csv}") if "t_host_utc_s" not in rows[0] or not rows[0].get("t_host_utc_s"): raise RuntimeError( f"{imu_csv} missing t_host_utc_s; re-export with HostReceiveUtcTicks support" ) t_dev = np.asarray([float(row["t"]) for row in rows], dtype=np.float64) t_host = np.asarray([float(row["t_host_utc_s"]) for row in rows], dtype=np.float64) order = np.argsort(t_host) return t_host[order], t_dev[order] def _imu_device_at_host(t_host_query: np.ndarray, imu_host: np.ndarray, imu_dev: np.ndarray) -> np.ndarray: """Map host UTC seconds → IMU device seconds (linear interp, edge clamp).""" return np.interp(t_host_query, imu_host, imu_dev) def rewrite_lidar_index_host_bridge( src_index: Path, dst_index: Path, *, imu_host: np.ndarray, imu_dev: np.ndarray, residual_delta_s: float = 0.0, ) -> dict: """Rewrite LiDAR times onto IMU device clock via host UTC bridge. For each frame: t_host_mid = mid of MSOP host receive window t_imu_mid = interp(IMU device @ t_host_mid) + residual_delta keep device duration: t_start/t_end centered on t_imu_mid """ rows = list(csv.DictReader(src_index.open(encoding="utf-8"))) if not rows: raise RuntimeError(f"empty frames_index: {src_index}") if "t_host_utc_s" not in rows[0]: raise RuntimeError( f"{src_index} missing t_host_utc_s; re-export DLog with MSOP HostReceiveUtcTicks" ) dst_index.parent.mkdir(parents=True, exist_ok=True) offsets: list[float] = [] with dst_index.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow(["frame_id", "filename", "t_start", "t_end"]) for row in rows: t0 = float(row["t_start"]) t1 = float(row["t_end"]) host0 = row.get("t_host_utc_s") or "" host1 = row.get("t_host_utc_end_s") or "" if not host0: raise RuntimeError(f"frame {row.get('frame_id')} missing t_host_utc_s") h0 = float(host0) h1 = float(host1) if host1 else h0 host_mid = 0.5 * (h0 + h1) imu_mid = float(_imu_device_at_host(np.asarray([host_mid]), imu_host, imu_dev)[0]) imu_mid += residual_delta_s duration = max(t1 - t0, 1e-3) new0 = imu_mid - 0.5 * duration new1 = imu_mid + 0.5 * duration offsets.append(imu_mid - 0.5 * (t0 + t1)) writer.writerow( [ row["frame_id"], row["filename"], f"{new0:.9f}", f"{new1:.9f}", ] ) arr = np.asarray(offsets, dtype=np.float64) return { "frames": len(offsets), "bridge_offset_median_s": float(np.median(arr)), "bridge_offset_mean_s": float(np.mean(arr)), "bridge_offset_std_s": float(np.std(arr)), "bridge_offset_min_s": float(np.min(arr)), "bridge_offset_max_s": float(np.max(arr)), "residual_delta_s": float(residual_delta_s), } def estimate_residual_delta(session_dir: Path, *, search_s: float = 5.0) -> tuple[float, float]: imu = load_imu_samples(session_dir / "imu.csv") frames = load_lidar_frames(session_dir / "lidar") # Short pairs only — large stride anti-correlates with IMU |gyro|. stride = 1 if len(frames) < 80 else 2 rotations, pair_times = estimate_frame_rotations(frames, stride=stride) if len(rotations) < 8: rotations, pair_times = estimate_frame_rotations(frames, stride=1) lidar_t = [] lidar_w = [] for (t_a, t_b), rotation in zip(pair_times, rotations): dt_pair = max(t_b - t_a, 1e-3) omega = so3_log(rotation) / dt_pair lidar_t.append(0.5 * (t_a + t_b)) lidar_w.append(omega) imu_t, imu_mag = _magnitude_series(imu.t_s, imu.gyro_rad_s) lidar_t_arr, lidar_mag = _magnitude_series(np.asarray(lidar_t), np.asarray(lidar_w)) delta, peak = _correlate_offset( imu_t, imu_mag, lidar_t_arr, lidar_mag, search_s=search_s, sample_hz=20.0, ) return float(delta), float(peak) def align_session(src: Path, dst: Path, *, residual_search_s: float = 5.0) -> dict: if dst.exists(): shutil.rmtree(dst) dst.mkdir(parents=True) shutil.copy2(src / "imu.csv", dst / "imu.csv") shutil.copytree(src / "lidar" / "frames", dst / "lidar" / "frames") imu_host, imu_dev = _read_imu_host_table(src / "imu.csv") bridge = rewrite_lidar_index_host_bridge( src / "lidar" / "frames_index.csv", dst / "lidar" / "frames_index.csv", imu_host=imu_host, imu_dev=imu_dev, residual_delta_s=0.0, ) residual_delta, residual_peak = estimate_residual_delta(dst, search_s=residual_search_s) # Only apply residual when correlation is clearly positive; otherwise the # host-UTC bridge alone is the trusted alignment (weak peaks are noise). apply_residual = residual_peak >= 0.5 and abs(residual_delta) <= residual_search_s applied = float(residual_delta) if apply_residual else 0.0 if apply_residual: bridge = rewrite_lidar_index_host_bridge( src / "lidar" / "frames_index.csv", dst / "lidar" / "frames_index.csv", imu_host=imu_host, imu_dev=imu_dev, residual_delta_s=applied, ) meta = { "source": str(src), "aligned": str(dst), "method": "host_utc_bridge", "imu_host_span_s": [float(imu_host[0]), float(imu_host[-1])], "imu_device_span_s": [float(imu_dev[0]), float(imu_dev[-1])], "bridge": bridge, "residual_delta_s": residual_delta, "residual_peak": residual_peak, "residual_applied_s": applied, "residual_applied": apply_residual, "note": ( "LiDAR t_* rewritten onto IMU device clock via MSOP/IMU HostReceiveUtc; " "not first-device-sample coincidence. residual |omega| shift applied only if peak>=0.5." ), } (dst / "align_meta.json").write_text( json.dumps(meta, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) return meta def run_one(session_dir: Path, vehicle: Path, search_s: float) -> dict: output = session_dir / "out" if output.exists(): shutil.rmtree(output) argv = [ "run", "--session-id", session_dir.name, "--imu", str(session_dir / "imu.csv"), "--lidar", str(session_dir / "lidar"), "--vehicle-config", str(vehicle), "--output", str(output), "--mode", "rotation_only", "--time-offset-search-s", str(search_s), "--min-pair-rotation-deg", "2.0", ] code = cli_main(argv) summary_path = output / "summary.json" summary = {} if summary_path.is_file(): summary = json.loads(summary_path.read_text(encoding="utf-8")) t_block = summary.get("T_IMU_lidar") or {} return { "session": session_dir.name, "exit_code": code, "status": summary.get("status"), "message": summary.get("message"), "time_offset_s": summary.get("time_offset_s"), "rotation_deg": t_block.get("rotation_deg") if isinstance(t_block, dict) else None, "summary": str(summary_path) if summary_path.is_file() else None, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--sessions-root", type=Path, default=Path(r"D:\data\calibration_usable_20260808\sessions_v1"), ) parser.add_argument( "--aligned-root", type=Path, default=Path(r"D:\data\calibration_usable_20260808\sessions_v1_host_aligned"), ) parser.add_argument( "--vehicle-config", type=Path, default=ROOT / "config" / "vehicle_hi13_h32_20260808.yaml", ) parser.add_argument( "--residual-search-s", type=float, default=5.0, help="|ω| residual search after host bridge (seconds)", ) parser.add_argument("--time-offset-search-s", type=float, default=1.0) args = parser.parse_args() results = [] for name in SESSIONS: src = args.sessions_root / name if not src.is_dir(): raise SystemExit(f"missing session: {src}") aligned = args.aligned_root / name print(f"=== align {name} ===", flush=True) meta = align_session(src, aligned, residual_search_s=args.residual_search_s) print(json.dumps(meta, ensure_ascii=False, indent=2), flush=True) print(f"=== calibrate {name} ===", flush=True) result = run_one(aligned, args.vehicle_config, args.time_offset_search_s) results.append({"align": meta, **result}) print(json.dumps(result, ensure_ascii=False, indent=2), flush=True) manifest = args.aligned_root / "calibration_manifest.json" args.aligned_root.mkdir(parents=True, exist_ok=True) manifest.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(f"manifest: {manifest}") return 0 if __name__ == "__main__": raise SystemExit(main())