From 5ad9a44ecdba68883318a4f662a4219a7b20d38b Mon Sep 17 00:00:00 2001 From: "w.pomp" Date: Fri, 7 Aug 2026 17:00:28 +0200 Subject: [PATCH] - add doc strings --- py/ndbioimage/ndbioimage_rs.pyi | 465 +++++++++++++++++++++++++------- src/axes.rs | 22 ++ src/cache.rs | 322 ++++++++++++++++++++++ src/colors.rs | 3 + src/error.rs | 50 ++++ src/lib.rs | 13 + src/metadata.rs | 17 ++ src/movie.rs | 8 + src/py.rs | 104 ++++++- src/readers.rs | 69 ++++- src/readers/czi.rs | 3 + src/readers/tiff.rs | 1 + src/readers/tiffseq.rs | 1 + src/stats.rs | 5 + src/tiffwrite.rs | 5 + src/view.rs | 333 +---------------------- 16 files changed, 993 insertions(+), 428 deletions(-) create mode 100644 src/cache.rs diff --git a/py/ndbioimage/ndbioimage_rs.pyi b/py/ndbioimage/ndbioimage_rs.pyi index e6b085c..3b74d60 100644 --- a/py/ndbioimage/ndbioimage_rs.pyi +++ b/py/ndbioimage/ndbioimage_rs.pyi @@ -47,9 +47,15 @@ class Imread: TODO: argmax, argmin, nanmax, nanmin, nanmean, nansum, nanstd, nanvar, std, var """ @property - def reader_name(self) -> builtins.str: ... + def reader_name(self) -> builtins.str: + r""" + the name of the reader used to open the file + """ @property - def transform(self) -> None: ... + def transform(self) -> None: + r""" + get the transformation matrix (not yet implemented) + """ @property def path(self) -> pathlib.Path: r""" @@ -71,7 +77,10 @@ class Imread: the shape of the view """ @property - def slice(self) -> builtins.list[builtins.str]: ... + def slice(self) -> builtins.list[builtins.str]: + r""" + the current slice applied to the view + """ @property def size(self) -> builtins.int: r""" @@ -83,25 +92,40 @@ class Imread: the number of dimensions in the view """ @property - def T(self) -> Imread: ... + def T(self) -> Imread: + r""" + transposed view (alias for transpose(None)) + """ @property - def dtype(self) -> numpy.dtype: ... + def dtype(self) -> numpy.dtype: + r""" + the numpy dtype of the view + """ @property - def z_stack(self) -> builtins.bool: ... + def z_stack(self) -> builtins.bool: + r""" + whether the view contains a z-stack (more than one z slice) + """ @property def zstack(self) -> builtins.bool: r""" backwards compatibility """ @property - def time_series(self) -> builtins.bool: ... + def time_series(self) -> builtins.bool: + r""" + whether the view contains a time series (more than one time point) + """ @property def timeseries(self) -> builtins.bool: r""" backwards compatibility """ @property - def pixel_size(self) -> typing.Optional[builtins.float]: ... + def pixel_size(self) -> typing.Optional[builtins.float]: + r""" + the pixel size in micrometers + """ @property def pxsize_um(self) -> typing.Optional[builtins.float]: r""" @@ -113,9 +137,15 @@ class Imread: backwards compatibility """ @property - def delta_z(self) -> typing.Optional[builtins.float]: ... + def delta_z(self) -> typing.Optional[builtins.float]: + r""" + the z-step size in micrometers + """ @property - def time_interval(self) -> typing.Optional[builtins.float]: ... + def time_interval(self) -> typing.Optional[builtins.float]: + r""" + the time interval between frames in seconds + """ @property def timeinterval(self) -> typing.Optional[builtins.float]: r""" @@ -127,15 +157,24 @@ class Imread: backwards compatibility """ @property - def objective_name(self) -> typing.Optional[builtins.str]: ... + def objective_name(self) -> typing.Optional[builtins.str]: + r""" + the name of the objective + """ @property - def magnification(self) -> typing.Optional[builtins.float]: ... + def magnification(self) -> typing.Optional[builtins.float]: + r""" + the total magnification (objective × tube lens) + """ @property - def tube_lens_name(self) -> typing.Optional[builtins.str]: ... + def tube_lens_name(self) -> typing.Optional[builtins.str]: + r""" + the name of the tube lens + """ def __new__( cls, path: str | pathlib.Path | Imread | bytes, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, axes: builtins.str = "cztyx", reader: typing.Optional[builtins.str] = None, ) -> Imread: @@ -145,20 +184,29 @@ class Imread: @staticmethod def get_positions( path: str | pathlib.Path | Imread | bytes, - ) -> builtins.set[builtins.int]: ... + ) -> builtins.set[builtins.int]: + r""" + get all available positions (series) in the file + """ @staticmethod def kill_vm() -> None: r""" only remains for backwards compatibility """ - def reshape(self, order: builtins.str, copy: builtins.bool) -> typing.Any: ... + def reshape(self, order: builtins.str, copy: builtins.bool) -> typing.Any: + r""" + reshape the view with a new axis order + """ def with_transform( self, channels: builtins.bool = True, drift: builtins.bool = False, file: typing.Optional[typing.Any] = None, bead_files: typing.Optional[typing.Any] = None, - ) -> Imread: ... + ) -> Imread: + r""" + return a new view with transformations applied (channel alignment, drift correction) + """ def squeeze(self) -> numpy.ndarray | int | float: ... def close(self) -> None: r""" @@ -178,69 +226,219 @@ class Imread: """ def __array__( self, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, copy: typing.Optional[builtins.bool] = None, - ) -> typing.Any: ... - def __contains__(self, _item: typing.Any) -> builtins.bool: ... - def __lt__(self, other: typing.Any) -> typing.Any: ... - def __le__(self, other: typing.Any) -> typing.Any: ... - def __eq__(self, other: typing.Any) -> typing.Any: ... - def __ne__(self, other: typing.Any) -> typing.Any: ... - def __gt__(self, other: typing.Any) -> typing.Any: ... - def __ge__(self, other: typing.Any) -> typing.Any: ... - def __add__(self, other: typing.Any) -> typing.Any: ... - def __radd__(self, other: typing.Any) -> typing.Any: ... - def __sub__(self, other: typing.Any) -> typing.Any: ... - def __rsub__(self, other: typing.Any) -> typing.Any: ... - def __mul__(self, other: typing.Any) -> typing.Any: ... - def __rmul__(self, other: typing.Any) -> typing.Any: ... - def __truediv__(self, other: typing.Any) -> typing.Any: ... - def __rtruediv__(self, other: typing.Any) -> typing.Any: ... - def __floordiv__(self, other: typing.Any) -> typing.Any: ... - def __rfloordiv__(self, other: typing.Any) -> typing.Any: ... - def __mod__(self, other: typing.Any) -> typing.Any: ... - def __rmod__(self, other: typing.Any) -> typing.Any: ... - def __matmul__(self, other: typing.Any) -> typing.Any: ... - def __rmatmul__(self, other: typing.Any) -> typing.Any: ... - def __and__(self, other: typing.Any) -> typing.Any: ... - def __rand__(self, other: typing.Any) -> typing.Any: ... - def __or__(self, other: typing.Any) -> typing.Any: ... - def __ror__(self, other: typing.Any) -> typing.Any: ... - def __xor__(self, other: typing.Any) -> typing.Any: ... - def __rxor__(self, other: typing.Any) -> typing.Any: ... - def __lshift__(self, other: typing.Any) -> typing.Any: ... - def __rlshift__(self, other: typing.Any) -> typing.Any: ... - def __rshift__(self, other: typing.Any) -> typing.Any: ... - def __rrshift__(self, other: typing.Any) -> typing.Any: ... - def __neg__(self) -> typing.Any: ... - def __pos__(self) -> typing.Any: ... - def __abs__(self) -> typing.Any: ... - def __invert__(self) -> typing.Any: ... - def __enter__(self) -> Imread: ... + ) -> typing.Any: + r""" + convert to a numpy array, optionally with a different dtype + """ + def __contains__(self, _item: typing.Any) -> builtins.bool: + r""" + check if an item is contained in the view (not implemented) + """ + def __lt__(self, other: typing.Any) -> typing.Any: + r""" + element-wise less than comparison + """ + def __le__(self, other: typing.Any) -> typing.Any: + r""" + element-wise less than or equal comparison + """ + def __eq__(self, other: typing.Any) -> typing.Any: + r""" + element-wise equality comparison + """ + def __ne__(self, other: typing.Any) -> typing.Any: + r""" + element-wise not equal comparison + """ + def __gt__(self, other: typing.Any) -> typing.Any: + r""" + element-wise greater than comparison + """ + def __ge__(self, other: typing.Any) -> typing.Any: + r""" + element-wise greater than or equal comparison + """ + def __add__(self, other: typing.Any) -> typing.Any: + r""" + element-wise addition + """ + def __radd__(self, other: typing.Any) -> typing.Any: + r""" + element-wise addition (reflected) + """ + def __sub__(self, other: typing.Any) -> typing.Any: + r""" + element-wise subtraction + """ + def __rsub__(self, other: typing.Any) -> typing.Any: + r""" + element-wise subtraction (reflected) + """ + def __mul__(self, other: typing.Any) -> typing.Any: + r""" + element-wise multiplication + """ + def __rmul__(self, other: typing.Any) -> typing.Any: + r""" + element-wise multiplication (reflected) + """ + def __truediv__(self, other: typing.Any) -> typing.Any: + r""" + element-wise true division + """ + def __rtruediv__(self, other: typing.Any) -> typing.Any: + r""" + element-wise true division (reflected) + """ + def __floordiv__(self, other: typing.Any) -> typing.Any: + r""" + element-wise floor division + """ + def __rfloordiv__(self, other: typing.Any) -> typing.Any: + r""" + element-wise floor division (reflected) + """ + def __mod__(self, other: typing.Any) -> typing.Any: + r""" + element-wise modulo + """ + def __rmod__(self, other: typing.Any) -> typing.Any: + r""" + element-wise modulo (reflected) + """ + def __matmul__(self, other: typing.Any) -> typing.Any: + r""" + element-wise matrix multiplication + """ + def __rmatmul__(self, other: typing.Any) -> typing.Any: + r""" + element-wise matrix multiplication (reflected) + """ + def __and__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise AND + """ + def __rand__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise AND (reflected) + """ + def __or__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise OR + """ + def __ror__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise OR (reflected) + """ + def __xor__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise XOR + """ + def __rxor__(self, other: typing.Any) -> typing.Any: + r""" + element-wise bitwise XOR (reflected) + """ + def __lshift__(self, other: typing.Any) -> typing.Any: + r""" + element-wise left shift + """ + def __rlshift__(self, other: typing.Any) -> typing.Any: + r""" + element-wise left shift (reflected) + """ + def __rshift__(self, other: typing.Any) -> typing.Any: + r""" + element-wise right shift + """ + def __rrshift__(self, other: typing.Any) -> typing.Any: + r""" + element-wise right shift (reflected) + """ + def __neg__(self) -> typing.Any: + r""" + element-wise negation + """ + def __pos__(self) -> typing.Any: + r""" + element-wise positive + """ + def __abs__(self) -> typing.Any: + r""" + element-wise absolute value + """ + def __invert__(self) -> typing.Any: + r""" + element-wise bitwise inversion + """ + def __enter__(self) -> Imread: + r""" + context manager entry + """ def __exit__( self, exc_type: typing.Optional[typing.Any] = None, exc_val: typing.Optional[typing.Any] = None, exc_tb: typing.Optional[typing.Any] = None, - ) -> None: ... - def __getnewargs__(self) -> tuple[builtins.list[builtins.int]]: ... - def __copy__(self) -> Imread: ... - def __deepcopy__(self) -> Imread: ... - def copy(self) -> Imread: ... - def __iter__(self) -> Imread: ... - def __next__(self) -> typing.Optional[typing.Any]: ... - def __len__(self) -> builtins.int: ... - def __repr__(self) -> builtins.str: ... - def __str__(self) -> builtins.str: ... + ) -> None: + r""" + context manager exit + """ + def __getnewargs__(self) -> tuple[builtins.list[builtins.int]]: + r""" + arguments for pickling + """ + def __copy__(self) -> Imread: + r""" + shallow copy of the view + """ + def __deepcopy__(self) -> Imread: + r""" + deep copy of the view (same as shallow copy for this type) + """ + def copy(self) -> Imread: + r""" + create a copy of the view + """ + def __iter__(self) -> Imread: + r""" + iterate over the first axis + """ + def __next__(self) -> typing.Optional[typing.Any]: + r""" + get the next item in the iteration + """ + def __len__(self) -> builtins.int: + r""" + number of elements in the first axis + """ + def __repr__(self) -> builtins.str: + r""" + string representation with a summary of the image + """ + def __str__(self) -> builtins.str: + r""" + the file path as a string + """ def get_frame( self, c: builtins.int, z: builtins.int, t: builtins.int ) -> typing.Any: r""" retrieve a single frame at czt, sliced accordingly """ - def flatten(self) -> typing.Any: ... - def to_bytes(self) -> builtins.list[builtins.int]: ... - def tobytes(self) -> builtins.list[builtins.int]: ... + def flatten(self) -> typing.Any: + r""" + flatten the view into a 1D numpy array + """ + def to_bytes(self) -> builtins.list[builtins.int]: + r""" + convert the view to bytes + """ + def tobytes(self) -> builtins.list[builtins.int]: + r""" + convert the view to bytes (alias for to_bytes) + """ def get_ax(self, axis: int | str) -> builtins.int: r""" find the position of an axis @@ -259,18 +457,32 @@ class Imread: r""" collect data into a numpy array """ - def exposure_time( - self, channel: builtins.int - ) -> typing.Optional[builtins.float]: ... - def binning(self, channel: builtins.int) -> typing.Optional[builtins.int]: ... + def exposure_time(self, channel: builtins.int) -> typing.Optional[builtins.float]: + r""" + the exposure time for a given channel in seconds + """ + def binning(self, channel: builtins.int) -> typing.Optional[builtins.int]: + r""" + the binning for a given channel + """ def laser_wavelengths( self, channel: builtins.int - ) -> typing.Optional[builtins.float]: ... - def laser_power(self, channel: builtins.int) -> typing.Optional[builtins.float]: ... - def filter_set_name( - self, channel: builtins.int - ) -> typing.Optional[builtins.str]: ... - def gain(self, channel: builtins.int) -> typing.Optional[builtins.float]: ... + ) -> typing.Optional[builtins.float]: + r""" + the laser wavelength for a given channel in nanometers + """ + def laser_power(self, channel: builtins.int) -> typing.Optional[builtins.float]: + r""" + the laser power for a given channel as a fraction + """ + def filter_set_name(self, channel: builtins.int) -> typing.Optional[builtins.str]: + r""" + the name of the filter set for a given channel + """ + def gain(self, channel: builtins.int) -> typing.Optional[builtins.float]: + r""" + the detector gain for a given channel + """ def summary(self) -> builtins.str: r""" gives a helpful summary of the recorded experiment @@ -297,7 +509,10 @@ class Imread: colors: typing.Optional[typing.Sequence[builtins.str]] = None, overwrite: builtins.bool = False, bar: builtins.bool = True, - ) -> None: ... + ) -> None: + r""" + save the view as a TIFF file + """ def save_as_movie( self, file: builtins.str | os.PathLike | pathlib.Path, @@ -308,7 +523,10 @@ class Imread: overwrite: builtins.bool = False, register: builtins.bool = False, no_scaling: builtins.bool = False, - ) -> None: ... + ) -> None: + r""" + save the view as a movie file (MP4) + """ def set_cache_size(self, size: builtins.int) -> None: r""" backwards compatibility @@ -328,7 +546,7 @@ class Imread: def max( self, axis: int | str = None, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, @@ -340,7 +558,7 @@ class Imread: def min( self, axis: int | str = None, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, @@ -352,7 +570,7 @@ class Imread: def mean( self, axis: int | str = None, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, ) -> Imread | numpy.typing.NDArray | int | float: @@ -362,7 +580,7 @@ class Imread: def sum( self, axis: int | str = None, - dtype: numpy.typing.DTypeLike = None, + dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, @@ -373,18 +591,39 @@ class Imread: """ class Shape: + r""" + represents the shape of an image with named dimensions (c, z, t, y, x) + """ @property - def c(self) -> builtins.int: ... + def c(self) -> builtins.int: + r""" + the number of channels + """ @property - def z(self) -> builtins.int: ... + def z(self) -> builtins.int: + r""" + the number of z slices + """ @property - def t(self) -> builtins.int: ... + def t(self) -> builtins.int: + r""" + the number of time points + """ @property - def y(self) -> builtins.int: ... + def y(self) -> builtins.int: + r""" + the number of pixels along y + """ @property - def x(self) -> builtins.int: ... + def x(self) -> builtins.int: + r""" + the number of pixels along x + """ @property - def axes(self) -> builtins.str: ... + def axes(self) -> builtins.str: + r""" + the axis order as a string (e.g., "CZTYX") + """ def __new__( cls, order: builtins.str, @@ -393,9 +632,18 @@ class Shape: t: builtins.int = 1, y: builtins.int = 1, x: builtins.int = 1, - ) -> Shape: ... - def __str__(self) -> builtins.str: ... - def __repr__(self) -> builtins.str: ... + ) -> Shape: + r""" + create a new shape with the given dimensions + """ + def __str__(self) -> builtins.str: + r""" + string representation + """ + def __repr__(self) -> builtins.str: + r""" + detailed representation for debugging + """ def __getnewargs__( self, ) -> tuple[ @@ -405,12 +653,24 @@ class Shape: builtins.int, builtins.int, builtins.int, - ]: ... + ]: + r""" + arguments for pickling + """ def __getitem__( self, idx: str | int | None | Ellipsis | slice | list[int] | tuple[int] - ) -> typing.Optional[int | list[int]]: ... - def __len__(self) -> builtins.int: ... - def to_list(self) -> builtins.list[builtins.int]: ... + ) -> typing.Optional[int | list[int]]: + r""" + get dimension size by index or axis name + """ + def __len__(self) -> builtins.int: + r""" + number of dimensions in the shape + """ + def to_list(self) -> builtins.list[builtins.int]: + r""" + convert shape to a list of dimension sizes in order + """ def batch_to_tiff( files_in: typing.Sequence[builtins.str | os.PathLike | pathlib.Path], @@ -422,5 +682,12 @@ def batch_to_tiff( overwrite: builtins.bool = False, bar: builtins.bool = True, message: typing.Optional[builtins.str] = None, -) -> None: ... -def main() -> None: ... +) -> None: + r""" + batch convert multiple image files to TIFF format + """ + +def main() -> None: + r""" + main entry point for the command-line interface + """ diff --git a/src/axes.rs b/src/axes.rs index 17f0c3c..b28a97a 100644 --- a/src/axes.rs +++ b/src/axes.rs @@ -32,12 +32,18 @@ pub trait Ax { )] #[strum(ascii_case_insensitive)] pub enum Axis { + /// channel C, + /// z slice Z, + /// time T, + /// y coordinate Y, + /// x coordinate X, #[strum(serialize = "N")] + /// a new axis created by slicing, can be operated on but has no reader data New, } @@ -114,10 +120,15 @@ impl Ax for usize { Clone, Debug, Serialize, Deserialize, EnumString, AsRefStr, Display, PartialEq, Eq, Hash, )] #[strum(ascii_case_insensitive)] +/// an operation to reduce an axis pub enum Operation { + /// take the max along the axis Max, + /// take the min along the axis Min, + /// take the sum along the axis Sum, + /// take the mean along the axis Mean, } @@ -229,6 +240,7 @@ impl IntoIterator for &Slice { } } +/// the size of a view along the axes it contains #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Shape { /// size c (# channels) @@ -241,6 +253,7 @@ pub struct Shape { pub y: usize, /// size x (horizontal) pub x: usize, + /// the axes that are present in the view, in order pub order: Vec, } @@ -330,6 +343,7 @@ impl From for HashMap { } } +/// iterator over the sizes along the axes of a [`Shape`] pub struct ShapeIter { shape: Shape, index: usize, @@ -344,6 +358,7 @@ impl Iterator for ShapeIter { } } +/// iterator over the sizes along the axes of a borrowed [`Shape`] pub struct ShapeIterBorrow<'a> { shape: &'a Shape, index: usize, @@ -371,6 +386,7 @@ impl IntoIterator for Shape { } impl Shape { + /// create an empty shape with all sizes set to 1 and no axes pub fn new() -> Self { Self { c: 1, @@ -382,6 +398,7 @@ impl Shape { } } + /// iterate over the sizes along the axes, in order pub fn iter(&self) -> ShapeIterBorrow<'_> { ShapeIterBorrow { shape: self, @@ -389,18 +406,22 @@ impl Shape { } } + /// the number of axes in the shape pub fn len(&self) -> usize { self.order.len() } + /// whether the shape has no axes pub fn is_empty(&self) -> bool { self.order.is_empty() } + /// the sizes along the axes, in order pub fn to_vec(&self) -> Vec { self.order.iter().map(|axis| self[axis]).collect() } + /// the sizes along the axes as a map from axis to size pub fn to_hashmap(&self) -> HashMap { let mut map = HashMap::new(); for axis in self.order.iter() { @@ -409,6 +430,7 @@ impl Shape { map } + /// set the size of an axis pub fn set_axis(&mut self, axis: &Axis, value: usize) { match axis { Axis::C => self.c = value, diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..c8c581a --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,322 @@ +use crate::axes::{Axis, Operation}; +use indexmap::{Equivalent, IndexMap}; +use ndarray::{ArrayD, SliceInfoElem}; +use std::any::Any; +use std::fmt::{Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::readers::Frame; + +/// maximum number of frames held in the cache +const DEFAULT_FRAME_CACHE_SIZE: usize = 128; + +/// maximum number of materialized arrays held in the cache +const DEFAULT_ARRAY_CACHE_SIZE: usize = 32; + +/// identity of the reader a frame was read from +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct ReaderKey { + pub(crate) name: String, + pub(crate) path: PathBuf, + pub(crate) series: usize, + pub(crate) position: usize, +} + +/// borrowed view of [`ReaderKey`] for cache lookups without allocation. +/// hashes byte-identically to [`ReaderKey`] (str/String and Path/PathBuf hash +/// the same), so it only matches the same reader identity. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ReaderKeyRef<'a> { + pub(crate) name: &'a str, + pub(crate) path: &'a Path, + pub(crate) series: usize, + pub(crate) position: usize, +} + +impl Hash for ReaderKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.path.hash(state); + self.series.hash(state); + self.position.hash(state); + } +} + +impl Equivalent for ReaderKeyRef<'_> { + fn equivalent(&self, key: &ReaderKey) -> bool { + self.name == key.name + && self.path == key.path.as_path() + && self.series == key.series + && self.position == key.position + } +} + +impl ReaderKeyRef<'_> { + pub(crate) fn to_owned(&self) -> ReaderKey { + ReaderKey { + name: self.name.to_string(), + path: self.path.to_path_buf(), + series: self.series, + position: self.position, + } + } +} + +pub(crate) type FrameKey = (ReaderKey, usize, usize, usize); + +/// borrowed view of [`FrameKey`] for cache lookups without allocation. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct FrameKeyRef<'a> { + pub(crate) reader: ReaderKeyRef<'a>, + pub(crate) c: usize, + pub(crate) z: usize, + pub(crate) t: usize, +} + +impl Hash for FrameKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.reader.hash(state); + self.c.hash(state); + self.z.hash(state); + self.t.hash(state); + } +} + +impl Equivalent for FrameKeyRef<'_> { + fn equivalent(&self, key: &FrameKey) -> bool { + let (rk, c, z, t) = key; + self.reader.equivalent(rk) && self.c == *c && self.z == *z && self.t == *t + } +} + +impl FrameKeyRef<'_> { + pub(crate) fn to_owned(&self) -> FrameKey { + (self.reader.to_owned(), self.c, self.z, self.t) + } +} + +/// identity of a materialized array in the process-wide cache +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct ArrayKey { + pub(crate) reader: ReaderKey, + pub(crate) dtype: &'static str, + pub(crate) slice: Vec, + pub(crate) axes: Vec, + pub(crate) operations: Vec<(Axis, Operation)>, +} + +/// borrowed view of [`ArrayKey`] for cache lookups without allocation. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ArrayKeyRef<'a> { + pub(crate) reader: ReaderKeyRef<'a>, + pub(crate) dtype: &'static str, + pub(crate) slice: &'a [SliceInfoElem], + pub(crate) axes: &'a [Axis], + pub(crate) operations: &'a IndexMap, +} + +impl Hash for ArrayKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.reader.hash(state); + self.dtype.hash(state); + self.slice.hash(state); + self.axes.hash(state); + self.operations.len().hash(state); + for (ax, op) in self.operations.iter() { + ax.hash(state); + op.hash(state); + } + } +} + +impl Equivalent for ArrayKeyRef<'_> { + fn equivalent(&self, key: &ArrayKey) -> bool { + self.reader.equivalent(&key.reader) + && self.dtype == key.dtype + && self.slice == key.slice + && self.axes == key.axes + && self.operations.len() == key.operations.len() + && self + .operations + .iter() + .zip(&key.operations) + .all(|((ax, op), (key_ax, key_op))| ax == key_ax && op == key_op) + } +} + +impl ArrayKeyRef<'_> { + pub(crate) fn to_owned(&self) -> ArrayKey { + ArrayKey { + reader: self.reader.to_owned(), + dtype: self.dtype, + slice: self.slice.to_vec(), + axes: self.axes.to_vec(), + operations: self + .operations + .iter() + .map(|(ax, op)| (*ax, op.clone())) + .collect(), + } + } +} + +/// process-wide LRU cache of frames, shared between all views and threads +static GLOBAL_FRAME_CACHE: OnceLock = OnceLock::new(); + +/// thread-safe LRU cache of frames read from the underlying reader +pub(crate) struct FrameCache { + inner: Mutex, +} + +struct FrameCacheInner { + map: IndexMap>, + capacity: usize, +} + +impl Default for FrameCache { + fn default() -> Self { + Self::new(DEFAULT_FRAME_CACHE_SIZE) + } +} + +impl FrameCache { + pub(crate) fn new(capacity: usize) -> Self { + Self { + inner: Mutex::new(FrameCacheInner { + map: IndexMap::with_capacity(capacity), + capacity, + }), + } + } + + pub(crate) fn global() -> &'static FrameCache { + GLOBAL_FRAME_CACHE.get_or_init(FrameCache::default) + } + + pub(crate) fn capacity(&self) -> usize { + self.inner.lock().unwrap().capacity + } + + pub(crate) fn set_capacity(&self, capacity: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.capacity = capacity; + while inner.map.len() > inner.capacity { + inner.map.shift_remove_index(0); + } + } + + pub(crate) fn get(&self, key: &Q) -> Option> + where + Q: ?Sized + Hash + Equivalent, + { + let mut inner = self.inner.lock().unwrap(); + if let Some(idx) = inner.map.get_index_of(key) { + let (key, frame) = inner.map.shift_remove_index(idx).unwrap(); + inner.map.insert(key, frame.clone()); + Some(frame) + } else { + None + } + } + + pub(crate) fn insert(&self, key: FrameKey, frame: Arc) { + let mut inner = self.inner.lock().unwrap(); + inner.map.insert(key, frame); + while inner.map.len() > inner.capacity { + inner.map.shift_remove_index(0); + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.inner.lock().unwrap().map.len() + } +} + +impl Debug for FrameCache { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FrameCache").finish_non_exhaustive() + } +} + +/// process-wide LRU cache of materialized arrays, shared between all views and threads +static GLOBAL_ARRAY_CACHE: OnceLock = OnceLock::new(); + +/// thread-safe LRU cache of materialized arrays produced by `as_array_dyn` +pub(crate) struct ArrayCache { + inner: Mutex, +} + +struct ArrayCacheInner { + map: IndexMap>, + capacity: usize, +} + +impl Default for ArrayCache { + fn default() -> Self { + Self::new(DEFAULT_ARRAY_CACHE_SIZE) + } +} + +impl ArrayCache { + pub(crate) fn new(capacity: usize) -> Self { + Self { + inner: Mutex::new(ArrayCacheInner { + map: IndexMap::with_capacity(capacity), + capacity, + }), + } + } + + pub(crate) fn global() -> &'static ArrayCache { + GLOBAL_ARRAY_CACHE.get_or_init(ArrayCache::default) + } + + pub(crate) fn capacity(&self) -> usize { + self.inner.lock().unwrap().capacity + } + + pub(crate) fn set_capacity(&self, capacity: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.capacity = capacity; + while inner.map.len() > inner.capacity { + inner.map.shift_remove_index(0); + } + } + + pub(crate) fn get(&self, key: &Q) -> Option>> + where + Q: ?Sized + Hash + Equivalent, + T: Any + Send + Sync, + { + let mut inner = self.inner.lock().unwrap(); + if let Some(idx) = inner.map.get_index_of(key) { + let (key, array) = inner.map.shift_remove_index(idx).unwrap(); + inner.map.insert(key, array.clone()); + array.downcast::>().ok() + } else { + None + } + } + + pub(crate) fn insert(&self, key: ArrayKey, array: ArrayD) { + let mut inner = self.inner.lock().unwrap(); + inner.map.insert(key, Arc::new(array)); + while inner.map.len() > inner.capacity { + inner.map.shift_remove_index(0); + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.inner.lock().unwrap().map.len() + } +} + +impl Debug for ArrayCache { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ArrayCache").finish_non_exhaustive() + } +} diff --git a/src/colors.rs b/src/colors.rs index e271f79..99f455c 100644 --- a/src/colors.rs +++ b/src/colors.rs @@ -3,6 +3,7 @@ use phf::phf_map; use std::fmt::Display; use std::str::FromStr; +/// a static map from color names (like `"red"` or single letters like `"r"`) to hex strings pub static COLORS: phf::Map<&'static str, &'static str> = phf_map! { "b" => "#0000FF", "g" => "#008000", @@ -162,6 +163,7 @@ pub static COLORS: phf::Map<&'static str, &'static str> = phf_map! { "yellowgreen" => "#9ACD32", }; +/// a color with red, green and blue components, parsed from a hex string (`#RRGGBB`) or a name in [`COLORS`] #[derive(Clone, Debug)] pub struct Color { r: u8, @@ -196,6 +198,7 @@ impl Display for Color { } impl Color { + /// the color as an RGB vector `[r, g, b]` pub fn to_rgb(&self) -> Vec { vec![self.r, self.g, self.b] } diff --git a/src/error.rs b/src/error.rs index cb346aa..a885b20 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,121 +1,171 @@ use strum::IntoStaticStr; use thiserror::Error; +/// the error type used throughout the crate #[derive(Debug, Error, IntoStaticStr)] pub enum Error { + /// an io error #[error(transparent)] IO(#[from] std::io::Error), + /// an ndarray shape error #[error(transparent)] Shape(#[from] ndarray::ShapeError), #[cfg(feature = "bioformats_java")] + /// an error from the j4rs java bridge #[error(transparent)] J4rs(#[from] j4rs::errors::J4RsError), + /// an infallible conversion #[error(transparent)] Infallible(#[from] std::convert::Infallible), + /// an integer parse error #[error(transparent)] ParseIntError(#[from] std::num::ParseIntError), + /// an ome metadata error #[error(transparent)] Ome(#[from] ome_metadata::error::Error), #[cfg(feature = "bioformats_java")] + /// an error while downloading (e.g. the bioformats jar) #[error(transparent)] Downloader(#[from] downloader::Error), + /// an error parsing an enum string with strum #[error(transparent)] Strum(#[from] strum::ParseError), #[cfg(feature = "tiffwrite")] + /// an indicatif progress bar template error #[error(transparent)] TemplateError(#[from] indicatif::style::TemplateError), #[cfg(feature = "tiffwrite")] + /// an error from the tiffwrite crate #[error(transparent)] TiffWrite(#[from] tiffwrite::error::Error), #[cfg(feature = "tiffseq")] + /// a yaml (de)serialization error #[error(transparent)] SerdeYaml(#[from] serde_yaml::Error), #[cfg(any(feature = "tiffseq", feature = "tiff"))] + /// an error from the tiff crate #[error(transparent)] Tiff(#[from] tiff::TiffError), #[cfg(feature = "python")] + /// a postcard (de)serialization error #[error(transparent)] PostCard(#[from] postcard::Error), #[cfg(feature = "czi")] + /// an error from the libczi binding #[error(transparent)] LibCzi(#[from] libczirw_sys::error::Error), + /// a regex error #[error(transparent)] RegexError(#[from] regex::Error), #[cfg(feature = "czi")] + /// an xmltree error #[error(transparent)] XmlTree(#[from] xmltree::Error), #[cfg(feature = "czi")] + /// an xmltree parse error #[error(transparent)] XmlTreeParse(#[from] xmltree::ParseError), #[cfg(feature = "czi")] + /// a czi-specific error #[error(transparent)] Czi(#[from] crate::readers::czi::CziError), #[cfg(feature = "movie")] + /// an error joining a tokio task #[error(transparent)] TokioJoin(#[from] tokio::task::JoinError), #[cfg(feature = "bioformats_rust")] + /// an error from the bioformats rust crate #[error(transparent)] BioFormats(#[from] bioformats::error::BioFormatsError), + /// the axis string could not be parsed #[error("invalid axis: {0}")] InvalidAxis(String), + /// the axis was not found in the axes #[error("axis {0} not found in axes {1}")] AxisNotFound(String, String), + /// a conversion error #[error("conversion error: {0}")] TryInto(String), + /// the target file already exists #[error("file already exists {0}")] FileAlreadyExists(String), + /// could not download ffmpeg #[error("could not download ffmpeg: {0}")] FfmpegDownload(String), + /// an ffmpeg error #[error("FFmpeg error: {0}")] Ffmpeg(String), + /// the index is out of bounds #[error("index {0} out of bounds {1}")] OutOfBounds(isize, isize), + /// the axis was not included in the view #[error("axis {0} has length {1}, but was not included")] OutOfBoundsAxis(String, usize), + /// the dimensionality of the data does not match #[error("dimensionality mismatch: {0} != {0}")] DimensionalityMismatch(usize, usize), + /// the axis already has an operation #[error("axis {0}: {1} is already operated on!")] AxisAlreadyOperated(usize, String), + /// not enough free dimensions #[error("not enough free dimensions")] NotEnoughFreeDimensions, + /// cannot cast a pixel value to the requested type #[error("cannot cast {0} to {1}")] Cast(String, String), + /// the view is empty #[error("empty view")] EmptyView, + /// the color string could not be parsed #[error("invalid color: {0}")] InvalidColor(String), + /// no image or pixels found in the metadata #[error("no image or pixels found")] NoImageOrPixels, + /// the attenuation value is invalid #[error("invalid attenuation value: {0}")] InvalidAttenuation(String), + /// the file name is invalid #[error("not a valid file name")] InvalidFileName, + /// the file has no parent directory #[error("file has no parent")] NoParent, + /// the pixel type is unknown #[error("unknown pixel type {0}")] UnknownPixelType(String), + /// cannot compute the mean of an empty axis #[error("no mean")] NoMean, + /// the tiff file lock is poisoned #[error("tiff is locked")] TiffLock, + /// this feature is not implemented #[error("not implemented: {0}")] NotImplemented(String), + /// a string could not be parsed #[error("cannot parse: {0}")] Parse(String), + /// cannot convert the libczi pixel type #[error("cannot convert libczi pixel type: {0}")] Conversion(String), + /// no reader could open the file #[error("no reader found for {0}, tried: {1}")] NoReader(String, String), + /// the reader cannot open the file #[error("reader {0} cannot open file {1} because {2}")] InvalidReader(String, String, String), + /// the file does not exist #[error("file does not exist: {0}")] FileDoesNotExist(String), + /// cannot remove axes that have a size != 1 #[error("cannot remove axes {0}, size {1} != 1")] SizeMismatch(String, usize), } impl Error { + /// the name of the error variant as a static string pub fn variant_name(&self) -> &'static str { self.into() } diff --git a/src/lib.rs b/src/lib.rs index fab7273..fc19bd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,22 +43,34 @@ //! # } //! ``` +/// axis handling: axis enum, slicing and shape pub mod axes; +/// process-wide LRU caches for frames and materialized arrays +mod cache; #[cfg(feature = "python")] mod py; +/// min/max/sum/mean operations along an axis pub mod stats; +/// the main data structure: an on-disk image that can be sliced without loading it fully pub mod view; +/// named colors and color conversion pub mod colors; +/// the error type used throughout the crate pub mod error; +/// ome metadata helpers pub mod metadata; #[cfg(feature = "movie")] +/// saving views as movies pub mod movie; +/// readers for the different supported image formats pub mod readers; #[cfg(feature = "tiffwrite")] +/// saving views as tiff files pub mod tiffwrite; mod utils; +/// main entry point for the application pub mod main { #[cfg(feature = "tiffwrite")] use crate::axes::{Axis, Operation}; @@ -172,6 +184,7 @@ pub mod main { } } + /// the command line interface, run `ndbioimage --help` for an overview of the commands pub fn main(args: Option>) -> Result<(), Error> { let cli = if let Some(args) = args { Cli::parse_from(args) diff --git a/src/metadata.rs b/src/metadata.rs index 52b8e5c..1af4750 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -20,10 +20,14 @@ impl Metadata for Ome { } } +/// helper trait to extract useful information from ome metadata pub trait Metadata { + /// the instrument used to acquire the image fn get_instrument(&self) -> Option<&Instrument>; + /// the first image in the ome structure fn get_image(&self) -> Option<&Image>; + /// the pixels of the image fn get_pixels(&self) -> Option<&Pixels> { if let Some(image) = self.get_image() { Some(&image.pixels) @@ -32,6 +36,7 @@ pub trait Metadata { } } + /// the objective used to acquire the image fn get_objective(&self) -> Option<&Objective> { let objective_id = self.get_image()?.objective_settings.as_ref()?.id.clone(); self.get_instrument()? @@ -40,6 +45,7 @@ pub trait Metadata { .find(|o| o.id == objective_id) } + /// the tube lens used to acquire the image fn get_tube_lens(&self) -> Option<&Objective> { self.get_instrument()? .objective @@ -160,6 +166,7 @@ pub trait Metadata { ) } + /// the binning of the detector for a channel (1, 2, 4 or 8) fn binning(&self, channel: usize) -> Option { match self .get_pixels()? @@ -178,6 +185,7 @@ pub trait Metadata { } } + /// the excitation wavelength of the laser for a channel in nm fn laser_wavelengths(&self, channel: usize) -> Result, Error> { Ok( if let Some(pixels) = self.get_pixels() @@ -195,6 +203,7 @@ pub trait Metadata { ) } + /// the laser power (fraction of the maximum) for a channel fn laser_powers(&self, channel: usize) -> Result, Error> { if let Some(pixels) = self.get_pixels() && let Some(channel) = pixels.channel.get(channel) @@ -211,10 +220,12 @@ pub trait Metadata { } } + /// the name of the objective fn objective_name(&self) -> Option { Some(self.get_objective()?.model.as_ref()?.clone()) } + /// the total magnification: objective magnification times tube lens magnification fn magnification(&self) -> Option { Some( (self.get_objective()?.nominal_magnification? as f64) @@ -222,10 +233,12 @@ pub trait Metadata { ) } + /// the name of the tube lens fn tube_lens_name(&self) -> Option { self.get_tube_lens()?.model.clone() } + /// the name of the filter set for a channel fn filter_set_name(&self, channel: usize) -> Option { let filter_set_id = self .get_pixels()? @@ -244,6 +257,7 @@ pub trait Metadata { .clone() } + /// the gain of the detector for a channel fn gain(&self, channel: usize) -> Option { self.get_pixels() .and_then(|p| p.channel.get(channel)) @@ -257,18 +271,21 @@ pub trait Metadata { }) } + /// whether the image is a z-stack (more than one z slice) fn is_zstack(&self) -> Result { self.get_pixels() .map(|p| Ok(p.size_z > 1)) .unwrap_or_else(|| Err(Error::NoImageOrPixels)) } + /// whether the image is a time lapse (more than one time point) fn is_time_lapse(&self) -> Result { self.get_pixels() .map(|p| Ok(p.size_t > 1)) .unwrap_or_else(|| Err(Error::NoImageOrPixels)) } + /// a multi-line summary of the most relevant metadata, one field per line fn summary(&self) -> Result { let size_c = if let Some(pixels) = self.get_pixels() { pixels.channel.len() diff --git a/src/movie.rs b/src/movie.rs index 4eefb14..f918b24 100644 --- a/src/movie.rs +++ b/src/movie.rs @@ -13,6 +13,7 @@ use ordered_float::OrderedFloat; use std::io::Write; use std::path::Path; +/// options for creating a movie from a view pub struct MovieOptions { velocity: f64, brightness: Vec, @@ -38,6 +39,7 @@ impl Default for MovieOptions { } impl MovieOptions { + /// create movie options with the given parameters pub fn new( velocity: f64, brightness: Vec, @@ -67,18 +69,22 @@ impl MovieOptions { }) } + /// set the frames per second of the movie pub fn set_velocity(&mut self, velocity: f64) { self.velocity = velocity; } + /// set the brightness scale factors for each channel pub fn set_brightness(&mut self, brightness: Vec) { self.brightness = brightness; } + /// set the display scale (zoom) factor for the movie pub fn set_scale(&mut self, scale: f64) { self.scale = scale; } + /// set the color lookup table for the movie pub fn set_colors(&mut self, colors: &[String]) -> Result<(), Error> { let colors = colors .iter() @@ -88,6 +94,7 @@ impl MovieOptions { Ok(()) } + /// set whether an existing movie file should be overwritten pub fn set_overwrite(&mut self, overwrite: bool) { self.overwrite = overwrite; } @@ -142,6 +149,7 @@ where R: Reader, Self: 'static, { + /// save the view as a movie file with the given options pub fn save_as_movie

(&self, path: P, options: &MovieOptions) -> Result<(), Error> where P: AsRef, diff --git a/src/py.rs b/src/py.rs index f382b45..d9f06f9 100644 --- a/src/py.rs +++ b/src/py.rs @@ -179,7 +179,7 @@ impl PyView { override_type(type_repr="str | pathlib.Path | Imread | bytes", imports=("pathlib")) )] path: Bound<'py, PyAny>, - #[gen_stub(override_type(type_repr = "numpy.typing.DTypeLike", imports=("numpy", "numpy.typing") + #[gen_stub(override_type(type_repr = "typing.Optional[numpy.typing.DTypeLike]", imports=("typing", "numpy", "numpy.typing") ))] dtype: Option>, axes: &str, @@ -229,6 +229,7 @@ impl PyView { } } + /// get all available positions (series) in the file #[staticmethod] fn get_positions<'py>( py: Python, @@ -244,16 +245,19 @@ impl PyView { #[staticmethod] fn kill_vm() {} + /// the name of the reader used to open the file #[getter] fn reader_name(&self) -> String { self.view.reader_name().to_string() } + /// reshape the view with a new axis order #[allow(unused_variables)] fn reshape<'py>(&self, order: &str, copy: bool) -> PyResult> { todo!() } + /// return a new view with transformations applied (channel alignment, drift correction) #[allow(unused_variables)] #[pyo3(signature = (channels = true, drift = false, file = None, bead_files = None))] fn with_transform<'py>( @@ -266,6 +270,7 @@ impl PyView { todo!() } + /// get the transformation matrix (not yet implemented) #[getter] fn get_transform(&self) -> PyResult<()> { todo!() @@ -533,12 +538,13 @@ impl PyView { } } + /// convert to a numpy array, optionally with a different dtype #[allow(unused_variables)] #[pyo3(signature = (dtype = None, copy = None))] fn __array__<'py>( &self, py: Python<'py>, - #[gen_stub(override_type(type_repr = "numpy.typing.DTypeLike", imports=("numpy", "numpy.typing") + #[gen_stub(override_type(type_repr = "typing.Optional[numpy.typing.DTypeLike]", imports=("typing", "numpy", "numpy.typing") ))] dtype: Option>, copy: Option, @@ -550,10 +556,12 @@ impl PyView { } } + /// check if an item is contained in the view (not implemented) fn __contains__(&self, _item: Bound) -> PyResult { Err(PyNotImplementedError::new_err("contains not implemented")) } + /// element-wise less than comparison fn __lt__<'py>( &self, py: Python<'py>, @@ -565,6 +573,7 @@ impl PyView { np.getattr("less")?.call1((&a, &b)) } + /// element-wise less than or equal comparison fn __le__<'py>( &self, py: Python<'py>, @@ -576,6 +585,7 @@ impl PyView { np.getattr("less_equal")?.call1((&a, &b)) } + /// element-wise equality comparison fn __eq__<'py>( &self, py: Python<'py>, @@ -587,6 +597,7 @@ impl PyView { np.getattr("equal")?.call1((&a, &b)) } + /// element-wise not equal comparison fn __ne__<'py>( &self, py: Python<'py>, @@ -598,6 +609,7 @@ impl PyView { np.getattr("not_equal")?.call1((&a, &b)) } + /// element-wise greater than comparison fn __gt__<'py>( &self, py: Python<'py>, @@ -609,6 +621,7 @@ impl PyView { np.getattr("greater")?.call1((&a, &b)) } + /// element-wise greater than or equal comparison fn __ge__<'py>( &self, py: Python<'py>, @@ -620,6 +633,7 @@ impl PyView { np.getattr("greater_equal")?.call1((&a, &b)) } + /// element-wise addition fn __add__<'py>( &self, py: Python<'py>, @@ -631,6 +645,7 @@ impl PyView { np.getattr("add")?.call1((&a, &b)) } + /// element-wise addition (reflected) fn __radd__<'py>( &self, py: Python<'py>, @@ -642,6 +657,7 @@ impl PyView { np.getattr("add")?.call1((&a, &b)) } + /// element-wise subtraction fn __sub__<'py>( &self, py: Python<'py>, @@ -653,6 +669,7 @@ impl PyView { np.getattr("subtract")?.call1((&a, &b)) } + /// element-wise subtraction (reflected) fn __rsub__<'py>( &self, py: Python<'py>, @@ -664,6 +681,7 @@ impl PyView { np.getattr("subtract")?.call1((&a, &b)) } + /// element-wise multiplication fn __mul__<'py>( &self, py: Python<'py>, @@ -675,6 +693,7 @@ impl PyView { np.getattr("multiply")?.call1((&a, &b)) } + /// element-wise multiplication (reflected) fn __rmul__<'py>( &self, py: Python<'py>, @@ -686,6 +705,7 @@ impl PyView { np.getattr("multiply")?.call1((&a, &b)) } + /// element-wise true division fn __truediv__<'py>( &self, py: Python<'py>, @@ -697,6 +717,7 @@ impl PyView { np.getattr("true_divide")?.call1((&a, &b)) } + /// element-wise true division (reflected) fn __rtruediv__<'py>( &self, py: Python<'py>, @@ -708,6 +729,7 @@ impl PyView { np.getattr("true_divide")?.call1((&a, &b)) } + /// element-wise floor division fn __floordiv__<'py>( &self, py: Python<'py>, @@ -719,6 +741,7 @@ impl PyView { np.getattr("floor_divide")?.call1((&a, &b)) } + /// element-wise floor division (reflected) fn __rfloordiv__<'py>( &self, py: Python<'py>, @@ -730,6 +753,7 @@ impl PyView { np.getattr("floor_divide")?.call1((&a, &b)) } + /// element-wise modulo fn __mod__<'py>( &self, py: Python<'py>, @@ -741,6 +765,7 @@ impl PyView { np.getattr("remainder")?.call1((&a, &b)) } + /// element-wise modulo (reflected) fn __rmod__<'py>( &self, py: Python<'py>, @@ -788,6 +813,7 @@ impl PyView { np.getattr("power")?.call1((&a, &b)) } + /// element-wise matrix multiplication fn __matmul__<'py>( &self, py: Python<'py>, @@ -799,6 +825,7 @@ impl PyView { np.getattr("matmul")?.call1((&a, &b)) } + /// element-wise matrix multiplication (reflected) fn __rmatmul__<'py>( &self, py: Python<'py>, @@ -810,6 +837,7 @@ impl PyView { np.getattr("matmul")?.call1((&a, &b)) } + /// element-wise bitwise AND fn __and__<'py>( &self, py: Python<'py>, @@ -821,6 +849,7 @@ impl PyView { np.getattr("bitwise_and")?.call1((&a, &b)) } + /// element-wise bitwise AND (reflected) fn __rand__<'py>( &self, py: Python<'py>, @@ -832,6 +861,7 @@ impl PyView { np.getattr("bitwise_and")?.call1((&a, &b)) } + /// element-wise bitwise OR fn __or__<'py>( &self, py: Python<'py>, @@ -843,6 +873,7 @@ impl PyView { np.getattr("bitwise_or")?.call1((&a, &b)) } + /// element-wise bitwise OR (reflected) fn __ror__<'py>( &self, py: Python<'py>, @@ -854,6 +885,7 @@ impl PyView { np.getattr("bitwise_or")?.call1((&a, &b)) } + /// element-wise bitwise XOR fn __xor__<'py>( &self, py: Python<'py>, @@ -865,6 +897,7 @@ impl PyView { np.getattr("bitwise_xor")?.call1((&a, &b)) } + /// element-wise bitwise XOR (reflected) fn __rxor__<'py>( &self, py: Python<'py>, @@ -876,6 +909,7 @@ impl PyView { np.getattr("bitwise_xor")?.call1((&a, &b)) } + /// element-wise left shift fn __lshift__<'py>( &self, py: Python<'py>, @@ -887,6 +921,7 @@ impl PyView { np.getattr("left_shift")?.call1((&a, &b)) } + /// element-wise left shift (reflected) fn __rlshift__<'py>( &self, py: Python<'py>, @@ -898,6 +933,7 @@ impl PyView { np.getattr("left_shift")?.call1((&a, &b)) } + /// element-wise right shift fn __rshift__<'py>( &self, py: Python<'py>, @@ -909,6 +945,7 @@ impl PyView { np.getattr("right_shift")?.call1((&a, &b)) } + /// element-wise right shift (reflected) fn __rrshift__<'py>( &self, py: Python<'py>, @@ -920,34 +957,40 @@ impl PyView { np.getattr("right_shift")?.call1((&a, &b)) } + /// element-wise negation fn __neg__<'py>(&self, py: Python<'py>) -> PyResult> { let np = PyModule::import(py, "numpy")?; let a = self.as_array(py)?; np.getattr("negative")?.call1((&a,)) } + /// element-wise positive fn __pos__<'py>(&self, py: Python<'py>) -> PyResult> { let np = PyModule::import(py, "numpy")?; let a = self.as_array(py)?; np.getattr("positive")?.call1((&a,)) } + /// element-wise absolute value fn __abs__<'py>(&self, py: Python<'py>) -> PyResult> { let np = PyModule::import(py, "numpy")?; let a = self.as_array(py)?; np.getattr("absolute")?.call1((&a,)) } + /// element-wise bitwise inversion fn __invert__<'py>(&self, py: Python<'py>) -> PyResult> { let np = PyModule::import(py, "numpy")?; let a = self.as_array(py)?; np.getattr("invert")?.call1((&a,)) } + /// context manager entry fn __enter__<'py>(slf: PyRef<'py, Self>) -> PyResult> { Ok(slf) } + /// context manager exit #[allow(unused_variables)] #[pyo3(signature = (exc_type=None, exc_val=None, exc_tb=None))] fn __exit__( @@ -959,10 +1002,12 @@ impl PyView { self.close() } + /// arguments for pickling pub(crate) fn __getnewargs__(&self) -> PyResult<(Vec,)> { Ok((to_stdvec(self).map_err(Error::from)?,)) } + /// shallow copy of the view fn __copy__(&self) -> Self { Self { view: self.view.clone(), @@ -972,6 +1017,7 @@ impl PyView { } } + /// deep copy of the view (same as shallow copy for this type) fn __deepcopy__(&self) -> Self { Self { view: self.view.clone(), @@ -981,6 +1027,7 @@ impl PyView { } } + /// create a copy of the view fn copy(&self) -> Self { Self { view: self.view.clone(), @@ -990,6 +1037,7 @@ impl PyView { } } + /// iterate over the first axis fn __iter__(&self) -> Self { Self { view: self.view.clone(), @@ -999,6 +1047,7 @@ impl PyView { } } + /// get the next item in the iteration fn __next__<'py>(&mut self, py: Python<'py>) -> PyResult>> { let shape = self.view.shape(); if shape.is_empty() || (self.index == shape[0]) { @@ -1030,14 +1079,17 @@ impl PyView { } } + /// number of elements in the first axis fn __len__(&self) -> PyResult { Ok(self.view.len()) } + /// string representation with a summary of the image fn __repr__(&self) -> PyResult { Ok(self.view.summary()?) } + /// the file path as a string fn __str__(&self) -> PyResult { Ok(self.view.path().display().to_string()) } @@ -1119,6 +1171,7 @@ impl PyView { }) } + /// flatten the view into a 1D numpy array fn flatten<'py>(&self, py: Python<'py>) -> PyResult> { Ok(match self.dtype { PixelType::I8 => self.view.flatten::()?.into_pyarray(py).into_any(), @@ -1137,6 +1190,7 @@ impl PyView { }) } + /// convert the view to bytes fn to_bytes(&self) -> PyResult> { Ok(match self.dtype { PixelType::I8 => self.view.to_bytes::()?, @@ -1155,6 +1209,7 @@ impl PyView { }) } + /// convert the view to bytes (alias for to_bytes) fn tobytes(&self) -> PyResult> { self.to_bytes() } @@ -1192,6 +1247,7 @@ impl PyView { } } + /// the current slice applied to the view #[getter] fn slice(&self) -> PyResult> { Ok(self @@ -1283,6 +1339,7 @@ impl PyView { }) } + /// transposed view (alias for transpose(None)) #[allow(non_snake_case)] #[getter] fn T(&self) -> PyResult { @@ -1308,6 +1365,7 @@ impl PyView { }) } + /// the numpy dtype of the view #[gen_stub(override_return_type(type_repr = "numpy.dtype", imports=("numpy")))] #[getter] fn get_dtype<'py>(&self, py: Python<'py>) -> PyResult> { @@ -1332,6 +1390,7 @@ impl PyView { } } + /// set the dtype of the view #[gen_stub(skip)] #[setter] fn set_dtype(&mut self, py: Python, dtype: Bound<'_, PyAny>) -> PyResult<()> { @@ -1451,6 +1510,7 @@ impl PyView { } } + /// get the mean overall or along a given axis #[gen_stub(skip)] #[pyo3(signature = (axis=None, dtype=None, out=None, keepdims=false, *, r#where=true), text_signature = "axis: str | int" )] @@ -1553,6 +1613,7 @@ impl PyView { } } + /// whether the view contains a z-stack (more than one z slice) #[getter] fn z_stack(&self) -> PyResult { if let Some(s) = self.view.size_ax(Axis::Z) { @@ -1572,6 +1633,7 @@ impl PyView { } } + /// whether the view contains a time series (more than one time point) #[getter] fn time_series(&self) -> PyResult { if let Some(s) = self.view.size_ax(Axis::T) { @@ -1591,6 +1653,7 @@ impl PyView { } } + /// the pixel size in micrometers #[getter] fn pixel_size(&self) -> PyResult> { Ok(self.ome.pixel_size()?) @@ -1608,11 +1671,13 @@ impl PyView { Ok(self.ome.delta_z()?.map(|p| p / 1000.)) } + /// the z-step size in micrometers #[getter] fn delta_z(&self) -> PyResult> { Ok(self.ome.delta_z()?) } + /// the time interval between frames in seconds #[getter] fn time_interval(&self) -> PyResult> { Ok(self.ome.time_interval()?) @@ -1624,6 +1689,7 @@ impl PyView { Ok(self.ome.time_interval()?) } + /// the exposure time for a given channel in seconds fn exposure_time(&self, channel: usize) -> PyResult> { Ok(self.ome.exposure_time(channel)?) } @@ -1636,37 +1702,45 @@ impl PyView { .collect::, Error>>()?) } + /// the binning for a given channel fn binning(&self, channel: usize) -> Option { self.ome.binning(channel) } + /// the laser wavelength for a given channel in nanometers fn laser_wavelengths(&self, channel: usize) -> PyResult> { Ok(self.ome.laser_wavelengths(channel)?) } + /// the laser power for a given channel as a fraction fn laser_power(&self, channel: usize) -> PyResult> { Ok(self.ome.laser_powers(channel)?) } + /// the name of the objective #[getter] fn objective_name(&self) -> Option { self.ome.objective_name() } + /// the total magnification (objective × tube lens) #[getter] fn magnification(&self) -> Option { self.ome.magnification() } + /// the name of the tube lens #[getter] fn tube_lens_name(&self) -> Option { self.ome.tube_lens_name() } + /// the name of the filter set for a given channel fn filter_set_name(&self, channel: usize) -> Option { self.ome.filter_set_name(channel) } + /// the detector gain for a given channel fn gain(&self, channel: usize) -> Option { self.ome.gain(channel) } @@ -1730,6 +1804,7 @@ impl PyView { )?) } + /// save the view as a TIFF file #[cfg(feature = "tiffwrite")] #[pyo3(signature = (file, colors = None, overwrite = false, bar = true))] fn save_as_tiff( @@ -1770,6 +1845,7 @@ impl PyView { Ok(()) } + /// save the view as a movie file (MP4) #[cfg(feature = "movie")] #[allow(clippy::too_many_arguments)] #[pyo3(signature = (file, speed = 1.0, brightness = None, scale = 1.0, colors = None, overwrite = false, register = false, no_scaling = false) @@ -1813,21 +1889,22 @@ submit! { def __pow__(other: Imread | numpy.typing.NDArray | int | float, mod: typing.Any = None, /) -> numpy.typing.NDArray: ... def __rpow__(other: Imread | numpy.typing.NDArray | int | float, mod: typing.Any = None, /) -> numpy.typing.NDArray: ... - def max(axis: int | str = None, dtype: numpy.typing.DTypeLike = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: + def max(axis: int | str = None, dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: """ Return the maximum along a given axis. Arguments beyond axis are not implemented """ - def min(axis: int | str = None, dtype: numpy.typing.DTypeLike = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: + def min(axis: int | str = None, dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: """ Return the minimum along a given axis. Arguments beyond axis are not implemented """ - def mean(axis: int | str = None, dtype: numpy.typing.DTypeLike = None, out: typing.Any = None, keepdims: bool = False) -> Imread | numpy.typing.NDArray | int | float: + def mean(axis: int | str = None, dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False) -> Imread | numpy.typing.NDArray | int | float: """ Return the mean along a given axis. Arguments beyond axis are not implemented """ - def sum(axis: int | str = None, dtype: numpy.typing.DTypeLike = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: + def sum(axis: int | str = None, dtype: typing.Optional[numpy.typing.DTypeLike] = None, out: typing.Any = None, keepdims: bool = False, initial: int | float = None, where: bool = True) -> Imread | numpy.typing.NDArray | int | float: """ Return the sum along a given axis. Arguments beyond axis are not implemented """ "# } } +/// batch convert multiple image files to TIFF format #[cfg(feature = "tiffwrite")] #[allow(clippy::too_many_arguments)] #[gen_stub_pyfunction(module = "ndbioimage.ndbioimage_rs")] @@ -1852,6 +1929,7 @@ fn batch_to_tiff( Ok(()) } +/// represents the shape of an image with named dimensions (c, z, t, y, x) #[gen_stub_pyclass] #[pyclass( subclass, @@ -1868,6 +1946,7 @@ struct PyShape { #[gen_stub_pymethods] #[pymethods] impl PyShape { + /// create a new shape with the given dimensions #[new] #[pyo3(signature = (order, c = 1, z = 1, t = 1, y = 1, x = 1))] fn new(order: String, c: usize, z: usize, t: usize, y: usize, x: usize) -> PyResult { @@ -1887,35 +1966,42 @@ impl PyShape { }) } + /// the number of channels #[getter] fn get_c(&self) -> usize { self.inner.c } + /// the number of z slices #[getter] fn get_z(&self) -> usize { self.inner.z } + /// the number of time points #[getter] fn get_t(&self) -> usize { self.inner.t } + /// the number of pixels along y #[getter] fn get_y(&self) -> usize { self.inner.y } + /// the number of pixels along x #[getter] fn get_x(&self) -> usize { self.inner.x } + /// string representation fn __str__(&self) -> String { format!("{}", self.inner) } + /// detailed representation for debugging fn __repr__(&self) -> String { format!( "Shape({}, {}, {}, {}, {})", @@ -1923,6 +2009,7 @@ impl PyShape { ) } + /// arguments for pickling fn __getnewargs__(&self) -> (String, usize, usize, usize, usize, usize) { ( self.inner @@ -1939,6 +2026,7 @@ impl PyShape { ) } + /// get dimension size by index or axis name #[gen_stub(override_return_type(type_repr="typing.Optional[int | list[int]]", imports=("typing") ))] fn __getitem__<'py>( @@ -2034,10 +2122,12 @@ impl PyShape { } } + /// number of dimensions in the shape fn __len__(&self) -> usize { self.inner.order.len() } + /// convert shape to a list of dimension sizes in order fn to_list(&self) -> Vec { vec![ self.inner.c, @@ -2048,6 +2138,7 @@ impl PyShape { ] } + /// the axis order as a string (e.g., "CZTYX") #[getter] fn axes(&self) -> String { self.inner @@ -2081,6 +2172,7 @@ pub fn generate_stub(dest_path: String) -> PyResult<()> { .generate()?) } +/// main entry point for the command-line interface #[gen_stub_pyfunction(module = "ndbioimage.ndbioimage_rs")] #[pyfunction] fn main() -> PyResult<()> { diff --git a/src/readers.rs b/src/readers.rs index 87abe51..3f3e5f5 100644 --- a/src/readers.rs +++ b/src/readers.rs @@ -14,33 +14,45 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::LazyLock; +/// czi file reader #[cfg(feature = "czi")] pub mod czi; +/// bioformats reader (pure rust) #[cfg(feature = "bioformats_rust")] pub mod bioformats_rust; +/// bioformats reader (java bindings) #[cfg(feature = "bioformats_java")] pub mod bioformats_java; +/// tiff sequence reader #[cfg(feature = "tiffseq")] pub mod tiffseq; +/// single tiff file reader #[cfg(feature = "tiff")] pub mod tiff; static RE: LazyLock = LazyLock::new(|| Regex::new(r"^([CZTSP])\D+(\d+)$").unwrap()); +/// dimensions of the image data #[derive(Debug, Clone, Copy, Default)] pub struct Dimensions { + /// number of channels pub c: Option, + /// number of z slices pub z: Option, + /// number of time points pub t: Option, + /// series index pub s: Option, + /// position index pub p: Option, } impl Dimensions { + /// create dimensions with the given series and position pub fn new(series: usize, position: usize) -> Self { Self { c: None, @@ -51,6 +63,7 @@ impl Dimensions { } } + /// parse a path and extract dimensions from the directory structure pub fn parse_path

(path: P) -> Result<(PathBuf, Self), Error> where P: AsRef, @@ -86,26 +99,40 @@ impl Dimensions { } } -/// Pixel types (u)int(8/16/32) or float(32/64), (u/i)(64/128) are not included in bioformats +/// pixel type enum #[allow(clippy::upper_case_acronyms)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] pub enum PixelType { + /// signed 8-bit integer I8, + /// unsigned 8-bit integer U8, + /// signed 16-bit integer I16, + /// unsigned 16-bit integer U16, + /// signed 32-bit integer I32, + /// unsigned 32-bit integer U32, + /// 32-bit float F32, + /// 64-bit float F64, + /// signed 64-bit integer I64, + /// unsigned 64-bit integer U64, + /// signed 128-bit integer I128, + /// unsigned 128-bit integer U128, + /// 128-bit float (emulated) F128, } impl PixelType { + /// number of bytes per pixel for this type pub fn bytes_per_pixel(&self) -> usize { match self { PixelType::I8 | PixelType::U8 => 1, @@ -117,37 +144,55 @@ impl PixelType { } } -/// Struct containing frame data in one of eight pixel types. Cast to `Array2` using try_into. +/// array data with a specific pixel type #[allow(clippy::upper_case_acronyms)] #[derive(Clone, Debug)] pub enum ArrayT { + /// signed 8-bit integer array I8(Array), + /// unsigned 8-bit integer array U8(Array), + /// signed 16-bit integer array I16(Array), + /// unsigned 16-bit integer array U16(Array), + /// signed 32-bit integer array I32(Array), + /// unsigned 32-bit integer array U32(Array), + /// 32-bit float array F32(Array), + /// 64-bit float array F64(Array), + /// signed 64-bit integer array I64(Array), + /// unsigned 64-bit integer array U64(Array), + /// signed 128-bit integer array I128(Array), + /// unsigned 128-bit integer array U128(Array), - F128(Array), // f128 is nightly + /// 128-bit float array (emulated as f64) + F128(Array), } +/// type alias for a single frame (2D image) pub type Frame = ArrayT; +/// trait for reading image files pub trait Reader: Clone + Sized + Debug + Send + Hash + Into { + /// create a new reader for the given path, series, and position fn new

(path: P, series: usize, position: usize) -> Result where P: AsRef; + /// the name of the reader type fn reader_name(&self) -> &'static str { type_name::() } // TODO: read from file if present + /// get the ome metadata for the image fn metadata(&self) -> Result; /// get a sliceable view on the image file @@ -161,18 +206,25 @@ pub trait Reader: Clone + Sized + Debug + Send + Hash + Into { ) } - /// Retrieve fame at channel c, slize z and time t. + /// retrieve frame at channel c, slice z and time t #[allow(clippy::if_same_then_else)] fn get_frame(&self, c: usize, z: usize, t: usize) -> Result; + /// the path to the image file fn path(&self) -> &Path; + /// the series index fn series(&self) -> usize; + /// the position index fn position(&self) -> usize; + /// the shape of the image data fn shape(&self) -> &Shape; + /// the pixel type of the image data fn pixel_type(&self) -> &PixelType; + /// get all available positions for a given series fn get_available_positions

(path: P, series: usize) -> Result, Error> where P: AsRef; + /// get all available series fn get_available_series

(path: P) -> Result, Error> where P: AsRef; @@ -365,16 +417,22 @@ where } } +/// dynamic reader that can handle multiple file formats #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] pub enum DynReader { + /// tiff file reader #[cfg(feature = "tiff")] Tiff(tiff::TiffReader), + /// tiff sequence reader #[cfg(feature = "tiffseq")] TiffSeq(tiffseq::TiffSeqReader), + /// czi file reader #[cfg(feature = "czi")] Czi(czi::CziReader), + /// bioformats reader (pure rust) #[cfg(feature = "bioformats_rust")] BioFormatsRust(bioformats_rust::BioFormatsRustReader), + /// bioformats reader (java bindings) #[cfg(feature = "bioformats_java")] BioFormatsJava(bioformats_java::BioFormatsJavaReader), } @@ -626,6 +684,7 @@ impl Reader for DynReader { } impl DynReader { + /// create a dynreader by selecting the reader type explicitly pub fn from_path_select_reader(path: P, reader: R) -> Result where P: AsRef, @@ -674,6 +733,7 @@ impl DynReader { Ok(reader) } + /// get all available positions, optionally selecting a specific reader type pub fn get_available_positions_select_reader( path: P, series: usize, @@ -709,6 +769,7 @@ impl DynReader { }) } + /// get all available series, optionally selecting a specific reader type pub fn get_available_series_select_reader( path: P, reader: Option, diff --git a/src/readers/czi.rs b/src/readers/czi.rs index 3ca6ba5..fb82099 100644 --- a/src/readers/czi.rs +++ b/src/readers/czi.rs @@ -12,6 +12,7 @@ use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use thread_local::ThreadLocal; +/// reader for czi (zeiss) image files #[derive(Debug, Deserialize, Serialize)] pub struct CziReader { #[serde(skip)] @@ -121,8 +122,10 @@ impl From> for Version { } } +/// errors specific to czi file reading #[derive(Debug, thiserror::Error)] pub enum CziError { + /// czi file has no valid blocks #[error("czi file has no valid blocks")] NoValidBlocks, } diff --git a/src/readers/tiff.rs b/src/readers/tiff.rs index 32703ec..8251ef1 100644 --- a/src/readers/tiff.rs +++ b/src/readers/tiff.rs @@ -12,6 +12,7 @@ use thread_local::ThreadLocal; use tiff::decoder::{Decoder, DecodingResult}; use tiff::tags::Tag; +/// reader for single tiff image files #[derive(Debug, Serialize, Deserialize)] pub struct TiffReader { #[serde(skip)] diff --git a/src/readers/tiffseq.rs b/src/readers/tiffseq.rs index 5b00c33..1277a60 100644 --- a/src/readers/tiffseq.rs +++ b/src/readers/tiffseq.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use tiff::decoder::{Decoder, DecodingResult}; use tiff::tags::Tag; +/// reader for sequences of tiff files (one file per z/time plane) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TiffSeqReader { path: PathBuf, diff --git a/src/stats.rs b/src/stats.rs index 3dfab28..0c87baa 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -3,11 +3,16 @@ use ndarray::{Array, ArrayD, ArrayView, Axis, Dimension, RemoveAxis}; /// a trait to define the min, max, sum and mean operations along an axis pub trait MinMax { + /// the type of the result after reducing one axis type Output; + /// the max of the array along `axis` fn max(self, axis: usize) -> Result; + /// the min of the array along `axis` fn min(self, axis: usize) -> Result; + /// the sum of the array along `axis` fn sum(self, axis: usize) -> Result; + /// the mean of the array along `axis` fn mean(self, axis: usize) -> Result; } diff --git a/src/tiffwrite.rs b/src/tiffwrite.rs index 8374632..5105f3c 100644 --- a/src/tiffwrite.rs +++ b/src/tiffwrite.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Condvar, Mutex}; use tiffwrite::{Bytes, Colors, Compression, IJTiffFile}; +/// options for saving tiff files #[derive(Debug, Clone)] pub struct TiffOptions { bar: Option, @@ -34,6 +35,7 @@ impl Default for TiffOptions { } impl TiffOptions { + /// create tiff options with the given parameters pub fn new( bar: Option, compression: Option, @@ -77,6 +79,7 @@ impl TiffOptions { self.compression = Compression::Deflate } + /// set the color lookup table for the tiff pub fn set_colors(&mut self, colors: &[String]) -> Result<(), Error> { let colors = colors .iter() @@ -86,6 +89,7 @@ impl TiffOptions { Ok(()) } + /// set whether an existing tiff file should be overwritten pub fn set_overwrite(&mut self, overwrite: bool) { self.overwrite = overwrite; } @@ -180,6 +184,7 @@ where } } +/// batch convert multiple files to tiff format pub fn batch_to_tiff( files_in: &[PathBuf], files_out: &[PathBuf], diff --git a/src/view.rs b/src/view.rs index 6a6551e..e3e960b 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,9 +1,10 @@ use crate::axes::{Ax, Axis, Operation, Shape, Slice, SliceInfoElemDef, slice_info}; +use crate::cache::{ArrayCache, ArrayKeyRef, FrameCache, FrameKeyRef, ReaderKeyRef}; use crate::error::Error; use crate::metadata::Metadata; use crate::readers::{Dimensions, DynReader, Frame, Reader}; use crate::stats::MinMax; -use indexmap::{Equivalent, IndexMap}; +use indexmap::IndexMap; use itertools::{Itertools, iproduct}; use ndarray::{ Array, Array0, Array1, Array2, ArrayD, Dimension, IntoDimension, Ix0, Ix1, Ix2, Ix5, IxDyn, @@ -13,15 +14,15 @@ use num::traits::ToBytes; use num::{Bounded, FromPrimitive, ToPrimitive, Zero}; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -use std::any::{Any, type_name}; +use std::any::type_name; use std::collections::{HashMap, HashSet}; use std::fmt::{Debug, Display, Formatter}; use std::hash::{Hash, Hasher}; use std::iter::Sum; use std::marker::PhantomData; use std::ops::{AddAssign, Deref, Div}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::path::Path; +use std::sync::Arc; fn idx_bnd(idx: isize, bnd: isize) -> Result { if idx < -bnd { @@ -47,6 +48,7 @@ fn slc_bnd(idx: isize, bnd: isize) -> Result { } } +/// a trait for numeric types that can be used as pixel values pub trait Number: 'static + Send @@ -74,318 +76,6 @@ impl Number for T where { } -/// maximum number of frames held in the cache -const DEFAULT_FRAME_CACHE_SIZE: usize = 128; - -/// maximum number of materialized arrays held in the cache -const DEFAULT_ARRAY_CACHE_SIZE: usize = 2; - -/// identity of the reader a frame was read from -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct ReaderKey { - name: String, - path: PathBuf, - series: usize, - position: usize, -} - -/// borrowed view of [`ReaderKey`] for cache lookups without allocation. -/// hashes byte-identically to [`ReaderKey`] (str/String and Path/PathBuf hash -/// the same), so it only matches the same reader identity. -#[derive(Debug, PartialEq, Eq)] -struct ReaderKeyRef<'a> { - name: &'a str, - path: &'a Path, - series: usize, - position: usize, -} - -impl Hash for ReaderKeyRef<'_> { - fn hash(&self, state: &mut H) { - self.name.hash(state); - self.path.hash(state); - self.series.hash(state); - self.position.hash(state); - } -} - -impl Equivalent for ReaderKeyRef<'_> { - fn equivalent(&self, key: &ReaderKey) -> bool { - self.name == key.name - && self.path == key.path.as_path() - && self.series == key.series - && self.position == key.position - } -} - -impl ReaderKeyRef<'_> { - fn to_owned(&self) -> ReaderKey { - ReaderKey { - name: self.name.to_string(), - path: self.path.to_path_buf(), - series: self.series, - position: self.position, - } - } -} - -type FrameKey = (ReaderKey, usize, usize, usize); - -/// borrowed view of [`FrameKey`] for cache lookups without allocation. -#[derive(Debug, PartialEq, Eq)] -struct FrameKeyRef<'a> { - reader: ReaderKeyRef<'a>, - c: usize, - z: usize, - t: usize, -} - -impl Hash for FrameKeyRef<'_> { - fn hash(&self, state: &mut H) { - self.reader.hash(state); - self.c.hash(state); - self.z.hash(state); - self.t.hash(state); - } -} - -impl Equivalent for FrameKeyRef<'_> { - fn equivalent(&self, key: &FrameKey) -> bool { - let (rk, c, z, t) = key; - self.reader.equivalent(rk) && self.c == *c && self.z == *z && self.t == *t - } -} - -impl FrameKeyRef<'_> { - fn to_owned(&self) -> FrameKey { - (self.reader.to_owned(), self.c, self.z, self.t) - } -} - -/// identity of a materialized array in the process-wide cache -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct ArrayKey { - reader: ReaderKey, - dtype: &'static str, - slice: Vec, - axes: Vec, - operations: Vec<(Axis, Operation)>, -} - -/// borrowed view of [`ArrayKey`] for cache lookups without allocation. -#[derive(Debug, PartialEq, Eq)] -struct ArrayKeyRef<'a> { - reader: ReaderKeyRef<'a>, - dtype: &'static str, - slice: &'a [SliceInfoElem], - axes: &'a [Axis], - operations: &'a IndexMap, -} - -impl Hash for ArrayKeyRef<'_> { - fn hash(&self, state: &mut H) { - self.reader.hash(state); - self.dtype.hash(state); - self.slice.hash(state); - self.axes.hash(state); - self.operations.len().hash(state); - for (ax, op) in self.operations.iter() { - ax.hash(state); - op.hash(state); - } - } -} - -impl Equivalent for ArrayKeyRef<'_> { - fn equivalent(&self, key: &ArrayKey) -> bool { - self.reader.equivalent(&key.reader) - && self.dtype == key.dtype - && self.slice == key.slice - && self.axes == key.axes - && self.operations.len() == key.operations.len() - && self - .operations - .iter() - .zip(&key.operations) - .all(|((ax, op), (key_ax, key_op))| ax == key_ax && op == key_op) - } -} - -impl ArrayKeyRef<'_> { - fn to_owned(&self) -> ArrayKey { - ArrayKey { - reader: self.reader.to_owned(), - dtype: self.dtype, - slice: self.slice.to_vec(), - axes: self.axes.to_vec(), - operations: self - .operations - .iter() - .map(|(ax, op)| (*ax, op.clone())) - .collect(), - } - } -} - -/// process-wide LRU cache of frames, shared between all views and threads -static GLOBAL_FRAME_CACHE: OnceLock = OnceLock::new(); - -/// thread-safe LRU cache of frames read from the underlying reader -struct FrameCache { - inner: Mutex, -} - -struct FrameCacheInner { - map: IndexMap>, - capacity: usize, -} - -impl Default for FrameCache { - fn default() -> Self { - Self::new(DEFAULT_FRAME_CACHE_SIZE) - } -} - -impl FrameCache { - fn new(capacity: usize) -> Self { - Self { - inner: Mutex::new(FrameCacheInner { - map: IndexMap::with_capacity(capacity), - capacity, - }), - } - } - - fn global() -> &'static FrameCache { - GLOBAL_FRAME_CACHE.get_or_init(FrameCache::default) - } - - fn capacity(&self) -> usize { - self.inner.lock().unwrap().capacity - } - - fn set_capacity(&self, capacity: usize) { - let mut inner = self.inner.lock().unwrap(); - inner.capacity = capacity; - while inner.map.len() > inner.capacity { - inner.map.shift_remove_index(0); - } - } - - fn get(&self, key: &Q) -> Option> - where - Q: ?Sized + Hash + Equivalent, - { - let mut inner = self.inner.lock().unwrap(); - if let Some(idx) = inner.map.get_index_of(key) { - let (key, frame) = inner.map.shift_remove_index(idx).unwrap(); - inner.map.insert(key, frame.clone()); - Some(frame) - } else { - None - } - } - - fn insert(&self, key: FrameKey, frame: Arc) { - let mut inner = self.inner.lock().unwrap(); - inner.map.insert(key, frame); - while inner.map.len() > inner.capacity { - inner.map.shift_remove_index(0); - } - } - - #[cfg(test)] - fn len(&self) -> usize { - self.inner.lock().unwrap().map.len() - } -} - -impl Debug for FrameCache { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FrameCache").finish_non_exhaustive() - } -} - -/// process-wide LRU cache of materialized arrays, shared between all views and threads -static GLOBAL_ARRAY_CACHE: OnceLock = OnceLock::new(); - -/// thread-safe LRU cache of materialized arrays produced by `as_array_dyn` -struct ArrayCache { - inner: Mutex, -} - -struct ArrayCacheInner { - map: IndexMap>, - capacity: usize, -} - -impl Default for ArrayCache { - fn default() -> Self { - Self::new(DEFAULT_ARRAY_CACHE_SIZE) - } -} - -impl ArrayCache { - fn new(capacity: usize) -> Self { - Self { - inner: Mutex::new(ArrayCacheInner { - map: IndexMap::with_capacity(capacity), - capacity, - }), - } - } - - fn global() -> &'static ArrayCache { - GLOBAL_ARRAY_CACHE.get_or_init(ArrayCache::default) - } - - fn capacity(&self) -> usize { - self.inner.lock().unwrap().capacity - } - - fn set_capacity(&self, capacity: usize) { - let mut inner = self.inner.lock().unwrap(); - inner.capacity = capacity; - while inner.map.len() > inner.capacity { - inner.map.shift_remove_index(0); - } - } - - fn get(&self, key: &Q) -> Option>> - where - Q: ?Sized + Hash + Equivalent, - T: Any + Send + Sync, - { - let mut inner = self.inner.lock().unwrap(); - if let Some(idx) = inner.map.get_index_of(key) { - let (key, array) = inner.map.shift_remove_index(idx).unwrap(); - inner.map.insert(key, array.clone()); - array.downcast::>().ok() - } else { - None - } - } - - fn insert(&self, key: ArrayKey, array: ArrayD) { - let mut inner = self.inner.lock().unwrap(); - inner.map.insert(key, Arc::new(array)); - while inner.map.len() > inner.capacity { - inner.map.shift_remove_index(0); - } - } - - #[cfg(test)] - fn len(&self) -> usize { - self.inner.lock().unwrap().map.len() - } -} - -impl Debug for ArrayCache { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ArrayCache").finish_non_exhaustive() - } -} - /// sliceable view on an image file #[serde_as] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -634,6 +324,7 @@ impl View { self.shape()[0] } + /// whether the view has no pixels in the first dimension pub fn is_empty(&self) -> bool { self.shape()[0] == 0 } @@ -643,6 +334,7 @@ impl View { self.shape().into_iter().product() } + /// the size of the view along an axis, or `None` if the axis is not present pub fn size_ax(&self, ax: Axis) -> Option { self.axes() .iter() @@ -745,6 +437,7 @@ impl View { .with_operations(self.operations.clone())) } + /// reduce an axis with an operation (max, min, sum or mean), returning a view of lower dimensionality pub fn operate( &self, axis: A, @@ -1450,6 +1143,7 @@ where /// trait to define a function to retrieve the only item in a 0d array pub trait Item { + /// the single item in the 0d array, cast to `T` fn item(&self) -> Result where T: Number, @@ -1459,6 +1153,7 @@ pub trait Item { } impl View { + /// create a view on the image at `path`, parsing series and position from the file name pub fn from_path

(path: P) -> Result where P: AsRef, @@ -1492,6 +1187,7 @@ impl Display for View { /// trait to convert numbers to bytes pub trait ToBytesVec { + /// the number as a vector of bytes in native endianness fn to_bytes_vec(&self) -> Vec; } @@ -1516,12 +1212,11 @@ to_bytes_vec_impl!( #[cfg(test)] mod tests { use crate::axes::{Axis, Operation}; + use crate::cache::{ArrayCache, ArrayKey, ArrayKeyRef, FrameCache, ReaderKey, ReaderKeyRef}; use crate::error::Error; use crate::readers::{DynReader, Frame, Reader}; use crate::stats::MinMax; - use crate::view::{ - ArrayCache, ArrayKey, ArrayKeyRef, FrameCache, Item, ReaderKey, ReaderKeyRef, - }; + use crate::view::Item; use indexmap::IndexMap; use ndarray::{Array, Array4, Array5, NewAxis}; use ndarray::{Array2, ArrayD, IxDyn, SliceInfoElem, s};