Compare commits

..

8 Commits

Author SHA1 Message Date
Wim Pomp
46c511dd49 - let tifread raise FileNotFoundError when series != 0
Some checks failed
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
5 changed files with 149 additions and 44 deletions

View File

@@ -87,6 +87,7 @@ def find(obj: Sequence[Any], **kwargs: Any) -> Any:
return item
except AttributeError:
pass
return None
R = TypeVar("R")
@@ -154,23 +155,26 @@ class OmeCache(DequeDict):
def __reduce__(self) -> tuple[type, tuple]:
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):
return super().__getitem__(path)
return super().__getitem__((path, series))
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):
super().__setitem__(path, value)
super().__setitem__((path, series), value)
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):
return super().__contains__(path)
return super().__contains__((path, series))
else:
return super().__contains__(self.path_and_lstat(path))
return super().__contains__((self.path_and_lstat(path), series))
@staticmethod
def path_and_lstat(path: str | Path) -> tuple[Path, Optional[os.stat_result], Optional[os.stat_result]]:
@@ -1025,7 +1029,7 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
return [self.get_channel(c) for c in czt[0]], *czt[1:3] # type: ignore
@staticmethod
def bioformats_ome(path: [str, Path]) -> OME:
def bioformats_ome(path: str | Path) -> OME:
"""Use java BioFormats to make an ome metadata structure."""
with multiprocessing.get_context("spawn").Pool(1) as pool:
return pool.map(bioformats_ome, (path,))[0]
@@ -1057,10 +1061,11 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
return ome
@staticmethod
def read_ome(path: [str, Path]) -> Optional[OME]:
def read_ome(path: str | Path) -> Optional[OME]:
path = Path(path)
if path.with_suffix(".ome.xml").exists():
return OME.from_xml(path.with_suffix(".ome.xml"))
return None
def get_ome(self) -> OME:
"""overload this"""
@@ -1069,12 +1074,12 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
@cached_property
def ome(self) -> OME:
cache = OmeCache()
if self.path not in cache:
if (self.path, self.series) not in cache:
ome = self.read_ome(self.path)
if ome is None:
ome = self.get_ome()
cache[self.path] = self.fix_ome(ome)
return cache[self.path]
cache[self.path, self.series] = self.fix_ome(ome)
return cache[self.path, self.series]
def is_noise(self, volume: ArrayLike = None) -> bool:
"""True if volume only has noise"""
@@ -1306,9 +1311,11 @@ class Imread(np.lib.mixins.NDArrayOperatorsMixin, ABC):
@staticmethod
def split_path_series(path: Path | str) -> tuple[Path, int]:
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():
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
def view(self, *args: Any, **kwargs: Any) -> View:

View File

@@ -14,7 +14,7 @@ from lxml import etree
from ome_types import OME, model
from tifffile import repeat_nd
from .. import AbstractReader
from .. import AbstractReader, ureg
try:
# 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
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):
priority = 0
do_not_pickle = "reader", "filedict"
@@ -163,16 +171,55 @@ class Reader(AbstractReader, ABC):
def open(self) -> None:
self.reader = czifile.CziFile(self.path)
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:
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")]):
for c in range(*idx[self.reader.axes.index("C")]):
for z in range(*idx[self.reader.axes.index("Z")]):
for t in range(*idx[self.reader.axes.index("T")]):
if (c, z, t) in filedict:
filedict[c, z, t].append(directory_entry)
syx.add((0 if si is None else idx[si], idx[yi], idx[xi]))
if self.tiles != (1, 1):
assert len({s for s, *_ in list(syx)}) == 1, "multiple tiled series not supported"
x, y = np.array(list(syx))[:, 1:, 0].T
a, b = np.min(x), np.max(x)
n = self.tiles[0]
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:
filedict[c, z, t] = [directory_entry]
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:
raise FileNotFoundError(f"Series {self.series} not found in {self.path}.")
self.filedict = filedict # noqa
@@ -187,17 +234,11 @@ class Reader(AbstractReader, ABC):
f = np.zeros(self.base_shape["yx"], self.dtype)
if (c, z, t) in self.filedict:
directory_entries = self.filedict[c, z, t]
x_min = min([f.start[f.axes.index("X")] for f in directory_entries])
y_min = min([f.start[f.axes.index("Y")] for f in directory_entries])
xy_min = {"X": x_min, "Y": y_min}
start = np.min([directory_entry.start for directory_entry in directory_entries], 0)
for directory_entry in directory_entries:
subblock = directory_entry.data_segment()
tile = subblock.data(resize=True, order=0)
axes_min = [xy_min.get(ax, 0) for ax in directory_entry.axes]
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 = [slice(i - j, i - j + k) for i, j, k in zip(directory_entry.start, start, tile.shape)]
index = tuple(index[self.reader.axes.index(i)] for i in "YX")
f[index] = tile.squeeze()
return f
@@ -206,6 +247,38 @@ class Reader(AbstractReader, ABC):
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)]
@cached_property
def tiles(self):
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:
size_x: int
@@ -429,7 +502,7 @@ class OmeParse:
self.size_x = x_max - x_min
self.size_y = y_max - y_min
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")
pixel_type = self.text(image.find("PixelType"), "Gray16")
@@ -495,6 +568,8 @@ class OmeParse:
except AttributeError:
center_position = [0, 0]
return center_position[0], center_position[1], None
else:
raise NotImplementedError(f"unknown czi version: {self.version}")
@cached_property
def channels_im(self) -> dict:
@@ -639,7 +714,16 @@ class OmeParse:
delta_ts = dt * np.arange(len(delta_ts))
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)):
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(
model.Plane(
the_c=c,
@@ -647,9 +731,9 @@ class OmeParse:
the_t=t,
delta_t=delta_ts[t],
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_y=self.positions[1],
position_y=self.positions[1] + y_min * pxsize_y,
position_y_unit=self.um,
position_z=self.positions[2],
position_z_unit=self.um,

View File

@@ -1,4 +1,5 @@
import re
import warnings
from abc import ABC
from datetime import datetime
from itertools import product
@@ -59,6 +60,7 @@ class Reader(AbstractReader, ABC):
ome = model.OME()
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()}
if "Summary" in metadata["Info"] and "UserName" in metadata["Info"]["Summary"]:
ome.experimenters.append(
model.Experimenter(id="Experimenter:0", user_name=metadata["Info"]["Summary"]["UserName"])
)
@@ -112,7 +114,9 @@ class Reader(AbstractReader, ABC):
type=pixel_type,
physical_size_x=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"),
)
@@ -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()}
# compare channel names from metadata with filenames
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])]
pattern_c = re.compile(r"img_\d{3,}_(.*)_\d{3,}$", re.IGNORECASE)

View File

@@ -145,6 +145,8 @@ class Reader(AbstractReader, ABC):
return ome
def open(self):
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)
page = self.reader.pages.first
self.p_ndim = page.ndim # noqa

View File

@@ -1,6 +1,6 @@
[project]
name = "ndbioimage"
version = "2026.1.2"
version = "2026.4.0"
description = "Bio image reading, metadata and some affine registration."
authors = [
{ name = "W. Pomp", email = "w.pomp@nki.nl" }
@@ -14,7 +14,7 @@ exclude = ["ndbioimage/jars"]
dependencies = [
"czifile == 2019.7.2",
"imagecodecs",
"imagecodecs <= 2026.1.14",
"lxml",
"numpy >= 1.20",
"ome-types",
@@ -35,7 +35,7 @@ write = ["matplotlib", "scikit-video"]
bioformats = ["JPype1"]
[project.urls]
repository = "https://github.com/wimpomp/ndbioimage"
repository = "https://git.wimpomp.nl/wim/focusfeedbackgui"
[project.scripts]
ndbioimage = "ndbioimage:main"