1use std::path::Path;
4
5use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
6use spatialrust_core::{
7 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
8};
9use spatialrust_records::{
10 BoundedSpatialRecordSource, CancellationToken, ChunkIdentity, MemoryReservation, MemoryTracker,
11 RecordProvenance, RecordsError, RecordsResult, SchemaDescriptor, SchemaVersion, SpatialRecord,
12 SpatialRecordChunk, StreamOptions,
13};
14use spatialrust_runtime::{
15 decode_point_cloud2_xyz, decode_tf_message, point_cloud2_has_intensity, PointCloud2Xyz,
16 RuntimeError, TfTransform, POINT_CLOUD2_TYPE, TF_MESSAGE_TYPE,
17};
18use thiserror::Error;
19
20pub const ROSBAG2_POINT_XYZ_SCHEMA_ID: &str = "ros2.sensor_msgs.msg.PointCloud2.xyz";
22pub const ROSBAG2_POINT_XYZI_SCHEMA_ID: &str = "ros2.sensor_msgs.msg.PointCloud2.xyzi";
24
25const CDR_SCRATCH_OVERHEAD_BYTES: u64 = 64 * 1024;
26
27pub type Rosbag2Result<T> = Result<T, Rosbag2Error>;
29
30#[derive(Debug, Error)]
32pub enum Rosbag2Error {
33 #[error("rosbag2 SQLite error: {0}")]
35 Sqlite(#[from] rusqlite::Error),
36 #[error("invalid rosbag2 SQLite bag: {0}")]
38 InvalidBag(String),
39 #[error("unsupported rosbag2 message: {0}")]
41 Unsupported(String),
42 #[error("ROS 2 CDR error: {0}")]
44 Cdr(#[from] RuntimeError),
45 #[error(transparent)]
47 Records(#[from] RecordsError),
48 #[error(transparent)]
50 Core(#[from] spatialrust_core::SpatialError),
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Rosbag2Topic {
56 pub id: i64,
58 pub name: String,
60 pub type_name: String,
62 pub serialization_format: String,
64 pub message_count: u64,
66}
67
68#[derive(Clone, Debug, PartialEq)]
70pub struct Rosbag2TfMessage {
71 pub bag_timestamp: u64,
73 pub transforms: Vec<TfTransform>,
75}
76
77pub fn list_topics(path: impl AsRef<Path>) -> Rosbag2Result<Vec<Rosbag2Topic>> {
79 let connection = open_connection(path)?;
80 read_topics(&connection)
81}
82
83pub fn list_tf_messages(
89 path: impl AsRef<Path>,
90 topic_name: &str,
91 max_messages: usize,
92) -> Rosbag2Result<Vec<Rosbag2TfMessage>> {
93 if max_messages == 0 {
94 return Err(Rosbag2Error::InvalidBag("TF message limit must be greater than zero".into()));
95 }
96 let connection = open_connection(path)?;
97 let topics = read_topics(&connection)?;
98 let topic = topics
99 .into_iter()
100 .find(|topic| topic.name == topic_name)
101 .ok_or_else(|| Rosbag2Error::InvalidBag(format!("topic `{topic_name}` was not found")))?;
102 if topic.type_name != TF_MESSAGE_TYPE {
103 return Err(Rosbag2Error::Unsupported(format!(
104 "topic `{}` has type `{}`, expected `{TF_MESSAGE_TYPE}`",
105 topic.name, topic.type_name
106 )));
107 }
108 if !topic.serialization_format.eq_ignore_ascii_case("cdr") {
109 return Err(Rosbag2Error::Unsupported(format!(
110 "topic `{}` uses serialization `{}`, only CDR is supported",
111 topic.name, topic.serialization_format
112 )));
113 }
114 let limit = i64::try_from(max_messages)
115 .map_err(|_| Rosbag2Error::InvalidBag("TF message limit exceeds SQLite bounds".into()))?;
116 let mut statement = connection.prepare(
117 "SELECT timestamp, data FROM messages WHERE topic_id = ?1 \
118 ORDER BY timestamp ASC, id ASC LIMIT ?2",
119 )?;
120 let rows = statement.query_map(params![topic.id, limit], |row| {
121 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
122 })?;
123 let mut messages = Vec::new();
124 for row in rows {
125 let (timestamp, data) = row?;
126 let bag_timestamp = u64::try_from(timestamp).map_err(|_| {
127 Rosbag2Error::InvalidBag(format!("TF message has negative timestamp {timestamp}"))
128 })?;
129 let transforms = decode_tf_message(&data)?;
130 messages.push(Rosbag2TfMessage { bag_timestamp, transforms });
131 }
132 Ok(messages)
133}
134
135pub struct Rosbag2PointCloudSource {
141 connection: Connection,
142 source_id: String,
143 topic: Rosbag2Topic,
144 schema: SchemaDescriptor,
145 options: StreamOptions,
146 tracker: MemoryTracker,
147 cancellation: CancellationToken,
148 max_chunk_bytes: u64,
149 max_message_bytes: u64,
150 has_intensity: bool,
151 cursor: Option<MessageCursor>,
152 pending: Option<PendingPointCloud>,
153 next_sequence: u64,
154 next_point_offset: u64,
155 finished: bool,
156}
157
158impl Rosbag2PointCloudSource {
159 pub fn open(
161 path: impl AsRef<Path>,
162 topic_name: &str,
163 options: StreamOptions,
164 cancellation: CancellationToken,
165 ) -> Rosbag2Result<Self> {
166 let path = path.as_ref();
167 let source_uri = path.display().to_string();
168 let source_id = format!("rosbag2-sqlite:{source_uri}");
169 let connection = open_connection(path)?;
170 let topics = read_topics(&connection)?;
171 let topic = topics.into_iter().find(|topic| topic.name == topic_name).ok_or_else(|| {
172 Rosbag2Error::InvalidBag(format!("topic `{topic_name}` was not found"))
173 })?;
174
175 if topic.type_name != POINT_CLOUD2_TYPE {
176 return Err(Rosbag2Error::Unsupported(format!(
177 "topic `{}` has type `{}`, expected `{POINT_CLOUD2_TYPE}`",
178 topic.name, topic.type_name
179 )));
180 }
181 if !topic.serialization_format.eq_ignore_ascii_case("cdr") {
182 return Err(Rosbag2Error::Unsupported(format!(
183 "topic `{}` uses serialization `{}`, only CDR is supported",
184 topic.name, topic.serialization_format
185 )));
186 }
187
188 let has_intensity = first_message_has_intensity(&connection, topic.id)?;
189 let (schema_id, point_schema) = if has_intensity {
190 (ROSBAG2_POINT_XYZI_SCHEMA_ID, StandardSchemas::point_xyzi())
191 } else {
192 (ROSBAG2_POINT_XYZ_SCHEMA_ID, StandardSchemas::point_xyz())
193 };
194 let max_message_bytes = max_message_bytes(&connection, topic.id)?;
195 let schema = SchemaDescriptor::try_new(schema_id, SchemaVersion::new(1, 0), point_schema)?;
196 let max_record_bytes = point_capacity_bytes(options.chunk_points(), has_intensity)?;
197 let max_message_working_bytes = message_working_bytes(max_message_bytes)?;
198 let max_chunk_bytes = max_record_bytes
199 .checked_add(max_message_working_bytes)
200 .ok_or_else(|| Rosbag2Error::InvalidBag("maximum source memory overflow".into()))?;
201 if max_chunk_bytes > options.memory_budget().limit_bytes() {
202 return Err(Rosbag2Error::Records(RecordsError::MemoryBudgetExceeded {
203 requested: max_chunk_bytes,
204 current: 0,
205 limit: options.memory_budget().limit_bytes(),
206 }));
207 }
208 let budget = options.memory_budget();
209
210 Ok(Self {
211 connection,
212 source_id,
213 topic,
214 schema,
215 options,
216 tracker: MemoryTracker::new(budget),
217 cancellation,
218 max_chunk_bytes,
219 max_message_bytes,
220 has_intensity,
221 cursor: None,
222 pending: None,
223 next_sequence: 0,
224 next_point_offset: 0,
225 finished: false,
226 })
227 }
228
229 #[must_use]
231 pub fn topic(&self) -> &Rosbag2Topic {
232 &self.topic
233 }
234
235 #[must_use]
237 pub const fn max_message_bytes(&self) -> u64 {
238 self.max_message_bytes
239 }
240
241 fn next_message(&mut self) -> Rosbag2Result<Option<(MessageCursor, Vec<u8>)>> {
242 let row = match self.cursor {
243 None => self.connection.query_row(
244 "SELECT id, timestamp, data \
245 FROM messages WHERE topic_id = ?1 \
246 ORDER BY timestamp ASC, id ASC LIMIT 1",
247 params![self.topic.id],
248 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Vec<u8>>(2)?)),
249 ),
250 Some(cursor) => self.connection.query_row(
251 "SELECT id, timestamp, data \
252 FROM messages WHERE topic_id = ?1 \
253 AND (timestamp > ?2 OR (timestamp = ?2 AND id > ?3)) \
254 ORDER BY timestamp ASC, id ASC LIMIT 1",
255 params![self.topic.id, cursor.timestamp, cursor.id],
256 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Vec<u8>>(2)?)),
257 ),
258 }
259 .optional()?;
260
261 row.map(|(id, timestamp, data)| {
262 if timestamp < 0 {
263 return Err(Rosbag2Error::InvalidBag(format!(
264 "message {id} has a negative timestamp {timestamp}"
265 )));
266 }
267 Ok((MessageCursor { id, timestamp }, data))
268 })
269 .transpose()
270 }
271
272 fn load_pending(&mut self) -> Rosbag2Result<bool> {
273 loop {
274 let Some((cursor, data)) = self.next_message()? else {
275 return Ok(false);
276 };
277 self.cursor = Some(cursor);
278
279 let working_bytes = message_working_bytes(
280 u64::try_from(data.len())
281 .map_err(|_| Rosbag2Error::InvalidBag("CDR payload is too large".into()))?,
282 )?;
283 let mut reservation = self.tracker.try_reserve(working_bytes)?;
284 let message = decode_point_cloud2_xyz(&data)?;
285 drop(data);
286 if message.intensity.is_some() != self.has_intensity {
287 return Err(Rosbag2Error::InvalidBag(format!(
288 "PointCloud2 fields changed in topic `{}`",
289 self.topic.name
290 )));
291 }
292 let decoded_bytes = point_capacity_bytes(message.point_count(), self.has_intensity)?;
293 reservation.shrink_to(
294 decoded_bytes
295 .checked_add(CDR_SCRATCH_OVERHEAD_BYTES)
296 .ok_or_else(|| Rosbag2Error::InvalidBag("decoded memory overflow".into()))?,
297 )?;
298 if message.point_count() == 0 {
299 continue;
300 }
301
302 let timestamp = ros_timestamp_ns(&message)?;
303 self.pending = Some(PendingPointCloud {
304 message,
305 offset: 0,
306 timestamp,
307 _reservation: reservation,
308 });
309 return Ok(true);
310 }
311 }
312
313 fn build_chunk(
314 &self,
315 pending: &PendingPointCloud,
316 count: usize,
317 reservation: MemoryReservation,
318 ) -> Rosbag2Result<SpatialRecordChunk> {
319 let start = pending
320 .offset
321 .checked_mul(3)
322 .ok_or_else(|| Rosbag2Error::InvalidBag("point offset overflow".into()))?;
323 let end = start
324 .checked_add(
325 count
326 .checked_mul(3)
327 .ok_or_else(|| Rosbag2Error::InvalidBag("chunk point count overflow".into()))?,
328 )
329 .ok_or_else(|| Rosbag2Error::InvalidBag("chunk range overflow".into()))?;
330 let point_end = pending
331 .offset
332 .checked_add(count)
333 .ok_or_else(|| Rosbag2Error::InvalidBag("chunk point range overflow".into()))?;
334 let values =
335 pending.message.xyz.get(start..end).ok_or_else(|| {
336 Rosbag2Error::InvalidBag("decoded XYZ range is out of bounds".into())
337 })?;
338
339 let mut x = Vec::with_capacity(count);
340 let mut y = Vec::with_capacity(count);
341 let mut z = Vec::with_capacity(count);
342 for point in values.chunks_exact(3) {
343 x.push(point[0]);
344 y.push(point[1]);
345 z.push(point[2]);
346 }
347 let mut buffers = PointBufferSet::new();
348 buffers.insert("x", PointBuffer::from_f32(x));
349 buffers.insert("y", PointBuffer::from_f32(y));
350 buffers.insert("z", PointBuffer::from_f32(z));
351 if self.has_intensity {
352 let intensity = pending
353 .message
354 .intensity
355 .as_ref()
356 .and_then(|values| values.get(pending.offset..point_end))
357 .ok_or_else(|| {
358 Rosbag2Error::InvalidBag("decoded intensity range is out of bounds".into())
359 })?;
360 buffers.insert("intensity", PointBuffer::from_f32(intensity.to_vec()));
361 }
362 let cloud = PointCloud::try_from_parts(
363 if self.has_intensity {
364 StandardSchemas::point_xyzi()
365 } else {
366 StandardSchemas::point_xyz()
367 },
368 buffers,
369 SpatialMetadata::new(
370 pending.message.frame_id.as_str(),
371 Timestamp::from_nanos(pending.timestamp),
372 ),
373 )?;
374 let provenance = RecordProvenance::try_new(self.source_id.clone())
375 .map_err(Rosbag2Error::Records)?
376 .with_source_uri(
377 self.source_id.strip_prefix("rosbag2-sqlite:").unwrap_or(&self.source_id),
378 )
379 .with_stream_id(self.topic.name.clone())
380 .with_sequence(Some(self.next_sequence));
381 let record =
382 SpatialRecord::try_new_with_provenance(self.schema.clone(), cloud, provenance)?;
383 Ok(SpatialRecordChunk::try_from_reserved(
384 ChunkIdentity { sequence: self.next_sequence, point_offset: self.next_point_offset },
385 record,
386 reservation,
387 )?)
388 }
389
390 fn fail(&mut self, error: Rosbag2Error) -> Option<RecordsResult<SpatialRecordChunk>> {
391 self.pending.take();
392 self.finished = true;
393 Some(Err(match error {
394 Rosbag2Error::Records(error) => error,
395 other => RecordsError::InvalidChunk(other.to_string()),
396 }))
397 }
398}
399
400impl BoundedSpatialRecordSource for Rosbag2PointCloudSource {
401 fn schema(&self) -> &SchemaDescriptor {
402 &self.schema
403 }
404
405 fn options(&self) -> &StreamOptions {
406 &self.options
407 }
408
409 fn memory_tracker(&self) -> &MemoryTracker {
410 &self.tracker
411 }
412
413 fn cancellation_token(&self) -> CancellationToken {
414 self.cancellation.clone()
415 }
416
417 fn max_chunk_bytes(&self) -> u64 {
418 self.max_chunk_bytes
419 }
420
421 fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
422 if self.finished {
423 return None;
424 }
425 if let Err(error) = self.cancellation.check() {
426 return self.fail(Rosbag2Error::Records(error));
427 }
428
429 if self.pending.is_none() {
430 match self.load_pending() {
431 Ok(true) => {}
432 Ok(false) => {
433 self.finished = true;
434 return None;
435 }
436 Err(error) => return self.fail(error),
437 }
438 }
439
440 let mut pending = self.pending.take().expect("pending message was loaded");
441 let remaining = pending.message.point_count().saturating_sub(pending.offset);
442 let count = remaining.min(self.options.chunk_points());
443 if count == 0 {
444 drop(pending);
445 return self.next_chunk();
446 }
447
448 let reservation =
449 match self.tracker.try_reserve(match point_capacity_bytes(count, self.has_intensity) {
450 Ok(bytes) => bytes,
451 Err(error) => return self.fail(error),
452 }) {
453 Ok(reservation) => reservation,
454 Err(error) => {
455 self.pending = Some(pending);
456 return self.fail(Rosbag2Error::Records(error));
457 }
458 };
459 let next_offset = pending.offset + count;
460 let chunk = match self.build_chunk(&pending, count, reservation) {
461 Ok(chunk) => chunk,
462 Err(error) => {
463 self.pending = Some(pending);
464 return self.fail(error);
465 }
466 };
467 pending.offset = next_offset;
468 if next_offset < pending.message.point_count() {
469 self.pending = Some(pending);
470 }
471 self.next_sequence = match self.next_sequence.checked_add(1) {
472 Some(value) => value,
473 None => {
474 return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
475 "rosbag2 chunk sequence".into(),
476 )))
477 }
478 };
479 let count = match u64::try_from(count) {
480 Ok(count) => count,
481 Err(_) => {
482 return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
483 "rosbag2 point count".into(),
484 )))
485 }
486 };
487 self.next_point_offset = match self.next_point_offset.checked_add(count) {
488 Some(value) => value,
489 None => {
490 return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
491 "rosbag2 point offset".into(),
492 )))
493 }
494 };
495 Some(Ok(chunk))
496 }
497}
498
499#[derive(Clone, Copy, Debug)]
500struct MessageCursor {
501 id: i64,
502 timestamp: i64,
503}
504
505struct PendingPointCloud {
506 message: PointCloud2Xyz,
507 offset: usize,
508 timestamp: u64,
509 _reservation: MemoryReservation,
510}
511
512fn open_connection(path: impl AsRef<Path>) -> Rosbag2Result<Connection> {
513 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
514 ensure_schema(&connection)?;
515 Ok(connection)
516}
517
518fn ensure_schema(connection: &Connection) -> Rosbag2Result<()> {
519 for table in ["topics", "messages"] {
520 let present: i64 = connection.query_row(
521 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
522 params![table],
523 |row| row.get(0),
524 )?;
525 if present != 1 {
526 return Err(Rosbag2Error::InvalidBag(format!(
527 "required rosbag2 table `{table}` is missing"
528 )));
529 }
530 }
531 Ok(())
532}
533
534fn read_topics(connection: &Connection) -> Rosbag2Result<Vec<Rosbag2Topic>> {
535 let mut statement = connection.prepare(
536 "SELECT t.id, t.name, t.\"type\", t.serialization_format, COUNT(m.id) \
537 FROM topics AS t LEFT JOIN messages AS m ON m.topic_id = t.id \
538 GROUP BY t.id, t.name, t.\"type\", t.serialization_format \
539 ORDER BY t.id",
540 )?;
541 let rows = statement.query_map([], |row| {
542 let message_count: i64 = row.get(4)?;
543 Ok(Rosbag2Topic {
544 id: row.get(0)?,
545 name: row.get(1)?,
546 type_name: row.get(2)?,
547 serialization_format: row.get(3)?,
548 message_count: u64::try_from(message_count).unwrap_or(0),
549 })
550 })?;
551 rows.collect::<Result<Vec<_>, _>>().map_err(Rosbag2Error::from)
552}
553
554fn max_message_bytes(connection: &Connection, topic_id: i64) -> Rosbag2Result<u64> {
555 let value: Option<i64> = connection.query_row(
556 "SELECT MAX(length(data)) FROM messages WHERE topic_id = ?1",
557 params![topic_id],
558 |row| row.get(0),
559 )?;
560 value
561 .unwrap_or(0)
562 .try_into()
563 .map_err(|_| Rosbag2Error::InvalidBag("negative message length".into()))
564}
565
566fn first_message_has_intensity(connection: &Connection, topic_id: i64) -> Rosbag2Result<bool> {
567 let data: Option<Vec<u8>> = connection
568 .query_row(
569 "SELECT data FROM messages WHERE topic_id = ?1 \
570 ORDER BY timestamp ASC, id ASC LIMIT 1",
571 params![topic_id],
572 |row| row.get(0),
573 )
574 .optional()?;
575 let Some(data) = data else {
576 return Ok(false);
577 };
578 Ok(point_cloud2_has_intensity(&data)?)
579}
580
581fn message_working_bytes(raw_bytes: u64) -> Rosbag2Result<u64> {
582 raw_bytes
583 .checked_mul(2)
584 .and_then(|bytes| bytes.checked_add(CDR_SCRATCH_OVERHEAD_BYTES))
585 .ok_or_else(|| Rosbag2Error::InvalidBag("message working-set overflow".into()))
586}
587
588fn point_capacity_bytes(point_count: usize, has_intensity: bool) -> Rosbag2Result<u64> {
589 u64::try_from(point_count)
590 .ok()
591 .and_then(|count| count.checked_mul(if has_intensity { 16 } else { 12 }))
592 .ok_or_else(|| Rosbag2Error::InvalidBag("point-column capacity overflow".into()))
593}
594
595fn ros_timestamp_ns(message: &PointCloud2Xyz) -> Rosbag2Result<u64> {
596 if message.stamp_sec < 0 || message.stamp_nanosec >= 1_000_000_000 {
597 return Err(Rosbag2Error::InvalidBag(format!(
598 "invalid PointCloud2 header timestamp {}.{:09}",
599 message.stamp_sec, message.stamp_nanosec
600 )));
601 }
602 u64::try_from(message.stamp_sec)
603 .ok()
604 .and_then(|seconds| seconds.checked_mul(1_000_000_000))
605 .and_then(|seconds| seconds.checked_add(u64::from(message.stamp_nanosec)))
606 .ok_or_else(|| Rosbag2Error::InvalidBag("PointCloud2 timestamp overflow".into()))
607}
608
609#[cfg(test)]
610mod tests {
611 use super::{
612 list_tf_messages, list_topics, Rosbag2PointCloudSource, ROSBAG2_POINT_XYZI_SCHEMA_ID,
613 ROSBAG2_POINT_XYZ_SCHEMA_ID,
614 };
615 use rusqlite::{params, Connection};
616 use spatialrust_core::PointBuffer;
617 use spatialrust_records::{
618 BoundedSpatialRecordSource, CancellationToken, MemoryBudget, StreamOptions,
619 };
620 use spatialrust_runtime::{
621 encode_point_cloud2_xyz, PointCloud2Xyz, TfTransform, POINT_CLOUD2_TYPE, TF_MESSAGE_TYPE,
622 };
623
624 fn align_from(bytes: &mut Vec<u8>, alignment: usize, origin: usize) {
625 let relative = bytes.len().saturating_sub(origin);
626 let remainder = relative % alignment;
627 if remainder != 0 {
628 bytes.resize(bytes.len() + alignment - remainder, 0);
629 }
630 }
631
632 fn write_string(bytes: &mut Vec<u8>, value: &str) {
633 while bytes.len() % 4 != 0 {
634 bytes.push(0);
635 }
636 bytes.extend_from_slice(&u32::try_from(value.len() + 1).unwrap().to_le_bytes());
637 bytes.extend_from_slice(value.as_bytes());
638 bytes.push(0);
639 }
640
641 fn encode_tf_message(transform: &TfTransform) -> Vec<u8> {
642 let mut bytes = vec![0x00, 0x01, 0x00, 0x00];
643 bytes.extend_from_slice(&1_u32.to_le_bytes());
644 bytes.extend_from_slice(&transform.stamp_sec.to_le_bytes());
645 bytes.extend_from_slice(&transform.stamp_nanosec.to_le_bytes());
646 write_string(&mut bytes, &transform.frame_id);
647 write_string(&mut bytes, &transform.child_frame_id);
648 align_from(&mut bytes, 8, 4);
649 for value in transform.translation.into_iter().chain(transform.rotation_xyzw) {
650 bytes.extend_from_slice(&value.to_le_bytes());
651 }
652 bytes
653 }
654
655 fn bag_file() -> (tempfile::TempDir, std::path::PathBuf) {
656 let directory = tempfile::tempdir().unwrap();
657 let path = directory.path().join("sample.db3");
658 let connection = Connection::open(&path).unwrap();
659 connection
660 .execute_batch(
661 "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
662 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
663 )
664 .unwrap();
665 connection
666 .execute(
667 "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
668 params!["/lidar/points", POINT_CLOUD2_TYPE],
669 )
670 .unwrap();
671 for (id, stamp, offset) in [(1_i64, 20_i64, 0.0_f32), (2, 10, 10.0)] {
672 let message = PointCloud2Xyz::try_new(
673 "lidar",
674 7,
675 id as u32,
676 vec![offset, 1.0, 2.0, offset + 1.0, 3.0, 4.0, offset + 2.0, 5.0, 6.0],
677 )
678 .unwrap();
679 connection
680 .execute(
681 "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(?1,1,?2,?3)",
682 params![id, stamp, encode_point_cloud2_xyz(&message).unwrap()],
683 )
684 .unwrap();
685 }
686 drop(connection);
687 (directory, path)
688 }
689
690 #[test]
691 fn lists_topics_and_counts_messages() {
692 let (_directory, path) = bag_file();
693 let topics = list_topics(path).unwrap();
694 assert_eq!(topics.len(), 1);
695 assert_eq!(topics[0].name, "/lidar/points");
696 assert_eq!(topics[0].message_count, 2);
697 }
698
699 #[test]
700 fn lists_and_decodes_bounded_tf_messages() {
701 let directory = tempfile::tempdir().unwrap();
702 let path = directory.path().join("tf.db3");
703 let connection = Connection::open(&path).unwrap();
704 connection
705 .execute_batch(
706 "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
707 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
708 )
709 .unwrap();
710 connection
711 .execute(
712 "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
713 params!["/tf_static", TF_MESSAGE_TYPE],
714 )
715 .unwrap();
716 let transform = TfTransform {
717 stamp_sec: 12,
718 stamp_nanosec: 34,
719 frame_id: "base_link".into(),
720 child_frame_id: "lidar_front".into(),
721 translation: [1.0, -2.0, 3.5],
722 rotation_xyzw: [0.0, 0.0, 0.707, 0.707],
723 };
724 connection
725 .execute(
726 "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(1,1,42,?1)",
727 params![encode_tf_message(&transform)],
728 )
729 .unwrap();
730 drop(connection);
731
732 let messages = list_tf_messages(&path, "/tf_static", 1).unwrap();
733 assert_eq!(messages.len(), 1);
734 assert_eq!(messages[0].bag_timestamp, 42);
735 assert_eq!(messages[0].transforms, vec![transform]);
736 assert!(list_tf_messages(&path, "/tf_static", 0).is_err());
737 }
738
739 #[test]
740 fn source_orders_messages_and_splits_chunks() {
741 let (_directory, path) = bag_file();
742 let source_uri = path.display().to_string();
743 let options = StreamOptions::new(2, MemoryBudget::new(1024 * 1024).unwrap()).unwrap();
744 let mut source = Rosbag2PointCloudSource::open(
745 path,
746 "/lidar/points",
747 options,
748 CancellationToken::default(),
749 )
750 .unwrap();
751 assert_eq!(source.schema().id.as_str(), ROSBAG2_POINT_XYZ_SCHEMA_ID);
752
753 let first = source.next_chunk().unwrap().unwrap();
754 assert_eq!(first.identity().sequence, 0);
755 assert_eq!(first.identity().point_offset, 0);
756 assert_eq!(first.record().metadata().timestamp.as_nanos(), 7_000_000_002);
757 assert_eq!(first.record().metadata().frame_id.0, "lidar");
758 assert_eq!(first.record().provenance().source_id, format!("rosbag2-sqlite:{source_uri}"));
759 assert_eq!(first.record().provenance().source_uri.as_deref(), Some(source_uri.as_str()));
760 assert_eq!(first.record().provenance().stream_id.as_deref(), Some("/lidar/points"));
761 assert_eq!(first.record().provenance().sequence, Some(0));
762 assert_eq!(first.record().cloud().len(), 2);
763 assert_eq!(
764 first.record().cloud().field("x").unwrap(),
765 &PointBuffer::from_f32(vec![10.0, 11.0])
766 );
767 drop(first);
768
769 let second = source.next_chunk().unwrap().unwrap();
770 assert_eq!(second.identity().sequence, 1);
771 assert_eq!(second.identity().point_offset, 2);
772 assert_eq!(second.record().cloud().field("x").unwrap(), &PointBuffer::from_f32(vec![12.0]));
773 drop(second);
774
775 let third = source.next_chunk().unwrap().unwrap();
776 assert_eq!(third.identity().sequence, 2);
777 assert_eq!(third.identity().point_offset, 3);
778 assert_eq!(third.record().metadata().timestamp.as_nanos(), 7_000_000_001);
779 assert_eq!(
780 third.record().cloud().field("x").unwrap(),
781 &PointBuffer::from_f32(vec![0.0, 1.0])
782 );
783 drop(third);
784
785 let fourth = source.next_chunk().unwrap().unwrap();
786 assert_eq!(fourth.identity().sequence, 3);
787 assert_eq!(fourth.identity().point_offset, 5);
788 drop(fourth);
789 assert!(source.next_chunk().is_none());
790 assert_eq!(source.memory_tracker().snapshot().current_bytes, 0);
791 }
792
793 fn bag_file_with_intensity() -> (tempfile::TempDir, std::path::PathBuf) {
794 let directory = tempfile::tempdir().unwrap();
795 let path = directory.path().join("sample-intensity.db3");
796 let connection = Connection::open(&path).unwrap();
797 connection
798 .execute_batch(
799 "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
800 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
801 )
802 .unwrap();
803 connection
804 .execute(
805 "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
806 params!["/lidar/points", POINT_CLOUD2_TYPE],
807 )
808 .unwrap();
809 for (id, stamp, offset) in [(1_i64, 20_i64, 0.0_f32), (2, 10, 10.0)] {
810 let message = PointCloud2Xyz::try_new_with_intensity(
811 "lidar",
812 7,
813 id as u32,
814 vec![offset, 1.0, 2.0, offset + 1.0, 3.0, 4.0, offset + 2.0, 5.0, 6.0],
815 vec![100.0 + offset, 101.0 + offset, 102.0 + offset],
816 )
817 .unwrap();
818 connection
819 .execute(
820 "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(?1,1,?2,?3)",
821 params![id, stamp, encode_point_cloud2_xyz(&message).unwrap()],
822 )
823 .unwrap();
824 }
825 drop(connection);
826 (directory, path)
827 }
828
829 #[test]
830 fn source_preserves_intensity_and_selects_xyzi_schema() {
831 let (_directory, path) = bag_file_with_intensity();
832 let options = StreamOptions::new(2, MemoryBudget::new(1024 * 1024).unwrap()).unwrap();
833 let mut source = Rosbag2PointCloudSource::open(
834 path,
835 "/lidar/points",
836 options,
837 CancellationToken::default(),
838 )
839 .unwrap();
840 assert_eq!(source.schema().id.as_str(), ROSBAG2_POINT_XYZI_SCHEMA_ID);
841
842 let first = source.next_chunk().unwrap().unwrap();
843 assert_eq!(
844 first.record().cloud().field("intensity").unwrap(),
845 &PointBuffer::from_f32(vec![110.0, 111.0])
846 );
847 drop(first);
848 let second = source.next_chunk().unwrap().unwrap();
849 assert_eq!(
850 second.record().cloud().field("intensity").unwrap(),
851 &PointBuffer::from_f32(vec![112.0])
852 );
853 drop(second);
854 assert!(source.next_chunk().is_some());
855 }
856}