Compare commits

..
13 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
Wim Pomp 46c511dd49 - 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:13:43 +02:00
w.pomp 0ee456a064 - improve support for tiled czi's 2026-03-30 11:35:25 +02:00
w.pomp 6123eeee8b - add support for tiled czi's: bugfix 2 2026-03-26 17:05:37 +01:00
w.pomp e56ef334f4 - add support for tiled czi's: bugfix 2026-03-26 16:52:18 +01:00
w.pomp bba24f2156 - add support for tiled czi's 2026-03-26 16:39:16 +01:00
w.pomp 7e9cf46d55 - limit imagecodecs version 2026-03-11 21:00:53 +01:00
w.pomp 351f563867 - cziread: fallback for when tile indices do not work 2026-03-11 20:51:32 +01:00
w.pomp 4563908254 - make paths absolute if possible
- seqfind: read some metadata from display_and_comments.txt if needed and present
2026-03-03 15:56:27 +01:00
7 changed files with 285 additions and 95 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
+31 -17
View File
@@ -87,6 +87,7 @@ def find(obj: Sequence[Any], **kwargs: Any) -> Any:
return item return item
except AttributeError: except AttributeError:
pass pass
return None
R = TypeVar("R") R = TypeVar("R")
@@ -154,23 +155,26 @@ class OmeCache(DequeDict):
def __reduce__(self) -> tuple[type, tuple]: def __reduce__(self) -> tuple[type, tuple]:
return self.__class__, () return self.__class__, ()
def __getitem__(self, path: Path | str | tuple) -> OME: def __getitem__(self, path_and_series: tuple[Path | str | tuple, int]) -> OME:
path, series = path_and_series
if isinstance(path, tuple): if isinstance(path, tuple):
return super().__getitem__(path) return super().__getitem__((path, series))
else: else:
return super().__getitem__(self.path_and_lstat(path)) return super().__getitem__((self.path_and_lstat(path), series))
def __setitem__(self, path: Path | str | tuple, value: OME) -> None: def __setitem__(self, path_and_series: tuple[Path | str | tuple, int], value: OME) -> None:
path, series = path_and_series
if isinstance(path, tuple): if isinstance(path, tuple):
super().__setitem__(path, value) super().__setitem__((path, series), value)
else: else:
super().__setitem__(self.path_and_lstat(path), value) super().__setitem__((self.path_and_lstat(path), series), value)
def __contains__(self, path: Path | str | tuple) -> bool: def __contains__(self, path_and_series: tuple[Path | str | tuple, int]) -> bool:
path, series = path_and_series
if isinstance(path, tuple): if isinstance(path, tuple):
return super().__contains__(path) return super().__contains__((path, series))
else: else:
return super().__contains__(self.path_and_lstat(path)) return super().__contains__((self.path_and_lstat(path), series))
@staticmethod @staticmethod
def path_and_lstat(path: str | Path) -> tuple[Path, Optional[os.stat_result], Optional[os.stat_result]]: def path_and_lstat(path: str | Path) -> tuple[Path, Optional[os.stat_result], Optional[os.stat_result]]:
@@ -187,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
@@ -1025,7 +1030,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
return [self.get_channel(c) for c in czt[0]], *czt[1:3] # type: ignore return [self.get_channel(c) for c in czt[0]], *czt[1:3] # type: ignore
@staticmethod @staticmethod
def bioformats_ome(path: [str, Path]) -> OME: def bioformats_ome(path: str | Path) -> OME:
"""Use java BioFormats to make an ome metadata structure.""" """Use java BioFormats to make an ome metadata structure."""
with multiprocessing.get_context("spawn").Pool(1) as pool: with multiprocessing.get_context("spawn").Pool(1) as pool:
return pool.map(bioformats_ome, (path,))[0] return pool.map(bioformats_ome, (path,))[0]
@@ -1057,10 +1062,11 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
return ome return ome
@staticmethod @staticmethod
def read_ome(path: [str, Path]) -> Optional[OME]: def read_ome(path: str | Path) -> Optional[OME]:
path = Path(path) path = Path(path)
if path.with_suffix(".ome.xml").exists(): if path.with_suffix(".ome.xml").exists():
return OME.from_xml(path.with_suffix(".ome.xml")) return OME.from_xml(path.with_suffix(".ome.xml"))
return None
def get_ome(self) -> OME: def get_ome(self) -> OME:
"""overload this""" """overload this"""
@@ -1069,12 +1075,12 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
@cached_property @cached_property
def ome(self) -> OME: def ome(self) -> OME:
cache = OmeCache() cache = OmeCache()
if self.path not in cache: if (self.path, self.series) not in cache:
ome = self.read_ome(self.path) ome = self.read_ome(self.path)
if ome is None: if ome is None:
ome = self.get_ome() ome = self.get_ome()
cache[self.path] = self.fix_ome(ome) cache[self.path, self.series] = self.fix_ome(ome)
return cache[self.path] return cache[self.path, self.series]
def is_noise(self, volume: ArrayLike = None) -> bool: def is_noise(self, volume: ArrayLike = None) -> bool:
"""True if volume only has noise""" """True if volume only has noise"""
@@ -1306,9 +1312,11 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
@staticmethod @staticmethod
def split_path_series(path: Path | str) -> tuple[Path, int]: def split_path_series(path: Path | str) -> tuple[Path, int]:
if isinstance(path, str): if isinstance(path, str):
path = Path(path) path = Path(path).absolute()
if isinstance(path, Path) and path.name.startswith("Pos") and path.name.lstrip("Pos").isdigit(): if isinstance(path, Path) and path.name.startswith("Pos") and path.name.lstrip("Pos").isdigit():
return path.parent, int(path.name.lstrip("Pos")) return path.absolute().parent, int(path.name.lstrip("Pos"))
if isinstance(path, Path):
return path.absolute(), 0
return path, 0 return path, 0
def view(self, *args: Any, **kwargs: Any) -> View: def view(self, *args: Any, **kwargs: Any) -> View:
@@ -1521,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(
*[ *[
+139 -40
View File
@@ -14,7 +14,7 @@ from lxml import etree
from ome_types import OME, model from ome_types import OME, model
from tifffile import repeat_nd from tifffile import repeat_nd
from .. import AbstractReader from .. import AbstractReader, ureg
try: try:
# TODO: use zoom from imagecodecs implementation when available # TODO: use zoom from imagecodecs implementation when available
@@ -152,6 +152,14 @@ def data(self, raw: bool = False, resize: bool = True, order: int = 0) -> np.nda
czifile.czifile.SubBlockSegment.data = data czifile.czifile.SubBlockSegment.data = data
def xml_walk(tree, elements):
element, *elements = elements
if elements:
return [j for i in tree.findall(element) for j in xml_walk(i, elements)]
else:
return tree.findall(element)
class Reader(AbstractReader, ABC): class Reader(AbstractReader, ABC):
priority = 0 priority = 0
do_not_pickle = "reader", "filedict" do_not_pickle = "reader", "filedict"
@@ -163,16 +171,55 @@ class Reader(AbstractReader, ABC):
def open(self) -> None: def open(self) -> None:
self.reader = czifile.CziFile(self.path) self.reader = czifile.CziFile(self.path)
filedict = {} filedict = {}
syx = set()
si = self.reader.axes.index("S") if "S" in self.reader.axes else None
ci = self.reader.axes.index("C") if "C" in self.reader.axes else None
zi = self.reader.axes.index("Z") if "Z" in self.reader.axes else None
ti = self.reader.axes.index("T") if "T" in self.reader.axes else None
yi = self.reader.axes.index("Y") if "Y" in self.reader.axes else None
xi = self.reader.axes.index("X") if "X" in self.reader.axes else None
if si is None and self.series > 0:
raise FileNotFoundError(f"Series {self.series} not found in {self.path}.")
for directory_entry in self.reader.filtered_subblock_directory: for directory_entry in self.reader.filtered_subblock_directory:
idx = self.get_index(directory_entry, self.reader.start) idx = self.get_index(directory_entry, self.reader.start)
if "S" not in self.reader.axes or self.series in range(*idx[self.reader.axes.index("S")]): syx.add((0 if si is None else idx[si], idx[yi], idx[xi]))
for c in range(*idx[self.reader.axes.index("C")]):
for z in range(*idx[self.reader.axes.index("Z")]): if self.tiles != (1, 1):
for t in range(*idx[self.reader.axes.index("T")]): assert len({s for s, *_ in list(syx)}) == 1, "multiple tiled series not supported"
if (c, z, t) in filedict: x, y = np.array(list(syx))[:, 1:, 0].T
filedict[c, z, t].append(directory_entry) a, b = np.min(x), np.max(x)
else: n = self.tiles[0]
filedict[c, z, t] = [directory_entry] bx = np.linspace(a - (b - a) / (n - 1) / 2, b + (b - a) / (n - 1) / 2, n + 1)
a, b = np.min(y), np.max(y)
n = self.tiles[1]
by = np.linspace(a - (b - a) / (n - 1) / 2, b + (b - a) / (n - 1) / 2, n + 1)
b = list(product([(i, j) for i, j in zip(by, by[1:])], [(i, j) for i, j in zip(bx, bx[1:])]))
if self.series < len(b):
by, bx = b[self.series]
else:
raise FileNotFoundError(f"Series {self.series} not found in {self.path}.")
for directory_entry in self.reader.filtered_subblock_directory:
idx = self.get_index(directory_entry, self.reader.start)
if bx[0] < idx[xi][0] < bx[1] and by[0] < idx[yi][0] < by[1]:
for cj in (0,) if ci is None else range(*idx[ci]):
for zj in (0,) if zi is None else range(*idx[zi]):
for tj in (0,) if ti is None else range(*idx[ti]):
if (cj, zj, tj) in filedict:
filedict[cj, zj, tj].append(directory_entry)
else:
filedict[cj, zj, tj] = [directory_entry]
else:
for directory_entry in self.reader.filtered_subblock_directory:
idx = self.get_index(directory_entry, self.reader.start)
if si is None or self.series == idx[si][0]:
for cj in (0,) if ci is None else range(*idx[ci]):
for zj in (0,) if zi is None else range(*idx[zi]):
for tj in (0,) if ti is None else range(*idx[ti]):
if (cj, zj, tj) in filedict:
filedict[cj, zj, tj].append(directory_entry)
else:
filedict[cj, zj, tj] = [directory_entry]
if len(filedict) == 0: if len(filedict) == 0:
raise FileNotFoundError(f"Series {self.series} not found in {self.path}.") raise FileNotFoundError(f"Series {self.series} not found in {self.path}.")
self.filedict = filedict # noqa self.filedict = filedict # noqa
@@ -187,17 +234,11 @@ class Reader(AbstractReader, ABC):
f = np.zeros(self.base_shape["yx"], self.dtype) f = np.zeros(self.base_shape["yx"], self.dtype)
if (c, z, t) in self.filedict: if (c, z, t) in self.filedict:
directory_entries = self.filedict[c, z, t] directory_entries = self.filedict[c, z, t]
x_min = min([f.start[f.axes.index("X")] for f in directory_entries]) start = np.min([directory_entry.start for directory_entry in directory_entries], 0)
y_min = min([f.start[f.axes.index("Y")] for f in directory_entries])
xy_min = {"X": x_min, "Y": y_min}
for directory_entry in directory_entries: for directory_entry in directory_entries:
subblock = directory_entry.data_segment() subblock = directory_entry.data_segment()
tile = subblock.data(resize=True, order=0) tile = subblock.data(resize=True, order=0)
axes_min = [xy_min.get(ax, 0) for ax in directory_entry.axes] index = [slice(i - j, i - j + k) for i, j, k in zip(directory_entry.start, start, tile.shape)]
index = [
slice(i - j - m, i - j + k)
for i, j, k, m in zip(directory_entry.start, self.reader.start, tile.shape, axes_min)
]
index = tuple(index[self.reader.axes.index(i)] for i in "YX") index = tuple(index[self.reader.axes.index(i)] for i in "YX")
f[index] = tile.squeeze() f[index] = tile.squeeze()
return f return f
@@ -206,6 +247,38 @@ class Reader(AbstractReader, ABC):
def get_index(directory_entry: czifile.DirectoryEntryDV, start: tuple[int]) -> list[tuple[int, int]]: def get_index(directory_entry: czifile.DirectoryEntryDV, start: tuple[int]) -> list[tuple[int, int]]:
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
def tiles(self) -> tuple[int, int]:
columns = 1
rows = 1
xml = self.reader.metadata()
tree = etree.fromstring(xml)
tile_regions = xml_walk(
tree,
(
"Metadata",
"Experiment",
"ExperimentBlocks",
"AcquisitionBlock",
"SubDimensionSetups",
"RegionsSetup",
"SampleHolder",
"TileRegions",
"TileRegion",
),
)
for tile_region in tile_regions:
used = tile_region.find("IsUsedForAcquisition")
if used is not None and used.text.lower() == "true":
c = tile_region.find("Columns")
if c is not None:
columns = int(c.text)
r = tile_region.find("Rows")
if r is not None:
rows = int(r.text)
break
return columns, rows
class OmeParse: class OmeParse:
size_x: int size_x: int
@@ -262,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()
@@ -429,7 +503,7 @@ class OmeParse:
self.size_x = x_max - x_min self.size_x = x_max - x_min
self.size_y = y_max - y_min self.size_y = y_max - y_min
self.size_c, self.size_z, self.size_t = ( self.size_c, self.size_z, self.size_t = (
self.reader.shape[self.reader.axes.index(directory_entry)] for directory_entry in "CZT" self.reader.shape[self.reader.axes.index(axis)] if axis in self.reader.axes else 1 for axis in "CZT"
) )
image = self.information.find("Image") image = self.information.find("Image")
pixel_type = self.text(image.find("PixelType"), "Gray16") pixel_type = self.text(image.find("PixelType"), "Gray16")
@@ -482,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":
@@ -495,6 +581,8 @@ class OmeParse:
except AttributeError: except AttributeError:
center_position = [0, 0] center_position = [0, 0]
return center_position[0], center_position[1], None return center_position[0], center_position[1], None
else:
raise NotImplementedError(f"unknown czi version: {self.version}")
@cached_property @cached_property
def channels_im(self) -> dict: def channels_im(self) -> dict:
@@ -602,28 +690,30 @@ 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" ),
), detector_settings=model.DetectorSettings(
color=color, id=detector.attrib["Id"].replace(" ", ""), binning=binning
detector_settings=model.DetectorSettings( ),
id=detector.attrib["Id"].replace(" ", ""), binning=binning emission_wavelength=emission_wavelength,
), excitation_wavelength=self.try_default(
emission_wavelength=emission_wavelength, float, None, self.text(channel.find("ExcitationWavelength"))
excitation_wavelength=self.try_default( ),
float, None, self.text(channel.find("ExcitationWavelength")) # filter_set_ref=model.FilterSetRef(id=ome.instruments[0].filter_sets[filterset_idx].id),
), illumination_type=self.text(channel.find("IlluminationType")), # type: ignore
# filter_set_ref=model.FilterSetRef(id=ome.instruments[0].filter_sets[filterset_idx].id), light_source_settings=light_source_settings,
illumination_type=self.text(channel.find("IlluminationType")), # type: ignore samples_per_pixel=samples_per_pixel,
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: def get_planes(self) -> None:
try: try:
exposure_times = [ exposure_times = [
@@ -639,7 +729,16 @@ class OmeParse:
delta_ts = dt * np.arange(len(delta_ts)) delta_ts = dt * np.arange(len(delta_ts))
warnings.warn(f"delta_t is inconsistent, using median value: {dt}") warnings.warn(f"delta_t is inconsistent, using median value: {dt}")
pxsize_x = self.ome.images[0].pixels.physical_size_x_quantity.to(ureg.um).magnitude
pxsize_y = self.ome.images[0].pixels.physical_size_y_quantity.to(ureg.um).magnitude
for t, z, c in product(range(self.size_t), range(self.size_z), range(self.size_c)): for t, z, c in product(range(self.size_t), range(self.size_z), range(self.size_c)):
x_min = (
min([f.start[f.axes.index("X")] for f in self.filedict[c, z, t]]) if (c, z, t) in self.filedict else 0
)
y_min = (
min([f.start[f.axes.index("Y")] for f in self.filedict[c, z, t]]) if (c, z, t) in self.filedict else 0
)
self.ome.images[0].pixels.planes.append( self.ome.images[0].pixels.planes.append(
model.Plane( model.Plane(
the_c=c, the_c=c,
@@ -647,9 +746,9 @@ class OmeParse:
the_t=t, the_t=t,
delta_t=delta_ts[t], delta_t=delta_ts[t],
exposure_time=exposure_times[min(c, len(exposure_times) - 1)] if len(exposure_times) > 0 else None, exposure_time=exposure_times[min(c, len(exposure_times) - 1)] if len(exposure_times) > 0 else None,
position_x=self.positions[0], position_x=self.positions[0] + x_min * pxsize_x,
position_x_unit=self.um, position_x_unit=self.um,
position_y=self.positions[1], position_y=self.positions[1] + y_min * pxsize_y,
position_y_unit=self.um, position_y_unit=self.um,
position_z=self.positions[2], position_z=self.positions[2],
position_z_unit=self.um, position_z_unit=self.um,
+18 -6
View File
@@ -1,4 +1,5 @@
import re import re
import warnings
from abc import ABC from abc import ABC
from datetime import datetime from datetime import datetime
from itertools import product from itertools import product
@@ -59,9 +60,10 @@ class Reader(AbstractReader, ABC):
ome = model.OME() ome = model.OME()
with tifffile.TiffFile(self.filedict[0, 0, 0]) as tif: with tifffile.TiffFile(self.filedict[0, 0, 0]) as tif:
metadata = {key: yaml.safe_load(value) for key, value in tif.pages[0].tags[50839].value.items()} metadata = {key: yaml.safe_load(value) for key, value in tif.pages[0].tags[50839].value.items()}
ome.experimenters.append( if "Summary" in metadata["Info"] and "UserName" in metadata["Info"]["Summary"]:
model.Experimenter(id="Experimenter:0", user_name=metadata["Info"]["Summary"]["UserName"]) ome.experimenters.append(
) model.Experimenter(id="Experimenter:0", user_name=metadata["Info"]["Summary"]["UserName"])
)
objective_str = metadata["Info"]["ZeissObjectiveTurret-Label"] objective_str = metadata["Info"]["ZeissObjectiveTurret-Label"]
ome.instruments.append(model.Instrument()) ome.instruments.append(model.Instrument())
ome.instruments[0].objectives.append( ome.instruments[0].objectives.append(
@@ -112,7 +114,9 @@ class Reader(AbstractReader, ABC):
type=pixel_type, type=pixel_type,
physical_size_x=pxsize, physical_size_x=pxsize,
physical_size_y=pxsize, physical_size_y=pxsize,
physical_size_z=metadata["Info"]["Summary"]["z-step_um"], physical_size_z=metadata["Info"]["Summary"]["z-step_um"]
if "Summary" in metadata["Info"]
else None,
), ),
objective_settings=model.ObjectiveSettings(id="Objective:0"), objective_settings=model.ObjectiveSettings(id="Objective:0"),
) )
@@ -164,7 +168,15 @@ class Reader(AbstractReader, ABC):
metadata = {key: yaml.safe_load(value) for key, value in tif.pages[0].tags[50839].value.items()} metadata = {key: yaml.safe_load(value) for key, value in tif.pages[0].tags[50839].value.items()}
# compare channel names from metadata with filenames # compare channel names from metadata with filenames
cnamelist = metadata["Info"]["Summary"]["ChNames"] if "Summary" in metadata["Info"] and "ChNames" in metadata["Info"]["Summary"]:
cnamelist = metadata["Info"]["Summary"]["ChNames"]
elif (self.path.parent / "display_and_comments.txt").exists():
warnings.warn(f"{self.path} is missing some metadata")
with open(self.path.parent / "display_and_comments.txt") as f:
cnamelist = [channel["Name"] for channel in yaml.safe_load(f)["Channels"]]
else:
raise ValueError("could not find metadata describing the order of the channels")
cnamelist = [c for c in cnamelist if any([c in f.name for f in filelist])] cnamelist = [c for c in cnamelist if any([c in f.name for f in filelist])]
pattern_c = re.compile(r"img_\d{3,}_(.*)_\d{3,}$", re.IGNORECASE) pattern_c = re.compile(r"img_\d{3,}_(.*)_\d{3,}$", re.IGNORECASE)
@@ -180,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])
+90 -26
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,9 +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:
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
@@ -152,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:
dimension_order = self.ome.images[0].pixels.dimension_order.value with ExitStack() as stack:
if self.p_ndim == 3: if self.file_dict:
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "zt"]) filename = self.file_dict[
ct = {"z": z, "t": t} c - c % self.mod_shape["c"], 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)]) ]
return np.transpose(self.reader.asarray(int(n)), self.p_transpose)[int(c)] if self.reader.filename == filename:
else: reader = self.reader
axes = "".join([ax.lower() for ax in dimension_order if ax.lower() in "czt"]) else:
czt = {"c": c, "z": z, "t": t} reader = stack.enter_context(tifffile.TiffFile(self.path.parent / filename))
n = sum([czt[ax] * np.prod(self.base_shape[axes[:i]]) for i, ax in enumerate(axes)]) else:
return np.transpose(self.reader.asarray(int(n)), self.p_transpose) 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 % 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 % 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)
+5 -5
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ndbioimage" name = "ndbioimage"
version = "2026.1.2" 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" }
@@ -14,7 +14,7 @@ exclude = ["ndbioimage/jars"]
dependencies = [ dependencies = [
"czifile == 2019.7.2", "czifile == 2019.7.2",
"imagecodecs", "imagecodecs <= 2026.1.14",
"lxml", "lxml",
"numpy >= 1.20", "numpy >= 1.20",
"ome-types", "ome-types",
@@ -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://github.com/wimpomp/ndbioimage" repository = "https://git.wimpomp.nl/wim/ndbioimage"
[project.scripts] [project.scripts]
ndbioimage = "ndbioimage:main" ndbioimage = "ndbioimage:main"