- work around a bioformats issue
Cargo Test / Cargo Test (push) Successful in 2m25s
PyTest / pytest (3.10) (push) Successful in 1m18s
PyTest / pytest (3.12) (push) Successful in 56s
PyTest / pytest (3.14) (push) Successful in 57s

This commit is contained in:
w.pomp
2026-06-16 14:25:26 +02:00
parent 998b24e7af
commit b3078dd915
9 changed files with 56 additions and 50 deletions
+3 -2
View File
@@ -1,9 +1,10 @@
name: PyTest name: Cargo Test
on: [push, pull_request, workflow_call] on: [push, pull_request, workflow_call]
jobs: jobs:
pytest: cargo_test:
name: Cargo Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
+1
View File
@@ -9,3 +9,4 @@
*.tif *.tif
*.so *.so
__pycache__ __pycache__
.agentbridge/
+4 -4
View File
@@ -1,13 +1,13 @@
[package] [package]
name = "tiffwrite" name = "tiffwrite"
version = "2026.5.1" version = "2026.6.0"
edition = "2024" edition = "2024"
rust-version = "1.88.0" rust-version = "1.88.0"
authors = ["Wim Pomp <w.pomp@nki.nl>"] authors = ["Wim Pomp <w.pomp@nki.nl>"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
description = "Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel." description = "Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel."
homepage = "https://git.wimpomp.nl/wim/tiffwrite" homepage = "https://git.pomppervova.nl/wim/tiffwrite"
repository = "https://git.wimpomp.nl/wim/tiffwrite" repository = "https://git.pomppervova.nl/wim/tiffwrite"
documentation = "https://docs.rs/tiffwrite" documentation = "https://docs.rs/tiffwrite"
readme = "README.md" readme = "README.md"
keywords = ["bioformats", "tiff", "ndarray", "zstd", "fiji"] keywords = ["bioformats", "tiff", "ndarray", "zstd", "fiji"]
@@ -28,7 +28,7 @@ lazy_static = "1"
ndarray = "0.17" ndarray = "0.17"
num = "0.4" num = "0.4"
numpy = { version = "0.28", optional = true } 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 } pyo3-stub-gen = { version = "0.22", optional = true }
rayon = "1" rayon = "1"
thiserror = "2" thiserror = "2"
+3 -3
View File
@@ -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) [![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.wimpomp.nl/wim/tiffwrite/actions/workflows/cargo_test.yml/badge.svg)](https://git.wimpomp.nl/wim/tiffwrite/actions?workflow=cargo_test.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 # Tiffwrite
Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel using Rust. Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel using Rust.
@@ -22,7 +22,7 @@ makes that very hard anyway.
or or
- install [rust](https://rustup.rs/) - 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 ## Usage
### Write an image stack ### Write an image stack
+2 -3
View File
@@ -57,7 +57,7 @@ class IJTiffFile(rs.IJTiffFile):
def __init__( def __init__(
self, self,
path: str | Path, path: str | Path, # noqa
*, *,
dtype: DTypeLike = "uint16", dtype: DTypeLike = "uint16",
colors: Sequence[str] = None, colors: Sequence[str] = None,
@@ -79,7 +79,6 @@ class IJTiffFile(rs.IJTiffFile):
if colors is not None and colormap is not None: if colors is not None and colormap is not None:
warn("Cannot have colors and colormap simultaneously.", TiffWriteWarning, stacklevel=2) warn("Cannot have colors and colormap simultaneously.", TiffWriteWarning, stacklevel=2)
self.path = Path(path)
self.dtype = np.dtype(dtype) self.dtype = np.dtype(dtype)
if compression is not None: if compression is not None:
if isinstance(compression, tuple): if isinstance(compression, tuple):
@@ -140,7 +139,7 @@ class IJTiffFile(rs.IJTiffFile):
case np.float64: case np.float64:
self.save_f64(frame, c, z, t) self.save_f64(frame, c, z, t)
case _: case _:
raise TypeError(f"Cannot save type {self.dtype}") raise TypeError(f"Cannot save type {self.dtype}") # noqa
if extratags is not None: if extratags is not None:
for extra_tag in extratags: for extra_tag in extratags:
self.append_extra_tag(extra_tag, (c, z, t)) self.append_extra_tag(extra_tag, (c, z, t))
+10 -7
View File
@@ -2,6 +2,7 @@
# ruff: noqa: E501, F401, F403, F405 # ruff: noqa: E501, F401, F403, F405
import builtins import builtins
import pathlib
import typing import typing
import numpy import numpy
@@ -13,6 +14,8 @@ __all__ = [
] ]
class IJTiffFile: class IJTiffFile:
@property
def path(self) -> typing.Optional[pathlib.Path]: ...
@property @property
def colors(self) -> typing.Optional[builtins.list[builtins.list[builtins.int]]]: ... def colors(self) -> typing.Optional[builtins.list[builtins.list[builtins.int]]]: ...
@colors.setter @colors.setter
@@ -49,16 +52,16 @@ class IJTiffFile:
self, czt: typing.Optional[tuple[builtins.int, builtins.int, builtins.int]] = None self, czt: typing.Optional[tuple[builtins.int, builtins.int, builtins.int]] = None
) -> builtins.list[Tag]: ... ) -> builtins.list[Tag]: ...
def close(self) -> None: ... 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_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_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_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: class Tag:
@staticmethod @staticmethod
+1 -1
View File
@@ -34,7 +34,7 @@ tiffwrite_generate_stub = "tiffwrite:tiffwrite_generate_stub"
[tool.maturin] [tool.maturin]
python-source = "py" python-source = "py"
features = ["pyo3/extension-module", "python"] features = ["python"]
module-name = "tiffwrite.tiffwrite_rs" module-name = "tiffwrite.tiffwrite_rs"
[tool.isort] [tool.isort]
+21 -6
View File
@@ -16,7 +16,7 @@ use std::collections::HashSet;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::{BufWriter, Read, Seek, SeekFrom, Write}; use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::available_parallelism; use std::thread::available_parallelism;
use std::{cmp::Ordering, collections::HashMap}; use std::{cmp::Ordering, collections::HashMap};
@@ -356,9 +356,6 @@ impl Tag {
self.offset + (TAG_SIZE - OFFSET_SIZE) as u64, self.offset + (TAG_SIZE - OFFSET_SIZE) as u64,
))?; ))?;
file.write_all(&offset.to_le_bytes())?; file.write_all(&offset.to_le_bytes())?;
if file.stream_position()? % 2 == 1 {
file.write_all(&[0])?;
}
} }
Ok(()) Ok(())
} }
@@ -560,9 +557,20 @@ impl Frame {
let bytes_per_sample = (T::BITS_PER_SAMPLE / 8) as usize; let bytes_per_sample = (T::BITS_PER_SAMPLE / 8) as usize;
encoder.include_contentsize(true)?; encoder.include_contentsize(true)?;
encoder.set_pledged_src_size(Some((bytes_per_sample * tile_width * tile_length) as u64))?; 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 = Frame::encode(encoder, frame, slice, tile_width, tile_length)?;
encoder.finish()?; 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) Ok(dest)
} }
} }
@@ -669,6 +677,7 @@ pub struct IJTiffFile {
frames: HashMap<(usize, usize, usize), Frame>, frames: HashMap<(usize, usize, usize), Frame>,
hashes: Arc<Mutex<HashMap<u64, u64>>>, hashes: Arc<Mutex<HashMap<u64, u64>>>,
threads: HashMap<(usize, usize, usize), JoinHandle<Result<Frame, Error>>>, threads: HashMap<(usize, usize, usize), JoinHandle<Result<Frame, Error>>>,
path: PathBuf,
/// zstd: -7 ..= 22 /// zstd: -7 ..= 22
pub compression: Compression, pub compression: Compression,
pub colors: Colors, pub colors: Colors,
@@ -695,6 +704,7 @@ impl IJTiffFile {
/// create new tifffile from path, use its save() method to save frames /// create new tifffile from path, use its save() method to save frames
/// the file is finalized when it goes out of scope /// the file is finalized when it goes out of scope
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, Error> { pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let path = path.as_ref();
let mut file = OpenOptions::new() let mut file = OpenOptions::new()
.create(true) .create(true)
.truncate(true) .truncate(true)
@@ -711,6 +721,7 @@ impl IJTiffFile {
frames: HashMap::new(), frames: HashMap::new(),
hashes: Arc::new(Mutex::new(HashMap::new())), hashes: Arc::new(Mutex::new(HashMap::new())),
threads: HashMap::new(), threads: HashMap::new(),
path: path.to_owned(),
compression: Compression::Zstd(DEFAULT_COMPRESSION_LEVEL), compression: Compression::Zstd(DEFAULT_COMPRESSION_LEVEL),
colors: Colors::None, colors: Colors::None,
comment: None, comment: None,
@@ -854,6 +865,7 @@ impl IJTiffFile {
} }
fn hash_check(f: &mut BufWriter<File>, bytes: &Vec<u8>, offset: u64) -> Result<bool, Error> { fn hash_check(f: &mut BufWriter<File>, bytes: &Vec<u8>, offset: u64) -> Result<bool, Error> {
f.flush()?;
let current_offset = f.stream_position()?; let current_offset = f.stream_position()?;
f.seek(SeekFrom::Start(offset))?; f.seek(SeekFrom::Start(offset))?;
let mut buffer = vec![0; bytes.len()]; let mut buffer = vec![0; bytes.len()];
@@ -1054,7 +1066,10 @@ impl IJTiffFile {
warn.push((frame_number, 0)); warn.push((frame_number, 0));
} }
if !warn.is_empty() { 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 { for (frame_number, channel) in &warn {
let (c, z, t) = self.get_czt(*frame_number, *channel, c_size, z_size); let (c, z, t) = self.get_czt(*frame_number, *channel, c_size, z_size);
println!("c: {c}, z: {z}, t: {t}") println!("c: {c}, z: {z}, t: {t}")
+11 -24
View File
@@ -184,6 +184,11 @@ impl PyIJTiffFile {
}) })
} }
#[getter]
fn get_path(&self) -> PyResult<Option<PathBuf>> {
Ok(self.ijtifffile.as_ref().map(|f| f.path.clone()))
}
/// set zstd compression level: -7 ..= 22 /// set zstd compression level: -7 ..= 22
fn set_compression(&mut self, compression: i32, level: i32) -> PyResult<()> { fn set_compression(&mut self, compression: i32, level: i32) -> PyResult<()> {
let c = match compression { let c = match compression {
@@ -240,11 +245,7 @@ impl PyIJTiffFile {
#[getter] #[getter]
fn get_px_size(&self) -> PyResult<Option<f64>> { fn get_px_size(&self) -> PyResult<Option<f64>> {
if let Some(ijtifffile) = &self.ijtifffile { Ok(self.ijtifffile.as_ref().and_then(|f| f.px_size))
Ok(ijtifffile.px_size)
} else {
Ok(None)
}
} }
#[setter] #[setter]
@@ -257,11 +258,7 @@ impl PyIJTiffFile {
#[getter] #[getter]
fn get_delta_z(&self) -> PyResult<Option<f64>> { fn get_delta_z(&self) -> PyResult<Option<f64>> {
if let Some(ijtifffile) = &self.ijtifffile { Ok(self.ijtifffile.as_ref().and_then(|f| f.delta_z))
Ok(ijtifffile.delta_z)
} else {
Ok(None)
}
} }
#[setter] #[setter]
@@ -274,11 +271,7 @@ impl PyIJTiffFile {
#[getter] #[getter]
fn get_time_interval(&self) -> PyResult<Option<f64>> { fn get_time_interval(&self) -> PyResult<Option<f64>> {
if let Some(ijtifffile) = &self.ijtifffile { Ok(self.ijtifffile.as_ref().and_then(|f| f.time_interval))
Ok(ijtifffile.time_interval)
} else {
Ok(None)
}
} }
#[setter] #[setter]
@@ -291,11 +284,7 @@ impl PyIJTiffFile {
#[getter] #[getter]
fn get_comment(&self) -> PyResult<Option<String>> { fn get_comment(&self) -> PyResult<Option<String>> {
if let Some(ijtifffile) = &self.ijtifffile { Ok(self.ijtifffile.as_ref().and_then(|f| f.comment.clone()))
Ok(ijtifffile.comment.clone())
} else {
Ok(None)
}
} }
#[setter] #[setter]
@@ -308,10 +297,8 @@ impl PyIJTiffFile {
#[pyo3(signature = (tag, czt=None))] #[pyo3(signature = (tag, czt=None))]
fn append_extra_tag(&mut self, tag: PyTag, czt: Option<(usize, usize, usize)>) { fn append_extra_tag(&mut self, tag: PyTag, czt: Option<(usize, usize, usize)>) {
if let Some(ijtifffile) = self.ijtifffile.as_mut() if let Some(ijtifffile) = self.ijtifffile.as_mut() {
&& let Some(extra_tags) = ijtifffile.extra_tags.get_mut(&czt) ijtifffile.extra_tags.entry(czt).or_default().push(tag.tag);
{
extra_tags.push(tag.tag)
} }
} }