diff --git a/Cargo.toml b/Cargo.toml index cfd2e07..c2500d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "tiffwrite" -version = "2025.5.0" -edition = "2021" +version = "2025.8.0" +edition = "2024" +rust-version = "1.85.1" authors = ["Wim Pomp "] license = "MIT" description = "Write BioFormats/ImageJ compatible tiffs with zstd compression in parallel." @@ -18,15 +19,16 @@ crate-type = ["cdylib", "rlib"] [dependencies] anyhow = "1.0.98" chrono = "0.4.41" +css-color = "0.2.8" flate2 = "1.1.1" ndarray = "0.16.1" num = "0.4.3" rayon = "1.10.0" zstd = "0.13.3" -numpy = { version = "0.24.0", optional = true } +numpy = { version = "0.25.0", optional = true } [dependencies.pyo3] -version = "0.24.0" +version = "0.25.1" features = ["extension-module", "abi3-py310", "generate-import-lib", "anyhow", "multiple-pymethods"] optional = true diff --git a/py/tiffwrite/__init__.py b/py/tiffwrite/__init__.py index bcebd3b..598a884 100644 --- a/py/tiffwrite/__init__.py +++ b/py/tiffwrite/__init__.py @@ -9,13 +9,12 @@ from warnings import warn import colorcet import matplotlib import numpy as np -from matplotlib import colors as mpl_colors from numpy.typing import ArrayLike, DTypeLike from tqdm.auto import tqdm from . import tiffwrite_rs as rs # noqa -__all__ = ['IJTiffFile', 'IJTiffParallel', 'FrameInfo', 'Tag', 'tiffwrite'] +__all__ = ["IJTiffFile", "IJTiffParallel", "FrameInfo", "Tag", "tiffwrite"] try: __version__ = version(Path(__file__).parent.name) @@ -27,11 +26,11 @@ FrameInfo = tuple[ArrayLike, int, int, int] class Header: - """ deprecated """ + """deprecated""" class IFD(dict): - """ deprecated """ + """deprecated""" class TiffWriteWarning(UserWarning): @@ -39,31 +38,40 @@ class TiffWriteWarning(UserWarning): class IJTiffFile(rs.IJTiffFile): - """ Writes a tiff file in a format that the BioFormats reader in Fiji understands. - Zstd compression is done in parallel using Rust. - path: path to the new tiff file - dtype: datatype to use when saving to tiff - colors: a tuple with a color per channel, chosen from matplotlib.colors, html colors are also possible - colormap: name of a colormap from colorcet - pxsize: pixel size in um - deltaz: z slice interval in um - timeinterval: time between frames in seconds - compression: ('zstd', level) for zstd with compression level: -7 to 22, 'deflate' for deflate compresion - comment: comment to be saved in tif - extratags: other tags to be saved, example: (Tag.ascii(315, 'John Doe'), Tag.bytes(4567, [400, 500]) - or (Tag.ascii(33432, 'Made by me'),). + """Writes a tiff file in a format that the BioFormats reader in Fiji understands. + Zstd compression is done in parallel using Rust. + path: path to the new tiff file + dtype: datatype to use when saving to tiff + colors: a tuple with a color per channel, chosen from matplotlib.colors, html colors are also possible + colormap: name of a colormap from colorcet + pxsize: pixel size in um + deltaz: z slice interval in um + timeinterval: time between frames in seconds + compression: ('zstd', level) for zstd with compression level: -7 to 22, 'deflate' for deflate compresion + comment: comment to be saved in tif + extratags: other tags to be saved, example: (Tag.ascii(315, 'John Doe'), Tag.bytes(4567, [400, 500]) + or (Tag.ascii(33432, 'Made by me'),). """ + def __new__(cls, path: str | Path, *args, **kwargs) -> IJTiffFile: return super().__new__(cls, str(path)) - def __init__(self, path: str | Path, *, dtype: DTypeLike = 'uint16', - colors: Sequence[str] = None, colormap: str = None, pxsize: float = None, - deltaz: float = None, timeinterval: float = None, - compression: int | str | tuple[int, int] | tuple[str, int] = None, comment: str = None, - extratags: Sequence[Tag] = None) -> None: - + def __init__( + self, + path: str | Path, + *, + dtype: DTypeLike = "uint16", + colors: Sequence[str] = None, + colormap: str = None, + pxsize: float = None, + deltaz: float = None, + timeinterval: float = None, + compression: int | str | tuple[int, int] | tuple[str, int] = None, + comment: str = None, + extratags: Sequence[Tag] = None, + ) -> None: def get_codec(idx: int | str): - codecs = {'z': 50000, 'd': 8, 8: 8, 50000: 50000} + codecs = {"z": 50000, "d": 8, 8: 8, 50000: 50000} if isinstance(idx, str): return codecs.get(idx[0].lower(), 50000) else: @@ -78,7 +86,7 @@ class IJTiffFile(rs.IJTiffFile): compression = get_codec(compression), 22 self.set_compression(*compression) if colors is not None: - self.colors = np.array([get_color(color) for color in colors]) + self.colors = [str(color) for color in colors] if colormap is not None: self.colormap = get_colormap(colormap) if pxsize is not None: @@ -93,10 +101,13 @@ class IJTiffFile(rs.IJTiffFile): for extra_tag in extratags: self.append_extra_tag(extra_tag, None) if self.dtype.itemsize == 1 and colors is not None: - warn('Fiji will not interpret colors saved in an (u)int8 tif, save as (u)int16 instead.', - TiffWriteWarning, stacklevel=2) + warn( + "Fiji will not interpret colors saved in an (u)int8 tif, save as (u)int16 instead.", + TiffWriteWarning, + stacklevel=2, + ) 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) def __enter__(self) -> IJTiffFile: return self @@ -105,7 +116,7 @@ class IJTiffFile(rs.IJTiffFile): self.close() def save(self, frame: ArrayLike, c: int, z: int, t: int, extratags: Sequence[Tag] = None) -> None: - """ save a 2d numpy array to the tiff at channel=c, slice=z, time=t, with optional extra tif tags """ + """save a 2d numpy array to the tiff at channel=c, slice=z, time=t, with optional extra tif tags""" frame = np.asarray(frame).astype(self.dtype) match self.dtype: case np.uint8: @@ -129,51 +140,67 @@ 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}") if extratags is not None: for extra_tag in extratags: self.append_extra_tag(extra_tag, (c, z, t)) def get_colormap(colormap: str) -> np.ndarray: - if hasattr(colorcet, colormap.rstrip('_r')): - cm = np.array([[int(''.join(i), 16) for i in zip(*[iter(s[1:])] * 2)] - for s in getattr(colorcet, colormap.rstrip('_r'))]).astype('uint8') - if colormap.endswith('_r'): + if hasattr(colorcet, colormap.rstrip("_r")): + cm = np.array( + [[int("".join(i), 16) for i in zip(*[iter(s[1:])] * 2)] for s in getattr(colorcet, colormap.rstrip("_r"))] + ).astype("uint8") + if colormap.endswith("_r"): cm = cm[::-1] - if colormap.startswith('glasbey') or colormap.endswith('glasbey'): + if colormap.startswith("glasbey") or colormap.endswith("glasbey"): cm[0] = 255, 255, 255 cm[-1] = 0, 0, 0 else: cmap = matplotlib.colormaps.get_cmap(colormap) if cmap.N < 256: - cm = (255 * np.vstack(((1, 1, 1), - matplotlib.cm.ScalarMappable(matplotlib.colors.Normalize(1, 254), - cmap).to_rgba(np.arange(1, 254))[:, :3], - (0, 0, 0)))).astype('uint8') + cm = ( + 255 + * np.vstack( + ( + (1, 1, 1), + matplotlib.cm.ScalarMappable(matplotlib.colors.Normalize(1, 254), cmap).to_rgba( + np.arange(1, 254) + )[:, :3], + (0, 0, 0), + ) + ) + ).astype("uint8") else: - cm = (255 * matplotlib.cm.ScalarMappable(matplotlib.colors.Normalize(0, 255), cmap) - .to_rgba(np.arange(256))[:, :3]).astype('uint8') + cm = ( + 255 + * matplotlib.cm.ScalarMappable(matplotlib.colors.Normalize(0, 255), cmap).to_rgba(np.arange(256))[ + :, :3 + ] + ).astype("uint8") return cm -def get_color(color: str) -> np.ndarray: - return np.array([int(''.join(i), 16) for i in zip(*[iter(mpl_colors.to_hex(color)[1:])] * 2)]).astype('uint8') - - -def tiffwrite(file: str | Path, data: np.ndarray, axes: str = 'TZCYX', dtype: DTypeLike = None, bar: bool = False, - *args: Any, **kwargs: Any) -> None: - """ file: string; filename of the new tiff file - data: 2 to 5D numpy array - axes: string; order of dimensions in data, default: TZCYX for 5D, ZCYX for 4D, CYX for 3D, YX for 2D data - dtype: string; datatype to use when saving to tiff - bar: bool; whether to show a progress bar - other args: see IJTiffFile +def tiffwrite( + file: str | Path, + data: np.ndarray, + axes: str = "TZCYX", + dtype: DTypeLike = None, + bar: bool = False, + *args: Any, + **kwargs: Any, +) -> None: + """file: string; filename of the new tiff file + data: 2 to 5D numpy array + axes: string; order of dimensions in data, default: TZCYX for 5D, ZCYX for 4D, CYX for 3D, YX for 2D data + dtype: string; datatype to use when saving to tiff + bar: bool; whether to show a progress bar + other args: see IJTiffFile """ - axes = axes[-np.ndim(data):].upper() - if not axes == 'CZTYX': - axes_shuffle = [axes.find(i) for i in 'CZTYX'] + axes = axes[-np.ndim(data) :].upper() + if not axes == "CZTYX": + axes_shuffle = [axes.find(i) for i in "CZTYX"] axes_add = [i for i, j in enumerate(axes_shuffle) if j < 0] axes_shuffle = [i for i in axes_shuffle if i >= 0] data = np.transpose(data, axes_shuffle) @@ -182,8 +209,12 @@ def tiffwrite(file: str | Path, data: np.ndarray, axes: str = 'TZCYX', dtype: DT shape = data.shape[:3] with IJTiffFile(file, dtype=data.dtype if dtype is None else dtype, *args, **kwargs) as f: - for n in tqdm(product(*[range(i) for i in shape]), total=np.prod(shape), # type: ignore - desc='Saving tiff', disable=not bar): + for n in tqdm( + product(*[range(i) for i in shape]), + total=np.prod(shape), # type: ignore + desc="Saving tiff", + disable=not bar, + ): f.save(data[n], *n) @@ -193,7 +224,6 @@ try: from parfor import ParPool, Task - class Pool(ParPool): def __init__(self, ijtifffile: IJTiffFile, parallel: Callable[[Any], Sequence[FrameInfo]]): self.ijtifffile = ijtifffile @@ -211,14 +241,13 @@ try: super().close() self.ijtifffile.close() - class IJTiffParallel(metaclass=ABCMeta): - """ wraps IJTiffFile.save in a parallel pool, the method 'parallel' needs to be overloaded """ + """wraps IJTiffFile.save in a parallel pool, the method 'parallel' needs to be overloaded""" @abstractmethod def parallel(self, frame: Any) -> Sequence[FrameInfo]: - """ does something with frame in a parallel process, - and returns a sequence of frames and offsets to c, z and t to save in the tif """ + """does something with frame in a parallel process, + and returns a sequence of frames and offsets to c, z and t to save in the tif""" @wraps(IJTiffFile.__init__) def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/pyproject.toml b/pyproject.toml index 2eaf9e3..7fc3255 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,7 @@ module-name = "tiffwrite.tiffwrite_rs" [tool.isort] line_length = 119 + +[tool.ruff] +line-length = 119 +indent-width = 4 \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index f18921e..a7892c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,12 @@ #[cfg(feature = "python")] mod py; -use anyhow::Result; +use anyhow::{Result, anyhow}; use chrono::Utc; +use css_color::Srgb; use flate2::write::ZlibEncoder; -use ndarray::{s, ArcArray2, AsArray, Ix2}; -use num::{traits::ToBytes, Complex, FromPrimitive, Rational32}; +use ndarray::{ArcArray2, AsArray, Ix2, s}; +use num::{Complex, FromPrimitive, Rational32, traits::ToBytes}; use rayon::prelude::*; use std::collections::HashSet; use std::fs::{File, OpenOptions}; @@ -16,10 +17,10 @@ use std::time::Duration; use std::{cmp::Ordering, collections::HashMap}; use std::{ thread, - thread::{available_parallelism, sleep, JoinHandle}, + thread::{JoinHandle, available_parallelism, sleep}, }; use zstd::zstd_safe::CompressionLevel; -use zstd::{stream::Encoder, DEFAULT_COMPRESSION_LEVEL}; +use zstd::{DEFAULT_COMPRESSION_LEVEL, stream::Encoder}; const TAG_SIZE: usize = 20; const OFFSET_SIZE: usize = 8; @@ -707,6 +708,38 @@ impl IJTiffFile { self.compression = compression; } + /// set colors from css color names and #C01085 + pub fn set_colors(&mut self, colors: &[String]) -> Result<()> { + self.colors = Colors::Colors( + colors + .iter() + .map(|c| { + let lc = c.to_lowercase(); + let c = match lc.as_str() { + "r" => "#ff0000", + "g" => "#008000", + "b" => "#0000ff", + "c" => "#00bfbf", + "m" => "#bf00bf", + "y" => "#bfbf00", + "k" => "#000000", + "w" => "#ffffff", + _ => c, + }; + match c.parse::() { + Ok(c) => Ok(vec![ + (255.0 * c.red).round() as u8, + (255.0 * c.green).round() as u8, + (255.0 * c.blue).round() as u8, + ]), + Err(_) => Err(anyhow!("could not parse color: {}", c)), + } + }) + .collect::>>()?, + ); + Ok(()) + } + /// to be saved in description tag (270) pub fn description(&self, c_size: usize, z_size: usize, t_size: usize) -> String { let mut desc: String = String::from("ImageJ=1.11a"); diff --git a/src/py.rs b/src/py.rs index 0da86ae..5c75851 100644 --- a/src/py.rs +++ b/src/py.rs @@ -183,7 +183,7 @@ impl PyIJTiffFile { return Err(PyValueError::new_err(format!( "Unknown compression {}", compression - ))) + ))); } }; if let Some(ref mut ijtifffile) = self.ijtifffile { @@ -203,14 +203,9 @@ impl PyIJTiffFile { } #[setter] - fn set_colors(&mut self, colors: PyReadonlyArray2) -> PyResult<()> { + fn set_colors(&mut self, colors: Vec) -> PyResult<()> { if let Some(ijtifffile) = &mut self.ijtifffile { - let a = colors.to_owned_array(); - ijtifffile.colors = Colors::Colors( - (0..a.shape()[0]) - .map(|i| Vec::from(a.slice(s![i, ..]).as_slice().unwrap())) - .collect(), - ); + ijtifffile.set_colors(&colors)?; } Ok(()) } diff --git a/tests/test_multiple.py b/tests/test_multiple.py index 936c4bc..514c704 100644 --- a/tests/test_multiple.py +++ b/tests/test_multiple.py @@ -10,7 +10,7 @@ from tiffwrite import IJTiffFile def test_mult(tmp_path: Path) -> None: shape = (2, 3, 5) - paths = [tmp_path / f'test{i}.tif' for i in range(6)] + paths = [tmp_path / f"test{i}.tif" for i in range(6)] with ExitStack() as stack: tifs = [stack.enter_context(IJTiffFile(path)) for path in paths] # noqa for c, z, t in tqdm(product(range(shape[0]), range(shape[1]), range(shape[2])), total=np.prod(shape)): # noqa diff --git a/tests/test_single.py b/tests/test_single.py index 0d88e69..e6fa626 100644 --- a/tests/test_single.py +++ b/tests/test_single.py @@ -7,10 +7,11 @@ from tifffile import imread from tiffwrite import IJTiffFile -@pytest.mark.parametrize('dtype', ('uint8', 'uint16', 'uint32', 'uint64', - 'int8', 'int16', 'int32', 'int64', 'float32', 'float64')) +@pytest.mark.parametrize( + "dtype", ("uint8", "uint16", "uint32", "uint64", "int8", "int16", "int32", "int64", "float32", "float64") +) def test_single(tmp_path: Path, dtype) -> None: - with IJTiffFile(tmp_path / 'test.tif', dtype=dtype, pxsize=0.1, deltaz=0.5, timeinterval=6.5) as tif: + with IJTiffFile(tmp_path / "test.tif", dtype=dtype, pxsize=0.1, deltaz=0.5, timeinterval=6.5) as tif: a0, b0 = np.meshgrid(range(100), range(100)) a0[::2, :] = 0 b0[:, ::2] = 1 @@ -23,6 +24,6 @@ def test_single(tmp_path: Path, dtype) -> None: tif.save(a1, 0, 0, 1) tif.save(b1, 1, 0, 1) - t = imread(tmp_path / 'test.tif') + t = imread(tmp_path / "test.tif") assert t.dtype == np.dtype(dtype), "data type does not match" assert np.all(np.stack(((a0, b0), (a1, b1))) == t), "data does not match"