Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add position labels to ndtiff metadata #166

Merged
merged 18 commits into from
Aug 8, 2023
Merged
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
pytest_temp/
.pytest_temp/

# Translations
*.mo
Expand Down
24 changes: 0 additions & 24 deletions iohub/multipagetiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,27 +417,3 @@ def get_num_positions(self):

"""
return self.positions

@property
def hcs_position_labels(self):
"""Parse plate position labels generated by the HCS position generator,
e.g. 'A1-Site_0', and split into row, column, and FOV names.

Returns
-------
list[tuple[str, str, str]]
FOV name paths, e.g. ('A', '1', '0')
"""
if not self.stage_positions:
raise ValueError("Stage position metadata not available.")
try:
labels = [
pos["Label"].split("-Site_") for pos in self.stage_positions
]
return [(well[0], well[1:], fov) for well, fov in labels]
except Exception:
raise ValueError(
"HCS position labels are in the format of "
"'A1-Site_0', 'H12-Site_1', ... "
f"Got labels {[pos['Label'] for pos in self.stage_positions]}"
)
164 changes: 120 additions & 44 deletions iohub/ndtiff.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import warnings
from typing import Union

import numpy as np
import zarr
Expand Down Expand Up @@ -39,11 +40,9 @@ def _get_summary_metadata(self):
pm_metadata = self.dataset.summary_metadata
pm_metadata["MicroManagerVersion"] = "pycromanager"
pm_metadata["Positions"] = self.get_num_positions()

img_metadata = self.get_image_metadata(0, 0, 0, 0)
pm_metadata["z-step_um"] = None
pm_metadata["StagePositions"] = []

pm_metadata["z-step_um"] = None
if "ZPosition_um_Intended" in img_metadata.keys():
pm_metadata["z-step_um"] = np.around(
abs(
Expand All @@ -57,31 +56,93 @@ def _get_summary_metadata(self):
decimals=3,
).astype(float)

if "XPosition_um_Intended" in img_metadata.keys():
for p in range(self.get_num_positions()):
img_metadata = self.get_image_metadata(p, 0, 0, 0)
pm_metadata["StagePositions"].append(
{
img_metadata["Core-XYStage"]: (
img_metadata["XPosition_um_Intended"],
img_metadata["YPosition_um_Intended"],
)
}
)
pm_metadata["StagePositions"] = []
if "position" in self._axes:
for position in self._axes["position"]:
position_metadata = {}
img_metadata = self.get_image_metadata(position, 0, 0, 0)

if all(
key in img_metadata.keys()
for key in [
"XPosition_um_Intended",
"YPosition_um_Intended",
]
):
position_metadata[img_metadata["Core-XYStage"]] = (
img_metadata["XPosition_um_Intended"],
img_metadata["YPosition_um_Intended"],
)

# Position label may also be obtained from "PositionName"
# metadata key with AcqEngJ >= 0.29.0
if isinstance(position, str):
position_metadata["Label"] = position

pm_metadata["StagePositions"].append(position_metadata)

return {"Summary": pm_metadata}

def _check_coordinates(self, p, t, c, z):
if p == 0 and "position" not in self._axes.keys():
p = None
if t == 0 and "time" not in self._axes.keys():
t = None
if c == 0 and "channel" not in self._axes.keys():
c = None
if z == 0 and "z" not in self._axes.keys():
z = None
def _check_coordinates(
self, p: Union[int, str], t: int, c: Union[int, str], z: int
):
"""
Check that the (p, t, c, z) coordinates are part of the ndtiff dataset.
Replace coordinates with None or string values in specific cases - see
below
"""
coords = [p, t, c, z]
axes = ("position", "time", "channel", "z")

for i, axis in enumerate(axes):
coord = coords[i]

# Check if the axis is part of the dataset axes
if axis in self._axes.keys():
# Check if coordinate is part of the dataset axis
if coord in self._axes[axis]:
# all good
pass

# The requested coordinate is not part of the axis
else:
# If coord=0 is requested and the coordinate axis exists,
# but is string valued (e.g. {'Pos0', 'Pos1'}), a warning
# will be raised and the coordinate will be replaced by a
# random sample.

# Coordinates are in sets, here we get one sample from the
# set without removing it:
# https://stackoverflow.com/questions/59825
coord_sample = next(iter(self._axes[axis]))
if coord == 0 and isinstance(coord_sample, str):
coords[i] = coord_sample
warnings.warn(
f"Indices of {axis} are string-valued. "
f"Returning data at {axis} = {coord}"
)
else:
# If the coordinate is not part of the axis and
# nonzero, a ValueError will be raised
raise ValueError(
f"Image coordinate {axis} = {coord} is not "
"part of this dataset."
)

return p, t, c, z
# The axis is not part of the dataset axes
else:
# If coord = 0 is requested, the coordinate will be replaced
# with None
if coord == 0:
coords[i] = None
# If coord != 0 is requested and the axis is not part of the
# dataset, ValueError will be raised
else:
raise ValueError(
f"Axis {axis} is not part of this dataset"
)

return (*coords,)

def get_num_positions(self) -> int:
return (
Expand All @@ -90,16 +151,18 @@ def get_num_positions(self) -> int:
else 1
)

def get_image(self, p, t, c, z) -> np.ndarray:
def get_image(
self, p: Union[int, str], t: int, c: Union[int, str], z: int
) -> np.ndarray:
"""return the image at the provided PTCZ coordinates

Parameters
----------
p : int
p : int or str
position index
t : int
time index
c : int
c : int or str
channel index
z : int
slice/z index
Expand All @@ -118,7 +181,7 @@ def get_image(self, p, t, c, z) -> np.ndarray:

return image

def get_zarr(self, position: int) -> zarr.array:
def get_zarr(self, position: Union[int, str]) -> zarr.array:
""".. danger::
The behavior of this function is different from other
ReaderBase children as it return a Dask array
Expand All @@ -140,12 +203,20 @@ def get_zarr(self, position: int) -> zarr.array:
# TODO: try casting the dask array into a zarr array
# using `dask.array.to_zarr()`.
# Currently this call brings the data into memory
if "position" not in self._axes.keys() and position not in (0, None):
warnings.warn(
f"Position index {position} is not part of this dataset. "
"Returning data at the default position."
)
position = None
if "position" in self._axes.keys():
if position not in self._axes["position"]:
raise ValueError(
f"Position index {position} is not part of this dataset. "
f'Valid positions are: {self._axes["position"]}'
)
else:
if position not in (0, None):
warnings.warn(
f"Position index {position} is not part of this dataset. "
"Returning data at the default position."
)
position = None

da = self.dataset.as_array(position=position)
shape = (
self.frames,
Expand All @@ -157,7 +228,7 @@ def get_zarr(self, position: int) -> zarr.array:
# add singleton axes so output is 5D
return da.reshape(shape)

def get_array(self, position: int) -> np.ndarray:
def get_array(self, position: Union[int, str]) -> np.ndarray:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring typing needs an update

"""
return a numpy array with shape TCZYX at the given position

Expand All @@ -173,21 +244,26 @@ def get_array(self, position: int) -> np.ndarray:

return np.asarray(self.get_zarr(position))

def get_image_metadata(self, p, t, c, z) -> dict:
"""
return image plane metadata at the requested PTCZ coordinates
def get_image_metadata(
self, p: Union[int, str], t: int, c: Union[int, str], z: int
) -> dict:
"""Return image plane metadata at the requested PTCZ coordinates

Parameters
----------
p: (int) position index
t: (int) time index
c: (int) channel index
z: (int) slice/z index
p : int or str
position index
t : int
time index
c : int or str
channel index
z : int
slice/z index

Returns
-------
metadata: (dict) image plane metadata dictionary

dict
image plane metadata
"""
metadata = None
p, t, c, z = self._check_coordinates(p, t, c, z)
Expand Down
24 changes: 24 additions & 0 deletions iohub/reader_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,27 @@ def get_num_positions(self) -> int:
number of positions
"""
raise NotImplementedError

@property
def hcs_position_labels(self):
"""Parse plate position labels generated by the HCS position generator,
e.g. 'A1-Site_0', and split into row, column, and FOV names.

Returns
-------
list[tuple[str, str, str]]
FOV name paths, e.g. ('A', '1', '0')
"""
if not self.stage_positions:
raise ValueError("Stage position metadata not available.")
try:
labels = [
pos["Label"].split("-Site_") for pos in self.stage_positions
]
return [(well[0], well[1:], fov) for well, fov in labels]
except Exception:
raise ValueError(
"HCS position labels are in the format of "
"'A1-Site_0', 'H12-Site_1', ... "
f"Got labels {[pos['Label'] for pos in self.stage_positions]}"
)
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ install_requires =
pydantic>=1.10.2, <2
tifffile>=2023.2.3, <2023.3.15
natsort>=7.1.1
ndtiff>=2.1.0
ndtiff>=2.2.1
zarr>=2.13, <2.16
tqdm
pillow>=9.4.0
Expand Down
Loading