"""Decode RoboSense H32 MSOP packets into Cartesian frames (metres). Angle / distance conventions follow the H32 Medulla plugins: azimuth = normalize(-(block_az + horizontal[ch])), altitude = vertical[ch], distance_mm = raw * distance_unit_mm, then: x = d_m * cos(alt) * cos(az) y = d_m * cos(alt) * sin(az) z = d_m * sin(alt) When DIFOP is unavailable, vertical angles default to a uniform -16°…+16° fan and horizontal channel offsets default to 0. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Sequence import numpy as np from .capture_format_v2 import CaptureFile PACKET_LENGTH = 1248 DATA_START = 42 BLOCKS = 12 BLOCK_LENGTH = 100 CHANNELS = 32 MIN_FRAME_POINTS_DEFAULT = 100 def default_vertical_deg() -> np.ndarray: return -16.0 + np.arange(CHANNELS, dtype=np.float64) * (32.0 / (CHANNELS - 1)) def default_horizontal_deg() -> np.ndarray: return np.zeros(CHANNELS, dtype=np.float64) def read_u16_be(packet: bytes, index: int) -> int: return (packet[index] << 8) | packet[index + 1] def device_timestamp_ms(packet: bytes) -> int: seconds = int.from_bytes(packet[20:26], "big") microseconds = int.from_bytes(packet[26:30], "big") return seconds * 1000 + microseconds // 1000 def distance_unit_mm(packet: bytes, *, auto: bool = True, fallback: float = 2.5) -> float: if not auto: return float(fallback) return 2.5 if packet[17] == 1 else 0.5 def normalize_azimuth_deg(angle: float) -> float: while angle > 180.0: angle -= 360.0 while angle < -180.0: angle += 360.0 return angle @dataclass class LidarFrameExport: t_start_s: float t_end_s: float points_xyz: np.ndarray # (N, 3) metres host_receive_utc_ticks_start: int = 0 host_receive_utc_ticks_end: int = 0 def decode_packet_points( packet: bytes, vertical_deg: np.ndarray, horizontal_deg: np.ndarray, *, min_range_m: float = 0.3, max_range_m: float = 120.0, ) -> tuple[list[float], np.ndarray]: """Decode one MSOP packet into block azimuths and concatenated XYZ points.""" if len(packet) != PACKET_LENGTH: return [], np.zeros((0, 3), dtype=np.float64) unit = distance_unit_mm(packet) az_list: list[float] = [] chunks: list[np.ndarray] = [] idx = DATA_START for _block in range(BLOCKS): if idx + BLOCK_LENGTH > PACKET_LENGTH or packet[idx] != 255 or packet[idx + 1] != 238: break az = read_u16_be(packet, idx + 2) * 0.01 az_list.append(az) pts = _block_points( packet, idx, az, unit, vertical_deg, horizontal_deg, min_range_m=min_range_m, max_range_m=max_range_m, ) if pts.shape[0]: chunks.append(pts) idx += BLOCK_LENGTH if not chunks: return az_list, np.zeros((0, 3), dtype=np.float64) return az_list, np.vstack(chunks) def _block_points( packet: bytes, block_offset: int, az_deg: float, unit_mm: float, vertical_deg: np.ndarray, horizontal_deg: np.ndarray, *, min_range_m: float, max_range_m: float, ) -> np.ndarray: xs: list[float] = [] ys: list[float] = [] zs: list[float] = [] idx = block_offset + 4 # after FF EE + azimuth for ch in range(CHANNELS): raw = read_u16_be(packet, idx) idx += 3 if raw == 0: continue d_m = (raw * unit_mm) * 0.001 if d_m < min_range_m or d_m > max_range_m: continue az_ch = np.deg2rad(normalize_azimuth_deg(-(az_deg + float(horizontal_deg[ch])))) alt = np.deg2rad(float(vertical_deg[ch])) cos_alt = np.cos(alt) xs.append(d_m * cos_alt * np.cos(az_ch)) ys.append(d_m * cos_alt * np.sin(az_ch)) zs.append(d_m * np.sin(alt)) if not xs: return np.zeros((0, 3), dtype=np.float64) return np.column_stack([xs, ys, zs]).astype(np.float64, copy=False) def iter_h32_frames_from_packets( packets: Iterable[bytes], *, host_utc_ticks: Sequence[int] | None = None, min_frame_points: int = MIN_FRAME_POINTS_DEFAULT, frame_stride: int = 1, min_range_m: float = 0.3, max_range_m: float = 120.0, max_points_per_frame: int | None = None, vertical_deg: np.ndarray | None = None, horizontal_deg: np.ndarray | None = None, ) -> list[LidarFrameExport]: """Assemble raw MSOP packets into frames using the 270°→90° azimuth wrap. ``host_utc_ticks`` is optional per-packet ``HostReceiveUtcTicks`` from the MSOP DLog payload (UTC DateTime ticks). When provided, each emitted frame carries host receive start/end ticks from the first/last contributing packet. """ vertical = default_vertical_deg() if vertical_deg is None else np.asarray(vertical_deg, dtype=np.float64) horizontal = default_horizontal_deg() if horizontal_deg is None else np.asarray(horizontal_deg, dtype=np.float64) if vertical.shape != (CHANNELS,) or horizontal.shape != (CHANNELS,): raise ValueError(f"vertical/horizontal must have shape ({CHANNELS},)") packet_list = list(packets) host_list = list(host_utc_ticks) if host_utc_ticks is not None else None if host_list is not None and len(host_list) != len(packet_list): raise ValueError( f"host_utc_ticks length {len(host_list)} != packets length {len(packet_list)}" ) frames: list[LidarFrameExport] = [] point_chunks: list[np.ndarray] = [] t_start: float | None = None t_end: float | None = None host_start: int | None = None host_end: int | None = None prev_az: float | None = None kept = 0 stride = max(1, int(frame_stride)) def emit() -> None: nonlocal point_chunks, t_start, t_end, host_start, host_end, kept if not point_chunks or t_start is None or t_end is None: point_chunks = [] t_start = t_end = None host_start = host_end = None return points = np.vstack(point_chunks) point_chunks = [] start_s, end_s = t_start, t_end h0 = int(host_start or 0) h1 = int(host_end or 0) t_start = t_end = None host_start = host_end = None if points.shape[0] < min_frame_points: return if kept % stride != 0: kept += 1 return kept += 1 if max_points_per_frame is not None and points.shape[0] > max_points_per_frame: select = np.linspace(0, points.shape[0] - 1, max_points_per_frame, dtype=int) points = points[select] if end_s <= start_s: end_s = start_s + 0.1 frames.append( LidarFrameExport( t_start_s=start_s, t_end_s=end_s, points_xyz=points, host_receive_utc_ticks_start=h0, host_receive_utc_ticks_end=h1, ) ) for index, packet in enumerate(packet_list): if len(packet) != PACKET_LENGTH: continue packet_t = device_timestamp_ms(packet) * 1e-3 packet_host = int(host_list[index]) if host_list is not None else 0 unit = distance_unit_mm(packet) idx = DATA_START for _block in range(BLOCKS): if idx + BLOCK_LENGTH > PACKET_LENGTH or packet[idx] != 255 or packet[idx + 1] != 238: break az = read_u16_be(packet, idx + 2) * 0.01 if prev_az is not None and prev_az > 270.0 and az < 90.0: emit() prev_az = az pts = _block_points( packet, idx, az, unit, vertical, horizontal, min_range_m=min_range_m, max_range_m=max_range_m, ) if pts.shape[0]: if t_start is None: t_start = packet_t host_start = packet_host t_end = packet_t host_end = packet_host point_chunks.append(pts) idx += BLOCK_LENGTH emit() return frames def iter_h32_frames( capture: CaptureFile, *, min_frame_points: int = MIN_FRAME_POINTS_DEFAULT, frame_stride: int = 1, min_range_m: float = 0.3, max_range_m: float = 120.0, max_points_per_frame: int | None = None, vertical_deg: np.ndarray | None = None, horizontal_deg: np.ndarray | None = None, ) -> list[LidarFrameExport]: """Assemble MSOP packets from a V2 .rscap capture into frames.""" packets = [chunk.raw for chunk in capture.chunks] host_ticks = [chunk.receive_utc_ticks for chunk in capture.chunks] return iter_h32_frames_from_packets( packets, host_utc_ticks=host_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=vertical_deg, horizontal_deg=horizontal_deg, )