- use a fork of the tiff crate to open RGBPalette tiffs

This commit is contained in:
Wim Pomp committed 2026-10-03 19:35:01 +02:00
1 parent aef3a2d502
commit e55152c55d
41 files changed
+728 -147

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
[patch.crates-io]
tiff = { git = "https://git.pomppervova.nl/wim/image-tiff.git" }
+3 -3
View File
@@ -72,11 +72,11 @@ docs/_build/
# Pyenv # Pyenv
.python-version .python-version
/tests/files/* /ndbioimage/tests/files/*
AGENTS.md AGENTS.md
.agentbridge .agentbridge
.agent-work .agent-work
py/ndbioimage/jassets /ndbioimage/py/ndbioimage/jassets
py/ndbioimage/deps /ndbioimage/py/ndbioimage/deps
/build_wheels.sh /build_wheels.sh
+6
View File
@@ -0,0 +1,6 @@
[submodule "image-tiff"]
path = image-tiff
url = https://git.pomppervova.nl/wim/image-tiff.git
[submodule "image-registration"]
path = image-registration
url = https://git.pomppervova.nl/wim/image-registration.git
+11 -71
View File
@@ -1,7 +1,11 @@
[package] [workspace]
name = "ndbioimage" resolver = "3"
members = ["ndbioimage", "image-tiff", "image-registration"]
[workspace.package]
version = "0.2.0" version = "0.2.0"
edition = "2024" edition = "2024"
# the workspace must satisfy every member, so this is the highest `rust-version` of the members
rust-version = "1.94.0" rust-version = "1.94.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"
@@ -9,77 +13,13 @@ description = "Read bio image formats using the bio-formats java package."
homepage = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs" homepage = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs"
repository = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs" repository = "https://git.pomppervova.nl/wim/ndbioimage/src/branch/rs"
documentation = "https://docs.rs/ndbioimage" documentation = "https://docs.rs/ndbioimage"
readme = "README.md" # inherited paths are resolved relative to the workspace root, not the member, so this has to point
# at the member's readme
readme = "ndbioimage/README.md"
keywords = ["bioformats", "imread", "ndarray", "metadata"] keywords = ["bioformats", "imread", "ndarray", "metadata"]
categories = ["multimedia::images", "science"] categories = ["multimedia::images", "science"]
exclude = ["/tests"]
[lib]
name = "ndbioimage"
crate-type = ["cdylib", "rlib"]
[dependencies]
bioformats = { version = "0.1", optional = true, default-features = false, features = ["zarr", "tissuefaxs"] }
clap = { version = "4", features = ["derive"] }
console = { version = "0.16", optional = true }
downloader = { version = "0.2", optional = true, default-features = false, features = ["rustls-tls"] }
ffmpeg-sidecar = { version = "2", optional = true }
itertools = "0.15"
image-registration = { path = "../image-registration", optional = true }
indexmap = { version = "2", features = ["serde"] }
indicatif = { version = "0.18", features = ["rayon"], optional = true }
j4rs = { version = "0.25", optional = true }
libczirw-sys = { version = "0.5", optional = true }
ndarray = { version = "0.17", features = ["serde"] }
num = "0.4"
numpy = { version = "0.29", optional = true }
ome-metadata = "0.5"
ordered-float = { version = "5", optional = true }
phf = { version = "0.14", features = ["macros"] }
postcard = { version = "1", features = ["use-std"], optional = true }
pyo3 = { version = "0.29", features = ["abi3-py310", "anyhow", "generate-import-lib"], optional = true }
pyo3-stub-gen = { version = "0.23", optional = true }
rayon = { version = "1", optional = true }
regex = "1"
serde = { version = "1", features = ["rc", "derive"] }
serde_with = "3"
strum = { version = "0.28", features = ["derive"] }
thiserror = "2"
tiff = { version = "0.11", features = ["zstd"], optional = true }
tiffwrite = { version = "2026.6.0", optional = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread"], optional = true }
thread_local = { version = "1", optional = true }
xmltree = { version = "0.12", optional = true }
yaml_serde = { version = "0.10", optional = true }
[dev-dependencies]
rayon = "1"
regex = "1"
[build-dependencies]
j4rs = "0.25"
ffmpeg-sidecar = "2"
retry = "2"
toml = "1"
[features]
default = ["bioformats_java", "gpl-formats", "czi", "tiff", "tiffseq", "movie", "tiffwrite"]
all = ["bioformats_java", "bioformats_rust", "czi", "gpl-formats", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
gpl-formats = []
python = ["dep:pyo3", "dep:numpy", "dep:pyo3-stub-gen", "dep:postcard", "ome-metadata/python"]
czi = ["dep:libczirw-sys", "dep:xmltree", "dep:thread_local"]
bioformats_rust = ["dep:bioformats", "dep:thread_local"]
bioformats_java = ["dep:j4rs", "dep:thread_local", "dep:downloader"]
tiffwrite = ["dep:tiffwrite", "dep:indicatif", "dep:console", "dep:rayon"]
tiffseq = ["dep:tiff", "dep:yaml_serde"]
tiff = ["dep:tiff", "dep:thread_local"]
movie = ["dep:ffmpeg-sidecar", "dep:tokio", "dep:ordered-float", "dep:indicatif", "dep:console"]
transforms = ["dep:image-registration", "dep:yaml_serde"]
[package.metadata.docs.rs]
no-default-features = true
features = ["bioformats_java", "bioformats_rust", "czi", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
# profiles are only honoured in the workspace root manifest
[profile.test] [profile.test]
inherits = "release" inherits = "release"
@@ -87,4 +27,4 @@ inherits = "release"
debug = true debug = true
[profile.dev.package.backtrace] [profile.dev.package.backtrace]
opt-level = 3 opt-level = 3
Submodule image-registration added at 7eb39a2fbf.
Submodule
+1
Submodule image-tiff added at 96d37bb087.
+82
View File
@@ -0,0 +1,82 @@
[package]
name = "ndbioimage"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
description.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
exclude = ["/tests"]
[lib]
name = "ndbioimage"
crate-type = ["cdylib", "rlib"]
[dependencies]
bioformats = { version = "0.1", optional = true, default-features = false, features = ["zarr", "tissuefaxs"] }
clap = { version = "4", features = ["derive"] }
console = { version = "0.16", optional = true }
downloader = { version = "0.2", optional = true, default-features = false, features = ["rustls-tls"] }
ffmpeg-sidecar = { version = "2", optional = true }
itertools = "0.15"
image-registration = { path = "../image-registration", version = "0.1", optional = true }
indexmap = { version = "2", features = ["serde"] }
indicatif = { version = "0.18", features = ["rayon"], optional = true }
j4rs = { version = "0.25", optional = true }
libczirw-sys = { version = "0.5", optional = true }
ndarray = { version = "0.17", features = ["serde"] }
num = "0.4"
numpy = { version = "0.29", optional = true }
ome-metadata = "0.5"
ordered-float = { version = "5", optional = true }
phf = { version = "0.14", features = ["macros"] }
postcard = { version = "1", features = ["use-std"], optional = true }
pyo3 = { version = "0.29", features = ["abi3-py310", "anyhow", "generate-import-lib"], optional = true }
pyo3-stub-gen = { version = "0.23", optional = true }
rayon = { version = "1", optional = true }
regex = "1"
serde = { version = "1", features = ["rc", "derive"] }
serde_with = "3"
strum = { version = "0.28", features = ["derive"] }
thiserror = "2"
tiff = { package = "ndbioimage-tiff", path = "../image-tiff", version = "0.11", features = ["zstd"], optional = true }
tiffwrite = { version = "2026.6.0", optional = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread"], optional = true }
thread_local = { version = "1", optional = true }
xmltree = { version = "0.12", optional = true }
yaml_serde = { version = "0.10", optional = true }
[dev-dependencies]
rayon = "1"
regex = "1"
postcard = { version = "1", features = ["use-std"] }
[build-dependencies]
j4rs = "0.25"
ffmpeg-sidecar = "2"
retry = "2"
toml = "1"
[features]
default = ["bioformats_java", "gpl-formats", "czi", "tiff", "tiffseq", "movie", "tiffwrite"]
all = ["bioformats_java", "bioformats_rust", "czi", "gpl-formats", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
gpl-formats = []
python = ["dep:pyo3", "dep:numpy", "dep:pyo3-stub-gen", "dep:postcard", "ome-metadata/python"]
czi = ["dep:libczirw-sys", "dep:xmltree", "dep:thread_local"]
bioformats_rust = ["dep:bioformats", "dep:thread_local"]
bioformats_java = ["dep:j4rs", "dep:thread_local", "dep:downloader"]
tiffwrite = ["dep:tiffwrite", "dep:indicatif", "dep:console", "dep:rayon"]
tiffseq = ["dep:tiff", "dep:yaml_serde"]
tiff = ["dep:tiff", "dep:thread_local"]
movie = ["dep:ffmpeg-sidecar", "dep:tokio", "dep:ordered-float", "dep:indicatif", "dep:console"]
transforms = ["dep:image-registration", "dep:yaml_serde"]
[package.metadata.docs.rs]
no-default-features = true
features = ["bioformats_java", "bioformats_rust", "czi", "movie", "tiffseq", "tiffwrite", "tiff", "transforms"]
View File
File renamed without changes.
+22 -2
View File
@@ -9,21 +9,41 @@ mod bioformats {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) enum BuildError { pub(crate) enum BuildError {
J4rsVersionNotFound, J4rsVersionNotFound,
LockFileNotFound,
} }
impl Display for BuildError { impl Display for BuildError {
fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> { fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> {
match self { match self {
Self::J4rsVersionNotFound => write!(fmt, "J4rsVersion not found in Cargo.lock"), Self::J4rsVersionNotFound => write!(fmt, "J4rsVersion not found in Cargo.lock"),
Self::LockFileNotFound => write!(
fmt,
"Cargo.lock not found in any parent of CARGO_MANIFEST_DIR ({})",
std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default()
),
} }
} }
} }
impl Error for BuildError {} impl Error for BuildError {}
/// the `Cargo.lock` that cargo resolved for this build: next to the manifest for a standalone
/// package, or at the workspace root when this crate is a workspace member
fn lock_file() -> Result<std::path::PathBuf, BuildError> {
let manifest_dir = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"),
);
manifest_dir
.ancestors()
.map(|dir| dir.join("Cargo.lock"))
.find(|lock| lock.is_file())
.ok_or(BuildError::LockFileNotFound)
}
fn get_j4rs_version() -> Result<String, Box<dyn Error>> { fn get_j4rs_version() -> Result<String, Box<dyn Error>> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); let lock_path = lock_file()?;
let lock_path = std::path::Path::new(&manifest_dir).join("Cargo.lock"); // the resolved j4rs version is baked into the generated constants, so rerun when it changes
println!("cargo::rerun-if-changed={}", lock_path.display());
let lock_toml = std::fs::read_to_string(&lock_path)?; let lock_toml = std::fs::read_to_string(&lock_path)?;
let value: toml::Value = toml::from_str(&lock_toml)?; let value: toml::Value = toml::from_str(&lock_toml)?;
if let Some(packages) = value.get("package").and_then(|v| v.as_array()) { if let Some(packages) = value.get("package").and_then(|v| v.as_array()) {
@@ -5,7 +5,7 @@ import sys
import warnings import warnings
from importlib.metadata import version from importlib.metadata import version
from pathlib import Path from pathlib import Path
from typing import TypeVar from typing import TYPE_CHECKING
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
@@ -13,14 +13,18 @@ from numpy.typing import ArrayLike
os.environ["RUST_BACKTRACE"] = "full" os.environ["RUST_BACKTRACE"] = "full"
os.environ["COLORBT_SHOW_HIDDEN"] = "1" os.environ["COLORBT_SHOW_HIDDEN"] = "1"
from .ndbioimage_rs import Imread, generate_stub from .ndbioimage_rs import Imread, Shape, generate_stub
try: if TYPE_CHECKING:
from .ndbioimage_rs import transforms from .transforms import Transform as Transform
else:
try:
from .ndbioimage_rs import transforms
sys.modules["ndbioimage.transforms"] = transforms sys.modules["ndbioimage.transforms"] = transforms
except (ImportError, ModuleNotFoundError): Transform = transforms.Transform
pass except (ImportError, ModuleNotFoundError):
pass
try: try:
from .ndbioimage_rs import batch_to_tiff from .ndbioimage_rs import batch_to_tiff
@@ -50,7 +54,16 @@ except Exception: # noqa
__git_commit_hash__ = "unknown" __git_commit_hash__ = "unknown"
warnings.filterwarnings("ignore", "Reference to unknown ID") warnings.filterwarnings("ignore", "Reference to unknown ID")
Number = int | float | np.integer | np.floating
__all__ = [
"Imread",
"JVMException",
"ReaderNotFoundError",
"Shape",
"Transform",
"get_positions",
"is_noise",
]
# backwards compatibility # backwards compatibility
@@ -90,9 +103,6 @@ def ndbioimage_generate_stub():
(nd / "ndbioimage.pyi").rename(nd / "__init__.pyi") (nd / "ndbioimage.pyi").rename(nd / "__init__.pyi")
R = TypeVar("R")
def get_positions(path: str | Path) -> set[int]: def get_positions(path: str | Path) -> set[int]:
return Imread.get_available_series(path) return Imread.get_available_series(path)
@@ -228,7 +228,26 @@ class Imread:
reader: typing.Optional[builtins.str] = None, reader: typing.Optional[builtins.str] = None,
) -> Imread: ) -> Imread:
r""" r"""
new view on a file at path, open series #, open as dtype: (u)int(8/16/32) or float(32/64) new view on an image file
path: path to image file to open, can include /Pos\d
dtype: when not None; will cast image to this dtype,
choices: (u)int(8/16/32) or float(32/64)
axes: the resulting object will represent the image with the axes in this order
reader: use a specific reader to open the file,
view available choices with Imread.available_readers
"""
@staticmethod
def warm_jvm() -> None:
r"""
creates the jvm (if it does not exist yet) so that hotspot installs its signal handlers before
anything else does: see `crate::readers::bioformats_java::warm_jvm`
in a python process, call this before `ray.init()`, which installs CPython's faulthandler
"""
@staticmethod
def jvm_is_running() -> builtins.bool:
r"""
returns whether the jvm was already created on this thread
""" """
def set_flags( def set_flags(
self, self,
@@ -773,7 +792,7 @@ def batch_to_tiff(
def generate_stub(dest_path: builtins.str) -> None: def generate_stub(dest_path: builtins.str) -> None:
r""" r"""
generates ndbioimage/__init__.pyi generates .pyi files
""" """
def main() -> None: def main() -> None:
@@ -120,6 +120,16 @@ class Transform:
@center.setter @center.setter
def center(self, value: typing.Sequence[builtins.float]) -> None: ... def center(self, value: typing.Sequence[builtins.float]) -> None: ...
@property @property
def origin(self) -> builtins.list[builtins.float]:
r"""
backwards compatibility
"""
@origin.setter
def origin(self, value: typing.Sequence[builtins.float]) -> None:
r"""
backwards compatibility
"""
@property
def shape(self) -> builtins.list[builtins.int]: ... def shape(self) -> builtins.list[builtins.int]: ...
@shape.setter @shape.setter
def shape(self, value: typing.Sequence[builtins.int]) -> None: ... def shape(self, value: typing.Sequence[builtins.int]) -> None: ...
@@ -184,6 +194,12 @@ class Transform:
def interpolate_par( def interpolate_par(
self, order: builtins.int, image: numpy.typing.ArrayLike self, order: builtins.int, image: numpy.typing.ArrayLike
) -> numpy.typing.NDArray[numpy.float64]: ... ) -> numpy.typing.NDArray[numpy.float64]: ...
def frame(
self, image: numpy.typing.ArrayLike, default: typing.Optional[typing.Any] = None
) -> numpy.ndarray:
r"""
backwards compatibility, default parameter is not implemented
"""
def is_unity(self) -> builtins.bool: ... def is_unity(self) -> builtins.bool: ...
def transform_point( def transform_point(
self, point: numpy.typing.ArrayLike self, point: numpy.typing.ArrayLike
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+107
View File
@@ -6,6 +6,7 @@ use std::fmt::{Debug, Formatter};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::SystemTime;
use crate::readers::Frame; use crate::readers::Frame;
@@ -15,6 +16,68 @@ const DEFAULT_FRAME_CACHE_SIZE: usize = 128;
/// maximum number of materialized arrays held in the cache /// maximum number of materialized arrays held in the cache
const DEFAULT_ARRAY_CACHE_SIZE: usize = 32; const DEFAULT_ARRAY_CACHE_SIZE: usize = 32;
/// fingerprint of the file(s) a reader reads from.
///
/// The cache is keyed partly on this stamp, so a file that is rewritten (for
/// example an output file that is written and then read back) gets a new cache
/// key instead of returning stale pixels. The stamp is read from the
/// filesystem on every lookup, so a long-lived view also notices a change.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub(crate) struct FileStamp {
/// size of the file in bytes
len: u64,
/// last modification time
modified: Option<SystemTime>,
/// last status change time; on unix this catches same-size rewrites that
/// happen within the timestamp resolution of `modified`
#[cfg(unix)]
changed: Option<(i64, u32)>,
/// inode number; on unix this catches an atomic replace that preserves the
/// modification time and size
#[cfg(unix)]
inode: u64,
/// creation time; windows has no stable `ctime` or file index, but this
/// changes when a file is deleted/recreated or atomically replaced, which is
/// the case the unix inode covers. in-place rewrites are caught by
/// `modified`, which has 100 ns resolution on windows.
#[cfg(windows)]
created: Option<SystemTime>,
}
impl FileStamp {
/// read the current stamp for `path`, returning the default (all-zero)
/// stamp when the metadata cannot be read
pub(crate) fn from_path(path: &Path) -> Self {
let Ok(meta) = std::fs::metadata(path) else {
return Self::default();
};
let len = meta.len();
let modified = meta.modified().ok();
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Self {
len,
modified,
changed: Some((meta.ctime(), meta.ctime_nsec() as u32)),
inode: meta.ino(),
}
}
#[cfg(windows)]
{
Self {
len,
modified,
created: meta.created().ok(),
}
}
#[cfg(not(any(unix, windows)))]
{
Self { len, modified }
}
}
}
/// identity of the reader a frame was read from /// identity of the reader a frame was read from
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ReaderKey { pub(crate) struct ReaderKey {
@@ -22,6 +85,7 @@ pub(crate) struct ReaderKey {
pub(crate) path: PathBuf, pub(crate) path: PathBuf,
pub(crate) series: usize, pub(crate) series: usize,
pub(crate) position: usize, pub(crate) position: usize,
pub(crate) stamp: FileStamp,
} }
/// borrowed view of [`ReaderKey`] for cache lookups without allocation. /// borrowed view of [`ReaderKey`] for cache lookups without allocation.
@@ -33,6 +97,7 @@ pub(crate) struct ReaderKeyRef<'a> {
pub(crate) path: &'a Path, pub(crate) path: &'a Path,
pub(crate) series: usize, pub(crate) series: usize,
pub(crate) position: usize, pub(crate) position: usize,
pub(crate) stamp: FileStamp,
} }
impl Hash for ReaderKeyRef<'_> { impl Hash for ReaderKeyRef<'_> {
@@ -41,6 +106,7 @@ impl Hash for ReaderKeyRef<'_> {
self.path.hash(state); self.path.hash(state);
self.series.hash(state); self.series.hash(state);
self.position.hash(state); self.position.hash(state);
self.stamp.hash(state);
} }
} }
@@ -50,6 +116,7 @@ impl Equivalent<ReaderKey> for ReaderKeyRef<'_> {
&& self.path == key.path.as_path() && self.path == key.path.as_path()
&& self.series == key.series && self.series == key.series
&& self.position == key.position && self.position == key.position
&& self.stamp == key.stamp
} }
} }
@@ -60,6 +127,7 @@ impl ReaderKeyRef<'_> {
path: self.path.to_path_buf(), path: self.path.to_path_buf(),
series: self.series, series: self.series,
position: self.position, position: self.position,
stamp: self.stamp,
} }
} }
} }
@@ -320,3 +388,42 @@ impl Debug for ArrayCache {
f.debug_struct("ArrayCache").finish_non_exhaustive() f.debug_struct("ArrayCache").finish_non_exhaustive()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ndbioimage-cache-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn file_stamp_detects_rewrite() {
let path = temp_path("stamp.bin");
std::fs::write(&path, b"aaaa").unwrap();
let before = FileStamp::from_path(&path);
std::fs::write(&path, b"bbbbbb").unwrap();
let after = FileStamp::from_path(&path);
assert_ne!(before, after);
let _ = std::fs::remove_file(&path);
}
#[test]
fn reader_key_includes_file_stamp() {
let key = |stamp| ReaderKey {
name: "test".to_string(),
path: PathBuf::from("test.tif"),
series: 0,
position: 0,
stamp,
};
let a = key(FileStamp::from_path(Path::new("does-not-exist.tif")));
let b = key(FileStamp {
len: 1,
..FileStamp::default()
});
assert_ne!(a, b);
}
}
File renamed without changes.
+8 -1
View File
@@ -47,7 +47,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
Tiff(#[from] tiff::TiffError), Tiff(#[from] tiff::TiffError),
/// a postcard (de)serialization error /// a postcard (de)serialization error
#[cfg(feature = "python")] #[cfg(any(test, feature = "python"))]
#[error(transparent)] #[error(transparent)]
PostCard(#[from] postcard::Error), PostCard(#[from] postcard::Error),
/// an error from the libczi binding /// an error from the libczi binding
@@ -172,6 +172,13 @@ pub enum Error {
/// file mismatch /// file mismatch
#[error("{0} do not match in {1} and {2}")] #[error("{0} do not match in {1} and {2}")]
FileMismatch(String, String, String), FileMismatch(String, String, String),
/// a jvm created in another process was inherited across a fork
#[cfg(feature = "bioformats_java")]
#[error(
"the jvm was created in process {0} but this is process {1}: a forked child inherits a jvm \
it cannot use. read the file before forking, or start the child with spawn/exec instead."
)]
ForkedJvm(u32, u32),
} }
impl Error { impl Error {
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) fn ndbioimage_file() -> PathBuf {
PathBuf::from(file) PathBuf::from(file)
} }
/// generates ndbioimage/__init__.pyi /// generates .pyi files
#[gen_stub_pyfunction] #[gen_stub_pyfunction]
#[pyfunction] #[pyfunction]
pub fn generate_stub(dest_path: String) -> PyResult<()> { pub fn generate_stub(dest_path: String) -> PyResult<()> {
@@ -147,7 +147,13 @@ impl PyView {
#[gen_stub_pymethods] #[gen_stub_pymethods]
#[pymethods] #[pymethods]
impl PyView { impl PyView {
/// new view on a file at path, open series #, open as dtype: (u)int(8/16/32) or float(32/64) /// new view on an image file
/// path: path to image file to open, can include /Pos\d
/// dtype: when not None; will cast image to this dtype,
/// choices: (u)int(8/16/32) or float(32/64)
/// axes: the resulting object will represent the image with the axes in this order
/// reader: use a specific reader to open the file,
/// view available choices with Imread.available_readers
#[new] #[new]
#[pyo3(signature = (path, dtype = None, axes = "cztyx", reader = None))] #[pyo3(signature = (path, dtype = None, axes = "cztyx", reader = None))]
fn new<'py>( fn new<'py>(
@@ -206,6 +212,26 @@ impl PyView {
} }
} }
/// creates the jvm (if it does not exist yet) so that hotspot installs its signal handlers before
/// anything else does: see `crate::readers::bioformats_java::warm_jvm`
///
/// in a python process, call this before `ray.init()`, which installs CPython's faulthandler
#[cfg(feature = "bioformats_java")]
#[staticmethod]
fn warm_jvm() -> PyResult<()> {
Ok(crate::readers::bioformats_java::warm_jvm()?)
}
/// returns whether the jvm was already created in this process
///
/// this is false in a process that inherited a jvm across a fork, since such a jvm cannot be
/// used; reading a bio-formats file there raises instead
#[cfg(feature = "bioformats_java")]
#[staticmethod]
fn jvm_is_running() -> bool {
crate::readers::bioformats_java::jvm_is_running()
}
/// https://numpy.org/doc/stable/reference/generated/numpy.ndarray.flags.html /// https://numpy.org/doc/stable/reference/generated/numpy.ndarray.flags.html
#[getter] #[getter]
fn flags(&self) -> PyFlags { fn flags(&self) -> PyFlags {
@@ -5,9 +5,11 @@ use image_registration::register::{Optimizer, RegistrationStep};
use image_registration::transform::Transform; use image_registration::transform::Transform;
use ndarray::{Ix0, Ix1, Ix2, Ix3, Ix4, Ix5, IxDyn}; use ndarray::{Ix0, Ix1, Ix2, Ix3, Ix4, Ix5, IxDyn};
use numpy::{ use numpy::{
AllowTypeChange, IntoPyArray, PyArray, PyArray1, PyArray2, PyArrayDyn, PyArrayLike1, AllowTypeChange, IntoPyArray, PyArray, PyArray1, PyArray2, PyArrayDescrMethods, PyArrayDyn,
PyArrayLike2, PyArrayLikeDyn, PyArrayLike1, PyArrayLike2, PyArrayLikeDyn, PyArrayMethods, PyUntypedArray,
PyUntypedArrayMethods, dtype,
}; };
use pyo3::IntoPyObjectExt;
use pyo3::exceptions::{PyNotImplementedError, PyValueError}; use pyo3::exceptions::{PyNotImplementedError, PyValueError};
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3_stub_gen::derive::*; use pyo3_stub_gen::derive::*;
@@ -165,6 +167,18 @@ impl PyTransform {
self.inner.center = center; self.inner.center = center;
} }
/// backwards compatibility
#[getter]
fn get_origin(&self) -> Vec<f64> {
self.inner.center.clone()
}
/// backwards compatibility
#[setter]
fn set_origin(&mut self, center: Vec<f64>) {
self.inner.center = center;
}
#[getter] #[getter]
fn get_shape(&self) -> Vec<usize> { fn get_shape(&self) -> Vec<usize> {
self.inner.shape.clone() self.inner.shape.clone()
@@ -329,6 +343,88 @@ impl PyTransform {
.into_pyarray(py)) .into_pyarray(py))
} }
/// backwards compatibility, default parameter is not implemented
#[gen_stub(override_return_type(type_repr="numpy.ndarray", imports=("numpy")))]
#[pyo3(signature = (image, default = None))]
fn frame<'py>(
&self,
py: Python<'py>,
#[gen_stub(override_type(type_repr="numpy.typing.ArrayLike", imports=("numpy.typing")))]
image: Bound<'py, PyUntypedArray>,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
if default.is_some() {
return Err(PyNotImplementedError::new_err(
"default parameter is not implemented",
));
}
if image.ndim() != self.inner.ndim() {
return Err(PyValueError::new_err(format!(
"dimensionality of image ({}) and transform ({}) do not correspond",
image.ndim(),
self.inner.ndim()
)));
}
macro_rules! interp {
($order:expr, $t:tt) => {
self.inner
.interpolate_par::<$order, _, _>(
unsafe { image.cast::<PyArrayDyn<$t>>()?.as_array() }
.mapv(|i| i as f64)
.into_dimensionality()
.map_err(Error::from)?
.view(),
)
.map_err(Error::from)?
.mapv(|i| i as $t)
.into_pyarray(py)
.into_bound_py_any(py)
};
}
let dt = image.dtype();
if dt.is_equiv_to(&dtype::<bool>(py)) {
self.inner
.interpolate_par::<0, _, _>(
unsafe { image.cast::<PyArrayDyn<bool>>()?.as_array() }
.mapv(|i| if i { 1.0 } else { 0.0 })
.into_dimensionality()
.map_err(Error::from)?
.view(),
)
.map_err(Error::from)?
.mapv(|i| i > 0.5)
.into_pyarray(py)
.into_bound_py_any(py)
} else if dt.is_equiv_to(&dtype::<u8>(py)) {
interp!(0, u8)
} else if dt.is_equiv_to(&dtype::<i8>(py)) {
interp!(0, i8)
} else if dt.is_equiv_to(&dtype::<u16>(py)) {
interp!(0, u16)
} else if dt.is_equiv_to(&dtype::<i16>(py)) {
interp!(0, i16)
} else if dt.is_equiv_to(&dtype::<u32>(py)) {
interp!(0, u32)
} else if dt.is_equiv_to(&dtype::<i32>(py)) {
interp!(0, i32)
} else if dt.is_equiv_to(&dtype::<u64>(py)) {
interp!(0, u64)
} else if dt.is_equiv_to(&dtype::<i64>(py)) {
interp!(0, i64)
} else if dt.is_equiv_to(&dtype::<f32>(py)) {
interp!(3, f32)
} else if dt.is_equiv_to(&dtype::<f64>(py)) {
interp!(3, f64)
} else {
Err(PyValueError::new_err(format!(
"invalid dtype: {}; must be bool, (u)int(8/16/32/64) or float(32/64)",
dt
)))
}
}
fn is_unity(&self) -> bool { fn is_unity(&self) -> bool {
self.inner.is_unity() self.inner.is_unity()
} }
File renamed without changes.
@@ -14,6 +14,7 @@ use std::hash::{Hash, Hasher};
use std::ops::Deref; use std::ops::Deref;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use thread_local::ThreadLocal; use thread_local::ThreadLocal;
include!(concat!(env!("OUT_DIR"), "/constants.rs")); include!(concat!(env!("OUT_DIR"), "/constants.rs"));
@@ -24,10 +25,33 @@ thread_local! {
static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(()); static DOWNLOAD_LOCK: Mutex<()> = Mutex::new(());
static JVM_BUILT: Mutex<bool> = Mutex::new(false); static JVM_BUILT: Mutex<bool> = Mutex::new(false);
/// the process that called [JvmBuilder::build], 0 if no jvm has been built yet
static JVM_PID: AtomicU32 = AtomicU32::new(0);
/// Returns `Ok(())` if the jvm in this process can still be used, and an error if it was inherited
/// across a fork.
///
/// A fork copies the address space, so the child gets the parent's jvm wholesale: its threads do
/// not exist and any lock another thread held at the moment of the fork stays held forever. There
/// is no way to repair that, and no way to start a replacement either, because HotSpot's
/// "a jvm already exists" flag lives in libjvm's private data and is copied along with everything
/// else -- verified by `JNI_GetCreatedJavaVMs`, which still reports 1 in the child, so a second
/// `JNI_CreateJavaVM` (which is what j4rs' `Jvm::attach_thread` calls) takes the already-exists
/// path. An `exec` replaces the address space, and that does reset the count to 0.
fn check_not_forked() -> Result<(), Error> {
let built_in = JVM_PID.load(Ordering::Relaxed);
if built_in != 0 && built_in != std::process::id() {
return Err(Error::ForkedJvm(built_in, std::process::id()));
}
Ok(())
}
/// Ensure 1 jvm per thread /// Ensure 1 jvm per thread
fn jvm() -> Rc<Jvm> { fn jvm() -> Result<Rc<Jvm>, Error> {
JVM.with(|cell| { // before touching JVM: in a forked child that thread's cell may already hold an Rc<Jvm> whose
// vm is gone, and the guard in check_not_forked is the only thing that can catch that.
check_not_forked()?;
Ok(JVM.with(|cell| {
cell.get_or_init(move || { cell.get_or_init(move || {
#[cfg(feature = "python")] #[cfg(feature = "python")]
let path = crate::py::ndbioimage_file(); let path = crate::py::ndbioimage_file();
@@ -113,6 +137,7 @@ fn jvm() -> Rc<Jvm> {
.java_opt(JavaOpt::new("--sun-misc-unsafe-memory-access=allow")) .java_opt(JavaOpt::new("--sun-misc-unsafe-memory-access=allow"))
.build() .build()
.expect("Failed to build JVM"); .expect("Failed to build JVM");
JVM_PID.store(std::process::id(), Ordering::Relaxed);
if let Ok(e) = InvocationArg::try_from("ERROR") { if let Ok(e) = InvocationArg::try_from("ERROR") {
let _ = j.invoke_static( let _ = j.invoke_static(
"loci.common.DebugTools", "loci.common.DebugTools",
@@ -125,9 +150,103 @@ fn jvm() -> Rc<Jvm> {
} }
}) })
.clone() .clone()
}) }))
} }
/// Returns `true` if the jvm was already created on the current thread.
///
/// The jvm is created lazily, on the first bio-formats read, and HotSpot installs its own signal
/// handlers while starting up. So this tells whether those handlers are in place yet.
///
/// This is process-wide, not thread-local: a forked child inherited the parent's jvm but cannot
/// use it, so reporting `true` there would only invite the deadlock that [check_not_forked] turns
/// into an error.
pub fn jvm_is_running() -> bool {
JVM_PID.load(Ordering::Relaxed) == std::process::id() && JVM.with(|cell| cell.get().is_some())
}
/// Creates the jvm on the current thread if it does not exist yet (no-op if it already does),
/// and initializes bio-formats on it.
///
/// This exists because of how HotSpot probes the cpu features during VM init: on an avx-512
/// capable cpu it deliberately reads through a NULL pointer (`VM_Version::_cpuinfo_segv_addr`, set
/// to the address of the generated cpu-feature stub) and resumes from its own SIGSEGV handler after
/// recognizing that address in `PosixSignals::pd_hotspot_signal_handler`.
///
/// Any SIGSEGV handler that was installed *before* the VM started is not that handler, and
/// HotSpot chains to it instead. Then the deliberate fault is not recognized, the chained handler
/// re-raises SIGSEGV, and the re-entrant pass no longer has a recognizable pc and ends in
/// `VMError::report_and_die` -> `os::die()` -> `abort()`.
///
/// In a python process this bites when `ray.init()` has already installed CPython's faulthandler
/// (ray/_private/worker.py), so call this before that.
///
/// Note that creating the VM is *not* enough: it has to be initialized far enough that the
/// deliberate fault has been taken before the foreign handler shows up. Measured on an AMD EPYC
/// 9124 (avx-512, XCR0 = 0x2e7) with ray: merely creating the VM leaves the crash rate unchanged,
/// and so does loading the reader classes without opening a file. What does work is initializing a
/// reader on an actual (tiny) image, so that is what this does.
pub fn warm_jvm() -> Result<(), Error> {
let jvm = jvm()?;
let no_args: &[InvocationArg] = &[];
// jvm() only *creates* the VM; run one static method so that this only returns Ok once the VM
// can actually run java bytecode.
let version = jvm.invoke_static(
"java.lang.System",
"getProperty",
&[InvocationArg::try_from("java.version")?],
)?;
let _: String = jvm.to_rust(version)?;
// loads (and runs the static initializers of) every reader class in the registry
let _ = jvm.invoke_static(
"loci.formats.ImageReader",
"getDefaultReaderClasses",
no_args,
)?;
// then open a 1x1 image so that the reader and its OMEXML metadata store get initialized.
// ImageReader.setId needs a real file, so write the tiff out and clean it up again.
let path = std::env::temp_dir().join(format!("ndbioimage-warm-jvm-{}.tif", std::process::id()));
std::fs::write(&path, WARM_TIFF)?;
let result = (|| -> Result<(), Error> {
let reader = jvm.create_instance("loci.formats.ImageReader", no_args)?;
jvm.invoke(
&reader,
"setId",
&[InvocationArg::try_from(path.to_string_lossy().as_ref())?],
)?;
let _: i32 = jvm.to_rust(jvm.invoke(&reader, "getSizeY", no_args)?)?;
let _ = jvm.invoke(&reader, "getMetadataStore", no_args)?;
Ok(())
})();
let _ = std::fs::remove_file(&path);
result
}
/// A 1x1 8-bit grayscale tiff (written by tifffile), used by [warm_jvm] to give bio-formats
/// something it can open.
const WARM_TIFF: &[u8] = &[
0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x01, 0x04, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x02, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03, 0x01,
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x06, 0x01, 0x03, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0e, 0x01, 0x02, 0x00, 0x12, 0x00, 0x00, 0x00, 0xb6, 0x00,
0x00, 0x00, 0x11, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x15, 0x01,
0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x16, 0x01, 0x04, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x17, 0x01, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x1a, 0x01, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0xd8, 0x00, 0x00, 0x00, 0x1b, 0x01,
0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x28, 0x01, 0x03, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x31, 0x01, 0x02, 0x00, 0x0c, 0x00, 0x00, 0x00, 0xe8, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7b, 0x22, 0x73, 0x68, 0x61, 0x70, 0x65, 0x22, 0x3a,
0x20, 0x5b, 0x31, 0x2c, 0x20, 0x31, 0x5d, 0x7d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x74, 0x69, 0x66, 0x66, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x79, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
];
macro_rules! method_return { macro_rules! method_return {
($R:ty$(|c)?) => { Result<$R, Error> }; ($R:ty$(|c)?) => { Result<$R, Error> };
() => { Result<(), Error> }; () => { Result<(), Error> };
@@ -147,14 +266,14 @@ macro_rules! method {
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn $name(&self, $($($n: $t),*)?) -> method_return!($($tt)?) { pub(crate) fn $name(&self, $($($n: $t),*)?) -> method_return!($($tt)?) {
let args: Vec<InvocationArg> = vec![$($( method_arg!($n:$t$(|$p)?) ),*)?]; let args: Vec<InvocationArg> = vec![$($( method_arg!($n:$t$(|$p)?) ),*)?];
let _result = jvm().invoke(&self.0, $method, &args)?; let _result = jvm()?.invoke(&self.0, $method, &args)?;
macro_rules! method_result { macro_rules! method_result {
($R:ty|c) => { ($R:ty|c) => {
Ok(jvm().to_rust(_result)?) Ok(jvm()?.to_rust(_result)?)
}; };
($R:ty|d) => { ($R:ty|d) => {
Ok(jvm().to_rust_deserialized(_result)?) Ok(jvm()?.to_rust_deserialized(_result)?)
}; };
($R:ty) => { ($R:ty) => {
Ok(_result) Ok(_result)
@@ -187,7 +306,7 @@ pub struct DebugTools;
impl DebugTools { impl DebugTools {
/// set debug root level: ERROR, DEBUG, TRACE, INFO, OFF /// set debug root level: ERROR, DEBUG, TRACE, INFO, OFF
pub fn set_root_level(level: &str) -> Result<(), Error> { pub fn set_root_level(level: &str) -> Result<(), Error> {
jvm().invoke_static( jvm()?.invoke_static(
"loci.common.DebugTools", "loci.common.DebugTools",
"setRootLevel", "setRootLevel",
&[InvocationArg::try_from(level)?], &[InvocationArg::try_from(level)?],
@@ -201,7 +320,7 @@ pub(crate) struct ChannelSeparator(Instance);
impl ChannelSeparator { impl ChannelSeparator {
pub(crate) fn new(image_reader: &ImageReader) -> Result<Self, Error> { pub(crate) fn new(image_reader: &ImageReader) -> Result<Self, Error> {
let jvm = jvm(); let jvm = jvm()?;
let channel_separator = jvm.create_instance( let channel_separator = jvm.create_instance(
"loci.formats.ChannelSeparator", "loci.formats.ChannelSeparator",
&[InvocationArg::from(jvm.clone_instance(&image_reader.0)?)], &[InvocationArg::from(jvm.clone_instance(&image_reader.0)?)],
@@ -228,7 +347,7 @@ impl Drop for ImageReader {
impl ImageReader { impl ImageReader {
pub(crate) fn new() -> Result<Self, Error> { pub(crate) fn new() -> Result<Self, Error> {
let reader = jvm().create_instance("loci.formats.ImageReader", InvocationArg::empty())?; let reader = jvm()?.create_instance("loci.formats.ImageReader", InvocationArg::empty())?;
Ok(ImageReader(reader)) Ok(ImageReader(reader))
} }
@@ -238,7 +357,7 @@ impl ImageReader {
pub(crate) fn ome_xml(&self) -> Result<String, Error> { pub(crate) fn ome_xml(&self) -> Result<String, Error> {
let mds = self.get_metadata_store()?; let mds = self.get_metadata_store()?;
Ok(jvm() Ok(jvm()?
.chain(&mds)? .chain(&mds)?
.cast("loci.formats.ome.OMEPyramidStore")? .cast("loci.formats.ome.OMEPyramidStore")?
.invoke("dumpXML", InvocationArg::empty())? .invoke("dumpXML", InvocationArg::empty())?
@@ -276,7 +395,7 @@ pub(crate) struct MetadataTools(Instance);
impl MetadataTools { impl MetadataTools {
pub(crate) fn new() -> Result<Self, Error> { pub(crate) fn new() -> Result<Self, Error> {
let meta_data_tools = let meta_data_tools =
jvm().create_instance("loci.formats.MetadataTools", InvocationArg::empty())?; jvm()?.create_instance("loci.formats.MetadataTools", InvocationArg::empty())?;
Ok(MetadataTools(meta_data_tools)) Ok(MetadataTools(meta_data_tools))
} }
@@ -517,6 +636,10 @@ impl Reader for BioFormatsJavaReader {
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
// checked here as well as in jvm() so that this entry point fails with the error rather
// than reaching get_size_x() below, which goes through deref() -> get_reader().unwrap()
// and would turn it into a panic.
check_not_forked()?;
let mut path = path.as_ref().to_path_buf(); let mut path = path.as_ref().to_path_buf();
if path.is_dir() { if path.is_dir() {
let orig = path.clone(); let orig = path.clone();
@@ -648,6 +771,7 @@ mod tests {
metadata_g: "tiffseq/4-Pos_001_002/img_000000000_Cy3-Cy3_filter_000.tif", metadata_g: "tiffseq/4-Pos_001_002/img_000000000_Cy3-Cy3_filter_000.tif",
metadata_h: "tiffseq/20-Pos_005_005/img_000000000_Cy3-Cy3_filter_000.tif", metadata_h: "tiffseq/20-Pos_005_005/img_000000000_Cy3-Cy3_filter_000.tif",
metadata_i: "tiffseq/YTL1841B2-2-1_1hr_DMSO_galinduction_1", metadata_i: "tiffseq/YTL1841B2-2-1_1hr_DMSO_galinduction_1",
metadata_j: "tiff/R1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001_MMStack_PosR1_NRF1_Gtf3a500nM_REAL_20260731_25pct_500ms_001.ome.tif",
} }
#[test] #[test]
File renamed without changes.
File renamed without changes.
@@ -7,10 +7,11 @@ use std::cell::{RefCell, RefMut};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs::File; use std::fs::File;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::io::{Read, Seek};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thread_local::ThreadLocal; use thread_local::ThreadLocal;
use tiff::decoder::ifd::Value; use tiff::decoder::ifd::Value;
use tiff::decoder::{Decoder, DecodingResult}; use tiff::decoder::{Decoder, DecodingSampleBuffer};
use tiff::tags::Tag; use tiff::tags::Tag;
/// reader for single tiff image files /// reader for single tiff image files
@@ -70,8 +71,9 @@ impl TiffReader {
fn get_reader(&self) -> Result<RefMut<'_, Decoder<File>>, Error> { fn get_reader(&self) -> Result<RefMut<'_, Decoder<File>>, Error> {
self.reader self.reader
.get_or_try(|| { .get_or_try(|| {
let file = File::open(&self.path)?; let mut decoder = Decoder::open(File::open(&self.path)?)?;
Ok(RefCell::new(Decoder::new(file)?)) decoder.next_image()?;
Ok(RefCell::new(decoder))
}) })
.map(|i| i.borrow_mut()) .map(|i| i.borrow_mut())
} }
@@ -128,10 +130,11 @@ impl Reader for TiffReader {
}; };
let mut reader = new.get_reader()?; let mut reader = new.get_reader()?;
let width = reader.get_tag(Tag::ImageWidth)?.into_u32()? as usize; let width = reader.current_ifd().get_tag(Tag::ImageWidth)?.into_u32()? as usize;
let height = reader.get_tag(Tag::ImageLength)?.into_u32()? as usize; let height = reader.current_ifd().get_tag(Tag::ImageLength)?.into_u32()? as usize;
let n_samples = reader let n_samples = reader
.current_ifd()
.get_tag(Tag::SamplesPerPixel) .get_tag(Tag::SamplesPerPixel)
.ok() .ok()
.and_then(|t| t.into_u16().ok()) .and_then(|t| t.into_u16().ok())
@@ -139,9 +142,14 @@ impl Reader for TiffReader {
let bits = tag_first_u16(&mut reader, Tag::BitsPerSample).unwrap_or(8); let bits = tag_first_u16(&mut reader, Tag::BitsPerSample).unwrap_or(8);
let sfmt = tag_first_u16(&mut reader, Tag::SampleFormat).unwrap_or(1); let sfmt = tag_first_u16(&mut reader, Tag::SampleFormat).unwrap_or(1);
// indexed color pages (PhotometricInterpretation::RGBPalette) are read as plain
// intensities: the pixel values are indices into the ColorMap palette, which is display
// information that ndbioimage does not use.
let pixtype = pixel_type_from_bits_sample(bits, sfmt)?; let pixtype = pixel_type_from_bits_sample(bits, sfmt)?;
let planar = reader let planar = reader
.current_ifd()
.get_tag(Tag::PlanarConfiguration) .get_tag(Tag::PlanarConfiguration)
.ok() .ok()
.and_then(|t| t.into_u16().ok()) .and_then(|t| t.into_u16().ok())
@@ -149,8 +157,9 @@ impl Reader for TiffReader {
== 2; == 2;
let imagej_bytes = reader let imagej_bytes = reader
.current_ifd()
.get_tag(Tag::Unknown(50839)) .get_tag(Tag::Unknown(50839))
.or_else(|_| reader.get_tag(Tag::ImageDescription))?; .or_else(|_| reader.current_ifd().get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes { let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(), Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?, other => other.into_u8_vec()?,
@@ -180,7 +189,7 @@ impl Reader for TiffReader {
let mut n_ifds = 1; let mut n_ifds = 1;
while reader.more_images() { while reader.more_images() {
reader.next_image()?; reader.next_directory()?;
n_ifds += 1; n_ifds += 1;
} }
@@ -212,8 +221,9 @@ impl Reader for TiffReader {
fn metadata(&self) -> Result<Ome, Error> { fn metadata(&self) -> Result<Ome, Error> {
let mut reader = self.get_reader()?; let mut reader = self.get_reader()?;
let imagej_bytes = reader let imagej_bytes = reader
.current_ifd()
.get_tag(Tag::Unknown(50839)) .get_tag(Tag::Unknown(50839))
.or_else(|_| reader.get_tag(Tag::ImageDescription))?; .or_else(|_| reader.current_ifd().get_tag(Tag::ImageDescription))?;
let imagej_bytes = match imagej_bytes { let imagej_bytes = match imagej_bytes {
Value::Ascii(s) => s.into_bytes(), Value::Ascii(s) => s.into_bytes(),
other => other.into_u8_vec()?, other => other.into_u8_vec()?,
@@ -225,11 +235,13 @@ impl Reader for TiffReader {
.get("interval") .get("interval")
.and_then(|v| v.parse::<f64>().ok()); .and_then(|v| v.parse::<f64>().ok());
let ru = reader let ru = reader
.current_ifd()
.get_tag(Tag::ResolutionUnit) .get_tag(Tag::ResolutionUnit)
.ok() .ok()
.and_then(|i| i.into_u16().ok()) .and_then(|i| i.into_u16().ok())
.unwrap_or(2); .unwrap_or(2);
let pixel_size = reader let pixel_size = reader
.current_ifd()
.get_tag(Tag::XResolution) .get_tag(Tag::XResolution)
.ok() .ok()
.and_then(|i| i.into_u64_vec().ok()) .and_then(|i| i.into_u64_vec().ok())
@@ -353,8 +365,8 @@ impl Reader for TiffReader {
let mut reader = self.get_reader()?; let mut reader = self.get_reader()?;
reader.seek_to_image(page_idx)?; reader.seek_to_image(page_idx)?;
let data: DecodingResult = if self.planar { let data: DecodingSampleBuffer = if self.planar {
let mut result = DecodingResult::U16(Vec::new()); let mut result = DecodingSampleBuffer::U16(Vec::new());
reader.read_image_to_buffer(&mut result)?; reader.read_image_to_buffer(&mut result)?;
result result
} else { } else {
@@ -364,7 +376,7 @@ impl Reader for TiffReader {
macro_rules! to_frame { macro_rules! to_frame {
($var:ident, $ty:ty) => {{ ($var:ident, $ty:ty) => {{
let v: Vec<$ty> = match data { let v: Vec<$ty> = match data {
DecodingResult::$var(v) => v, DecodingSampleBuffer::$var(v) => v,
_ => { _ => {
return Err(Error::Parse(format!( return Err(Error::Parse(format!(
"type mismatch: expected {}", "type mismatch: expected {}",
@@ -448,9 +460,8 @@ impl Reader for TiffReader {
} }
} }
fn tag_first_u16(reader: &mut RefMut<Decoder<File>>, tag: Tag) -> Option<u16> { fn tag_first_u16<R: Read + Seek>(reader: &mut RefMut<Decoder<R>>, tag: Tag) -> Option<u16> {
let val = reader.get_tag(tag).ok()?; let val = reader.current_ifd().get_tag(tag).ok()?;
use tiff::decoder::ifd::Value;
match val { match val {
Value::Short(v) => Some(v), Value::Short(v) => Some(v),
Value::List(list) => list.first().and_then(|v| match v { Value::List(list) => list.first().and_then(|v| match v {
@@ -464,6 +475,7 @@ fn tag_first_u16(reader: &mut RefMut<Decoder<File>>, tag: Tag) -> Option<u16> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use ndarray::s;
fn open(file: &str) -> Result<TiffReader, Error> { fn open(file: &str) -> Result<TiffReader, Error> {
let path = std::env::current_dir()? let path = std::env::current_dir()?
@@ -480,6 +492,22 @@ mod tests {
fn $name() -> Result<(), Error> { fn $name() -> Result<(), Error> {
let ts = open($file)?; let ts = open($file)?;
println!("{}", ts.view().squeeze()?.summary()?); println!("{}", ts.view().squeeze()?.summary()?);
let view = ts.view();
let first_frame = view.slice_cztyx(s![0, 0, 0, .., ..])?;
let array = first_frame.as_array::<u16>()?;
println!("{:?}", array);
assert_eq!(ts.shape.y, array.shape()[0]);
assert_eq!(ts.shape.x, array.shape()[1]);
if view.shape.t > 1 {
let second_frame = view.slice_cztyx(s![0, 0, 1, .., ..])?;
let array = second_frame.as_array::<u16>()?;
println!("{:?}", array);
assert_eq!(ts.shape.y, array.shape()[0]);
assert_eq!(ts.shape.x, array.shape()[1]);
}
Ok(()) Ok(())
} }
)* )*
@@ -487,11 +515,12 @@ mod tests {
} }
test_metadata! { test_metadata! {
// metadata_a: "tiff/20251014_20-Pos_000_000_mask.tif", metadata_a: "tiff/20251014_20-Pos_000_000_mask.tif",
metadata_b: "tiff/20251014_20-Pos_000_000_max.tif", metadata_b: "tiff/20251014_20-Pos_000_000_max.tif",
metadata_c: "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif", metadata_c: "tiff/20251014_20-Pos_000_000_loc_results_Cy3.tif",
metadata_e: "tiff/1xp53-01-AP1.tiff", metadata_e: "tiff/1xp53-01-AP1.tiff",
metadata_f: "tiff/test.tif", metadata_f: "tiff/test.tif",
// metadata_g: "tiff/YTL1849A111_2023_05_04__14_46_19_cellnr_1_track.tif", metadata_g: "tiff/YTL1849A111_2023_05_04__14_46_19_cellnr_1_track.tif",
metadata_h: "tiff/Wapl-AID_C46_13_tagRFPt_MCP_well1_video1_1_max.tif"
} }
} }
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use tiff::decoder::{Decoder, DecodingResult}; use tiff::decoder::{Decoder, DecodingSampleBuffer};
use tiff::tags::Tag; use tiff::tags::Tag;
/// reader for sequences of tiff files (one file per z/time plane) /// reader for sequences of tiff files (one file per z/time plane)
@@ -84,9 +84,13 @@ impl TiffSeqReader {
fn read_tiff_dimensions(path: &Path) -> Result<(usize, usize), Error> { fn read_tiff_dimensions(path: &Path) -> Result<(usize, usize), Error> {
let file = std::fs::File::open(path)?; let file = std::fs::File::open(path)?;
let mut decoder = Decoder::new(file)?; let mut decoder = Decoder::open(file)?;
let width = decoder.get_tag(Tag::ImageWidth)?.into_u32()? as usize; decoder.next_image()?;
let height = decoder.get_tag(Tag::ImageLength)?.into_u32()? as usize; let width = decoder.current_ifd().get_tag(Tag::ImageWidth)?.into_u32()? as usize;
let height = decoder
.current_ifd()
.get_tag(Tag::ImageLength)?
.into_u32()? as usize;
Ok((width, height)) Ok((width, height))
} }
} }
@@ -514,38 +518,39 @@ impl Reader for TiffSeqReader {
.ok_or_else(|| Error::OutOfBounds(c.max(z).max(t) as isize, 0))?; .ok_or_else(|| Error::OutOfBounds(c.max(z).max(t) as isize, 0))?;
let rdr = std::fs::File::open(file)?; let rdr = std::fs::File::open(file)?;
let mut decoder = Decoder::new(rdr)?; let mut decoder = Decoder::open(rdr)?;
decoder.next_image()?;
match decoder.read_image()? { match decoder.read_image()? {
DecodingResult::U8(d) => Ok(ArrayT::U8(Array2::from_shape_vec( DecodingSampleBuffer::U8(d) => Ok(ArrayT::U8(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::U16(d) => Ok(ArrayT::U16(Array2::from_shape_vec( DecodingSampleBuffer::U16(d) => Ok(ArrayT::U16(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::U32(d) => Ok(ArrayT::U32(Array2::from_shape_vec( DecodingSampleBuffer::U32(d) => Ok(ArrayT::U32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I8(d) => Ok(ArrayT::I8(Array2::from_shape_vec( DecodingSampleBuffer::I8(d) => Ok(ArrayT::I8(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I16(d) => Ok(ArrayT::I16(Array2::from_shape_vec( DecodingSampleBuffer::I16(d) => Ok(ArrayT::I16(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::I32(d) => Ok(ArrayT::I32(Array2::from_shape_vec( DecodingSampleBuffer::I32(d) => Ok(ArrayT::I32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::F32(d) => Ok(ArrayT::F32(Array2::from_shape_vec( DecodingSampleBuffer::F32(d) => Ok(ArrayT::F32(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
DecodingResult::F64(d) => Ok(ArrayT::F64(Array2::from_shape_vec( DecodingSampleBuffer::F64(d) => Ok(ArrayT::F64(Array2::from_shape_vec(
(self.shape.y, self.shape.x), (self.shape.y, self.shape.x),
d, d,
)?)), )?)),
File renamed without changes.
@@ -321,4 +321,44 @@ mod tests {
batch_to_tiff(&files_in, &files_out, None, None, true, true, None)?; batch_to_tiff(&files_in, &files_out, None, None, true, true, None)?;
Ok(()) Ok(())
} }
/// a rewritten file must not be served from the process-wide cache
#[cfg(feature = "tiff")]
#[test]
fn cache_invalidated_after_rewrite() -> Result<(), Error> {
use crate::view::View;
use ndarray::Array2;
use std::time::{Duration, SystemTime};
use tiffwrite::IJTiffFile;
let dir = std::env::temp_dir().join(format!("ndbioimage-rewrite-{}", std::process::id()));
create_dir_all(&dir)?;
let path = dir.join("cache_rewrite.tif");
let write = |value: u16| -> Result<(), Error> {
let mut tiff = IJTiffFile::new(&path)?;
tiff.save(Array2::<u16>::from_elem((4, 4), value), 0, 0, 0)?;
Ok(())
};
write(1)?;
let view: View<_, DynReader> = View::from_path(&path)?;
let frame: Array2<u16> = view.get_frame(0, 0, 0)?;
assert_eq!(frame[(0, 0)], 1);
write(2)?;
// make the modification time unambiguous even on filesystems with a
// coarse timestamp resolution
let file = std::fs::OpenOptions::new().write(true).open(&path)?;
file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000))?;
drop(file);
let view: View<_, DynReader> = View::from_path(&path)?;
let frame: Array2<u16> = view.get_frame(0, 0, 0)?;
assert_eq!(frame[(0, 0)], 2);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
Ok(())
}
} }
@@ -46,14 +46,49 @@ impl TryFrom<HashMap<String, TransformOld>> for Transforms {
} }
} }
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize)]
pub struct Transforms { pub struct Transforms {
#[serde(skip_serializing_if = "HashMap::is_empty", default = "HashMap::new")] #[serde(default)]
pub channel: HashMap<String, Transform<IxDyn>>, pub channel: HashMap<String, Transform<IxDyn>>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::new")] #[serde(default)]
pub drift: Vec<Transform<IxDyn>>, pub drift: Vec<Transform<IxDyn>>,
} }
impl Serialize for Transforms {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
#[derive(Serialize)]
struct HumanReadable<'a> {
#[serde(skip_serializing_if = "HashMap::is_empty")]
channel: &'a HashMap<String, Transform<IxDyn>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
drift: &'a Vec<Transform<IxDyn>>,
}
#[derive(Serialize)]
struct Binary<'a> {
channel: &'a HashMap<String, Transform<IxDyn>>,
drift: &'a Vec<Transform<IxDyn>>,
}
if serializer.is_human_readable() {
HumanReadable {
channel: &self.channel,
drift: &self.drift,
}
.serialize(serializer)
} else {
Binary {
channel: &self.channel,
drift: &self.drift,
}
.serialize(serializer)
}
}
}
impl Transforms { impl Transforms {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> { pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let mut file = std::fs::File::open(&path)?; let mut file = std::fs::File::open(&path)?;
File renamed without changes.
+27 -5
View File
@@ -1,5 +1,5 @@
use crate::axes::{Ax, Axis, Operation, Shape, Slice, SliceInfoElemDef, slice_info}; use crate::axes::{Ax, Axis, Operation, Shape, Slice, SliceInfoElemDef, slice_info};
use crate::cache::{ArrayCache, ArrayKeyRef, FrameCache, FrameKeyRef, ReaderKeyRef}; use crate::cache::{ArrayCache, ArrayKeyRef, FileStamp, FrameCache, FrameKeyRef, ReaderKeyRef};
use crate::error::Error; use crate::error::Error;
use crate::metadata::Metadata; use crate::metadata::Metadata;
use crate::readers::{Dimensions, DynReader, Frame, Reader}; use crate::readers::{Dimensions, DynReader, Frame, Reader};
@@ -274,7 +274,7 @@ impl<D: Dimension, R: Reader> View<D, R> {
&self.axes &self.axes
} }
#[expect(dead_code)] #[allow(dead_code)]
pub(crate) fn get_operations(&self) -> &IndexMap<Axis, Operation> { pub(crate) fn get_operations(&self) -> &IndexMap<Axis, Operation> {
&self.operations &self.operations
} }
@@ -964,6 +964,7 @@ impl<D: Dimension, R: Reader> View<D, R> {
path: self.reader.path(), path: self.reader.path(),
series: self.reader.series(), series: self.reader.series(),
position: self.reader.position(), position: self.reader.position(),
stamp: FileStamp::from_path(self.reader.path()),
}, },
c, c,
z, z,
@@ -978,6 +979,7 @@ impl<D: Dimension, R: Reader> View<D, R> {
path: self.reader.path(), path: self.reader.path(),
series: self.reader.series(), series: self.reader.series(),
position: self.reader.position(), position: self.reader.position(),
stamp: FileStamp::from_path(self.reader.path()),
}, },
dtype: type_name::<T>(), dtype: type_name::<T>(),
slice: &self.slice, slice: &self.slice,
@@ -1226,14 +1228,17 @@ to_bytes_vec_impl!(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::axes::{Axis, Operation}; use crate::axes::{Axis, Operation};
use crate::cache::{ArrayCache, ArrayKey, ArrayKeyRef, FrameCache, ReaderKey, ReaderKeyRef}; use crate::cache::{
ArrayCache, ArrayKey, ArrayKeyRef, FileStamp, FrameCache, ReaderKey, ReaderKeyRef,
};
use crate::error::Error; use crate::error::Error;
use crate::readers::{DynReader, Frame, Reader}; use crate::readers::{DynReader, Frame, Reader};
use crate::stats::MinMax; use crate::stats::MinMax;
use crate::view::Item; use crate::view::{Item, View};
use indexmap::IndexMap; use indexmap::IndexMap;
use ndarray::{Array, Array4, Array5, NewAxis}; use ndarray::{Array, Array4, Array5, Ix5, NewAxis};
use ndarray::{Array2, ArrayD, IxDyn, SliceInfoElem, s}; use ndarray::{Array2, ArrayD, IxDyn, SliceInfoElem, s};
use postcard::{from_bytes, to_stdvec};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -1514,6 +1519,7 @@ mod tests {
path: PathBuf::from("test.tif"), path: PathBuf::from("test.tif"),
series: 0, series: 0,
position: 0, position: 0,
stamp: FileStamp::default(),
}, },
c, c,
z, z,
@@ -1549,6 +1555,7 @@ mod tests {
path: PathBuf::from("test.tif"), path: PathBuf::from("test.tif"),
series: 0, series: 0,
position: 0, position: 0,
stamp: FileStamp::default(),
}, },
dtype, dtype,
slice: vec![SliceInfoElem::Index(index)], slice: vec![SliceInfoElem::Index(index)],
@@ -1574,6 +1581,7 @@ mod tests {
path: Path::new("test.tif"), path: Path::new("test.tif"),
series: 0, series: 0,
position: 0, position: 0,
stamp: FileStamp::default(),
}, },
dtype: "u16", dtype: "u16",
slice: &[SliceInfoElem::Index(0)], slice: &[SliceInfoElem::Index(0)],
@@ -1604,4 +1612,18 @@ mod tests {
assert_eq!(a, c2); assert_eq!(a, c2);
Ok(()) Ok(())
} }
#[test]
fn serde() -> Result<(), Error> {
let file = "tiff/20251014_20-Pos_000_000_max.tif";
let reader = open(file)?;
let view = reader.view();
let bytes = to_stdvec(&view)?;
let view2: View<Ix5> = from_bytes(&bytes)?;
println!("{:?}", view);
println!("{:?}", view2);
Ok(())
}
} }
File renamed without changes.
File renamed without changes.
File renamed without changes.
-3
View File
@@ -1,3 +0,0 @@
from .ndbioimage_rs import Imread, Shape
__all__ = ["Imread", "Shape"]