Skip to main content

nereids_io/
tiff_stack.rs

1//! Multi-frame TIFF stack loading for neutron imaging data.
2//!
3//! VENUS beamline data is typically stored as multi-frame TIFF files where each
4//! frame corresponds to a time-of-flight (TOF) bin.  The result is a 3D array
5//! with dimensions (n_tof, height, width).
6//!
7//! ## Supported formats
8//! - Single multi-frame TIFF (all TOF bins in one file)
9//! - Directory of single-frame TIFFs (one file per TOF bin, sorted by name)
10//!
11//! ## Data types
12//! - 16-bit unsigned integer (common for neutron detectors)
13//! - 32-bit float (normalized data)
14
15use std::path::Path;
16
17use ndarray::Array3;
18use tiff::decoder::Decoder;
19use tiff::decoder::DecodingResult;
20
21use crate::error::IoError;
22
23/// Load a multi-frame TIFF into a 3D array (n_frames, height, width).
24///
25/// Each TIFF frame becomes one slice along the first axis.
26/// Data is converted to `f64` regardless of the source pixel type.
27///
28/// # Arguments
29/// * `path` — Path to the multi-frame TIFF file.
30///
31/// # Returns
32/// 3D array with shape (n_frames, height, width) and f64 values.
33pub fn load_tiff_stack(path: &Path) -> Result<Array3<f64>, IoError> {
34    let file = std::fs::File::open(path)
35        .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
36    let mut decoder = Decoder::new(file).map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
37
38    let mut frames: Vec<Vec<f64>> = Vec::new();
39    let mut width = 0u32;
40    let mut height = 0u32;
41
42    loop {
43        let (w, h) = decoder
44            .dimensions()
45            .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
46
47        if frames.is_empty() {
48            width = w;
49            height = h;
50        } else if w != width || h != height {
51            return Err(IoError::DimensionMismatch {
52                expected: (width, height),
53                got: (w, h),
54                frame: frames.len(),
55            });
56        }
57
58        let data = decoder
59            .read_image()
60            .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
61
62        let pixels = decode_to_f64(data)?;
63        let expected_len = (width as usize) * (height as usize);
64        if pixels.len() != expected_len {
65            return Err(IoError::TiffDecode(format!(
66                "Frame {} has {} pixels, expected {}",
67                frames.len(),
68                pixels.len(),
69                expected_len
70            )));
71        }
72        frames.push(pixels);
73
74        if !decoder.more_images() {
75            break;
76        }
77        decoder
78            .next_image()
79            .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
80    }
81
82    let n_frames = frames.len();
83    if n_frames == 0 {
84        return Err(IoError::TiffDecode("TIFF file contains no frames".into()));
85    }
86
87    // Flatten all frames into a single Vec and reshape to 3D
88    let flat: Vec<f64> = frames.into_iter().flatten().collect();
89    Array3::from_shape_vec((n_frames, height as usize, width as usize), flat)
90        .map_err(|e| IoError::TiffDecode(format!("Shape error: {}", e)))
91}
92
93/// Load TIFF data from either a single multi-frame file or a directory.
94///
95/// Auto-detects based on whether `path` is a file or directory:
96/// - File → [`load_tiff_stack`] (multi-frame TIFF)
97/// - Directory → [`load_tiff_directory`] (one file per frame)
98///
99/// # Arguments
100/// * `path` — Path to either a multi-frame TIFF file or a directory of TIFFs.
101///
102/// # Returns
103/// 3D array with shape (n_frames, height, width) and f64 values.
104pub fn load_tiff_auto(path: &Path) -> Result<Array3<f64>, IoError> {
105    match std::fs::metadata(path) {
106        Ok(meta) => {
107            if meta.is_file() {
108                load_tiff_stack(path)
109            } else if meta.is_dir() {
110                load_tiff_directory(path)
111            } else {
112                Err(IoError::FileNotFound(
113                    path.to_string_lossy().into_owned(),
114                    std::io::Error::new(
115                        std::io::ErrorKind::InvalidInput,
116                        "path is neither a regular file nor a directory",
117                    ),
118                ))
119            }
120        }
121        Err(e) => Err(IoError::FileNotFound(
122            path.to_string_lossy().into_owned(),
123            e,
124        )),
125    }
126}
127
128/// Load a directory of single-frame TIFFs as a 3D stack.
129///
130/// Files are sorted by name (lexicographic), so they should be named with
131/// zero-padded indices (e.g., `frame_0001.tiff`, `frame_0002.tiff`, ...).
132///
133/// # Arguments
134/// * `dir` — Path to the directory containing TIFF files.
135///
136/// # Returns
137/// 3D array with shape (n_files, height, width) and f64 values.
138pub fn load_tiff_directory(dir: &Path) -> Result<Array3<f64>, IoError> {
139    load_tiff_folder(dir, None).map_err(|e| match e {
140        // Preserve the original error message for backward compatibility.
141        IoError::NoMatchingFiles { .. } => {
142            IoError::TiffDecode("No TIFF files found in directory".into())
143        }
144        other => other,
145    })
146}
147
148/// Load a directory of TIFFs matching a glob pattern as a 3D stack.
149///
150/// Files are sorted lexicographically by name, so they should be named with
151/// zero-padded indices (e.g., `frame_0001.tif`, `frame_0002.tif`, ...).
152///
153/// Only files with `.tif` or `.tiff` extensions (case-insensitive) are considered.
154/// When `pattern` is `None`, all such files are loaded.  When `Some`, the pattern
155/// is additionally matched against each filename (not the full path) and supports
156/// `*` (matches any sequence of characters) and `?` (matches a single character).
157/// Examples: `"*.tif"`, `"frame_*.tiff"`, `"scan_*"` (the extension guard still
158/// applies, so non-TIFF files are never decoded).
159///
160/// # Arguments
161/// * `dir`     — Path to the directory containing TIFF files.
162/// * `pattern` — Optional glob pattern to filter filenames.
163///
164/// # Returns
165/// 3D array with shape (n_files, height, width) and f64 values.
166///
167/// # Errors
168/// * [`IoError::NoMatchingFiles`] if no files match the pattern.
169/// * [`IoError::DimensionMismatch`] if frames have inconsistent dimensions.
170pub fn load_tiff_folder(dir: &Path, pattern: Option<&str>) -> Result<Array3<f64>, IoError> {
171    if !dir.is_dir() {
172        return Err(IoError::NotADirectory(dir.to_string_lossy().into_owned()));
173    }
174
175    // Collect directory entries, propagating per-entry read errors instead of
176    // silently dropping them (which could produce incomplete stacks).
177    let entries: Vec<_> = std::fs::read_dir(dir)
178        .map_err(|e| IoError::FileNotFound(dir.to_string_lossy().into_owned(), e))?
179        .collect::<Result<Vec<_>, _>>()
180        .map_err(|e| IoError::FileNotFound(dir.to_string_lossy().into_owned(), e))?;
181
182    let mut paths: Vec<_> = entries
183        .iter()
184        .filter_map(|entry| {
185            // Compute path once to avoid repeated PathBuf allocations.
186            let p = entry.path();
187            // Use path().is_file() which follows symlinks, unlike file_type().is_file()
188            if !p.is_file() {
189                return None;
190            }
191            let is_tiff = p
192                .extension()
193                .and_then(|ext| ext.to_str())
194                .map(|ext| matches!(ext.to_lowercase().as_str(), "tif" | "tiff"))
195                .unwrap_or(false);
196            if !is_tiff {
197                return None;
198            }
199            if let Some(pat) = pattern {
200                let matches = entry
201                    .file_name()
202                    .to_str()
203                    .map(|name| glob_match(pat, name))
204                    .unwrap_or(false);
205                if !matches {
206                    return None;
207                }
208            }
209            Some(p)
210        })
211        .collect();
212
213    paths.sort();
214
215    if paths.is_empty() {
216        return Err(IoError::NoMatchingFiles {
217            directory: dir.to_string_lossy().into_owned(),
218            pattern: pattern.unwrap_or("*.tif / *.tiff").to_string(),
219        });
220    }
221
222    load_frames_from_paths(&paths)
223}
224
225/// Shared helper: load a sorted slice of single-frame TIFF paths into a 3D array.
226///
227/// Each file must contain exactly one frame.  Dimensions are checked for
228/// consistency across all files and pixel counts are validated against the
229/// reported image dimensions.
230fn load_frames_from_paths(paths: &[std::path::PathBuf]) -> Result<Array3<f64>, IoError> {
231    debug_assert!(
232        !paths.is_empty(),
233        "load_frames_from_paths called with empty paths"
234    );
235    let mut frames: Vec<Vec<f64>> = Vec::new();
236    let mut width = 0u32;
237    let mut height = 0u32;
238
239    for (i, path) in paths.iter().enumerate() {
240        let file = std::fs::File::open(path)
241            .map_err(|e| IoError::FileNotFound(path.to_string_lossy().into_owned(), e))?;
242        let mut decoder = Decoder::new(file).map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
243
244        let (w, h) = decoder
245            .dimensions()
246            .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
247
248        if i == 0 {
249            width = w;
250            height = h;
251        } else if w != width || h != height {
252            return Err(IoError::DimensionMismatch {
253                expected: (width, height),
254                got: (w, h),
255                frame: i,
256            });
257        }
258
259        let data = decoder
260            .read_image()
261            .map_err(|e| IoError::TiffDecode(format!("{}", e)))?;
262
263        // Reject multi-frame TIFFs in folder loading mode — each file
264        // in a directory is expected to contain exactly one frame.
265        // Use load_tiff_stack() for multi-frame TIFFs.
266        if decoder.more_images() {
267            return Err(IoError::InvalidParameter(format!(
268                "File '{}' contains multiple frames; use load_tiff_stack() for multi-frame TIFFs",
269                path.display()
270            )));
271        }
272
273        let pixels = decode_to_f64(data)?;
274        let expected_len = (width as usize) * (height as usize);
275        if pixels.len() != expected_len {
276            return Err(IoError::TiffDecode(format!(
277                "Frame {} has {} pixels, expected {}",
278                i,
279                pixels.len(),
280                expected_len
281            )));
282        }
283        frames.push(pixels);
284    }
285
286    let n_frames = frames.len();
287    let flat: Vec<f64> = frames.into_iter().flatten().collect();
288    Array3::from_shape_vec((n_frames, height as usize, width as usize), flat)
289        .map_err(|e| IoError::TiffDecode(format!("Shape error: {}", e)))
290}
291
292/// Simple glob pattern matching against a filename.
293///
294/// Supports `*` (matches zero or more characters) and `?` (matches exactly one
295/// Unicode character).  The match is case-insensitive to handle mixed-case
296/// extensions (`.TIF`, `.Tiff`, etc.).
297///
298/// Uses an iterative two-pointer algorithm (O(p*n) worst case) to avoid
299/// exponential blowup on pathological patterns like `*a*a*a*b`.
300fn glob_match(pattern: &str, name: &str) -> bool {
301    let p: Vec<char> = pattern.to_lowercase().chars().collect();
302    let n: Vec<char> = name.to_lowercase().chars().collect();
303
304    let (mut pi, mut ni) = (0usize, 0usize);
305    // Saved backtrack positions when we encounter a '*'.
306    let (mut star_pi, mut star_ni) = (None::<usize>, 0usize);
307
308    while ni < n.len() {
309        if pi < p.len() && p[pi] == '*' {
310            // Record the star position and current name index for backtracking.
311            star_pi = Some(pi);
312            star_ni = ni;
313            pi += 1; // Try matching '*' with zero characters first.
314        } else if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
315            pi += 1;
316            ni += 1;
317        } else if let Some(sp) = star_pi {
318            // Mismatch — backtrack: let the last '*' consume one more character.
319            star_ni += 1;
320            ni = star_ni;
321            pi = sp + 1;
322        } else {
323            return false;
324        }
325    }
326
327    // Consume any trailing '*' characters in the pattern.
328    while pi < p.len() && p[pi] == '*' {
329        pi += 1;
330    }
331
332    pi == p.len()
333}
334
335/// Convert TIFF decoded data to f64 values.
336fn decode_to_f64(data: DecodingResult) -> Result<Vec<f64>, IoError> {
337    match data {
338        DecodingResult::U8(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
339        DecodingResult::U16(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
340        DecodingResult::U32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
341        DecodingResult::U64(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
342        DecodingResult::F32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
343        DecodingResult::F64(v) => Ok(v),
344        DecodingResult::I8(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
345        DecodingResult::I16(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
346        DecodingResult::I32(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
347        DecodingResult::I64(v) => Ok(v.into_iter().map(|x| x as f64).collect()),
348        DecodingResult::F16(v) => Ok(v.into_iter().map(f64::from).collect()),
349    }
350}
351
352/// Metadata about a loaded TIFF stack.
353#[derive(Debug, Clone)]
354pub struct TiffStackInfo {
355    /// Number of TOF frames.
356    pub n_frames: usize,
357    /// Image height in pixels.
358    pub height: usize,
359    /// Image width in pixels.
360    pub width: usize,
361}
362
363impl TiffStackInfo {
364    /// Extract info from a loaded 3D array.
365    pub fn from_array(arr: &Array3<f64>) -> Self {
366        let shape = arr.shape();
367        Self {
368            n_frames: shape[0],
369            height: shape[1],
370            width: shape[2],
371        }
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use tiff::encoder::TiffEncoder;
379
380    /// Create a minimal multi-frame TIFF for testing.
381    fn write_test_tiff(path: &Path, frames: &[Vec<u16>], width: u32, height: u32) {
382        let file = std::fs::File::create(path).unwrap();
383        let mut encoder = TiffEncoder::new(file).unwrap();
384        for frame in frames {
385            encoder
386                .write_image::<tiff::encoder::colortype::Gray16>(width, height, frame)
387                .unwrap();
388        }
389    }
390
391    #[test]
392    fn test_load_single_frame_tiff() {
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join("test.tiff");
395
396        // 3x2 image, single frame, values 1-6
397        let data: Vec<u16> = vec![1, 2, 3, 4, 5, 6];
398        write_test_tiff(&path, &[data], 3, 2);
399
400        let arr = load_tiff_stack(&path).unwrap();
401        assert_eq!(arr.shape(), &[1, 2, 3]);
402        assert_eq!(arr[[0, 0, 0]], 1.0);
403        assert_eq!(arr[[0, 0, 2]], 3.0);
404        assert_eq!(arr[[0, 1, 0]], 4.0);
405        assert_eq!(arr[[0, 1, 2]], 6.0);
406    }
407
408    #[test]
409    fn test_load_multi_frame_tiff() {
410        let dir = tempfile::tempdir().unwrap();
411        let path = dir.path().join("multi.tiff");
412
413        let frame1: Vec<u16> = vec![10, 20, 30, 40];
414        let frame2: Vec<u16> = vec![50, 60, 70, 80];
415        let frame3: Vec<u16> = vec![90, 100, 110, 120];
416        write_test_tiff(&path, &[frame1, frame2, frame3], 2, 2);
417
418        let arr = load_tiff_stack(&path).unwrap();
419        assert_eq!(arr.shape(), &[3, 2, 2]);
420        // First frame
421        assert_eq!(arr[[0, 0, 0]], 10.0);
422        assert_eq!(arr[[0, 1, 1]], 40.0);
423        // Third frame
424        assert_eq!(arr[[2, 0, 0]], 90.0);
425        assert_eq!(arr[[2, 1, 1]], 120.0);
426    }
427
428    #[test]
429    fn test_load_tiff_directory() {
430        let dir = tempfile::tempdir().unwrap();
431
432        // Write 3 single-frame TIFFs
433        for i in 0..3u16 {
434            let path = dir.path().join(format!("frame_{:04}.tiff", i));
435            let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
436            write_test_tiff(&path, &[data], 2, 2);
437        }
438
439        let arr = load_tiff_directory(dir.path()).unwrap();
440        assert_eq!(arr.shape(), &[3, 2, 2]);
441        // frame_0000: 10, 11, 12, 13
442        assert_eq!(arr[[0, 0, 0]], 10.0);
443        // frame_0002: 30, 31, 32, 33
444        assert_eq!(arr[[2, 0, 0]], 30.0);
445        assert_eq!(arr[[2, 1, 1]], 33.0);
446    }
447
448    #[test]
449    fn test_load_tiff_folder_no_pattern() {
450        let dir = tempfile::tempdir().unwrap();
451
452        // Mix of .tif and .tiff — both should be picked up
453        for i in 0..2u16 {
454            let path = dir.path().join(format!("frame_{:04}.tif", i));
455            let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
456            write_test_tiff(&path, &[data], 2, 2);
457        }
458        let path = dir.path().join("frame_0002.tiff");
459        write_test_tiff(&path, &[vec![30, 31, 32, 33]], 2, 2);
460
461        // Non-TIFF sidecar should be ignored
462        std::fs::write(dir.path().join("frame_0001.tif.bak"), b"not a tiff").unwrap();
463
464        let arr = load_tiff_folder(dir.path(), None).unwrap();
465        assert_eq!(arr.shape(), &[3, 2, 2]);
466    }
467
468    #[test]
469    fn test_load_tiff_folder_with_pattern() {
470        let dir = tempfile::tempdir().unwrap();
471
472        for i in 0..3u16 {
473            let path = dir.path().join(format!("frame_{:04}.tif", i));
474            let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
475            write_test_tiff(&path, &[data], 2, 2);
476        }
477
478        let arr = load_tiff_folder(dir.path(), Some("*.tif")).unwrap();
479        assert_eq!(arr.shape(), &[3, 2, 2]);
480        assert_eq!(arr[[0, 0, 0]], 10.0);
481        assert_eq!(arr[[2, 1, 1]], 33.0);
482    }
483
484    #[test]
485    fn test_load_tiff_folder_custom_pattern() {
486        let dir = tempfile::tempdir().unwrap();
487
488        // Write files matching "scan_*.tif" and a non-matching file
489        for i in 0..2u16 {
490            let path = dir.path().join(format!("scan_{:04}.tif", i));
491            let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
492            write_test_tiff(&path, &[data], 2, 2);
493        }
494        // This file should NOT be matched by "scan_*.tif"
495        let extra = dir.path().join("other_0001.tif");
496        write_test_tiff(&extra, &[vec![99, 99, 99, 99]], 2, 2);
497
498        let arr = load_tiff_folder(dir.path(), Some("scan_*.tif")).unwrap();
499        assert_eq!(arr.shape(), &[2, 2, 2]);
500        assert_eq!(arr[[0, 0, 0]], 10.0);
501    }
502
503    #[test]
504    fn test_load_tiff_folder_no_matching_files() {
505        let dir = tempfile::tempdir().unwrap();
506
507        // Write a .tiff file but search for .png
508        let path = dir.path().join("frame_0001.tiff");
509        write_test_tiff(&path, &[vec![1, 2, 3, 4]], 2, 2);
510
511        let result = load_tiff_folder(dir.path(), Some("*.png"));
512        assert!(result.is_err());
513        let err = result.unwrap_err();
514        assert!(
515            matches!(err, IoError::NoMatchingFiles { .. }),
516            "Expected NoMatchingFiles, got: {:?}",
517            err,
518        );
519    }
520
521    #[test]
522    fn test_load_tiff_folder_case_insensitive() {
523        let dir = tempfile::tempdir().unwrap();
524
525        // Write a file with uppercase extension
526        let path = dir.path().join("frame_0001.TIF");
527        write_test_tiff(&path, &[vec![1, 2, 3, 4]], 2, 2);
528
529        // Pattern with lowercase should still match
530        let arr = load_tiff_folder(dir.path(), Some("*.tif")).unwrap();
531        assert_eq!(arr.shape(), &[1, 2, 2]);
532    }
533
534    #[test]
535    fn test_glob_match_basic() {
536        assert!(glob_match("*.tif", "frame_0001.tif"));
537        assert!(glob_match("*.tif", "a.tif"));
538        assert!(!glob_match("*.tif", "frame_0001.tiff"));
539        assert!(!glob_match("*.tif", "frame_0001.png"));
540    }
541
542    #[test]
543    fn test_glob_match_question_mark() {
544        assert!(glob_match("frame_?.tif", "frame_1.tif"));
545        assert!(!glob_match("frame_?.tif", "frame_12.tif"));
546        // '?' should match a single Unicode character, not a single byte
547        assert!(glob_match("?.tif", "\u{00e9}.tif")); // é is multi-byte in UTF-8
548    }
549
550    #[test]
551    fn test_glob_match_case_insensitive() {
552        assert!(glob_match("*.tif", "FILE.TIF"));
553        assert!(glob_match("*.TIF", "file.tif"));
554    }
555
556    #[test]
557    fn test_glob_match_pattern_longer_than_name() {
558        assert!(!glob_match("abcdef.tif", "a.tif"));
559    }
560
561    #[test]
562    fn test_glob_match_empty_strings() {
563        assert!(glob_match("", ""));
564        assert!(!glob_match("", "foo"));
565        assert!(glob_match("*", ""));
566    }
567
568    #[test]
569    fn test_glob_match_pathological_pattern() {
570        // Verify the iterative matcher handles patterns that would cause
571        // exponential blowup in a naive recursive implementation.
572        let pattern = "*a*a*a*a*a*b";
573        let name = "aaaaaaaaaaaaaaaaaaaac";
574        assert!(!glob_match(pattern, name));
575    }
576
577    #[test]
578    fn test_load_tiff_folder_empty_directory() {
579        let dir = tempfile::tempdir().unwrap();
580        let result = load_tiff_folder(dir.path(), None);
581        assert!(result.is_err());
582        let err = result.unwrap_err();
583        assert!(
584            matches!(err, IoError::NoMatchingFiles { .. }),
585            "Expected NoMatchingFiles, got: {:?}",
586            err,
587        );
588    }
589
590    #[test]
591    fn test_load_tiff_folder_not_a_directory() {
592        let dir = tempfile::tempdir().unwrap();
593        let file_path = dir.path().join("frame_0001.tif");
594        write_test_tiff(&file_path, &[vec![1, 2, 3, 4]], 2, 2);
595
596        let result = load_tiff_folder(&file_path, None);
597        assert!(result.is_err());
598        let err = result.unwrap_err();
599        assert!(
600            matches!(err, IoError::NotADirectory(..)),
601            "Expected NotADirectory, got: {:?}",
602            err,
603        );
604    }
605
606    #[test]
607    fn test_load_tiff_folder_dimension_mismatch() {
608        let dir = tempfile::tempdir().unwrap();
609
610        // Frame 0: 2x2
611        write_test_tiff(
612            &dir.path().join("frame_0000.tif"),
613            &[vec![1, 2, 3, 4]],
614            2,
615            2,
616        );
617        // Frame 1: 3x2 — different width
618        write_test_tiff(
619            &dir.path().join("frame_0001.tif"),
620            &[vec![1, 2, 3, 4, 5, 6]],
621            3,
622            2,
623        );
624
625        let result = load_tiff_folder(dir.path(), None);
626        assert!(result.is_err());
627        let err = result.unwrap_err();
628        assert!(
629            matches!(err, IoError::DimensionMismatch { .. }),
630            "Expected DimensionMismatch, got: {:?}",
631            err,
632        );
633    }
634
635    #[test]
636    fn test_nonexistent_file() {
637        let result = load_tiff_stack(Path::new("/nonexistent/file.tiff"));
638        assert!(result.is_err());
639    }
640
641    #[test]
642    fn test_tiff_stack_info() {
643        let arr = Array3::<f64>::zeros((10, 512, 512));
644        let info = TiffStackInfo::from_array(&arr);
645        assert_eq!(info.n_frames, 10);
646        assert_eq!(info.height, 512);
647        assert_eq!(info.width, 512);
648    }
649
650    #[test]
651    fn test_load_tiff_auto_file() {
652        let dir = tempfile::tempdir().unwrap();
653        let path = dir.path().join("multi.tiff");
654
655        let frame1: Vec<u16> = vec![10, 20, 30, 40];
656        let frame2: Vec<u16> = vec![50, 60, 70, 80];
657        write_test_tiff(&path, &[frame1, frame2], 2, 2);
658
659        let arr = load_tiff_auto(&path).unwrap();
660        assert_eq!(arr.shape(), &[2, 2, 2]);
661        assert_eq!(arr[[0, 0, 0]], 10.0);
662        assert_eq!(arr[[1, 1, 1]], 80.0);
663    }
664
665    #[test]
666    fn test_load_tiff_auto_directory() {
667        let dir = tempfile::tempdir().unwrap();
668
669        for i in 0..2u16 {
670            let path = dir.path().join(format!("frame_{:04}.tif", i));
671            let data: Vec<u16> = (0..4).map(|j| (i + 1) * 10 + j).collect();
672            write_test_tiff(&path, &[data], 2, 2);
673        }
674
675        let arr = load_tiff_auto(dir.path()).unwrap();
676        assert_eq!(arr.shape(), &[2, 2, 2]);
677        assert_eq!(arr[[0, 0, 0]], 10.0);
678    }
679
680    #[test]
681    fn test_load_tiff_auto_nonexistent() {
682        let result = load_tiff_auto(Path::new("/nonexistent/path"));
683        assert!(result.is_err());
684    }
685
686    /// Folder loading should reject files containing multiple frames.
687    #[test]
688    fn test_load_tiff_folder_rejects_multi_frame() {
689        let dir = tempfile::tempdir().unwrap();
690
691        // Write a multi-frame TIFF into the directory.
692        let path = dir.path().join("multi.tiff");
693        let frame1: Vec<u16> = vec![1, 2, 3, 4];
694        let frame2: Vec<u16> = vec![5, 6, 7, 8];
695        write_test_tiff(&path, &[frame1, frame2], 2, 2);
696
697        let result = load_tiff_folder(dir.path(), None);
698        assert!(
699            result.is_err(),
700            "Multi-frame TIFF in folder should be rejected"
701        );
702        let err = format!("{}", result.unwrap_err());
703        assert!(
704            err.contains("multiple frames"),
705            "Error should mention multiple frames, got: {err}"
706        );
707    }
708}