Skip to main content

fpm_rs/measurements/
lazy.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    mem::size_of,
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex},
6};
7
8use crate::{
9    Result,
10    error::Error,
11    image_io::{
12        GrayscaleScaling, grayscale_image_shape, grayscale_tiff_page_shapes, load_grayscale,
13        load_grayscale_tiff_page,
14    },
15};
16
17use super::{
18    FrameMetadata, MeasurementSpec, MeasurementStack, PreprocessingConfig,
19    stack::{load_manifest_image, load_manifest_image_set, resolve_path},
20};
21
22/// File-backed measurement frames decoded, preprocessed, and cached on access.
23///
24/// Construction reads image headers to validate grayscale type and dimensions,
25/// but does not decode measurement pixel buffers. A bounded thread-safe LRU
26/// cache keeps the working set out of core while returned shared frame handles
27/// remain stable. Cache byte accounting covers frame pixel buffers retained by
28/// the cache, not handles that callers keep alive after eviction.
29///
30/// Frame decoding occurs without holding the cache mutex, so callers can read
31/// different uncached frames concurrently; a second lookup makes concurrent
32/// reads of the same frame converge on one cached handle. A poisoned cache is
33/// treated as corrupted state: [`Self::frame`] returns an error rather than
34/// reusing it. The infallible cache-statistics accessors report zero after
35/// poisoning because they cannot expose that error; rebuild the stack before
36/// using it again.
37#[derive(Debug)]
38pub struct LazyMeasurementStack {
39    paths: Vec<PathBuf>,
40    page_indices: Vec<Option<usize>>,
41    image_shape: (usize, usize),
42    frame_metadata: Vec<FrameMetadata>,
43    dark_frame: Option<Vec<f64>>,
44    flat_field: Option<Vec<f64>>,
45    background: Option<Vec<f64>>,
46    masks: Option<Vec<u8>>,
47    preprocessing: PreprocessingConfig,
48    cache_capacity: usize,
49    cache_byte_capacity: Option<usize>,
50    cache: Mutex<FrameCache>,
51}
52
53#[derive(Debug, Default)]
54struct FrameCache {
55    frames: HashMap<usize, Arc<Vec<f64>>>,
56    least_recently_used: VecDeque<usize>,
57    bytes: usize,
58}
59
60impl FrameCache {
61    fn touch(&mut self, index: usize) {
62        self.least_recently_used.retain(|&cached| cached != index);
63        self.least_recently_used.push_back(index);
64    }
65
66    fn evict_least_recently_used(&mut self) -> bool {
67        let Some(index) = self.least_recently_used.pop_front() else {
68            return false;
69        };
70        if let Some(frame) = self.frames.remove(&index) {
71            self.bytes = self
72                .bytes
73                .saturating_sub(frame.len().saturating_mul(size_of::<f64>()));
74        }
75        true
76    }
77}
78
79impl Clone for LazyMeasurementStack {
80    fn clone(&self) -> Self {
81        Self {
82            paths: self.paths.clone(),
83            page_indices: self.page_indices.clone(),
84            image_shape: self.image_shape,
85            frame_metadata: self.frame_metadata.clone(),
86            dark_frame: self.dark_frame.clone(),
87            flat_field: self.flat_field.clone(),
88            background: self.background.clone(),
89            masks: self.masks.clone(),
90            preprocessing: self.preprocessing.clone(),
91            cache_capacity: self.cache_capacity,
92            cache_byte_capacity: self.cache_byte_capacity,
93            // Clones intentionally start empty so decoded frames are not
94            // duplicated implicitly.
95            cache: Mutex::new(FrameCache::default()),
96        }
97    }
98}
99
100impl LazyMeasurementStack {
101    pub fn from_image_files<P: AsRef<Path>>(
102        paths: &[P],
103        frame_metadata: Vec<FrameMetadata>,
104    ) -> Result<Self> {
105        if paths.is_empty() {
106            return Err(Error::InvalidMeasurements(
107                "at least one image path is required".into(),
108            ));
109        }
110        let paths: Vec<PathBuf> = paths.iter().map(|path| path.as_ref().to_owned()).collect();
111        let image_shape = grayscale_image_shape(&paths[0])?;
112        for path in &paths[1..] {
113            let shape = grayscale_image_shape(path)?;
114            if shape != image_shape {
115                return Err(Error::InvalidMeasurements(format!(
116                    "image {} has shape {shape:?}, expected {image_shape:?}",
117                    path.display()
118                )));
119            }
120        }
121        let frame_metadata =
122            metadata_or_labels(&paths, frame_metadata, |path, _| path.display().to_string());
123        let frame_count = paths.len();
124        Self::new(paths, vec![None; frame_count], image_shape, frame_metadata)
125    }
126
127    /// Opens a multipage TIFF lazily. Page headers are validated up front, but
128    /// each page's pixels are decoded only when that frame is requested.
129    pub fn from_tiff_stack(
130        path: impl AsRef<Path>,
131        frame_metadata: Vec<FrameMetadata>,
132    ) -> Result<Self> {
133        let path = path.as_ref().to_owned();
134        let shapes = grayscale_tiff_page_shapes(&path)?;
135        let image_shape = *shapes.first().ok_or_else(|| {
136            Error::InvalidMeasurements("TIFF stack does not contain an image".into())
137        })?;
138        if shapes.iter().any(|&shape| shape != image_shape) {
139            return Err(Error::InvalidMeasurements(
140                "all TIFF pages must have the same dimensions".into(),
141            ));
142        }
143        let paths = vec![path; shapes.len()];
144        let frame_metadata = metadata_or_labels(&paths, frame_metadata, |path, index| {
145            format!("{}#page={index}", path.display())
146        });
147        let page_indices = (0..shapes.len()).map(Some).collect();
148        Self::new(paths, page_indices, image_shape, frame_metadata)
149    }
150
151    /// Loads a manifest while leaving measurement frames file-backed. Small
152    /// shared correction images and optional per-frame backgrounds/masks are
153    /// loaded once; configured corrections are applied during frame decoding.
154    pub fn from_manifest(path: impl AsRef<Path>) -> Result<Self> {
155        let path = path.as_ref();
156        let manifest = MeasurementSpec::load(path)?;
157        let base_directory = path.parent().unwrap_or_else(|| Path::new("."));
158        Self::from_manifest_definition(manifest, base_directory)
159    }
160
161    pub fn from_manifest_definition(
162        manifest: MeasurementSpec,
163        base_directory: impl AsRef<Path>,
164    ) -> Result<Self> {
165        let base_directory = base_directory.as_ref();
166        let paths: Vec<_> = manifest
167            .frames
168            .iter()
169            .map(|frame| resolve_path(base_directory, &frame.path))
170            .collect();
171        let metadata = manifest
172            .frames
173            .iter()
174            .enumerate()
175            .map(|(index, frame)| FrameMetadata {
176                frame_index: index,
177                illumination_index: frame.illumination_index,
178                original_frame_index: Some(index),
179                original_illumination_index: frame.illumination_index,
180                exposure_time: frame.exposure_time,
181                weight: frame.weight,
182                label: frame
183                    .label
184                    .clone()
185                    .or_else(|| Some(frame.path.display().to_string())),
186            })
187            .collect();
188        let mut stack = Self::from_image_files(&paths, metadata)?;
189        if let Some(path) = &manifest.dark_frame {
190            stack.dark_frame = Some(load_manifest_image(
191                base_directory,
192                path,
193                stack.image_shape,
194            )?);
195        }
196        if let Some(path) = &manifest.flat_field {
197            stack.flat_field = Some(load_manifest_image(
198                base_directory,
199                path,
200                stack.image_shape,
201            )?);
202        }
203        if let Some(background) = &manifest.background {
204            stack.background = Some(load_manifest_image_set(
205                base_directory,
206                background,
207                stack.image_shape,
208                stack.frame_count(),
209            )?);
210        }
211        if let Some(mask) = &manifest.mask {
212            stack.masks = Some(
213                load_manifest_image_set(
214                    base_directory,
215                    mask,
216                    stack.image_shape,
217                    stack.frame_count(),
218                )?
219                .into_iter()
220                .map(|value| u8::from(value != 0.0))
221                .collect(),
222            );
223        }
224        stack.preprocessing = manifest.preprocessing;
225        stack.validate()?;
226        Ok(stack)
227    }
228
229    fn new(
230        paths: Vec<PathBuf>,
231        page_indices: Vec<Option<usize>>,
232        image_shape: (usize, usize),
233        frame_metadata: Vec<FrameMetadata>,
234    ) -> Result<Self> {
235        let stack = Self {
236            paths,
237            page_indices,
238            image_shape,
239            frame_metadata,
240            dark_frame: None,
241            flat_field: None,
242            background: None,
243            masks: None,
244            preprocessing: PreprocessingConfig::default(),
245            cache_capacity: 1,
246            cache_byte_capacity: None,
247            cache: Mutex::new(FrameCache::default()),
248        };
249        stack.validate()?;
250        Ok(stack)
251    }
252
253    pub fn with_dark_frame(mut self, dark: Vec<f64>) -> Result<Self> {
254        self.validate_correction("dark frame", &dark, false)?;
255        self.dark_frame = Some(dark);
256        self.preprocessing.subtract_dark = true;
257        self.reset_cache();
258        Ok(self)
259    }
260
261    pub fn with_flat_field(mut self, flat: Vec<f64>) -> Result<Self> {
262        self.validate_correction("flat field", &flat, false)?;
263        if flat.iter().any(|&value| value <= 0.0) {
264            return Err(Error::InvalidMeasurements(
265                "flat-field values must be positive".into(),
266            ));
267        }
268        self.flat_field = Some(flat);
269        self.preprocessing.divide_flat_field = true;
270        self.reset_cache();
271        Ok(self)
272    }
273
274    pub fn with_background(mut self, background: Vec<f64>) -> Result<Self> {
275        self.validate_correction("background", &background, true)?;
276        self.background = Some(background);
277        self.preprocessing.subtract_background = true;
278        self.reset_cache();
279        Ok(self)
280    }
281
282    pub fn with_masks(mut self, masks: Vec<u8>) -> Result<Self> {
283        let frame_len = self.frame_len();
284        let stack_len = self.stack_len()?;
285        if masks.len() != frame_len && masks.len() != stack_len {
286            return Err(Error::InvalidMeasurements(format!(
287                "mask length {} must be {frame_len} or {stack_len}",
288                masks.len()
289            )));
290        }
291        self.masks = Some(masks);
292        Ok(self)
293    }
294
295    pub fn normalize_exposure(mut self) -> Self {
296        self.preprocessing.normalize_exposure = true;
297        self.reset_cache();
298        self
299    }
300
301    pub fn clamp_negative(mut self) -> Self {
302        self.preprocessing.clamp_negative = true;
303        self.reset_cache();
304        self
305    }
306
307    /// Replaces the preprocessing flags. Required correction arrays must have
308    /// already been supplied through the corresponding builder.
309    pub fn with_preprocessing(mut self, preprocessing: PreprocessingConfig) -> Result<Self> {
310        self.preprocessing = preprocessing;
311        self.reset_cache();
312        self.validate()?;
313        Ok(self)
314    }
315
316    /// Sets the maximum decoded frames retained in memory. The default is one.
317    pub fn with_cache_capacity(mut self, capacity: usize) -> Result<Self> {
318        if capacity == 0 {
319            return Err(Error::InvalidParameter {
320                name: "cache_capacity",
321                reason: "must be greater than zero".into(),
322            });
323        }
324        self.cache_capacity = capacity;
325        self.reset_cache();
326        Ok(self)
327    }
328
329    /// Sets a second cache limit in bytes. It must fit at least one decoded
330    /// `f64` frame. Count and byte limits are enforced simultaneously.
331    pub fn with_cache_byte_capacity(mut self, capacity: usize) -> Result<Self> {
332        let frame_bytes = self.frame_bytes()?;
333        if capacity < frame_bytes {
334            return Err(Error::InvalidParameter {
335                name: "cache_byte_capacity",
336                reason: format!("must be at least one decoded frame ({frame_bytes} bytes)"),
337            });
338        }
339        self.cache_byte_capacity = Some(capacity);
340        self.reset_cache();
341        Ok(self)
342    }
343
344    pub fn frame_count(&self) -> usize {
345        self.paths.len()
346    }
347
348    pub fn image_shape(&self) -> (usize, usize) {
349        self.image_shape
350    }
351
352    pub fn frame_len(&self) -> usize {
353        self.image_shape.0 * self.image_shape.1
354    }
355
356    pub fn frame_metadata(&self) -> &[FrameMetadata] {
357        &self.frame_metadata
358    }
359
360    pub fn preprocessing(&self) -> &PreprocessingConfig {
361        &self.preprocessing
362    }
363
364    /// Returns one path per frame. Multipage TIFF frames therefore repeat the
365    /// stack path and can be distinguished by their metadata labels.
366    pub fn paths(&self) -> &[PathBuf] {
367        &self.paths
368    }
369
370    pub fn cached_frame_count(&self) -> usize {
371        // These lightweight status accessors predate fallible cache metrics.
372        // Zero is deliberately a conservative value when a poisoned lock makes
373        // the cache contents unavailable; `frame` reports the actual error.
374        self.cache.lock().map_or(0, |cache| cache.frames.len())
375    }
376
377    pub fn cached_byte_count(&self) -> usize {
378        self.cache.lock().map_or(0, |cache| cache.bytes)
379    }
380
381    pub fn frame(&self, index: usize) -> Result<Arc<Vec<f64>>> {
382        if index >= self.frame_count() {
383            return Err(Error::FrameOutOfRange {
384                index,
385                frames: self.frame_count(),
386            });
387        }
388        {
389            let mut cache = self.lock_cache()?;
390            if let Some(frame) = cache.frames.get(&index).cloned() {
391                cache.touch(index);
392                return Ok(frame);
393            }
394        }
395
396        // Decoding occurs outside the cache lock so unrelated frame reads can
397        // proceed concurrently. The second lookup below resolves races safely.
398        let path = &self.paths[index];
399        let mut values = match self.page_indices[index] {
400            Some(page_index) => {
401                load_grayscale_tiff_page(path, page_index, GrayscaleScaling::NativeCounts)?
402            }
403            None => load_grayscale(path, GrayscaleScaling::NativeCounts)?,
404        };
405        if values.shape() != self.image_shape {
406            return Err(Error::InvalidMeasurements(format!(
407                "image {} changed shape to {:?}, expected {:?}",
408                path.display(),
409                values.shape(),
410                self.image_shape
411            )));
412        }
413        self.preprocess_frame(index, values.as_mut_slice())?;
414        let loaded = Arc::new(values.into_vec());
415        let loaded_bytes = loaded.len().checked_mul(size_of::<f64>()).ok_or_else(|| {
416            Error::InvalidMeasurements("decoded lazy frame byte length overflows".into())
417        })?;
418
419        let mut cache = self.lock_cache()?;
420        if let Some(existing) = cache.frames.get(&index).cloned() {
421            cache.touch(index);
422            return Ok(existing);
423        }
424        while cache.frames.len() >= self.cache_capacity
425            || self
426                .cache_byte_capacity
427                .is_some_and(|capacity| cache.bytes.saturating_add(loaded_bytes) > capacity)
428        {
429            if !cache.evict_least_recently_used() {
430                break;
431            }
432        }
433        cache.frames.insert(index, loaded.clone());
434        cache.bytes = cache.bytes.checked_add(loaded_bytes).ok_or_else(|| {
435            Error::InvalidMeasurements("lazy cache byte accounting overflows".into())
436        })?;
437        cache.touch(index);
438        Ok(loaded)
439    }
440
441    pub fn frame_weight(&self, index: usize) -> Result<f64> {
442        self.frame_metadata
443            .get(index)
444            .map(|metadata| metadata.weight)
445            .ok_or(Error::FrameOutOfRange {
446                index,
447                frames: self.frame_count(),
448            })
449    }
450
451    pub fn frame_mask(&self, index: usize) -> Result<Option<&[u8]>> {
452        if index >= self.frame_count() {
453            return Err(Error::FrameOutOfRange {
454                index,
455                frames: self.frame_count(),
456            });
457        }
458        let Some(masks) = &self.masks else {
459            return Ok(None);
460        };
461        let frame_len = self.frame_len();
462        if masks.len() == frame_len {
463            Ok(Some(masks))
464        } else {
465            let start = index * frame_len;
466            Ok(Some(&masks[start..start + frame_len]))
467        }
468    }
469
470    pub fn materialize(&self) -> Result<MeasurementStack> {
471        let mut data = Vec::with_capacity(self.stack_len()?);
472        for frame in 0..self.frame_count() {
473            data.extend_from_slice(self.frame(frame)?.as_slice());
474        }
475        let mut stack =
476            MeasurementStack::from_vec(data, self.image_shape, self.frame_metadata.clone())?;
477        if let Some(masks) = &self.masks {
478            stack = stack.with_masks(masks.clone())?;
479        }
480        Ok(stack)
481    }
482
483    pub fn validate(&self) -> Result<()> {
484        if self.paths.is_empty()
485            || self.frame_metadata.len() != self.paths.len()
486            || self.page_indices.len() != self.paths.len()
487            || self.cache_capacity == 0
488        {
489            return Err(Error::InvalidMeasurements(
490                "lazy paths, page indices, and metadata must have equal non-zero counts".into(),
491            ));
492        }
493        let frame_bytes = self.frame_bytes()?;
494        for (index, metadata) in self.frame_metadata.iter().enumerate() {
495            if metadata.frame_index != index
496                || !metadata.exposure_time.is_finite()
497                || metadata.exposure_time <= 0.0
498                || !metadata.weight.is_finite()
499                || metadata.weight < 0.0
500            {
501                return Err(Error::InvalidMeasurements(format!(
502                    "lazy frame metadata {index} is invalid"
503                )));
504            }
505        }
506        if let Some(dark) = &self.dark_frame {
507            self.validate_correction("dark frame", dark, false)?;
508        }
509        if let Some(flat) = &self.flat_field {
510            self.validate_correction("flat field", flat, false)?;
511            if flat.iter().any(|&value| value <= 0.0) {
512                return Err(Error::InvalidMeasurements(
513                    "flat-field values must be positive".into(),
514                ));
515            }
516        }
517        if let Some(background) = &self.background {
518            self.validate_correction("background", background, true)?;
519        }
520        if let Some(masks) = &self.masks {
521            let frame_len = self.frame_len();
522            let stack_len = self.stack_len()?;
523            if masks.len() != frame_len && masks.len() != stack_len {
524                return Err(Error::InvalidMeasurements(
525                    "lazy mask dimensions are invalid".into(),
526                ));
527            }
528        }
529        if self.preprocessing.subtract_dark && self.dark_frame.is_none() {
530            return Err(Error::InvalidMeasurements(
531                "dark subtraction requested without a dark frame".into(),
532            ));
533        }
534        if self.preprocessing.divide_flat_field && self.flat_field.is_none() {
535            return Err(Error::InvalidMeasurements(
536                "flat-field correction requested without a flat field".into(),
537            ));
538        }
539        if self.preprocessing.subtract_background && self.background.is_none() {
540            return Err(Error::InvalidMeasurements(
541                "background subtraction requested without a background".into(),
542            ));
543        }
544        if self
545            .cache_byte_capacity
546            .is_some_and(|capacity| capacity < frame_bytes)
547        {
548            return Err(Error::InvalidMeasurements(
549                "lazy byte cache must fit at least one decoded frame".into(),
550            ));
551        }
552        Ok(())
553    }
554
555    fn preprocess_frame(&self, frame_index: usize, values: &mut [f64]) -> Result<()> {
556        let frame_len = self.frame_len();
557        let exposure = self.frame_metadata[frame_index].exposure_time;
558        let dark = if self.preprocessing.subtract_dark {
559            Some(self.dark_frame.as_deref().ok_or_else(|| {
560                Error::InvalidMeasurements("dark subtraction requested without a dark frame".into())
561            })?)
562        } else {
563            None
564        };
565        let background = if self.preprocessing.subtract_background {
566            Some(self.background.as_deref().ok_or_else(|| {
567                Error::InvalidMeasurements(
568                    "background subtraction requested without a background".into(),
569                )
570            })?)
571        } else {
572            None
573        };
574        let flat = if self.preprocessing.divide_flat_field {
575            Some(self.flat_field.as_deref().ok_or_else(|| {
576                Error::InvalidMeasurements(
577                    "flat-field correction requested without a flat field".into(),
578                )
579            })?)
580        } else {
581            None
582        };
583        for (pixel, value) in values.iter_mut().enumerate() {
584            if let Some(dark) = dark {
585                *value -= dark[pixel];
586            }
587            if let Some(background) = background {
588                *value -= background[if background.len() == frame_len {
589                    pixel
590                } else {
591                    frame_index * frame_len + pixel
592                }];
593            }
594            if let Some(flat) = flat {
595                *value /= flat[pixel];
596            }
597            if self.preprocessing.normalize_exposure {
598                *value /= exposure;
599            }
600            if self.preprocessing.clamp_negative {
601                *value = value.max(0.0);
602            }
603            if !value.is_finite() {
604                return Err(Error::InvalidMeasurements(format!(
605                    "preprocessing frame {frame_index} produced a non-finite value at pixel {pixel}"
606                )));
607            }
608        }
609        Ok(())
610    }
611
612    fn validate_correction(&self, name: &str, values: &[f64], per_frame: bool) -> Result<()> {
613        let frame_len = self.frame_len();
614        let stack_len = self.stack_len()?;
615        let valid_length = values.len() == frame_len || (per_frame && values.len() == stack_len);
616        if !valid_length || values.iter().any(|value| !value.is_finite()) {
617            return Err(Error::InvalidMeasurements(format!(
618                "{name} must contain finite values and have length {frame_len}{}",
619                if per_frame {
620                    format!(" or {stack_len}")
621                } else {
622                    String::new()
623                }
624            )));
625        }
626        Ok(())
627    }
628
629    fn frame_bytes(&self) -> Result<usize> {
630        self.image_shape
631            .0
632            .checked_mul(self.image_shape.1)
633            .filter(|&length| length > 0)
634            .and_then(|length| length.checked_mul(size_of::<f64>()))
635            .ok_or_else(|| Error::InvalidShape("lazy measurement shape is invalid".into()))
636    }
637
638    fn stack_len(&self) -> Result<usize> {
639        self.frame_len()
640            .checked_mul(self.frame_count())
641            .ok_or_else(|| {
642                Error::InvalidMeasurements("lazy measurement stack length overflows".into())
643            })
644    }
645
646    fn reset_cache(&mut self) {
647        self.cache = Mutex::new(FrameCache::default());
648    }
649
650    fn lock_cache(&self) -> Result<std::sync::MutexGuard<'_, FrameCache>> {
651        self.cache
652            .lock()
653            .map_err(|_| Error::Numerical("lazy measurement cache lock was poisoned".into()))
654    }
655}
656
657fn metadata_or_labels(
658    paths: &[PathBuf],
659    frame_metadata: Vec<FrameMetadata>,
660    label: impl Fn(&Path, usize) -> String,
661) -> Vec<FrameMetadata> {
662    if frame_metadata.is_empty() {
663        paths
664            .iter()
665            .enumerate()
666            .map(|(index, path)| {
667                let mut metadata = FrameMetadata::new(index);
668                metadata.label = Some(label(path, index));
669                metadata
670            })
671            .collect()
672    } else {
673        frame_metadata
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use std::panic::{AssertUnwindSafe, catch_unwind};
680
681    use super::*;
682
683    #[test]
684    fn poisoned_cache_fails_frame_reads_and_hides_unavailable_statistics() {
685        let stack = LazyMeasurementStack::new(
686            vec![PathBuf::from("unreadable-after-poison.png")],
687            vec![None],
688            (1, 1),
689            vec![FrameMetadata::new(0)],
690        )
691        .unwrap();
692
693        let result = catch_unwind(AssertUnwindSafe(|| {
694            let _guard = stack.cache.lock().unwrap();
695            panic!("intentional cache-lock poison for test");
696        }));
697        assert!(result.is_err());
698
699        assert!(
700            matches!(stack.frame(0), Err(Error::Numerical(message)) if message.contains("poisoned"))
701        );
702        assert_eq!(stack.cached_frame_count(), 0);
703        assert_eq!(stack.cached_byte_count(), 0);
704    }
705}