forked from Dan4ick/Lidar_Muxa
Узел tools/flyguard_ros2_node.py (п. 16.7: отдавал массив вместо PointCloud2, публиковал треки без решения) переписан: разбор через новый flyguard.cdr.from_ros_message, публикация через flyguard.export.export_frame, не падает от битого кадра, по умолчанию подключает artifacts/*.npz. flyguard/bag.py: битый CDR-пакет в бэге пропускается с логом вместо обрыва всего чтения (Bag.n_frames_failed). tools/compare_benchmark.py: флаг --fail-on-net-down для CI-гейта регрессии по методологии парных переворотов (EXPERIMENTS п. 16.1). Добавлен pyproject.toml (ruff, не прогнан — в окружении нет ruff/pip). Тесты 46/46 (tests/test_pipeline.py). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.5 KiB
Python
149 lines
5.5 KiB
Python
"""Разбор sensor_msgs/msg/PointCloud2 из CDR без зависимости от ROS.
|
||
|
||
Нужен для двух сценариев:
|
||
* офлайн-эксперименты на машине без ROS (Windows);
|
||
* прямое чтение rosbag внутри контейнера, минуя `ros2 bag play`.
|
||
|
||
Внутри ROS-ноды сообщение приходит уже разобранным; его переводит в тот же
|
||
`PointCloud2` функция `from_ros_message`.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import struct
|
||
from dataclasses import dataclass
|
||
|
||
import numpy as np
|
||
|
||
# sensor_msgs/msg/PointField: код типа -> (numpy dtype, размер в байтах)
|
||
_PF_DTYPES = {
|
||
1: ("i1", 1), 2: ("u1", 1), 3: ("i2", 2), 4: ("u2", 2),
|
||
5: ("i4", 4), 6: ("u4", 4), 7: ("f4", 4), 8: ("f8", 8),
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PointCloud2:
|
||
"""Минимальное представление облака точек."""
|
||
|
||
stamp: float
|
||
frame_id: str
|
||
height: int
|
||
width: int
|
||
point_step: int
|
||
is_dense: bool
|
||
points: np.ndarray # структурированный массив длиной height*width
|
||
|
||
@property
|
||
def n_points(self) -> int:
|
||
return int(self.points.shape[0])
|
||
|
||
|
||
class _CdrReader:
|
||
"""Чтение little-endian CDR с выравниванием примитивов относительно тела сообщения."""
|
||
|
||
__slots__ = ("buf", "origin", "pos")
|
||
|
||
def __init__(self, buf: bytes | memoryview):
|
||
self.buf = buf
|
||
self.origin = 4 # заголовок инкапсуляции
|
||
self.pos = 4
|
||
|
||
def _align(self, size: int) -> None:
|
||
self.pos += (-(self.pos - self.origin)) % size
|
||
|
||
def u8(self) -> int:
|
||
v = self.buf[self.pos]
|
||
self.pos += 1
|
||
return v
|
||
|
||
def u32(self) -> int:
|
||
self._align(4)
|
||
v = struct.unpack_from("<I", self.buf, self.pos)[0]
|
||
self.pos += 4
|
||
return v
|
||
|
||
def i32(self) -> int:
|
||
self._align(4)
|
||
v = struct.unpack_from("<i", self.buf, self.pos)[0]
|
||
self.pos += 4
|
||
return v
|
||
|
||
def string(self) -> str:
|
||
n = self.u32()
|
||
s = bytes(self.buf[self.pos:self.pos + max(n - 1, 0)]).decode("utf-8", "replace")
|
||
self.pos += n
|
||
return s
|
||
|
||
|
||
def point_dtype(fields: list[tuple[str, int, int, int]], point_step: int) -> np.dtype:
|
||
"""Собрать numpy-dtype по описанию полей, явно добивая пропуски паддингом.
|
||
|
||
Поля лидара невыровнены (`timestamp` float64 по смещению 18), поэтому
|
||
структурированный dtype строится вручную, а не через `np.dtype(align=True)`.
|
||
"""
|
||
spec: list[tuple[str, str]] = []
|
||
used = 0
|
||
for name, offset, datatype, count in fields:
|
||
kind, size = _PF_DTYPES[datatype]
|
||
if offset > used:
|
||
spec.append((f"_pad{used}", f"V{offset - used}"))
|
||
elif offset < used:
|
||
raise ValueError(f"перекрывающиеся поля в PointCloud2: {name}")
|
||
spec.append((name, kind if count == 1 else f"{count}{kind}"))
|
||
used = offset + size * count
|
||
if point_step > used:
|
||
spec.append((f"_pad{used}", f"V{point_step - used}"))
|
||
dt = np.dtype(spec)
|
||
if dt.itemsize != point_step:
|
||
raise ValueError(f"dtype {dt.itemsize} байт != point_step {point_step}")
|
||
return dt
|
||
|
||
|
||
def parse_pointcloud2(blob: bytes | memoryview) -> PointCloud2:
|
||
"""Разобрать CDR-сериализованное sensor_msgs/msg/PointCloud2."""
|
||
r = _CdrReader(blob)
|
||
sec = r.i32()
|
||
nsec = r.u32()
|
||
frame_id = r.string()
|
||
height = r.u32()
|
||
width = r.u32()
|
||
|
||
fields = []
|
||
for _ in range(r.u32()):
|
||
name = r.string()
|
||
offset = r.u32()
|
||
datatype = r.u8()
|
||
count = r.u32()
|
||
fields.append((name, offset, datatype, count))
|
||
|
||
r.u8() # is_bigendian: в данных всегда 0, little-endian
|
||
point_step = r.u32()
|
||
r.u32() # row_step
|
||
n_bytes = r.u32()
|
||
data = memoryview(blob)[r.pos:r.pos + n_bytes]
|
||
r.pos += n_bytes
|
||
is_dense = bool(r.u8())
|
||
|
||
dt = point_dtype(fields, point_step)
|
||
points = np.frombuffer(data, dtype=dt, count=height * width)
|
||
return PointCloud2(stamp=sec + nsec * 1e-9, frame_id=frame_id, height=height,
|
||
width=width, point_step=point_step, is_dense=is_dense,
|
||
points=points)
|
||
|
||
|
||
def from_ros_message(msg) -> PointCloud2:
|
||
"""Перевести разобранное rclpy-сообщение sensor_msgs/msg/PointCloud2.
|
||
|
||
Все height·width точек сохраняются, включая NaN: сетчатка раскладывает облако
|
||
в решётку азимут × кольцо по порядку точек, и выброшенная точка сдвинула бы
|
||
всю решётку (`sensor_msgs_py.read_points(skip_nans=True)` делает именно это).
|
||
"""
|
||
if msg.is_bigendian:
|
||
raise ValueError("big-endian PointCloud2 не поддерживается")
|
||
fields = [(f.name, f.offset, f.datatype, f.count) for f in msg.fields]
|
||
dt = point_dtype(fields, msg.point_step)
|
||
points = np.frombuffer(msg.data, dtype=dt, count=msg.height * msg.width)
|
||
stamp = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9
|
||
return PointCloud2(stamp=stamp, frame_id=msg.header.frame_id, height=msg.height,
|
||
width=msg.width, point_step=msg.point_step,
|
||
is_dense=bool(msg.is_dense), points=points)
|