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 aedat4;
7mod bag;
8mod e2vid;
9#[cfg(feature = "hdf5")]
10mod h5;
11mod npz;
12mod prophesee;
13mod prophesee_raw;
14mod text;
15
16pub use aedat::{open_aedat_slice, read_aedat, AedatEventSink};
17pub use aedat4::{
18    open_aedat4_slice, read_aedat4, Aedat4EventSink, Compression as PacketCompression,
19};
20pub use bag::{
21    bag_topics, open_bag_slice, read_bag, read_bag_camera_info, read_bag_frames, read_bag_imu,
22    write_bag, BagEventSink, BagSliceSource, ImuSample,
23};
24pub use e2vid::{write_e2vid, E2vidWriter};
25#[cfg(feature = "hdf5")]
26pub use h5::{
27    open_hdf5_slice, read_hdf5, read_hdf5_frame, write_hdf5_frame, write_hdf5_stream,
28    Hdf5EventSink, Hdf5FrameSink, Hdf5SliceSource,
29};
30pub use npz::{read_npz, read_npz_frame, write_npz_frame, write_npz_stream, NpzEventSink};
31pub use prophesee::{read_dat, DatEventSink};
32pub use prophesee_raw::{decode_words, open_raw_slice, read_raw, EvtVersion, RawEventSink};
33pub use text::{
34    load_rows, open_text_slice, read_text, write_text_stream, ColumnOrder, RawRow, TextEventSink,
35    TextOptions, TextReader, TimeUnit,
36};
37
38use crate::representation::{EventFrame, EventFrameData};
39use crate::viz::{render_frame, Colormap};
40
41/// A single event as produced by a reader, before it is placed on the sensor grid.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct RawEvent {
44    pub x: u16,
45    pub y: u16,
46    pub t: i64,
47    pub p: bool,
48}
49
50/// A bounded-memory source of events. Readers parse on demand so multi-gigabyte
51/// files never need to be resident; the consumer decides what to accumulate.
52pub trait EventSource {
53    fn sensor_size(&self) -> (usize, usize);
54    fn timestamp_scale_ms(&self) -> f64;
55    /// Returns the next event, or `None` at end of stream.
56    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
57}
58
59/// Drains a source into an in-memory [`EventStream`], dropping out-of-bounds events.
60pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
61    read_capped(source, None)
62}
63
64/// Like [`read_all`] but stops after `max` kept events (for previewing huge files).
65pub fn read_capped(
66    mut source: impl EventSource,
67    max: Option<usize>,
68) -> Result<EventStream, IoError> {
69    let (width, height) = source.sensor_size();
70    let mut builder = EventStreamBuilder::new(width, height, source.timestamp_scale_ms());
71    while let Some(event) = source.next_event()? {
72        builder.push(event.x, event.y, event.t, event.p);
73        if max.is_some_and(|max| builder.len() >= max) {
74            break;
75        }
76    }
77    Ok(builder.build())
78}
79
80/// Options for the unified [`load`] entry point. Most fields apply to a single
81/// format; readers ignore the ones they do not need.
82#[derive(Clone, Debug, Default)]
83pub struct LoadOptions {
84    /// `(width, height)`. `None` infers it from the data (coordinate range), or from the
85    /// message for rosbag. An explicit value overrides and, for HDF5, skips the scan.
86    pub sensor_size: Option<(usize, usize)>,
87    /// Timestamp unit. `None` infers it (HDF5/text): a fractional text value means
88    /// seconds, otherwise the unit is chosen from the recording span (see
89    /// `TimeUnit::infer_from_span`). Ignored for rosbag (always ROS `sec`+`nsec`).
90    pub time_unit: Option<TimeUnit>,
91    /// Text column order.
92    pub order: ColumnOrder,
93    /// Rosbag topic to read (defaults to `/davis/left/events`).
94    pub topic: Option<String>,
95    /// Cap on the number of events to read.
96    pub max_events: Option<usize>,
97    /// Absolute timestamp (µs, the file's own time base): events before it are skipped.
98    /// `max_events` then caps the events *after* the offset. `None`/`<= 0` reads from the start.
99    pub offset: Option<i64>,
100    /// Explicit x/y/t/p column names, overriding auto-detection when a file's layout can't
101    /// be guessed. For HDF5 each value is a dataset path (a compound field is `dataset/field`);
102    /// for text/CSV a header column name or 0-based index. `None` auto-detects. Readers that
103    /// don't need it (rosbag, aedat, dat) ignore it.
104    pub keys: Option<EventKeys>,
105}
106
107/// User-supplied names for the four event columns, the escape hatch when auto-detection
108/// can't identify them. Interpreted per format (HDF5 dataset paths, text header
109/// names/indices); see [`LoadOptions::keys`].
110#[derive(Clone, Debug)]
111pub struct EventKeys {
112    pub x: String,
113    pub y: String,
114    pub t: String,
115    pub p: String,
116}
117
118/// Column index of each event field in the `[x, y, t, p]` ordering the readers share.
119pub(crate) const X: usize = 0;
120pub(crate) const Y: usize = 1;
121pub(crate) const T: usize = 2;
122pub(crate) const P: usize = 3;
123
124/// Case-insensitive name synonyms for each event field, used by the HDF5 and text readers
125/// to identify x/y/t/p under varied headings (dataset names, CSV headers). Order within a
126/// list is the tie-break preference — earlier is more canonical.
127pub(crate) const ROLE_KEYS: [&[&str]; 4] = [
128    &[
129        "x",
130        "xs",
131        "x_coordinate",
132        "x_coordinates",
133        "u",
134        "col",
135        "cols",
136        "column",
137        "columns",
138    ],
139    &[
140        "y",
141        "ys",
142        "y_coordinate",
143        "y_coordinates",
144        "v",
145        "row",
146        "rows",
147    ],
148    &[
149        "t",
150        "ts",
151        "time",
152        "times",
153        "timestamp",
154        "timestamps",
155        "time_stamp",
156    ],
157    &[
158        "p",
159        "ps",
160        "pol",
161        "pols",
162        "polarity",
163        "polarities",
164        "polarity_bit",
165        "polarity_bits",
166        "sign",
167    ],
168];
169
170/// The event field (`X`/`Y`/`T`/`P`) a column or dataset base name denotes, matched
171/// case-insensitively against [`ROLE_KEYS`]; `None` if it matches none. The synonym lists
172/// are disjoint, so at most one role matches.
173pub(crate) fn role_of(name: &str) -> Option<usize> {
174    let lower = name.to_ascii_lowercase();
175    ROLE_KEYS
176        .iter()
177        .position(|keys| keys.contains(&lower.as_str()))
178}
179
180/// Rank of `name` within `role`'s synonym list (lower = more canonical), used to choose
181/// between several names in one group that map to the same role; `None` if it isn't one.
182#[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
183pub(crate) fn role_rank(role: usize, name: &str) -> Option<usize> {
184    let lower = name.to_ascii_lowercase();
185    ROLE_KEYS[role].iter().position(|key| *key == lower)
186}
187
188#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189enum Format {
190    Npz,
191    Text,
192    Hdf5,
193    Rosbag,
194    Aedat,
195    Aedat4,
196    PropheseeDat,
197    PropheseeRaw,
198    Png,
199    /// E2VID's `t x y p` text interchange (see [`e2vid`]) — write-only.
200    E2vid,
201}
202
203fn detect_format(path: &Path) -> Result<Format, IoError> {
204    let extension = path
205        .extension()
206        .and_then(|extension| extension.to_str())
207        .map(str::to_ascii_lowercase);
208    match extension.as_deref() {
209        Some("npz") => Ok(Format::Npz),
210        Some("txt") | Some("csv") => Ok(Format::Text),
211        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
212        Some("bag") => Ok(Format::Rosbag),
213        Some("aedat") => Ok(Format::Aedat),
214        Some("aedat4") => Ok(Format::Aedat4),
215        Some("dat") => Ok(Format::PropheseeDat),
216        Some("raw") => Ok(Format::PropheseeRaw),
217        Some("png") => Ok(Format::Png),
218        Some("zip") => Ok(Format::E2vid),
219        Some(other) => Err(IoError::Unsupported(format!(
220            "unrecognised file extension: .{other}"
221        ))),
222        None => Err(IoError::Unsupported(
223            "file has no extension to detect its format".to_owned(),
224        )),
225    }
226}
227
228/// A writer that takes an event recording a window at a time, so a session longer than memory can
229/// be streamed to disk as it is captured. The counterpart of [`SliceSource`] on the read side.
230///
231/// Every event format eventcv writes implements this — the bulk writers in [`save_stream`] are the
232/// same sinks driven from a single stream. What differs is where the cost falls: HDF5, text, AEDAT
233/// and the Prophesee formats append straight to the file, while npz and rosbag have to fix up a
234/// header or an index at the end, which is what [`finish`](Self::finish) is for.
235pub trait EventSink: Send {
236    /// Appends one window's events. Empty windows are a no-op. The first non-empty window fixes
237    /// the sensor size and time base for the whole file.
238    fn append(&mut self, stream: &EventStream) -> Result<(), IoError>;
239
240    /// Total events written so far.
241    fn n_events(&self) -> usize;
242
243    /// Pushes buffered data at the file without closing it, so a crash mid-recording keeps
244    /// everything appended so far. Formats that only become readable at [`finish`](Self::finish)
245    /// (npz, rosbag) still flush their bytes, but the file stays incomplete until then.
246    fn flush(&mut self) -> Result<(), IoError>;
247
248    /// Writes whatever the format keeps for last — an index, a patched header — and closes.
249    fn finish(self: Box<Self>) -> Result<(), IoError>;
250}
251
252/// Opens `path` for window-by-window writing, the format chosen exactly as [`save_stream`] chooses
253/// it. Every event format is supported; `.png` is not an event container and is rejected.
254pub fn open_sink(
255    path: impl AsRef<Path>,
256    options: &SaveOptions,
257) -> Result<Box<dyn EventSink>, IoError> {
258    let path = path.as_ref();
259    match requested_format(path, options)? {
260        Format::Npz => Ok(Box::new(npz::NpzEventSink::create(path)?)),
261        Format::Text => Ok(Box::new(text::TextEventSink::create(path)?)),
262        Format::E2vid => Ok(Box::new(e2vid::E2vidWriter::create(path)?)),
263        Format::Rosbag => Ok(Box::new(bag::BagEventSink::create(
264            path,
265            options.topic.as_deref(),
266        )?)),
267        Format::Aedat => Ok(Box::new(aedat::AedatEventSink::create(path)?)),
268        Format::Aedat4 => Ok(Box::new(aedat4::Aedat4EventSink::create(
269            path,
270            options.packet_compression,
271        )?)),
272        Format::PropheseeDat => Ok(Box::new(prophesee::DatEventSink::create(path)?)),
273        Format::PropheseeRaw => Ok(Box::new(prophesee_raw::RawEventSink::create(
274            path,
275            options.evt_version(),
276        )?)),
277        Format::Hdf5 => {
278            #[cfg(feature = "hdf5")]
279            {
280                Ok(Box::new(h5::Hdf5EventSink::open(path, options.compression)?))
281            }
282            #[cfg(not(feature = "hdf5"))]
283            {
284                Err(IoError::Unsupported(
285                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
286                ))
287            }
288        }
289        Format::Png => Err(IoError::Unsupported(
290            "PNG is a frame export format, not an event container".to_owned(),
291        )),
292    }
293}
294
295/// Whether `path`'s format can be written incrementally with [`open_sink`] — every event format,
296/// which is to say everything but `.png`. The live recorder and the simulator use this to stream
297/// to disk window-by-window rather than buffering a whole session in memory.
298pub fn supports_event_append(path: impl AsRef<Path>) -> bool {
299    !matches!(
300        detect_format(path.as_ref()),
301        Err(_) | Ok(Format::Png)
302    )
303}
304
305/// Whether `path` (with an optional explicit `format`) names an E2VID export — the target
306/// [`E2vidWriter`] streams into. Lets a caller that owns the read loop, like the Python
307/// `save(reader, …)`, pick the incremental path without duplicating extension rules.
308pub fn is_e2vid_target(path: impl AsRef<Path>, format: Option<&str>) -> bool {
309    let options = SaveOptions {
310        format: format.map(str::to_owned),
311        ..SaveOptions::default()
312    };
313    matches!(requested_format(path.as_ref(), &options), Ok(Format::E2vid))
314}
315
316/// Loads events from any supported file, detected by extension — the OpenCV-style
317/// single entry point. Supported today: `.npz`, `.txt`/`.csv`, `.bag`, `.h5`/`.hdf5`,
318/// `.aedat` (AEDAT 2.0), `.aedat4` (AEDAT 4.0), and `.dat` (Prophesee CD).
319pub fn load(path: impl AsRef<Path>, options: LoadOptions) -> Result<EventStream, IoError> {
320    let path = path.as_ref();
321    let Some(cutoff) = options.offset.filter(|&offset| offset > 0) else {
322        return load_format(path, &options);
323    };
324    // The offset is an absolute timestamp, so the whole recording must be read before
325    // skipping; the cap then applies to the events at/after the offset.
326    let mut read_options = options.clone();
327    read_options.max_events = None;
328    load_format(path, &read_options).map(|stream| skip_before(stream, cutoff, options.max_events))
329}
330
331/// Drops events before the absolute timestamp `cutoff` (µs), then keeps at most `max` of the rest.
332fn skip_before(stream: EventStream, cutoff: i64, max: Option<usize>) -> EventStream {
333    let (ts, (xs, ys, ps)) = (stream.ts(), (stream.xs(), stream.ys(), stream.ps()));
334    if ts.is_empty() {
335        return stream;
336    }
337    let (width, height) = stream.sensor_size();
338    let mut builder = EventStreamBuilder::new(width, height, stream.timestamp_scale_ms());
339    for index in (0..ts.len()).filter(|&index| ts[index] >= cutoff) {
340        builder.push(xs[index], ys[index], ts[index], ps[index]);
341        if max.is_some_and(|max| builder.len() >= max) {
342            break;
343        }
344    }
345    builder.build()
346}
347
348fn load_format(path: &Path, options: &LoadOptions) -> Result<EventStream, IoError> {
349    match detect_format(path)? {
350        Format::Npz => npz::read_npz(path, options.sensor_size),
351        Format::Text => text::load_text(path, options),
352        Format::Rosbag => bag::read_bag(path, options),
353        Format::Hdf5 => {
354            #[cfg(feature = "hdf5")]
355            {
356                h5::read_hdf5(path, options)
357            }
358            #[cfg(not(feature = "hdf5"))]
359            {
360                Err(IoError::Unsupported(
361                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
362                ))
363            }
364        }
365        Format::Aedat => aedat::read_aedat(path, options),
366        Format::Aedat4 => aedat4::read_aedat4(path, options),
367        Format::PropheseeDat => prophesee::read_dat(path, options),
368        Format::PropheseeRaw => prophesee_raw::read_raw(path, options),
369        Format::Png => Err(IoError::Unsupported(
370            "PNG is a frame export format, not an event stream; use save_frame".to_owned(),
371        )),
372        Format::E2vid => Err(IoError::Unsupported(
373            "E2VID's .zip is an export format for that reconstruction pipeline, not one eventcv \
374             reads back; save an npz/h5/bag alongside it to keep the recording"
375                .to_owned(),
376        )),
377    }
378}
379
380/// Random-access view over a file's events: fetch an arbitrary time or count range
381/// without materialising the whole stream. This backs the lazy [`open`] handle — the
382/// OpenCV `VideoCapture` to [`load`]'s `imread`. Each call returns a new [`EventStream`].
383/// Receives decoded events one at a time — the alternative to materialising an [`EventStream`]
384/// when the caller only wants an accumulation of the events (a histogram, a count image, …).
385///
386/// A representation that implements this can be fed straight from a lazy source's decoder
387/// (see [`SliceSource::slice_time_into`]): no four-column stream is built, written and re-read
388/// per window, which for the file readers was the larger half of the cost of a dense frame.
389pub trait EventConsumer {
390    /// One event: the sensor coordinates, the timestamp in the source's own unit (µs for the
391    /// readers here) and the polarity (`true` = positive/ON). Coordinates are inside the
392    /// source's sensor; a sink need not test them again.
393    fn push(&mut self, x: u16, y: u16, t: i64, p: bool);
394}
395
396impl EventConsumer for EventStreamBuilder {
397    #[inline]
398    fn push(&mut self, x: u16, y: u16, t: i64, p: bool) {
399        self.push_in_bounds(x, y, t, p);
400    }
401}
402
403pub trait SliceSource: Send {
404    fn sensor_size(&self) -> (usize, usize);
405    fn timestamp_scale_ms(&self) -> f64;
406    /// Total events in the file.
407    fn n_events(&self) -> usize;
408    /// `(t_min, t_max)` in microseconds across the whole file; `(0, 0)` when empty.
409    fn time_span(&self) -> (i64, i64);
410    /// Events whose index lies in `[i0, i1)` (clamped to the file).
411    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
412    /// Events whose timestamp (µs) lies in the half-open window `[t0, t1)`.
413    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
414
415    /// Streams the events of `[t0, t1)` into `sink`, in decode order, without keeping them.
416    ///
417    /// The default fetches the slice and replays it, so every source supports it; a source that
418    /// decodes lazily overrides it to hand each event to the sink as it comes off the decoder
419    /// (the RAW reader does), which is what makes `open(raw, repr="polarity")` a single pass.
420    fn slice_time_into(&self, t0: i64, t1: i64, sink: &mut dyn EventConsumer) -> Result<(), IoError> {
421        let stream = self.slice_time(t0, t1)?;
422        let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
423        for index in 0..stream.len() {
424            sink.push(xs[index], ys[index], ts[index], ps[index]);
425        }
426        Ok(())
427    }
428
429    /// The intensity (APS) frames a DAVIS recorded alongside its events, those whose timestamp
430    /// lies in the half-open window `[t0, t1)` (µs), paired with that timestamp.
431    ///
432    /// Frames, IMU and intrinsics default to empty rather than to an error: most formats carry
433    /// events and nothing else, and a caller asking a `.npz` for its IMU wants "there isn't
434    /// any", not a failure it has to match on per format.
435    fn frames(&self, _t0: i64, _t1: i64) -> Result<Vec<(i64, EventFrame)>, IoError> {
436        Ok(Vec::new())
437    }
438
439    /// The IMU samples in `[t0, t1)` (µs).
440    fn imu(&self, _t0: i64, _t1: i64) -> Result<Vec<ImuSample>, IoError> {
441        Ok(Vec::new())
442    }
443
444    /// The camera intrinsics the recording carries, if it carries any.
445    fn camera(&self) -> Result<Option<crate::camera::Camera>, IoError> {
446        Ok(None)
447    }
448
449    /// Per-pixel event counts over the whole file (row-major `width·height`), out-of-bounds
450    /// events dropped — what the reader's hot-pixel pre-scan needs. The default tallies through
451    /// `slice_index` in bounded chunks; a source that can read coordinates alone (HDF5) overrides
452    /// this to skip the `t`/`p` columns and stream construction, which is most of the work.
453    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
454        const CHUNK: usize = 8_000_000;
455        let (width, height) = self.sensor_size();
456        let mut counts = vec![0u64; width * height];
457        let total = self.n_events();
458        let mut start = 0;
459        while start < total {
460            let end = (start + CHUNK).min(total);
461            self.slice_index(start, end)?.add_pixel_counts(&mut counts);
462            start = end;
463        }
464        Ok(counts)
465    }
466}
467
468/// A [`SliceSource`] backed by an already-loaded stream — the universal fallback for
469/// formats without native random access (npz/txt/bag today). Slicing is entirely
470/// in-RAM, so `open()` returns a working handle for every supported format from day one.
471pub struct MemorySliceSource {
472    stream: EventStream,
473}
474
475impl MemorySliceSource {
476    pub fn new(stream: EventStream) -> Self {
477        Self { stream }
478    }
479
480    fn rebuild(&self, indices: impl Iterator<Item = usize>) -> EventStream {
481        let (width, height) = self.stream.sensor_size();
482        let mut builder = EventStreamBuilder::new(width, height, self.stream.timestamp_scale_ms());
483        let (xs, ys, ts, ps) = (
484            self.stream.xs(),
485            self.stream.ys(),
486            self.stream.ts(),
487            self.stream.ps(),
488        );
489        for index in indices {
490            builder.push(xs[index], ys[index], ts[index], ps[index]);
491        }
492        builder.build()
493    }
494}
495
496impl SliceSource for MemorySliceSource {
497    fn sensor_size(&self) -> (usize, usize) {
498        self.stream.sensor_size()
499    }
500
501    fn timestamp_scale_ms(&self) -> f64 {
502        self.stream.timestamp_scale_ms()
503    }
504
505    fn n_events(&self) -> usize {
506        self.stream.len()
507    }
508
509    fn time_span(&self) -> (i64, i64) {
510        let ts = self.stream.ts();
511        match (ts.iter().min(), ts.iter().max()) {
512            (Some(&min), Some(&max)) => (min, max),
513            _ => (0, 0),
514        }
515    }
516
517    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
518        let i0 = i0.min(self.stream.len());
519        let i1 = i1.clamp(i0, self.stream.len());
520        Ok(self.rebuild(i0..i1))
521    }
522
523    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
524        let ts = self.stream.ts();
525        Ok(self.rebuild((0..ts.len()).filter(|&index| ts[index] >= t0 && ts[index] < t1)))
526    }
527
528    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
529        // Already resident — tally straight from the stream, no chunked rebuilds.
530        let (width, height) = self.stream.sensor_size();
531        let mut counts = vec![0u64; width * height];
532        self.stream.add_pixel_counts(&mut counts);
533        Ok(counts)
534    }
535}
536
537/// A boxed [`SliceSource`] — the handle [`open`] returns.
538pub type Reader = Box<dyn SliceSource>;
539
540/// Opens a file for lazy slicing, detected by extension (the `VideoCapture` analogue to
541/// [`load`]'s `imread`). HDF5 is sliced in place by binary-searching its timestamp dataset,
542/// text and AEDAT 2.0 build a sparse index, rosbags use their own chunk index; formats
543/// without random access are loaded once and sliced in memory. Same `LoadOptions` as [`load`]
544/// (`max_events` is ignored — slicing supersedes it).
545pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Reader, IoError> {
546    let path = path.as_ref();
547    match detect_format(path)? {
548        Format::Hdf5 => {
549            #[cfg(feature = "hdf5")]
550            {
551                Ok(Box::new(h5::open_hdf5_slice(path, &options)?))
552            }
553            #[cfg(not(feature = "hdf5"))]
554            {
555                Err(IoError::Unsupported(
556                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
557                ))
558            }
559        }
560        Format::Text => Ok(Box::new(text::open_text_slice(path, &options)?)),
561        Format::Rosbag => Ok(Box::new(bag::open_bag_slice(path, &options)?)),
562        Format::Aedat => Ok(Box::new(aedat::open_aedat_slice(path, &options)?)),
563        Format::Aedat4 => Ok(Box::new(aedat4::open_aedat4_slice(path, &options)?)),
564        Format::PropheseeRaw => Ok(Box::new(prophesee_raw::open_raw_slice(path, &options)?)),
565        _ => Ok(Box::new(MemorySliceSource::new(load(path, options)?))),
566    }
567}
568
569/// How an HDF5 event dataset is stored on disk.
570///
571/// Events are the one thing eventcv writes in bulk, and they compress far better than their raw
572/// width suggests: `t` is monotonically increasing and `p` is one bit wearing a whole byte, so a
573/// byte shuffle followed by deflate typically more than halves a recording. Uncompressed is still
574/// offered because a live capture would rather spend the bytes than the CPU.
575#[derive(Clone, Copy, Debug, PartialEq, Eq)]
576pub enum Compression {
577    /// Chunked but unfiltered — the fastest to write.
578    None,
579    /// Byte-shuffled, then deflated at this level (`1..=9`).
580    Gzip(u8),
581}
582
583/// Gzip level 1, because on event columns almost all of the win comes from the byte shuffle rather
584/// than from how hard deflate then looks. Measured on a 167 M-event simulation: unfiltered
585/// 2.18 GB / 22 s, level 1 429 MB / 35 s, level 4 408 MB / 42 s. Level 4 spends a fifth more time
586/// to save a twentieth more space, which is the wrong side of the trade for a default.
587impl Default for Compression {
588    fn default() -> Self {
589        Self::Gzip(1)
590    }
591}
592
593impl Compression {
594    /// The deflate level, or `None` when the data is stored unfiltered. Level 0 is treated as off:
595    /// HDF5 accepts it, but it pays the filter's framing cost to compress nothing.
596    pub fn level(self) -> Option<u8> {
597        match self {
598            Self::None | Self::Gzip(0) => None,
599            Self::Gzip(level) => Some(level.min(9)),
600        }
601    }
602}
603
604/// Options for the [`save_stream`] / [`save_frame`] writers — the symmetric mirror of
605/// [`LoadOptions`]. Most formats ignore every field; readers and writers agree on the rest.
606#[derive(Clone, Debug, Default)]
607pub struct SaveOptions {
608    /// Rosbag topic to write the `dvs_msgs/EventArray` messages on (defaults to
609    /// `/davis/left/events`, matching the reader).
610    pub topic: Option<String>,
611    /// PNG frame export only: the colour map (default [`Colormap::Viridis`]).
612    pub colormap: Colormap,
613    /// PNG frame export only: auto-contrast the field to its data range. `None` = `true`.
614    pub normalize: Option<bool>,
615    /// Overrides the format the extension implies. Two cases need it: writing E2VID's layout to a
616    /// `.txt` (which otherwise means eventcv's own text format — `.zip` already implies it), and
617    /// choosing between the two Prophesee `.raw` encodings with `"evt2"` / `"evt3"`. `None`
618    /// follows the extension.
619    pub format: Option<String>,
620    /// HDF5 only: how the event columns are filtered. Defaults to [`Compression::Gzip`] at level 4.
621    pub compression: Compression,
622    /// AEDAT 4 only: how each packet body is compressed. Defaults to LZ4, which is what DV writes.
623    pub packet_compression: PacketCompression,
624}
625
626impl SaveOptions {
627    /// The Prophesee `.raw` encoding [`format`](Self::format) asks for, defaulting to EVT2.
628    ///
629    /// EVT2 is the default because it is the encoding a reader can decode without carrying state
630    /// across words, so a file eventcv writes stays the easiest thing for another tool to open;
631    /// EVT3 is smaller and is chosen explicitly.
632    pub fn evt_version(&self) -> EvtVersion {
633        match self.format.as_deref() {
634            Some("evt3") => EvtVersion::Evt3,
635            _ => EvtVersion::Evt2,
636        }
637    }
638}
639
640/// The format `path` and `options` together ask for: an explicit `format` name, else the
641/// extension.
642fn requested_format(path: &Path, options: &SaveOptions) -> Result<Format, IoError> {
643    match options.format.as_deref() {
644        None => detect_format(path),
645        Some("e2vid") => Ok(Format::E2vid),
646        Some("npz") => Ok(Format::Npz),
647        Some("txt") | Some("csv") | Some("text") => Ok(Format::Text),
648        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
649        Some("bag") | Some("rosbag") => Ok(Format::Rosbag),
650        Some("aedat") | Some("aedat2") => Ok(Format::Aedat),
651        Some("aedat4") => Ok(Format::Aedat4),
652        Some("dat") => Ok(Format::PropheseeDat),
653        // The two `.raw` encodings name the same container, so they resolve to one format and are
654        // told apart later by `SaveOptions::evt_version`.
655        Some("raw") | Some("evt2") | Some("evt3") => Ok(Format::PropheseeRaw),
656        Some("png") => Ok(Format::Png),
657        Some(other) => Err(IoError::Unsupported(format!(
658            "unknown format: {other} (expected npz, txt, h5, bag, aedat, aedat4, dat, raw, evt2, \
659             evt3, png, or e2vid)"
660        ))),
661    }
662}
663
664/// Persists an [`EventStream`] to `path`, the format chosen by extension — the symmetric
665/// counterpart of [`load`]. npz/HDF5/rosbag round-trip exactly (metadata stored); txt
666/// stores `t x y p` and recovers sensor size / time unit on load via inference or options.
667/// `.zip` (or `format: "e2vid"`) writes E2VID's interchange text instead, which eventcv does not
668/// read back.
669pub fn save_stream(
670    path: impl AsRef<Path>,
671    stream: &EventStream,
672    options: &SaveOptions,
673) -> Result<(), IoError> {
674    let path = path.as_ref();
675    match requested_format(path, options)? {
676        Format::Npz => npz::write_npz_stream(path, stream),
677        Format::Text => text::write_text_stream(path, stream),
678        Format::E2vid => e2vid::write_e2vid(path, stream),
679        Format::Rosbag => bag::write_bag(path, stream, options.topic.as_deref()),
680        Format::Hdf5 => {
681            #[cfg(feature = "hdf5")]
682            {
683                h5::write_hdf5_stream(path, stream, options.compression)
684            }
685            #[cfg(not(feature = "hdf5"))]
686            {
687                Err(IoError::Unsupported(
688                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
689                ))
690            }
691        }
692        // The formats whose writers are sinks first and bulk writers second: one window in, one
693        // window out. Driving them through `open_sink` keeps a single code path, so `save` and a
694        // window-by-window recording cannot drift apart.
695        Format::Aedat | Format::Aedat4 | Format::PropheseeDat | Format::PropheseeRaw => {
696            let mut sink = open_sink(path, options)?;
697            sink.append(stream)?;
698            sink.finish()
699        }
700        Format::Png => Err(IoError::Unsupported(
701            "PNG is a frame export format; use save_frame".to_owned(),
702        )),
703    }
704}
705
706/// Persists an [`EventFrame`] (a computed representation) to `path`, preserving its shape,
707/// dtype, `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
708pub fn save_frame(
709    path: impl AsRef<Path>,
710    frame: &EventFrame,
711    options: &SaveOptions,
712) -> Result<(), IoError> {
713    let path = path.as_ref();
714    match detect_format(path)? {
715        Format::Npz => npz::write_npz_frame(path, frame),
716        Format::Hdf5 => {
717            #[cfg(feature = "hdf5")]
718            {
719                h5::write_hdf5_frame(path, frame)
720            }
721            #[cfg(not(feature = "hdf5"))]
722            {
723                Err(IoError::Unsupported(
724                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
725                ))
726            }
727        }
728        Format::Png => write_png_frame(path, frame, options),
729        other => Err(IoError::Unsupported(format!(
730            "saving an event frame as {other:?} is not supported"
731        ))),
732    }
733}
734
735/// Renders a frame through [`render_frame`] (colormapped 2-D view) and encodes it as an
736/// 8-bit RGB PNG. Unlike npz/HDF5 this is a *view*, not a round-trippable dump.
737fn write_png_frame(path: &Path, frame: &EventFrame, options: &SaveOptions) -> Result<(), IoError> {
738    let image = render_frame(frame, options.colormap, options.normalize.unwrap_or(true));
739    let file = std::fs::File::create(path)?;
740    let writer = std::io::BufWriter::new(file);
741    let mut encoder = png::Encoder::new(writer, image.width as u32, image.height as u32);
742    encoder.set_color(png::ColorType::Rgb);
743    encoder.set_depth(png::BitDepth::Eight);
744    encoder
745        .write_header()
746        .and_then(|mut writer| writer.write_image_data(&image.pixels))
747        .map_err(png_encoding_error)
748}
749
750fn png_encoding_error(error: png::EncodingError) -> IoError {
751    match error {
752        png::EncodingError::IoError(error) => IoError::Io(error),
753        other => IoError::Format(other.to_string()),
754    }
755}
756
757/// Writes an ROI mask (row-major `width·height`, `true` = keep) as an 8-bit greyscale `.png`:
758/// white where events are kept, black where they are dropped. Lets an ROI drawn or computed once
759/// be reused across sessions, and inspected in any image viewer. Read it back with [`read_mask`].
760pub fn write_mask(
761    path: impl AsRef<Path>,
762    mask: &[bool],
763    width: usize,
764    height: usize,
765) -> Result<(), IoError> {
766    let path = path.as_ref();
767    if !matches!(detect_format(path)?, Format::Png) {
768        return Err(IoError::Unsupported("masks are saved as .png".to_owned()));
769    }
770    if width == 0 || height == 0 {
771        return Err(IoError::InvalidSensorSize);
772    }
773    if mask.len() != width * height {
774        return Err(IoError::Format(format!(
775            "mask has {} pixels, expected {} for a {width}x{height} grid",
776            mask.len(),
777            width * height
778        )));
779    }
780    let pixels: Vec<u8> = mask.iter().map(|&keep| if keep { 255 } else { 0 }).collect();
781    let writer = std::io::BufWriter::new(std::fs::File::create(path)?);
782    let mut encoder = png::Encoder::new(writer, width as u32, height as u32);
783    encoder.set_color(png::ColorType::Grayscale);
784    encoder.set_depth(png::BitDepth::Eight);
785    encoder
786        .write_header()
787        .and_then(|mut writer| writer.write_image_data(&pixels))
788        .map_err(png_encoding_error)
789}
790
791/// Reads an ROI mask from a `.png`, returning it as `(mask, width, height)` (row-major, `true` =
792/// keep). Any 8-bit-normalisable PNG works — greyscale, palette, or colour, with or without alpha —
793/// so a mask binarised in another tool loads as-is: a pixel is **kept where it is non-black and not
794/// fully transparent**. The counterpart of [`write_mask`].
795pub fn read_mask(path: impl AsRef<Path>) -> Result<(Vec<bool>, usize, usize), IoError> {
796    let png = decode_png(path.as_ref(), "masks are loaded from .png")?;
797    let mut mask = Vec::with_capacity(png.width * png.height);
798    png.for_each_pixel(|colour, opacity| {
799        // Any non-black, non-transparent pixel is "keep" — the intensities themselves don't matter
800        // for a mask, only whether the pixel was painted.
801        mask.push(colour.iter().any(|&value| value != 0) && opacity.first() != Some(&0));
802    });
803    Ok((mask, png.width, png.height))
804}
805
806/// Reads a PNG as a single-channel 8-bit intensity frame.
807///
808/// Colour images are collapsed to luma with the Rec. 601 weights the rest of the library uses;
809/// an alpha channel is ignored rather than composited, since there is no background to composite
810/// against and a half-transparent pixel still had a measured brightness.
811pub fn read_png_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
812    let png = decode_png(path.as_ref(), "frames are loaded from .png")?;
813    let mut samples = Vec::with_capacity(png.width * png.height);
814    png.for_each_pixel(|colour, _| samples.push(luma(colour)));
815    EventFrame::intensity(EventFrameData::U8(samples), png.width, png.height)
816        .map_err(|error| IoError::Format(error.to_string()))
817}
818
819/// Rec. 601 luma. A single sample is already grey and passes through untouched, which keeps a
820/// greyscale PNG bit-exact rather than round-tripping it through the weights.
821pub(crate) fn luma(colour: &[u8]) -> u8 {
822    match colour {
823        [grey] => *grey,
824        [r, g, b, ..] => {
825            (0.299 * f32::from(*r) + 0.587 * f32::from(*g) + 0.114 * f32::from(*b)).round() as u8
826        }
827        _ => 0,
828    }
829}
830
831/// An 8-bit PNG decoded into memory, with enough shape to walk its pixels.
832struct DecodedPng {
833    buffer: Vec<u8>,
834    width: usize,
835    height: usize,
836    line_size: usize,
837    samples: usize,
838    alpha: bool,
839}
840
841impl DecodedPng {
842    /// Calls `visit(colour, opacity)` per pixel in row-major order, where `colour` excludes any
843    /// alpha sample and `opacity` is the alpha (empty when the image has none).
844    fn for_each_pixel(&self, mut visit: impl FnMut(&[u8], &[u8])) {
845        for row in self.buffer.chunks_exact(self.line_size).take(self.height) {
846            for pixel in row[..self.width * self.samples].chunks_exact(self.samples) {
847                let (colour, opacity) = pixel.split_at(self.samples - usize::from(self.alpha));
848                visit(colour, opacity);
849            }
850        }
851    }
852}
853
854/// Shared 8-bit PNG decode behind [`read_mask`] and [`read_png_frame`]. `unsupported` is the
855/// message used when the path is not a PNG at all, so each caller can say what it wanted.
856fn decode_png(path: &Path, unsupported: &str) -> Result<DecodedPng, IoError> {
857    if !matches!(detect_format(path)?, Format::Png) {
858        return Err(IoError::Unsupported(unsupported.to_owned()));
859    }
860    let mut decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?));
861    // Expands palette and sub-byte images and strips 16-bit samples, so everything below is 8-bit.
862    decoder.set_transformations(png::Transformations::normalize_to_color8());
863    let mut reader = decoder.read_info().map_err(png_decoding_error)?;
864    let mut buffer = vec![0; reader.output_buffer_size()];
865    let info = reader.next_frame(&mut buffer).map_err(png_decoding_error)?;
866    Ok(DecodedPng {
867        width: info.width as usize,
868        height: info.height as usize,
869        line_size: info.line_size,
870        samples: info.color_type.samples(),
871        alpha: matches!(
872            info.color_type,
873            png::ColorType::GrayscaleAlpha | png::ColorType::Rgba
874        ),
875        buffer,
876    })
877}
878
879fn png_decoding_error(error: png::DecodingError) -> IoError {
880    match error {
881        png::DecodingError::IoError(error) => IoError::Io(error),
882        other => IoError::Format(other.to_string()),
883    }
884}
885
886/// Reads an [`EventFrame`] previously written by [`save_frame`], reconstructing its dtype,
887/// `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
888pub fn load_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
889    let path = path.as_ref();
890    match detect_format(path)? {
891        Format::Npz => npz::read_npz_frame(path),
892        // A PNG has no stored kind or channel names, so it comes back as a greyscale intensity
893        // frame — the one representation that is not derived from events.
894        Format::Png => read_png_frame(path),
895        Format::Hdf5 => {
896            #[cfg(feature = "hdf5")]
897            {
898                h5::read_hdf5_frame(path)
899            }
900            #[cfg(not(feature = "hdf5"))]
901            {
902                Err(IoError::Unsupported(
903                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
904                ))
905            }
906        }
907        other => Err(IoError::Unsupported(format!(
908            "loading an event frame from {other:?} is not supported"
909        ))),
910    }
911}
912
913#[derive(Debug)]
914pub enum IoError {
915    Io(io::Error),
916    Parse { line: usize, message: String },
917    Format(String),
918    InvalidSensorSize,
919    Unsupported(String),
920}
921
922impl fmt::Display for IoError {
923    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
924        match self {
925            Self::Io(error) => error.fmt(formatter),
926            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
927            Self::Format(message) => formatter.write_str(message),
928            Self::InvalidSensorSize => {
929                formatter.write_str("sensor width and height must be positive")
930            }
931            Self::Unsupported(message) => formatter.write_str(message),
932        }
933    }
934}
935
936impl Error for IoError {
937    fn source(&self) -> Option<&(dyn Error + 'static)> {
938        match self {
939            Self::Io(error) => Some(error),
940            _ => None,
941        }
942    }
943}
944
945impl From<io::Error> for IoError {
946    fn from(error: io::Error) -> Self {
947        Self::Io(error)
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::{
954        load, open, open_sink, read_mask, save_stream, supports_event_append, write_mask, Format,
955        IoError, LoadOptions, MemorySliceSource, SaveOptions, SliceSource,
956    };
957    use crate::{EventStream, EventStreamBuilder};
958
959    /// A DAVIS346-sized recording, since AEDAT 2.0 can only name a real chip's geometry.
960    ///
961    /// Events are grouped twenty to a timestamp, and every other group is an ascending run along
962    /// one row — the shape that makes the EVT3 writer choose its vector encoding, so both of its
963    /// paths are covered. The span is deliberately over a second: the text reader infers its time
964    /// unit from the span, and a shorter recording would come back as milliseconds.
965    fn round_trip_stream() -> EventStream {
966        let mut builder = EventStreamBuilder::new(346, 260, 0.001);
967        for index in 0..500i64 {
968            let group = index / 20;
969            let (x, y, polarity) = if group % 2 == 0 {
970                ((index % 20) as u16, group as u16, true)
971            } else {
972                (((index * 7) % 300) as u16, ((index * 13) % 200) as u16, index % 3 == 0)
973            };
974            builder.push(x, y, group * 50_000, polarity);
975        }
976        builder.build()
977    }
978
979    fn assert_same(written: &EventStream, read: &EventStream, what: &str) {
980        assert_eq!(written.len(), read.len(), "{what}: event count");
981        assert_eq!(written.xs(), read.xs(), "{what}: x");
982        assert_eq!(written.ys(), read.ys(), "{what}: y");
983        assert_eq!(written.ts(), read.ts(), "{what}: t");
984        assert_eq!(written.ps(), read.ps(), "{what}: p");
985    }
986
987    /// Every event format eventcv reads, and the `SaveOptions.format` name for it when the
988    /// extension alone does not pick the encoding.
989    const ROUND_TRIP_FORMATS: &[(&str, Option<&str>)] = &[
990        ("npz", None),
991        ("txt", None),
992        ("aedat", None),
993        ("aedat4", None),
994        ("dat", None),
995        ("raw", Some("evt2")),
996        ("raw", Some("evt3")),
997        ("bag", None),
998        #[cfg(feature = "hdf5")]
999        ("h5", None),
1000    ];
1001
1002    fn scratch(name: &str, extension: &str) -> std::path::PathBuf {
1003        std::env::temp_dir().join(format!(
1004            "eventcv_sink_{}_{}_{name}.{extension}",
1005            std::process::id(),
1006            std::time::SystemTime::now()
1007                .duration_since(std::time::UNIX_EPOCH)
1008                .map(|since| since.as_nanos())
1009                .unwrap_or(0),
1010        ))
1011    }
1012
1013    #[test]
1014    fn every_format_round_trips_through_save_and_load() {
1015        let stream = round_trip_stream();
1016        for (extension, format) in ROUND_TRIP_FORMATS {
1017            let path = scratch(format.unwrap_or("plain"), extension);
1018            let options = SaveOptions {
1019                format: format.map(str::to_owned),
1020                ..SaveOptions::default()
1021            };
1022            save_stream(&path, &stream, &options).expect(extension);
1023            // Text carries no sensor size, so it is the one format that needs telling.
1024            let load_options = LoadOptions {
1025                sensor_size: Some(stream.sensor_size()),
1026                ..LoadOptions::default()
1027            };
1028            let read = load(&path, load_options).expect(extension);
1029            assert_same(&stream, &read, format.unwrap_or(extension));
1030            std::fs::remove_file(&path).ok();
1031        }
1032    }
1033
1034    #[test]
1035    fn appending_windows_matches_saving_the_whole_stream() {
1036        let stream = round_trip_stream();
1037        for (extension, format) in ROUND_TRIP_FORMATS {
1038            let path = scratch("append", extension);
1039            let options = SaveOptions {
1040                format: format.map(str::to_owned),
1041                ..SaveOptions::default()
1042            };
1043            let mut sink = open_sink(&path, &options).expect(extension);
1044            // Three windows, in time order — a recording arriving as a live camera would deliver it.
1045            for window in [0..100, 100..340, 340..stream.len()] {
1046                sink.append(&slice(&stream, window)).expect(extension);
1047            }
1048            assert_eq!(sink.n_events(), stream.len());
1049            sink.finish().expect(extension);
1050            let read = load(
1051                &path,
1052                LoadOptions {
1053                    sensor_size: Some(stream.sensor_size()),
1054                    ..LoadOptions::default()
1055                },
1056            )
1057            .expect(extension);
1058            assert_same(&stream, &read, format.unwrap_or(extension));
1059            std::fs::remove_file(&path).ok();
1060        }
1061    }
1062
1063    fn slice(stream: &EventStream, range: std::ops::Range<usize>) -> EventStream {
1064        let (width, height) = stream.sensor_size();
1065        let mut builder = EventStreamBuilder::new(width, height, stream.timestamp_scale_ms());
1066        let (xs, ys, ts, ps) = (stream.xs(), stream.ys(), stream.ts(), stream.ps());
1067        for index in range {
1068            builder.push(xs[index], ys[index], ts[index], ps[index]);
1069        }
1070        builder.build()
1071    }
1072
1073    #[test]
1074    fn every_event_format_can_be_appended_but_png_cannot() {
1075        for (extension, _) in ROUND_TRIP_FORMATS {
1076            assert!(
1077                supports_event_append(format!("recording.{extension}")),
1078                ".{extension} should be appendable"
1079            );
1080        }
1081        assert!(supports_event_append("export.zip")); // E2VID streams too
1082        assert!(!supports_event_append("frame.png"));
1083        assert!(!supports_event_append("clip.mp4"));
1084    }
1085
1086    #[test]
1087    fn raw_encoding_follows_the_requested_format() {
1088        let evt3 = SaveOptions {
1089            format: Some("evt3".to_owned()),
1090            ..SaveOptions::default()
1091        };
1092        assert_eq!(evt3.evt_version(), super::EvtVersion::Evt3);
1093        // Anything else — including a bare `.raw` — is EVT2, the encoding a reader needs no state
1094        // to decode.
1095        assert_eq!(SaveOptions::default().evt_version(), super::EvtVersion::Evt2);
1096        assert!(matches!(
1097            super::requested_format(std::path::Path::new("out.raw"), &evt3),
1098            Ok(Format::PropheseeRaw)
1099        ));
1100    }
1101
1102    /// npz and rosbag spill to a scratch file beside the target while a recording runs. Neither
1103    /// may leave one behind — finished or abandoned — and the handle has to be *closed* before the
1104    /// file is removed, because Windows will not unlink a file that is still open.
1105    #[test]
1106    fn the_spilling_sinks_leave_no_scratch_files() {
1107        let stream = round_trip_stream();
1108        for extension in ["npz", "bag"] {
1109            let path = scratch("spill", extension);
1110            let siblings = |suffix: &str| -> Vec<std::path::PathBuf> {
1111                let stem = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("");
1112                std::fs::read_dir(path.parent().expect("a parent"))
1113                    .into_iter()
1114                    .flatten()
1115                    .flatten()
1116                    .map(|entry| entry.path())
1117                    .filter(|found| {
1118                        found
1119                            .file_name()
1120                            .and_then(|name| name.to_str())
1121                            .is_some_and(|name| name.starts_with(stem) && name.ends_with(suffix))
1122                    })
1123                    .collect()
1124            };
1125
1126            // Abandoned without `finish`: no archive, and no scratch either.
1127            let mut sink = open_sink(&path, &SaveOptions::default()).expect(extension);
1128            sink.append(&stream).expect(extension);
1129            drop(sink);
1130            assert!(
1131                siblings(".part").is_empty(),
1132                "{extension}: an abandoned sink left its scratch file behind"
1133            );
1134
1135            // Finished: the archive exists and the scratch is gone.
1136            let mut sink = open_sink(&path, &SaveOptions::default()).expect(extension);
1137            sink.append(&stream).expect(extension);
1138            sink.finish().expect(extension);
1139            assert!(path.exists(), "{extension}: no archive was written");
1140            assert!(
1141                siblings(".part").is_empty(),
1142                "{extension}: a finished sink left its scratch file behind"
1143            );
1144            std::fs::remove_file(&path).ok();
1145        }
1146    }
1147
1148    #[test]
1149    fn out_of_order_events_are_refused_by_the_raw_writer() {
1150        let mut builder = EventStreamBuilder::new(64, 64, 0.001);
1151        builder.push(1, 1, 500, true);
1152        builder.push(2, 2, 100, false);
1153        let path = scratch("unsorted", "raw");
1154        let error = save_stream(&path, &builder.build(), &SaveOptions::default()).unwrap_err();
1155        std::fs::remove_file(&path).ok();
1156        match error {
1157            IoError::Unsupported(message) => assert!(message.contains("time order"), "{message}"),
1158            other => panic!("expected an ordering error, got {other:?}"),
1159        }
1160    }
1161
1162    #[test]
1163    fn unknown_extension_is_unsupported() {
1164        let error = load("recording.mp4", LoadOptions::default()).unwrap_err();
1165        assert!(matches!(error, IoError::Unsupported(_)));
1166    }
1167
1168    #[test]
1169    fn hdf5_extension_dispatches_per_feature() {
1170        // `.h5` is always recognised; with the feature it reaches the reader (a missing
1171        // file is then an IO error), without it the dispatch reports missing support.
1172        let error = load("recording.h5", LoadOptions::default()).unwrap_err();
1173        #[cfg(feature = "hdf5")]
1174        assert!(matches!(error, IoError::Io(_)));
1175        #[cfg(not(feature = "hdf5"))]
1176        match error {
1177            IoError::Unsupported(message) => assert!(message.contains("HDF5")),
1178            other => panic!("expected unsupported error, got {other:?}"),
1179        }
1180    }
1181
1182    #[test]
1183    fn text_missing_file_is_reported() {
1184        // Text no longer requires sensor_size (it infers); a missing file is an IO error.
1185        let error = load("events.txt", LoadOptions::default()).unwrap_err();
1186        assert!(matches!(error, IoError::Io(_)));
1187    }
1188
1189    #[test]
1190    fn aedat_dispatches_to_the_reader() {
1191        // `.aedat` reaches the AEDAT 2.0 reader, so a missing file is an IO error.
1192        let error = load("recording.aedat", LoadOptions::default()).unwrap_err();
1193        assert!(matches!(error, IoError::Io(_)));
1194    }
1195
1196    #[test]
1197    fn prophesee_dat_dispatches_to_the_reader() {
1198        let error = load("recording.dat", LoadOptions::default()).unwrap_err();
1199        assert!(matches!(error, IoError::Io(_)));
1200    }
1201
1202    #[test]
1203    fn aedat4_and_raw_dispatch_to_their_readers() {
1204        // Both reach a reader, so the failure is the missing file rather than the format.
1205        for path in ["recording.aedat4", "recording.raw"] {
1206            assert!(
1207                matches!(load(path, LoadOptions::default()), Err(IoError::Io(_))),
1208                "{path} should dispatch into its reader"
1209            );
1210        }
1211    }
1212
1213    fn sample_source() -> MemorySliceSource {
1214        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
1215        builder.push(0, 0, 0, true);
1216        builder.push(1, 1, 10, false);
1217        builder.push(2, 2, 20, true);
1218        builder.push(0, 1, 30, false);
1219        MemorySliceSource::new(builder.build())
1220    }
1221
1222    #[test]
1223    fn memory_source_reports_span_and_count() {
1224        let source = sample_source();
1225        assert_eq!(source.n_events(), 4);
1226        assert_eq!(source.time_span(), (0, 30));
1227    }
1228
1229    #[test]
1230    fn memory_source_slices_by_time_and_index() {
1231        let source = sample_source();
1232
1233        // Half-open [10, 30) keeps t = 10 and 20.
1234        assert_eq!(source.slice_time(10, 30).unwrap().ts(), &[10, 20]);
1235        assert_eq!(source.slice_index(1, 3).unwrap().ts(), &[10, 20]);
1236        assert_eq!(source.slice_index(2, 100).unwrap().len(), 2); // hi clamped to len
1237        assert!(source.slice_time(100, 200).unwrap().is_empty());
1238    }
1239
1240    #[test]
1241    fn open_rejects_unknown_extension() {
1242        // `Reader` is a trait object (not `Debug`), so match rather than `unwrap_err`.
1243        match open("recording.mp4", LoadOptions::default()) {
1244            Err(IoError::Unsupported(_)) => {}
1245            Err(other) => panic!("expected unsupported error, got {other:?}"),
1246            Ok(_) => panic!("expected an error for an unknown extension"),
1247        }
1248    }
1249
1250    #[test]
1251    fn mask_png_round_trips_and_validates() {
1252        let path = std::env::temp_dir().join(format!("eventcv_mask_{}.png", std::process::id()));
1253        let mask = crate::mask::ellipse(16, 12, 8.0, 6.0, 5.0, 4.0);
1254
1255        write_mask(&path, &mask, 16, 12).unwrap();
1256        let (loaded, width, height) = read_mask(&path).unwrap();
1257        assert_eq!((width, height), (16, 12));
1258        assert_eq!(loaded, mask);
1259        std::fs::remove_file(&path).ok();
1260
1261        // A mask that doesn't match the grid it claims, and a non-PNG target, both report why.
1262        assert!(matches!(
1263            write_mask(&path, &mask, 16, 11),
1264            Err(IoError::Format(_))
1265        ));
1266        assert!(matches!(
1267            write_mask("mask.npz", &mask, 16, 12),
1268            Err(IoError::Unsupported(_))
1269        ));
1270    }
1271}