Files
calibration/tools/export_rscap_to_v1.py

431 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 LiDARIMU 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())