Compare commits

...
5 Commits
Author SHA1 Message Date
w.pomp ee4b07d4ec - bugfix in tifread reading files missing a dimension in metadata
PyTest / pytest (3.10) (push) Successful in 1m28s
PyTest / pytest (3.11) (push) Successful in 11s
PyTest / pytest (3.12) (push) Successful in 12s
PyTest / pytest (3.13) (push) Successful in 11s
2026-09-24 10:27:21 +02:00
w.pomp e32b7ea810 - support some sequences of files in tifread
PyTest / pytest (3.10) (push) Successful in 15s
PyTest / pytest (3.11) (push) Successful in 12s
PyTest / pytest (3.12) (push) Successful in 12s
PyTest / pytest (3.13) (push) Successful in 12s
2026-07-27 18:00:32 +02:00
w.pomp d9806244d3 - add test file
PyTest / pytest (3.10) (push) Successful in 11s
PyTest / pytest (3.11) (push) Successful in 11s
PyTest / pytest (3.12) (push) Successful in 11s
PyTest / pytest (3.13) (push) Successful in 11s
2026-07-27 14:20:32 +02:00
w.pomp 74b81da78c - czi: read tirf angle
PyTest / pytest (3.10) (push) Failing after 1m52s
PyTest / pytest (3.11) (push) Failing after 1m0s
PyTest / pytest (3.12) (push) Failing after 32s
PyTest / pytest (3.13) (push) Failing after 58s
- tiff: read spacing
2026-07-27 14:14:18 +02:00
Wim Pomp 2f076bd7b8 - let tifread raise FileNotFoundError when series != 0
PyTest / pytest (3.10) (push) Has been cancelled
PyTest / pytest (3.11) (push) Has been cancelled
PyTest / pytest (3.12) (push) Has been cancelled
PyTest / pytest (3.13) (push) Has been cancelled
2026-04-01 10:23:21 +02:00
7 changed files with 119 additions and 34 deletions

No files matched your search

+1
View File
@@ -11,3 +11,4 @@
/poetry.lock
/dist/
/uv.lock
.agentbridge
+1 -1
View File
@@ -1,4 +1,4 @@
[![Pytest](https://github.com/wimpomp/ndbioimage/actions/workflows/pytest.yml/badge.svg)](https://github.com/wimpomp/ndbioimage/actions/workflows/pytest.yml)
[![Pytest](https://git.wimpomp.nl/wim/ndbioimage/actions/workflows/pytest.yml/badge.svg)](https://git.wimpomp.nl/wim/ndbioimage/actions?workflow=pytest.yml)
# ndbioimage
+8 -1
View File
@@ -191,7 +191,8 @@ def get_positions(path: str | Path) -> Optional[list[int]]:
return subclass.get_positions(AbstractReader.split_path_series(path)[0])
class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
# noinspection PyAbstractClass
class Imread(np.lib.mixins.NDArrayOperatorsMixin):
"""class to read image files, while taking good care of important metadata,
currently optimized for .czi files, but can open anything that bioformats can handle
path: path to the image file
@@ -1528,6 +1529,12 @@ class AbstractReader(Imread, metaclass=ABCMeta):
self.immersionN = 1
p = re.compile(r"(\d+):(\d+)$")
if self.ome.structured_annotations is not None:
tirf_angles = {}
for annotation in self.ome.structured_annotations:
if annotation.description is not None and annotation.description.lower() == "tirfangle":
tirf_angles[int(annotation.id.split(":")[1])] = annotation.value
self.tirfangle = [angle for track, angle in sorted(tirf_angles.items(), key=lambda x: x[0])]
try:
self.track, self.detector = zip(
*[
+20 -5
View File
@@ -248,7 +248,7 @@ class Reader(AbstractReader, ABC):
return [(i - j, i - j + k) for i, j, k in zip(directory_entry.start, start, directory_entry.shape)]
@cached_property
def tiles(self):
def tiles(self) -> tuple[int, int]:
columns = 1
rows = 1
xml = self.reader.metadata()
@@ -335,6 +335,7 @@ class OmeParse:
self.get_light_sources()
self.get_filters()
self.get_pixels()
self.get_tirf_angle()
self.get_channels()
self.get_planes()
self.get_annotations()
@@ -555,6 +556,18 @@ class OmeParse:
elif self.size_z > 1 and distance.attrib["Id"] == "Z":
self.ome.images[0].pixels.physical_size_z = float(self.text(distance.find("Value"))) * 1e6
def get_tirf_angle(self) -> None:
if self.version == "1.0":
for track_setup in self.multi_track_setup:
tirf_angle = track_setup.find("TirfAngle")
if tirf_angle is not None:
self.ome.structured_annotations.append(
model.DoubleAnnotation(
description="TirfAngle", id="Annotation:0", value=50 * float(tirf_angle.text)
)
)
self.ome.images[0].annotation_refs.append(model.AnnotationRef(id="Annotation:0"))
@cached_property
def positions(self) -> tuple[float, float, Optional[float]]:
if self.version == "1.0":
@@ -677,14 +690,12 @@ class OmeParse:
else:
light_source_settings = None
self.ome.images[0].pixels.channels.append(
model.Channel(
channel = dict(
id=f"Channel:{idx}",
name=channel.attrib["Name"],
acquisition_mode=self.text(channel.find("AcquisitionMode")).replace( # type: ignore
"SingleMoleculeLocalisation", "SingleMoleculeImaging"
),
color=color,
detector_settings=model.DetectorSettings(
id=detector.attrib["Id"].replace(" ", ""), binning=binning
),
@@ -697,7 +708,11 @@ class OmeParse:
light_source_settings=light_source_settings,
samples_per_pixel=samples_per_pixel,
)
)
if color is not None:
channel["color"] = color
self.ome.images[0].pixels.channels.append(model.Channel(**channel))
def get_planes(self) -> None:
try:
+1 -1
View File
@@ -192,4 +192,4 @@ class Reader(AbstractReader, ABC):
}
def __frame__(self, c=0, z=0, t=0):
return tifffile.imread(self.path / self.filedict[(c, z, t)])
return tifffile.imread(self.path / self.filedict[c, z, t])
+84 -22
View File
@@ -1,16 +1,18 @@
import re
import warnings
from abc import ABC
from contextlib import ExitStack
from functools import cached_property
from itertools import product
from pathlib import Path
from typing import Any
import numpy as np
import tifffile
import yaml
from ome_types import from_xml, model
from ome_types import OME, from_xml, model
from .. import AbstractReader, try_default
from .. import AbstractReader, Shape, try_default
class Reader(AbstractReader, ABC):
@@ -18,7 +20,7 @@ class Reader(AbstractReader, ABC):
do_not_pickle = "reader"
@staticmethod
def _can_open(path):
def _can_open(path) -> bool:
if isinstance(path, Path) and path.suffix in (".tif", ".tiff"):
with tifffile.TiffFile(path) as tif:
return tif.is_imagej and tif.pages[-1]._nextifd() == 0 # noqa
@@ -26,13 +28,13 @@ class Reader(AbstractReader, ABC):
return False
@cached_property
def metadata(self):
def metadata(self) -> dict[str, Any]:
return {
key: try_default(yaml.safe_load, value, value) if isinstance(value, str) else value
for key, value in self.reader.imagej_metadata.items()
}
def get_ome(self):
def get_ome(self) -> OME:
if self.reader.is_ome:
pos_number_pat = re.compile(r"\d+")
@@ -88,13 +90,17 @@ class Reader(AbstractReader, ABC):
page = self.reader.pages[0]
size_y = page.imagelength
size_x = page.imagewidth
if self.p_ndim == 3:
size_c = page.samplesperpixel
size_t = self.metadata.get("frames", 1) # // C
else:
size_c = self.metadata.get("channels", 1)
size_t = self.metadata.get("frames", 1)
size_z = self.metadata.get("slices", 1)
max_c = max_z = max_t = 0
if self.file_dict:
for ci, zi, ti in self.file_dict.keys():
max_c = max(max_c, ci)
max_z = max(max_z, zi)
max_t = max(max_t, ti)
size_c: int = self.mod_shape["c"] * (max_c + 1) # type: ignore
size_z: int = self.mod_shape["z"] * (max_z + 1) # type: ignore
size_t: int = self.mod_shape["t"] * (max_t + 1) # type: ignore
if 282 in page.tags and 296 in page.tags and page.tags[296].value == 1:
f = page.tags[282].value
pxsize = f[1] / f[0]
@@ -142,11 +148,18 @@ class Reader(AbstractReader, ABC):
)
for c, z, t in product(range(size_c), range(size_z), range(size_t)):
ome.images[0].pixels.planes.append(model.Plane(the_c=c, the_z=z, the_t=t, delta_t=interval_t * t))
if (spacing := self.metadata.get("spacing")) is not None:
ome.images[0].pixels.physical_size_z = spacing
if (unit := self.metadata.get("unit")) is not None and unit.lower() != "micron":
raise ValueError(f"cannot parse unit {unit}")
return ome
def open(self):
def open(self) -> None:
if self.series != 0:
raise FileNotFoundError(f"Series {self.series} not found in {self.path}. Tifread only supports one series.")
raise FileNotFoundError(
f"Series {self.series} not found in {self.path}. Tifread only supports one series."
)
self.reader = tifffile.TiffFile(self.path)
page = self.reader.pages.first
self.p_ndim = page.ndim # noqa
@@ -154,19 +167,68 @@ class Reader(AbstractReader, ABC):
self.p_transpose = [i for i in [page.axes.find(j) for j in "SYX"] if i >= 0] # noqa
else:
self.p_transpose = [i for i in [page.axes.find(j) for j in "YX"] if i >= 0] # noqa
self.file_dict = {} # noqa
pat = re.compile(r"^.*?((?:_[czt]\d+)*)$", re.IGNORECASE)
if (m := pat.match(self.path.stem)) is not None:
m = m.group(1)
cpat = re.compile(r"_c(\d+)", re.IGNORECASE)
zpat = re.compile(r"_z(\d+)", re.IGNORECASE)
tpat = re.compile(r"_t(\d+)", re.IGNORECASE)
for file in self.path.parent.glob(
re.sub(m, re.sub(r"\d+", "*", m), self.path.stem, re.IGNORECASE) + self.path.suffix
):
m = pat.match(file.stem)
if m:
m = m.group(0)
c = cpat.findall(m)
z = zpat.findall(m)
t = tpat.findall(m)
c = int(c[0]) if len(c) else 0
z = int(z[0]) if len(z) else 0
t = int(t[0]) if len(t) else 0
self.file_dict[c, z, t] = file.name
def close(self):
page = self.reader.pages[0]
if self.p_ndim == 3:
mod_c = page.samplesperpixel
mod_t = self.metadata.get("frames", 1) # // C
else:
mod_c = self.metadata.get("channels", 1)
mod_t = self.metadata.get("frames", 1)
mod_z = self.metadata.get("slices")
if mod_z is None:
if self.metadata.get("spacing") is None:
mod_t = len(self.reader.pages)
else:
mod_z = len(self.reader.pages)
self.mod_shape = Shape((mod_c or 1, mod_z or 1, mod_t or 1), "czt") # noqa
def close(self) -> None:
self.reader.close()
def __frame__(self, c: int, z: int, t: int):
def __frame__(self, c: int, z: int, t: int) -> np.ndarray:
with ExitStack() as stack:
if self.file_dict:
filename = self.file_dict[
c - c % self.mod_shape["c"], z - z % self.mod_shape["z"], t - t % self.mod_shape["t"]
]
if self.reader.filename == filename:
reader = self.reader
else:
reader = stack.enter_context(tifffile.TiffFile(self.path.parent / filename))
else:
reader = self.reader
dimension_order = self.ome.images[0].pixels.dimension_order.value
if self.p_ndim == 3:
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "zt"])
ct = {"z": z, "t": t}
n = sum([ct[ax] * np.prod(self.base_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(self.reader.asarray(int(n)), self.p_transpose)[int(c)]
ct = {"z": z % self.mod_shape["z"], "t": t % self.mod_shape["t"]}
n = sum([ct[ax] * np.prod(self.mod_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(reader.asarray(int(n)), self.p_transpose)[int(c % self.mod_shape["c"])]
else:
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "czt"])
czt = {"c": c, "z": z, "t": t}
n = sum([czt[ax] * np.prod(self.base_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(self.reader.asarray(int(n)), self.p_transpose)
czt = {"c": c % self.mod_shape["c"], "z": z % self.mod_shape["z"], "t": t % self.mod_shape["t"]}
n = sum([czt[ax] * np.prod(self.mod_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(reader.asarray(int(n)), self.p_transpose)
+4 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "ndbioimage"
version = "2026.4.0"
version = "2026.9.0"
description = "Bio image reading, metadata and some affine registration."
authors = [
{ name = "W. Pomp", email = "w.pomp@nki.nl" }
@@ -24,8 +24,8 @@ dependencies = [
"pyyaml",
"SimpleITK-SimpleElastix; sys_platform != 'darwin'",
"scikit-image",
"tifffile <= 2025.1.10",
"tiffwrite >= 2024.12.1",
"tifffile >= 2024.1.30, <= 2025.1.10",
"tiffwrite >= 2026.5.0",
"tqdm",
]
@@ -35,7 +35,7 @@ write = ["matplotlib", "scikit-video"]
bioformats = ["JPype1"]
[project.urls]
repository = "https://git.wimpomp.nl/wim/focusfeedbackgui"
repository = "https://git.wimpomp.nl/wim/ndbioimage"
[project.scripts]
ndbioimage = "ndbioimage:main"