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#[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
43pub trait EventSource {
46 fn sensor_size(&self) -> (usize, usize);
47 fn timestamp_scale_ms(&self) -> f64;
48 fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
50}
51
52pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
54 read_capped(source, None)
55}
56
57pub 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#[derive(Clone, Debug, Default)]
76pub struct LoadOptions {
77 pub sensor_size: Option<(usize, usize)>,
80 pub time_unit: Option<TimeUnit>,
84 pub order: ColumnOrder,
86 pub topic: Option<String>,
88 pub max_events: Option<usize>,
90 pub offset: Option<i64>,
93 pub keys: Option<EventKeys>,
98}
99
100#[derive(Clone, Debug)]
104pub struct EventKeys {
105 pub x: String,
106 pub y: String,
107 pub t: String,
108 pub p: String,
109}
110
111pub(crate) const X: usize = 0;
113pub(crate) const Y: usize = 1;
114pub(crate) const T: usize = 2;
115pub(crate) const P: usize = 3;
116
117pub(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
163pub(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#[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,
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
221pub fn supports_event_append(path: impl AsRef<Path>) -> bool {
225 matches!(detect_format(path.as_ref()), Ok(Format::Hdf5))
226}
227
228pub 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
239pub 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 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
254fn 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
306pub trait SliceSource: Send {
310 fn sensor_size(&self) -> (usize, usize);
311 fn timestamp_scale_ms(&self) -> f64;
312 fn n_events(&self) -> usize;
314 fn time_span(&self) -> (i64, i64);
316 fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
318 fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
320
321 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
340pub 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 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
409pub type Reader = Box<dyn SliceSource>;
411
412pub 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#[derive(Clone, Debug, Default)]
441pub struct SaveOptions {
442 pub topic: Option<String>,
445 pub colormap: Colormap,
447 pub normalize: Option<bool>,
449 pub format: Option<String>,
453}
454
455fn 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
472pub 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
506pub 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
535fn 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
557pub 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
591pub 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 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
629pub 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 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 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 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 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); assert!(source.slice_time(100, 200).unwrap().is_empty());
775 }
776
777 #[test]
778 fn open_rejects_unknown_extension() {
779 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 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}