diff --git a/.github/workflows/cargo_test.yml b/.github/workflows/cargo_test.yml index 46d43a6..cd8bd77 100644 --- a/.github/workflows/cargo_test.yml +++ b/.github/workflows/cargo_test.yml @@ -1,9 +1,10 @@ -name: PyTest +name: Cargo Test on: [push, pull_request, workflow_call] jobs: - pytest: + cargo_test: + name: Cargo Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 8df4013..4d5760c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ *.tif *.so __pycache__ +.agentbridge/ diff --git a/Cargo.toml b/Cargo.toml index d1fc5a9..91ac424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "tiffwrite" -version = "2026.5.1" +version = "2026.6.0" edition = "2024" rust-version = "1.88.0" authors = ["Wim Pomp "] license = "MIT OR Apache-2.0" description = "Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel." -homepage = "https://git.wimpomp.nl/wim/tiffwrite" -repository = "https://git.wimpomp.nl/wim/tiffwrite" +homepage = "https://git.pomppervova.nl/wim/tiffwrite" +repository = "https://git.pomppervova.nl/wim/tiffwrite" documentation = "https://docs.rs/tiffwrite" readme = "README.md" keywords = ["bioformats", "tiff", "ndarray", "zstd", "fiji"] @@ -28,7 +28,7 @@ lazy_static = "1" ndarray = "0.17" num = "0.4" numpy = { version = "0.28", optional = true } -pyo3 = { version = "0.28", features = ["abi3-py310", "eyre", "generate-import-lib", "multiple-pymethods"], optional = true } +pyo3 = { version = "0.28", features = ["abi3-py310", "eyre", "multiple-pymethods"], optional = true } pyo3-stub-gen = { version = "0.22", optional = true } rayon = "1" thiserror = "2" diff --git a/README.md b/README.md index 451c351..8a535f5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![pytest](https://git.wimpomp.nl/wim/tiffwrite/actions/workflows/pytest.yml/badge.svg)](https://git.wimpomp.nl/wim/tiffwrite/actions?workflow=pytest.yml) -[![cargo test](https://git.wimpomp.nl/wim/tiffwrite/actions/workflows/cargo_test.yml/badge.svg)](https://git.wimpomp.nl/wim/tiffwrite/actions?workflow=cargo_test.yml) +[![pytest](https://git.pomppervova.nl/wim/tiffwrite/actions/workflows/pytest.yml/badge.svg)](https://git.pomppervova.nl/wim/tiffwrite/actions?workflow=pytest.yml) +[![cargo test](https://git.pomppervova.nl/wim/tiffwrite/actions/workflows/cargo_test.yml/badge.svg)](https://git.pomppervova.nl/wim/tiffwrite/actions?workflow=cargo_test.yml) # Tiffwrite Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel using Rust. @@ -22,7 +22,7 @@ makes that very hard anyway. or - install [rust](https://rustup.rs/) -- ``` pip install tiffwrite@git+https://git.wimpomp.nl/wim/tiffwrite ``` +- ``` pip install tiffwrite@git+https://git.pomppervova.nl/wim/tiffwrite ``` ## Usage ### Write an image stack diff --git a/py/tiffwrite/__init__.py b/py/tiffwrite/__init__.py index fa4c804..05b4339 100644 --- a/py/tiffwrite/__init__.py +++ b/py/tiffwrite/__init__.py @@ -57,7 +57,7 @@ class IJTiffFile(rs.IJTiffFile): def __init__( self, - path: str | Path, + path: str | Path, # noqa *, dtype: DTypeLike = "uint16", colors: Sequence[str] = None, @@ -79,7 +79,6 @@ class IJTiffFile(rs.IJTiffFile): if colors is not None and colormap is not None: warn("Cannot have colors and colormap simultaneously.", TiffWriteWarning, stacklevel=2) - self.path = Path(path) self.dtype = np.dtype(dtype) if compression is not None: if isinstance(compression, tuple): @@ -140,7 +139,7 @@ class IJTiffFile(rs.IJTiffFile): case np.float64: self.save_f64(frame, c, z, t) case _: - raise TypeError(f"Cannot save type {self.dtype}") + raise TypeError(f"Cannot save type {self.dtype}") # noqa if extratags is not None: for extra_tag in extratags: self.append_extra_tag(extra_tag, (c, z, t)) diff --git a/py/tiffwrite/tiffwrite_rs.pyi b/py/tiffwrite/tiffwrite_rs.pyi index 56376a6..264a667 100644 --- a/py/tiffwrite/tiffwrite_rs.pyi +++ b/py/tiffwrite/tiffwrite_rs.pyi @@ -2,6 +2,7 @@ # ruff: noqa: E501, F401, F403, F405 import builtins +import pathlib import typing import numpy @@ -13,6 +14,8 @@ __all__ = [ ] class IJTiffFile: + @property + def path(self) -> typing.Optional[pathlib.Path]: ... @property def colors(self) -> typing.Optional[builtins.list[builtins.list[builtins.int]]]: ... @colors.setter @@ -49,16 +52,16 @@ class IJTiffFile: self, czt: typing.Optional[tuple[builtins.int, builtins.int, builtins.int]] = None ) -> builtins.list[Tag]: ... def close(self) -> None: ... - def save_f64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_u32(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_u16(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_i64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... def save_f32(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_f64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_i64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_u16(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_u32(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... def save_u8(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_i32(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_u64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... - def save_i16(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... def save_i8(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_u64(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_i32(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... + def save_i16(self, frame: numpy.typing.ArrayLike, c: builtins.int, t: builtins.int, z: builtins.int) -> None: ... class Tag: @staticmethod diff --git a/pyproject.toml b/pyproject.toml index 5474fc2..d0b0a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ tiffwrite_generate_stub = "tiffwrite:tiffwrite_generate_stub" [tool.maturin] python-source = "py" -features = ["pyo3/extension-module", "python"] +features = ["python"] module-name = "tiffwrite.tiffwrite_rs" [tool.isort] diff --git a/src/lib.rs b/src/lib.rs index 1156a26..3d7cbb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ use std::collections::HashSet; use std::fs::{File, OpenOptions}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::thread::available_parallelism; use std::{cmp::Ordering, collections::HashMap}; @@ -356,9 +356,6 @@ impl Tag { self.offset + (TAG_SIZE - OFFSET_SIZE) as u64, ))?; file.write_all(&offset.to_le_bytes())?; - if file.stream_position()? % 2 == 1 { - file.write_all(&[0])?; - } } Ok(()) } @@ -560,9 +557,20 @@ impl Frame { let bytes_per_sample = (T::BITS_PER_SAMPLE / 8) as usize; encoder.include_contentsize(true)?; encoder.set_pledged_src_size(Some((bytes_per_sample * tile_width * tile_length) as u64))?; - encoder.include_checksum(false)?; + encoder.include_checksum(true)?; encoder = Frame::encode(encoder, frame, slice, tile_width, tile_length)?; encoder.finish()?; + // work around https://github.com/ome/bioformats/issues/4442 + if dest.len() == frame.len() { + dest.clear(); + let mut encoder = Encoder::new(&mut dest, compression_level)?; + encoder.include_contentsize(true)?; + encoder + .set_pledged_src_size(Some((bytes_per_sample * tile_width * tile_length) as u64))?; + encoder.include_checksum(false)?; + encoder = Frame::encode(encoder, frame, slice, tile_width, tile_length)?; + encoder.finish()?; + } Ok(dest) } } @@ -669,6 +677,7 @@ pub struct IJTiffFile { frames: HashMap<(usize, usize, usize), Frame>, hashes: Arc>>, threads: HashMap<(usize, usize, usize), JoinHandle>>, + path: PathBuf, /// zstd: -7 ..= 22 pub compression: Compression, pub colors: Colors, @@ -695,6 +704,7 @@ impl IJTiffFile { /// create new tifffile from path, use its save() method to save frames /// the file is finalized when it goes out of scope pub fn new>(path: P) -> Result { + let path = path.as_ref(); let mut file = OpenOptions::new() .create(true) .truncate(true) @@ -711,6 +721,7 @@ impl IJTiffFile { frames: HashMap::new(), hashes: Arc::new(Mutex::new(HashMap::new())), threads: HashMap::new(), + path: path.to_owned(), compression: Compression::Zstd(DEFAULT_COMPRESSION_LEVEL), colors: Colors::None, comment: None, @@ -854,6 +865,7 @@ impl IJTiffFile { } fn hash_check(f: &mut BufWriter, bytes: &Vec, offset: u64) -> Result { + f.flush()?; let current_offset = f.stream_position()?; f.seek(SeekFrom::Start(offset))?; let mut buffer = vec![0; bytes.len()]; @@ -1054,7 +1066,10 @@ impl IJTiffFile { warn.push((frame_number, 0)); } if !warn.is_empty() { - println!("The following frames were not added to the tif file:"); + println!( + "The following frames were not added to the tif file: {}", + self.path.display() + ); for (frame_number, channel) in &warn { let (c, z, t) = self.get_czt(*frame_number, *channel, c_size, z_size); println!("c: {c}, z: {z}, t: {t}") diff --git a/src/py.rs b/src/py.rs index 3b86a15..0e7e17d 100644 --- a/src/py.rs +++ b/src/py.rs @@ -184,6 +184,11 @@ impl PyIJTiffFile { }) } + #[getter] + fn get_path(&self) -> PyResult> { + Ok(self.ijtifffile.as_ref().map(|f| f.path.clone())) + } + /// set zstd compression level: -7 ..= 22 fn set_compression(&mut self, compression: i32, level: i32) -> PyResult<()> { let c = match compression { @@ -240,11 +245,7 @@ impl PyIJTiffFile { #[getter] fn get_px_size(&self) -> PyResult> { - if let Some(ijtifffile) = &self.ijtifffile { - Ok(ijtifffile.px_size) - } else { - Ok(None) - } + Ok(self.ijtifffile.as_ref().and_then(|f| f.px_size)) } #[setter] @@ -257,11 +258,7 @@ impl PyIJTiffFile { #[getter] fn get_delta_z(&self) -> PyResult> { - if let Some(ijtifffile) = &self.ijtifffile { - Ok(ijtifffile.delta_z) - } else { - Ok(None) - } + Ok(self.ijtifffile.as_ref().and_then(|f| f.delta_z)) } #[setter] @@ -274,11 +271,7 @@ impl PyIJTiffFile { #[getter] fn get_time_interval(&self) -> PyResult> { - if let Some(ijtifffile) = &self.ijtifffile { - Ok(ijtifffile.time_interval) - } else { - Ok(None) - } + Ok(self.ijtifffile.as_ref().and_then(|f| f.time_interval)) } #[setter] @@ -291,11 +284,7 @@ impl PyIJTiffFile { #[getter] fn get_comment(&self) -> PyResult> { - if let Some(ijtifffile) = &self.ijtifffile { - Ok(ijtifffile.comment.clone()) - } else { - Ok(None) - } + Ok(self.ijtifffile.as_ref().and_then(|f| f.comment.clone())) } #[setter] @@ -308,10 +297,8 @@ impl PyIJTiffFile { #[pyo3(signature = (tag, czt=None))] fn append_extra_tag(&mut self, tag: PyTag, czt: Option<(usize, usize, usize)>) { - if let Some(ijtifffile) = self.ijtifffile.as_mut() - && let Some(extra_tags) = ijtifffile.extra_tags.get_mut(&czt) - { - extra_tags.push(tag.tag) + if let Some(ijtifffile) = self.ijtifffile.as_mut() { + ijtifffile.extra_tags.entry(czt).or_default().push(tag.tag); } }