diff --git a/Cargo.toml b/Cargo.toml index c725066..e2962df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ itertools = "0.15" indexmap = { version = "2", features = ["serde"] } indicatif = { version = "0.18", features = ["rayon"], optional = true } j4rs = { version = "0.25", optional = true } -libczirw-sys = { path = "../libczirw-sys", optional = true } +libczirw-sys = { version = "0.5", optional = true } ndarray = { version = "0.17", features = ["serde"] } num = "0.4" numpy = { version = "0.29", optional = true } @@ -76,7 +76,7 @@ tiff = ["dep:tiff", "dep:thread_local"] movie = ["dep:ffmpeg-sidecar", "dep:tokio", "dep:ordered-float", "dep:indicatif", "dep:console"] [package.metadata.docs.rs] -features = ["bioformats_java", "gpl-formats", "czi", "tiff", "movie"] +features = ["bioformats_java", "czi", "tiff", "tiffseq", "movie", "tiffwrite", "movie"] [profile.test] inherits = "release" diff --git a/README.md b/README.md index 90a63a7..ea56c1f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # ndbioimage + [![Pytest](https://github.com/pomppervova/ndbioimage/actions/workflows/pytest.yml/badge.svg)](https://github.com/pomppervova/ndbioimage/actions/workflows/pytest.yml) ## Work in progress + Rust rewrite of python version. Read bio image formats using the bio-formats java package. [https://www.openmicroscopy.org/bio-formats/](https://www.openmicroscopy.org/bio-formats/) -Exposes (bio) images as a numpy ndarray-like object, but without loading the whole -image into memory, reading from the file only when needed. Some metadata is read +Exposes (bio) images as a numpy ndarray-like object (Python) or a struct that can be sliced like an ndarray Array +(Rust), but without loading the whole image into memory, reading from the file only when needed. Some metadata is read and stored in an [ome](https://genomebiology.biomedcentral.com/articles/10.1186/gb-2005-6-5-r47) structure. Additionally, it can automatically calculate an affine transform that corrects for chromatic aberrations etc. and apply it on the fly to the image. @@ -23,6 +25,7 @@ pip install ndbioimage ``` ### Installation with option to write mp4 or mkv: + Work in progress! Make sure ffmpeg is installed. ``` @@ -30,6 +33,7 @@ pip install ndbioimage[write] ``` ## Usage + ### Python - Reading an image file and plotting the frame at channel=2, time=1 @@ -60,7 +64,6 @@ with Imread('image_file.tif', axes='cztyx') as im: sliced_im is an instance of Imread which will load any image data from file only when needed - - Converting (part) of the image to a numpy ndarray ``` @@ -71,36 +74,45 @@ with Imread('image_file.tif', axes='cztyx') as im: ``` ### Rust -``` -use ndarray::Array2; -use ndbioimage::Reader; -let path = "/path/to/file"; -let reader = Reader::new(&path, 0)?; -println!("size: {}, {}", reader.size_y, reader.size_y); -let frame = reader.get_frame(0, 0, 0).unwrap(); -if let Ok(arr) = >>::try_into(frame) { - println!("{:?}", arr); -} else { - println!("could not convert Frame to Array"); +```rust +use ndarray::Array2; +use ndbioimage::{DynReader, Frame, Reader}; + +fn main() -> Result<(), ndbioimage::error::Error> { + let path = "/path/to/file"; + let reader = DynReader::new(&path, 0, 0)?; + println!("shape: {}", reader.shape()); + let frame = reader.get_frame(0, 0, 0)?; + if let Ok(arr) = >>::try_into(frame) { + println!("{:?}", arr); + } else { + println!("could not convert Frame to Array"); + } + let xml = reader.metadata()?.to_xml()?; + println!("{}", xml); + Ok(()) } -let xml = reader.get_ome_xml().unwrap(); -println!("{}", xml); ``` -``` -use ndarray::Array2; -use ndbioimage::Reader; +```rust +use ndbioimage::{DynReader, Reader}; -let path = "/path/to/file"; -let reader = Reader::new(&path, 0)?; -let view = reader.view(); -let view = view.max_proj(3)?; -let array = view.as_array::()? +fn main() -> Result<(), ndbioimage::error::Error> { + let path = "/path/to/file"; + let reader = DynReader::new(&path, 0, 0)?; + let view = reader.view(); + let view = view.max_proj(3)?; + let array = view.as_array::()?; + println!("{:?}", array.shape()); + Ok(()) +} ``` ### Command line + ```ndbioimage --help```: show help -```ndbioimage image```: show metadata about image -```ndbioimage image -w {name}.tif -r```: copy image into image.tif (replacing {name} with image), while registering channels -```ndbioimage image -w image.mp4 -C cyan lime red``` copy image into image.mp4 (z will be max projected), make channel colors cyan lime and red +```ndbioimage info image```: show metadata about image +```ndbioimage tiff image image.tif -r```: copy image into image.tif, while registering channels +```ndbioimage movie image image.mp4 -C cyan lime red``` copy image into image.mp4 (z will be max projected), make channel +colors cyan lime and red diff --git a/pyproject.toml b/pyproject.toml index f45b2d8..016bd70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ndbioimage" -version = "2027.0.2" +version = "2027.0.3" requires-python = ">=3.10" classifiers = [ "License :: OSI Approved :: MIT License", diff --git a/src/lib.rs b/src/lib.rs index 139810a..fab7273 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,47 @@ #![cfg_attr(docsrs, feature(doc_cfg))] +//! The ndbioimage crate exposes (bio) images a struct that can be sliced like an ndarray Array +//! (Rust), but without loading the whole image into memory, reading from the file only when needed. +//! Some metadata is read +//! and stored in an [ome](https://genomebiology.biomedcentral.com/articles/10.1186/gb-2005-6-5-r47) +//! structure. Additionally, it can automatically calculate an affine transform that corrects for +//! chromatic aberrations etc. and apply it on the fly to the image. +//! +//! Currently, it supports imagej tif files, czi files, micromanager tif sequences and anything +//! [bioformats](https://www.openmicroscopy.org/bio-formats/) can handle. +//! +//! ```rust,no_run +//! use ndarray::Array2; +//! use ndbioimage::readers::{DynReader, Frame, Reader}; +//! +//! # fn main() -> Result<(), ndbioimage::error::Error> { +//! let path = "/path/to/file"; +//! let reader = DynReader::new(&path, 0, 0)?; +//! println!("shape: {}", reader.shape()); +//! let frame = reader.get_frame(0, 0, 0)?; +//! if let Ok(arr) = >>::try_into(frame) { +//! println!("{:?}", arr); +//! } else { +//! println!("could not convert Frame to Array"); +//! } +//! let xml = reader.metadata()?.to_xml()?; +//! println!("{}", xml); +//! # Ok(()) +//! # } +//! ``` +//! +//! ```rust,no_run +//! use ndbioimage::readers::{DynReader, Reader}; +//! +//! # fn main() -> Result<(), ndbioimage::error::Error> { +//! let path = "/path/to/file"; +//! let reader = DynReader::new(&path, 0, 0)?; +//! let view = reader.view(); +//! let view = view.max_proj(3)?; +//! let array = view.as_array::()?; +//! println!("{:?}", array.shape()); +//! # Ok(()) +//! # } +//! ``` pub mod axes; #[cfg(feature = "python")] @@ -14,7 +57,7 @@ pub mod movie; pub mod readers; #[cfg(feature = "tiffwrite")] pub mod tiffwrite; -// mod cache; +mod utils; pub mod main { #[cfg(feature = "tiffwrite")] @@ -30,7 +73,8 @@ pub mod main { use std::path::PathBuf; #[derive(Parser)] - #[command(arg_required_else_help = true, version, about, long_about = None, propagate_version = true)] + #[command(arg_required_else_help = true, version, about, long_about = None, propagate_version = true + )] struct Cli { #[command(subcommand)] command: Commands, @@ -162,7 +206,7 @@ pub mod main { output, } => { let options = crate::tiffwrite::TiffOptions::new( - Some(crate::tiffwrite::get_bar( + Some(crate::utils::progress::get_bar( Some(0), Some("writing tiff file".to_string()), )), diff --git a/src/movie.rs b/src/movie.rs index d00b740..4eefb14 100644 --- a/src/movie.rs +++ b/src/movie.rs @@ -2,18 +2,16 @@ use crate::axes::Axis; use crate::colors::Color; use crate::error::Error; use crate::readers::{PixelType, Reader}; +use crate::utils::progress::get_bar; use crate::view::View; -use console::Term; use ffmpeg_sidecar::command::FfmpegCommand; use ffmpeg_sidecar::download::auto_download; use ffmpeg_sidecar::event::{FfmpegEvent, LogLevel}; -use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use itertools::Itertools; use ndarray::{Array2, Array3, Dimension, IxDyn, s, stack}; use ordered_float::OrderedFloat; use std::io::Write; use std::path::Path; -use std::time::Duration; pub struct MovieOptions { velocity: f64, @@ -138,20 +136,6 @@ fn cframe(frame: Array2, color: &[u8], a: f64, b: f64) -> Array3 { stack(ndarray::Axis(2), &view).unwrap() } -/// a progress bar with an ok style that when py::detach is used also works in jupyter -pub fn get_bar(count: Option) -> ProgressBar { - let style = ProgressStyle::with_template( - "{spinner:.green} {percent}% [{wide_bar:.green/lime}] {pos:>7}/{len:7} [{elapsed}/{eta}, {per_sec:<5}]", - ).expect("template should be working").progress_chars("#>-"); - let bar = ProgressBar::with_draw_target( - count.map(|i| i as u64), - ProgressDrawTarget::term_like_with_hz(Box::new(Term::buffered_stdout()), 20), - ) - .with_style(style); - bar.enable_steady_tick(Duration::from_millis(100)); - bar -} - impl View where D: Dimension, @@ -252,7 +236,10 @@ where }; let rt = tokio::runtime::Runtime::new()?; - let bar = get_bar(Some(size_t)); + let bar = get_bar( + Some(size_t), + Some("autoscaling movie brightness".to_string()), + ); let rt_bar = bar.clone(); let write_task = rt.spawn(async move { for t in 0..size_t { @@ -275,7 +262,7 @@ where }); bar.finish(); - let bar = get_bar(Some(size_t)); + let bar = get_bar(Some(size_t), Some("saving movie".to_string())); let rt_bar = bar.clone(); let progress_task = rt.spawn(async move { for event in movie.iter().map_err(|e| Error::Ffmpeg(e.to_string()))? { diff --git a/src/py.rs b/src/py.rs index fc38a33..cd19713 100644 --- a/src/py.rs +++ b/src/py.rs @@ -1741,7 +1741,7 @@ impl PyView { bar: bool, ) -> PyResult<()> { let bar = if bar { - Some(crate::tiffwrite::get_bar( + Some(crate::utils::progress::get_bar( Some(0), Some("writing tiff file".to_string()), )) @@ -1991,7 +1991,16 @@ impl PyShape { false, ) } else if idx.is_instance_of::() { - (vec![idx.cast::()?.extract::()?], true) + let i = idx.cast::()?.extract::()?; + let len = self.inner.order.len() as isize; + let i = if i < 0 { i + len } else { i }; + if i < 0 || i >= len { + return Err(PyIndexError::new_err(format!( + "index {} is out of bounds for size {}", + i, len + ))); + } + (vec![i as usize], true) } else { return Err(PyErr::new::(format!( "Unknown type: {:?}", diff --git a/src/readers.rs b/src/readers.rs index 9cd7591..87abe51 100644 --- a/src/readers.rs +++ b/src/readers.rs @@ -136,7 +136,7 @@ pub enum ArrayT { F128(Array), // f128 is nightly } -pub(crate) type Frame = ArrayT; +pub type Frame = ArrayT; pub trait Reader: Clone + Sized + Debug + Send + Hash + Into { fn new

(path: P, series: usize, position: usize) -> Result diff --git a/src/readers/bioformats_java.rs b/src/readers/bioformats_java.rs index cce6a7f..149cbf9 100644 --- a/src/readers/bioformats_java.rs +++ b/src/readers/bioformats_java.rs @@ -5,10 +5,9 @@ use serde::{Deserialize, Serialize}; use std::fmt::Debug; use std::path::{Path, PathBuf}; -pub use crate::readers::{ArrayT, PixelType, Reader}; -use crate::readers::{DynReader, Frame, Shape}; +use crate::readers::{ArrayT, DynReader, Frame, PixelType, Reader, Shape}; use itertools::Itertools; -use j4rs::{Instance, InvocationArg, Jvm, JvmBuilder}; +use j4rs::{Instance, InvocationArg, JavaOpt, Jvm, JvmBuilder}; use std::cell::OnceCell; use std::collections::HashSet; use std::hash::{Hash, Hasher}; @@ -109,8 +108,16 @@ fn jvm() -> Rc { let j = JvmBuilder::new() .skip_setting_native_lib() .with_base_path(class_path.to_str().unwrap()) - .build() - .expect("Failed to build JVM"); + .java_opt(JavaOpt::new("--enable-native-access=ALL-UNNAMED")) + .build(); + let j = match j { + Ok(j) => j, + Err(_) => JvmBuilder::new() + .skip_setting_native_lib() + .with_base_path(class_path.to_str().unwrap()) + .build() + .expect("Failed to build JVM"), + }; if let Ok(e) = InvocationArg::try_from("ERROR") { let _ = j.invoke_static( "loci.common.DebugTools", @@ -122,7 +129,7 @@ fn jvm() -> Rc { }) } }) - .clone() + .clone() }) } diff --git a/src/readers/tiff.rs b/src/readers/tiff.rs index 113376e..32703ec 100644 --- a/src/readers/tiff.rs +++ b/src/readers/tiff.rs @@ -331,7 +331,7 @@ impl Reader for TiffReader { fn get_frame(&self, c: usize, z: usize, t: usize) -> Result { let (page_idx, offset, stride) = if self.p_ndim == 3 { - (z * self.shape.t + t, c, self.n_samples) + (t * self.shape.z + z, c, self.n_samples) } else { (c + z * self.shape.c + t * self.shape.c * self.shape.z, 0, 1) }; diff --git a/src/tiffwrite.rs b/src/tiffwrite.rs index 620636b..8374632 100644 --- a/src/tiffwrite.rs +++ b/src/tiffwrite.rs @@ -4,15 +4,14 @@ use crate::error::Error; use crate::metadata::Metadata; use crate::readers::{DynReader, PixelType, Reader}; use crate::stats::MinMax; +use crate::utils::progress::get_bar; use crate::view::{Number, View}; -use console::Term; -use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; +use indicatif::ProgressBar; use itertools::iproduct; use ndarray::{Array0, Array1, Array2, ArrayD, Dimension}; use rayon::prelude::*; use std::path::{Path, PathBuf}; use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; use tiffwrite::{Bytes, Colors, Compression, IJTiffFile}; #[derive(Debug, Clone)] @@ -34,23 +33,6 @@ impl Default for TiffOptions { } } -/// a progress bar with an ok style that when py::detach is used also works in jupyter -pub fn get_bar(count: Option, message: Option) -> ProgressBar { - let style = ProgressStyle::with_template( - "{spinner:.green} {percent}% [{wide_bar:.green/lime}] {pos:>7}/{len:7} [{elapsed}/{eta}, {per_sec:<5}]", - ).expect("template should be working").progress_chars("#>-"); - let bar = ProgressBar::with_draw_target( - count.map(|i| i as u64), - ProgressDrawTarget::term_like_with_hz(Box::new(Term::buffered_stdout()), 20), - ) - .with_style(style); - if let Some(message) = message { - bar.set_message(message); - } - bar.enable_steady_tick(Duration::from_millis(100)); - bar -} - impl TiffOptions { pub fn new( bar: Option, @@ -258,14 +240,25 @@ mod tests { use std::fs::create_dir_all; use std::path::PathBuf; + #[cfg(any( + feature = "czi", + feature = "tiffseq", + feature = "tiff", + feature = "bioformats_java" + ))] #[test] fn tiff() -> Result<(), Error> { - #[cfg(any(feature = "czi", feature = "bioformats_java"))] - let file = "czi/1xp53-01-AP1.czi"; - #[cfg(feature = "tiff")] - let file = "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif"; - #[cfg(feature = "tiffseq")] - let file = "tiffseq/20-Pos_005_005"; + let file = if cfg!(any(feature = "czi", feature = "bioformats_java")) { + "czi/1xp53-01-AP1.czi" + } else if cfg!(feature = "tiff") { + "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif" + } else if cfg!(feature = "tiffseq") { + "tiffseq/20-Pos_005_005" + } else { + unreachable!( + "need to enable one of these features: czi, bioformats_java, tiff, tiffseq" + ); + }; let path = std::env::current_dir()? .join("tests") .join("files") diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..54f4531 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,32 @@ +#[cfg(any(feature = "tiffwrite", feature = "movie"))] +pub(crate) mod progress { + use console::Term; + use indicatif::{ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle}; + use std::time::Duration; + + /// a progress bar with an ok style that when py::detach is used also works in jupyter + pub fn get_bar(count: Option, message: Option) -> ProgressBar { + let style = ProgressStyle::with_template( + "{spinner:.green} {msg} {percent}% [{wide_bar:.green/lime}] {pos:>7}/{len:7} [{elapsed}/{eta}, {rate}]", + ) + .expect("could not build progress bar style") + .with_key("rate", |state: &ProgressState, w: &mut dyn std::fmt::Write| { + if state.per_sec() < 1.0 { + write!(w, "{:>4.2} s", 1.0 / state.per_sec()).expect("could not write to progress bar"); + } else { + write!(w, "{:>4.2}/s", state.per_sec()).expect("could not write to progress bar"); + } + }) + .progress_chars("#>-"); + let bar = ProgressBar::with_draw_target( + count.map(|i| i as u64), + ProgressDrawTarget::term_like_with_hz(Box::new(Term::buffered_stdout()), 20), + ) + .with_style(style); + if let Some(message) = message { + bar.set_message(message); + } + bar.enable_steady_tick(Duration::from_millis(100)); + bar + } +} diff --git a/src/view.rs b/src/view.rs index 7e035b1..6a6551e 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1197,14 +1197,23 @@ impl View { let _ = out.insert(a); } } - let mut n = 1; - for (ax, size) in self.shape().to_hashmap().into_iter() { - if ((ax == Axis::C) || (ax == Axis::Z) || (ax == Axis::T)) - && let Some(Operation::Mean) = self.operations.get(&ax) - { - n *= size; - } - } + let n = if let Some((&ax, op)) = op_czt.first() + && *op == Operation::Mean + { + self.axes + .iter() + .zip(self.slice.iter()) + .find(|(a, _)| **a == ax) + .and_then(|(_, s)| match s { + SliceInfoElem::Slice { start, end, step } => { + end.map(|e| (((e - start).max(0) / step) as usize).max(1)) + } + _ => Some(1), + }) + .unwrap_or(1) + } else { + 1 + }; let array = if n == 1 { out.take().unwrap() } else { diff --git a/tests/test_ufuncs.py b/tests/test_ufuncs.py index 96bcfdc..15424c8 100644 --- a/tests/test_ufuncs.py +++ b/tests/test_ufuncs.py @@ -14,10 +14,11 @@ def array(): return np.random.randint(0, 255, (64, 64, 2, 3, 4), "uint16") -@pytest.fixture() +@pytest.fixture def image(array): with tempfile.TemporaryDirectory() as folder: file = Path(folder) / "tiff" / "test.tif" + file.parent.mkdir(parents=True) tiffwrite(file, array, "yxczt") with Imread(file, axes="yxczt") as im: yield im