feat(icp): LiDAR TCP frame decoder + tests

This commit is contained in:
L'électron rare
2026-05-14 11:54:49 +02:00
parent 0967faecb6
commit d9bb3c3f68
2 changed files with 94 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
"""TCP receiver for iPhone ARBodyTracker LiDAR ARMeshAnchor stream.
Wire format (per frame, after the 4-byte big-endian length prefix consumed
by the socket reader):
[uint64 BE timestamp_ns]
[uint32 BE vertex_count]
[float32 LE x y z] * vertex_count
The decoder is pure and side-effect-free so it can be unit-tested without a
socket. The socket reader lives in a separate class (LidarTCPReader) so its
threading model is independently testable.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass
import numpy as np
_HEADER = struct.Struct(">QI") # timestamp_ns, vertex_count
@dataclass(frozen=True)
class LidarFrame:
"""One decoded LiDAR frame from the iPhone."""
timestamp_ns: int
points: np.ndarray # shape (N, 3), float32, ARKit world frame (meters)
def decode_frame(body: bytes) -> LidarFrame:
"""Decode a frame body (length prefix already stripped)."""
if len(body) < _HEADER.size:
raise ValueError(f"truncated frame: header needs {_HEADER.size} bytes, got {len(body)}")
timestamp_ns, vertex_count = _HEADER.unpack_from(body, 0)
if vertex_count == 0:
raise ValueError("vertex_count must be > 0")
expected = _HEADER.size + vertex_count * 12
if len(body) < expected:
raise ValueError(f"truncated frame: need {expected} bytes for {vertex_count} verts, got {len(body)}")
raw = body[_HEADER.size : expected]
pts = np.frombuffer(raw, dtype="<f4").reshape(vertex_count, 3).astype(np.float32, copy=True)
return LidarFrame(timestamp_ns=int(timestamp_ns), points=pts)
@@ -0,0 +1,50 @@
"""Unit tests for the iPhone LiDAR TCP frame decoder."""
from __future__ import annotations
import struct
import numpy as np
import pytest
def _encode_frame(points: np.ndarray, timestamp_ns: int) -> bytes:
"""Mimic the iPhone-side encoder for round-trip testing."""
n = points.shape[0]
body = struct.pack(">Q", timestamp_ns) + struct.pack(">I", n) + points.astype("<f4").tobytes()
header = struct.pack(">I", len(body))
return header + body
def test_decode_lidar_frame_roundtrip() -> None:
from data_only_viz.lidar_receiver import LidarFrame, decode_frame
pts = np.array([[0.1, 0.2, 0.3], [-1.0, 2.0, 5.5]], dtype=np.float32)
payload = _encode_frame(pts, timestamp_ns=1_700_000_000_000_000_000)
body = payload[4:]
frame = decode_frame(body)
assert isinstance(frame, LidarFrame)
assert frame.timestamp_ns == 1_700_000_000_000_000_000
np.testing.assert_allclose(frame.points, pts, atol=1e-6)
def test_decode_lidar_frame_rejects_truncated() -> None:
from data_only_viz.lidar_receiver import decode_frame
pts = np.array([[1.0, 2.0, 3.0]], dtype=np.float32)
body = (
struct.pack(">Q", 0) +
struct.pack(">I", 1) +
pts.astype("<f4").tobytes()[:8] # truncated
)
with pytest.raises(ValueError, match="truncated"):
decode_frame(body)
def test_decode_lidar_frame_rejects_zero_vertex_count() -> None:
from data_only_viz.lidar_receiver import decode_frame
body = struct.pack(">Q", 0) + struct.pack(">I", 0)
with pytest.raises(ValueError, match="vertex_count"):
decode_frame(body)