1use std::collections::BTreeMap;
88use std::path::{Path, PathBuf};
89
90use ndarray::{Array3, ArrayView2, s};
91use tiff::decoder::Decoder;
92use tiff::decoder::DecodingResult;
93
94use crate::error::IoError;
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum PixelValuePolicy {
99 #[default]
104 Reject,
105 ClipToZero,
109 Allow,
113}
114
115#[derive(Debug, Clone, Copy)]
117pub struct TiffFolderOptions {
118 pub sum_chunks: bool,
137 pub pixel_policy: PixelValuePolicy,
140}
141
142impl Default for TiffFolderOptions {
143 fn default() -> Self {
144 Self {
145 sum_chunks: true,
146 pixel_policy: PixelValuePolicy::default(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct TiffLoadInfo {
154 pub n_files: usize,
156 pub n_chunks: usize,
159 pub chunk_ids: Vec<u64>,
161 pub chunks_summed: bool,
163 pub n_clipped_pixels: usize,
166 pub n_unrecognized_files: usize,
175 pub unrecognized_examples: Vec<String>,
179 pub chunk_inconsistent: bool,
190}
191
192pub const MAX_UNRECOGNIZED_EXAMPLES: usize = 3;
197
198fn enforce_pixel_policy(
204 pixels: &mut [f64],
205 policy: PixelValuePolicy,
206 file: &Path,
207 frame: usize,
208) -> Result<usize, IoError> {
209 match policy {
210 PixelValuePolicy::Allow => Ok(0),
211 PixelValuePolicy::Reject => {
212 nereids_core::validation::first_non_finite_or_negative(pixels.iter().copied())
213 .map_err(|(index, value)| IoError::BadPixelValue {
214 file: file.to_string_lossy().into_owned(),
215 frame,
216 index,
217 value,
218 })?;
219 Ok(0)
220 }
221 PixelValuePolicy::ClipToZero => {
222 let mut clipped = 0usize;
223 for (index, v) in pixels.iter_mut().enumerate() {
224 if !v.is_finite() {
227 return Err(IoError::BadPixelValue {
228 file: file.to_string_lossy().into_owned(),
229 frame,
230 index,
231 value: *v,
232 });
233 }
234 if *v < 0.0 {
235 *v = 0.0;
236 clipped += 1;
237 }
238 }
239 Ok(clipped)
240 }
241 }
242}
243
244pub fn load_tiff_stack(path: &Path) -> Result<Array3<f64>, IoError> {
258 load_tiff_stack_with_options(path, PixelValuePolicy::default()).map(|(arr, _)| arr)
259}
260
261pub fn load_tiff_stack_with_options(
274 path: &Path,
275 pixel_policy: PixelValuePolicy,
276) -> Result<(Array3<f64>, TiffLoadInfo), IoError> {
277 let file = std::fs::File::open(path)
278 .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
279 let mut decoder = Decoder::new(file).map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
280
281 let mut frames: Vec<Vec<f64>> = Vec::new();
282 let mut width = 0u32;
283 let mut height = 0u32;
284 let mut n_clipped_pixels = 0usize;
285
286 loop {
287 let (w, h) = decoder
288 .dimensions()
289 .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
290
291 if frames.is_empty() {
292 width = w;
293 height = h;
294 } else if w != width || h != height {
295 return Err(IoError::DimensionMismatch {
296 expected: (width, height),
297 got: (w, h),
298 frame: frames.len(),
299 });
300 }
301
302 let data = decoder
303 .read_image()
304 .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
305
306 let mut pixels = decode_to_f64(data)?;
307 n_clipped_pixels += enforce_pixel_policy(&mut pixels, pixel_policy, path, frames.len())?;
308 let expected_len = (width as usize) * (height as usize);
309 if pixels.len() != expected_len {
310 return Err(IoError::TiffDecode(format!(
311 "Frame {} has {} pixels, expected {}",
312 frames.len(),
313 pixels.len(),
314 expected_len
315 )));
316 }
317 frames.push(pixels);
318
319 if !decoder.more_images() {
320 break;
321 }
322 decoder
323 .next_image()
324 .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
325 }
326
327 let n_frames = frames.len();
328 if n_frames == 0 {
329 return Err(IoError::TiffDecode("TIFF file contains no frames".into()));
330 }
331
332 let flat: Vec<f64> = frames.into_iter().flatten().collect();
334 let arr = Array3::from_shape_vec((n_frames, height as usize, width as usize), flat)
335 .map_err(|e| IoError::TiffDecode(format!("Shape error: {}", e)))?;
336 Ok((
337 arr,
338 TiffLoadInfo {
339 n_files: 1,
340 n_chunks: 0,
341 chunk_ids: Vec::new(),
342 chunks_summed: false,
343 n_clipped_pixels,
344 n_unrecognized_files: 0,
345 unrecognized_examples: Vec::new(),
346 chunk_inconsistent: false,
347 },
348 ))
349}
350
351pub fn load_tiff_auto(path: &Path) -> Result<Array3<f64>, IoError> {
370 match std::fs::metadata(path) {
371 Ok(meta) => {
372 if meta.is_file() {
373 load_tiff_stack(path)
374 } else if meta.is_dir() {
375 load_tiff_directory(path)
376 } else {
377 Err(IoError::FileNotFound(
378 path.to_string_lossy().into_owned(),
379 std::io::Error::new(
380 std::io::ErrorKind::InvalidInput,
381 "path is neither a regular file nor a directory",
382 ),
383 ))
384 }
385 }
386 Err(e) => Err(IoError::FileNotFound(
387 path.to_string_lossy().into_owned(),
388 e,
389 )),
390 }
391}
392
393pub fn load_tiff_auto_with_options(
406 path: &Path,
407 options: &TiffFolderOptions,
408) -> Result<(Array3<f64>, TiffLoadInfo), IoError> {
409 match std::fs::metadata(path) {
410 Ok(meta) => {
411 if meta.is_file() {
412 load_tiff_stack_with_options(path, options.pixel_policy)
413 } else if meta.is_dir() {
414 load_tiff_folder_with_options(path, None, options)
415 } else {
416 Err(IoError::FileNotFound(
417 path.to_string_lossy().into_owned(),
418 std::io::Error::new(
419 std::io::ErrorKind::InvalidInput,
420 "path is neither a regular file nor a directory",
421 ),
422 ))
423 }
424 }
425 Err(e) => Err(IoError::FileNotFound(
426 path.to_string_lossy().into_owned(),
427 e,
428 )),
429 }
430}
431
432pub fn load_tiff_directory(dir: &Path) -> Result<Array3<f64>, IoError> {
450 load_tiff_folder(dir, None).map_err(|e| match e {
451 IoError::NoMatchingFiles { .. } => {
453 IoError::TiffDecode("No TIFF files found in directory".into())
454 }
455 other => other,
456 })
457}
458
459pub fn load_tiff_folder(dir: &Path, pattern: Option<&str>) -> Result<Array3<f64>, IoError> {
495 load_tiff_folder_with_options(dir, pattern, &TiffFolderOptions::default()).map(|(arr, _)| arr)
496}
497
498pub fn load_tiff_folder_with_options(
526 dir: &Path,
527 pattern: Option<&str>,
528 options: &TiffFolderOptions,
529) -> Result<(Array3<f64>, TiffLoadInfo), IoError> {
530 match std::fs::metadata(dir) {
543 Ok(meta) if meta.is_dir() => {}
544 Ok(_) => return Err(IoError::NotADirectory(dir.to_string_lossy().into_owned())),
545 Err(e) => return Err(IoError::FileNotFound(dir.to_string_lossy().into_owned(), e)),
546 }
547
548 let entries: Vec<_> = std::fs::read_dir(dir)
551 .map_err(|e| IoError::FileNotFound(dir.to_string_lossy().into_owned(), e))?
552 .collect::<Result<Vec<_>, _>>()
553 .map_err(|e| IoError::FileNotFound(dir.to_string_lossy().into_owned(), e))?;
554
555 let mut paths: Vec<_> = entries
556 .iter()
557 .filter_map(|entry| {
558 let p = entry.path();
560 if !p.is_file() {
562 return None;
563 }
564 let is_tiff = p
565 .extension()
566 .and_then(|ext| ext.to_str())
567 .map(|ext| matches!(ext.to_lowercase().as_str(), "tif" | "tiff"))
568 .unwrap_or(false);
569 if !is_tiff {
570 return None;
571 }
572 if let Some(pat) = pattern {
573 let matches = entry
574 .file_name()
575 .to_str()
576 .map(|name| glob_match(pat, name))
577 .unwrap_or(false);
578 if !matches {
579 return None;
580 }
581 }
582 Some(p)
583 })
584 .collect();
585
586 if paths.is_empty() {
587 return Err(IoError::NoMatchingFiles {
588 directory: dir.to_string_lossy().into_owned(),
589 pattern: pattern.unwrap_or("*.tif / *.tiff").to_string(),
590 });
591 }
592
593 let n_files = paths.len();
594 let mut n_clipped_pixels = 0usize;
595
596 match detect_chunk_layout(dir, &paths, options.sum_chunks)? {
597 ChunkLayout::Legacy {
598 n_unrecognized_files,
599 unrecognized_examples,
600 } => {
601 paths.sort();
602 let arr = load_frames_from_paths(&paths, options.pixel_policy, &mut n_clipped_pixels)?;
603 Ok((
604 arr,
605 TiffLoadInfo {
606 n_files,
607 n_chunks: 0,
608 chunk_ids: Vec::new(),
609 chunks_summed: false,
610 n_clipped_pixels,
611 n_unrecognized_files,
612 unrecognized_examples,
613 chunk_inconsistent: false,
614 },
615 ))
616 }
617 ChunkLayout::InconsistentChunks { chunk_ids } => {
618 let n_chunks = chunk_ids.len();
627 paths.sort();
628 let arr = load_frames_from_paths(&paths, options.pixel_policy, &mut n_clipped_pixels)?;
629 Ok((
630 arr,
631 TiffLoadInfo {
632 n_files,
633 n_chunks,
634 chunk_ids,
635 chunks_summed: false,
636 n_clipped_pixels,
637 n_unrecognized_files: 0,
638 unrecognized_examples: Vec::new(),
639 chunk_inconsistent: true,
640 },
641 ))
642 }
643 ChunkLayout::Chunked(chunks) => {
644 let chunk_ids: Vec<u64> = chunks.keys().copied().collect();
645 let n_chunks = chunks.len();
646 if n_chunks == 1 || options.sum_chunks {
647 let arr =
652 load_chunked_sum(dir, &chunks, options.pixel_policy, &mut n_clipped_pixels)?;
653 Ok((
654 arr,
655 TiffLoadInfo {
656 n_files,
657 n_chunks,
658 chunk_ids,
659 chunks_summed: n_chunks > 1,
660 n_clipped_pixels,
661 n_unrecognized_files: 0,
662 unrecognized_examples: Vec::new(),
663 chunk_inconsistent: false,
664 },
665 ))
666 } else {
667 paths.sort();
671 let arr =
672 load_frames_from_paths(&paths, options.pixel_policy, &mut n_clipped_pixels)?;
673 Ok((
674 arr,
675 TiffLoadInfo {
676 n_files,
677 n_chunks,
678 chunk_ids,
679 chunks_summed: false,
680 n_clipped_pixels,
681 n_unrecognized_files: 0,
682 unrecognized_examples: Vec::new(),
683 chunk_inconsistent: false,
684 },
685 ))
686 }
687 }
688 }
689}
690
691enum ChunkLayout {
693 Legacy {
699 n_unrecognized_files: usize,
700 unrecognized_examples: Vec<String>,
701 },
702 InconsistentChunks { chunk_ids: Vec<u64> },
711 Chunked(BTreeMap<u64, Vec<(u64, PathBuf)>>),
715}
716
717fn parse_ascii_digits(s: &str) -> Option<u64> {
721 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
722 return None;
723 }
724 s.parse::<u64>().ok()
725}
726
727fn parse_chunked_stem(stem: &str) -> Option<(&str, u64, u64)> {
733 let (rest, frame_str) = stem.rsplit_once('_')?;
734 let (prefix, chunk_str) = rest.rsplit_once('_')?;
735 let frame = parse_ascii_digits(frame_str)?;
736 let chunk = parse_ascii_digits(chunk_str)?;
737 Some((prefix, chunk, frame))
738}
739
740fn detect_chunk_layout(
769 dir: &Path,
770 paths: &[PathBuf],
771 sum_chunks: bool,
772) -> Result<ChunkLayout, IoError> {
773 let mut parsed: Vec<(&str, u64, u64, &PathBuf)> = Vec::with_capacity(paths.len());
774 let mut unrecognized: Vec<String> = Vec::new();
775 for path in paths {
776 match path
780 .file_stem()
781 .and_then(|s| s.to_str())
782 .and_then(parse_chunked_stem)
783 {
784 Some((prefix, chunk, frame)) => parsed.push((prefix, chunk, frame, path)),
785 None => unrecognized.push(
786 path.file_name()
787 .map(|n| n.to_string_lossy().into_owned())
788 .unwrap_or_else(|| path.to_string_lossy().into_owned()),
789 ),
790 }
791 }
792
793 if parsed.is_empty() || !unrecognized.is_empty() {
794 let (n_unrecognized_files, unrecognized_examples) = if parsed.is_empty() {
799 (0, Vec::new())
800 } else {
801 let n = unrecognized.len();
802 unrecognized.sort();
804 unrecognized.truncate(MAX_UNRECOGNIZED_EXAMPLES);
805 (n, unrecognized)
806 };
807 return Ok(ChunkLayout::Legacy {
808 n_unrecognized_files,
809 unrecognized_examples,
810 });
811 }
812
813 let first_prefix = parsed[0].0;
814 if parsed.iter().any(|(prefix, ..)| *prefix != first_prefix) {
815 return Ok(ChunkLayout::Legacy {
818 n_unrecognized_files: 0,
819 unrecognized_examples: Vec::new(),
820 });
821 }
822
823 let mut chunks: BTreeMap<u64, Vec<(u64, PathBuf)>> = BTreeMap::new();
824 for (_, chunk, frame, path) in parsed {
825 chunks.entry(chunk).or_default().push((frame, path.clone()));
826 }
827
828 match validate_chunk_consistency(&mut chunks) {
833 Ok(()) => Ok(ChunkLayout::Chunked(chunks)),
834 Err(details) if sum_chunks => Err(IoError::ChunkMismatch {
835 directory: dir.to_string_lossy().into_owned(),
836 details,
837 }),
838 Err(_) => Ok(ChunkLayout::InconsistentChunks {
839 chunk_ids: chunks.keys().copied().collect(),
840 }),
841 }
842}
843
844fn validate_chunk_consistency(
857 chunks: &mut BTreeMap<u64, Vec<(u64, PathBuf)>>,
858) -> Result<(), String> {
859 for (chunk_id, frames) in chunks.iter_mut() {
861 frames.sort_by_key(|(frame, _)| *frame);
862 if let Some(pair) = frames.windows(2).find(|pair| pair[0].0 == pair[1].0) {
863 return Err(format!(
864 "duplicate frame {} in chunk {}: '{}' and '{}'",
865 pair[0].0,
866 chunk_id,
867 pair[0].1.display(),
868 pair[1].1.display(),
869 ));
870 }
871 }
872
873 let mut iter = chunks.iter();
875 let (first_id, first_frames) = iter.next().expect("chunks is non-empty");
876 for (chunk_id, frames) in iter {
877 if frames.len() != first_frames.len() {
878 return Err(format!(
879 "chunk {} has {} frames but chunk {} has {} frames",
880 first_id,
881 first_frames.len(),
882 chunk_id,
883 frames.len(),
884 ));
885 }
886 if let Some((a, b)) = first_frames
887 .iter()
888 .zip(frames.iter())
889 .find(|(a, b)| a.0 != b.0)
890 {
891 return Err(format!(
892 "chunks {} and {} cover different frame indices: first difference {} vs {}",
893 first_id, chunk_id, a.0, b.0,
894 ));
895 }
896 }
897
898 Ok(())
899}
900
901fn load_chunked_sum(
910 dir: &Path,
911 chunks: &BTreeMap<u64, Vec<(u64, PathBuf)>>,
912 pixel_policy: PixelValuePolicy,
913 n_clipped_pixels: &mut usize,
914) -> Result<Array3<f64>, IoError> {
915 let mut iter = chunks.iter();
916 let (&first_id, first) = iter.next().expect("detect_chunk_layout yields >= 1 chunk");
917 let first_paths: Vec<PathBuf> = first.iter().map(|(_, path)| path.clone()).collect();
918 let mut acc = load_frames_from_paths(&first_paths, pixel_policy, n_clipped_pixels)?;
919 let (_, height, width) = acc.dim();
920
921 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
931 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
932 let fnv_step = |h: u64, bits: u64| (h ^ bits).wrapping_mul(FNV_PRIME);
933
934 let multi_chunk = chunks.len() > 1;
937 let mut seen_hashes: Vec<(u64, u64)> = Vec::new(); if multi_chunk {
939 let first_hash = acc
940 .iter()
941 .fold(FNV_OFFSET, |h, &v| fnv_step(h, v.to_bits()));
942 seen_hashes.push((first_id, first_hash));
943 }
944
945 for (&chunk_id, frames) in iter {
946 let mut chunk_hash = FNV_OFFSET;
947 for (i, (_, path)) in frames.iter().enumerate() {
948 let (pixels, w, h) = read_single_frame(path, i, pixel_policy, n_clipped_pixels)?;
949 if w as usize != width || h as usize != height {
950 return Err(IoError::DimensionMismatch {
951 expected: (width as u32, height as u32),
952 got: (w, h),
953 frame: i,
954 });
955 }
956 for &p in &pixels {
959 chunk_hash = fnv_step(chunk_hash, p.to_bits());
960 }
961 let mut slice = acc.slice_mut(s![i, .., ..]);
962 for (dst, src) in slice.iter_mut().zip(pixels.iter()) {
963 *dst += src;
964 }
965 }
966 if let Some(&(dup_of, _)) = seen_hashes.iter().find(|&&(_, h)| h == chunk_hash) {
967 return Err(IoError::ChunkMismatch {
968 directory: dir.to_string_lossy().into_owned(),
969 details: format!(
970 "DAQ chunk {chunk_id} has an identical content fingerprint to chunk \
971 {dup_of} (FNV-1a hash over all pixel bits) — almost certainly a \
972 duplicate write of the same exposure, not a distinct DAQ segment. \
973 Summing them (default sum_chunks=true) would double every count \
974 (and inflate proton-charge normalisation by the chunk multiplicity). \
975 Pass sum_chunks=false to load all frames without summing (issue #653)."
976 ),
977 });
978 }
979 seen_hashes.push((chunk_id, chunk_hash));
980 }
981
982 Ok(acc)
983}
984
985fn load_frames_from_paths(
994 paths: &[std::path::PathBuf],
995 pixel_policy: PixelValuePolicy,
996 n_clipped_pixels: &mut usize,
997) -> Result<Array3<f64>, IoError> {
998 debug_assert!(
999 !paths.is_empty(),
1000 "load_frames_from_paths called with empty paths"
1001 );
1002 let mut width = 0u32;
1003 let mut height = 0u32;
1004 let mut arr = Array3::<f64>::zeros((0, 0, 0));
1008
1009 for (i, path) in paths.iter().enumerate() {
1010 let (pixels, w, h) = read_single_frame(path, i, pixel_policy, n_clipped_pixels)?;
1011
1012 if i == 0 {
1013 width = w;
1014 height = h;
1015 arr = Array3::zeros((paths.len(), h as usize, w as usize));
1016 } else if w != width || h != height {
1017 return Err(IoError::DimensionMismatch {
1018 expected: (width, height),
1019 got: (w, h),
1020 frame: i,
1021 });
1022 }
1023
1024 let view = ArrayView2::from_shape((h as usize, w as usize), &pixels)
1027 .map_err(|e| IoError::TiffDecode(format!("Shape error: {}", e)))?;
1028 arr.slice_mut(s![i, .., ..]).assign(&view);
1029 }
1030
1031 Ok(arr)
1032}
1033
1034fn read_single_frame(
1043 path: &Path,
1044 frame_label: usize,
1045 pixel_policy: PixelValuePolicy,
1046 n_clipped_pixels: &mut usize,
1047) -> Result<(Vec<f64>, u32, u32), IoError> {
1048 let file = std::fs::File::open(path)
1049 .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
1050 let mut decoder = Decoder::new(file).map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
1051
1052 let (w, h) = decoder
1053 .dimensions()
1054 .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
1055
1056 let data = decoder
1057 .read_image()
1058 .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
1059
1060 if decoder.more_images() {
1064 return Err(IoError::InvalidParameter(format!(
1065 "File '{}' contains multiple frames; use load_tiff_stack() for multi-frame TIFFs",
1066 path.display()
1067 )));
1068 }
1069
1070 let mut pixels = decode_to_f64(data)?;
1071 *n_clipped_pixels += enforce_pixel_policy(&mut pixels, pixel_policy, path, frame_label)?;
1072 let expected_len = (w as usize) * (h as usize);
1073 if pixels.len() != expected_len {
1074 return Err(IoError::TiffDecode(format!(
1075 "Frame {} has {} pixels, expected {}",
1076 frame_label,
1077 pixels.len(),
1078 expected_len
1079 )));
1080 }
1081 Ok((pixels, w, h))
1082}
1083
1084fn glob_match(pattern: &str, name: &str) -> bool {
1093 let p: Vec<char> = pattern.to_lowercase().chars().collect();
1094 let n: Vec<char> = name.to_lowercase().chars().collect();
1095
1096 let (mut pi, mut ni) = (0usize, 0usize);
1097 let (mut star_pi, mut star_ni) = (None::<usize>, 0usize);
1099
1100 while ni < n.len() {
1101 if pi < p.len() && p[pi] == '*' {
1102 star_pi = Some(pi);
1104 star_ni = ni;
1105 pi += 1; } else if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
1107 pi += 1;
1108 ni += 1;
1109 } else if let Some(sp) = star_pi {
1110 star_ni += 1;
1112 ni = star_ni;
1113 pi = sp + 1;
1114 } else {
1115 return false;
1116 }
1117 }
1118
1119 while pi < p.len() && p[pi] == '*' {
1121 pi += 1;
1122 }
1123
1124 pi == p.len()
1125}
1126
1127fn decode_to_f64(data: DecodingResult) -> Result<Vec<f64>, IoError> {
1129 match data {
1130 DecodingResult::U8(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1131 DecodingResult::U16(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1132 DecodingResult::U32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1133 DecodingResult::U64(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1134 DecodingResult::F32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1135 DecodingResult::F64(v) => Ok(v),
1136 DecodingResult::I8(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1137 DecodingResult::I16(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1138 DecodingResult::I32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1139 DecodingResult::I64(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
1140 DecodingResult::F16(v) => Ok(v.into_iter().map(f64::from).collect()),
1141 }
1142}
1143
1144#[derive(Debug, Clone)]
1146pub struct TiffStackInfo {
1147 pub n_frames: usize,
1149 pub height: usize,
1151 pub width: usize,
1153}
1154
1155impl TiffStackInfo {
1156 pub fn from_array(arr: &Array3<f64>) -> Self {
1158 let shape = arr.shape();
1159 Self {
1160 n_frames: shape[0],
1161 height: shape[1],
1162 width: shape[2],
1163 }
1164 }
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169 use super::*;
1170 use tiff::encoder::TiffEncoder;
1171
1172 fn write_test_tiff(path: &Path, frames: &[Vec<u16>], width: u32, height: u32) {
1174 let file = std::fs::File::create(path).unwrap();
1175 let mut encoder = TiffEncoder::new(file).unwrap();
1176 for frame in frames {
1177 encoder
1178 .write_image::<tiff::encoder::colortype::Gray16>(width, height, frame)
1179 .unwrap();
1180 }
1181 }
1182
1183 #[test]
1184 fn test_load_single_frame_tiff() {
1185 let dir = tempfile::tempdir().unwrap();
1186 let path = dir.path().join("test.tiff");
1187
1188 let data: Vec<u16> = vec![1, 2, 3, 4, 5, 6];
1190 write_test_tiff(&path, &[data], 3, 2);
1191
1192 let arr = load_tiff_stack(&path).unwrap();
1193 assert_eq!(arr.shape(), &[1, 2, 3]);
1194 assert_eq!(arr[[0, 0, 0]], 1.0);
1195 assert_eq!(arr[[0, 0, 2]], 3.0);
1196 assert_eq!(arr[[0, 1, 0]], 4.0);
1197 assert_eq!(arr[[0, 1, 2]], 6.0);
1198 }
1199
1200 #[test]
1201 fn test_load_multi_frame_tiff() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let path = dir.path().join("multi.tiff");
1204
1205 let frame1: Vec<u16> = vec![10, 20, 30, 40];
1206 let frame2: Vec<u16> = vec![50, 60, 70, 80];
1207 let frame3: Vec<u16> = vec![90, 100, 110, 120];
1208 write_test_tiff(&path, &[frame1, frame2, frame3], 2, 2);
1209
1210 let arr = load_tiff_stack(&path).unwrap();
1211 assert_eq!(arr.shape(), &[3, 2, 2]);
1212 assert_eq!(arr[[0, 0, 0]], 10.0);
1214 assert_eq!(arr[[0, 1, 1]], 40.0);
1215 assert_eq!(arr[[2, 0, 0]], 90.0);
1217 assert_eq!(arr[[2, 1, 1]], 120.0);
1218 }
1219
1220 #[test]
1221 fn test_load_tiff_directory() {
1222 let dir = tempfile::tempdir().unwrap();
1223
1224 for i in 0..3u16 {
1226 let path = dir.path().join(format!("frame_{:04}.tiff", i));
1227 let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
1228 write_test_tiff(&path, &[data], 2, 2);
1229 }
1230
1231 let arr = load_tiff_directory(dir.path()).unwrap();
1232 assert_eq!(arr.shape(), &[3, 2, 2]);
1233 assert_eq!(arr[[0, 0, 0]], 10.0);
1235 assert_eq!(arr[[2, 0, 0]], 30.0);
1237 assert_eq!(arr[[2, 1, 1]], 33.0);
1238 }
1239
1240 #[test]
1241 fn test_load_tiff_folder_no_pattern() {
1242 let dir = tempfile::tempdir().unwrap();
1243
1244 for i in 0..2u16 {
1246 let path = dir.path().join(format!("frame_{:04}.tif", i));
1247 let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
1248 write_test_tiff(&path, &[data], 2, 2);
1249 }
1250 let path = dir.path().join("frame_0002.tiff");
1251 write_test_tiff(&path, &[vec![30, 31, 32, 33]], 2, 2);
1252
1253 std::fs::write(dir.path().join("frame_0001.tif.bak"), b"not a tiff").unwrap();
1255
1256 let arr = load_tiff_folder(dir.path(), None).unwrap();
1257 assert_eq!(arr.shape(), &[3, 2, 2]);
1258 }
1259
1260 #[test]
1261 fn test_load_tiff_folder_with_pattern() {
1262 let dir = tempfile::tempdir().unwrap();
1263
1264 for i in 0..3u16 {
1265 let path = dir.path().join(format!("frame_{:04}.tif", i));
1266 let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
1267 write_test_tiff(&path, &[data], 2, 2);
1268 }
1269
1270 let arr = load_tiff_folder(dir.path(), Some("*.tif")).unwrap();
1271 assert_eq!(arr.shape(), &[3, 2, 2]);
1272 assert_eq!(arr[[0, 0, 0]], 10.0);
1273 assert_eq!(arr[[2, 1, 1]], 33.0);
1274 }
1275
1276 #[test]
1277 fn test_load_tiff_folder_custom_pattern() {
1278 let dir = tempfile::tempdir().unwrap();
1279
1280 for i in 0..2u16 {
1282 let path = dir.path().join(format!("scan_{:04}.tif", i));
1283 let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
1284 write_test_tiff(&path, &[data], 2, 2);
1285 }
1286 let extra = dir.path().join("other_0001.tif");
1288 write_test_tiff(&extra, &[vec![99, 99, 99, 99]], 2, 2);
1289
1290 let arr = load_tiff_folder(dir.path(), Some("scan_*.tif")).unwrap();
1291 assert_eq!(arr.shape(), &[2, 2, 2]);
1292 assert_eq!(arr[[0, 0, 0]], 10.0);
1293 }
1294
1295 #[test]
1296 fn test_load_tiff_folder_no_matching_files() {
1297 let dir = tempfile::tempdir().unwrap();
1298
1299 let path = dir.path().join("frame_0001.tiff");
1301 write_test_tiff(&path, &[vec![1, 2, 3, 4]], 2, 2);
1302
1303 let result = load_tiff_folder(dir.path(), Some("*.png"));
1304 assert!(result.is_err());
1305 let err = result.unwrap_err();
1306 assert!(
1307 matches!(err, IoError::NoMatchingFiles { .. }),
1308 "Expected NoMatchingFiles, got: {:?}",
1309 err,
1310 );
1311 }
1312
1313 #[test]
1314 fn test_load_tiff_folder_case_insensitive() {
1315 let dir = tempfile::tempdir().unwrap();
1316
1317 let path = dir.path().join("frame_0001.TIF");
1319 write_test_tiff(&path, &[vec![1, 2, 3, 4]], 2, 2);
1320
1321 let arr = load_tiff_folder(dir.path(), Some("*.tif")).unwrap();
1323 assert_eq!(arr.shape(), &[1, 2, 2]);
1324 }
1325
1326 #[test]
1327 fn test_glob_match_basic() {
1328 assert!(glob_match("*.tif", "frame_0001.tif"));
1329 assert!(glob_match("*.tif", "a.tif"));
1330 assert!(!glob_match("*.tif", "frame_0001.tiff"));
1331 assert!(!glob_match("*.tif", "frame_0001.png"));
1332 }
1333
1334 #[test]
1335 fn test_glob_match_question_mark() {
1336 assert!(glob_match("frame_?.tif", "frame_1.tif"));
1337 assert!(!glob_match("frame_?.tif", "frame_12.tif"));
1338 assert!(glob_match("?.tif", "\u{00e9}.tif")); }
1341
1342 #[test]
1343 fn test_glob_match_case_insensitive() {
1344 assert!(glob_match("*.tif", "FILE.TIF"));
1345 assert!(glob_match("*.TIF", "file.tif"));
1346 }
1347
1348 #[test]
1349 fn test_glob_match_pattern_longer_than_name() {
1350 assert!(!glob_match("abcdef.tif", "a.tif"));
1351 }
1352
1353 #[test]
1354 fn test_glob_match_empty_strings() {
1355 assert!(glob_match("", ""));
1356 assert!(!glob_match("", "foo"));
1357 assert!(glob_match("*", ""));
1358 }
1359
1360 #[test]
1361 fn test_glob_match_pathological_pattern() {
1362 let pattern = "*a*a*a*a*a*b";
1365 let name = "aaaaaaaaaaaaaaaaaaaac";
1366 assert!(!glob_match(pattern, name));
1367 }
1368
1369 #[test]
1370 fn test_load_tiff_folder_empty_directory() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let result = load_tiff_folder(dir.path(), None);
1373 assert!(result.is_err());
1374 let err = result.unwrap_err();
1375 assert!(
1376 matches!(err, IoError::NoMatchingFiles { .. }),
1377 "Expected NoMatchingFiles, got: {:?}",
1378 err,
1379 );
1380 }
1381
1382 #[test]
1383 fn test_load_tiff_folder_not_a_directory() {
1384 let dir = tempfile::tempdir().unwrap();
1385 let file_path = dir.path().join("frame_0001.tif");
1386 write_test_tiff(&file_path, &[vec![1, 2, 3, 4]], 2, 2);
1387
1388 let result = load_tiff_folder(&file_path, None);
1389 assert!(result.is_err());
1390 let err = result.unwrap_err();
1391 assert!(
1392 matches!(err, IoError::NotADirectory(..)),
1393 "Expected NotADirectory, got: {:?}",
1394 err,
1395 );
1396 }
1397
1398 #[test]
1399 fn test_load_tiff_folder_dimension_mismatch() {
1400 let dir = tempfile::tempdir().unwrap();
1401
1402 write_test_tiff(
1404 &dir.path().join("frame_0000.tif"),
1405 &[vec![1, 2, 3, 4]],
1406 2,
1407 2,
1408 );
1409 write_test_tiff(
1411 &dir.path().join("frame_0001.tif"),
1412 &[vec![1, 2, 3, 4, 5, 6]],
1413 3,
1414 2,
1415 );
1416
1417 let result = load_tiff_folder(dir.path(), None);
1418 assert!(result.is_err());
1419 let err = result.unwrap_err();
1420 assert!(
1421 matches!(err, IoError::DimensionMismatch { .. }),
1422 "Expected DimensionMismatch, got: {:?}",
1423 err,
1424 );
1425 }
1426
1427 #[test]
1428 fn test_nonexistent_file() {
1429 let result = load_tiff_stack(Path::new("/nonexistent/file.tiff"));
1430 assert!(result.is_err());
1431 }
1432
1433 #[test]
1434 fn test_tiff_stack_info() {
1435 let arr = Array3::<f64>::zeros((10, 512, 512));
1436 let info = TiffStackInfo::from_array(&arr);
1437 assert_eq!(info.n_frames, 10);
1438 assert_eq!(info.height, 512);
1439 assert_eq!(info.width, 512);
1440 }
1441
1442 #[test]
1443 fn test_load_tiff_auto_file() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let path = dir.path().join("multi.tiff");
1446
1447 let frame1: Vec<u16> = vec![10, 20, 30, 40];
1448 let frame2: Vec<u16> = vec![50, 60, 70, 80];
1449 write_test_tiff(&path, &[frame1, frame2], 2, 2);
1450
1451 let arr = load_tiff_auto(&path).unwrap();
1452 assert_eq!(arr.shape(), &[2, 2, 2]);
1453 assert_eq!(arr[[0, 0, 0]], 10.0);
1454 assert_eq!(arr[[1, 1, 1]], 80.0);
1455 }
1456
1457 #[test]
1458 fn test_load_tiff_auto_directory() {
1459 let dir = tempfile::tempdir().unwrap();
1460
1461 for i in 0..2u16 {
1462 let path = dir.path().join(format!("frame_{:04}.tif", i));
1463 let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
1464 write_test_tiff(&path, &[data], 2, 2);
1465 }
1466
1467 let arr = load_tiff_auto(dir.path()).unwrap();
1468 assert_eq!(arr.shape(), &[2, 2, 2]);
1469 assert_eq!(arr[[0, 0, 0]], 10.0);
1470 }
1471
1472 #[test]
1473 fn test_load_tiff_auto_nonexistent() {
1474 let result = load_tiff_auto(Path::new("/nonexistent/path"));
1475 assert!(result.is_err());
1476 }
1477
1478 fn write_chunk_files(dir: &Path, prefix: &str, chunk: u64, base: u16, frames: &[u64]) {
1482 for &f in frames {
1483 let path = dir.join(format!("{}_{}_{:04}.tif", prefix, chunk, f));
1484 let data: Vec<u16> = (0..4).map(|j| base + (f as u16) * 10 + j).collect();
1485 write_test_tiff(&path, &[data], 2, 2);
1486 }
1487 }
1488
1489 fn write_test_tiff_i16(path: &Path, frames: &[Vec<i16>], width: u32, height: u32) {
1493 let file = std::fs::File::create(path).unwrap();
1494 let mut encoder = TiffEncoder::new(file).unwrap();
1495 for frame in frames {
1496 encoder
1497 .write_image::<tiff::encoder::colortype::GrayI16>(width, height, frame)
1498 .unwrap();
1499 }
1500 }
1501
1502 fn write_test_tiff_f32(path: &Path, frames: &[Vec<f32>], width: u32, height: u32) {
1505 let file = std::fs::File::create(path).unwrap();
1506 let mut encoder = TiffEncoder::new(file).unwrap();
1507 for frame in frames {
1508 encoder
1509 .write_image::<tiff::encoder::colortype::Gray32Float>(width, height, frame)
1510 .unwrap();
1511 }
1512 }
1513
1514 const BAD_I16: i16 = -32554;
1516
1517 #[test]
1521 fn test_pixel_policy_reject_negative_i16() {
1522 let dir = tempfile::tempdir().unwrap();
1523 let path = dir.path().join("frame_0000.tif");
1524 write_test_tiff_i16(&path, &[vec![10, BAD_I16, 30, 40]], 2, 2);
1525
1526 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1527 .unwrap_err();
1528 assert!(
1529 matches!(err, IoError::BadPixelValue { .. }),
1530 "Expected BadPixelValue, got: {:?}",
1531 err,
1532 );
1533 let msg = format!("{}", err);
1534 assert!(msg.contains("frame_0000.tif"), "file missing: {msg}");
1535 assert!(msg.contains("frame 0"), "frame missing: {msg}");
1536 assert!(msg.contains("index 1"), "index missing: {msg}");
1537 assert!(msg.contains("-32554"), "value missing: {msg}");
1538 assert!(
1539 msg.contains("detect_bad_pixels"),
1540 "detect_bad_pixels hint missing: {msg}"
1541 );
1542 }
1543
1544 #[test]
1546 fn test_pixel_policy_clip_to_zero() {
1547 let dir = tempfile::tempdir().unwrap();
1548 let path = dir.path().join("frame_0000.tif");
1549 write_test_tiff_i16(&path, &[vec![10, BAD_I16, 30, 40]], 2, 2);
1550
1551 let options = TiffFolderOptions {
1552 pixel_policy: PixelValuePolicy::ClipToZero,
1553 ..Default::default()
1554 };
1555 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1556 assert_eq!(arr[[0, 0, 0]], 10.0);
1557 assert_eq!(arr[[0, 0, 1]], 0.0);
1558 assert_eq!(info.n_clipped_pixels, 1);
1559 }
1560
1561 #[test]
1563 fn test_pixel_policy_allow_negative() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let path = dir.path().join("frame_0000.tif");
1566 write_test_tiff_i16(&path, &[vec![10, BAD_I16, 30, 40]], 2, 2);
1567
1568 let options = TiffFolderOptions {
1569 pixel_policy: PixelValuePolicy::Allow,
1570 ..Default::default()
1571 };
1572 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1573 assert_eq!(arr[[0, 0, 1]], f64::from(BAD_I16));
1574 assert_eq!(info.n_clipped_pixels, 0);
1575 }
1576
1577 #[test]
1579 fn test_pixel_policy_reject_nan_f32() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let path = dir.path().join("frame_0000.tif");
1582 write_test_tiff_f32(&path, &[vec![1.0, f32::NAN, 3.0, 4.0]], 2, 2);
1583
1584 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1585 .unwrap_err();
1586 assert!(
1587 matches!(err, IoError::BadPixelValue { .. }),
1588 "Expected BadPixelValue, got: {:?}",
1589 err,
1590 );
1591 }
1592
1593 #[test]
1595 fn test_pixel_policy_clip_still_rejects_nan() {
1596 let dir = tempfile::tempdir().unwrap();
1597 let path = dir.path().join("frame_0000.tif");
1598 write_test_tiff_f32(&path, &[vec![1.0, f32::NAN, 3.0, 4.0]], 2, 2);
1599
1600 let options = TiffFolderOptions {
1601 pixel_policy: PixelValuePolicy::ClipToZero,
1602 ..Default::default()
1603 };
1604 let err = load_tiff_folder_with_options(dir.path(), None, &options).unwrap_err();
1605 assert!(
1606 matches!(err, IoError::BadPixelValue { .. }),
1607 "Expected BadPixelValue, got: {:?}",
1608 err,
1609 );
1610 }
1611
1612 #[test]
1614 fn test_pixel_policy_allow_nan_and_negative() {
1615 let dir = tempfile::tempdir().unwrap();
1616 let path = dir.path().join("frame_0000.tif");
1617 write_test_tiff_f32(&path, &[vec![1.0, f32::NAN, -5.0, 4.0]], 2, 2);
1618
1619 let options = TiffFolderOptions {
1620 pixel_policy: PixelValuePolicy::Allow,
1621 ..Default::default()
1622 };
1623 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1624 assert!(arr[[0, 0, 1]].is_nan());
1625 assert_eq!(arr[[0, 1, 0]], -5.0);
1626 assert_eq!(info.n_clipped_pixels, 0);
1627 }
1628
1629 #[test]
1632 fn test_pixel_policy_multi_frame_stack() {
1633 let dir = tempfile::tempdir().unwrap();
1634 let path = dir.path().join("multi.tiff");
1635 let frame1: Vec<i16> = vec![1, 2, 3, 4];
1636 let frame2: Vec<i16> = vec![5, BAD_I16, 7, 8];
1637 write_test_tiff_i16(&path, &[frame1, frame2], 2, 2);
1638
1639 let err = load_tiff_stack(&path).unwrap_err();
1640 assert!(
1641 matches!(err, IoError::BadPixelValue { frame: 1, .. }),
1642 "Expected BadPixelValue at frame 1, got: {:?}",
1643 err,
1644 );
1645
1646 let (arr, info) =
1647 load_tiff_stack_with_options(&path, PixelValuePolicy::ClipToZero).unwrap();
1648 assert_eq!(arr.shape(), &[2, 2, 2]);
1649 assert_eq!(arr[[1, 0, 1]], 0.0);
1650 assert_eq!(info.n_clipped_pixels, 1);
1651 }
1652
1653 #[test]
1655 fn test_pixel_policy_clip_counts_accumulate_across_chunks() {
1656 let dir = tempfile::tempdir().unwrap();
1657 write_test_tiff_i16(
1659 &dir.path().join("run_1_0000.tif"),
1660 &[vec![10, -1, 30, 40]],
1661 2,
1662 2,
1663 );
1664 write_test_tiff_i16(
1665 &dir.path().join("run_1_0001.tif"),
1666 &[vec![11, 21, 31, 41]],
1667 2,
1668 2,
1669 );
1670 write_test_tiff_i16(
1672 &dir.path().join("run_2_0000.tif"),
1673 &[vec![100, 200, 300, 400]],
1674 2,
1675 2,
1676 );
1677 write_test_tiff_i16(
1678 &dir.path().join("run_2_0001.tif"),
1679 &[vec![-2, 201, -3, 401]],
1680 2,
1681 2,
1682 );
1683
1684 let options = TiffFolderOptions {
1685 pixel_policy: PixelValuePolicy::ClipToZero,
1686 ..Default::default()
1687 };
1688 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1689 assert_eq!(info.n_clipped_pixels, 3);
1690 assert!(info.chunks_summed);
1691 assert_eq!(arr[[0, 0, 1]], 200.0);
1694 assert_eq!(arr[[1, 0, 0]], 11.0);
1695 assert_eq!(arr[[1, 1, 0]], 31.0);
1696 }
1697
1698 #[test]
1700 fn test_chunked_two_chunks_summed() {
1701 let dir = tempfile::tempdir().unwrap();
1702 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2, 3]);
1703 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1, 2, 3]);
1704
1705 let (arr, info) =
1706 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
1707 assert_eq!(arr.shape(), &[4, 2, 2]);
1708 for f in 0..4usize {
1709 for j in 0..4usize {
1710 let expected = (100 + f * 10 + j) as f64 + (200 + f * 10 + j) as f64;
1711 assert_eq!(arr[[f, j / 2, j % 2]], expected, "frame {f} pixel {j}");
1712 }
1713 }
1714 assert_eq!(
1715 info,
1716 TiffLoadInfo {
1717 n_files: 8,
1718 n_chunks: 2,
1719 chunk_ids: vec![764, 765],
1720 chunks_summed: true,
1721 n_clipped_pixels: 0,
1722 n_unrecognized_files: 0,
1723 unrecognized_examples: vec![],
1724 chunk_inconsistent: false,
1725 }
1726 );
1727 }
1728
1729 #[test]
1734 fn test_chunked_duplicate_write_rejected_on_sum_path() {
1735 let dir = tempfile::tempdir().unwrap();
1736 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2, 3]);
1738 write_chunk_files(dir.path(), "run", 765, 100, &[0, 1, 2, 3]);
1739
1740 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1741 .expect_err("summing byte-identical duplicate chunks must be a hard error");
1742 match err {
1743 IoError::ChunkMismatch { details, .. } => {
1744 assert!(
1745 details.contains("identical") && details.contains("765"),
1746 "unexpected message: {details}"
1747 );
1748 assert!(
1749 details.contains("sum_chunks=false"),
1750 "must name the escape hatch"
1751 );
1752 }
1753 other => panic!("expected ChunkMismatch, got {other:?}"),
1754 }
1755
1756 let options = TiffFolderOptions {
1758 sum_chunks: false,
1759 ..TiffFolderOptions::default()
1760 };
1761 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1762 assert_eq!(arr.shape(), &[8, 2, 2]);
1763 assert!(!info.chunks_summed);
1764 let dir2 = tempfile::tempdir().unwrap();
1767 write_chunk_files(dir2.path(), "run", 764, 100, &[0, 1, 2, 3]);
1768 write_chunk_files(dir2.path(), "run", 765, 200, &[0, 1, 2, 3]);
1769 let (_, info2) =
1770 load_tiff_folder_with_options(dir2.path(), None, &TiffFolderOptions::default())
1771 .unwrap();
1772 assert!(info2.chunks_summed);
1773
1774 let dir3 = tempfile::tempdir().unwrap();
1778 write_chunk_files(dir3.path(), "run", 764, 100, &[0, 1, 2, 3]); write_chunk_files(dir3.path(), "run", 765, 200, &[0, 1, 2, 3]); write_chunk_files(dir3.path(), "run", 766, 200, &[0, 1, 2, 3]); let err3 = load_tiff_folder_with_options(dir3.path(), None, &TiffFolderOptions::default())
1782 .expect_err("[A, B, B] must be rejected — 766 duplicates 765");
1783 match err3 {
1784 IoError::ChunkMismatch { details, .. } => assert!(
1785 details.contains("766") && details.contains("765"),
1786 "must name 766 as identical to 765, got: {details}"
1787 ),
1788 other => panic!("expected ChunkMismatch, got {other:?}"),
1789 }
1790 }
1791
1792 #[test]
1794 fn test_chunked_sum_opt_out() {
1795 let dir = tempfile::tempdir().unwrap();
1796 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2, 3]);
1797 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1, 2, 3]);
1798
1799 let options = TiffFolderOptions {
1800 sum_chunks: false,
1801 ..Default::default()
1802 };
1803 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1804 assert_eq!(arr.shape(), &[8, 2, 2]);
1805 assert_eq!(arr[[0, 0, 0]], 100.0);
1807 assert_eq!(arr[[4, 0, 0]], 200.0);
1808 assert_eq!(info.n_chunks, 2);
1809 assert_eq!(info.chunk_ids, vec![764, 765]);
1810 assert!(!info.chunks_summed);
1811 }
1812
1813 #[test]
1815 fn test_chunked_single_chunk_matches_legacy() {
1816 let dir = tempfile::tempdir().unwrap();
1817 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2]);
1818
1819 let (arr, info) =
1820 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
1821 let legacy = {
1822 let mut paths: Vec<_> = std::fs::read_dir(dir.path())
1823 .unwrap()
1824 .map(|e| e.unwrap().path())
1825 .collect();
1826 paths.sort();
1827 let mut clipped = 0usize;
1828 load_frames_from_paths(&paths, PixelValuePolicy::Reject, &mut clipped).unwrap()
1829 };
1830 assert_eq!(arr, legacy);
1831 assert_eq!(
1832 info,
1833 TiffLoadInfo {
1834 n_files: 3,
1835 n_chunks: 1,
1836 chunk_ids: vec![764],
1837 chunks_summed: false,
1838 n_clipped_pixels: 0,
1839 n_unrecognized_files: 0,
1840 unrecognized_examples: vec![],
1841 chunk_inconsistent: false,
1842 }
1843 );
1844 }
1845
1846 #[test]
1848 fn test_non_chunked_names_legacy() {
1849 let dir = tempfile::tempdir().unwrap();
1850 for i in 0..3u16 {
1851 let path = dir.path().join(format!("frame_{:04}.tif", i));
1852 write_test_tiff(&path, &[vec![i * 10, 1, 2, 3]], 2, 2);
1853 }
1854
1855 let (arr, info) =
1856 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
1857 assert_eq!(arr.shape(), &[3, 2, 2]);
1858 assert_eq!(info.n_chunks, 0);
1859 assert!(info.chunk_ids.is_empty());
1860 assert!(!info.chunks_summed);
1861 assert_eq!(info.n_unrecognized_files, 0);
1863 assert!(info.unrecognized_examples.is_empty());
1864 }
1865
1866 #[test]
1869 fn test_chunked_ragged_counts_error() {
1870 let dir = tempfile::tempdir().unwrap();
1871 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2]);
1872 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1]);
1873
1874 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1875 .unwrap_err();
1876 assert!(
1877 matches!(err, IoError::ChunkMismatch { .. }),
1878 "Expected ChunkMismatch, got: {:?}",
1879 err,
1880 );
1881 let msg = format!("{}", err);
1882 assert!(msg.contains("3 frames"), "counts missing: {msg}");
1883 assert!(msg.contains("2 frames"), "counts missing: {msg}");
1884 }
1885
1886 #[test]
1889 fn test_chunked_differing_frame_sets_error() {
1890 let dir = tempfile::tempdir().unwrap();
1891 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
1892 write_chunk_files(dir.path(), "run", 765, 200, &[0, 2]);
1893
1894 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1895 .unwrap_err();
1896 assert!(
1897 matches!(err, IoError::ChunkMismatch { .. }),
1898 "Expected ChunkMismatch, got: {:?}",
1899 err,
1900 );
1901 let msg = format!("{}", err);
1902 assert!(
1903 msg.contains("1 vs 2"),
1904 "first differing frame missing: {msg}"
1905 );
1906 }
1907
1908 #[test]
1910 fn test_chunked_mixed_prefixes_legacy() {
1911 let dir = tempfile::tempdir().unwrap();
1912 write_chunk_files(dir.path(), "run_a", 764, 100, &[0, 1]);
1913 write_chunk_files(dir.path(), "run_b", 764, 200, &[0, 1]);
1914
1915 let (arr, info) =
1916 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
1917 assert_eq!(arr.shape(), &[4, 2, 2]);
1918 assert_eq!(info.n_chunks, 0);
1919 assert!(!info.chunks_summed);
1920 assert_eq!(info.n_unrecognized_files, 0);
1923 assert!(info.unrecognized_examples.is_empty());
1924 }
1925
1926 #[test]
1928 fn test_chunked_duplicate_frame_error() {
1929 let dir = tempfile::tempdir().unwrap();
1930 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
1931 let dup = dir.path().join("run_764_0001.tiff");
1932 write_test_tiff(&dup, &[vec![9, 9, 9, 9]], 2, 2);
1933
1934 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
1935 .unwrap_err();
1936 assert!(
1937 matches!(err, IoError::ChunkMismatch { .. }),
1938 "Expected ChunkMismatch, got: {:?}",
1939 err,
1940 );
1941 let msg = format!("{}", err);
1942 assert!(msg.contains("duplicate frame 1"), "got: {msg}");
1943 }
1944
1945 #[test]
1952 fn test_chunked_ragged_opt_out_loads_concatenation() {
1953 let dir = tempfile::tempdir().unwrap();
1954 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1, 2]);
1955 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1]);
1956
1957 let options = TiffFolderOptions {
1958 sum_chunks: false,
1959 ..Default::default()
1960 };
1961 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1962 assert_eq!(arr.shape(), &[5, 2, 2]);
1965 assert_eq!(arr[[0, 0, 0]], 100.0);
1967 assert_eq!(arr[[3, 0, 0]], 200.0);
1968 assert_eq!(
1969 info,
1970 TiffLoadInfo {
1971 n_files: 5,
1972 n_chunks: 2,
1973 chunk_ids: vec![764, 765],
1974 chunks_summed: false,
1975 n_clipped_pixels: 0,
1976 n_unrecognized_files: 0,
1977 unrecognized_examples: vec![],
1978 chunk_inconsistent: true,
1979 }
1980 );
1981 }
1982
1983 #[test]
1988 fn test_chunked_duplicate_opt_out_loads_concatenation() {
1989 let dir = tempfile::tempdir().unwrap();
1990 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
1991 let dup = dir.path().join("run_764_0001.tiff");
1992 write_test_tiff(&dup, &[vec![9, 9, 9, 9]], 2, 2);
1993
1994 let options = TiffFolderOptions {
1995 sum_chunks: false,
1996 ..Default::default()
1997 };
1998 let (arr, info) = load_tiff_folder_with_options(dir.path(), None, &options).unwrap();
1999 assert_eq!(arr.shape(), &[3, 2, 2]);
2002 assert_eq!(
2003 info,
2004 TiffLoadInfo {
2005 n_files: 3,
2006 n_chunks: 1,
2007 chunk_ids: vec![764],
2008 chunks_summed: false,
2009 n_clipped_pixels: 0,
2010 n_unrecognized_files: 0,
2011 unrecognized_examples: vec![],
2012 chunk_inconsistent: true,
2013 }
2014 );
2015 }
2016
2017 #[test]
2020 fn test_chunked_numeric_frame_order() {
2021 let dir = tempfile::tempdir().unwrap();
2022 write_test_tiff(&dir.path().join("run_1_2.tif"), &[vec![20, 0, 0, 0]], 2, 2);
2023 write_test_tiff(
2024 &dir.path().join("run_1_10.tif"),
2025 &[vec![100, 0, 0, 0]],
2026 2,
2027 2,
2028 );
2029
2030 let (arr, info) =
2031 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
2032 assert_eq!(arr.shape(), &[2, 2, 2]);
2033 assert_eq!(arr[[0, 0, 0]], 20.0);
2035 assert_eq!(arr[[1, 0, 0]], 100.0);
2036 assert_eq!(info.n_chunks, 1);
2037 assert_eq!(info.chunk_ids, vec![1]);
2038 }
2039
2040 #[test]
2046 fn test_mixed_folder_legacy_fallback_counts_unrecognized() {
2047 let dir = tempfile::tempdir().unwrap();
2048 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
2049 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1]);
2050 write_test_tiff(&dir.path().join("overview.tif"), &[vec![7, 7, 7, 7]], 2, 2);
2051
2052 let (arr, info) =
2053 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
2054 assert_eq!(arr.shape(), &[5, 2, 2]);
2057 assert_eq!(arr[[0, 0, 0]], 7.0);
2058 assert_eq!(arr[[1, 0, 0]], 100.0);
2059 assert_eq!(
2060 info,
2061 TiffLoadInfo {
2062 n_files: 5,
2063 n_chunks: 0,
2064 chunk_ids: vec![],
2065 chunks_summed: false,
2066 n_clipped_pixels: 0,
2067 n_unrecognized_files: 1,
2068 unrecognized_examples: vec!["overview.tif".to_string()],
2069 chunk_inconsistent: false,
2070 }
2071 );
2072 }
2073
2074 #[test]
2078 fn test_unrecognized_examples_capped() {
2079 let dir = tempfile::tempdir().unwrap();
2080 write_chunk_files(dir.path(), "run", 764, 100, &[0]);
2081 for name in ["stray_d.tif", "stray_c.tif", "stray_b.tif", "stray_a.tif"] {
2082 write_test_tiff(&dir.path().join(name), &[vec![1, 1, 1, 1]], 2, 2);
2083 }
2084
2085 let (arr, info) =
2086 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
2087 assert_eq!(arr.shape(), &[5, 2, 2]);
2088 assert_eq!(info.n_unrecognized_files, 4);
2089 assert_eq!(info.unrecognized_examples.len(), MAX_UNRECOGNIZED_EXAMPLES);
2090 assert_eq!(
2091 info.unrecognized_examples,
2092 vec!["stray_a.tif", "stray_b.tif", "stray_c.tif"]
2093 );
2094 }
2095
2096 #[test]
2105 fn test_load_tiff_folder_missing_dir_file_not_found() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let missing = dir.path().join("no_such_dir");
2108
2109 let err = load_tiff_folder_with_options(&missing, None, &TiffFolderOptions::default())
2110 .unwrap_err();
2111 assert!(
2112 matches!(
2113 &err,
2114 IoError::FileNotFound(_, source)
2115 if source.kind() == std::io::ErrorKind::NotFound
2116 ),
2117 "Expected FileNotFound with kind NotFound, got: {:?}",
2118 err,
2119 );
2120 }
2121
2122 #[test]
2124 fn test_chunked_cross_chunk_dimension_mismatch() {
2125 let dir = tempfile::tempdir().unwrap();
2126 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
2127 for f in 0..2u64 {
2129 let path = dir.path().join(format!("run_765_{:04}.tif", f));
2130 write_test_tiff(&path, &[vec![1, 2, 3, 4, 5, 6]], 3, 2);
2131 }
2132
2133 let err = load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default())
2134 .unwrap_err();
2135 assert!(
2136 matches!(err, IoError::DimensionMismatch { .. }),
2137 "Expected DimensionMismatch, got: {:?}",
2138 err,
2139 );
2140 }
2141
2142 #[test]
2144 fn test_chunked_three_chunks() {
2145 let dir = tempfile::tempdir().unwrap();
2146 write_chunk_files(dir.path(), "run", 1, 100, &[0, 1]);
2147 write_chunk_files(dir.path(), "run", 2, 200, &[0, 1]);
2148 write_chunk_files(dir.path(), "run", 7, 400, &[0, 1]);
2150
2151 let (arr, info) =
2152 load_tiff_folder_with_options(dir.path(), None, &TiffFolderOptions::default()).unwrap();
2153 assert_eq!(arr.shape(), &[2, 2, 2]);
2154 for f in 0..2usize {
2155 for j in 0..4usize {
2156 let expected = (700 + 3 * (f * 10 + j)) as f64;
2157 assert_eq!(arr[[f, j / 2, j % 2]], expected, "frame {f} pixel {j}");
2158 }
2159 }
2160 assert_eq!(info.n_chunks, 3);
2161 assert_eq!(info.chunk_ids, vec![1, 2, 7]);
2162 assert!(info.chunks_summed);
2163 }
2164
2165 #[test]
2167 fn test_chunked_pattern_selects_one_chunk() {
2168 let dir = tempfile::tempdir().unwrap();
2169 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
2170 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1]);
2171
2172 let (arr, info) = load_tiff_folder_with_options(
2173 dir.path(),
2174 Some("run_764_*"),
2175 &TiffFolderOptions::default(),
2176 )
2177 .unwrap();
2178 assert_eq!(arr.shape(), &[2, 2, 2]);
2179 assert_eq!(arr[[0, 0, 0]], 100.0);
2180 assert_eq!(info.n_chunks, 1);
2181 assert_eq!(info.chunk_ids, vec![764]);
2182 assert!(!info.chunks_summed);
2183 }
2184
2185 #[test]
2187 fn test_load_tiff_auto_chunked_directory() {
2188 let dir = tempfile::tempdir().unwrap();
2189 write_chunk_files(dir.path(), "run", 764, 100, &[0, 1]);
2190 write_chunk_files(dir.path(), "run", 765, 200, &[0, 1]);
2191
2192 let arr = load_tiff_auto(dir.path()).unwrap();
2193 assert_eq!(arr.shape(), &[2, 2, 2]);
2194 assert_eq!(arr[[0, 0, 0]], 300.0);
2195
2196 let (arr2, info) =
2197 load_tiff_auto_with_options(dir.path(), &TiffFolderOptions::default()).unwrap();
2198 assert_eq!(arr2, arr);
2199 assert!(info.chunks_summed);
2200 assert_eq!(info.n_chunks, 2);
2201 }
2202
2203 #[test]
2205 fn test_load_tiff_folder_rejects_multi_frame() {
2206 let dir = tempfile::tempdir().unwrap();
2207
2208 let path = dir.path().join("multi.tiff");
2210 let frame1: Vec<u16> = vec![1, 2, 3, 4];
2211 let frame2: Vec<u16> = vec![5, 6, 7, 8];
2212 write_test_tiff(&path, &[frame1, frame2], 2, 2);
2213
2214 let result = load_tiff_folder(dir.path(), None);
2215 assert!(
2216 result.is_err(),
2217 "Multi-frame TIFF in folder should be rejected"
2218 );
2219 let err = format!("{}", result.unwrap_err());
2220 assert!(
2221 err.contains("multiple frames"),
2222 "Error should mention multiple frames, got: {err}"
2223 );
2224 }
2225}