Files
calibration/tools/export_rscap_to_v1.py
T

431 lines
16 KiB
Python
Raw Normal View History

#!/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())