2022-04-10 23:32:25 +02:00
|
|
|
# Copyright 2020-2022 Ternaris.
|
2021-05-02 14:49:33 +02:00
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
"""Rosbag2 reader."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import sqlite3
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from tempfile import TemporaryDirectory
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
import zstandard
|
2021-11-25 14:26:17 +01:00
|
|
|
from ruamel.yaml import YAML
|
|
|
|
|
from ruamel.yaml.error import YAMLError
|
2021-05-02 14:49:33 +02:00
|
|
|
|
2022-04-13 10:42:47 +02:00
|
|
|
from rosbags.interfaces import Connection, ConnectionExtRosbag2, TopicInfo
|
2021-08-01 18:22:36 +02:00
|
|
|
|
2021-05-02 14:49:33 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from types import TracebackType
|
2022-04-11 00:07:53 +02:00
|
|
|
from typing import Any, Generator, Iterable, Literal, Optional, Type, TypedDict, Union
|
|
|
|
|
|
|
|
|
|
class StartingTime(TypedDict):
|
|
|
|
|
"""Bag starting time."""
|
|
|
|
|
|
|
|
|
|
nanoseconds_since_epoch: int
|
|
|
|
|
|
|
|
|
|
class Duration(TypedDict):
|
|
|
|
|
"""Bag starting time."""
|
|
|
|
|
|
|
|
|
|
nanoseconds: int
|
|
|
|
|
|
|
|
|
|
class TopicMetadata(TypedDict):
|
|
|
|
|
"""Topic metadata."""
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
type: str
|
|
|
|
|
serialization_format: str
|
|
|
|
|
offered_qos_profiles: str
|
|
|
|
|
|
|
|
|
|
class TopicWithMessageCount(TypedDict):
|
|
|
|
|
"""Topic with message count."""
|
|
|
|
|
|
|
|
|
|
message_count: int
|
|
|
|
|
topic_metadata: TopicMetadata
|
|
|
|
|
|
|
|
|
|
class Metadata(TypedDict):
|
|
|
|
|
"""Rosbag2 metadata file."""
|
|
|
|
|
|
|
|
|
|
version: int
|
|
|
|
|
storage_identifier: str
|
|
|
|
|
relative_file_paths: list[str]
|
|
|
|
|
starting_time: StartingTime
|
|
|
|
|
duration: Duration
|
|
|
|
|
message_count: int
|
|
|
|
|
compression_format: str
|
|
|
|
|
compression_mode: str
|
|
|
|
|
topics_with_message_count: list[TopicWithMessageCount]
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReaderError(Exception):
|
|
|
|
|
"""Reader Error."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
2021-11-25 14:26:17 +01:00
|
|
|
def decompress(path: Path, do_decompress: bool) -> Generator[Path, None, None]:
|
2021-05-02 14:49:33 +02:00
|
|
|
"""Transparent rosbag2 database decompression context.
|
|
|
|
|
|
|
|
|
|
This context manager will yield a path to the decompressed file contents.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
path: Potentially compressed file.
|
|
|
|
|
do_decompress: Flag indicating if decompression shall occur.
|
|
|
|
|
|
|
|
|
|
Yields:
|
|
|
|
|
Path of transparently decompressed file.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
if do_decompress:
|
|
|
|
|
decomp = zstandard.ZstdDecompressor()
|
|
|
|
|
with TemporaryDirectory() as tempdir:
|
|
|
|
|
dbfile = Path(tempdir, path.stem)
|
|
|
|
|
with path.open('rb') as infile, dbfile.open('wb') as outfile:
|
|
|
|
|
decomp.copy_stream(infile, outfile)
|
|
|
|
|
yield dbfile
|
|
|
|
|
else:
|
|
|
|
|
yield path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Reader:
|
|
|
|
|
"""Reader for rosbag2 files.
|
|
|
|
|
|
|
|
|
|
It implements all necessary features to access metadata and message
|
|
|
|
|
streams.
|
|
|
|
|
|
|
|
|
|
Version history:
|
|
|
|
|
|
|
|
|
|
- Version 1: Initial format.
|
|
|
|
|
- Version 2: Changed field sizes in C++ implementation.
|
|
|
|
|
- Version 3: Added compression.
|
|
|
|
|
- Version 4: Added QoS metadata to topics, changed relative file paths
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, path: Union[Path, str]):
|
|
|
|
|
"""Open rosbag and check metadata.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
path: Filesystem path to bag.
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ReaderError: Bag not readable or bag metadata.
|
2022-04-11 00:07:53 +02:00
|
|
|
|
2021-05-02 14:49:33 +02:00
|
|
|
"""
|
|
|
|
|
path = Path(path)
|
2022-04-11 00:07:53 +02:00
|
|
|
yamlpath = path / 'metadata.yaml'
|
|
|
|
|
self.path = path
|
2021-05-02 14:49:33 +02:00
|
|
|
self.bio = False
|
|
|
|
|
try:
|
|
|
|
|
yaml = YAML(typ='safe')
|
|
|
|
|
dct = yaml.load(yamlpath.read_text())
|
|
|
|
|
except OSError as err:
|
|
|
|
|
raise ReaderError(f'Could not read metadata at {yamlpath}: {err}.') from None
|
|
|
|
|
except YAMLError as exc:
|
|
|
|
|
raise ReaderError(f'Could not load YAML from {yamlpath}: {exc}') from None
|
|
|
|
|
|
|
|
|
|
try:
|
2022-04-11 00:07:53 +02:00
|
|
|
self.metadata: Metadata = dct['rosbag2_bagfile_information']
|
2021-05-02 14:49:33 +02:00
|
|
|
if (ver := self.metadata['version']) > 4:
|
|
|
|
|
raise ReaderError(f'Rosbag2 version {ver} not supported; please report issue.')
|
|
|
|
|
if storageid := self.metadata['storage_identifier'] != 'sqlite3':
|
|
|
|
|
raise ReaderError(
|
|
|
|
|
f'Storage plugin {storageid!r} not supported; please report issue.',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.paths = [path / Path(x).name for x in self.metadata['relative_file_paths']]
|
2022-04-11 00:07:53 +02:00
|
|
|
if missing := [x for x in self.paths if not x.exists()]:
|
2021-05-02 14:49:33 +02:00
|
|
|
raise ReaderError(f'Some database files are missing: {[str(x) for x in missing]!r}')
|
|
|
|
|
|
2021-08-01 18:22:36 +02:00
|
|
|
self.connections = {
|
|
|
|
|
idx + 1: Connection(
|
|
|
|
|
id=idx + 1,
|
|
|
|
|
topic=x['topic_metadata']['name'],
|
|
|
|
|
msgtype=x['topic_metadata']['type'],
|
2022-04-13 09:40:22 +02:00
|
|
|
msgdef='',
|
|
|
|
|
md5sum='',
|
|
|
|
|
msgcount=x['message_count'],
|
|
|
|
|
ext=ConnectionExtRosbag2(
|
|
|
|
|
serialization_format=x['topic_metadata']['serialization_format'],
|
|
|
|
|
offered_qos_profiles=x['topic_metadata'].get('offered_qos_profiles', ''),
|
|
|
|
|
),
|
2021-08-01 18:22:36 +02:00
|
|
|
) for idx, x in enumerate(self.metadata['topics_with_message_count'])
|
|
|
|
|
}
|
|
|
|
|
noncdr = {
|
2022-04-13 09:40:22 +02:00
|
|
|
fmt for x in self.connections.values() if isinstance(x.ext, ConnectionExtRosbag2)
|
|
|
|
|
if (fmt := x.ext.serialization_format) != 'cdr'
|
2021-08-01 18:22:36 +02:00
|
|
|
}
|
2021-05-02 14:49:33 +02:00
|
|
|
if noncdr:
|
|
|
|
|
raise ReaderError(f'Serialization format {noncdr!r} is not supported.')
|
|
|
|
|
|
|
|
|
|
if self.compression_mode and (cfmt := self.compression_format) != 'zstd':
|
|
|
|
|
raise ReaderError(f'Compression format {cfmt!r} is not supported.')
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise ReaderError(f'A metadata key is missing {exc!r}.') from None
|
|
|
|
|
|
2021-11-25 14:26:17 +01:00
|
|
|
def open(self) -> None:
|
2021-05-02 14:49:33 +02:00
|
|
|
"""Open rosbag2."""
|
|
|
|
|
# Future storage formats will require file handles.
|
|
|
|
|
self.bio = True
|
|
|
|
|
|
2021-11-25 14:26:17 +01:00
|
|
|
def close(self) -> None:
|
2021-05-02 14:49:33 +02:00
|
|
|
"""Close rosbag2."""
|
|
|
|
|
# Future storage formats will require file handles.
|
|
|
|
|
assert self.bio
|
|
|
|
|
self.bio = False
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def duration(self) -> int:
|
|
|
|
|
"""Duration in nanoseconds between earliest and latest messages."""
|
2021-11-25 14:26:17 +01:00
|
|
|
nsecs: int = self.metadata['duration']['nanoseconds']
|
|
|
|
|
return nsecs + 1
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def start_time(self) -> int:
|
|
|
|
|
"""Timestamp in nanoseconds of the earliest message."""
|
2022-04-11 00:07:53 +02:00
|
|
|
return self.metadata['starting_time']['nanoseconds_since_epoch']
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def end_time(self) -> int:
|
2021-07-04 22:27:52 +02:00
|
|
|
"""Timestamp in nanoseconds after the latest message."""
|
2021-05-02 14:49:33 +02:00
|
|
|
return self.start_time + self.duration
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def message_count(self) -> int:
|
|
|
|
|
"""Total message count."""
|
2022-04-11 00:07:53 +02:00
|
|
|
return self.metadata['message_count']
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def compression_format(self) -> Optional[str]:
|
|
|
|
|
"""Compression format."""
|
|
|
|
|
return self.metadata.get('compression_format', None) or None
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def compression_mode(self) -> Optional[str]:
|
|
|
|
|
"""Compression mode."""
|
|
|
|
|
mode = self.metadata.get('compression_mode', '').lower()
|
|
|
|
|
return mode if mode != 'none' else None
|
|
|
|
|
|
2021-08-01 18:22:36 +02:00
|
|
|
@property
|
2022-04-13 10:42:47 +02:00
|
|
|
def topics(self) -> dict[str, TopicInfo]:
|
|
|
|
|
"""Topic information."""
|
|
|
|
|
return {
|
|
|
|
|
x.topic: TopicInfo(x.msgtype, x.msgdef, x.msgcount, [x])
|
|
|
|
|
for x in self.connections.values()
|
|
|
|
|
}
|
2021-08-01 18:22:36 +02:00
|
|
|
|
2021-05-02 14:49:33 +02:00
|
|
|
def messages( # pylint: disable=too-many-locals
|
|
|
|
|
self,
|
2021-08-01 18:22:36 +02:00
|
|
|
connections: Iterable[Connection] = (),
|
2021-05-02 14:49:33 +02:00
|
|
|
start: Optional[int] = None,
|
|
|
|
|
stop: Optional[int] = None,
|
2021-08-06 12:03:29 +02:00
|
|
|
) -> Generator[tuple[Connection, int, bytes], None, None]:
|
2021-05-02 14:49:33 +02:00
|
|
|
"""Read messages from bag.
|
|
|
|
|
|
|
|
|
|
Args:
|
2021-08-01 18:22:36 +02:00
|
|
|
connections: Iterable with connections to filter for. An empty
|
|
|
|
|
iterable disables filtering on connections.
|
2021-05-02 14:49:33 +02:00
|
|
|
start: Yield only messages at or after this timestamp (ns).
|
|
|
|
|
stop: Yield only messages before this timestamp (ns).
|
|
|
|
|
|
|
|
|
|
Yields:
|
2021-08-06 12:03:29 +02:00
|
|
|
tuples of connection, timestamp (ns), and rawdata.
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
ReaderError: Bag not open.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
if not self.bio:
|
|
|
|
|
raise ReaderError('Rosbag is not open.')
|
|
|
|
|
|
2021-08-01 18:22:36 +02:00
|
|
|
query = [
|
|
|
|
|
'SELECT topics.id,messages.timestamp,messages.data',
|
|
|
|
|
'FROM messages JOIN topics ON messages.topic_id=topics.id',
|
|
|
|
|
]
|
2021-08-06 12:03:29 +02:00
|
|
|
args: list[Any] = []
|
2021-08-01 18:22:36 +02:00
|
|
|
clause = 'WHERE'
|
|
|
|
|
|
|
|
|
|
if connections:
|
|
|
|
|
topics = {x.topic for x in connections}
|
|
|
|
|
query.append(f'{clause} topics.name IN ({",".join("?" for _ in topics)})')
|
|
|
|
|
args += topics
|
|
|
|
|
clause = 'AND'
|
|
|
|
|
|
|
|
|
|
if start is not None:
|
|
|
|
|
query.append(f'{clause} messages.timestamp >= ?')
|
|
|
|
|
args.append(start)
|
|
|
|
|
clause = 'AND'
|
|
|
|
|
|
|
|
|
|
if stop is not None:
|
|
|
|
|
query.append(f'{clause} messages.timestamp < ?')
|
|
|
|
|
args.append(stop)
|
|
|
|
|
clause = 'AND'
|
|
|
|
|
|
|
|
|
|
query.append('ORDER BY timestamp')
|
|
|
|
|
querystr = ' '.join(query)
|
|
|
|
|
|
2021-05-02 14:49:33 +02:00
|
|
|
for filepath in self.paths:
|
|
|
|
|
with decompress(filepath, self.compression_mode == 'file') as path:
|
|
|
|
|
conn = sqlite3.connect(f'file:{path}?immutable=1', uri=True)
|
|
|
|
|
conn.row_factory = lambda _, x: x
|
|
|
|
|
cur = conn.cursor()
|
|
|
|
|
cur.execute(
|
|
|
|
|
'SELECT count(*) FROM sqlite_master '
|
|
|
|
|
'WHERE type="table" AND name IN ("messages", "topics")',
|
|
|
|
|
)
|
|
|
|
|
if cur.fetchone()[0] != 2:
|
|
|
|
|
raise ReaderError(f'Cannot open database {path} or database missing tables.')
|
|
|
|
|
|
2021-09-13 17:30:09 +02:00
|
|
|
cur.execute('SELECT name,id FROM topics')
|
2021-11-25 14:26:17 +01:00
|
|
|
connmap: dict[int, Connection] = {
|
2021-09-13 17:30:09 +02:00
|
|
|
row[1]: next((x for x in self.connections.values() if x.topic == row[0]),
|
|
|
|
|
None) # type: ignore
|
|
|
|
|
for row in cur
|
|
|
|
|
}
|
|
|
|
|
|
2021-08-01 18:22:36 +02:00
|
|
|
cur.execute(querystr, args)
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
if self.compression_mode == 'message':
|
|
|
|
|
decomp = zstandard.ZstdDecompressor().decompress
|
|
|
|
|
for row in cur:
|
2021-08-01 18:22:36 +02:00
|
|
|
cid, timestamp, data = row
|
2021-09-13 17:30:09 +02:00
|
|
|
yield connmap[cid], timestamp, decomp(data)
|
2021-05-02 14:49:33 +02:00
|
|
|
else:
|
2021-08-01 18:22:36 +02:00
|
|
|
for cid, timestamp, data in cur:
|
2021-09-13 17:30:09 +02:00
|
|
|
yield connmap[cid], timestamp, data
|
2021-05-02 14:49:33 +02:00
|
|
|
|
|
|
|
|
def __enter__(self) -> Reader:
|
|
|
|
|
"""Open rosbag2 when entering contextmanager."""
|
|
|
|
|
self.open()
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(
|
|
|
|
|
self,
|
|
|
|
|
exc_type: Optional[Type[BaseException]],
|
|
|
|
|
exc_val: Optional[BaseException],
|
|
|
|
|
exc_tb: Optional[TracebackType],
|
|
|
|
|
) -> Literal[False]:
|
|
|
|
|
"""Close rosbag2 when exiting contextmanager."""
|
|
|
|
|
self.close()
|
|
|
|
|
return False
|