Skip to main content

eventcv_core/io/
bag.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::io::{BufReader, BufWriter, Write};
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6
7use rosbag::{ChunkRecord, IndexRecord, MessageRecord, RosBag};
8
9use super::{IoError, LoadOptions, SliceSource};
10use crate::representation::{EventFrame, EventFrameData};
11use crate::{EventStream, EventStreamBuilder};
12
13const DEFAULT_TOPIC: &str = "/davis/left/events";
14const EVENT_ARRAY_TYPE: &str = "dvs_msgs/EventArray";
15/// `dvs_msgs/EventArray` md5sum (32 lowercase hex). The reader matches on topic + type,
16/// not this value, but the rosbag parser requires a well-formed md5sum on every connection.
17const EVENT_ARRAY_MD5: &str = "5e8beee5759d85e9f5a8c1f5ad1cd00b";
18/// Message definition stored on the connection (informational; any non-empty text parses).
19const EVENT_ARRAY_DEF: &str =
20    "std_msgs/Header header\nuint32 height\nuint32 width\ndvs_msgs/Event[] events\n";
21/// Events per `dvs_msgs/EventArray` message — batches keep individual message buffers bounded.
22const MESSAGE_EVENTS: usize = 1_000_000;
23/// ROS bag 2.0 magic line.
24const BAG_MAGIC: &[u8] = b"#ROSBAG V2.0\n";
25
26/// Reads a ROS1 bag, decoding `dvs_msgs/EventArray` messages on a single topic
27/// (`options.topic`, default `/davis/left/events`). Sensor size comes from the
28/// messages unless `options.sensor_size` overrides it; timestamps become microseconds.
29pub fn read_bag(path: impl AsRef<Path>, options: &LoadOptions) -> Result<EventStream, IoError> {
30    let topic = options.topic.as_deref().unwrap_or(DEFAULT_TOPIC);
31    let bag = RosBag::new(path).map_err(IoError::Io)?;
32
33    let mut wanted: HashMap<u32, bool> = HashMap::new();
34    let mut builder: Option<EventStreamBuilder> = None;
35
36    'outer: for record in bag.chunk_records() {
37        let ChunkRecord::Chunk(chunk) = record.map_err(map_bag_error)? else {
38            continue;
39        };
40        for message in chunk.messages() {
41            match message.map_err(map_bag_error)? {
42                MessageRecord::Connection(connection) => {
43                    let matches = connection.topic == topic && connection.tp == EVENT_ARRAY_TYPE;
44                    wanted.insert(connection.id, matches);
45                }
46                MessageRecord::MessageData(data) => {
47                    if !wanted.get(&data.conn_id).copied().unwrap_or(false) {
48                        continue;
49                    }
50                    let (width, height) = match options.sensor_size {
51                        Some(size) => size,
52                        None => read_event_array_header(data.data)?,
53                    };
54                    let builder = builder
55                        .get_or_insert_with(|| EventStreamBuilder::new(width, height, 0.001));
56                    let stopped = decode_event_array(data.data, &mut |x, y, t, p| {
57                        builder.push(x, y, t, p);
58                        options.max_events.is_some_and(|max| builder.len() >= max)
59                    })?;
60                    if stopped {
61                        break 'outer;
62                    }
63                }
64            }
65        }
66    }
67
68    builder.map(EventStreamBuilder::build).ok_or_else(|| {
69        IoError::Format(format!(
70            "no {EVENT_ARRAY_TYPE} messages found on topic {topic}"
71        ))
72    })
73}
74
75/// Reads the `(width, height)` from a `dvs_msgs/EventArray` header (the fields precede
76/// the events), without decoding the events.
77fn read_event_array_header(bytes: &[u8]) -> Result<(usize, usize), IoError> {
78    let mut reader = ByteReader::new(bytes);
79    reader.skip(4 + 8)?; // Header: seq (u32) + stamp (sec u32, nsec u32)
80    let frame_id_len = reader.u32()? as usize;
81    reader.skip(frame_id_len)?; // Header: frame_id
82    let height = reader.u32()? as usize;
83    let width = reader.u32()? as usize;
84    Ok((width, height))
85}
86
87/// Decodes one serialized `dvs_msgs/EventArray`, calling `on_event(x, y, t_us, polarity)`
88/// per event. Stops early (returning `Ok(true)`) once `on_event` returns `true`. The
89/// single decode path shared by `read_bag` (eager), time slicing, and the count pass.
90fn decode_event_array(
91    bytes: &[u8],
92    on_event: &mut dyn FnMut(u16, u16, i64, bool) -> bool,
93) -> Result<bool, IoError> {
94    let mut reader = ByteReader::new(bytes);
95    reader.skip(4 + 8)?;
96    let frame_id_len = reader.u32()? as usize;
97    reader.skip(frame_id_len)?;
98    reader.skip(4 + 4)?; // height + width (read by `read_event_array_header`)
99    let count = reader.u32()? as usize;
100    for _ in 0..count {
101        let x = reader.u16()?;
102        let y = reader.u16()?;
103        let seconds = i64::from(reader.u32()?);
104        let nanoseconds = i64::from(reader.u32()?);
105        let polarity = reader.u8()? != 0;
106        if on_event(x, y, seconds * 1_000_000 + nanoseconds / 1000, polarity) {
107            return Ok(true);
108        }
109    }
110    Ok(false)
111}
112
113/// Writes a stream as a ROS1 bag containing one connection (`topic`, default
114/// `/davis/left/events`) and a single chunk of `dvs_msgs/EventArray` messages, round-tripping
115/// through [`read_bag`] and [`open_bag_slice`]. Events are batched into messages and the
116/// timestamps split into ROS `sec`/`nsec` (so microseconds are preserved exactly). The whole
117/// chunk is buffered, so very large streams use proportional memory.
118pub fn write_bag(
119    path: impl AsRef<Path>,
120    stream: &EventStream,
121    topic: Option<&str>,
122) -> Result<(), IoError> {
123    let topic = topic.unwrap_or(DEFAULT_TOPIC);
124    let conn_id: u32 = 0;
125    let ts = stream.ts();
126
127    // Build the chunk payload: one connection record, then the batched message records.
128    // `index_entries` records each message's (start time, byte offset within the payload).
129    let mut payload: Vec<u8> =
130        make_record(&connection_fields(conn_id, topic), &connection_data(topic));
131    let mut index_entries: Vec<(i64, u32)> = Vec::new();
132    let mut start = 0;
133    while start < stream.len() {
134        let end = (start + MESSAGE_EVENTS).min(stream.len());
135        let offset = payload.len() as u32;
136        let message = serialize_event_array(stream, start, end);
137        write_record(&mut payload, &message_fields(conn_id, ts[start]), &message)
138            .expect("writing to a Vec never fails");
139        index_entries.push((ts[start], offset));
140        start = end;
141    }
142    if stream.is_empty() {
143        // Emit one empty message so `read_bag` finds a connection + data and rebuilds an
144        // empty stream (with the correct sensor size from the message header).
145        let offset = payload.len() as u32;
146        let message = serialize_event_array(stream, 0, 0);
147        write_record(&mut payload, &message_fields(conn_id, 0), &message)
148            .expect("writing to a Vec never fails");
149        index_entries.push((0, offset));
150    }
151    let message_count = index_entries.len() as u32;
152    let (start_us, end_us) = match (ts.iter().min(), ts.iter().max()) {
153        (Some(&lo), Some(&hi)) => (lo, hi),
154        _ => (0, 0),
155    };
156
157    // The bag header has a fixed length (its fields are fixed-size), so the chunk position is
158    // known before the index position is, and the header can be built once index_pos is set.
159    let bag_header_len = make_record(&bag_header_fields(0, 1, 1), &[]).len();
160    let chunk_pos = BAG_MAGIC.len() + bag_header_len;
161    let chunk_fields = chunk_fields(payload.len() as u32);
162    let mut index_data: Vec<u8> = Vec::with_capacity(index_entries.len() * 12);
163    for (time_us, offset) in &index_entries {
164        index_data.extend_from_slice(&ros_time(*time_us));
165        index_data.extend_from_slice(&offset.to_le_bytes());
166    }
167    let index_record = make_record(&index_data_fields(conn_id, message_count), &index_data);
168    let index_pos = chunk_pos + record_len(&chunk_fields, payload.len()) + index_record.len();
169
170    let mut writer = BufWriter::new(File::create(path).map_err(IoError::Io)?);
171    writer.write_all(BAG_MAGIC).map_err(IoError::Io)?;
172    write_record(&mut writer, &bag_header_fields(index_pos as u64, 1, 1), &[])
173        .map_err(IoError::Io)?;
174    write_record(&mut writer, &chunk_fields, &payload).map_err(IoError::Io)?;
175    writer.write_all(&index_record).map_err(IoError::Io)?;
176    // Footer (read by `index_records`): the connection, then the chunk info.
177    write_record(
178        &mut writer,
179        &connection_fields(conn_id, topic),
180        &connection_data(topic),
181    )
182    .map_err(IoError::Io)?;
183    let chunk_info_data = {
184        let mut data = Vec::with_capacity(8);
185        data.extend_from_slice(&conn_id.to_le_bytes());
186        data.extend_from_slice(&message_count.to_le_bytes());
187        data
188    };
189    write_record(
190        &mut writer,
191        &chunk_info_fields(chunk_pos as u64, start_us, end_us),
192        &chunk_info_data,
193    )
194    .map_err(IoError::Io)?;
195    writer.flush().map_err(IoError::Io)
196}
197
198/// Writes a ROS1 bag a window at a time — the streaming form of [`write_bag`].
199///
200/// A bag chunk states its uncompressed size in a header that precedes the payload, and the bag
201/// header states where the index sits, so neither can be written until the recording is closed.
202/// The chunk payload is therefore spilled to a scratch file beside the target as it is appended and
203/// copied into place at [`finish`](super::EventSink::finish); only the index entries — twelve bytes
204/// per million-event message — stay in memory.
205///
206/// The result is byte-for-byte what [`write_bag`] would have produced from the concatenated
207/// windows, so a recording made this way is indistinguishable from one saved in a single call.
208pub struct BagEventSink {
209    path: PathBuf,
210    scratch: PathBuf,
211    /// An `Option` so the handle can be closed *before* the scratch file is removed: unlinking a
212    /// file that is still open is fine on Unix and fails on Windows.
213    payload: Option<BufWriter<File>>,
214    payload_len: u64,
215    topic: String,
216    /// `(start time, byte offset within the payload)` for every message written.
217    index_entries: Vec<(i64, u32)>,
218    /// Sensor size and time span, taken from the appended windows.
219    sensor_size: Option<(usize, usize)>,
220    span: Option<(i64, i64)>,
221    n_events: usize,
222}
223
224/// The one connection every bag eventcv writes carries.
225const CONN_ID: u32 = 0;
226
227impl BagEventSink {
228    pub fn create(path: impl AsRef<Path>, topic: Option<&str>) -> Result<Self, IoError> {
229        let path = path.as_ref().to_path_buf();
230        let scratch = path.with_extension("chunk.part");
231        let topic = topic.unwrap_or(DEFAULT_TOPIC).to_owned();
232        let mut payload = BufWriter::new(File::create(&scratch).map_err(IoError::Io)?);
233        // The connection record opens the chunk, exactly as in `write_bag`.
234        let connection = make_record(&connection_fields(CONN_ID, &topic), &connection_data(&topic));
235        payload.write_all(&connection).map_err(IoError::Io)?;
236        Ok(Self {
237            path,
238            scratch,
239            payload: Some(payload),
240            payload_len: connection.len() as u64,
241            topic,
242            index_entries: Vec::new(),
243            sensor_size: None,
244            span: None,
245            n_events: 0,
246        })
247    }
248
249    /// The open payload writer, or an error if the sink has already been finished.
250    fn payload(&mut self) -> Result<&mut BufWriter<File>, IoError> {
251        self.payload.as_mut().ok_or_else(|| {
252            IoError::Io(std::io::Error::other("this rosbag sink has already been finished"))
253        })
254    }
255
256    /// Writes one `dvs_msgs/EventArray` message covering `[start, end)` and indexes it.
257    fn write_message(
258        &mut self,
259        stream: &EventStream,
260        start: usize,
261        end: usize,
262    ) -> Result<(), IoError> {
263        let stamp = stream.ts().get(start).copied().unwrap_or(0);
264        let offset = u32::try_from(self.payload_len).map_err(|_| {
265            IoError::Unsupported(
266                "a ROS bag chunk is limited to 4 GB by its 32-bit size field; split the recording \
267                 across files, or record to .h5/.aedat4 which have no such ceiling"
268                    .to_owned(),
269            )
270        })?;
271        let message = serialize_event_array(stream, start, end);
272        let record = make_record(&message_fields(CONN_ID, stamp), &message);
273        self.payload()?.write_all(&record).map_err(IoError::Io)?;
274        self.payload_len += record.len() as u64;
275        self.index_entries.push((stamp, offset));
276        Ok(())
277    }
278}
279
280impl Drop for BagEventSink {
281    fn drop(&mut self) {
282        // Closed before it is removed, for the same reason `finish` closes it: Windows refuses to
283        // unlink an open file, and a sink dropped without `finish` would leave the scratch behind.
284        drop(self.payload.take());
285        let _ = std::fs::remove_file(&self.scratch);
286    }
287}
288
289impl super::EventSink for BagEventSink {
290    fn append(&mut self, stream: &EventStream) -> Result<(), IoError> {
291        if stream.is_empty() {
292            return Ok(());
293        }
294        self.sensor_size.get_or_insert_with(|| stream.sensor_size());
295        let ts = stream.ts();
296        if let (Some(&lo), Some(&hi)) = (ts.iter().min(), ts.iter().max()) {
297            self.span = Some(match self.span {
298                Some((start, end)) => (start.min(lo), end.max(hi)),
299                None => (lo, hi),
300            });
301        }
302        // Messages are capped at `MESSAGE_EVENTS` here for the same reason `write_bag` caps them:
303        // a reader deserialises a whole message at once.
304        let mut start = 0;
305        while start < stream.len() {
306            let end = (start + MESSAGE_EVENTS).min(stream.len());
307            self.write_message(stream, start, end)?;
308            start = end;
309        }
310        self.n_events += stream.len();
311        Ok(())
312    }
313
314    fn n_events(&self) -> usize {
315        self.n_events
316    }
317
318    fn flush(&mut self) -> Result<(), IoError> {
319        self.payload()?.flush().map_err(IoError::Io)
320    }
321
322    fn finish(mut self: Box<Self>) -> Result<(), IoError> {
323        if self.index_entries.is_empty() {
324            // Same as `write_bag`: one empty message so the reader finds a connection and data,
325            // and rebuilds an empty stream with the right sensor size.
326            let (width, height) = self.sensor_size.unwrap_or((1, 1));
327            let empty = EventStreamBuilder::new(width, height, 0.001).build();
328            self.write_message(&empty, 0, 0)?;
329        }
330        // Closed rather than merely flushed: the scratch file is about to be read back into the
331        // bag, and Windows will not let that happen through a second handle while this one is open.
332        if let Some(mut payload) = self.payload.take() {
333            payload.flush().map_err(IoError::Io)?;
334        }
335        let payload_len = u32::try_from(self.payload_len).map_err(|_| {
336            IoError::Unsupported(
337                "a ROS bag chunk is limited to 4 GB by its 32-bit size field; record to \
338                 .h5/.aedat4 instead"
339                    .to_owned(),
340            )
341        })?;
342
343        let message_count = self.index_entries.len() as u32;
344        let (start_us, end_us) = self.span.unwrap_or((0, 0));
345        let bag_header_len = make_record(&bag_header_fields(0, 1, 1), &[]).len();
346        let chunk_pos = BAG_MAGIC.len() + bag_header_len;
347        let chunk_fields = chunk_fields(payload_len);
348        let mut index_data: Vec<u8> = Vec::with_capacity(self.index_entries.len() * 12);
349        for (time_us, offset) in &self.index_entries {
350            index_data.extend_from_slice(&ros_time(*time_us));
351            index_data.extend_from_slice(&offset.to_le_bytes());
352        }
353        let index_record = make_record(&index_data_fields(CONN_ID, message_count), &index_data);
354        let index_pos =
355            chunk_pos + record_len(&chunk_fields, payload_len as usize) + index_record.len();
356
357        let mut writer = BufWriter::new(File::create(&self.path).map_err(IoError::Io)?);
358        writer.write_all(BAG_MAGIC).map_err(IoError::Io)?;
359        write_record(&mut writer, &bag_header_fields(index_pos as u64, 1, 1), &[])
360            .map_err(IoError::Io)?;
361        // The chunk record is framed by hand rather than through `write_record`, because its data
362        // is the scratch file rather than a slice in memory — the whole point of this sink. The
363        // framing is `write_record`'s: header length, the fields, data length, then the data.
364        let header_len: usize = chunk_fields.iter().map(Vec::len).sum();
365        writer
366            .write_all(&(header_len as u32).to_le_bytes())
367            .map_err(IoError::Io)?;
368        for field in &chunk_fields {
369            writer.write_all(field).map_err(IoError::Io)?;
370        }
371        writer
372            .write_all(&payload_len.to_le_bytes())
373            .map_err(IoError::Io)?;
374        let mut scratch = BufReader::new(File::open(&self.scratch).map_err(IoError::Io)?);
375        std::io::copy(&mut scratch, &mut writer).map_err(IoError::Io)?;
376
377        writer.write_all(&index_record).map_err(IoError::Io)?;
378        // Footer (read by `index_records`): the connection, then the chunk info.
379        write_record(
380            &mut writer,
381            &connection_fields(CONN_ID, &self.topic),
382            &connection_data(&self.topic),
383        )
384        .map_err(IoError::Io)?;
385        let mut chunk_info_data = Vec::with_capacity(8);
386        chunk_info_data.extend_from_slice(&CONN_ID.to_le_bytes());
387        chunk_info_data.extend_from_slice(&message_count.to_le_bytes());
388        write_record(
389            &mut writer,
390            &chunk_info_fields(chunk_pos as u64, start_us, end_us),
391            &chunk_info_data,
392        )
393        .map_err(IoError::Io)?;
394        writer.flush().map_err(IoError::Io)
395    }
396}
397
398/// Splits a microsecond timestamp into the ROS `sec`/`nsec` pair (8 bytes, little-endian) the
399/// reader recombines into nanoseconds — exact for microsecond data. Negative times clamp to 0.
400fn ros_time(t_us: i64) -> [u8; 8] {
401    let t_us = t_us.max(0);
402    let seconds = (t_us / 1_000_000) as u32;
403    let nanoseconds = ((t_us % 1_000_000) * 1000) as u32;
404    let mut bytes = [0u8; 8];
405    bytes[..4].copy_from_slice(&seconds.to_le_bytes());
406    bytes[4..].copy_from_slice(&nanoseconds.to_le_bytes());
407    bytes
408}
409
410/// Serializes events `[start, end)` as a `dvs_msgs/EventArray` body matching [`decode_event_array`].
411fn serialize_event_array(stream: &EventStream, start: usize, end: usize) -> Vec<u8> {
412    let (width, height) = stream.sensor_size();
413    let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
414    let stamp = ts.get(start).copied().unwrap_or(0);
415    let mut message = Vec::with_capacity(24 + (end - start) * 13);
416    message.extend_from_slice(&0u32.to_le_bytes()); // header.seq
417    message.extend_from_slice(&ros_time(stamp)); // header.stamp
418    message.extend_from_slice(&0u32.to_le_bytes()); // header.frame_id (empty string)
419    message.extend_from_slice(&(height as u32).to_le_bytes());
420    message.extend_from_slice(&(width as u32).to_le_bytes());
421    message.extend_from_slice(&((end - start) as u32).to_le_bytes());
422    for index in start..end {
423        message.extend_from_slice(&xs[index].to_le_bytes());
424        message.extend_from_slice(&ys[index].to_le_bytes());
425        message.extend_from_slice(&ros_time(ts[index]));
426        message.push(u8::from(ps[index]));
427    }
428    message
429}
430
431/// One `<len:u32><name=value>` record-header field.
432fn field(name: &str, value: &[u8]) -> Vec<u8> {
433    let mut bytes = Vec::with_capacity(4 + name.len() + 1 + value.len());
434    let body_len = (name.len() + 1 + value.len()) as u32;
435    bytes.extend_from_slice(&body_len.to_le_bytes());
436    bytes.extend_from_slice(name.as_bytes());
437    bytes.push(b'=');
438    bytes.extend_from_slice(value);
439    bytes
440}
441
442/// Byte length of a record: `<header_len:u32><header><data_len:u32><data>`.
443fn record_len(fields: &[Vec<u8>], data_len: usize) -> usize {
444    4 + fields.iter().map(Vec::len).sum::<usize>() + 4 + data_len
445}
446
447/// Builds a complete record in memory (for the small records whose bytes/length are reused).
448fn make_record(fields: &[Vec<u8>], data: &[u8]) -> Vec<u8> {
449    let mut record = Vec::with_capacity(record_len(fields, data.len()));
450    write_record(&mut record, fields, data).expect("writing to a Vec never fails");
451    record
452}
453
454/// Streams one record to `writer` (used for the large chunk record to avoid copying the payload).
455fn write_record<W: Write>(writer: &mut W, fields: &[Vec<u8>], data: &[u8]) -> std::io::Result<()> {
456    let header_len: usize = fields.iter().map(Vec::len).sum();
457    writer.write_all(&(header_len as u32).to_le_bytes())?;
458    for field in fields {
459        writer.write_all(field)?;
460    }
461    writer.write_all(&(data.len() as u32).to_le_bytes())?;
462    writer.write_all(data)
463}
464
465fn bag_header_fields(index_pos: u64, conn_count: u32, chunk_count: u32) -> Vec<Vec<u8>> {
466    vec![
467        field("op", &[0x03]),
468        field("index_pos", &index_pos.to_le_bytes()),
469        field("conn_count", &conn_count.to_le_bytes()),
470        field("chunk_count", &chunk_count.to_le_bytes()),
471    ]
472}
473
474fn chunk_fields(uncompressed_size: u32) -> Vec<Vec<u8>> {
475    vec![
476        field("op", &[0x05]),
477        field("compression", b"none"),
478        field("size", &uncompressed_size.to_le_bytes()),
479    ]
480}
481
482fn connection_fields(conn_id: u32, topic: &str) -> Vec<Vec<u8>> {
483    vec![
484        field("op", &[0x07]),
485        field("conn", &conn_id.to_le_bytes()),
486        field("topic", topic.as_bytes()),
487    ]
488}
489
490fn connection_data(topic: &str) -> Vec<u8> {
491    let fields = [
492        field("topic", topic.as_bytes()),
493        field("type", EVENT_ARRAY_TYPE.as_bytes()),
494        field("md5sum", EVENT_ARRAY_MD5.as_bytes()),
495        field("message_definition", EVENT_ARRAY_DEF.as_bytes()),
496    ];
497    fields.concat()
498}
499
500fn message_fields(conn_id: u32, time_us: i64) -> Vec<Vec<u8>> {
501    vec![
502        field("op", &[0x02]),
503        field("conn", &conn_id.to_le_bytes()),
504        field("time", &ros_time(time_us)),
505    ]
506}
507
508fn index_data_fields(conn_id: u32, count: u32) -> Vec<Vec<u8>> {
509    vec![
510        field("op", &[0x04]),
511        field("ver", &1u32.to_le_bytes()),
512        field("conn", &conn_id.to_le_bytes()),
513        field("count", &count.to_le_bytes()),
514    ]
515}
516
517fn chunk_info_fields(chunk_pos: u64, start_us: i64, end_us: i64) -> Vec<Vec<u8>> {
518    vec![
519        field("op", &[0x06]),
520        field("ver", &1u32.to_le_bytes()),
521        field("chunk_pos", &chunk_pos.to_le_bytes()),
522        field("start_time", &ros_time(start_us)),
523        field("end_time", &ros_time(end_us)),
524        field("count", &1u32.to_le_bytes()),
525    ]
526}
527
528/// One chunk's byte offset and (microsecond) time range, copied out of the bag's index
529/// so the [`BagSliceSource`] holds no borrows of the mmapped `RosBag`.
530#[derive(Clone, Copy, Debug, PartialEq, Eq)]
531struct ChunkMeta {
532    pos: u64,
533    start_us: i64,
534    end_us: i64,
535}
536
537/// In-place [`SliceSource`] for rosbags. The bag's own chunk index (`ChunkInfo`) gives
538/// each chunk's byte offset and time range, so time slicing seeks straight to the
539/// overlapping chunks and decompresses only those — no full read, bounded memory. Event
540/// **counts** aren't in the index, so `n_events`/`slice_index` decode the target chunks
541/// once and cache the per-chunk cumulative counts; time slicing never triggers that.
542pub struct BagSliceSource {
543    bag: RosBag,
544    /// Kept so the auxiliary streams (APS frames, IMU, intrinsics) can be read on demand —
545    /// those live on other topics and need their own pass over the file.
546    path: std::path::PathBuf,
547    conn_ids: Vec<u32>,
548    chunks: Vec<ChunkMeta>,
549    sensor: (usize, usize),
550    span_us: (i64, i64),
551    counts: Mutex<Option<Vec<usize>>>,
552}
553
554/// Opens a bag for lazy slicing: reads the index for the target topic's connection ids,
555/// the chunks containing them (offset + time range), the span, and the sensor size
556/// (override, else the first message header). No chunk data is decompressed.
557pub fn open_bag_slice(
558    path: impl AsRef<Path>,
559    options: &LoadOptions,
560) -> Result<BagSliceSource, IoError> {
561    let topic = options.topic.as_deref().unwrap_or(DEFAULT_TOPIC);
562    let path = path.as_ref();
563    let bag = RosBag::new(path).map_err(IoError::Io)?;
564
565    let mut conn_ids: Vec<u32> = Vec::new();
566    for record in bag.index_records() {
567        if let IndexRecord::Connection(connection) = record.map_err(map_bag_error)? {
568            if connection.topic == topic && connection.tp == EVENT_ARRAY_TYPE {
569                conn_ids.push(connection.id);
570            }
571        }
572    }
573    if conn_ids.is_empty() {
574        return Err(IoError::Format(format!(
575            "no {EVENT_ARRAY_TYPE} connection on topic {topic}"
576        )));
577    }
578
579    let mut chunks: Vec<ChunkMeta> = Vec::new();
580    for record in bag.index_records() {
581        if let IndexRecord::ChunkInfo(info) = record.map_err(map_bag_error)? {
582            if info
583                .entries()
584                .any(|entry| conn_ids.contains(&entry.conn_id))
585            {
586                chunks.push(ChunkMeta {
587                    pos: info.chunk_pos,
588                    start_us: (info.start_time / 1000) as i64,
589                    end_us: (info.end_time / 1000) as i64,
590                });
591            }
592        }
593    }
594    chunks.sort_by_key(|chunk| chunk.start_us);
595
596    let span_us = match (
597        chunks.iter().map(|c| c.start_us).min(),
598        chunks.iter().map(|c| c.end_us).max(),
599    ) {
600        (Some(lo), Some(hi)) => (lo, hi),
601        _ => (0, 0),
602    };
603    let sensor = match options.sensor_size {
604        Some(size) => size,
605        None => detect_sensor(&bag, &chunks, &conn_ids)?,
606    };
607
608    Ok(BagSliceSource {
609        bag,
610        path: path.to_path_buf(),
611        conn_ids,
612        chunks,
613        sensor,
614        span_us,
615        counts: Mutex::new(None),
616    })
617}
618
619/// Reads the sensor size from the first event message of the first chunk.
620fn detect_sensor(
621    bag: &RosBag,
622    chunks: &[ChunkMeta],
623    conn_ids: &[u32],
624) -> Result<(usize, usize), IoError> {
625    let Some(first) = chunks.first() else {
626        return Ok((1, 1));
627    };
628    let mut iterator = bag.chunk_records();
629    iterator.seek(first.pos).map_err(map_bag_error)?;
630    if let Some(record) = iterator.next() {
631        if let ChunkRecord::Chunk(chunk) = record.map_err(map_bag_error)? {
632            for message in chunk.messages() {
633                if let MessageRecord::MessageData(data) = message.map_err(map_bag_error)? {
634                    if conn_ids.contains(&data.conn_id) {
635                        return read_event_array_header(data.data);
636                    }
637                }
638            }
639        }
640    }
641    Ok((1, 1))
642}
643
644/// Chunks whose time range overlaps the half-open window `[t0, t1)` (µs).
645fn select_chunks(chunks: &[ChunkMeta], t0: i64, t1: i64) -> impl Iterator<Item = &ChunkMeta> {
646    chunks
647        .iter()
648        .filter(move |chunk| chunk.start_us < t1 && chunk.end_us >= t0)
649}
650
651/// Index of the chunk containing global event `i`, given cumulative per-chunk counts
652/// (`counts[k]` = events before chunk `k`, `counts.len() == chunks + 1`).
653fn locate_chunk(counts: &[usize], i: usize) -> usize {
654    counts
655        .partition_point(|&count| count <= i)
656        .saturating_sub(1)
657}
658
659impl BagSliceSource {
660    /// Seeks to `chunk` and calls `on_event` for every event on a target connection,
661    /// stopping the chunk early once `on_event` returns `true`.
662    fn for_each_event(
663        &self,
664        chunk: &ChunkMeta,
665        on_event: &mut dyn FnMut(u16, u16, i64, bool) -> bool,
666    ) -> Result<(), IoError> {
667        let mut iterator = self.bag.chunk_records();
668        iterator.seek(chunk.pos).map_err(map_bag_error)?;
669        let Some(record) = iterator.next() else {
670            return Ok(());
671        };
672        let ChunkRecord::Chunk(decoded) = record.map_err(map_bag_error)? else {
673            return Ok(());
674        };
675        for message in decoded.messages() {
676            if let MessageRecord::MessageData(data) = message.map_err(map_bag_error)? {
677                if self.conn_ids.contains(&data.conn_id) && decode_event_array(data.data, on_event)?
678                {
679                    break;
680                }
681            }
682        }
683        Ok(())
684    }
685
686    /// Cumulative event count per chunk, decoded once and cached.
687    fn cumulative_counts(&self) -> Result<Vec<usize>, IoError> {
688        let mut guard = self.counts.lock().unwrap();
689        if let Some(counts) = guard.as_ref() {
690            return Ok(counts.clone());
691        }
692        let mut counts = Vec::with_capacity(self.chunks.len() + 1);
693        counts.push(0);
694        for chunk in &self.chunks {
695            let mut events = 0usize;
696            self.for_each_event(chunk, &mut |_, _, _, _| {
697                events += 1;
698                false
699            })?;
700            counts.push(counts.last().unwrap() + events);
701        }
702        *guard = Some(counts.clone());
703        Ok(counts)
704    }
705}
706
707impl SliceSource for BagSliceSource {
708    fn sensor_size(&self) -> (usize, usize) {
709        self.sensor
710    }
711
712    fn timestamp_scale_ms(&self) -> f64 {
713        0.001
714    }
715
716    fn n_events(&self) -> usize {
717        // The count requires decoding; treat a decode error as "unknown" (0).
718        self.cumulative_counts()
719            .ok()
720            .and_then(|counts| counts.last().copied())
721            .unwrap_or(0)
722    }
723
724    fn time_span(&self) -> (i64, i64) {
725        self.span_us
726    }
727
728    // The auxiliary streams live on their own topics, so they get their own pass over the file
729    // rather than sharing the event chunk index. Topic selection stays automatic (the sole
730    // topic of the right type), which is what the free functions already do.
731    fn frames(&self, t0: i64, t1: i64) -> Result<Vec<(i64, EventFrame)>, IoError> {
732        read_bag_frames(&self.path, None, t0, t1)
733    }
734
735    fn imu(&self, t0: i64, t1: i64) -> Result<Vec<ImuSample>, IoError> {
736        read_bag_imu(&self.path, None, t0, t1)
737    }
738
739    fn camera(&self) -> Result<Option<crate::camera::Camera>, IoError> {
740        read_bag_camera_info(&self.path, None)
741    }
742
743    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
744        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
745        for chunk in select_chunks(&self.chunks, t0, t1) {
746            self.for_each_event(chunk, &mut |x, y, t, p| {
747                if t >= t1 {
748                    return true; // events are time-ordered within a chunk
749                }
750                if t >= t0 {
751                    builder.push(x, y, t, p);
752                }
753                false
754            })?;
755        }
756        Ok(builder.build())
757    }
758
759    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
760        let counts = self.cumulative_counts()?;
761        let total = counts.last().copied().unwrap_or(0);
762        let i0 = i0.min(total);
763        let i1 = i1.clamp(i0, total);
764        let mut builder = EventStreamBuilder::new(self.sensor.0, self.sensor.1, 0.001);
765        if i0 == i1 {
766            return Ok(builder.build());
767        }
768        for (offset, chunk) in self
769            .chunks
770            .iter()
771            .enumerate()
772            .skip(locate_chunk(&counts, i0))
773        {
774            if counts[offset] >= i1 {
775                break;
776            }
777            let mut index = counts[offset];
778            self.for_each_event(chunk, &mut |x, y, t, p| {
779                if (i0..i1).contains(&index) {
780                    builder.push(x, y, t, p);
781                }
782                index += 1;
783                index >= i1
784            })?;
785        }
786        Ok(builder.build())
787    }
788}
789
790fn map_bag_error(error: rosbag::Error) -> IoError {
791    IoError::Format(format!("rosbag: {error}"))
792}
793
794// ---------------------------------------------------------------------------------------------
795// Auxiliary streams: APS frames, IMU and intrinsics
796//
797// A DAVIS bag carries more than events, and until now this reader dropped all of it. The frames are
798// what the simulator needs a reference for; the IMU is the only ground-truth motion available for
799// contrast maximisation; the intrinsics are what the rotation warp needs to map pixels onto rays.
800// The chunk index, connection matching and `ByteReader` below are the same ones the event path
801// uses — only the message layouts are new.
802// ---------------------------------------------------------------------------------------------
803
804const IMAGE_TYPE: &str = "sensor_msgs/Image";
805const IMU_TYPE: &str = "sensor_msgs/Imu";
806const CAMERA_INFO_TYPE: &str = "sensor_msgs/CameraInfo";
807
808/// One `sensor_msgs/Imu` sample.
809///
810/// Orientation is in the message but not here: on a DAVIS it is dead-reckoned from the same gyro
811/// and accelerometer, so exposing it would imply an independent measurement that does not exist.
812#[derive(Clone, Copy, Debug, PartialEq)]
813pub struct ImuSample {
814    pub t_us: i64,
815    /// Rad/s about the IMU's x, y and z axes.
816    pub angular_velocity: [f64; 3],
817    /// m/s² along the same axes, gravity included.
818    pub linear_acceleration: [f64; 3],
819}
820
821/// Every topic in a bag, with its message type.
822///
823/// Bags do not agree on topic names — this reader defaults to `/davis/left/events` while a DAVIS
824/// recorded through the `dvs_ros_driver` uses `/dvs/events` — so being able to ask is the
825/// difference between a reader that works on the first file someone tries and one that appears
826/// broken.
827pub fn bag_topics(path: impl AsRef<Path>) -> Result<Vec<(String, String)>, IoError> {
828    let bag = RosBag::new(path).map_err(IoError::Io)?;
829    let mut topics: Vec<(String, String)> = Vec::new();
830    for record in bag.index_records() {
831        if let IndexRecord::Connection(connection) = record.map_err(map_bag_error)? {
832            let entry = (connection.topic.to_owned(), connection.tp.to_owned());
833            if !topics.contains(&entry) {
834                topics.push(entry);
835            }
836        }
837    }
838    topics.sort();
839    Ok(topics)
840}
841
842/// Finds the connection ids carrying `wanted_type`, preferring `topic` when it is given and
843/// present. Returns the ids and the topic they belong to.
844///
845/// Falling back to the sole connection of the right type is deliberate: asking for frames from a
846/// bag that has exactly one image topic should work without the caller having to know its name.
847fn connections_of_type(
848    bag: &RosBag,
849    wanted_type: &str,
850    topic: Option<&str>,
851) -> Result<(Vec<u32>, String), IoError> {
852    let mut by_topic: HashMap<String, Vec<u32>> = HashMap::new();
853    for record in bag.index_records() {
854        if let IndexRecord::Connection(connection) = record.map_err(map_bag_error)? {
855            if connection.tp == wanted_type {
856                by_topic
857                    .entry(connection.topic.to_owned())
858                    .or_default()
859                    .push(connection.id);
860            }
861        }
862    }
863    if let Some(topic) = topic {
864        if let Some(ids) = by_topic.get(topic) {
865            return Ok((ids.clone(), topic.to_owned()));
866        }
867    }
868    match by_topic.len() {
869        1 => {
870            let (found, ids) = by_topic.into_iter().next().expect("checked len");
871            Ok((ids, found))
872        }
873        0 => Err(IoError::Unsupported(format!(
874            "the bag has no {wanted_type} topic"
875        ))),
876        _ => {
877            let mut names: Vec<String> = by_topic.into_keys().collect();
878            names.sort();
879            Err(IoError::Unsupported(format!(
880                "the bag has several {wanted_type} topics ({}); pass one explicitly",
881                names.join(", ")
882            )))
883        }
884    }
885}
886
887/// Chunk metadata for the whole bag, so a windowed read can skip chunks entirely.
888fn chunk_metadata(bag: &RosBag) -> Result<Vec<ChunkMeta>, IoError> {
889    let mut chunks = Vec::new();
890    for record in bag.index_records() {
891        if let IndexRecord::ChunkInfo(info) = record.map_err(map_bag_error)? {
892            chunks.push(ChunkMeta {
893                pos: info.chunk_pos,
894                start_us: (info.start_time / 1_000) as i64,
895                end_us: (info.end_time / 1_000) as i64,
896            });
897        }
898    }
899    chunks.sort_by_key(|chunk| chunk.start_us);
900    Ok(chunks)
901}
902
903/// Walks messages on the given connections, skipping whole chunks outside `[t0_us, t1_us)`.
904/// Essential rather than an optimisation: these bags run to tens of gigabytes.
905///
906/// The window is applied *coarsely* here, on the bag's record times, and each reader then filters
907/// precisely on the timestamp inside the message. Those are not the same clock: a message's header
908/// stamp is when the sensor sampled, while its record time is when the recorder wrote it, and on
909/// this DAVIS they differ by several milliseconds. The header stamp is the one that lines up with
910/// the events, so it is the one readers report and filter on — and this pass has to be generous
911/// enough not to drop a message whose header falls inside the window while its record time does
912/// not.
913fn for_each_message(
914    bag: &RosBag,
915    conn_ids: &[u32],
916    t0_us: i64,
917    t1_us: i64,
918    visit: &mut dyn FnMut(&[u8]) -> Result<bool, IoError>,
919) -> Result<(), IoError> {
920    let chunks = chunk_metadata(bag)?;
921    for chunk in select_chunks(&chunks, t0_us, t1_us) {
922        let mut iterator = bag.chunk_records();
923        iterator.seek(chunk.pos).map_err(map_bag_error)?;
924        let Some(record) = iterator.next() else {
925            continue;
926        };
927        let ChunkRecord::Chunk(decoded) = record.map_err(map_bag_error)? else {
928            continue;
929        };
930        for message in decoded.messages() {
931            if let MessageRecord::MessageData(data) = message.map_err(map_bag_error)? {
932                if !conn_ids.contains(&data.conn_id) {
933                    continue;
934                }
935                if visit(data.data)? {
936                    return Ok(());
937                }
938            }
939        }
940    }
941    Ok(())
942}
943
944/// Reads `sensor_msgs/Image` frames in `[t0_us, t1_us)` as timestamped greyscale frames.
945///
946/// DAVIS APS is `mono8`. Other encodings are refused by name rather than silently misread — a
947/// `bgr8` frame decoded as `mono8` produces a plausible-looking image that is simply wrong.
948pub fn read_bag_frames(
949    path: impl AsRef<Path>,
950    topic: Option<&str>,
951    t0_us: i64,
952    t1_us: i64,
953) -> Result<Vec<(i64, EventFrame)>, IoError> {
954    let bag = RosBag::new(path).map_err(IoError::Io)?;
955    let (conn_ids, _) = connections_of_type(&bag, IMAGE_TYPE, topic)?;
956    let mut frames = Vec::new();
957    for_each_message(&bag, &conn_ids, t0_us, t1_us, &mut |bytes| {
958        let mut reader = ByteReader::new(bytes);
959        let t_us = reader.ros_header()?;
960        if t_us < t0_us || t_us >= t1_us {
961            return Ok(false);
962        }
963        let height = reader.u32()? as usize;
964        let width = reader.u32()? as usize;
965        let encoding = reader.string()?;
966        reader.skip(1)?; // is_bigendian
967        let step = reader.u32()? as usize;
968        let length = reader.u32()? as usize;
969        let data = reader.take(length)?;
970
971        // A DAVIS publishes `mono8` when the driver hands the APS through untouched and `rgb8` when
972        // it colourises first — this recording does the latter — so both have to work. Colour is
973        // reduced to luma with the same weights the PNG reader uses, since the underlying APS pixel
974        // was monochrome to begin with and the three channels carry no extra information.
975        let channels = match encoding.as_str() {
976            "mono8" => 1usize,
977            "rgb8" | "bgr8" => 3,
978            other => {
979                return Err(IoError::Unsupported(format!(
980                    "frame encoding {other:?} is not supported; this reader handles mono8, rgb8 \
981                     and bgr8"
982                )))
983            }
984        };
985        let swap_rb = encoding == "bgr8";
986        // `step` is the row stride, which may exceed `width * channels` for alignment — copy row by
987        // row rather than assuming the buffer is tightly packed.
988        let mut samples = Vec::with_capacity(width * height);
989        for row in 0..height {
990            let start = row * step;
991            let line = data.get(start..start + width * channels).ok_or_else(|| {
992                IoError::Format("image row runs past the message payload".to_owned())
993            })?;
994            for pixel in line.chunks_exact(channels) {
995                samples.push(match (channels, swap_rb) {
996                    (1, _) => pixel[0],
997                    (_, false) => super::luma(pixel),
998                    (_, true) => super::luma(&[pixel[2], pixel[1], pixel[0]]),
999                });
1000            }
1001        }
1002        let frame = EventFrame::intensity(EventFrameData::U8(samples), width, height)
1003            .map_err(|error| IoError::Format(error.to_string()))?;
1004        frames.push((t_us, frame));
1005        Ok(false)
1006    })?;
1007    Ok(frames)
1008}
1009
1010/// Reads `sensor_msgs/Imu` samples in `[t0_us, t1_us)`.
1011pub fn read_bag_imu(
1012    path: impl AsRef<Path>,
1013    topic: Option<&str>,
1014    t0_us: i64,
1015    t1_us: i64,
1016) -> Result<Vec<ImuSample>, IoError> {
1017    let bag = RosBag::new(path).map_err(IoError::Io)?;
1018    let (conn_ids, _) = connections_of_type(&bag, IMU_TYPE, topic)?;
1019    let mut samples = Vec::new();
1020    for_each_message(&bag, &conn_ids, t0_us, t1_us, &mut |bytes| {
1021        let mut reader = ByteReader::new(bytes);
1022        let t_us = reader.ros_header()?;
1023        if t_us < t0_us || t_us >= t1_us {
1024            return Ok(false);
1025        }
1026        reader.skip(4 * 8)?; // orientation quaternion
1027        reader.skip(9 * 8)?; // orientation_covariance
1028        let angular_velocity = reader.vector3()?;
1029        reader.skip(9 * 8)?; // angular_velocity_covariance
1030        let linear_acceleration = reader.vector3()?;
1031        samples.push(ImuSample {
1032            t_us,
1033            angular_velocity,
1034            linear_acceleration,
1035        });
1036        Ok(false)
1037    })?;
1038    Ok(samples)
1039}
1040
1041/// Reads the first `sensor_msgs/CameraInfo` as camera intrinsics.
1042///
1043/// Only the pinhole terms are taken from `K`; distortion coefficients are read but not applied,
1044/// because `Camera::with_distortion` expects the radial/tangential model and a bag may carry any of
1045/// several. Callers wanting undistortion should build the `Camera` themselves from `D`.
1046pub fn read_bag_camera_info(
1047    path: impl AsRef<Path>,
1048    topic: Option<&str>,
1049) -> Result<Option<crate::camera::Camera>, IoError> {
1050    let bag = RosBag::new(path).map_err(IoError::Io)?;
1051    let (conn_ids, _) = connections_of_type(&bag, CAMERA_INFO_TYPE, topic)?;
1052    let mut camera = None;
1053    for_each_message(&bag, &conn_ids, i64::MIN, i64::MAX, &mut |bytes| {
1054        let mut reader = ByteReader::new(bytes);
1055        reader.ros_header()?;
1056        reader.skip(4 + 4)?; // height, width
1057        let _distortion_model = reader.string()?;
1058        let coefficients = reader.u32()? as usize;
1059        reader.skip(coefficients * 8)?; // D
1060                                        // K is row-major [fx 0 cx; 0 fy cy; 0 0 1].
1061        let k: Vec<f64> = (0..9).map(|_| reader.f64()).collect::<Result<_, _>>()?;
1062        camera = Some(crate::camera::Camera::new(k[0], k[4], k[2], k[5]));
1063        Ok(true) // intrinsics do not change mid-recording
1064    })?;
1065    Ok(camera)
1066}
1067
1068/// Little-endian cursor over a ROS message payload.
1069struct ByteReader<'a> {
1070    bytes: &'a [u8],
1071    position: usize,
1072}
1073
1074impl<'a> ByteReader<'a> {
1075    fn new(bytes: &'a [u8]) -> Self {
1076        Self { bytes, position: 0 }
1077    }
1078
1079    fn take(&mut self, count: usize) -> Result<&'a [u8], IoError> {
1080        let end = self
1081            .position
1082            .checked_add(count)
1083            .filter(|&end| end <= self.bytes.len())
1084            .ok_or_else(|| IoError::Format("truncated dvs_msgs/EventArray message".to_owned()))?;
1085        let slice = &self.bytes[self.position..end];
1086        self.position = end;
1087        Ok(slice)
1088    }
1089
1090    fn skip(&mut self, count: usize) -> Result<(), IoError> {
1091        self.take(count).map(|_| ())
1092    }
1093
1094    fn u8(&mut self) -> Result<u8, IoError> {
1095        Ok(self.take(1)?[0])
1096    }
1097
1098    fn u16(&mut self) -> Result<u16, IoError> {
1099        Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
1100    }
1101
1102    fn u32(&mut self) -> Result<u32, IoError> {
1103        Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
1104    }
1105
1106    fn f64(&mut self) -> Result<f64, IoError> {
1107        Ok(f64::from_le_bytes(self.take(8)?.try_into().unwrap()))
1108    }
1109
1110    /// A ROS string: 32-bit length then the bytes. Invalid UTF-8 is replaced rather than rejected —
1111    /// a mangled `frame_id` is no reason to fail a frame that decodes fine otherwise.
1112    fn string(&mut self) -> Result<String, IoError> {
1113        let length = self.u32()? as usize;
1114        Ok(String::from_utf8_lossy(self.take(length)?).into_owned())
1115    }
1116
1117    /// `std_msgs/Header`: seq, stamp, frame_id. Returns the stamp in microseconds, which is the
1118    /// clock every reader here works in.
1119    fn ros_header(&mut self) -> Result<i64, IoError> {
1120        self.skip(4)?; // seq
1121        let seconds = i64::from(self.u32()?);
1122        let nanoseconds = i64::from(self.u32()?);
1123        let _frame_id = self.string()?;
1124        Ok(seconds * 1_000_000 + nanoseconds / 1_000)
1125    }
1126
1127    fn vector3(&mut self) -> Result<[f64; 3], IoError> {
1128        Ok([self.f64()?, self.f64()?, self.f64()?])
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::{locate_chunk, open_bag_slice, read_bag, select_chunks, write_bag, ChunkMeta};
1135    use crate::io::{LoadOptions, SliceSource};
1136    use crate::{EventStream, EventStreamBuilder};
1137
1138    fn temp_path(tag: &str) -> std::path::PathBuf {
1139        let nanos = std::time::SystemTime::now()
1140            .duration_since(std::time::UNIX_EPOCH)
1141            .unwrap()
1142            .as_nanos();
1143        std::env::temp_dir().join(format!("eventcv_{tag}_{nanos}.bag"))
1144    }
1145
1146    fn sample_stream() -> EventStream {
1147        let mut builder = EventStreamBuilder::new(16, 12, 0.001);
1148        for &(x, y, t, p) in &[
1149            (0u16, 0u16, 5i64, true),
1150            (3, 4, 1_000_001, false),
1151            (15, 11, 2_500_000, true),
1152            (7, 8, 2_500_000, false),
1153        ] {
1154            builder.push(x, y, t, p);
1155        }
1156        builder.build()
1157    }
1158
1159    #[test]
1160    fn bag_round_trips_through_the_reader() {
1161        let stream = sample_stream();
1162        let path = temp_path("bag_rt");
1163        write_bag(&path, &stream, None).unwrap();
1164
1165        let loaded = read_bag(&path, &LoadOptions::default()).unwrap();
1166        assert_eq!(loaded.sensor_size(), (16, 12));
1167        assert_eq!(loaded.xs(), stream.xs());
1168        assert_eq!(loaded.ys(), stream.ys());
1169        assert_eq!(loaded.ts(), stream.ts()); // microseconds preserved exactly
1170        assert_eq!(loaded.ps(), stream.ps());
1171
1172        // The lazy slicer reads the same file in place.
1173        let reader = open_bag_slice(&path, &LoadOptions::default()).unwrap();
1174        assert_eq!(reader.sensor_size(), (16, 12));
1175        assert_eq!(reader.n_events(), stream.len());
1176        assert_eq!(reader.time_span(), (5, 2_500_000));
1177        let window = reader.slice_time(1_000_000, 2_000_000).unwrap();
1178        assert_eq!(window.ts(), &[1_000_001]);
1179
1180        std::fs::remove_file(&path).ok();
1181    }
1182
1183    #[test]
1184    fn bag_round_trips_an_empty_stream() {
1185        let stream = EventStreamBuilder::new(8, 6, 0.001).build();
1186        let path = temp_path("bag_empty");
1187        write_bag(&path, &stream, Some("/cam/events")).unwrap();
1188
1189        let options = LoadOptions {
1190            topic: Some("/cam/events".to_owned()),
1191            ..LoadOptions::default()
1192        };
1193        let loaded = read_bag(&path, &options).unwrap();
1194        assert!(loaded.is_empty());
1195        assert_eq!(loaded.sensor_size(), (8, 6));
1196
1197        std::fs::remove_file(&path).ok();
1198    }
1199
1200    fn meta(pos: u64, start_us: i64, end_us: i64) -> ChunkMeta {
1201        ChunkMeta {
1202            pos,
1203            start_us,
1204            end_us,
1205        }
1206    }
1207
1208    #[test]
1209    fn select_chunks_picks_overlapping_windows() {
1210        let chunks = [meta(0, 0, 100), meta(1, 100, 200), meta(2, 200, 300)];
1211
1212        let picked: Vec<u64> = select_chunks(&chunks, 150, 250).map(|c| c.pos).collect();
1213        assert_eq!(picked, [1, 2]); // [150, 250) overlaps chunks 1 and 2
1214
1215        // Half-open: a window ending exactly at a chunk's start excludes it.
1216        let picked: Vec<u64> = select_chunks(&chunks, 0, 100).map(|c| c.pos).collect();
1217        assert_eq!(picked, [0]);
1218
1219        assert!(select_chunks(&chunks, 1000, 2000).next().is_none());
1220    }
1221
1222    #[test]
1223    fn locate_chunk_finds_the_containing_chunk() {
1224        let counts = [0usize, 10, 25, 40]; // cumulative counts for 3 chunks
1225
1226        assert_eq!(locate_chunk(&counts, 0), 0);
1227        assert_eq!(locate_chunk(&counts, 9), 0);
1228        assert_eq!(locate_chunk(&counts, 10), 1);
1229        assert_eq!(locate_chunk(&counts, 24), 1);
1230        assert_eq!(locate_chunk(&counts, 39), 2);
1231    }
1232}