Skip to main content

eventcv_core/
io.rs

1use std::{error::Error, fmt, io, path::Path};
2
3use crate::{EventStream, EventStreamBuilder};
4
5mod aedat;
6mod bag;
7#[cfg(feature = "hdf5")]
8mod h5;
9mod npz;
10mod prophesee;
11mod text;
12
13pub use aedat::read_aedat;
14pub use bag::{open_bag_slice, read_bag, write_bag, BagSliceSource};
15#[cfg(feature = "hdf5")]
16pub use h5::{
17    open_hdf5_slice, read_hdf5, read_hdf5_frame, write_hdf5_frame, write_hdf5_stream,
18    Hdf5FrameSink, Hdf5SliceSource,
19};
20pub use npz::{read_npz, read_npz_frame, write_npz_frame, write_npz_stream};
21pub use prophesee::read_dat;
22pub use text::{
23    open_text_slice, read_text, write_text_stream, ColumnOrder, TextOptions, TextReader, TimeUnit,
24};
25
26use crate::representation::EventFrame;
27use crate::viz::{render_frame, Colormap};
28
29/// A single event as produced by a reader, before it is placed on the sensor grid.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct RawEvent {
32    pub x: u16,
33    pub y: u16,
34    pub t: i64,
35    pub p: bool,
36}
37
38/// A bounded-memory source of events. Readers parse on demand so multi-gigabyte
39/// files never need to be resident; the consumer decides what to accumulate.
40pub trait EventSource {
41    fn sensor_size(&self) -> (usize, usize);
42    fn timestamp_scale_ms(&self) -> f64;
43    /// Returns the next event, or `None` at end of stream.
44    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
45}
46
47/// Drains a source into an in-memory [`EventStream`], dropping out-of-bounds events.
48pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
49    read_capped(source, None)
50}
51
52/// Like [`read_all`] but stops after `max` kept events (for previewing huge files).
53pub fn read_capped(
54    mut source: impl EventSource,
55    max: Option<usize>,
56) -> Result<EventStream, IoError> {
57    let (width, height) = source.sensor_size();
58    let mut builder = EventStreamBuilder::new(width, height, source.timestamp_scale_ms());
59    while let Some(event) = source.next_event()? {
60        builder.push(event.x, event.y, event.t, event.p);
61        if max.is_some_and(|max| builder.len() >= max) {
62            break;
63        }
64    }
65    Ok(builder.build())
66}
67
68/// Options for the unified [`load`] entry point. Most fields apply to a single
69/// format; readers ignore the ones they do not need.
70#[derive(Clone, Debug, Default)]
71pub struct LoadOptions {
72    /// `(width, height)`. `None` infers it from the data (coordinate range), or from the
73    /// message for rosbag. An explicit value overrides and, for HDF5, skips the scan.
74    pub sensor_size: Option<(usize, usize)>,
75    /// Timestamp unit. `None` infers it (HDF5/text): a fractional text value means
76    /// seconds, otherwise the unit is chosen from the recording span (see
77    /// `TimeUnit::infer_from_span`). Ignored for rosbag (always ROS `sec`+`nsec`).
78    pub time_unit: Option<TimeUnit>,
79    /// Text column order.
80    pub order: ColumnOrder,
81    /// Rosbag topic to read (defaults to `/davis/left/events`).
82    pub topic: Option<String>,
83    /// Cap on the number of events to read.
84    pub max_events: Option<usize>,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88enum Format {
89    Npz,
90    Text,
91    Hdf5,
92    Rosbag,
93    Aedat,
94    Aedat4,
95    PropheseeDat,
96    PropheseeRaw,
97    Png,
98}
99
100fn detect_format(path: &Path) -> Result<Format, IoError> {
101    let extension = path
102        .extension()
103        .and_then(|extension| extension.to_str())
104        .map(str::to_ascii_lowercase);
105    match extension.as_deref() {
106        Some("npz") => Ok(Format::Npz),
107        Some("txt") | Some("csv") => Ok(Format::Text),
108        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
109        Some("bag") => Ok(Format::Rosbag),
110        Some("aedat") => Ok(Format::Aedat),
111        Some("aedat4") => Ok(Format::Aedat4),
112        Some("dat") => Ok(Format::PropheseeDat),
113        Some("raw") => Ok(Format::PropheseeRaw),
114        Some("png") => Ok(Format::Png),
115        Some(other) => Err(IoError::Unsupported(format!(
116            "unrecognised file extension: .{other}"
117        ))),
118        None => Err(IoError::Unsupported(
119            "file has no extension to detect its format".to_owned(),
120        )),
121    }
122}
123
124/// Loads events from any supported file, detected by extension — the OpenCV-style
125/// single entry point. Supported today: `.npz`, `.txt`/`.csv`, `.bag`, `.h5`/`.hdf5`,
126/// `.aedat` (AEDAT 2.0), and `.dat` (Prophesee CD).
127pub fn load(path: impl AsRef<Path>, options: LoadOptions) -> Result<EventStream, IoError> {
128    let path = path.as_ref();
129    match detect_format(path)? {
130        Format::Npz => npz::read_npz(path, options.sensor_size),
131        Format::Text => text::load_text(path, &options),
132        Format::Rosbag => bag::read_bag(path, &options),
133        Format::Hdf5 => {
134            #[cfg(feature = "hdf5")]
135            {
136                h5::read_hdf5(path, &options)
137            }
138            #[cfg(not(feature = "hdf5"))]
139            {
140                Err(IoError::Unsupported(
141                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
142                ))
143            }
144        }
145        Format::Aedat => aedat::read_aedat(path, &options),
146        Format::Aedat4 => Err(IoError::Unsupported(
147            "AEDAT4 (.aedat4, iniVation DV FlatBuffer/LZ4) reading is not implemented yet"
148                .to_owned(),
149        )),
150        Format::PropheseeDat => prophesee::read_dat(path, &options),
151        Format::PropheseeRaw => Err(IoError::Unsupported(
152            "Prophesee .raw (EVT2/EVT3) reading is not implemented yet".to_owned(),
153        )),
154        Format::Png => Err(IoError::Unsupported(
155            "PNG is a frame export format, not an event stream; use save_frame".to_owned(),
156        )),
157    }
158}
159
160/// Random-access view over a file's events: fetch an arbitrary time or count range
161/// without materialising the whole stream. This backs the lazy [`open`] handle — the
162/// OpenCV `VideoCapture` to [`load`]'s `imread`. Each call returns a new [`EventStream`].
163pub trait SliceSource: Send {
164    fn sensor_size(&self) -> (usize, usize);
165    fn timestamp_scale_ms(&self) -> f64;
166    /// Total events in the file.
167    fn n_events(&self) -> usize;
168    /// `(t_min, t_max)` in microseconds across the whole file; `(0, 0)` when empty.
169    fn time_span(&self) -> (i64, i64);
170    /// Events whose index lies in `[i0, i1)` (clamped to the file).
171    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
172    /// Events whose timestamp (µs) lies in the half-open window `[t0, t1)`.
173    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
174}
175
176/// A [`SliceSource`] backed by an already-loaded stream — the universal fallback for
177/// formats without native random access (npz/txt/bag today). Slicing is entirely
178/// in-RAM, so `open()` returns a working handle for every supported format from day one.
179pub struct MemorySliceSource {
180    stream: EventStream,
181}
182
183impl MemorySliceSource {
184    pub fn new(stream: EventStream) -> Self {
185        Self { stream }
186    }
187
188    fn rebuild(&self, indices: impl Iterator<Item = usize>) -> EventStream {
189        let (width, height) = self.stream.sensor_size();
190        let mut builder = EventStreamBuilder::new(width, height, self.stream.timestamp_scale_ms());
191        let (xs, ys, ts, ps) = (
192            self.stream.xs(),
193            self.stream.ys(),
194            self.stream.ts(),
195            self.stream.ps(),
196        );
197        for index in indices {
198            builder.push(xs[index], ys[index], ts[index], ps[index]);
199        }
200        builder.build()
201    }
202}
203
204impl SliceSource for MemorySliceSource {
205    fn sensor_size(&self) -> (usize, usize) {
206        self.stream.sensor_size()
207    }
208
209    fn timestamp_scale_ms(&self) -> f64 {
210        self.stream.timestamp_scale_ms()
211    }
212
213    fn n_events(&self) -> usize {
214        self.stream.len()
215    }
216
217    fn time_span(&self) -> (i64, i64) {
218        let ts = self.stream.ts();
219        match (ts.iter().min(), ts.iter().max()) {
220            (Some(&min), Some(&max)) => (min, max),
221            _ => (0, 0),
222        }
223    }
224
225    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
226        let i0 = i0.min(self.stream.len());
227        let i1 = i1.clamp(i0, self.stream.len());
228        Ok(self.rebuild(i0..i1))
229    }
230
231    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
232        let ts = self.stream.ts();
233        Ok(self.rebuild((0..ts.len()).filter(|&index| ts[index] >= t0 && ts[index] < t1)))
234    }
235}
236
237/// A boxed [`SliceSource`] — the handle [`open`] returns.
238pub type Reader = Box<dyn SliceSource>;
239
240/// Opens a file for lazy slicing, detected by extension (the `VideoCapture` analogue to
241/// [`load`]'s `imread`). HDF5 is sliced in place by binary-searching its timestamp
242/// dataset; every other format is loaded once and sliced in memory. Same `LoadOptions`
243/// as [`load`] (`max_events` is ignored — slicing supersedes it).
244pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Reader, IoError> {
245    let path = path.as_ref();
246    match detect_format(path)? {
247        Format::Hdf5 => {
248            #[cfg(feature = "hdf5")]
249            {
250                Ok(Box::new(h5::open_hdf5_slice(path, &options)?))
251            }
252            #[cfg(not(feature = "hdf5"))]
253            {
254                Err(IoError::Unsupported(
255                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
256                ))
257            }
258        }
259        Format::Text => Ok(Box::new(text::open_text_slice(path, &options)?)),
260        Format::Rosbag => Ok(Box::new(bag::open_bag_slice(path, &options)?)),
261        _ => Ok(Box::new(MemorySliceSource::new(load(path, options)?))),
262    }
263}
264
265/// Options for the [`save_stream`] / [`save_frame`] writers — the symmetric mirror of
266/// [`LoadOptions`]. Most formats ignore every field; readers and writers agree on the rest.
267#[derive(Clone, Debug, Default)]
268pub struct SaveOptions {
269    /// Rosbag topic to write the `dvs_msgs/EventArray` messages on (defaults to
270    /// `/davis/left/events`, matching the reader).
271    pub topic: Option<String>,
272    /// PNG frame export only: the colour map (default [`Colormap::Viridis`]).
273    pub colormap: Colormap,
274    /// PNG frame export only: auto-contrast the field to its data range. `None` = `true`.
275    pub normalize: Option<bool>,
276}
277
278/// Persists an [`EventStream`] to `path`, the format chosen by extension — the symmetric
279/// counterpart of [`load`]. npz/HDF5/rosbag round-trip exactly (metadata stored); txt
280/// stores `t x y p` and recovers sensor size / time unit on load via inference or options.
281pub fn save_stream(
282    path: impl AsRef<Path>,
283    stream: &EventStream,
284    options: &SaveOptions,
285) -> Result<(), IoError> {
286    let path = path.as_ref();
287    match detect_format(path)? {
288        Format::Npz => npz::write_npz_stream(path, stream),
289        Format::Text => text::write_text_stream(path, stream),
290        Format::Rosbag => bag::write_bag(path, stream, options.topic.as_deref()),
291        Format::Hdf5 => {
292            #[cfg(feature = "hdf5")]
293            {
294                h5::write_hdf5_stream(path, stream)
295            }
296            #[cfg(not(feature = "hdf5"))]
297            {
298                Err(IoError::Unsupported(
299                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
300                ))
301            }
302        }
303        other => Err(IoError::Unsupported(format!(
304            "saving an event stream as {other:?} is not supported"
305        ))),
306    }
307}
308
309/// Persists an [`EventFrame`] (a computed representation) to `path`, preserving its shape,
310/// dtype, `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
311pub fn save_frame(
312    path: impl AsRef<Path>,
313    frame: &EventFrame,
314    options: &SaveOptions,
315) -> Result<(), IoError> {
316    let path = path.as_ref();
317    match detect_format(path)? {
318        Format::Npz => npz::write_npz_frame(path, frame),
319        Format::Hdf5 => {
320            #[cfg(feature = "hdf5")]
321            {
322                h5::write_hdf5_frame(path, frame)
323            }
324            #[cfg(not(feature = "hdf5"))]
325            {
326                Err(IoError::Unsupported(
327                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
328                ))
329            }
330        }
331        Format::Png => write_png_frame(path, frame, options),
332        other => Err(IoError::Unsupported(format!(
333            "saving an event frame as {other:?} is not supported"
334        ))),
335    }
336}
337
338/// Renders a frame through [`render_frame`] (colormapped 2-D view) and encodes it as an
339/// 8-bit RGB PNG. Unlike npz/HDF5 this is a *view*, not a round-trippable dump.
340fn write_png_frame(path: &Path, frame: &EventFrame, options: &SaveOptions) -> Result<(), IoError> {
341    let image = render_frame(frame, options.colormap, options.normalize.unwrap_or(true));
342    let file = std::fs::File::create(path)?;
343    let writer = std::io::BufWriter::new(file);
344    let mut encoder = png::Encoder::new(writer, image.width as u32, image.height as u32);
345    encoder.set_color(png::ColorType::Rgb);
346    encoder.set_depth(png::BitDepth::Eight);
347    encoder
348        .write_header()
349        .and_then(|mut writer| writer.write_image_data(&image.pixels))
350        .map_err(|error| match error {
351            png::EncodingError::IoError(error) => IoError::Io(error),
352            other => IoError::Format(other.to_string()),
353        })
354}
355
356/// Reads an [`EventFrame`] previously written by [`save_frame`], reconstructing its dtype,
357/// `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
358pub fn load_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
359    let path = path.as_ref();
360    match detect_format(path)? {
361        Format::Npz => npz::read_npz_frame(path),
362        Format::Hdf5 => {
363            #[cfg(feature = "hdf5")]
364            {
365                h5::read_hdf5_frame(path)
366            }
367            #[cfg(not(feature = "hdf5"))]
368            {
369                Err(IoError::Unsupported(
370                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
371                ))
372            }
373        }
374        other => Err(IoError::Unsupported(format!(
375            "loading an event frame from {other:?} is not supported"
376        ))),
377    }
378}
379
380#[derive(Debug)]
381pub enum IoError {
382    Io(io::Error),
383    Parse { line: usize, message: String },
384    Format(String),
385    InvalidSensorSize,
386    Unsupported(String),
387}
388
389impl fmt::Display for IoError {
390    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
391        match self {
392            Self::Io(error) => error.fmt(formatter),
393            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
394            Self::Format(message) => formatter.write_str(message),
395            Self::InvalidSensorSize => {
396                formatter.write_str("sensor width and height must be positive")
397            }
398            Self::Unsupported(message) => formatter.write_str(message),
399        }
400    }
401}
402
403impl Error for IoError {
404    fn source(&self) -> Option<&(dyn Error + 'static)> {
405        match self {
406            Self::Io(error) => Some(error),
407            _ => None,
408        }
409    }
410}
411
412impl From<io::Error> for IoError {
413    fn from(error: io::Error) -> Self {
414        Self::Io(error)
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::{load, open, IoError, LoadOptions, MemorySliceSource, SliceSource};
421    use crate::EventStreamBuilder;
422
423    #[test]
424    fn unknown_extension_is_unsupported() {
425        let error = load("recording.mp4", LoadOptions::default()).unwrap_err();
426        assert!(matches!(error, IoError::Unsupported(_)));
427    }
428
429    #[test]
430    fn hdf5_extension_dispatches_per_feature() {
431        // `.h5` is always recognised; with the feature it reaches the reader (a missing
432        // file is then an IO error), without it the dispatch reports missing support.
433        let error = load("recording.h5", LoadOptions::default()).unwrap_err();
434        #[cfg(feature = "hdf5")]
435        assert!(matches!(error, IoError::Io(_)));
436        #[cfg(not(feature = "hdf5"))]
437        match error {
438            IoError::Unsupported(message) => assert!(message.contains("HDF5")),
439            other => panic!("expected unsupported error, got {other:?}"),
440        }
441    }
442
443    #[test]
444    fn text_missing_file_is_reported() {
445        // Text no longer requires sensor_size (it infers); a missing file is an IO error.
446        let error = load("events.txt", LoadOptions::default()).unwrap_err();
447        assert!(matches!(error, IoError::Io(_)));
448    }
449
450    #[test]
451    fn aedat_dispatches_to_the_reader() {
452        // `.aedat` reaches the AEDAT 2.0 reader, so a missing file is an IO error.
453        let error = load("recording.aedat", LoadOptions::default()).unwrap_err();
454        assert!(matches!(error, IoError::Io(_)));
455    }
456
457    #[test]
458    fn prophesee_dat_dispatches_to_the_reader() {
459        let error = load("recording.dat", LoadOptions::default()).unwrap_err();
460        assert!(matches!(error, IoError::Io(_)));
461    }
462
463    #[test]
464    fn aedat4_and_prophesee_raw_are_unsupported() {
465        // These extensions are recognised but their formats are not implemented yet.
466        for path in ["recording.aedat4", "recording.raw"] {
467            match load(path, LoadOptions::default()) {
468                Err(IoError::Unsupported(_)) => {}
469                other => panic!("expected unsupported for {path}, got {other:?}"),
470            }
471        }
472    }
473
474    fn sample_source() -> MemorySliceSource {
475        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
476        builder.push(0, 0, 0, true);
477        builder.push(1, 1, 10, false);
478        builder.push(2, 2, 20, true);
479        builder.push(0, 1, 30, false);
480        MemorySliceSource::new(builder.build())
481    }
482
483    #[test]
484    fn memory_source_reports_span_and_count() {
485        let source = sample_source();
486        assert_eq!(source.n_events(), 4);
487        assert_eq!(source.time_span(), (0, 30));
488    }
489
490    #[test]
491    fn memory_source_slices_by_time_and_index() {
492        let source = sample_source();
493
494        // Half-open [10, 30) keeps t = 10 and 20.
495        assert_eq!(source.slice_time(10, 30).unwrap().ts(), &[10, 20]);
496        assert_eq!(source.slice_index(1, 3).unwrap().ts(), &[10, 20]);
497        assert_eq!(source.slice_index(2, 100).unwrap().len(), 2); // hi clamped to len
498        assert!(source.slice_time(100, 200).unwrap().is_empty());
499    }
500
501    #[test]
502    fn open_rejects_unknown_extension() {
503        // `Reader` is a trait object (not `Debug`), so match rather than `unwrap_err`.
504        match open("recording.mp4", LoadOptions::default()) {
505            Err(IoError::Unsupported(_)) => {}
506            Err(other) => panic!("expected unsupported error, got {other:?}"),
507            Ok(_) => panic!("expected an error for an unknown extension"),
508        }
509    }
510}