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 /poetry.lock
/dist/ /dist/
/uv.lock /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 # 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]) 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, """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 currently optimized for .czi files, but can open anything that bioformats can handle
path: path to the image file path: path to the image file
@@ -1528,6 +1529,12 @@ class AbstractReader(Imread, metaclass=ABCMeta):
self.immersionN = 1 self.immersionN = 1
p = re.compile(r"(\d+):(\d+)$") 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: try:
self.track, self.detector = zip( 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)] return [(i - j, i - j + k) for i, j, k in zip(directory_entry.start, start, directory_entry.shape)]
@cached_property @cached_property
def tiles(self): def tiles(self) -> tuple[int, int]:
columns = 1 columns = 1
rows = 1 rows = 1
xml = self.reader.metadata() xml = self.reader.metadata()
@@ -335,6 +335,7 @@ class OmeParse:
self.get_light_sources() self.get_light_sources()
self.get_filters() self.get_filters()
self.get_pixels() self.get_pixels()
self.get_tirf_angle()
self.get_channels() self.get_channels()
self.get_planes() self.get_planes()
self.get_annotations() self.get_annotations()
@@ -555,6 +556,18 @@ class OmeParse:
elif self.size_z > 1 and distance.attrib["Id"] == "Z": 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 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 @cached_property
def positions(self) -> tuple[float, float, Optional[float]]: def positions(self) -> tuple[float, float, Optional[float]]:
if self.version == "1.0": if self.version == "1.0":
@@ -677,14 +690,12 @@ class OmeParse:
else: else:
light_source_settings = None light_source_settings = None
self.ome.images[0].pixels.channels.append( channel = dict(
model.Channel(
id=f"Channel:{idx}", id=f"Channel:{idx}",
name=channel.attrib["Name"], name=channel.attrib["Name"],
acquisition_mode=self.text(channel.find("AcquisitionMode")).replace( # type: ignore acquisition_mode=self.text(channel.find("AcquisitionMode")).replace( # type: ignore
"SingleMoleculeLocalisation", "SingleMoleculeImaging" "SingleMoleculeLocalisation", "SingleMoleculeImaging"
), ),
color=color,
detector_settings=model.DetectorSettings( detector_settings=model.DetectorSettings(
id=detector.attrib["Id"].replace(" ", ""), binning=binning id=detector.attrib["Id"].replace(" ", ""), binning=binning
), ),
@@ -697,7 +708,11 @@ class OmeParse:
light_source_settings=light_source_settings, light_source_settings=light_source_settings,
samples_per_pixel=samples_per_pixel, 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: def get_planes(self) -> None:
try: try:
+1 -1
View File
@@ -192,4 +192,4 @@ class Reader(AbstractReader, ABC):
} }
def __frame__(self, c=0, z=0, t=0): 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 re
import warnings import warnings
from abc import ABC from abc import ABC
from contextlib import ExitStack
from functools import cached_property from functools import cached_property
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
from typing import Any
import numpy as np import numpy as np
import tifffile import tifffile
import yaml 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): class Reader(AbstractReader, ABC):
@@ -18,7 +20,7 @@ class Reader(AbstractReader, ABC):
do_not_pickle = "reader" do_not_pickle = "reader"
@staticmethod @staticmethod
def _can_open(path): def _can_open(path) -> bool:
if isinstance(path, Path) and path.suffix in (".tif", ".tiff"): if isinstance(path, Path) and path.suffix in (".tif", ".tiff"):
with tifffile.TiffFile(path) as tif: with tifffile.TiffFile(path) as tif:
return tif.is_imagej and tif.pages[-1]._nextifd() == 0 # noqa return tif.is_imagej and tif.pages[-1]._nextifd() == 0 # noqa
@@ -26,13 +28,13 @@ class Reader(AbstractReader, ABC):
return False return False
@cached_property @cached_property
def metadata(self): def metadata(self) -> dict[str, Any]:
return { return {
key: try_default(yaml.safe_load, value, value) if isinstance(value, str) else value key: try_default(yaml.safe_load, value, value) if isinstance(value, str) else value
for key, value in self.reader.imagej_metadata.items() for key, value in self.reader.imagej_metadata.items()
} }
def get_ome(self): def get_ome(self) -> OME:
if self.reader.is_ome: if self.reader.is_ome:
pos_number_pat = re.compile(r"\d+") pos_number_pat = re.compile(r"\d+")
@@ -88,13 +90,17 @@ class Reader(AbstractReader, ABC):
page = self.reader.pages[0] page = self.reader.pages[0]
size_y = page.imagelength size_y = page.imagelength
size_x = page.imagewidth size_x = page.imagewidth
if self.p_ndim == 3: max_c = max_z = max_t = 0
size_c = page.samplesperpixel if self.file_dict:
size_t = self.metadata.get("frames", 1) # // C for ci, zi, ti in self.file_dict.keys():
else: max_c = max(max_c, ci)
size_c = self.metadata.get("channels", 1) max_z = max(max_z, zi)
size_t = self.metadata.get("frames", 1) max_t = max(max_t, ti)
size_z = self.metadata.get("slices", 1)
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: if 282 in page.tags and 296 in page.tags and page.tags[296].value == 1:
f = page.tags[282].value f = page.tags[282].value
pxsize = f[1] / f[0] 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)): 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)) 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 return ome
def open(self): def open(self) -> None:
if self.series != 0: 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) self.reader = tifffile.TiffFile(self.path)
page = self.reader.pages.first page = self.reader.pages.first
self.p_ndim = page.ndim # noqa 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 self.p_transpose = [i for i in [page.axes.find(j) for j in "SYX"] if i >= 0] # noqa
else: else:
self.p_transpose = [i for i in [page.axes.find(j) for j in "YX"] if i >= 0] # noqa 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() 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 dimension_order = self.ome.images[0].pixels.dimension_order.value
if self.p_ndim == 3: if self.p_ndim == 3:
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "zt"]) axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "zt"])
ct = {"z": z, "t": t} ct = {"z": z % self.mod_shape["z"], "t": t % self.mod_shape["t"]}
n = sum([ct[ax] * np.prod(self.base_shape[axes[:i]]) for i, ax in enumerate(axes)]) n = sum([ct[ax] * np.prod(self.mod_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(self.reader.asarray(int(n)), self.p_transpose)[int(c)] return np.transpose(reader.asarray(int(n)), self.p_transpose)[int(c % self.mod_shape["c"])]
else: else:
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "czt"]) axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "czt"])
czt = {"c": c, "z": z, "t": t} 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.base_shape[axes[:i]]) for i, ax in enumerate(axes)]) n = sum([czt[ax] * np.prod(self.mod_shape[axes[:i]]) for i, ax in enumerate(axes)])
return np.transpose(self.reader.asarray(int(n)), self.p_transpose) return np.transpose(reader.asarray(int(n)), self.p_transpose)
+4 -4
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ndbioimage" name = "ndbioimage"
version = "2026.4.0" version = "2026.9.0"
description = "Bio image reading, metadata and some affine registration." description = "Bio image reading, metadata and some affine registration."
authors = [ authors = [
{ name = "W. Pomp", email = "w.pomp@nki.nl" } { name = "W. Pomp", email = "w.pomp@nki.nl" }
@@ -24,8 +24,8 @@ dependencies = [
"pyyaml", "pyyaml",
"SimpleITK-SimpleElastix; sys_platform != 'darwin'", "SimpleITK-SimpleElastix; sys_platform != 'darwin'",
"scikit-image", "scikit-image",
"tifffile <= 2025.1.10", "tifffile >= 2024.1.30, <= 2025.1.10",
"tiffwrite >= 2024.12.1", "tiffwrite >= 2026.5.0",
"tqdm", "tqdm",
] ]
@@ -35,7 +35,7 @@ write = ["matplotlib", "scikit-video"]
bioformats = ["JPype1"] bioformats = ["JPype1"]
[project.urls] [project.urls]
repository = "https://git.wimpomp.nl/wim/focusfeedbackgui" repository = "https://git.wimpomp.nl/wim/ndbioimage"
[project.scripts] [project.scripts]
ndbioimage = "ndbioimage:main" ndbioimage = "ndbioimage:main"