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//! - Chunked VENUS autoreduced folder: files named
11//!   `<prefix>_<chunk>_<frame>.tif(f)` where the DAQ split one run into
12//!   several chunks that each cover the full TOF frame range
13//!
14//! ## Chunked folders
15//!
16//! When *every* filename stem in the (extension- and pattern-filtered) folder
17//! parses as `<prefix>_<chunk>_<frame>` with a single common prefix, the
18//! folder is treated as chunked:
19//! - one chunk → frames are ordered by *numeric* frame index (identical to
20//!   lexicographic order for zero-padded names, and strictly better for
21//!   unpadded ones where `_10` sorts before `_2` lexicographically);
22//! - two or more chunks with identical frame-index sequences → chunks are
23//!   summed element-wise by default (each chunk covers the same TOF bins, so
24//!   the physical stack is the sum, not a concatenation).  Opt out with
25//!   [`TiffFolderOptions::sum_chunks`]` = false` to get the legacy
26//!   lexicographic concatenation (the flag only affects folders with two or
27//!   more chunks — single-chunk folders always load in numeric frame
28//!   order);
29//! - ragged chunks (differing frame counts or frame sets) or duplicate
30//!   (chunk, frame) pairs → dispatched on the summing flag.  On the default
31//!   summing path ([`TiffFolderOptions::sum_chunks`]` = true`) they are a
32//!   hard [`IoError::ChunkMismatch`] error, never a silent stack or partial
33//!   sum (summing ragged chunks would corrupt counts).  With summing opted
34//!   out ([`sum_chunks`](TiffFolderOptions::sum_chunks)` = false`) there is
35//!   nothing to corrupt, so the documented legacy lexicographic
36//!   concatenation loads even for inconsistent chunks, with the irregularity
37//!   surfaced through [`TiffLoadInfo::chunk_inconsistent`].
38//!
39//! Folders with two or more distinct prefixes fall back to legacy
40//! lexicographic stacking — summing across different prefixes would merge
41//! different runs.  Use the `pattern` argument to select one run.
42//!
43//! *Mixed* folders — where at least one stem parses as
44//! `<prefix>_<chunk>_<frame>` but others do not (a stray overview TIFF, a
45//! misnamed frame) — also fall back to legacy lexicographic stacking, but
46//! the fallback is counted: [`TiffLoadInfo::n_unrecognized_files`] reports
47//! how many files disabled chunk detection and
48//! [`TiffLoadInfo::unrecognized_examples`] names up to
49//! [`MAX_UNRECOGNIZED_EXAMPLES`] of them, so consumers (the GUI provenance
50//! log, the Python `UserWarning`) can surface that a chunked-looking run
51//! folder was *not* chunk-loaded.  Remove the stray files or use `pattern`
52//! to exclude them.
53//!
54//! ### One acquisition per folder
55//!
56//! The chunk heuristic assumes the folder holds **one acquisition** — the
57//! VENUS autoreduce layout, where each run gets its own directory (verified
58//! on IPTS-37432 output; note the `<chunk>` field in real names is a
59//! run-ish id, e.g. `..._ob_0_116_00000.tif`).  The heuristic cannot
60//! distinguish same-prefix sibling *runs* co-located in one folder from DAQ
61//! chunks of a single run: such siblings would be summed.  When a folder
62//! may hold multiple runs, select one with `pattern` or pass
63//! [`TiffFolderOptions::sum_chunks`]` = false`.
64//!
65//! ## Pixel-value policy
66//!
67//! Raw detector counts are non-negative by construction, so a negative or
68//! non-finite pixel signals file corruption or a signed-type readout bug.
69//! By default every loader rejects such values with
70//! [`IoError::BadPixelValue`] ([`PixelValuePolicy::Reject`]).  Two escape
71//! hatches exist:
72//! - [`PixelValuePolicy::ClipToZero`] clamps negative values to `0.0`
73//!   (counted in [`TiffLoadInfo::n_clipped_pixels`]); non-finite values
74//!   still error, because clipping a NaN would invent data;
75//! - [`PixelValuePolicy::Allow`] accepts all values verbatim — required for
76//!   pre-normalized transmission stacks, where noise around zero can
77//!   legitimately produce small negative values.
78//!
79//! For *corrupt readout pixels* in raw counts (e.g. a railed pixel stuck at
80//! a signed sentinel), the right tool is a per-acquisition mask from
81//! `nereids_io::normalization::detect_bad_pixels`, not a load-time clamp.
82//!
83//! ## Data types
84//! - 16-bit unsigned integer (common for neutron detectors)
85//! - 32-bit float (normalized data)
86
87use 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/// Policy for negative or non-finite pixel values encountered during load.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum PixelValuePolicy {
99    /// Reject the load with [`IoError::BadPixelValue`] (default).  Raw
100    /// detector counts are non-negative by construction, so a negative or
101    /// non-finite pixel signals corruption that must be surfaced, not
102    /// silently imported.
103    #[default]
104    Reject,
105    /// Clamp negative values to `0.0`, counting them in
106    /// [`TiffLoadInfo::n_clipped_pixels`].  Non-finite values still error —
107    /// clipping a NaN would invent data.
108    ClipToZero,
109    /// Accept all values verbatim.  Needed for pre-normalized transmission
110    /// stacks, where noise around zero legitimately produces small negative
111    /// values.
112    Allow,
113}
114
115/// Options controlling how a TIFF folder (or file) is loaded.
116#[derive(Debug, Clone, Copy)]
117pub struct TiffFolderOptions {
118    /// Sum DAQ chunks element-wise when a chunked folder is detected
119    /// (default `true`).  When `false`, a *multi-chunk* folder is loaded
120    /// as the legacy lexicographic concatenation of all files.  The flag
121    /// only affects folders with two or more chunks: single-chunk (and
122    /// non-chunk-patterned) folders load identically either way —
123    /// chunk-patterned names in numeric frame order, others
124    /// lexicographically.
125    ///
126    /// The flag also decides how *inconsistent* chunks (ragged frame
127    /// counts/sets or duplicate (chunk, frame) pairs) are handled.  With
128    /// summing on (the default), inconsistency is a hard
129    /// [`IoError::ChunkMismatch`] error — summing ragged chunks would
130    /// silently corrupt counts.  With summing off, the legacy lexicographic
131    /// concatenation loads even for inconsistent chunks (there is nothing to
132    /// corrupt), and the irregularity is reported via
133    /// [`TiffLoadInfo::chunk_inconsistent`] rather than raised — inspecting
134    /// raw frames of a ragged folder is exactly when `sum_chunks = false` is
135    /// reached for.
136    pub sum_chunks: bool,
137    /// Policy for negative / non-finite pixel values (default
138    /// [`PixelValuePolicy::Reject`]).
139    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/// Provenance metadata about a completed TIFF load.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct TiffLoadInfo {
154    /// Number of TIFF files read (1 for a single multi-frame file).
155    pub n_files: usize,
156    /// Number of DAQ chunks detected (0 when the folder does not follow the
157    /// chunked `<prefix>_<chunk>_<frame>` naming convention).
158    pub n_chunks: usize,
159    /// Detected chunk identifiers, ascending (empty when `n_chunks == 0`).
160    pub chunk_ids: Vec<u64>,
161    /// Whether chunks were summed element-wise into a single stack.
162    pub chunks_summed: bool,
163    /// Number of negative pixels clamped to zero.  Only ever nonzero under
164    /// [`PixelValuePolicy::ClipToZero`].
165    pub n_clipped_pixels: usize,
166    /// Number of files that did **not** parse as `<prefix>_<chunk>_<frame>`
167    /// while at least one other file in the same folder did — a *mixed*
168    /// folder, where the non-conforming files disabled chunk detection and
169    /// forced the legacy lexicographic load.  `0` in every other path:
170    /// single-file loads, fully chunk-patterned folders, folders where no
171    /// file matches the convention (the normal `frame_0000.tif` world), and
172    /// multi-prefix folders (every stem parses; a different, documented
173    /// fallback).
174    pub n_unrecognized_files: usize,
175    /// Lexicographically-first filenames of the non-conforming files, capped
176    /// at [`MAX_UNRECOGNIZED_EXAMPLES`] entries so the provenance stays
177    /// message-sized.  Empty iff `n_unrecognized_files == 0`.
178    pub unrecognized_examples: Vec<String>,
179    /// Whether the folder's chunk-patterned files were internally
180    /// inconsistent (ragged frame counts/sets or a duplicate (chunk, frame)
181    /// pair) yet were still loaded, as the legacy lexicographic
182    /// concatenation, because the caller opted out of summing
183    /// ([`TiffFolderOptions::sum_chunks`]` = false`).  This is a distinct
184    /// signal from [`n_unrecognized_files`](Self::n_unrecognized_files): the
185    /// files *do* follow `<prefix>_<chunk>_<frame>`, they just do not agree
186    /// on a common frame set.  Always `false` on the summing path — there the
187    /// same inconsistency is a hard [`IoError::ChunkMismatch`] error, because
188    /// summing ragged chunks would silently corrupt counts.
189    pub chunk_inconsistent: bool,
190}
191
192/// Maximum number of offending filenames retained in
193/// [`TiffLoadInfo::unrecognized_examples`] when a mixed folder disables
194/// chunk detection (the count in [`TiffLoadInfo::n_unrecognized_files`] is
195/// never capped).
196pub const MAX_UNRECOGNIZED_EXAMPLES: usize = 3;
197
198/// Apply the pixel-value policy to one decoded frame, in place.
199///
200/// Returns the number of pixels clamped to zero (only ever nonzero under
201/// [`PixelValuePolicy::ClipToZero`]).  `frame` is the frame's position in
202/// the stack being assembled, used only for error reporting.
203fn 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                // NaN bypasses `<`, so the finiteness check must come first
225                // and cannot be folded into the comparison below.
226                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
244/// Load a multi-frame TIFF into a 3D array (n_frames, height, width).
245///
246/// Each TIFF frame becomes one slice along the first axis.
247/// Data is converted to `f64` regardless of the source pixel type.
248/// Negative or non-finite pixels are rejected (the default
249/// [`PixelValuePolicy::Reject`]); use [`load_tiff_stack_with_options`] to
250/// choose a different policy.
251///
252/// # Arguments
253/// * `path` — Path to the multi-frame TIFF file.
254///
255/// # Returns
256/// 3D array with shape (n_frames, height, width) and f64 values.
257pub fn load_tiff_stack(path: &Path) -> Result<Array3<f64>, IoError> {
258    load_tiff_stack_with_options(path, PixelValuePolicy::default()).map(|(arr, _)| arr)
259}
260
261/// Load a multi-frame TIFF with an explicit pixel-value policy, returning
262/// provenance metadata.
263///
264/// Behaves like [`load_tiff_stack`], with the pixel-value policy applied to
265/// every frame as it is decoded (see the [module docs](self)).
266///
267/// # Arguments
268/// * `path`         — Path to the multi-frame TIFF file.
269/// * `pixel_policy` — Policy for negative / non-finite pixel values.
270///
271/// # Returns
272/// `(stack, info)` where `stack` has shape (n_frames, height, width).
273pub 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    // Flatten all frames into a single Vec and reshape to 3D
333    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
351/// Load TIFF data from either a single multi-frame file or a directory.
352///
353/// Auto-detects based on whether `path` is a file or directory:
354/// - File → [`load_tiff_stack`] (multi-frame TIFF)
355/// - Directory → [`load_tiff_directory`] (one file per frame)
356///
357/// Directories are **not** loaded purely lexicographically: chunked VENUS
358/// folders (`<prefix>_<chunk>_<frame>.tif`) are detected automatically,
359/// ordered by numeric frame index, and chunks are summed element-wise
360/// (the [`TiffFolderOptions`] defaults — see the [module docs](self)).
361/// No provenance is returned; use [`load_tiff_auto_with_options`] to get
362/// a [`TiffLoadInfo`] and to control chunk summing.
363///
364/// # Arguments
365/// * `path` — Path to either a multi-frame TIFF file or a directory of TIFFs.
366///
367/// # Returns
368/// 3D array with shape (n_frames, height, width) and f64 values.
369pub 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
393/// Load TIFF data from a file or directory, returning provenance metadata.
394///
395/// Auto-detects based on whether `path` is a file or directory, like
396/// [`load_tiff_auto`], but additionally applies [`TiffFolderOptions`] (chunk
397/// summing for directories) and reports what was done via [`TiffLoadInfo`].
398///
399/// # Arguments
400/// * `path`    — Path to either a multi-frame TIFF file or a directory of TIFFs.
401/// * `options` — Loading options (chunk summing).
402///
403/// # Returns
404/// `(stack, info)` where `stack` has shape (n_frames, height, width).
405pub 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
432/// Load a directory of single-frame TIFFs as a 3D stack.
433///
434/// Delegates to [`load_tiff_folder`] with default options: chunked VENUS
435/// folders (`<prefix>_<chunk>_<frame>.tif`) are detected automatically,
436/// ordered by numeric frame index, and chunks covering identical frame
437/// ranges are **summed element-wise**.  Only folders *not* following the
438/// chunk convention load in lexicographic filename order — name legacy
439/// files with zero-padded indices (e.g., `frame_0001.tiff`,
440/// `frame_0002.tiff`, ...).  No provenance is returned; use
441/// [`load_tiff_folder_with_options`] to get a [`TiffLoadInfo`] and to
442/// control chunk summing.
443///
444/// # Arguments
445/// * `dir` — Path to the directory containing TIFF files.
446///
447/// # Returns
448/// 3D array with shape (n_frames, height, width) and f64 values.
449pub fn load_tiff_directory(dir: &Path) -> Result<Array3<f64>, IoError> {
450    load_tiff_folder(dir, None).map_err(|e| match e {
451        // Preserve the original error message for backward compatibility.
452        IoError::NoMatchingFiles { .. } => {
453            IoError::TiffDecode("No TIFF files found in directory".into())
454        }
455        other => other,
456    })
457}
458
459/// Load a directory of TIFFs matching a glob pattern as a 3D stack.
460///
461/// Applies the default [`TiffFolderOptions`]: chunked VENUS folders
462/// (`<prefix>_<chunk>_<frame>.tif`) are detected automatically, ordered by
463/// numeric frame index, and chunks covering identical frame ranges are
464/// **summed element-wise**.  Only folders *not* following the chunk
465/// convention load in lexicographic filename order — name legacy files
466/// with zero-padded indices (e.g., `frame_0001.tif`, `frame_0002.tif`,
467/// ...).  No provenance is returned; use
468/// [`load_tiff_folder_with_options`] to get a [`TiffLoadInfo`] and to
469/// control chunk summing.
470///
471/// Only files with `.tif` or `.tiff` extensions (case-insensitive) are considered.
472/// When `pattern` is `None`, all such files are loaded.  When `Some`, the pattern
473/// is additionally matched against each filename (not the full path) and supports
474/// `*` (matches any sequence of characters) and `?` (matches a single character).
475/// Examples: `"*.tif"`, `"frame_*.tiff"`, `"scan_*"` (the extension guard still
476/// applies, so non-TIFF files are never decoded).
477///
478/// # Arguments
479/// * `dir`     — Path to the directory containing TIFF files.
480/// * `pattern` — Optional glob pattern to filter filenames.
481///
482/// # Returns
483/// 3D array with shape (n_files, height, width) and f64 values.
484///
485/// # Errors
486/// * [`IoError::FileNotFound`] if `dir` does not exist.
487/// * [`IoError::NotADirectory`] if `dir` exists but is not a directory.
488/// * [`IoError::NoMatchingFiles`] if no files match the pattern.
489/// * [`IoError::DimensionMismatch`] if frames have inconsistent dimensions.
490/// * [`IoError::ChunkMismatch`] if a chunked folder is internally
491///   inconsistent *and* chunk summing is enabled (the default); with
492///   `sum_chunks = false` the inconsistency is reported via
493///   [`TiffLoadInfo::chunk_inconsistent`] instead of raised.
494pub 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
498/// Load a directory of TIFFs matching a glob pattern, returning provenance
499/// metadata.
500///
501/// Behaves like [`load_tiff_folder`] (same extension guard and glob pattern
502/// semantics), with two additions:
503/// - chunked-folder detection and element-wise chunk summing (see the
504///   [module docs](self) and [`TiffFolderOptions::sum_chunks`]);
505/// - a [`TiffLoadInfo`] report of what was loaded.
506///
507/// # Arguments
508/// * `dir`     — Path to the directory containing TIFF files.
509/// * `pattern` — Optional glob pattern to filter filenames.
510/// * `options` — Loading options (chunk summing).
511///
512/// # Returns
513/// `(stack, info)` where `stack` has shape (n_frames, height, width).
514///
515/// # Errors
516/// * [`IoError::FileNotFound`] if `dir` does not exist.
517/// * [`IoError::NotADirectory`] if `dir` exists but is not a directory.
518/// * [`IoError::NoMatchingFiles`] if no files match the pattern.
519/// * [`IoError::DimensionMismatch`] if frames have inconsistent dimensions.
520/// * [`IoError::ChunkMismatch`] if a chunked folder is internally
521///   inconsistent (ragged chunks or duplicate (chunk, frame) pairs) *and*
522///   `options.sum_chunks` is `true`.  With `sum_chunks = false` the same
523///   inconsistency is not raised: the files load as the legacy lexicographic
524///   concatenation and [`TiffLoadInfo::chunk_inconsistent`] is set.
525pub fn load_tiff_folder_with_options(
526    dir: &Path,
527    pattern: Option<&str>,
528    options: &TiffFolderOptions,
529) -> Result<(Array3<f64>, TiffLoadInfo), IoError> {
530    // Distinguish "does not exist" from "exists but is not a directory":
531    // the Python binding maps `FileNotFound` *whose source kind is
532    // `NotFound`* to `FileNotFoundError` and `NotADirectory` to
533    // `NotADirectoryError`, and its docstring promises exactly that split.
534    //
535    // A single `metadata` probe (mirroring `load_tiff_auto_with_options`) is
536    // the honest test: `Path::exists()`/`is_dir()` collapse *every* metadata
537    // failure to `false`, so a permission-denied parent (EACCES) would be
538    // mislabeled `FileNotFound(NotFound)` → Python `FileNotFoundError` — the
539    // exact confusion `is_genuine_not_found` exists to prevent.  Wrapping the
540    // real `io::Error` preserves its true kind, so only a genuine `NotFound`
541    // reaches `FileNotFoundError` while EACCES falls through to `OSError`.
542    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    // Collect directory entries, propagating per-entry read errors instead of
549    // silently dropping them (which could produce incomplete stacks).
550    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            // Compute path once to avoid repeated PathBuf allocations.
559            let p = entry.path();
560            // Use path().is_file() which follows symlinks, unlike file_type().is_file()
561            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            // Only reachable with `sum_chunks == false` (detect_chunk_layout
619            // yields this variant *instead of* a hard `ChunkMismatch` exactly
620            // when summing was opted out).  There is nothing to corrupt when
621            // we are not summing, so honor the documented `sum_chunks=false`
622            // contract: load every matching file as the legacy lexicographic
623            // concatenation (frame count = sum of all files) and surface the
624            // irregularity through `chunk_inconsistent` — the chunk ids are
625            // still reported so consumers can name what was inconsistent.
626            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                // Single chunk loads in ascending numeric frame order (a
648                // strict improvement over lexicographic order for unpadded
649                // frame numbers); multiple chunks additionally sum
650                // element-wise across chunks.
651                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                // Chunk summing opted out (only reachable with >= 2 chunks):
668                // legacy lexicographic concatenation of every matching file
669                // (chunk structure is still reported in the info).
670                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
691/// Detected layout of a TIFF folder's (filtered) file list.
692enum ChunkLayout {
693    /// Not a chunked folder — load in lexicographic filename order.
694    /// The fields are nonzero/non-empty only for *mixed* folders (some
695    /// stems parsed as `<prefix>_<chunk>_<frame>` but others did not),
696    /// where the non-conforming files disabled chunk detection; see
697    /// [`TiffLoadInfo::n_unrecognized_files`].
698    Legacy {
699        n_unrecognized_files: usize,
700        unrecognized_examples: Vec<String>,
701    },
702    /// Chunk-conforming filenames that are internally inconsistent — ragged
703    /// frame counts/sets or a duplicate (chunk, frame) pair.  Produced *only*
704    /// when `sum_chunks` is `false`: with summing requested the identical
705    /// inconsistency is a hard [`IoError::ChunkMismatch`] (summing ragged
706    /// chunks would silently corrupt counts).  The caller loads these files
707    /// as the legacy lexicographic concatenation and records the irregularity
708    /// in [`TiffLoadInfo::chunk_inconsistent`]; the detected chunk ids are
709    /// carried so the provenance can still name them.
710    InconsistentChunks { chunk_ids: Vec<u64> },
711    /// Chunked folder: chunk id → frames as `(frame index, path)`, with
712    /// chunk ids ascending (BTreeMap) and frames sorted ascending by index.
713    /// Every chunk is validated to cover the identical frame-index sequence.
714    Chunked(BTreeMap<u64, Vec<(u64, PathBuf)>>),
715}
716
717/// Parse a numeric filename field: non-empty, all ASCII digits, within `u64`
718/// range.  Overflow (or any non-digit) yields `None` so the caller falls back
719/// to legacy lexicographic loading rather than guessing.
720fn 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
727/// Parse a filename stem of the chunked form `<prefix>_<chunk>_<frame>`.
728///
729/// Splits from the right (the prefix itself may contain underscores), and
730/// requires both numeric fields to be non-empty ASCII digit runs.  Returns
731/// `None` when the stem does not follow the convention.
732fn 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
740/// Classify a filtered file list as legacy or chunked.
741///
742/// Legacy fallbacks (no error): any stem that does not parse as
743/// `<prefix>_<chunk>_<frame>` (including non-UTF-8 stems), or two or more
744/// distinct prefixes (summing across prefixes would merge different runs;
745/// use `pattern` to select one).  *Mixed* folders — at least one stem
746/// parsed but others did not — still fall back (a hand-assembled folder is
747/// legitimate) but are counted in the returned
748/// [`ChunkLayout::Legacy`] fields so every consumer can surface that a
749/// stray file disabled chunk detection; all-non-conforming folders (the
750/// normal `frame_0000.tif` world) report a count of 0.
751///
752/// Internally *inconsistent* chunks — duplicate (chunk, frame) pairs (e.g.
753/// the same stem with both `.tif` and `.tiff` extensions, or `_764_`
754/// alongside `_0764_`) or ragged chunks (differing frame counts or frame
755/// sets) — are dispatched on `sum_chunks`:
756/// - `sum_chunks == true` (the default summing path): a hard
757///   [`IoError::ChunkMismatch`] error, because summing ragged chunks would
758///   silently corrupt counts in the missing frames.  This guard is airtight
759///   — the only path that ever *sums* rejects inconsistency before a single
760///   frame is added.
761/// - `sum_chunks == false` (the opt-out): [`ChunkLayout::InconsistentChunks`]
762///   instead of an error.  Nothing is summed, so nothing can be corrupted;
763///   the caller loads the documented legacy lexicographic concatenation and
764///   flags [`TiffLoadInfo::chunk_inconsistent`].
765///
766/// Chunk ids need *not* be consecutive — a dropped middle chunk is still the
767/// same run.
768fn 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        // Non-UTF-8 stems cannot follow the ASCII naming convention, so
777        // they count as non-conforming like any other unparseable stem
778        // (displayed lossily in the examples).
779        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        // Mixed folders (some stems parsed, some did not) must be loud:
795        // without the count, ONE stray overview TIFF silently reinstates
796        // the doubled-stack load — n_chunks reports 0 and neither the
797        // Python warning nor the GUI provenance ever mentions chunks.
798        let (n_unrecognized_files, unrecognized_examples) = if parsed.is_empty() {
799            (0, Vec::new())
800        } else {
801            let n = unrecognized.len();
802            // Sort for deterministic examples (read_dir order is arbitrary).
803            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        // Every stem parsed but prefixes differ — a multi-run folder, the
816        // documented legacy fallback, not a stray-file situation: count 0.
817        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    // One source of truth for "what makes chunks inconsistent"; the caller
829    // decides whether that inconsistency is fatal (summing) or a soft
830    // fall-back (opt-out).  Keeping the check in one place stops the two
831    // paths from ever drifting apart.
832    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
844/// Validate a parsed chunk map for internal consistency, sorting each
845/// chunk's frames by numeric index in place (needed both here and by
846/// [`load_chunked_sum`]).
847///
848/// Returns `Ok(())` when every chunk covers the identical frame-index
849/// sequence with no duplicate (chunk, frame) pair, or `Err(details)`
850/// describing the first inconsistency found (a duplicate frame within a
851/// chunk, differing per-chunk frame counts, or differing frame sets).  The
852/// caller maps `details` onto either a hard [`IoError::ChunkMismatch`] (the
853/// summing path — summing ragged chunks would silently corrupt counts) or a
854/// [`ChunkLayout::InconsistentChunks`] soft fall-back (`sum_chunks = false`,
855/// nothing to corrupt).
856fn validate_chunk_consistency(
857    chunks: &mut BTreeMap<u64, Vec<(u64, PathBuf)>>,
858) -> Result<(), String> {
859    // Sort each chunk's frames by numeric index and reject duplicates.
860    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    // Every chunk must cover the identical frame-index sequence.
874    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
901/// Load a validated chunked layout: first chunk becomes the stack, remaining
902/// chunks are decoded frame-by-frame and added element-wise.
903///
904/// Peak memory is one full stack plus one frame's decode buffers: the first
905/// chunk is decoded straight into its preallocated stack (see
906/// [`load_frames_from_paths`]) and every later chunk is added one frame at
907/// a time.  VENUS stacks run to several GB, so materialising every chunk
908/// (or a transient second copy of one chunk's stack) is not an option.
909fn 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    // Duplicate-chunk guard (issue #653).  On real VENUS data every observed
922    // multi-chunk folder is a *duplicate write* of one
923    // exposure, not sequential DAQ segments — summing them (the default)
924    // silently doubles every count.  We fingerprint each chunk with an
925    // FNV-1a hash over the raw f64 bits (O(1) extra memory — the stacks run
926    // to several GB, so a second copy for an equality check is not an
927    // option) and refuse to sum a chunk that is identical to ANY earlier
928    // chunk (not just the first — otherwise a duplicate pair that excludes
929    // the first chunk, e.g. [A, B, B], would still be double-summed).
930    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    // Single-chunk folders (the dominant real case) never enter the loop, so
935    // skip the full hash pass over the multi-GB first chunk entirely.
936    let multi_chunk = chunks.len() > 1;
937    let mut seen_hashes: Vec<(u64, u64)> = Vec::new(); // (chunk_id, hash)
938    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            // Fold in the SAME element order as `acc.iter()` (row-major
957            // frame,y,x) so identical content yields identical hashes.
958            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
985/// Shared helper: load a sorted slice of single-frame TIFF paths into a 3D array.
986///
987/// Each file must contain exactly one frame.  Dimensions are checked for
988/// consistency across all files and pixel counts are validated against the
989/// reported image dimensions.  The stack is preallocated once the first
990/// frame reveals the dimensions and every frame is copied straight into
991/// its slice, so peak memory is the full stack plus one frame's decode
992/// buffers — never a transient second copy of the stack.
993fn 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    // Placeholder until the first frame reveals the dimensions (returned
1005    // as-is only in the release-mode empty-input case the debug_assert
1006    // above rules out in tests).
1007    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        // read_single_frame validated pixels.len() == w × h, so this shape
1025        // check cannot fail in practice; map it anyway rather than unwrap.
1026        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
1034/// Decode one single-frame TIFF file to `(pixels, width, height)`.
1035///
1036/// Rejects files containing more than one frame — each file in a directory is
1037/// expected to contain exactly one frame; use [`load_tiff_stack`] for
1038/// multi-frame TIFFs.  The pixel count is validated against the reported
1039/// image dimensions and the pixel-value policy is enforced (clipped pixels
1040/// accumulate into `n_clipped_pixels`).  `frame_label` is the frame's
1041/// position in the stack being assembled, used only for error messages.
1042fn 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    // Reject multi-frame TIFFs in folder loading mode — each file
1061    // in a directory is expected to contain exactly one frame.
1062    // Use load_tiff_stack() for multi-frame TIFFs.
1063    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
1084/// Simple glob pattern matching against a filename.
1085///
1086/// Supports `*` (matches zero or more characters) and `?` (matches exactly one
1087/// Unicode character).  The match is case-insensitive to handle mixed-case
1088/// extensions (`.TIF`, `.Tiff`, etc.).
1089///
1090/// Uses an iterative two-pointer algorithm (O(p*n) worst case) to avoid
1091/// exponential blowup on pathological patterns like `*a*a*a*b`.
1092fn 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    // Saved backtrack positions when we encounter a '*'.
1098    let (mut star_pi, mut star_ni) = (None::<usize>, 0usize);
1099
1100    while ni < n.len() {
1101        if pi < p.len() && p[pi] == '*' {
1102            // Record the star position and current name index for backtracking.
1103            star_pi = Some(pi);
1104            star_ni = ni;
1105            pi += 1; // Try matching '*' with zero characters first.
1106        } 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            // Mismatch — backtrack: let the last '*' consume one more character.
1111            star_ni += 1;
1112            ni = star_ni;
1113            pi = sp + 1;
1114        } else {
1115            return false;
1116        }
1117    }
1118
1119    // Consume any trailing '*' characters in the pattern.
1120    while pi < p.len() && p[pi] == '*' {
1121        pi += 1;
1122    }
1123
1124    pi == p.len()
1125}
1126
1127/// Convert TIFF decoded data to f64 values.
1128fn 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/// Metadata about a loaded TIFF stack.
1145#[derive(Debug, Clone)]
1146pub struct TiffStackInfo {
1147    /// Number of TOF frames.
1148    pub n_frames: usize,
1149    /// Image height in pixels.
1150    pub height: usize,
1151    /// Image width in pixels.
1152    pub width: usize,
1153}
1154
1155impl TiffStackInfo {
1156    /// Extract info from a loaded 3D array.
1157    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    /// Create a minimal multi-frame TIFF for testing.
1173    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        // 3x2 image, single frame, values 1-6
1189        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        // First frame
1213        assert_eq!(arr[[0, 0, 0]], 10.0);
1214        assert_eq!(arr[[0, 1, 1]], 40.0);
1215        // Third frame
1216        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        // Write 3 single-frame TIFFs
1225        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        // frame_0000: 10, 11, 12, 13
1234        assert_eq!(arr[[0, 0, 0]], 10.0);
1235        // frame_0002: 30, 31, 32, 33
1236        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        // Mix of .tif and .tiff — both should be picked up
1245        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        // Non-TIFF sidecar should be ignored
1254        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        // Write files matching "scan_*.tif" and a non-matching file
1281        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        // This file should NOT be matched by "scan_*.tif"
1287        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        // Write a .tiff file but search for .png
1300        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        // Write a file with uppercase extension
1318        let path = dir.path().join("frame_0001.TIF");
1319        write_test_tiff(&path, &[vec![1, 2, 3, 4]], 2, 2);
1320
1321        // Pattern with lowercase should still match
1322        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        // '?' should match a single Unicode character, not a single byte
1339        assert!(glob_match("?.tif", "\u{00e9}.tif")); // é is multi-byte in UTF-8
1340    }
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        // Verify the iterative matcher handles patterns that would cause
1363        // exponential blowup in a naive recursive implementation.
1364        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        // Frame 0: 2x2
1403        write_test_tiff(
1404            &dir.path().join("frame_0000.tif"),
1405            &[vec![1, 2, 3, 4]],
1406            2,
1407            2,
1408        );
1409        // Frame 1: 3x2 — different width
1410        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    /// Write a chunked-run test folder: `<prefix>_<chunk>_<frame>.tif` files
1479    /// with 2x2 pixels valued `base + frame*10 + pixel` so per-element sums
1480    /// are easy to assert.
1481    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    /// Create a signed-16-bit TIFF (native GrayI16 encoding) for pixel-value
1490    /// policy tests — a railed/corrupt readout pixel shows up as a negative
1491    /// signed sentinel such as -32554.
1492    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    /// Create a 32-bit float TIFF (native Gray32Float encoding) so NaN and
1503    /// negative float pixels can be synthesized directly.
1504    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    /// The corrupt-readout sentinel used across the pixel-policy tests.
1515    const BAD_I16: i16 = -32554;
1516
1517    /// T15: a negative signed pixel is rejected by default with a message
1518    /// naming the file, frame, index, value, and the detect_bad_pixels()
1519    /// escape hatch.
1520    #[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    /// T16: ClipToZero clamps the negative pixel to 0.0 and counts it.
1545    #[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    /// T17: Allow passes the negative value through verbatim.
1562    #[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    /// T18: a NaN float pixel is rejected by default.
1578    #[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    /// T19: ClipToZero still errors on NaN — clipping NaN would invent data.
1594    #[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    /// T20: Allow passes NaN and negative floats through verbatim.
1613    #[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    /// T21: multi-frame load_tiff_stack rejects negatives by default;
1630    /// load_tiff_stack_with_options can clip instead.
1631    #[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    /// T22: clipped-pixel counts accumulate across summed chunks.
1654    #[test]
1655    fn test_pixel_policy_clip_counts_accumulate_across_chunks() {
1656        let dir = tempfile::tempdir().unwrap();
1657        // Chunk 1: one negative pixel in frame 0.
1658        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        // Chunk 2: two negative pixels in frame 1.
1671        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        // Clipping applies per frame before summing: frame 0 pixel 1 is
1692        // 0 + 200, frame 1 pixel 0 is 11 + 0.
1693        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    /// T1: two chunks with identical frame sequences sum element-wise.
1699    #[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    /// Issue #653: two chunks with byte-identical content are a duplicate DAQ
1730    /// write, not sequential segments — the default summing path must refuse
1731    /// them (silently doubling every count is the exact real-VENUS failure),
1732    /// while `sum_chunks=false` still loads every frame for inspection.
1733    #[test]
1734    fn test_chunked_duplicate_write_rejected_on_sum_path() {
1735        let dir = tempfile::tempdir().unwrap();
1736        // Same base ⇒ chunk 765 is byte-identical to chunk 764.
1737        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        // The escape hatch loads all 8 frames (concatenation), no summing.
1757        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        // Distinct chunks (the T1 case) must still sum — the guard is
1765        // content-based, not a blanket multi-chunk refusal.
1766        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        // A duplicate pair that does NOT include the first chunk ([A, B, B])
1775        // must also be caught — the guard compares against ALL earlier
1776        // chunks, not just the first.
1777        let dir3 = tempfile::tempdir().unwrap();
1778        write_chunk_files(dir3.path(), "run", 764, 100, &[0, 1, 2, 3]); // A
1779        write_chunk_files(dir3.path(), "run", 765, 200, &[0, 1, 2, 3]); // B
1780        write_chunk_files(dir3.path(), "run", 766, 200, &[0, 1, 2, 3]); // B' == B
1781        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    /// T2: sum_chunks=false loads the legacy lexicographic concatenation.
1793    #[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        // Lexicographic: run_764_0000 .. run_764_0003, run_765_0000 ..
1806        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    /// T3: a single chunk loads identically to legacy (zero-padded names).
1814    #[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    /// T4: non-chunked names (single `_<num>` field) use the legacy path.
1847    #[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        // All-non-conforming folders are the normal legacy world: no noise.
1862        assert_eq!(info.n_unrecognized_files, 0);
1863        assert!(info.unrecognized_examples.is_empty());
1864    }
1865
1866    /// T5: ragged chunks (differing frame counts) are a hard error naming
1867    /// the per-chunk counts.
1868    #[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    /// T6: equal counts but differing frame sets are a hard error naming the
1887    /// first differing frame.
1888    #[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    /// T7: two distinct prefixes fall back to legacy (never sum across runs).
1909    #[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        // Every stem parsed (prefixes merely differ) — not a stray-file
1921        // situation, so no unrecognized-file noise.
1922        assert_eq!(info.n_unrecognized_files, 0);
1923        assert!(info.unrecognized_examples.is_empty());
1924    }
1925
1926    /// T8: duplicate (chunk, frame) via `.tif` + `.tiff` of the same stem.
1927    #[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    /// T25: with `sum_chunks = false`, ragged chunks are NOT a hard error —
1946    /// they load as the legacy lexicographic concatenation (frame count = the
1947    /// sum of every file) and the irregularity is surfaced through
1948    /// `chunk_inconsistent`, not raised.  Inspecting raw frames of a ragged
1949    /// folder is exactly what the opt-out is for.  (Contrast T5, where the
1950    /// same folder under the default summing path is a `ChunkMismatch`.)
1951    #[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        // Legacy concatenation of all 5 files (3 from chunk 764, 2 from 765),
1963        // never a partial sum.
1964        assert_eq!(arr.shape(), &[5, 2, 2]);
1965        // Lexicographic: run_764_0000..0002, then run_765_0000..0001.
1966        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    /// T26: with `sum_chunks = false`, a duplicate (chunk, frame) pair is
1984    /// likewise NOT a hard error — the files load as the legacy lexicographic
1985    /// concatenation and `chunk_inconsistent` is set.  (Contrast T8, where
1986    /// the same folder under the default summing path is a `ChunkMismatch`.)
1987    #[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        // A single chunk (764) with a duplicated frame 1 — three files load
2000        // verbatim in lexicographic order (`.tif` before `.tiff`).
2001        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    /// T9: unpadded frame numbers order numerically (`_2` before `_10`),
2018    /// unlike lexicographic order where `run_1_10` sorts before `run_1_2`.
2019    #[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        // Numeric order: frame 2 first, then frame 10.
2034        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    /// T10: a mixed folder (chunk-patterned files plus one stray) falls
2041    /// back to legacy lexicographic loading — but the fallback is counted:
2042    /// the stray is reported in `n_unrecognized_files` and named in
2043    /// `unrecognized_examples`, so a mis-picked raw chunked run folder can
2044    /// never load as a k× concatenated stack with zero provenance.
2045    #[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        // Legacy lexicographic concatenation of all 5 files, stray first
2055        // ("overview.tif" < "run_..."), never a chunk sum.
2056        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    /// T11: `unrecognized_examples` is capped at
2075    /// [`MAX_UNRECOGNIZED_EXAMPLES`] lexicographically-first names while
2076    /// `n_unrecognized_files` keeps the full count.
2077    #[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    /// A nonexistent folder path is `FileNotFound` carrying the *real* OS
2097    /// error kind `NotFound` (Python: `FileNotFoundError`); `NotADirectory`
2098    /// is reserved for paths that exist but are not directories (see
2099    /// `test_load_tiff_folder_not_a_directory`).  The kind is now the genuine
2100    /// `std::fs::metadata` error, not a synthesized sentinel, so a
2101    /// permission-denied parent (EACCES — not portably reproducible in a unit
2102    /// test) surfaces as its true `PermissionDenied` kind and falls through
2103    /// to `OSError` rather than being mislabeled `FileNotFoundError`.
2104    #[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    /// T23: dimension mismatch across chunks is surfaced as DimensionMismatch.
2123    #[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        // Chunk 765 has the same frames but 3x2 images.
2128        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    /// T24: three chunks sum element-wise.
2143    #[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        // Chunk ids need not be consecutive — a dropped middle chunk is fine.
2149        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    /// T12: a glob pattern that selects one chunk yields n_chunks == 1.
2166    #[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    /// T13: load_tiff_auto on a chunked directory sums by default.
2186    #[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    /// Folder loading should reject files containing multiple frames.
2204    #[test]
2205    fn test_load_tiff_folder_rejects_multi_frame() {
2206        let dir = tempfile::tempdir().unwrap();
2207
2208        // Write a multi-frame TIFF into the directory.
2209        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}