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;
7mod e2vid;
8#[cfg(feature = "hdf5")]
9mod h5;
10mod npz;
11mod prophesee;
12mod prophesee_raw;
13mod text;
14
15pub use aedat::read_aedat;
16pub use bag::{open_bag_slice, read_bag, write_bag, BagSliceSource};
17pub use e2vid::{write_e2vid, E2vidWriter};
18#[cfg(feature = "hdf5")]
19pub use h5::{
20    open_hdf5_slice, read_hdf5, read_hdf5_frame, write_hdf5_frame, write_hdf5_stream,
21    Hdf5EventSink, Hdf5FrameSink, Hdf5SliceSource,
22};
23pub use npz::{read_npz, read_npz_frame, write_npz_frame, write_npz_stream};
24pub use prophesee::read_dat;
25pub use prophesee_raw::{open_raw_slice, read_raw};
26pub use text::{
27    load_rows, open_text_slice, read_text, write_text_stream, ColumnOrder, RawRow, TextOptions,
28    TextReader, TimeUnit,
29};
30
31use crate::representation::EventFrame;
32use crate::viz::{render_frame, Colormap};
33
34/// A single event as produced by a reader, before it is placed on the sensor grid.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct RawEvent {
37    pub x: u16,
38    pub y: u16,
39    pub t: i64,
40    pub p: bool,
41}
42
43/// A bounded-memory source of events. Readers parse on demand so multi-gigabyte
44/// files never need to be resident; the consumer decides what to accumulate.
45pub trait EventSource {
46    fn sensor_size(&self) -> (usize, usize);
47    fn timestamp_scale_ms(&self) -> f64;
48    /// Returns the next event, or `None` at end of stream.
49    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
50}
51
52/// Drains a source into an in-memory [`EventStream`], dropping out-of-bounds events.
53pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
54    read_capped(source, None)
55}
56
57/// Like [`read_all`] but stops after `max` kept events (for previewing huge files).
58pub fn read_capped(
59    mut source: impl EventSource,
60    max: Option<usize>,
61) -> Result<EventStream, IoError> {
62    let (width, height) = source.sensor_size();
63    let mut builder = EventStreamBuilder::new(width, height, source.timestamp_scale_ms());
64    while let Some(event) = source.next_event()? {
65        builder.push(event.x, event.y, event.t, event.p);
66        if max.is_some_and(|max| builder.len() >= max) {
67            break;
68        }
69    }
70    Ok(builder.build())
71}
72
73/// Options for the unified [`load`] entry point. Most fields apply to a single
74/// format; readers ignore the ones they do not need.
75#[derive(Clone, Debug, Default)]
76pub struct LoadOptions {
77    /// `(width, height)`. `None` infers it from the data (coordinate range), or from the
78    /// message for rosbag. An explicit value overrides and, for HDF5, skips the scan.
79    pub sensor_size: Option<(usize, usize)>,
80    /// Timestamp unit. `None` infers it (HDF5/text): a fractional text value means
81    /// seconds, otherwise the unit is chosen from the recording span (see
82    /// `TimeUnit::infer_from_span`). Ignored for rosbag (always ROS `sec`+`nsec`).
83    pub time_unit: Option<TimeUnit>,
84    /// Text column order.
85    pub order: ColumnOrder,
86    /// Rosbag topic to read (defaults to `/davis/left/events`).
87    pub topic: Option<String>,
88    /// Cap on the number of events to read.
89    pub max_events: Option<usize>,
90    /// Absolute timestamp (µs, the file's own time base): events before it are skipped.
91    /// `max_events` then caps the events *after* the offset. `None`/`<= 0` reads from the start.
92    pub offset: Option<i64>,
93    /// Explicit x/y/t/p column names, overriding auto-detection when a file's layout can't
94    /// be guessed. For HDF5 each value is a dataset path (a compound field is `dataset/field`);
95    /// for text/CSV a header column name or 0-based index. `None` auto-detects. Readers that
96    /// don't need it (rosbag, aedat, dat) ignore it.
97    pub keys: Option<EventKeys>,
98}
99
100/// User-supplied names for the four event columns, the escape hatch when auto-detection
101/// can't identify them. Interpreted per format (HDF5 dataset paths, text header
102/// names/indices); see [`LoadOptions::keys`].
103#[derive(Clone, Debug)]
104pub struct EventKeys {
105    pub x: String,
106    pub y: String,
107    pub t: String,
108    pub p: String,
109}
110
111/// Column index of each event field in the `[x, y, t, p]` ordering the readers share.
112pub(crate) const X: usize = 0;
113pub(crate) const Y: usize = 1;
114pub(crate) const T: usize = 2;
115pub(crate) const P: usize = 3;
116
117/// Case-insensitive name synonyms for each event field, used by the HDF5 and text readers
118/// to identify x/y/t/p under varied headings (dataset names, CSV headers). Order within a
119/// list is the tie-break preference — earlier is more canonical.
120pub(crate) const ROLE_KEYS: [&[&str]; 4] = [
121    &[
122        "x",
123        "xs",
124        "x_coordinate",
125        "x_coordinates",
126        "u",
127        "col",
128        "cols",
129        "column",
130        "columns",
131    ],
132    &[
133        "y",
134        "ys",
135        "y_coordinate",
136        "y_coordinates",
137        "v",
138        "row",
139        "rows",
140    ],
141    &[
142        "t",
143        "ts",
144        "time",
145        "times",
146        "timestamp",
147        "timestamps",
148        "time_stamp",
149    ],
150    &[
151        "p",
152        "ps",
153        "pol",
154        "pols",
155        "polarity",
156        "polarities",
157        "polarity_bit",
158        "polarity_bits",
159        "sign",
160    ],
161];
162
163/// The event field (`X`/`Y`/`T`/`P`) a column or dataset base name denotes, matched
164/// case-insensitively against [`ROLE_KEYS`]; `None` if it matches none. The synonym lists
165/// are disjoint, so at most one role matches.
166pub(crate) fn role_of(name: &str) -> Option<usize> {
167    let lower = name.to_ascii_lowercase();
168    ROLE_KEYS
169        .iter()
170        .position(|keys| keys.contains(&lower.as_str()))
171}
172
173/// Rank of `name` within `role`'s synonym list (lower = more canonical), used to choose
174/// between several names in one group that map to the same role; `None` if it isn't one.
175#[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
176pub(crate) fn role_rank(role: usize, name: &str) -> Option<usize> {
177    let lower = name.to_ascii_lowercase();
178    ROLE_KEYS[role].iter().position(|key| *key == lower)
179}
180
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182enum Format {
183    Npz,
184    Text,
185    Hdf5,
186    Rosbag,
187    Aedat,
188    Aedat4,
189    PropheseeDat,
190    PropheseeRaw,
191    Png,
192    /// E2VID's `t x y p` text interchange (see [`e2vid`]) — write-only.
193    E2vid,
194}
195
196fn detect_format(path: &Path) -> Result<Format, IoError> {
197    let extension = path
198        .extension()
199        .and_then(|extension| extension.to_str())
200        .map(str::to_ascii_lowercase);
201    match extension.as_deref() {
202        Some("npz") => Ok(Format::Npz),
203        Some("txt") | Some("csv") => Ok(Format::Text),
204        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
205        Some("bag") => Ok(Format::Rosbag),
206        Some("aedat") => Ok(Format::Aedat),
207        Some("aedat4") => Ok(Format::Aedat4),
208        Some("dat") => Ok(Format::PropheseeDat),
209        Some("raw") => Ok(Format::PropheseeRaw),
210        Some("png") => Ok(Format::Png),
211        Some("zip") => Ok(Format::E2vid),
212        Some(other) => Err(IoError::Unsupported(format!(
213            "unrecognised file extension: .{other}"
214        ))),
215        None => Err(IoError::Unsupported(
216            "file has no extension to detect its format".to_owned(),
217        )),
218    }
219}
220
221/// Whether `path`'s format can be written incrementally with [`Hdf5EventSink`] — currently only
222/// HDF5. The live recorder uses this to stream a camera to disk window-by-window when it can, and
223/// fall back to buffering the whole recording in memory (then [`save_stream`]) when it can't.
224pub fn supports_event_append(path: impl AsRef<Path>) -> bool {
225    matches!(detect_format(path.as_ref()), Ok(Format::Hdf5))
226}
227
228/// Whether `path` (with an optional explicit `format`) names an E2VID export — the target
229/// [`E2vidWriter`] streams into. Lets a caller that owns the read loop, like the Python
230/// `save(reader, …)`, pick the incremental path without duplicating extension rules.
231pub fn is_e2vid_target(path: impl AsRef<Path>, format: Option<&str>) -> bool {
232    let options = SaveOptions {
233        format: format.map(str::to_owned),
234        ..SaveOptions::default()
235    };
236    matches!(requested_format(path.as_ref(), &options), Ok(Format::E2vid))
237}
238
239/// Loads events from any supported file, detected by extension — the OpenCV-style
240/// single entry point. Supported today: `.npz`, `.txt`/`.csv`, `.bag`, `.h5`/`.hdf5`,
241/// `.aedat` (AEDAT 2.0), and `.dat` (Prophesee CD).
242pub fn load(path: impl AsRef<Path>, options: LoadOptions) -> Result<EventStream, IoError> {
243    let path = path.as_ref();
244    let Some(cutoff) = options.offset.filter(|&offset| offset > 0) else {
245        return load_format(path, &options);
246    };
247    // The offset is an absolute timestamp, so the whole recording must be read before
248    // skipping; the cap then applies to the events at/after the offset.
249    let mut read_options = options.clone();
250    read_options.max_events = None;
251    load_format(path, &read_options).map(|stream| skip_before(stream, cutoff, options.max_events))
252}
253
254/// Drops events before the absolute timestamp `cutoff` (µs), then keeps at most `max` of the rest.
255fn skip_before(stream: EventStream, cutoff: i64, max: Option<usize>) -> EventStream {
256    let (ts, (xs, ys, ps)) = (stream.ts(), (stream.xs(), stream.ys(), stream.ps()));
257    if ts.is_empty() {
258        return stream;
259    }
260    let (width, height) = stream.sensor_size();
261    let mut builder = EventStreamBuilder::new(width, height, stream.timestamp_scale_ms());
262    for index in (0..ts.len()).filter(|&index| ts[index] >= cutoff) {
263        builder.push(xs[index], ys[index], ts[index], ps[index]);
264        if max.is_some_and(|max| builder.len() >= max) {
265            break;
266        }
267    }
268    builder.build()
269}
270
271fn load_format(path: &Path, options: &LoadOptions) -> Result<EventStream, IoError> {
272    match detect_format(path)? {
273        Format::Npz => npz::read_npz(path, options.sensor_size),
274        Format::Text => text::load_text(path, options),
275        Format::Rosbag => bag::read_bag(path, options),
276        Format::Hdf5 => {
277            #[cfg(feature = "hdf5")]
278            {
279                h5::read_hdf5(path, options)
280            }
281            #[cfg(not(feature = "hdf5"))]
282            {
283                Err(IoError::Unsupported(
284                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
285                ))
286            }
287        }
288        Format::Aedat => aedat::read_aedat(path, options),
289        Format::Aedat4 => Err(IoError::Unsupported(
290            "AEDAT4 (.aedat4, iniVation DV FlatBuffer/LZ4) reading is not implemented yet"
291                .to_owned(),
292        )),
293        Format::PropheseeDat => prophesee::read_dat(path, options),
294        Format::PropheseeRaw => prophesee_raw::read_raw(path, options),
295        Format::Png => Err(IoError::Unsupported(
296            "PNG is a frame export format, not an event stream; use save_frame".to_owned(),
297        )),
298        Format::E2vid => Err(IoError::Unsupported(
299            "E2VID's .zip is an export format for that reconstruction pipeline, not one eventcv \
300             reads back; save an npz/h5/bag alongside it to keep the recording"
301                .to_owned(),
302        )),
303    }
304}
305
306/// Random-access view over a file's events: fetch an arbitrary time or count range
307/// without materialising the whole stream. This backs the lazy [`open`] handle — the
308/// OpenCV `VideoCapture` to [`load`]'s `imread`. Each call returns a new [`EventStream`].
309pub trait SliceSource: Send {
310    fn sensor_size(&self) -> (usize, usize);
311    fn timestamp_scale_ms(&self) -> f64;
312    /// Total events in the file.
313    fn n_events(&self) -> usize;
314    /// `(t_min, t_max)` in microseconds across the whole file; `(0, 0)` when empty.
315    fn time_span(&self) -> (i64, i64);
316    /// Events whose index lies in `[i0, i1)` (clamped to the file).
317    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
318    /// Events whose timestamp (µs) lies in the half-open window `[t0, t1)`.
319    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
320
321    /// Per-pixel event counts over the whole file (row-major `width·height`), out-of-bounds
322    /// events dropped — what the reader's hot-pixel pre-scan needs. The default tallies through
323    /// `slice_index` in bounded chunks; a source that can read coordinates alone (HDF5) overrides
324    /// this to skip the `t`/`p` columns and stream construction, which is most of the work.
325    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
326        const CHUNK: usize = 8_000_000;
327        let (width, height) = self.sensor_size();
328        let mut counts = vec![0u64; width * height];
329        let total = self.n_events();
330        let mut start = 0;
331        while start < total {
332            let end = (start + CHUNK).min(total);
333            self.slice_index(start, end)?.add_pixel_counts(&mut counts);
334            start = end;
335        }
336        Ok(counts)
337    }
338}
339
340/// A [`SliceSource`] backed by an already-loaded stream — the universal fallback for
341/// formats without native random access (npz/txt/bag today). Slicing is entirely
342/// in-RAM, so `open()` returns a working handle for every supported format from day one.
343pub struct MemorySliceSource {
344    stream: EventStream,
345}
346
347impl MemorySliceSource {
348    pub fn new(stream: EventStream) -> Self {
349        Self { stream }
350    }
351
352    fn rebuild(&self, indices: impl Iterator<Item = usize>) -> EventStream {
353        let (width, height) = self.stream.sensor_size();
354        let mut builder = EventStreamBuilder::new(width, height, self.stream.timestamp_scale_ms());
355        let (xs, ys, ts, ps) = (
356            self.stream.xs(),
357            self.stream.ys(),
358            self.stream.ts(),
359            self.stream.ps(),
360        );
361        for index in indices {
362            builder.push(xs[index], ys[index], ts[index], ps[index]);
363        }
364        builder.build()
365    }
366}
367
368impl SliceSource for MemorySliceSource {
369    fn sensor_size(&self) -> (usize, usize) {
370        self.stream.sensor_size()
371    }
372
373    fn timestamp_scale_ms(&self) -> f64 {
374        self.stream.timestamp_scale_ms()
375    }
376
377    fn n_events(&self) -> usize {
378        self.stream.len()
379    }
380
381    fn time_span(&self) -> (i64, i64) {
382        let ts = self.stream.ts();
383        match (ts.iter().min(), ts.iter().max()) {
384            (Some(&min), Some(&max)) => (min, max),
385            _ => (0, 0),
386        }
387    }
388
389    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
390        let i0 = i0.min(self.stream.len());
391        let i1 = i1.clamp(i0, self.stream.len());
392        Ok(self.rebuild(i0..i1))
393    }
394
395    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
396        let ts = self.stream.ts();
397        Ok(self.rebuild((0..ts.len()).filter(|&index| ts[index] >= t0 && ts[index] < t1)))
398    }
399
400    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
401        // Already resident — tally straight from the stream, no chunked rebuilds.
402        let (width, height) = self.stream.sensor_size();
403        let mut counts = vec![0u64; width * height];
404        self.stream.add_pixel_counts(&mut counts);
405        Ok(counts)
406    }
407}
408
409/// A boxed [`SliceSource`] — the handle [`open`] returns.
410pub type Reader = Box<dyn SliceSource>;
411
412/// Opens a file for lazy slicing, detected by extension (the `VideoCapture` analogue to
413/// [`load`]'s `imread`). HDF5 is sliced in place by binary-searching its timestamp
414/// dataset; every other format is loaded once and sliced in memory. Same `LoadOptions`
415/// as [`load`] (`max_events` is ignored — slicing supersedes it).
416pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Reader, IoError> {
417    let path = path.as_ref();
418    match detect_format(path)? {
419        Format::Hdf5 => {
420            #[cfg(feature = "hdf5")]
421            {
422                Ok(Box::new(h5::open_hdf5_slice(path, &options)?))
423            }
424            #[cfg(not(feature = "hdf5"))]
425            {
426                Err(IoError::Unsupported(
427                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
428                ))
429            }
430        }
431        Format::Text => Ok(Box::new(text::open_text_slice(path, &options)?)),
432        Format::Rosbag => Ok(Box::new(bag::open_bag_slice(path, &options)?)),
433        Format::PropheseeRaw => Ok(Box::new(prophesee_raw::open_raw_slice(path, &options)?)),
434        _ => Ok(Box::new(MemorySliceSource::new(load(path, options)?))),
435    }
436}
437
438/// Options for the [`save_stream`] / [`save_frame`] writers — the symmetric mirror of
439/// [`LoadOptions`]. Most formats ignore every field; readers and writers agree on the rest.
440#[derive(Clone, Debug, Default)]
441pub struct SaveOptions {
442    /// Rosbag topic to write the `dvs_msgs/EventArray` messages on (defaults to
443    /// `/davis/left/events`, matching the reader).
444    pub topic: Option<String>,
445    /// PNG frame export only: the colour map (default [`Colormap::Viridis`]).
446    pub colormap: Colormap,
447    /// PNG frame export only: auto-contrast the field to its data range. `None` = `true`.
448    pub normalize: Option<bool>,
449    /// Overrides the format the extension implies. The one case that needs it is writing E2VID's
450    /// layout to a `.txt` (which otherwise means eventcv's own text format); `.zip` already
451    /// implies it. `None` follows the extension.
452    pub format: Option<String>,
453}
454
455/// The format `path` and `options` together ask for: an explicit `format` name, else the
456/// extension.
457fn requested_format(path: &Path, options: &SaveOptions) -> Result<Format, IoError> {
458    match options.format.as_deref() {
459        None => detect_format(path),
460        Some("e2vid") => Ok(Format::E2vid),
461        Some("npz") => Ok(Format::Npz),
462        Some("txt") | Some("csv") | Some("text") => Ok(Format::Text),
463        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
464        Some("bag") | Some("rosbag") => Ok(Format::Rosbag),
465        Some("png") => Ok(Format::Png),
466        Some(other) => Err(IoError::Unsupported(format!(
467            "unknown format: {other} (expected npz, txt, h5, bag, png, or e2vid)"
468        ))),
469    }
470}
471
472/// Persists an [`EventStream`] to `path`, the format chosen by extension — the symmetric
473/// counterpart of [`load`]. npz/HDF5/rosbag round-trip exactly (metadata stored); txt
474/// stores `t x y p` and recovers sensor size / time unit on load via inference or options.
475/// `.zip` (or `format: "e2vid"`) writes E2VID's interchange text instead, which eventcv does not
476/// read back.
477pub fn save_stream(
478    path: impl AsRef<Path>,
479    stream: &EventStream,
480    options: &SaveOptions,
481) -> Result<(), IoError> {
482    let path = path.as_ref();
483    match requested_format(path, options)? {
484        Format::Npz => npz::write_npz_stream(path, stream),
485        Format::Text => text::write_text_stream(path, stream),
486        Format::E2vid => e2vid::write_e2vid(path, stream),
487        Format::Rosbag => bag::write_bag(path, stream, options.topic.as_deref()),
488        Format::Hdf5 => {
489            #[cfg(feature = "hdf5")]
490            {
491                h5::write_hdf5_stream(path, stream)
492            }
493            #[cfg(not(feature = "hdf5"))]
494            {
495                Err(IoError::Unsupported(
496                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
497                ))
498            }
499        }
500        other => Err(IoError::Unsupported(format!(
501            "saving an event stream as {other:?} is not supported"
502        ))),
503    }
504}
505
506/// Persists an [`EventFrame`] (a computed representation) to `path`, preserving its shape,
507/// dtype, `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
508pub fn save_frame(
509    path: impl AsRef<Path>,
510    frame: &EventFrame,
511    options: &SaveOptions,
512) -> Result<(), IoError> {
513    let path = path.as_ref();
514    match detect_format(path)? {
515        Format::Npz => npz::write_npz_frame(path, frame),
516        Format::Hdf5 => {
517            #[cfg(feature = "hdf5")]
518            {
519                h5::write_hdf5_frame(path, frame)
520            }
521            #[cfg(not(feature = "hdf5"))]
522            {
523                Err(IoError::Unsupported(
524                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
525                ))
526            }
527        }
528        Format::Png => write_png_frame(path, frame, options),
529        other => Err(IoError::Unsupported(format!(
530            "saving an event frame as {other:?} is not supported"
531        ))),
532    }
533}
534
535/// Renders a frame through [`render_frame`] (colormapped 2-D view) and encodes it as an
536/// 8-bit RGB PNG. Unlike npz/HDF5 this is a *view*, not a round-trippable dump.
537fn write_png_frame(path: &Path, frame: &EventFrame, options: &SaveOptions) -> Result<(), IoError> {
538    let image = render_frame(frame, options.colormap, options.normalize.unwrap_or(true));
539    let file = std::fs::File::create(path)?;
540    let writer = std::io::BufWriter::new(file);
541    let mut encoder = png::Encoder::new(writer, image.width as u32, image.height as u32);
542    encoder.set_color(png::ColorType::Rgb);
543    encoder.set_depth(png::BitDepth::Eight);
544    encoder
545        .write_header()
546        .and_then(|mut writer| writer.write_image_data(&image.pixels))
547        .map_err(png_encoding_error)
548}
549
550fn png_encoding_error(error: png::EncodingError) -> IoError {
551    match error {
552        png::EncodingError::IoError(error) => IoError::Io(error),
553        other => IoError::Format(other.to_string()),
554    }
555}
556
557/// Writes an ROI mask (row-major `width·height`, `true` = keep) as an 8-bit greyscale `.png`:
558/// white where events are kept, black where they are dropped. Lets an ROI drawn or computed once
559/// be reused across sessions, and inspected in any image viewer. Read it back with [`read_mask`].
560pub fn write_mask(
561    path: impl AsRef<Path>,
562    mask: &[bool],
563    width: usize,
564    height: usize,
565) -> Result<(), IoError> {
566    let path = path.as_ref();
567    if !matches!(detect_format(path)?, Format::Png) {
568        return Err(IoError::Unsupported("masks are saved as .png".to_owned()));
569    }
570    if width == 0 || height == 0 {
571        return Err(IoError::InvalidSensorSize);
572    }
573    if mask.len() != width * height {
574        return Err(IoError::Format(format!(
575            "mask has {} pixels, expected {} for a {width}x{height} grid",
576            mask.len(),
577            width * height
578        )));
579    }
580    let pixels: Vec<u8> = mask.iter().map(|&keep| if keep { 255 } else { 0 }).collect();
581    let writer = std::io::BufWriter::new(std::fs::File::create(path)?);
582    let mut encoder = png::Encoder::new(writer, width as u32, height as u32);
583    encoder.set_color(png::ColorType::Grayscale);
584    encoder.set_depth(png::BitDepth::Eight);
585    encoder
586        .write_header()
587        .and_then(|mut writer| writer.write_image_data(&pixels))
588        .map_err(png_encoding_error)
589}
590
591/// Reads an ROI mask from a `.png`, returning it as `(mask, width, height)` (row-major, `true` =
592/// keep). Any 8-bit-normalisable PNG works — greyscale, palette, or colour, with or without alpha —
593/// so a mask binarised in another tool loads as-is: a pixel is **kept where it is non-black and not
594/// fully transparent**. The counterpart of [`write_mask`].
595pub fn read_mask(path: impl AsRef<Path>) -> Result<(Vec<bool>, usize, usize), IoError> {
596    let path = path.as_ref();
597    if !matches!(detect_format(path)?, Format::Png) {
598        return Err(IoError::Unsupported("masks are loaded from .png".to_owned()));
599    }
600    let mut decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?));
601    // Expands palette and sub-byte images and strips 16-bit samples, so everything below is 8-bit.
602    decoder.set_transformations(png::Transformations::normalize_to_color8());
603    let mut reader = decoder.read_info().map_err(png_decoding_error)?;
604    let mut buffer = vec![0; reader.output_buffer_size()];
605    let info = reader.next_frame(&mut buffer).map_err(png_decoding_error)?;
606    let (width, height) = (info.width as usize, info.height as usize);
607    let samples = info.color_type.samples();
608    let alpha = matches!(
609        info.color_type,
610        png::ColorType::GrayscaleAlpha | png::ColorType::Rgba
611    );
612    let mut mask = Vec::with_capacity(width * height);
613    for row in buffer.chunks_exact(info.line_size).take(height) {
614        for pixel in row[..width * samples].chunks_exact(samples) {
615            let (colour, opacity) = pixel.split_at(samples - usize::from(alpha));
616            mask.push(colour.iter().any(|&value| value != 0) && opacity.first() != Some(&0));
617        }
618    }
619    Ok((mask, width, height))
620}
621
622fn png_decoding_error(error: png::DecodingError) -> IoError {
623    match error {
624        png::DecodingError::IoError(error) => IoError::Io(error),
625        other => IoError::Format(other.to_string()),
626    }
627}
628
629/// Reads an [`EventFrame`] previously written by [`save_frame`], reconstructing its dtype,
630/// `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
631pub fn load_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
632    let path = path.as_ref();
633    match detect_format(path)? {
634        Format::Npz => npz::read_npz_frame(path),
635        Format::Hdf5 => {
636            #[cfg(feature = "hdf5")]
637            {
638                h5::read_hdf5_frame(path)
639            }
640            #[cfg(not(feature = "hdf5"))]
641            {
642                Err(IoError::Unsupported(
643                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
644                ))
645            }
646        }
647        other => Err(IoError::Unsupported(format!(
648            "loading an event frame from {other:?} is not supported"
649        ))),
650    }
651}
652
653#[derive(Debug)]
654pub enum IoError {
655    Io(io::Error),
656    Parse { line: usize, message: String },
657    Format(String),
658    InvalidSensorSize,
659    Unsupported(String),
660}
661
662impl fmt::Display for IoError {
663    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
664        match self {
665            Self::Io(error) => error.fmt(formatter),
666            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
667            Self::Format(message) => formatter.write_str(message),
668            Self::InvalidSensorSize => {
669                formatter.write_str("sensor width and height must be positive")
670            }
671            Self::Unsupported(message) => formatter.write_str(message),
672        }
673    }
674}
675
676impl Error for IoError {
677    fn source(&self) -> Option<&(dyn Error + 'static)> {
678        match self {
679            Self::Io(error) => Some(error),
680            _ => None,
681        }
682    }
683}
684
685impl From<io::Error> for IoError {
686    fn from(error: io::Error) -> Self {
687        Self::Io(error)
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::{
694        load, open, read_mask, write_mask, IoError, LoadOptions, MemorySliceSource, SliceSource,
695    };
696    use crate::EventStreamBuilder;
697
698    #[test]
699    fn unknown_extension_is_unsupported() {
700        let error = load("recording.mp4", LoadOptions::default()).unwrap_err();
701        assert!(matches!(error, IoError::Unsupported(_)));
702    }
703
704    #[test]
705    fn hdf5_extension_dispatches_per_feature() {
706        // `.h5` is always recognised; with the feature it reaches the reader (a missing
707        // file is then an IO error), without it the dispatch reports missing support.
708        let error = load("recording.h5", LoadOptions::default()).unwrap_err();
709        #[cfg(feature = "hdf5")]
710        assert!(matches!(error, IoError::Io(_)));
711        #[cfg(not(feature = "hdf5"))]
712        match error {
713            IoError::Unsupported(message) => assert!(message.contains("HDF5")),
714            other => panic!("expected unsupported error, got {other:?}"),
715        }
716    }
717
718    #[test]
719    fn text_missing_file_is_reported() {
720        // Text no longer requires sensor_size (it infers); a missing file is an IO error.
721        let error = load("events.txt", LoadOptions::default()).unwrap_err();
722        assert!(matches!(error, IoError::Io(_)));
723    }
724
725    #[test]
726    fn aedat_dispatches_to_the_reader() {
727        // `.aedat` reaches the AEDAT 2.0 reader, so a missing file is an IO error.
728        let error = load("recording.aedat", LoadOptions::default()).unwrap_err();
729        assert!(matches!(error, IoError::Io(_)));
730    }
731
732    #[test]
733    fn prophesee_dat_dispatches_to_the_reader() {
734        let error = load("recording.dat", LoadOptions::default()).unwrap_err();
735        assert!(matches!(error, IoError::Io(_)));
736    }
737
738    #[test]
739    fn aedat4_is_unsupported_and_raw_dispatches() {
740        match load("recording.aedat4", LoadOptions::default()) {
741            Err(IoError::Unsupported(_)) => {}
742            other => panic!("expected unsupported for recording.aedat4, got {other:?}"),
743        }
744        assert!(matches!(
745            load("recording.raw", LoadOptions::default()),
746            Err(IoError::Io(_))
747        ));
748    }
749
750    fn sample_source() -> MemorySliceSource {
751        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
752        builder.push(0, 0, 0, true);
753        builder.push(1, 1, 10, false);
754        builder.push(2, 2, 20, true);
755        builder.push(0, 1, 30, false);
756        MemorySliceSource::new(builder.build())
757    }
758
759    #[test]
760    fn memory_source_reports_span_and_count() {
761        let source = sample_source();
762        assert_eq!(source.n_events(), 4);
763        assert_eq!(source.time_span(), (0, 30));
764    }
765
766    #[test]
767    fn memory_source_slices_by_time_and_index() {
768        let source = sample_source();
769
770        // Half-open [10, 30) keeps t = 10 and 20.
771        assert_eq!(source.slice_time(10, 30).unwrap().ts(), &[10, 20]);
772        assert_eq!(source.slice_index(1, 3).unwrap().ts(), &[10, 20]);
773        assert_eq!(source.slice_index(2, 100).unwrap().len(), 2); // hi clamped to len
774        assert!(source.slice_time(100, 200).unwrap().is_empty());
775    }
776
777    #[test]
778    fn open_rejects_unknown_extension() {
779        // `Reader` is a trait object (not `Debug`), so match rather than `unwrap_err`.
780        match open("recording.mp4", LoadOptions::default()) {
781            Err(IoError::Unsupported(_)) => {}
782            Err(other) => panic!("expected unsupported error, got {other:?}"),
783            Ok(_) => panic!("expected an error for an unknown extension"),
784        }
785    }
786
787    #[test]
788    fn mask_png_round_trips_and_validates() {
789        let path = std::env::temp_dir().join(format!("eventcv_mask_{}.png", std::process::id()));
790        let mask = crate::mask::ellipse(16, 12, 8.0, 6.0, 5.0, 4.0);
791
792        write_mask(&path, &mask, 16, 12).unwrap();
793        let (loaded, width, height) = read_mask(&path).unwrap();
794        assert_eq!((width, height), (16, 12));
795        assert_eq!(loaded, mask);
796        std::fs::remove_file(&path).ok();
797
798        // A mask that doesn't match the grid it claims, and a non-PNG target, both report why.
799        assert!(matches!(
800            write_mask(&path, &mask, 16, 11),
801            Err(IoError::Format(_))
802        ));
803        assert!(matches!(
804            write_mask("mask.npz", &mask, 16, 12),
805            Err(IoError::Unsupported(_))
806        ));
807    }
808}