Skip to content

Commit

Permalink
Convert ND-TIFF position label (#171)
Browse files Browse the repository at this point in the history
* fix error handling

* convert non-HCS ndtiff position labels

* test

* always label positions

* fix flaky test

* add string axis check to ndtiff reader

* convert string channel axis

* test new properties
  • Loading branch information
ziw-liu authored Aug 17, 2023
1 parent 736564c commit c52944b
Show file tree
Hide file tree
Showing 8 changed files with 103 additions and 63 deletions.
10 changes: 1 addition & 9 deletions iohub/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,13 @@ def info(files, verbose):
is_flag=True,
help="Arrange FOVs in a row/column grid layout for tiled acquisition",
)
@click.option(
"--label-positions",
"-p",
required=False,
is_flag=True,
help="Dump postion labels in MM metadata to Omero metadata",
)
def convert(input, output, format, scale_voxels, grid_layout, label_positions):
def convert(input, output, format, scale_voxels, grid_layout):
"""Converts Micro-Manager TIFF datasets to OME-Zarr"""
converter = TIFFConverter(
input_dir=input,
output_dir=output,
data_type=format,
scale_voxels=scale_voxels,
grid_layout=grid_layout,
label_positions=label_positions,
)
converter.run()
87 changes: 49 additions & 38 deletions iohub/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import logging
import os
from typing import Literal
from typing import Literal, Union

import numpy as np
from numpy.typing import NDArray
Expand Down Expand Up @@ -87,9 +87,6 @@ class TIFFConverter:
chunks : tuple[int], optional
Chunk size of the output Zarr arrays, by default None
(chunk by XY planes, this is the fastest at converting time)
label_positions : bool, optional
Dump postion labels in MM metadata to Omero metadata,
by default False
scale_voxels : bool, optional
Write voxel size (XY pixel size and Z-step) as scaling transform,
by default True
Expand All @@ -113,7 +110,6 @@ def __init__(
data_type: Literal["singlepagetiff", "ometiff", "ndtiff"] = None,
grid_layout: int = False,
chunks: tuple[int] = None,
label_positions: bool = False,
scale_voxels: bool = True,
hcs_plate: bool = None,
):
Expand All @@ -125,6 +121,14 @@ def __init__(
self.reader = read_micromanager(
input_dir, data_type, extract_data=False
)
if reader_type := type(self.reader) not in (
MicromanagerSequenceReader,
MicromanagerOmeTiffReader,
NDTiffReader,
):
raise TypeError(
f"Reader type {reader_type} not supported for conversion."
)
logging.debug("Finished initializing data.")
self.summary_metadata = (
self.reader.mm_meta["Summary"] if self.reader.mm_meta else None
Expand All @@ -146,7 +150,6 @@ def __init__(
self.x = self.reader.width
self.dim = (self.p, self.t, self.c, self.z, self.y, self.x)
self.prefix_list = []
self.label_positions = label_positions
self.hcs_plate = hcs_plate
self._check_hcs_sites()
self._get_pos_names()
Expand Down Expand Up @@ -176,15 +179,9 @@ def __init__(
self.transform = self._scale_voxels() if scale_voxels else None

def _check_hcs_sites(self):
is_mmstack = isinstance(self.reader, MicromanagerOmeTiffReader)
if self.hcs_plate:
if is_mmstack:
self.hcs_sites = self.reader.hcs_position_labels
else:
raise ValueError(
f"HCS plate position not supported for {type(self.reader)}"
)
elif self.hcs_plate is None and is_mmstack:
self.hcs_sites = self.reader.hcs_position_labels
elif self.hcs_plate is None:
try:
self.hcs_sites = self.reader.hcs_position_labels
self.hcs_plate = True
Expand All @@ -195,9 +192,12 @@ def _check_hcs_sites(self):
)

def _make_default_grid(self):
self.position_grid = np.expand_dims(
np.arange(self.p, dtype=int), axis=0
)
if isinstance(self.reader, NDTiffReader):
self.position_grid = np.array([self.pos_names])
else:
self.position_grid = np.expand_dims(
np.arange(self.p, dtype=int), axis=0
)

def _gen_coordset(self):
"""Generates a coordinate set in the dimensional order
Expand Down Expand Up @@ -303,21 +303,16 @@ def _get_pos_names(self):
"""Append a list of pos names in ascending order
(order in which they were acquired).
"""
if not self.label_positions:
self.pos_names = ["0"] * self.p
else:
if self.p > 1:
self.pos_names = []
for p in range(self.p):
if self.p > 1:
try:
name = self.summary_metadata["StagePositions"][p][
"Label"
]
except KeyError:
name = None
else:
name = None
name = (
self.summary_metadata["StagePositions"][p].get("Label")
or p
)
self.pos_names.append(name)
else:
self.pos_names = ["0"]

def _get_image_array(self, p: int, t: int, c: int, z: int):
try:
Expand All @@ -328,18 +323,28 @@ def _get_image_array(self, p: int, t: int, c: int, z: int):
return None

def _get_coord_reorder(self, coord):
return (
reordered = [
coord[self.p_dim],
coord[self.t_dim],
coord[self.c_dim],
coord[self.z_dim],
)
]
return tuple(reordered)

def _normalize_ndtiff_coord(
self, p: int, t: int, c: int, z: int
) -> tuple[Union[str, int], ...]:
if self.reader.str_position_axis:
p = self.pos_names[p]
if self.reader.str_channel_axis:
c = self.reader.channel_names[c]
return p, t, c, z

def _get_channel_names(self):
cns = self.reader.channel_names
if not cns and isinstance(self.reader, MicromanagerSequenceReader):
if not cns:
logging.warning(
"Using an empty channel name for the single channel."
"Cannot find channel names, using indices instead."
)
cns = [str(i) for i in range(self.c)]
return cns
Expand Down Expand Up @@ -448,24 +453,30 @@ def run(self, check_image: bool = True):
all_ndtiff_metadata = {}
for coord in tqdm(self.coords, bar_format=bar_format):
coord_reorder = self._get_coord_reorder(coord)
img_raw = self._get_image_array(*coord_reorder)
if isinstance(self.reader, NDTiffReader):
p, t, c, z = self._normalize_ndtiff_coord(*coord_reorder)
else:
p, t, c, z = coord_reorder
img_raw = self._get_image_array(p, t, c, z)
if img_raw is None or not getattr(img_raw, "shape", ()):
# Leave incomplete datasets zero-filled
logging.warning(
f"Cannot load image at PTCZ={coord_reorder}, "
f"Cannot load image at PTCZ={(p, t, c, z)}, "
"filling with zeros. Check if the raw data is incomplete."
)
continue
pos_name = self.zarr_position_names[coord_reorder[0]]
else:
pos_idx = coord_reorder[0]
pos_name = self.zarr_position_names[pos_idx]
zarr_img = self.writer[pos_name]["0"]
zarr_img[coord_reorder[1:]] = img_raw
if check_image:
self._perform_image_check(zarr_img[coord_reorder[1:]], img_raw)
if ndtiff:
image_metadata = self.reader.get_image_metadata(*coord_reorder)
image_metadata = self.reader.get_image_metadata(p, t, c, z)
# row/well/fov/img/T/C/Z
frame_key = "/".join(
[zarr_img.path] + [str(i) for i in coord_reorder[1:]]
[zarr_img.path] + [str(i) for i in (t, c, z)]
)
all_ndtiff_metadata[frame_key] = image_metadata
self.writer.zgroup.attrs.update(self.metadata)
Expand Down
22 changes: 20 additions & 2 deletions iohub/ndtiff.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import warnings
from typing import Union
from typing import Literal, Union

import numpy as np
import zarr
Expand All @@ -18,7 +18,8 @@ def __init__(self, data_path: str):

self.dataset = Dataset(data_path)
self._axes = self.dataset.axes

self._str_posistion_axis = self._check_str_axis("position")
self._str_channel_axis = self._check_str_axis("channel")
self.frames = (
len(self._axes["time"]) if "time" in self._axes.keys() else 1
)
Expand Down Expand Up @@ -83,6 +84,23 @@ def _get_summary_metadata(self):

return {"Summary": pm_metadata}

def _check_str_axis(self, axis: Literal["position", "channel"]) -> bool:
if axis in self._axes:
coord_sample = next(iter(self._axes[axis]))
return isinstance(coord_sample, str)
else:
return False

@property
def str_position_axis(self) -> bool:
"""Position axis is string-valued"""
return self._str_posistion_axis

@property
def str_channel_axis(self) -> bool:
"""Channel axis is string-valued"""
return self._str_channel_axis

def _check_coordinates(
self, p: Union[int, str], t: int, c: Union[int, str], z: int
):
Expand Down
3 changes: 2 additions & 1 deletion iohub/reader_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ def hcs_position_labels(self):
]
return [(well[0], well[1:], fov) for well, fov in labels]
except Exception:
labels = [pos.get("Label") for pos in self.stage_positions]
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]}"
f"Got labels {labels}"
)
7 changes: 2 additions & 5 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,24 @@ def test_cli_info_ome_zarr(setup_test_data, setup_hcs_ref, verbose):
assert "scale (um)" in result_pos.output


@given(f=st.booleans(), g=st.booleans(), p=st.booleans(), s=st.booleans())
@given(f=st.booleans(), g=st.booleans(), s=st.booleans())
@settings(
suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=20000
)
def test_cli_convert_ome_tiff(
setup_test_data, setup_mm2gamma_ome_tiffs, f, g, p, s
setup_test_data, setup_mm2gamma_ome_tiffs, f, g, s
):
_, _, input_dir = setup_mm2gamma_ome_tiffs
runner = CliRunner()
f = "-f ometiff" if f else ""
g = "-g" if g else ""
p = "-p" if p else ""
with TemporaryDirectory() as tmp_dir:
output_dir = os.path.join(tmp_dir, "converted.zarr")
cmd = ["convert", "-i", input_dir, "-o", output_dir, "-s", s]
if f:
cmd += ["-f", "ometiff"]
if g:
cmd += ["-g"]
if p:
cmd += ["-p"]
result = runner.invoke(cli, cmd)
assert result.exit_code == 0
assert "Status" in result.output
5 changes: 4 additions & 1 deletion tests/ngff/test_ngff.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,8 +562,11 @@ def test_get_channel_index(setup_test_data, setup_hcs_ref, wrong_channel_name):
row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric
)
@settings(max_examples=16, deadline=2000)
def test_modify_hcs_ref(setup_test_data, setup_hcs_ref, row, col, pos):
def test_modify_hcs_ref(
setup_test_data, setup_hcs_ref, row: str, col: str, pos: str
):
"""Test `iohub.ngff.open_ome_zarr()`"""
assume((row.lower() != "b"))
with _temp_copy(setup_hcs_ref) as store_path:
with open_ome_zarr(store_path, layout="hcs", mode="r+") as dataset:
assert dataset.axes[0].name == "c"
Expand Down
9 changes: 9 additions & 0 deletions tests/reader/test_pycromanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,14 @@ def test_get_num_positions(setup_test_data, setup_pycromanager_test_data):
def test_v3_labeled_positions(ndtiff_v3_labeled_positions):
data_dir: str = ndtiff_v3_labeled_positions
reader = NDTiffReader(data_dir)
assert reader.str_position_axis
assert not reader.str_channel_axis
position_labels = [pos["Label"] for pos in reader.stage_positions]
assert position_labels == ["Pos0", "Pos1", "Pos2"]


def test_v2_non_str_axis(setup_test_data, setup_pycromanager_test_data):
first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data
reader = NDTiffReader(rand_dir)
assert not reader.str_position_axis
assert not reader.str_channel_axis
23 changes: 16 additions & 7 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@

CONVERTER_TEST_GIVEN = dict(
grid_layout=st.booleans(),
label_positions=st.booleans(),
scale_voxels=st.booleans(),
)

Expand All @@ -51,7 +50,6 @@ def test_converter_ometiff(
setup_test_data,
setup_mm2gamma_ome_tiffs,
grid_layout,
label_positions,
scale_voxels,
):
logging.getLogger("tifffile").setLevel(logging.ERROR)
Expand All @@ -62,7 +60,6 @@ def test_converter_ometiff(
data,
output,
grid_layout=grid_layout,
label_positions=label_positions,
scale_voxels=scale_voxels,
)
assert isinstance(converter.reader, MicromanagerOmeTiffReader)
Expand Down Expand Up @@ -138,7 +135,6 @@ def test_converter_ndtiff(
setup_test_data,
setup_pycromanager_test_data,
grid_layout,
label_positions,
scale_voxels,
):
logging.getLogger("tifffile").setLevel(logging.ERROR)
Expand All @@ -149,7 +145,6 @@ def test_converter_ndtiff(
data,
output,
grid_layout=grid_layout,
label_positions=label_positions,
scale_voxels=scale_voxels,
)
assert isinstance(converter.reader, NDTiffReader)
Expand All @@ -176,13 +171,28 @@ def test_converter_ndtiff(
assert "ElapsedTime-ms" in metadata[key]


def test_converter_ndtiff_v3_position_labels(
ndtiff_v3_labeled_positions,
):
with TemporaryDirectory() as tmp_dir:
output = os.path.join(tmp_dir, "converted.zarr")
converter = TIFFConverter(ndtiff_v3_labeled_positions, output)
converter.run(check_image=True)
with open_ome_zarr(output, mode="r") as result:
assert result.channel_names == ["0"]
assert [name.split("/")[1] for name, _ in result.positions()] == [
"Pos0",
"Pos1",
"Pos2",
]


@given(**CONVERTER_TEST_GIVEN)
@settings(CONVERTER_TEST_SETTINGS)
def test_converter_singlepagetiff(
setup_test_data,
setup_mm2gamma_singlepage_tiffs,
grid_layout,
label_positions,
scale_voxels,
caplog,
):
Expand All @@ -194,7 +204,6 @@ def test_converter_singlepagetiff(
data,
output,
grid_layout=grid_layout,
label_positions=label_positions,
scale_voxels=scale_voxels,
)
assert isinstance(converter.reader, MicromanagerSequenceReader)
Expand Down

0 comments on commit c52944b

Please sign in to comment.