#!/usr/bin/env python3 """Export IMU + H32 LiDAR captures to Lidar-IMU V1 intermediate format. IMU sources: - ``--imu-kind hi13`` (HI13R4 / HI91) or ``n300`` or ``auto`` - one or more ``--imu-rscap`` files (concatenated) LiDAR sources (exactly one): - ``--lidar-dlog``: Medulla dlog dir **or recovered zip** (MSOP + DIFOP) - ``--lidar-rscap``: legacy H32 MSOP V2 ``.rscap`` Optional host-time window (local wall clock, DateTime.Now.Ticks convention): - ``--host-start`` / ``--host-end`` e.g. ``2026-08-08T17:40:05`` Output under ``--out``: imu.csv lidar/ frames_index.csv frames/frame_XXXXX.npz export_summary.json Device times stay in ``t`` / ``t_start``/``t_end``. Host UTC receive times are also written so LiDAR–IMU alignment can bridge clocks without forcing first-frame device coincidence. """ from __future__ import annotations import argparse import csv import json 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 tools.h32_dlog.load_session import load_h32_dlog_lidar from tools.h32_dlog.timeutil import ( local_wall_to_dotnet_ticks, local_wall_to_utc_dotnet_ticks, utc_dotnet_ticks_to_unix_s, ) from tools.rscap_v2.capture_format_v2 import file_summary, read_capture from tools.rscap_v2.h32_msop import iter_h32_frames, iter_h32_frames_from_packets from tools.rscap_v2.hi13_imu import iter_hi13_imu_samples from tools.rscap_v2.n300_imu import ImuSample, iter_n300_imu_samples, samples_to_arrays def write_imu_csv(path: Path, samples: list[ImuSample]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow( [ "t", "gx", "gy", "gz", "ax", "ay", "az", "t_host_utc_s", "receive_utc_ticks", ] ) for sample in samples: ticks = int(sample.host_receive_utc_ticks) t_host = utc_dotnet_ticks_to_unix_s(ticks) if ticks > 0 else float("nan") writer.writerow( [ f"{sample.t_s:.9f}", f"{sample.gyro_rad_s[0]:.12g}", f"{sample.gyro_rad_s[1]:.12g}", f"{sample.gyro_rad_s[2]:.12g}", f"{sample.accel_m_s2[0]:.12g}", f"{sample.accel_m_s2[1]:.12g}", f"{sample.accel_m_s2[2]:.12g}", f"{t_host:.9f}" if ticks > 0 else "", ticks, ] ) def write_lidar_session(root: Path, frames) -> dict: frames_dir = root / "frames" frames_dir.mkdir(parents=True, exist_ok=True) index_path = root / "frames_index.csv" with index_path.open("w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerow( [ "frame_id", "filename", "t_start", "t_end", "host_receive_utc_ticks", "t_host_utc_s", "host_receive_utc_end_ticks", "t_host_utc_end_s", ] ) point_counts = [] host_ok = 0 for index, frame in enumerate(frames): rel = f"frames/frame_{index:05d}.npz" np.savez_compressed(root / rel, points=np.asarray(frame.points_xyz, dtype=np.float32)) h0 = int(getattr(frame, "host_receive_utc_ticks_start", 0) or 0) h1 = int(getattr(frame, "host_receive_utc_ticks_end", 0) or 0) t_host0 = utc_dotnet_ticks_to_unix_s(h0) if h0 > 0 else float("nan") t_host1 = utc_dotnet_ticks_to_unix_s(h1) if h1 > 0 else float("nan") if h0 > 0: host_ok += 1 writer.writerow( [ index, rel, f"{frame.t_start_s:.9f}", f"{frame.t_end_s:.9f}", h0, f"{t_host0:.9f}" if h0 > 0 else "", h1, f"{t_host1:.9f}" if h1 > 0 else "", ] ) point_counts.append(int(frame.points_xyz.shape[0])) return { "frames": len(frames), "frames_with_host_utc": host_ok, "points_min": int(min(point_counts)) if point_counts else 0, "points_max": int(max(point_counts)) if point_counts else 0, "points_mean": float(np.mean(point_counts)) if point_counts else 0.0, "t_start": float(frames[0].t_start_s) if frames else None, "t_end": float(frames[-1].t_end_s) if frames else None, } def detect_imu_kind(paths: list[Path], explicit: str) -> str: if explicit != "auto": return explicit joined = " ".join(path.name.lower() for path in paths) if "hi13" in joined or "hipnuc" in joined: return "hi13" if "n300" in joined or "wheeltec" in joined: return "n300" return "hi13" def load_imu_samples( paths: list[Path], *, kind: str, host_ticks_min: int | None, host_ticks_max: int | None, ) -> tuple[list[ImuSample], list[dict], str]: samples: list[ImuSample] = [] captures_meta: list[dict] = [] for path in paths: capture = read_capture(path) captures_meta.append(file_summary(capture)) if kind == "hi13": part = iter_hi13_imu_samples( capture, host_utc_ticks_min=host_ticks_min, host_utc_ticks_max=host_ticks_max, ) elif kind == "n300": part = iter_n300_imu_samples(capture) if host_ticks_min is not None or host_ticks_max is not None: part = [ sample for sample in part if (host_ticks_min is None or sample.host_receive_utc_ticks >= host_ticks_min) and (host_ticks_max is None or sample.host_receive_utc_ticks <= host_ticks_max) ] else: raise ValueError(f"unsupported imu kind: {kind}") samples.extend(part) samples.sort(key=lambda sample: (sample.t_s, sample.device_timestamp_us)) return samples, captures_meta, kind def export_session( *, imu_rscap: list[Path] | Path, out: Path, lidar_rscap: Path | None = None, lidar_dlog: Path | None = None, imu_kind: str = "auto", msop_object: str = "frontlidar-msop-raw", difop_object: str = "frontlidar-difop-raw", require_difop: bool = False, host_start: str | None = None, host_end: str | None = None, frame_stride: int = 1, max_points_per_frame: int | None = 80000, min_range_m: float = 0.3, max_range_m: float = 120.0, min_frame_points: int = 100, ) -> dict: if (lidar_rscap is None) == (lidar_dlog is None): raise ValueError("provide exactly one of lidar_rscap or lidar_dlog") imu_paths = [imu_rscap] if isinstance(imu_rscap, Path) else list(imu_rscap) if not imu_paths: raise ValueError("at least one --imu-rscap is required") # LiDAR DObject tic uses DateTime.Now; IMU/MSOP host fields use UTC. lidar_ticks_min = local_wall_to_dotnet_ticks(host_start) if host_start else None lidar_ticks_max = local_wall_to_dotnet_ticks(host_end) if host_end else None imu_ticks_min = local_wall_to_utc_dotnet_ticks(host_start) if host_start else None imu_ticks_max = local_wall_to_utc_dotnet_ticks(host_end) if host_end else None kind = detect_imu_kind(imu_paths, imu_kind) out.mkdir(parents=True, exist_ok=True) samples, imu_captures, kind = load_imu_samples( imu_paths, kind=kind, host_ticks_min=imu_ticks_min, host_ticks_max=imu_ticks_max, ) t, _gyro, _accel = samples_to_arrays(samples) imu_csv = out / "imu.csv" write_imu_csv(imu_csv, samples) imu_host_ok = sum(1 for sample in samples if sample.host_receive_utc_ticks > 0) if lidar_dlog is not None: session = load_h32_dlog_lidar( lidar_dlog, msop_object=msop_object, difop_object=difop_object, require_difop=require_difop, host_ticks_min=lidar_ticks_min, host_ticks_max=lidar_ticks_max, ) frames = iter_h32_frames_from_packets( session.msop_packets, host_utc_ticks=session.msop_host_utc_ticks, min_frame_points=min_frame_points, frame_stride=frame_stride, min_range_m=min_range_m, max_range_m=max_range_m, max_points_per_frame=max_points_per_frame, vertical_deg=session.vertical_deg, horizontal_deg=session.horizontal_deg, ) lidar_meta = { "source": "dlog", "lidar_dlog": session.dlog_root, "msop_object": session.msop_object, "difop_object": session.difop_object, "msop_packets": len(session.msop_packets), "msop_packets_with_host_utc": sum(1 for ticks in session.msop_host_utc_ticks if ticks > 0), "msop_batches": session.msop_batch_count, "difop_records": session.difop_record_count, "session_id": session.session_id, "lidar_ip": session.lidar_ip, "angle_source": session.angle_source, "timestamp_note": ( "device: h32_msop_device_timestamp -> seconds; " "host: MSOP HostReceiveUtcTicks -> unix seconds" ), } else: assert lidar_rscap is not None lidar_capture = read_capture(lidar_rscap) frames = iter_h32_frames( lidar_capture, min_frame_points=min_frame_points, frame_stride=frame_stride, min_range_m=min_range_m, max_range_m=max_range_m, max_points_per_frame=max_points_per_frame, ) lidar_meta = { "source": "rscap_v2", "lidar_rscap": str(lidar_rscap), "capture": file_summary(lidar_capture), "angle_source": "default_msop_only_vertical_-16_to_16_deg", "timestamp_note": ( "device: h32_msop_device_timestamp_ms -> seconds; " "host: rscap receive_utc_ticks -> unix seconds" ), } lidar_dir = out / "lidar" lidar_stats = write_lidar_session(lidar_dir, frames) imu_time_note = ( "hi13_device_timestamp_ms -> seconds" if kind == "hi13" else "n300_device_timestamp_us -> seconds" ) summary = { "imu_rscap": [str(path) for path in imu_paths], "imu_kind": kind, "out": str(out), "host_window": { "host_start": host_start, "host_end": host_end, "lidar_ticks_min": lidar_ticks_min, "lidar_ticks_max": lidar_ticks_max, "imu_ticks_min": imu_ticks_min, "imu_ticks_max": imu_ticks_max, "note": "local wall cut; lidar DObject tic=DateTime.Now, IMU/MSOP host=UTC", }, "timestamp_policy": { "imu_device": imu_time_note, "imu_host": "rscap receive_utc_ticks -> t_host_utc_s", "lidar_device": "MSOP device timestamp -> t_start/t_end", "lidar_host": "MSOP HostReceiveUtcTicks -> t_host_utc_s", "calibration_align": "bridge via host UTC; do not force first device samples to coincide", }, "imu": { "samples": int(t.shape[0]), "samples_with_host_utc": imu_host_ok, "t_start": float(t[0]) if t.size else None, "t_end": float(t[-1]) if t.size else None, "captures": imu_captures, }, "lidar": { **lidar_stats, "frame_stride": int(frame_stride), "max_points_per_frame": max_points_per_frame, **lidar_meta, }, "outputs": { "imu_csv": str(imu_csv), "lidar_session": str(lidar_dir), }, } (out / "export_summary.json").write_text( json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) return summary def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--imu-rscap", type=Path, action="append", required=True, help="IMU V2 .rscap (repeatable)", ) parser.add_argument( "--imu-kind", choices=("auto", "hi13", "n300"), default="auto", help="IMU decoder (default: auto from filename)", ) lidar = parser.add_mutually_exclusive_group(required=True) lidar.add_argument( "--lidar-dlog", type=Path, help="H32 dlog directory or recovered zip (indices.log + data.bin)", ) lidar.add_argument( "--lidar-rscap", type=Path, help="Legacy H32 MSOP V2 .rscap", ) parser.add_argument("--msop-object", default="frontlidar-msop-raw") parser.add_argument("--difop-object", default="frontlidar-difop-raw") parser.add_argument("--require-difop", action="store_true") parser.add_argument("--host-start", type=str, default=None, help="Local wall start, e.g. 2026-08-08T17:40:05") parser.add_argument("--host-end", type=str, default=None, help="Local wall end, e.g. 2026-08-08T17:45:15") parser.add_argument("--out", type=Path, required=True) parser.add_argument("--frame-stride", type=int, default=1) parser.add_argument("--max-points-per-frame", type=int, default=80000) parser.add_argument("--min-range-m", type=float, default=0.3) parser.add_argument("--max-range-m", type=float, default=120.0) parser.add_argument("--min-frame-points", type=int, default=100) args = parser.parse_args() max_points = None if args.max_points_per_frame <= 0 else args.max_points_per_frame summary = export_session( imu_rscap=args.imu_rscap, lidar_rscap=args.lidar_rscap, lidar_dlog=args.lidar_dlog, imu_kind=args.imu_kind, msop_object=args.msop_object, difop_object=args.difop_object, require_difop=args.require_difop, host_start=args.host_start, host_end=args.host_end, out=args.out, frame_stride=args.frame_stride, max_points_per_frame=max_points, min_range_m=args.min_range_m, max_range_m=args.max_range_m, min_frame_points=args.min_frame_points, ) print( json.dumps( { "imu_kind": summary["imu_kind"], "imu_samples": summary["imu"]["samples"], "imu_host_utc": summary["imu"]["samples_with_host_utc"], "lidar_frames": summary["lidar"]["frames"], "lidar_host_utc": summary["lidar"]["frames_with_host_utc"], "lidar_source": summary["lidar"]["source"], "angle_source": summary["lidar"]["angle_source"], "host_window": summary["host_window"], "imu_csv": summary["outputs"]["imu_csv"], "lidar_session": summary["outputs"]["lidar_session"], "export_summary": str(Path(args.out) / "export_summary.json"), }, ensure_ascii=False, indent=2, ) ) if summary["imu"]["samples"] == 0: raise SystemExit("no valid IMU samples decoded in window") if summary["lidar"]["frames"] == 0: raise SystemExit("no valid H32 frames decoded in window") if summary["lidar"]["frames_with_host_utc"] == 0: raise SystemExit("no LiDAR frames with MSOP HostReceiveUtcTicks; cannot host-bridge align") if summary["imu"]["samples_with_host_utc"] == 0: raise SystemExit("no IMU samples with host receive UTC; cannot host-bridge align") return 0 if __name__ == "__main__": raise SystemExit(main())