Узел 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
6.5 KiB
Python
149 lines
6.5 KiB
Python
"""Чтение rosbag2 (storage sqlite3) без установленного ROS.
|
||
|
||
Поддерживает многошардовые бэги (`new_data` — 221 файл `*.db3`), произвольные
|
||
имена топиков и порядок шардов по числовому суффиксу. Метаданные `metadata.yaml`
|
||
не требуются: список топиков берётся из самой БД, что снимает зависимость от PyYAML.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import sqlite3
|
||
from contextlib import closing
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Iterator
|
||
|
||
from .cdr import PointCloud2, parse_pointcloud2
|
||
|
||
logger = logging.getLogger("flyguard.bag")
|
||
|
||
_SHARD_RE = re.compile(r"_(\d+)\.db3$")
|
||
_POINTCLOUD_TYPE = "sensor_msgs/msg/PointCloud2"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BagTopic:
|
||
name: str
|
||
type: str
|
||
count: int
|
||
|
||
|
||
class Bag:
|
||
"""Последовательное чтение облаков точек из rosbag2."""
|
||
|
||
def __init__(self, path: str | Path, topic: str | None = None):
|
||
path = Path(path)
|
||
if path.is_dir():
|
||
shards = sorted(path.glob("*.db3"), key=self._shard_key)
|
||
elif path.suffix == ".db3":
|
||
shards = [path]
|
||
else:
|
||
raise FileNotFoundError(f"не бэг и не .db3: {path}")
|
||
if not shards:
|
||
raise FileNotFoundError(f"в {path} нет файлов *.db3")
|
||
|
||
self.path = path
|
||
self.shards = shards
|
||
self.topics = self._scan_topics()
|
||
self.topic = topic or self._pick_topic()
|
||
self.n_frames_failed = 0 # обновляется по ходу последнего frames()
|
||
|
||
@staticmethod
|
||
def _shard_key(p: Path) -> tuple[int, str]:
|
||
m = _SHARD_RE.search(p.name)
|
||
return (int(m.group(1)) if m else 0, p.name)
|
||
|
||
def _scan_topics(self) -> dict[str, BagTopic]:
|
||
found: dict[str, BagTopic] = {}
|
||
for shard in self.shards:
|
||
with closing(self._connect(shard)) as con:
|
||
rows = con.execute(
|
||
"SELECT t.name, t.type, count(m.id) FROM topics t "
|
||
"LEFT JOIN messages m ON m.topic_id = t.id GROUP BY t.id"
|
||
).fetchall()
|
||
for name, type_, count in rows:
|
||
prev = found.get(name)
|
||
found[name] = BagTopic(name, type_, (prev.count if prev else 0) + count)
|
||
return found
|
||
|
||
def _pick_topic(self) -> str:
|
||
clouds = [t for t in self.topics.values() if t.type == _POINTCLOUD_TYPE]
|
||
if not clouds:
|
||
raise ValueError(f"в {self.path} нет топиков {_POINTCLOUD_TYPE}: "
|
||
f"{sorted(self.topics)}")
|
||
# при нескольких облачных топиках берём самый наполненный
|
||
return max(clouds, key=lambda t: t.count).name
|
||
|
||
@staticmethod
|
||
def _connect(shard: Path) -> sqlite3.Connection:
|
||
return sqlite3.connect(f"file:{shard.as_posix()}?mode=ro&immutable=1", uri=True)
|
||
|
||
def __len__(self) -> int:
|
||
return self.topics[self.topic].count
|
||
|
||
def frames(self, start: int = 0, stop: int | None = None,
|
||
stride: int = 1) -> Iterator[tuple[int, PointCloud2]]:
|
||
"""Выдать (timestamp_ns, облако) для сообщений выбранного топика.
|
||
|
||
Индексация сквозная по всему бэгу; шарды читаются по порядку, внутри
|
||
шарда — по возрастанию времени. Сообщение, которое не удаётся разобрать
|
||
(битый CDR у реального сенсора), пропускается с предупреждением в лог,
|
||
а не останавливает чтение бэга — `start`/`stop` считают по позиции
|
||
сообщения в бэге, а не по числу успешно разобранных.
|
||
"""
|
||
idx = 0
|
||
self.n_frames_failed = 0
|
||
for shard in self.shards:
|
||
# closing(), а не сам connection: у sqlite3 `with` управляет
|
||
# транзакцией и файл остаётся открытым — на Windows его потом
|
||
# невозможно удалить
|
||
with closing(self._connect(shard)) as con:
|
||
row = con.execute("SELECT id FROM topics WHERE name = ?",
|
||
(self.topic,)).fetchone()
|
||
if row is None:
|
||
continue
|
||
topic_id = row[0]
|
||
n_here = con.execute(
|
||
"SELECT count(*) FROM messages WHERE topic_id = ?", (topic_id,)
|
||
).fetchone()[0]
|
||
if stop is not None and idx >= stop:
|
||
return
|
||
if idx + n_here <= start:
|
||
idx += n_here
|
||
continue
|
||
cur = con.execute(
|
||
"SELECT timestamp, data FROM messages WHERE topic_id = ? "
|
||
"ORDER BY timestamp", (topic_id,))
|
||
for ts, blob in cur:
|
||
if stop is not None and idx >= stop:
|
||
return
|
||
if idx >= start and (idx - start) % stride == 0:
|
||
try:
|
||
pc = parse_pointcloud2(blob)
|
||
except Exception as e: # noqa: BLE001
|
||
self.n_frames_failed += 1
|
||
logger.warning(
|
||
"кадр %d в %s не разобран (%s: %s) — пропущен",
|
||
idx, shard.name, type(e).__name__, e)
|
||
else:
|
||
yield ts, pc
|
||
idx += 1
|
||
|
||
def describe(self) -> str:
|
||
lines = [f"бэг: {self.path}",
|
||
f"шардов: {len(self.shards)}",
|
||
f"топик: {self.topic}"]
|
||
for t in sorted(self.topics.values(), key=lambda t: -t.count):
|
||
mark = "*" if t.name == self.topic else " "
|
||
lines.append(f" {mark} {t.name} [{t.type}] {t.count} сообщений")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def find_bags(root: str | Path) -> list[Path]:
|
||
"""Найти все каталоги-бэги под указанным корнем."""
|
||
root = Path(root)
|
||
if not root.exists():
|
||
return []
|
||
out = {p.parent for p in root.rglob("*.db3")}
|
||
return sorted(out)
|