diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 154a4421..37a77a00 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,4 @@ -name: Build and deploy docs +name: documentation on: push: @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - with: + with: fetch-depth: 0 - name: Setup Pages id: pages @@ -45,7 +45,7 @@ jobs: - name: Upload build artifacts uses: actions/upload-pages-artifact@v1 with: - path: './docs/build/html' + path: "./docs/build/html" deploy: environment: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 08fd352e..7e746232 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + - unified-api jobs: style: @@ -29,8 +30,8 @@ jobs: pip install black - name: Check code styling with Black run: | - black --diff -S -t py39 iohub - black --check -S -t py39 iohub + black --diff -S -t py310 iohub + black --check -S -t py310 iohub lint: name: Lint Check diff --git a/.gitignore b/.gitignore index d102fb18..bb4029bd 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ instance/ # Sphinx documentation docs/_build/ docs/source/auto_examples/ +docs/source/sg_execution_times.rst # PyBuilder target/ diff --git a/README.md b/README.md index 5a99471a..4a6ae610 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ used at the Biohub and in the broader imaging community. ### Installation -Install a pre-release of iohub with pip: +Install a released version of iohub from PyPI with pip: ```sh pip install iohub @@ -100,10 +100,10 @@ and the [example scripts](https://github.com/czbiohub-sf/iohub/tree/main/docs/ex Read a directory containing a TIFF dataset: ```py -from iohub import read_micromanager +from iohub import read_images -reader = read_micromanager("/path/to/data/") -print(reader.shape) +reader = read_images("/path/to/data/") +print(reader) ``` ## Why iohub? diff --git a/docs/source/_static/switcher.json b/docs/source/_static/switcher.json index 81e37131..fe14eff2 100644 --- a/docs/source/_static/switcher.json +++ b/docs/source/_static/switcher.json @@ -2,5 +2,9 @@ { "version": "latest", "url": "/main/index.html" + }, + { + "version": "v0.1.0", + "url": "/v0.1.0/index.html" } -] \ No newline at end of file +] diff --git a/docs/source/api/mm_converter.rst b/docs/source/api/mm_converter.rst index f43949df..a6cbbcdd 100644 --- a/docs/source/api/mm_converter.rst +++ b/docs/source/api/mm_converter.rst @@ -11,3 +11,4 @@ Convert TIFF to OME-Zarr .. autoclass:: TIFFConverter :members: + :special-members: __call__ diff --git a/docs/source/api/mm_ometiff_reader.rst b/docs/source/api/mm_ometiff_reader.rst index 58c47f14..35ad7ce4 100644 --- a/docs/source/api/mm_ometiff_reader.rst +++ b/docs/source/api/mm_ometiff_reader.rst @@ -1,8 +1,12 @@ Read MMStack OME-TIFF ===================== -.. currentmodule:: iohub.multipagetiff +.. currentmodule:: iohub.mmstack -.. autoclass:: MicromanagerOmeTiffReader +.. autoclass:: MMStack :members: :inherited-members: + +.. autoclass:: MMOmeTiffFOV + :members: + :inherited-members: \ No newline at end of file diff --git a/docs/source/api/mm_reader.rst b/docs/source/api/mm_reader.rst index fab118ee..5446650b 100644 --- a/docs/source/api/mm_reader.rst +++ b/docs/source/api/mm_reader.rst @@ -1,6 +1,6 @@ -Read Micro-Manager datasets -=========================== +Read multi-FOV datasets +======================= .. currentmodule:: iohub -.. autofunction:: read_micromanager +.. autofunction:: read_images diff --git a/docs/source/api/mm_sequence_reader.rst b/docs/source/api/mm_sequence_reader.rst index e6eaa2e0..f5108e66 100644 --- a/docs/source/api/mm_sequence_reader.rst +++ b/docs/source/api/mm_sequence_reader.rst @@ -1,7 +1,7 @@ Read MM TIFF sequence ===================== -.. currentmodule:: iohub.singlepagetiff +.. currentmodule:: iohub._deprecated.singlepagetiff .. autoclass:: MicromanagerSequenceReader :members: diff --git a/docs/source/api/ndtiff.rst b/docs/source/api/ndtiff.rst index a8dc6414..fdf6d08d 100644 --- a/docs/source/api/ndtiff.rst +++ b/docs/source/api/ndtiff.rst @@ -3,6 +3,10 @@ Read NDTiff .. currentmodule:: iohub.ndtiff -.. autoclass:: NDTiffReader +.. autoclass:: NDTiffDataset :members: :inherited-members: + +.. autoclass:: NDTiffFOV + :members: + :inherited-members: \ No newline at end of file diff --git a/docs/source/api/ngff.rst b/docs/source/api/ngff.rst index 30539620..1200562e 100644 --- a/docs/source/api/ngff.rst +++ b/docs/source/api/ngff.rst @@ -6,8 +6,6 @@ OME-NGFF (OME-Zarr) Convenience ----------- -`open_ome_zarr` -^^^^^^^^^^^^^^^ .. autofunction:: open_ome_zarr @@ -16,25 +14,14 @@ NGFF Nodes .. currentmodule:: iohub.ngff - -`NGFFNode` -^^^^^^^^^^ - .. autoclass:: NGFFNode :members: -`Position` -^^^^^^^^^^ .. autoclass:: Position :members: - -`TiledPosition` -^^^^^^^^^^^^^^^ .. autoclass:: TiledPosition :members: -`Plate` -^^^^^^^ .. autoclass:: Plate :members: diff --git a/docs/source/api/upti.rst b/docs/source/api/upti.rst index 39b2a566..bd2ee9cb 100644 --- a/docs/source/api/upti.rst +++ b/docs/source/api/upti.rst @@ -1,7 +1,7 @@ Read PTI TIFF ============= -.. currentmodule:: iohub.upti +.. currentmodule:: iohub._deprecated.upti .. autoclass:: UPTIReader :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 0427a8dd..b7f014a2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -124,7 +124,15 @@ "json_url": json_url, "version_match": version_match, }, - "navbar_end": ["theme-switcher", "version-switcher", "navbar-icon-links"], + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/czbiohub-sf/iohub", + "icon": "fa-brands fa-square-github", + "type": "fontawesome", + } + ], + "navbar_end": ["theme-switcher", "navbar-icon-links", "version-switcher"], } # Add any paths that contain custom themes here, relative to this directory. diff --git a/iohub/__init__.py b/iohub/__init__.py index a6b4a36c..3eb59f08 100644 --- a/iohub/__init__.py +++ b/iohub/__init__.py @@ -1,12 +1,22 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +""" +iohub +===== -"""iohub N-dimensional bioimaging data I/O with OME metadata in Python """ +import logging +import os from iohub.ngff import open_ome_zarr -from iohub.reader import read_micromanager +from iohub.reader import read_images + +__all__ = ["open_ome_zarr", "read_images"] + + +_level = os.environ.get("IOHUB_LOG_LEVEL", logging.INFO) +if str(_level).isdigit(): + _level = int(_level) -__all__ = ["open_ome_zarr", "read_micromanager"] +logging.basicConfig() +logging.getLogger(__name__).setLevel(_level) diff --git a/iohub/_deprecated/__init__.py b/iohub/_deprecated/__init__.py new file mode 100644 index 00000000..3c57beb8 --- /dev/null +++ b/iohub/_deprecated/__init__.py @@ -0,0 +1 @@ +"""Deprecated modules""" diff --git a/iohub/reader_base.py b/iohub/_deprecated/reader_base.py similarity index 94% rename from iohub/reader_base.py rename to iohub/_deprecated/reader_base.py index fb6f6876..3d7842cc 100644 --- a/iohub/reader_base.py +++ b/iohub/_deprecated/reader_base.py @@ -30,17 +30,21 @@ def shape(self): return self.frames, self.channels, self.slices, self.height, self.width @property - def mm_meta(self): + def micromanager_metadata(self) -> dict | None: return self._mm_meta - @mm_meta.setter - def mm_meta(self, value): + @micromanager_metadata.setter + def micromanager_metadata(self, value): if not isinstance(value, dict): raise TypeError( f"Type of `mm_meta` should be `dict`, got `{type(value)}`." ) self._mm_meta = value + @property + def micromanager_summary(self) -> dict | None: + return self._mm_meta.get("Summary", None) + @property def stage_positions(self): return self._stage_positions diff --git a/iohub/singlepagetiff.py b/iohub/_deprecated/singlepagetiff.py similarity index 91% rename from iohub/singlepagetiff.py rename to iohub/_deprecated/singlepagetiff.py index 1b753d91..ee76db9b 100644 --- a/iohub/singlepagetiff.py +++ b/iohub/_deprecated/singlepagetiff.py @@ -9,7 +9,7 @@ import tifffile as tiff import zarr -from iohub.reader_base import ReaderBase +from iohub._deprecated.reader_base import ReaderBase class MicromanagerSequenceReader(ReaderBase): @@ -91,9 +91,9 @@ def _set_mm_meta(self, one_pos): # pull one metadata sample and extract experiment dimensions metadata_path = os.path.join(one_pos, "metadata.txt") with open(metadata_path, "r") as f: - self.mm_meta = json.load(f) + self._mm_meta = json.load(f) - mm_version = self.mm_meta["Summary"]["MicroManagerVersion"] + mm_version = self._mm_meta["Summary"]["MicroManagerVersion"] if mm_version == "1.4.22": self._mm1_meta_parser() elif "beta" in mm_version: @@ -402,12 +402,12 @@ def _mm1_meta_parser(self): ------- """ - self.z_step_size = self.mm_meta["Summary"]["z-step_um"] - self.width = self.mm_meta["Summary"]["Width"] - self.height = self.mm_meta["Summary"]["Height"] - self.frames = self.mm_meta["Summary"]["Frames"] - self.slices = self.mm_meta["Summary"]["Slices"] - self.channels = self.mm_meta["Summary"]["Channels"] + self.z_step_size = self._mm_meta["Summary"]["z-step_um"] + self.width = self._mm_meta["Summary"]["Width"] + self.height = self._mm_meta["Summary"]["Height"] + self.frames = self._mm_meta["Summary"]["Frames"] + self.slices = self._mm_meta["Summary"]["Slices"] + self.channels = self._mm_meta["Summary"]["Channels"] def _mm2beta_meta_parser(self): """ @@ -418,14 +418,14 @@ def _mm2beta_meta_parser(self): ------- """ - self.z_step_size = self.mm_meta["Summary"]["z-step_um"] + self.z_step_size = self._mm_meta["Summary"]["z-step_um"] self.width = int( - self.mm_meta["Summary"]["UserData"]["Width"]["PropVal"] + self._mm_meta["Summary"]["UserData"]["Width"]["PropVal"] ) self.height = int( - self.mm_meta["Summary"]["UserData"]["Height"]["PropVal"] + self._mm_meta["Summary"]["UserData"]["Height"]["PropVal"] ) - self.time_stamp = self.mm_meta["Summary"]["StartTime"] + self.time_stamp = self._mm_meta["Summary"]["StartTime"] def _mm2gamma_meta_parser(self): """ @@ -436,18 +436,18 @@ def _mm2gamma_meta_parser(self): ------- """ - keys_list = list(self.mm_meta.keys()) + keys_list = list(self._mm_meta.keys()) if "FrameKey-0-0-0" in keys_list[1]: - roi_string = self.mm_meta[keys_list[1]]["ROI"] + roi_string = self._mm_meta[keys_list[1]]["ROI"] self.width = int(roi_string.split("-")[2]) self.height = int(roi_string.split("-")[3]) elif "Metadata-" in keys_list[2]: - self.width = self.mm_meta[keys_list[2]]["Width"] - self.height = self.mm_meta[keys_list[2]]["Height"] + self.width = self._mm_meta[keys_list[2]]["Width"] + self.height = self._mm_meta[keys_list[2]]["Height"] else: raise ValueError("Metadata file incompatible with metadata reader") - self.z_step_size = self.mm_meta["Summary"]["z-step_um"] - self.frames = self.mm_meta["Summary"]["Frames"] - self.slices = self.mm_meta["Summary"]["Slices"] - self.channels = self.mm_meta["Summary"]["Channels"] + self.z_step_size = self._mm_meta["Summary"]["z-step_um"] + self.frames = self._mm_meta["Summary"]["Frames"] + self.slices = self._mm_meta["Summary"]["Slices"] + self.channels = self._mm_meta["Summary"]["Channels"] diff --git a/iohub/upti.py b/iohub/_deprecated/upti.py similarity index 99% rename from iohub/upti.py rename to iohub/_deprecated/upti.py index 6cc3c212..b68e624d 100644 --- a/iohub/upti.py +++ b/iohub/_deprecated/upti.py @@ -6,7 +6,7 @@ import tifffile as tiff import zarr -from iohub.reader_base import ReaderBase +from iohub._deprecated.reader_base import ReaderBase class UPTIReader(ReaderBase): diff --git a/iohub/zarrfile.py b/iohub/_deprecated/zarrfile.py similarity index 92% rename from iohub/zarrfile.py rename to iohub/_deprecated/zarrfile.py index 0689e99f..1674d47b 100644 --- a/iohub/zarrfile.py +++ b/iohub/_deprecated/zarrfile.py @@ -9,7 +9,9 @@ import numpy as np import zarr -from iohub.reader_base import ReaderBase +from iohub._deprecated.reader_base import ReaderBase + +_logger = logging.getLogger(__name__) class ZarrReader(ReaderBase): @@ -29,7 +31,7 @@ def __init__( ): super().__init__() - logging.warning( + _logger.warning( DeprecationWarning( "`iohub.zarrfile.ZarrReader` is deprecated " "and will be removed in the future. " @@ -93,7 +95,7 @@ def __init__( try: self._set_mm_meta() except TypeError: - self.mm_meta = dict() + self.micromanager_metadata = {} self._generate_hcs_meta() @@ -186,37 +188,37 @@ def _set_mm_meta(self): ------- """ - self.mm_meta = self.root.attrs.get("Summary") - mm_version = self.mm_meta["MicroManagerVersion"] + self._mm_meta = self.root.attrs.get("Summary") + mm_version = self._mm_meta["MicroManagerVersion"] if mm_version != "pycromanager": if "beta" in mm_version: - if self.mm_meta["Positions"] > 1: + if self._mm_meta["Positions"] > 1: self.stage_positions = [] - for p in range(len(self.mm_meta["StagePositions"])): + for p in range(len(self._mm_meta["StagePositions"])): pos = self._simplify_stage_position_beta( - self.mm_meta["StagePositions"][p] + self._mm_meta["StagePositions"][p] ) self.stage_positions.append(pos) # elif mm_version == '1.4.22': - # for ch in self.mm_meta['ChNames']: + # for ch in self._mm_meta['ChNames']: # self.channel_names.append(ch) else: - if self.mm_meta["Positions"] > 1: + if self._mm_meta["Positions"] > 1: self.stage_positions = [] - for p in range(self.mm_meta["Positions"]): + for p in range(self._mm_meta["Positions"]): pos = self._simplify_stage_position( - self.mm_meta["StagePositions"][p] + self._mm_meta["StagePositions"][p] ) self.stage_positions.append(pos) - # for ch in self.mm_meta['ChNames']: + # for ch in self._mm_meta['ChNames']: # self.channel_names.append(ch) - self.z_step_size = self.mm_meta["z-step_um"] + self.z_step_size = self._mm_meta["z-step_um"] def _get_channel_names(self): well = self.hcs_meta["plate"]["wells"][0]["path"] diff --git a/iohub/clearcontrol.py b/iohub/clearcontrol.py index f51e3306..4d7ca677 100644 --- a/iohub/clearcontrol.py +++ b/iohub/clearcontrol.py @@ -9,6 +9,8 @@ import numpy as np import pandas as pd +from iohub.fov import BaseFOV + if TYPE_CHECKING: from _typeshed import StrOrBytesPath @@ -128,7 +130,7 @@ def _key_cache_wrapper( return _key_cache_wrapper -class ClearControlFOV: +class ClearControlFOV(BaseFOV): """ Reader class for Clear Control dataset https://github.com/royerlab/opensimview. @@ -157,7 +159,7 @@ def __init__( cache: bool = False, ): super().__init__() - self._data_path = Path(data_path) + self._root = Path(data_path) self._missing_value = missing_value self._dtype = np.uint16 self._cache = cache @@ -165,7 +167,11 @@ def __init__( self._cache_array = None @property - def shape(self) -> tuple[int, ...]: + def root(self) -> Path: + return self._root + + @property + def shape(self) -> tuple[int, int, int, int, int]: """ Reads Clear Control index data of every data and returns the element-wise minimum shape. @@ -177,7 +183,7 @@ def shape(self) -> tuple[int, ...]: minimum_size = 64 numbers = re.compile(r"\d+\.\d+|\d+") - for index_filepath in self._data_path.glob("*.index.txt"): + for index_filepath in self._root.glob("*.index.txt"): with open(index_filepath, "rb") as f: if index_filepath.stat().st_size > minimum_size: f.seek( @@ -195,19 +201,23 @@ def shape(self) -> tuple[int, ...]: shape = [min(s, v) for s, v in zip(shape, values)] - shape.insert(1, len(self.channels)) + shape.insert(1, len(self.channel_names)) shape[0] += 1 # time points starts counts on zero return tuple(shape) @property - def channels(self) -> list[str]: + def axes_names(self) -> list[str]: + return ["T", "C", "Z", "Y", "X"] + + @property + def channel_names(self) -> list[str]: """Return sorted channels name.""" suffix = ".index.txt" return sorted( [ p.name.removesuffix(suffix) - for p in self._data_path.glob(f"*{suffix}") + for p in self._root.glob(f"*{suffix}") ] ) @@ -243,7 +253,7 @@ def _read_volume( # single channel if isinstance(channels, str): volume_name = f"{str(time_point).zfill(6)}.blc" - volume_path = self._data_path / "stacks" / channels / volume_name + volume_path = self._root / "stacks" / channels / volume_name if not volume_path.exists(): if self._missing_value is None: raise ValueError(f"{volume_path} not found.") @@ -323,7 +333,7 @@ def _load_array( ) -> np.ndarray: # these are properties are loaded to avoid multiple reads per call shape = self.shape - channels = np.asarray(self.channels) + channels = np.asarray(self.channel_names) time_pts = list(range(shape[0])) volume_shape = shape[-3:] @@ -391,7 +401,7 @@ def cache(self, value: bool) -> None: def metadata(self) -> dict[str, Any]: """Summarizes Clear Control metadata into a dictionary.""" cc_metadata = [] - for path in self._data_path.glob("*.metadata.txt"): + for path in self._root.glob("*.metadata.txt"): with open(path, mode="r") as f: channel_metadata = pd.DataFrame( [json.loads(s) for s in f.readlines()] @@ -418,6 +428,10 @@ def metadata(self) -> dict[str, Any]: @property def scale(self) -> list[float]: """Dataset temporal, channel and spacial scales.""" + warnings.warn( + ".scale will be deprecated use .zyx_scale or .t_scale.", + category=DeprecationWarning, + ) metadata = self.metadata() return [ metadata["time_delta"], @@ -427,6 +441,22 @@ def scale(self) -> list[float]: metadata["voxel_size_x"], ] + @property + def zyx_scale(self) -> tuple[float, float, float]: + """Helper function for FOV spatial scale (micrometer).""" + metadata = self.metadata() + return ( + metadata["voxel_size_z"], + metadata["voxel_size_y"], + metadata["voxel_size_x"], + ) + + @property + def t_scale(self) -> float: + """Helper function for FOV time scale (seconds).""" + metadata = self.metadata() + return metadata["time_delta"] + def create_mock_clear_control_dataset(path: "StrOrBytesPath") -> None: """ diff --git a/iohub/cli/cli.py b/iohub/cli/cli.py index befdeb3d..901b92a9 100644 --- a/iohub/cli/cli.py +++ b/iohub/cli/cli.py @@ -1,3 +1,5 @@ +import pathlib + import click from iohub._version import __version__ @@ -6,7 +8,9 @@ VERSION = __version__ -_DATASET_PATH = click.Path(exists=True, file_okay=False, resolve_path=True) +_DATASET_PATH = click.Path( + exists=True, file_okay=False, resolve_path=True, path_type=pathlib.Path +) @click.group() @@ -59,22 +63,6 @@ def info(files, verbose): type=click.Path(exists=False, resolve_path=True), help="Output zarr store (/**/converted.zarr)", ) -@click.option( - "--format", - "-f", - required=False, - type=str, - help="Data type, 'ometiff', 'ndtiff', 'singlepagetiff'", -) -@click.option( - "--scale-voxels", - "-s", - required=False, - type=bool, - default=True, - help="Write voxel size (XY pixel size and Z-step, in micrometers) " - "as scale coordinate transformation in NGFF. By default true.", -) @click.option( "--grid-layout", "-g", @@ -86,34 +74,16 @@ def info(files, verbose): "--chunks", "-c", required=False, - default="XY", + default="XYZ", help="Zarr chunk size given as 'XY', 'XYZ', or a tuple of chunk " "dimensions. If 'XYZ', chunk size will be limited to 500 MB.", ) -@click.option( - "--check-image/--no-check-image", - "-chk/-no-chk", - required=False, - is_flag=True, - default=True, - help="Checks copied image data with original data.", -) -def convert( - input, - output, - format, - scale_voxels, - grid_layout, - chunks, - check_image, -): +def convert(input, output, grid_layout, chunks): """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, chunks=chunks, ) - converter.run(check_image=check_image) + converter() diff --git a/iohub/convert.py b/iohub/convert.py index 777b489c..d810fbba 100644 --- a/iohub/convert.py +++ b/iohub/convert.py @@ -1,33 +1,28 @@ -import copy import json import logging -import os -from typing import Literal, Union +from pathlib import Path +from typing import Literal import numpy as np from dask.array import to_zarr -from numpy.typing import NDArray from tqdm import tqdm from tqdm.contrib.itertools import product +from tqdm.contrib.logging import logging_redirect_tqdm from iohub._version import version as iohub_version from iohub.ngff import Position, TransformationMeta, open_ome_zarr -from iohub.reader import ( - MicromanagerOmeTiffReader, - MicromanagerSequenceReader, - NDTiffReader, - read_micromanager, -) +from iohub.reader import MMStack, NDTiffDataset, read_images __all__ = ["TIFFConverter"] +_logger = logging.getLogger(__name__) + MAX_CHUNK_SIZE = 500e6 # in bytes def _create_grid_from_coordinates( xy_coords: list[tuple[float, float]], rows: int, columns: int ): - """Function to create a grid from XY-position coordinates. - Useful for generating HCS Zarr metadata. + """Create a grid from XY-position coordinates. Parameters ---------- @@ -72,27 +67,22 @@ def _create_grid_from_coordinates( class TIFFConverter: """Convert Micro-Manager TIFF formats - (single-page TIFF, OME-TIFF, ND-TIFF) into HCS OME-Zarr. + (OME-TIFF, ND-TIFF) into HCS OME-Zarr. Each FOV will be written to a separate well in the plate layout. Parameters ---------- - input_dir : str - Input directory - output_dir : str - Output directory - data_type : Literal['singlepagetiff', 'ometiff', 'ndtiff'], optional - Input data type, by default None + input_dir : str | Path + Input directory path + output_dir : str | Path + Output zarr directory path grid_layout : bool, optional Whether to lay out the positions in a grid-like format based on how the data was acquired (useful for tiled acquisitions), by default False chunks : tuple[int] or Literal['XY', 'XYZ'], optional Chunk size of the output Zarr arrays, by default None - (chunk by XY planes, this is the fastest at converting time) - scale_voxels : bool, optional - Write voxel size (XY pixel size and Z-step) as scaling transform, - by default True + (chunk by XYZ volumes or 500 MB size limit, whichever is smaller) hcs_plate : bool, optional Create NGFF HCS layout based on position names from the HCS Site Generator in Micro-Manager (only available for OME-TIFF), @@ -101,67 +91,53 @@ class TIFFConverter: Notes ----- - When converting ND-TIFF, the image plane metadata for all frames - are aggregated into a file named ``image_plane_metadata.json``, - and placed under the root Zarr group (alongside plate metadata). + The image plane metadata for each FOV is aggregated into a JSON file, + and placed under the Zarr array directory + (e.g. ``/row/column/fov/0/image_plane_metadata.json``). """ def __init__( self, - input_dir: str, - output_dir: str, - data_type: Literal["singlepagetiff", "ometiff", "ndtiff"] = None, + input_dir: str | Path, + output_dir: str | Path, grid_layout: int = False, - chunks: Union[tuple[int], Literal["XY", "XYZ"]] = None, - scale_voxels: bool = True, + chunks: tuple[int] | Literal["XY", "XYZ"] = None, hcs_plate: bool = None, ): - logging.debug("Checking output.") - if not output_dir.strip("/").endswith(".zarr"): + _logger.debug("Checking output.") + output_dir = Path(output_dir) + if "zarr" in output_dir.suffixes: raise ValueError("Please specify .zarr at the end of your output") self.output_dir = output_dir - logging.info("Initializing data.") - self.reader = read_micromanager( - input_dir, data_type, extract_data=False - ) + _logger.info("Initializing data.") + self.reader = read_images(input_dir) if reader_type := type(self.reader) not in ( - MicromanagerSequenceReader, - MicromanagerOmeTiffReader, - NDTiffReader, + MMStack, + NDTiffDataset, ): 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 - ) - self.save_name = os.path.basename(output_dir) - logging.debug("Getting dataset summary information.") + _logger.debug("Finished initializing data.") + self.summary_metadata = self.reader.micromanager_summary + self.save_name = output_dir.name + _logger.debug("Getting dataset summary information.") self.coord_map = dict() - self.p = self.reader.get_num_positions() - if self.p is None and isinstance( - self.reader, MicromanagerSequenceReader - ): - # single page tiff reader may not return total positions - # for `get_num_positions()` - self.p = self.reader.num_positions + self.p = len(self.reader) self.t = self.reader.frames self.c = self.reader.channels self.z = self.reader.slices self.y = self.reader.height self.x = self.reader.width - self.dtype = self.reader.dtype self.dim = (self.p, self.t, self.c, self.z, self.y, self.x) self.prefix_list = [] self.hcs_plate = hcs_plate self._check_hcs_sites() self._get_pos_names() - logging.info( - f"Found Dataset {self.save_name} with " + _logger.info( + f"Found Dataset {input_dir} with " f"dimensions (P, T, C, Z, Y, X): {self.dim}" ) - self._gen_coordset() self.metadata = dict() self.metadata["iohub_version"] = iohub_version self.metadata["Summary"] = self.summary_metadata @@ -170,17 +146,18 @@ def __init__( raise ValueError( "grid_layout and hcs_plate must not be both true" ) - logging.info("Generating HCS plate level grid.") + _logger.info("Generating HCS plate level grid.") try: self.position_grid = _create_grid_from_coordinates( *self._get_position_coords() ) - except ValueError: + except ValueError as e: + _logger.warning(f"Failed to generate grid layout: {e}") self._make_default_grid() else: self._make_default_grid() self.chunks = self._gen_chunks(chunks) - self.transform = self._scale_voxels() if scale_voxels else None + self.transform = self._scale_voxels() def _check_hcs_sites(self): if self.hcs_plate: @@ -190,118 +167,55 @@ def _check_hcs_sites(self): self.hcs_sites = self.reader.hcs_position_labels self.hcs_plate = True except ValueError: - logging.debug( + _logger.debug( "HCS sites not detected, " "dumping all position into a single row." ) def _make_default_grid(self): - if isinstance(self.reader, NDTiffReader): + if isinstance(self.reader, NDTiffDataset): 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 - to which the data was acquired. - This is important for keeping track of where - we are in the tiff file during conversion + def _get_position_coords(self): + """Get the position coordinates from the reader metadata. - Returns - ------- - list(tuples) w/ length [N_images] + Raises: + ValueError: If stage positions are not available. + Returns: + list: XY stage position coordinates. + int: Number of grid rows. + int: Number of grid columns. """ + rows = set() + cols = set() + xy_coords = [] - # if acquisition information is not present - # make an arbitrary dimension order - if ( - not self.summary_metadata - or "AxisOrder" not in self.summary_metadata.keys() - ): - self.p_dim = 0 - self.t_dim = 1 - self.c_dim = 2 - self.z_dim = 3 - - self.dim_order = ["position", "time", "channel", "z"] - - # Assume data was collected slice first - dims = [ - self.reader.slices, - self.reader.channels, - self.reader.frames, - self.reader.get_num_positions(), - ] - - # get the order in which the data was collected to minimize i/o calls - else: - # 4 possible dimensions: p, c, t, z - n_dim = 4 - hashmap = { - "position": self.p, - "time": self.t, - "channel": self.c, - "z": self.z, - } - - self.dim_order = copy.copy(self.summary_metadata["AxisOrder"]) - - dims = [] - for i in range(n_dim): - if i < len(self.dim_order): - dims.append(hashmap[self.dim_order[i]]) - else: - dims.append(1) - - # Reverse the dimension order and gather dimension indices - self.dim_order.reverse() - self.p_dim = self.dim_order.index("position") - self.t_dim = self.dim_order.index("time") - self.c_dim = self.dim_order.index("channel") - self.z_dim = self.dim_order.index("z") - - # create array of coordinate tuples with innermost dimension - # being the first dim acquired - self.coords = [ - (dim3, dim2, dim1, dim0) - for dim3 in range(dims[3]) - for dim2 in range(dims[2]) - for dim1 in range(dims[1]) - for dim0 in range(dims[0]) - ] - - def _get_position_coords(self): - row_max = 0 - col_max = 0 - coords_list = [] - - # TODO: read rows, cols directly from XY corods # TODO: account for non MM2gamma meta? if not self.reader.stage_positions: raise ValueError("Stage positions not available.") for idx, pos in enumerate(self.reader.stage_positions): - stage_pos = pos.get("XYStage") + stage_pos = ( + pos.get("XYStage") or pos.get("XY") or pos.get("XY Stage") + ) if stage_pos is None: raise ValueError( f"Stage position is not available for position {idx}" ) - coords_list.append(pos["XYStage"]) - row = pos["GridRow"] - col = pos["GridCol"] - row_max = row if row > row_max else row_max - col_max = col if col > col_max else col_max - - return coords_list, row_max + 1, col_max + 1 - - def _perform_image_check(self, zarr_img: NDArray, tiff_img: NDArray): - if not np.array_equal(zarr_img, tiff_img): - raise ValueError( - "Converted Zarr image does not match the raw data. " - "Conversion Failed." - ) + xy_coords.append(stage_pos) + try: + rows.add(pos["GridRow"]) + cols.add(pos["GridCol"]) + except KeyError: + raise ValueError( + f"Grid indices not available for position {idx}" + ) + + return xy_coords, len(rows), len(cols) def _get_pos_names(self): """Append a list of pos names in ascending order @@ -315,26 +229,10 @@ def _get_pos_names(self): name = str(p) self.pos_names.append(name) - def _get_image_array(self, p: int, t: int, c: int, z: int): - try: - return np.asarray(self.reader.get_image(p, t, c, z)) - except KeyError: - # Converter will log a warning and - # fill zeros if the image does not exist - return None - - def _get_coord_reorder(self, coord): - reordered = [ - coord[self.p_dim], - coord[self.t_dim], - coord[self.c_dim], - coord[self.z_dim], - ] - return tuple(reordered) - def _gen_chunks(self, input_chunks): if not input_chunks: - chunks = [1, 1, 1, self.y, self.x] + _logger.debug("No chunk size specified, using ZYX.") + chunks = [1, 1, self.z, self.y, self.x] elif isinstance(input_chunks, tuple): chunks = list(input_chunks) elif isinstance(input_chunks, str): @@ -350,7 +248,7 @@ def _gen_chunks(self, input_chunks): ) # limit chunks to MAX_CHUNK_SIZE bytes - bytes_per_pixel = np.dtype(self.dtype).itemsize + bytes_per_pixel = np.dtype(self.reader.dtype).itemsize # it's OK if a single image is larger than MAX_CHUNK_SIZE while ( chunks[-3] > 1 @@ -359,46 +257,14 @@ def _gen_chunks(self, input_chunks): ): chunks[-3] = np.ceil(chunks[-3] / 2).astype(int) - logging.debug(f"Zarr store chunk size will be set to {chunks}.") + _logger.debug(f"Zarr store chunk size will be set to {chunks}.") return tuple(chunks) - def _get_channel_names(self): - cns = self.reader.channel_names - if not cns: - logging.warning( - "Cannot find channel names, using indices instead." - ) - cns = [str(i) for i in range(self.c)] - return cns - def _scale_voxels(self): - z_um = self.reader.z_step_size - if self.z_dim > 1 and not z_um: - logging.warning( - "Z step size is not available. " - "Setting the Z axis scaling factor to 1." - ) - z_um = 1.0 - xy_warning = ( - " Setting X and Y scaling factors to 1." - " Suppress this warning by setting `scale-voxels` to false." - ) - if isinstance(self.reader, MicromanagerSequenceReader): - logging.warning( - "Pixel size detection is not supported for single-page TIFFs." - + xy_warning - ) - xy_um = 1.0 - else: - try: - xy_um = self.reader.xy_pixel_size - except AttributeError as e: - logging.warning(str(e) + xy_warning) - xy_um = 1.0 return [ TransformationMeta( - type="scale", scale=[1.0, 1.0, z_um, xy_um, xy_um] + type="scale", scale=[1.0, 1.0, *self.reader.zyx_scale] ) ] @@ -407,7 +273,7 @@ def _init_zarr_arrays(self): self.output_dir, layout="hcs", mode="w-", - channel_names=self._get_channel_names(), + channel_names=self.reader.channel_names, version="0.4", ) self.zarr_position_names = [] @@ -420,7 +286,7 @@ def _init_zarr_arrays(self): self.y, self.x, ), - "dtype": self.dtype, + "dtype": self.reader.dtype, "chunks": self.chunks, "transform": self.transform, } @@ -432,7 +298,7 @@ def _init_zarr_arrays(self): def _init_hcs_arrays(self, arr_kwargs): for row, col, fov in self.hcs_sites: self._create_zeros_array(row, col, fov, arr_kwargs) - logging.info( + _logger.info( "Created HCS NGFF layout from Micro-Manager HCS position labels." ) self.writer.print_tree() @@ -453,160 +319,77 @@ def _create_zeros_array( ] pos.dump_meta() - def _convert_ndtiff(self): - bar_format_positions = ( - "Converting Positions: |{bar:16}|{n_fmt}/{total_fmt} " - "(Time Remaining: {remaining}), {rate_fmt}{postfix}]" - ) - bar_format_time_channel = ( - "Converting Timepoints/Channels: |{bar:16}|{n_fmt}/{total_fmt} " - "(Time Remaining: {remaining}), {rate_fmt}{postfix}]" - ) - for p_idx in tqdm(range(self.p), bar_format=bar_format_positions): - position_image_plane_metadata = {} - - # ndtiff_pos_idx, ndtiff_t_idx, and ndtiff_channel_idx - # may be None - ndtiff_pos_idx = ( - self.pos_names[p_idx] - if self.reader.str_position_axis - else p_idx - ) - try: - ndtiff_pos_idx, *_ = self.reader._check_coordinates( - ndtiff_pos_idx, 0, 0, 0 - ) - except ValueError: - # Log warning and continue if some positions were not - # acquired in the dataset - logging.warning( - f"Cannot load data at position {ndtiff_pos_idx}, " - "filling with zeros. Raw data may be incomplete." - ) - continue - - dask_arr = self.reader.get_zarr(position=ndtiff_pos_idx) - zarr_pos_name = self.zarr_position_names[p_idx] - zarr_arr = self.writer[zarr_pos_name]["0"] - - for t_idx, c_idx in product( - range(self.t), - range(self.c), - bar_format=bar_format_time_channel, - position=1, - leave=False, - ): - ndtiff_channel_idx = ( + def _convert_image_plane_metadata(self, fov, zarr_name: str): + position_image_plane_metadata = {} + sorted_keys = [] + for t_idx, c_idx in product( + range(self.t), + range(self.c), + desc="Converting frame metadata", + unit="frame", + leave=False, + ncols=80, + ): + c_key = c_idx + if isinstance(self.reader, NDTiffDataset): + c_key = ( self.reader.channel_names[c_idx] if self.reader.str_channel_axis else c_idx ) - # set ndtiff_t_idx and ndtiff_z_idx to None if these axes were - # not acquired - ( - _, - ndtiff_t_idx, - ndtiff_channel_idx, - ndtiff_z_idx, - ) = self.reader._check_coordinates( - ndtiff_pos_idx, t_idx, ndtiff_channel_idx, 0 - ) - # Log warning and continue if some T/C were not acquired in the - # dataset - if not self.reader.dataset.has_image( - position=ndtiff_pos_idx, - time=ndtiff_t_idx, - channel=ndtiff_channel_idx, - z=ndtiff_z_idx, - ): - logging.warning( - f"Cannot load data at timepoint {t_idx}, channel " - f"{c_idx}, filling with zeros. Raw data may be " - "incomplete." - ) + missing_data_warning_issued = False + for z_idx in range(self.z): + metadata = fov.frame_metadata(t=t_idx, c=c_key, z=z_idx) + if metadata is None: + if not missing_data_warning_issued: + missing_data_warning_issued = True + _logger.warning( + f"Cannot load data at P: {zarr_name}, T: {t_idx}, " + f"C: {c_idx}, filling with zeros. Raw data may be " + "incomplete." + ) continue - - data_slice = (slice(t_idx, t_idx + 1), slice(c_idx, c_idx + 1)) - to_zarr( - dask_arr[data_slice].rechunk(self.chunks), - zarr_arr, - region=data_slice, - ) - - for z_idx in range(self.z): - # this function will handle z_idx=0 when no z stacks - # acquired - image_metadata = self.reader.get_image_metadata( - ndtiff_pos_idx, - ndtiff_t_idx, - ndtiff_channel_idx, - z_idx, + if not sorted_keys: + # Sort keys, ordering keys without dashes first + sorted_keys = sorted( + metadata.keys(), key=lambda x: ("-" in x, x) ) - # T/C/Z - frame_key = "/".join( - [str(i) for i in (t_idx, c_idx, z_idx)] - ) - position_image_plane_metadata[frame_key] = image_metadata - - logging.info("Writing ND-TIFF image plane metadata...") - # image plane metadata is save in - # output_dir/row/well/fov/img/image_plane_metadata.json, - # e.g. output_dir/A/1/FOV0/0/image_plane_metadata.json - with open( - os.path.join( - self.output_dir, zarr_arr.path, "image_plane_metadata.json" - ), - mode="x", - ) as metadata_file: - json.dump( - position_image_plane_metadata, metadata_file, indent=4 - ) - def run(self, check_image: bool = True): - """Runs the conversion. + sorted_metadata = {key: metadata[key] for key in sorted_keys} + # T/C/Z + frame_key = "/".join([str(i) for i in (t_idx, c_idx, z_idx)]) + position_image_plane_metadata[frame_key] = sorted_metadata + with open( + self.output_dir / zarr_name / "image_plane_metadata.json", + mode="x", + ) as metadata_file: + json.dump(position_image_plane_metadata, metadata_file, indent=4) + + def __call__(self) -> None: + """ + Runs the conversion. - Parameters - ---------- - check_image : bool, optional - Whether to check that the written Zarr array has the same - pixel values as in TIFF files, by default True + Examples + -------- + >>> from iohub.convert import TIFFConverter + >>> converter = TIFFConverter("input/path/", "output/path/") + >>> converter() """ - logging.debug("Setting up Zarr store.") + _logger.debug("Setting up Zarr store.") self._init_zarr_arrays() - bar_format_images = ( - "Converting Images: |{bar:16}|{n_fmt}/{total_fmt} " - "(Time Remaining: {remaining}), {rate_fmt}{postfix}]" - ) # Run through every coordinate and convert in acquisition order - logging.info("Converting Images...") - if isinstance(self.reader, NDTiffReader): - if check_image: - logging.info( - "Checking converted image is not supported for ND-TIFF. " - "Ignoring..." - ) - self._convert_ndtiff() - else: - for coord in tqdm(self.coords, bar_format=bar_format_images): - coord_reorder = self._get_coord_reorder(coord) - 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={(p, t, c, z)}, filling " - "with zeros. Check if the raw data is incomplete." - ) - continue - else: - pos_idx = coord_reorder[0] - ndtiff_pos_idx = self.zarr_position_names[pos_idx] - zarr_img = self.writer[ndtiff_pos_idx]["0"] - zarr_img[coord_reorder[1:]] = img_raw - if check_image: - self._perform_image_check( - zarr_img[coord_reorder[1:]], img_raw - ) - + _logger.debug("Converting images.") + with logging_redirect_tqdm(): + for zarr_pos_name, (_, fov) in tqdm( + zip(self.zarr_position_names, self.reader, strict=True), + total=len(self.reader), + desc="Converting images", + unit="FOV", + ncols=80, + ): + zarr_img = self.writer[zarr_pos_name]["0"] + to_zarr(fov.xdata.data.rechunk(self.chunks), zarr_img) + self._convert_image_plane_metadata(fov, zarr_img.path) self.writer.zgroup.attrs.update(self.metadata) self.writer.close() + self.reader.close() diff --git a/iohub/fov.py b/iohub/fov.py index d91a21f8..82233801 100644 --- a/iohub/fov.py +++ b/iohub/fov.py @@ -30,7 +30,7 @@ def channel_names(self) -> list[str]: def channel_index(self, key: str) -> int: """Return index of given channel.""" - return self.channels.index(key) + return self.channel_names.index(key) def _missing_axes(self) -> list[int]: """Return sorted indices of missing axes.""" diff --git a/iohub/mm_fov.py b/iohub/mm_fov.py new file mode 100644 index 00000000..65dc8e30 --- /dev/null +++ b/iohub/mm_fov.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from pathlib import Path + +from xarray import DataArray + +from iohub.fov import BaseFOV, BaseFOVMapping + + +class MicroManagerFOV(BaseFOV): + def __init__(self, parent: MicroManagerFOVMapping, key: int) -> None: + self._position = key + self._parent = parent + + def __repr__(self) -> str: + return ( + f"Type: {type(self)}\n" + f"Parent: {self.parent}\n" + f"FOV key: {self._position}\n" + f"Data:\n" + ) + self.xdata.__repr__() + + @property + def parent(self) -> MicroManagerFOVMapping: + return self._parent + + @property + def root(self) -> Path: + return self.parent.root + + @property + def zyx_scale(self) -> tuple[float, float, float]: + return self.parent.zyx_scale + + @property + def channel_names(self) -> list[str]: + return self.parent.channel_names + + @property + def xdata(self) -> DataArray: + raise NotImplementedError + + def frame_metadata(self, t: int, z: int, c: int) -> dict | None: + """ + Return image plane metadata for a given camera frame. + + Parameters + ---------- + t : int + Time index. + z : int + Z slice index. + c : int + Channel index. + + Returns + ------- + dict | None + Image plane metadata. None if not available. + """ + raise NotImplementedError + + +class MicroManagerFOVMapping(BaseFOVMapping): + def __init__(self): + self._root: Path = None + self._mm_meta: dict = None + self._stage_positions: list[dict[str, str | float]] = [] + self.channel_names: list[str] = None + + def __repr__(self) -> str: + return (f"Type: {type(self)}\nData:\n") + self.xdata.__repr__() + + @property + def root(self) -> Path: + """Root directory of the dataset.""" + return self._root + + @property + def micromanager_metadata(self) -> dict | None: + return self._mm_meta + + @micromanager_metadata.setter + def micromanager_metadata(self, value): + if not isinstance(value, dict): + raise TypeError( + f"Type of `mm_meta` should be `dict`, got `{type(value)}`." + ) + self._mm_meta = value + + @property + def micromanager_summary(self) -> dict | None: + """Micro-manager summary metadata.""" + return self._mm_meta.get("Summary", None) + + @property + def stage_positions(self): + return self._stage_positions + + @stage_positions.setter + def stage_positions(self, value): + if not isinstance(value, list): + raise TypeError( + "Type of `stage_position` should be `list`, " + f"got `{type(value)}`." + ) + self._stage_positions = value + + @property + def hcs_position_labels(self): + """Parse plate position labels generated by the HCS position generator, + e.g. 'A1-Site_0' or '1-Pos000_000', and split into row, column, and + FOV names. + + Returns + ------- + list[tuple[str, str, str]] + FOV name paths, e.g. ('A', '1', '0') or ('0', '1', '000000') + """ + if not self.stage_positions: + raise ValueError("Stage position metadata not available.") + try: + # Look for "'A1-Site_0', 'H12-Site_1', ... " format + labels = [ + pos["Label"].split("-Site_") for pos in self.stage_positions + ] + return [(well[0], well[1:], fov) for well, fov in labels] + except Exception: + try: + # Look for "'1-Pos000_000', '2-Pos000_001', ... " + # and split into ('1', '000_000'), ... + labels = [ + pos["Label"].split("-Pos") for pos in self.stage_positions + ] + # remove underscore from FOV name, i.e. '000_000' + # collect all wells in row '0' so output is + # ('0', '1', '000000') + return [ + ("0", col, fov.replace("_", "")) for col, 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', or '1-Pos000_000' " + f"Got labels {labels}" + ) + + @property + def zyx_scale(self) -> tuple[float, float, float]: + """ZXY pixel size in micrometers.""" + raise NotImplementedError diff --git a/iohub/mmstack.py b/iohub/mmstack.py new file mode 100644 index 00000000..386104b0 --- /dev/null +++ b/iohub/mmstack.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import logging +from copy import copy +from pathlib import Path +from typing import TYPE_CHECKING, Iterable +from warnings import catch_warnings, filterwarnings + +import dask.array as da +import numpy as np +import zarr +from natsort import natsorted +from numpy.typing import ArrayLike +from tifffile import TiffFile +from xarray import DataArray + +from iohub.mm_fov import MicroManagerFOV, MicroManagerFOVMapping + +if TYPE_CHECKING: + from _typeshed import StrOrBytesPath + + +__all__ = ["MMOmeTiffFOV", "MMStack"] +_logger = logging.getLogger(__name__) + + +def _normalize_mm_pos_key(key: str | int) -> int: + try: + return int(key) + except TypeError: + raise TypeError("Micro-Manager position keys must be integers.") + + +def find_first_ome_tiff_in_mmstack(data_path: Path) -> Path: + if data_path.is_file(): + if "ome.tif" in data_path.name: + return data_path + else: + raise ValueError("{data_path} is not a OME-TIFF file.") + elif data_path.is_dir(): + files = data_path.glob("*.ome.tif") + try: + return next(files) + except StopIteration: + raise FileNotFoundError( + f"Path {data_path} contains no OME-TIFF files." + ) + raise FileNotFoundError(f"Path {data_path} does not exist.") + + +class MMOmeTiffFOV(MicroManagerFOV): + def __init__(self, parent: MMStack, key: str) -> None: + super().__init__(parent, key) + self._xdata = parent.xdata[key] + + @property + def axes_names(self) -> list[str]: + return list(self._xdata.dims) + + @property + def shape(self) -> tuple[int, int, int, int, int]: + return self._xdata.shape + + @property + def dtype(self) -> np.dtype: + return self._xdata.dtype + + @property + def t_scale(self) -> float: + return self.parent._t_scale + + def __getitem__(self, key: int | slice | tuple[int | slice]) -> ArrayLike: + return self._xdata[key] + + @property + def xdata(self) -> DataArray: + return self._xdata + + def frame_metadata(self, t: int, c: int, z: int) -> dict | None: + """Read image plane metadata from the OME-TIFF file.""" + return self.parent.read_image_metadata(self._position, t, c, z) + + +class MMStack(MicroManagerFOVMapping): + """Micro-Manager multi-file OME-TIFF (MMStack) reader. + + Parameters + ---------- + data_path : StrOrBytesPath + Path to the directory containing OME-TIFF files + or the path to the first OME-TIFF file in the series + """ + + def __init__(self, data_path: StrOrBytesPath): + super().__init__() + data_path = Path(data_path) + first_file = find_first_ome_tiff_in_mmstack(data_path) + self._root = first_file.parent + self.dirname = self._root.name + self._first_tif = TiffFile(first_file, is_mmstack=True) + _logger.debug(f"Parsing {first_file} as MMStack.") + with catch_warnings(): + # The IJMetadata tag (50839) is sometimes not written + # See https://micro-manager.org/Micro-Manager_File_Formats + filterwarnings("ignore", message=r".*50839.*", module="tifffile") + self._parse_data() + self._store = None + + def _parse_data(self): + series = self._first_tif.series[0] + raw_dims = dict( + (axis, size) + for axis, size in zip(series.get_axes(), series.get_shape()) + ) + axes = ("R", "T", "C", "Z", "Y", "X") + dims = dict((ax, raw_dims.get(ax, 1)) for ax in axes) + _logger.debug(f"Got dataset dimensions from tifffile: {dims}.") + ( + self.positions, + self.frames, + self.channels, + self.slices, + self.height, + self.width, + ) = dims.values() + self._set_mm_meta(self._first_tif.micromanager_metadata) + self._store = series.aszarr() + _logger.debug(f"Opened {self._store}.") + data = da.from_zarr(zarr.open(self._store)) + self.dtype = data.dtype + img = DataArray(data, dims=raw_dims, name=self.dirname) + xarr = img.expand_dims( + [ax for ax in axes if ax not in img.dims] + ).transpose(*axes) + if self.channels > len(self.channel_names): + for c in range(self.channels): + if c >= len(self.channel_names): + self.channel_names.append(f"Channel{c}") + _logger.warning( + "Number of channel names in the metadata is " + "smaller than the number of channels. " + f"Completing with fallback names: {self.channel_names}." + ) + # not all positions in the position list may have been acquired + xarr = xarr[: self.positions] + xarr.coords["C"] = self.channel_names + xset = xarr.to_dataset(dim="R") + self._xdata = xset + self._infer_image_meta() + + @property + def xdata(self): + return self._xdata + + def __len__(self) -> int: + return len(self.xdata.keys()) + + def __getitem__(self, key: str | int) -> MMOmeTiffFOV: + key = _normalize_mm_pos_key(key) + return MMOmeTiffFOV(self, key) + + def __setitem__(self, key, value) -> None: + raise PermissionError("MMStack is read-only.") + + def __delitem__(self, key, value) -> None: + raise PermissionError("MMStack is read-only.") + + def __contains__(self, key: str | int) -> bool: + key = _normalize_mm_pos_key(key) + return key in self.xdata + + def __iter__(self) -> Iterable[tuple[str, MMOmeTiffFOV]]: + for key in self.xdata: + yield str(key), self[key] + + def __enter__(self) -> MMStack: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + def close(self) -> None: + """Close file handles""" + self._first_tif.close() + + def _set_mm_meta(self, mm_meta: dict) -> None: + """Assign image metadata from summary metadata.""" + self._mm_meta = mm_meta + self.channel_names = [] + mm_version = self._mm_meta["Summary"]["MicroManagerVersion"] + if "beta" in mm_version: + if self._mm_meta["Summary"]["Positions"] > 1: + self._stage_positions = [] + + for p in range( + len(self._mm_meta["Summary"]["StagePositions"]) + ): + pos = self._simplify_stage_position_beta( + self._mm_meta["Summary"]["StagePositions"][p] + ) + self._stage_positions.append(pos) + + # MM beta versions sometimes don't have 'ChNames', + # so I'm wrapping in a try-except and setting the + # channel names to empty strings if it fails. + try: + for ch in self._mm_meta["Summary"]["ChNames"]: + self.channel_names.append(ch) + except Exception: + self.channel_names = self._mm_meta["Summary"]["Channels"] * [ + "" + ] # empty strings + + elif mm_version == "1.4.22": + for ch in self._mm_meta["Summary"].get("ChNames", []): + self.channel_names.append(ch) + + # Parsing of data acquired with the OpenCell + # acquisition script on the Dragonfly miroscope + elif ( + mm_version == "2.0.1 20220920" + and self._mm_meta["Summary"]["Prefix"] == "raw_data" + ): + files = natsorted(self.root.glob("*.ome.tif")) + self.positions = len(files) # not all positions are saved + + if self._mm_meta["Summary"]["Positions"] > 1: + self._stage_positions = [None] * self.positions + + for p_idx, file_name in enumerate(files): + site_idx = int(str(file_name).split("_")[-1].split("-")[0]) + pos = self._simplify_stage_position( + self._mm_meta["Summary"]["StagePositions"][site_idx] + ) + self._stage_positions[p_idx] = pos + + for ch in self._mm_meta["Summary"]["ChNames"]: + self.channel_names.append(ch) + + else: + if self._mm_meta["Summary"]["Positions"] > 1: + self._stage_positions = [] + + for p in range(self._mm_meta["Summary"]["Positions"]): + pos = self._simplify_stage_position( + self._mm_meta["Summary"]["StagePositions"][p] + ) + self._stage_positions.append(pos) + + for ch in self._mm_meta["Summary"].get("ChNames", []): + self.channel_names.append(ch) + z_step_size = float(self._mm_meta["Summary"].get("z-step_um", 1.0)) + if z_step_size == 0: + if self.slices == 1: + z_step_size = 1.0 + else: + _logger.warning( + f"Z-step size is {z_step_size} um in the metadata, " + "Using 1.0 um instead." + ) + self._z_step_size = z_step_size + self.height = self._mm_meta["Summary"]["Height"] + self.width = self._mm_meta["Summary"]["Width"] + self._t_scale = ( + float(self._mm_meta["Summary"].get("Interval_ms", 1e3)) / 1e3 + ) + + def _simplify_stage_position(self, stage_pos: dict): + """ + flattens the nested dictionary structure of stage_pos + and removes superfluous keys + + Parameters + ---------- + stage_pos: (dict) + dictionary containing a single position's device info + + Returns + ------- + out: (dict) + flattened dictionary + """ + + out = copy(stage_pos) + out.pop("DevicePositions") + for dev_pos in stage_pos["DevicePositions"]: + out.update({dev_pos["Device"]: dev_pos["Position_um"]}) + return out + + def _simplify_stage_position_beta(self, stage_pos: dict) -> dict: + """ + flattens the nested dictionary structure of stage_pos + and removes superfluous keys + for MM2.0 Beta versions + + Parameters + ---------- + stage_pos: (dict) + dictionary containing a single position's device info + + Returns + ------- + new_dict: (dict) + flattened dictionary + + """ + + new_dict = {} + new_dict["Label"] = stage_pos["label"] + new_dict["GridRow"] = stage_pos["gridRow"] + new_dict["GridCol"] = stage_pos["gridCol"] + + for sub in stage_pos["subpositions"]: + values = [] + for field in ["x", "y", "z"]: + if sub[field] != 0: + values.append(sub[field]) + if len(values) == 1: + new_dict[sub["stageName"]] = values[0] + else: + new_dict[sub["stageName"]] = values + + return new_dict + + def read_image_metadata( + self, p: int, t: int, c: int, z: int + ) -> dict | None: + """Read image plane metadata from the OME-TIFF file.""" + multi_index = (p, t, c, z) + tif_shape = (self.positions, self.frames, self.channels, self.slices) + idx = np.ravel_multi_index(multi_index, tif_shape) + return self._read_frame_metadata(idx) + + def _read_frame_metadata(self, idx: int) -> dict | None: + # `TiffPageSeries` is not a collection of `TiffPage` objects + # but a mixture of `TiffPage` and `TiffFrame` objects + # https://github.com/cgohlke/tifffile/issues/179 + with catch_warnings(): + filterwarnings( + "ignore", message=r".*from closed file.*", module="tifffile" + ) + try: + # virtual frames + page = self._first_tif.pages[idx] + except IndexError: + page = self._first_tif.series[0].pages[idx] + if page: + try: + page = page.aspage() + except ValueError: + _logger.warning("Cannot read tags from virtual frame.") + return None + else: + # invalid page + _logger.warning(f"Page {idx} is not present in the dataset.") + return None + try: + return page.tags["MicroManagerMetadata"].value + except KeyError: + _logger.warning("The Micro-Manager metadata tag is not found.") + return None + + def _infer_image_meta(self) -> None: + """ + Infer data type and pixel size from the first image plane metadata. + """ + _logger.debug("Inferring image metadata.") + metadata = self._read_frame_metadata(0) + if metadata is not None: + try: + self._xy_pixel_size = float(metadata["PixelSizeUm"]) + if self._xy_pixel_size > 0: + return + except Exception: + _logger.warning( + "Micro-Manager image plane metadata cannot be loaded." + ) + _logger.warning( + "XY pixel size cannot be determined, defaulting to 1.0 um." + ) + self._xy_pixel_size = 1.0 + + @property + def zyx_scale(self) -> tuple[float, float, float]: + return ( + self._z_step_size, + self._xy_pixel_size, + self._xy_pixel_size, + ) diff --git a/iohub/multipagetiff.py b/iohub/multipagetiff.py deleted file mode 100644 index d666e8f3..00000000 --- a/iohub/multipagetiff.py +++ /dev/null @@ -1,444 +0,0 @@ -import glob -import logging -import os -from copy import copy - -import numpy as np -import zarr -from tifffile import TiffFile - -from iohub.reader_base import ReaderBase - - -class MicromanagerOmeTiffReader(ReaderBase): - def __init__(self, folder: str, extract_data: bool = False): - super().__init__() - - """ - Parameters - ---------- - folder: (str) - folder or file containing all ome-tiff files - extract_data: (bool) - True if ome_series should be extracted immediately - - """ - - # Add Initial Checks - if len(glob.glob(os.path.join(folder, "*.ome.tif"))) == 0: - raise ValueError( - ( - f"Path {folder} contains no `.ome.tif` files, " - "please specify a valid input directory." - ) - ) - - # Grab all image files - self.data_directory = folder - self._files = sorted( - glob.glob(os.path.join(self.data_directory, "*.ome.tif")) - ) - - # Generate Data Specific Properties - self.coords = None - self.coord_map = dict() - self.pos_names = [] - self.position_arrays = dict() - self.positions = 0 - self.frames = 0 - self.channels = 0 - self.slices = 0 - self.height = 0 - self.width = 0 - self._infer_image_meta() - - # Initialize MM attributes - self.channel_names = [] - - # Gather index map of file, page, byte offset - self._gather_index_maps() - - # Read MM data - self._set_mm_meta() - - # if extract data, create all of the virtual zarr stores up front - if extract_data: - for i in range(self.positions): - self._create_position_array(i) - - def _gather_index_maps(self): - """ - Will return a dictionary of {coord: (filepath, page, byte_offset)} - of length (N_Images) to later query - - Returns - ------- - - """ - - positions = 0 - frames = 0 - channels = 0 - slices = 0 - for file in self._files: - tf = TiffFile(file) - meta = tf.micromanager_metadata["IndexMap"] - tf.close() - offsets = self._get_byte_offsets(meta) - for page, offset in enumerate(offsets): - coord = [0, 0, 0, 0] - coord[0] = meta["Position"][page] - coord[1] = meta["Frame"][page] - coord[2] = meta["Channel"][page] - coord[3] = meta["Slice"][page] - self.coord_map[tuple(coord)] = (file, page, offset) - - # update dimensions as we go along, - # helps with incomplete datasets - if coord[0] + 1 > positions: - positions = coord[0] + 1 - - if coord[1] + 1 > frames: - frames = coord[1] + 1 - - if coord[2] + 1 > channels: - channels = coord[2] + 1 - - if coord[3] + 1 > slices: - slices = coord[3] + 1 - - # update dimensions to the largest dimensions present in the saved data - self.positions = positions - self.frames = frames - self.channels = channels - self.slices = slices - - @staticmethod - def _get_byte_offsets(meta: dict): - """Get byte offsets from Micro-Manager metadata. - - Parameters - ---------- - meta : dict - Micro-Manager metadata in the OME-TIFF header - - Returns - ------- - list - List of byte offsets for image arrays in the multi-page TIFF file - """ - offsets = meta["Offset"][meta["Offset"] > 0] - offsets[0] += 210 # first page array offset - offsets[1:] += 162 # image array offset - return list(offsets) - - def _set_mm_meta(self): - """ - assign image metadata from summary metadata - - Returns - ------- - - """ - with TiffFile(self._files[0]) as tif: - self.mm_meta = tif.micromanager_metadata - - mm_version = self.mm_meta["Summary"]["MicroManagerVersion"] - if "beta" in mm_version: - if self.mm_meta["Summary"]["Positions"] > 1: - self._stage_positions = [] - - for p in range( - len(self.mm_meta["Summary"]["StagePositions"]) - ): - pos = self._simplify_stage_position_beta( - self.mm_meta["Summary"]["StagePositions"][p] - ) - self._stage_positions.append(pos) - - # MM beta versions sometimes don't have 'ChNames', - # so I'm wrapping in a try-except and setting the - # channel names to empty strings if it fails. - try: - for ch in self.mm_meta["Summary"]["ChNames"]: - self.channel_names.append(ch) - except Exception: - self.channel_names = self.mm_meta["Summary"][ - "Channels" - ] * [ - "" - ] # empty strings - - elif mm_version == "1.4.22": - for ch in self.mm_meta["Summary"].get("ChNames", []): - self.channel_names.append(ch) - - # Parsing of data acquired with the OpenCell - # acquisition script on the Dragonfly miroscope - elif ( - mm_version == "2.0.1 20220920" - and self.mm_meta["Summary"]["Prefix"] == "raw_data" - ): - file_names = set( - [(key[0], val[0]) for key, val in self.coord_map.items()] - ) - - if self.mm_meta["Summary"]["Positions"] > 1: - self._stage_positions = [None] * self.positions - - for p_idx, file_name in file_names: - site_idx = int(file_name.split("_")[-1].split("-")[0]) - pos = self._simplify_stage_position( - self.mm_meta["Summary"]["StagePositions"][site_idx] - ) - self._stage_positions[p_idx] = pos - - for ch in self.mm_meta["Summary"]["ChNames"]: - self.channel_names.append(ch) - - else: - if self.mm_meta["Summary"]["Positions"] > 1: - self._stage_positions = [] - - for p in range(self.mm_meta["Summary"]["Positions"]): - pos = self._simplify_stage_position( - self.mm_meta["Summary"]["StagePositions"][p] - ) - self._stage_positions.append(pos) - - for ch in self.mm_meta["Summary"].get("ChNames", []): - self.channel_names.append(ch) - - self.z_step_size = self.mm_meta["Summary"]["z-step_um"] - self.height = self.mm_meta["Summary"]["Height"] - self.width = self.mm_meta["Summary"]["Width"] - - # dimensions based on mm metadata - # do not reflect final written dimensions - # these set in _gather_index_maps - # - # self.frames = self.mm_meta["Summary"]["Frames"] - # self.slices = self.mm_meta["Summary"]["Slices"] - # self.channels = self.mm_meta["Summary"]["Channels"] - - def _simplify_stage_position(self, stage_pos: dict): - """ - flattens the nested dictionary structure of stage_pos - and removes superfluous keys - - Parameters - ---------- - stage_pos: (dict) - dictionary containing a single position's device info - - Returns - ------- - out: (dict) - flattened dictionary - """ - - out = copy(stage_pos) - out.pop("DevicePositions") - for dev_pos in stage_pos["DevicePositions"]: - out.update({dev_pos["Device"]: dev_pos["Position_um"]}) - return out - - def _simplify_stage_position_beta(self, stage_pos: dict): - """ - flattens the nested dictionary structure of stage_pos - and removes superfluous keys - for MM2.0 Beta versions - - Parameters - ---------- - stage_pos: (dict) - dictionary containing a single position's device info - - Returns - ------- - new_dict: (dict) - flattened dictionary - - """ - - new_dict = {} - new_dict["Label"] = stage_pos["label"] - new_dict["GridRow"] = stage_pos["gridRow"] - new_dict["GridCol"] = stage_pos["gridCol"] - - for sub in stage_pos["subpositions"]: - values = [] - for field in ["x", "y", "z"]: - if sub[field] != 0: - values.append(sub[field]) - if len(values) == 1: - new_dict[sub["stageName"]] = values[0] - else: - new_dict[sub["stageName"]] = values - - return new_dict - - def _create_position_array(self, pos): - """maps all of the tiff data into a virtual zarr store - in memory for a given position - - Parameters - ---------- - pos: (int) index of the position to create array under - - Returns - ------- - - """ - - # intialize virtual zarr store and save it under positions - timepoints, channels, slices = self._get_dimensions(pos) - self.position_arrays[pos] = zarr.zeros( - shape=(timepoints, channels, slices, self.height, self.width), - chunks=(1, 1, 1, self.height, self.width), - dtype=self.dtype, - ) - # add all the images with this specific dimension. - # Will be blank images if dataset - # is incomplete - for p, t, c, z in self.coord_map.keys(): - if p == pos: - self.position_arrays[pos][t, c, z, :, :] = self.get_image( - pos, t, c, z - ) - - def _infer_image_meta(self): - """ - Infer data type and pixel size from the first image plane metadata. - """ - with TiffFile(self._files[0]) as tf: - page = tf.pages[0] - self.dtype = page.dtype - for tag in page.tags.values(): - if tag.name == "MicroManagerMetadata": - # assuming X and Y pixel sizes are the same - xy_size = tag.value.get("PixelSizeUm") - self._xy_pixel_size = xy_size if xy_size else None - return - else: - continue - logging.warning( - "Micro-Manager image plane metadata cannot be loaded." - ) - self._xy_pixel_size = None - - @property - def xy_pixel_size(self): - """XY pixel size of the camera in micrometers.""" - if self._xy_pixel_size is None: - raise AttributeError("XY pixel size cannot be determined.") - return self._xy_pixel_size - - def _get_dimensions(self, position): - """ - Gets the max dimensions from the current position - in case of incomplete datasets - - Parameters - ---------- - position: (int) Position index to grab dimensions from - - Returns - ------- - - """ - - t = 0 - c = 0 - z = 0 - - # dimension size = index + 1 - for tup in self.coord_map.keys(): - if position != tup[0]: - continue - else: - if tup[1] + 1 > t: - t = tup[1] + 1 - if tup[2] + 1 > c: - c = tup[2] + 1 - if tup[3] + 1 > z: - z = tup[3] + 1 - - return t, c, z - - def get_image(self, p, t, c, z): - """ - get the image at a specific coordinate through memory mapping - - Parameters - ---------- - p: (int) position index - t: (int) time index - c: (int) channel index - z: (int) slice/z index - - Returns - ------- - image: (np-array) - numpy array of shape (Y, X) at given coordinate - - """ - - coord_key = (p, t, c, z) - coord = self.coord_map[coord_key] # (file, page, offset) - - return np.memmap( - coord[0], - dtype=self.dtype, - mode="r", - offset=coord[2], - shape=(self.height, self.width), - ) - - def get_zarr(self, position): - """ - return a zarr array for a given position - - Parameters - ---------- - position: (int) position (aka ome-tiff scene) - - Returns - ------- - position: (zarr.array) - - """ - if position not in self.position_arrays.keys(): - self._create_position_array(position) - return self.position_arrays[position] - - def get_array(self, position): - """ - return a numpy array for a given position - - Parameters - ---------- - position: (int) position (aka ome-tiff scene) - - Returns - ------- - position: (np.ndarray) - - """ - - # if position hasn't been initialized in memory, do that. - if position not in self.position_arrays.keys(): - self._create_position_array(position) - - return np.array(self.position_arrays[position]) - - def get_num_positions(self): - """ - get total number of scenes referenced in ome-tiff metadata - - Returns - ------- - number of positions (int) - - """ - return self.positions diff --git a/iohub/ndtiff.py b/iohub/ndtiff.py index 65e44e15..65f9da52 100644 --- a/iohub/ndtiff.py +++ b/iohub/ndtiff.py @@ -1,23 +1,80 @@ +from __future__ import annotations + +import logging import warnings -from typing import Literal, Union +from pathlib import Path +from typing import Any, Iterable, Literal import numpy as np -import zarr +from natsort import natsorted from ndtiff import Dataset +from numpy.typing import ArrayLike +from xarray import DataArray +from xarray import Dataset as XDataset + +from iohub.mm_fov import MicroManagerFOV, MicroManagerFOVMapping + +__all__ = ["NDTiffDataset", "NDTiffFOV"] +_logger = logging.getLogger(__name__) + + +class NDTiffFOV(MicroManagerFOV): + def __init__(self, parent: NDTiffDataset, key: int) -> None: + super().__init__(parent, key) + self._xdata = parent.xdata[key] + + @property + def axes_names(self) -> list[str]: + return list(self._xdata.dims) + + @property + def shape(self) -> tuple[int, int, int, int, int]: + return self._xdata.shape + + @property + def dtype(self) -> np.dtype: + return self._xdata.dtype + + @property + def t_scale(self) -> float: + return 1.0 + + def __getitem__( + self, key: int | slice | tuple[int | slice, ...] + ) -> ArrayLike: + return self._xdata[key] + + @property + def xdata(self) -> DataArray: + return self._xdata -from iohub.reader_base import ReaderBase + def frame_metadata(self, t: int, c: int, z: int) -> dict[str, Any]: + return self.parent.get_image_metadata(self._position, t, c, z) -class NDTiffReader(ReaderBase): +class NDTiffDataset(MicroManagerFOVMapping): """Reader for ND-TIFF datasets acquired with Micro/Pycro-Manager, effectively a wrapper of the `ndtiff.Dataset` class. """ - def __init__(self, data_path: str): - super().__init__() + _ndtiff_axes: tuple[str] = ("position", "time", "channel", "z", "y", "x") - self.dataset = Dataset(data_path) + def __init__(self, data_path: Path | str): + super().__init__() + data_path = Path(data_path) + if not data_path.is_dir(): + raise FileNotFoundError( + f"{data_path} is not a valid NDTiff dataset." + ) + self.dataset = Dataset(str(data_path)) + self._root = data_path + self.dirname = data_path.name self._axes = self.dataset.axes + if any([a for a in self._axes.keys() if a not in self._ndtiff_axes]): + raise NotImplementedError( + f"Custom axis names {self._axes.keys()} are not supported. " + f"Supported axes are: {self._ndtiff_axes}" + ) self._str_posistion_axis = self._check_str_axis("position") self._str_channel_axis = self._check_str_axis("channel") self.frames = ( @@ -31,26 +88,66 @@ def __init__(self, data_path: str): self.width = self.dataset.image_width self.dtype = self.dataset.dtype - self.mm_meta = self._get_summary_metadata() - self.channel_names = list(self.dataset.get_channel_names()) - self.stage_positions = self.mm_meta["Summary"]["StagePositions"] - self.z_step_size = self.mm_meta["Summary"]["z-step_um"] - self.xy_pixel_size = self.mm_meta["Summary"]["PixelSize_um"] + self._all_position_keys = self._parse_all_position_keys() + self._ndtiff_channel_names = list(self._axes.get("channel", [None])) + self._mm_meta = self._get_summary_metadata() + self.channel_names = self._ndtiff_channel_names + if self.channel_names[0] is None or self.channel_names[0] == 0: + self.channel_names = [f"Channel{i}" for i in range(self.channels)] + _logger.warning( + "No channel names found in metadata. Using defaults: " + f"{self.channel_names}" + ) + self.stage_positions = self._mm_meta["Summary"]["StagePositions"] + z_step_size = float(self._mm_meta["Summary"]["z-step_um"] or 1.0) + xy_pixel_size = float(self._mm_meta["Summary"]["PixelSize_um"] or 1.0) + self._zyx_scale = (z_step_size, xy_pixel_size, xy_pixel_size) + self._gather_xdata() + + def __enter__(self) -> NDTiffDataset: + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def __iter__(self) -> Iterable[tuple[str, NDTiffFOV]]: + for key in self.xdata.keys(): + key = str(key) + yield key, NDTiffFOV(self, key) + + def __contains__(self, key: str | int) -> bool: + return str(key) in self._xdata + + def __len__(self) -> int: + return len(self._all_position_keys) + + def __getitem__(self, key: int | str) -> NDTiffFOV: + return NDTiffFOV(self, str(key)) + + def close(self) -> None: + self.dataset.close() + + @property + def zyx_scale(self) -> tuple[float, float, float]: + return self._zyx_scale 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["Positions"] = len(self) + + p_idx = self._all_position_keys[0] + c_idx = self._ndtiff_channel_names[0] + img_metadata = self.get_image_metadata(p_idx, 0, c_idx, 0) pm_metadata["z-step_um"] = None if "ZPosition_um_Intended" in img_metadata.keys(): pm_metadata["z-step_um"] = np.around( abs( - self.get_image_metadata(0, 0, 0, 1)[ + self.get_image_metadata(p_idx, 0, c_idx, 1)[ "ZPosition_um_Intended" ] - - self.get_image_metadata(0, 0, 0, 0)[ + - self.get_image_metadata(p_idx, 0, c_idx, 0)[ "ZPosition_um_Intended" ] ), @@ -61,7 +158,7 @@ def _get_summary_metadata(self): if "position" in self._axes: for position in self._axes["position"]: position_metadata = {} - img_metadata = self.get_image_metadata(position, 0, 0, 0) + img_metadata = self.get_image_metadata(position, 0, c_idx, 0) if img_metadata is not None and all( key in img_metadata.keys() @@ -101,16 +198,14 @@ 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 - ): + def _check_coordinates(self, p: int | str, t: int, c: 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") + axes = self._ndtiff_axes[:4] for i, axis in enumerate(axes): coord = coords[i] @@ -162,110 +257,55 @@ def _check_coordinates( f"Axis {axis} is not part of this dataset" ) - return (*coords,) - - def get_num_positions(self) -> int: - return ( - len(self._axes["position"]) - if "position" in self._axes.keys() - else 1 - ) - - 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 or str - position index - t : int - time index - c : int or str - channel index - z : int - slice/z index - - Returns - ------- - np.ndarray - numpy array of shape (Y, X) at given coordinate - """ - - image = None - p, t, c, z = self._check_coordinates(p, t, c, z) - - if self.dataset.has_image(position=p, time=t, channel=c, z=z): - image = self.dataset.read_image(position=p, time=t, channel=c, z=z) - - return image + return tuple(coords) - 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 - rather than a zarr array. - - Return a lazy-loaded dask array with shape TCZYX at the given position. - Data is not loaded into memory. - - - Parameters - ---------- - position: (int) position index + def _parse_all_position_keys(self) -> list[int | str | None]: + return natsorted(list(self._axes.get("position", [None]))) - Returns - ------- - position: (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 + def _check_position_key(self, key: int | str) -> bool: if "position" in self._axes.keys(): - if position not in self._axes["position"]: + if key not in self._axes["position"]: raise ValueError( - f"Position index {position} is not part of this dataset. " + f"Position index {key} is not part of this dataset. " f'Valid positions are: {self._axes["position"]}' ) else: - if position not in (0, None): + if key not in (0, None): warnings.warn( - f"Position index {position} is not part of this dataset. " + f"Position index {key} is not part of this dataset. " "Returning data at the default position." ) - position = None + key = None + return key - da = self.dataset.as_array(position=position) + def _gather_xdata(self) -> None: shape = ( + len(self), self.frames, self.channels, self.slices, self.height, self.width, ) - # add singleton axes so output is 5D - return da.reshape(shape) - - def get_array(self, position: Union[int, str]) -> np.ndarray: - """ - return a numpy array with shape TCZYX at the given position - - Parameters - ---------- - position: (int) position index - - Returns - ------- - position: (np.ndarray) - - """ + if self._all_position_keys == [None]: + pkeys = ["0"] + else: + pkeys = [str(k) for k in self._all_position_keys] + # add singleton axes so output is always (p, t, c, z, y, x) + da = DataArray( + self.dataset.as_array().reshape(shape), + dims=self._ndtiff_axes, + name=self.dirname, + coords={"position": pkeys, "channel": self.channel_names}, + ) + self._xdata = da.to_dataset(dim="position") - return np.asarray(self.get_zarr(position)) + @property + def xdata(self) -> XDataset: + return self._xdata def get_image_metadata( - self, p: Union[int, str], t: int, c: Union[int, str], z: int + self, p: int | str, t: int, c: int | str, z: int ) -> dict: """Return image plane metadata at the requested PTCZ coordinates @@ -286,11 +326,12 @@ def get_image_metadata( image plane metadata """ metadata = None + if not self.str_position_axis and isinstance(p, str): + if p.isdigit(): + p = int(p) p, t, c, z = self._check_coordinates(p, t, c, z) - if self.dataset.has_image(position=p, time=t, channel=c, z=z): metadata = self.dataset.read_metadata( position=p, time=t, channel=c, z=z ) - return metadata diff --git a/iohub/ngff.py b/iohub/ngff.py index 19b9d29a..d5a307e0 100644 --- a/iohub/ngff.py +++ b/iohub/ngff.py @@ -35,6 +35,8 @@ if TYPE_CHECKING: from _typeshed import StrOrBytesPath +_logger = logging.getLogger(__name__) + def _pad_shape(shape: tuple[int], target: int = 5): """Pad shape tuple to a target length.""" @@ -53,7 +55,7 @@ def _open_store( f"Dataset directory not found at {store_path}." ) if version != "0.4": - logging.warning( + _logger.warning( "\n".join( "IOHub is only tested against OME-NGFF v0.4.", f"Requested version {version} may not work properly.", @@ -253,7 +255,7 @@ def iteritems(self): try: yield key, self[key] except Exception: - logging.warning( + _logger.warning( "Skipped item at {}: invalid {}.".format( key, type(self._MEMBER_TYPE) ) @@ -288,7 +290,7 @@ def _warn_invalid_meta(self): msg = "Zarr group at {} does not have valid metadata for {}".format( self._group.path, type(self) ) - logging.warning(msg) + _logger.warning(msg) def _parse_meta(self): """Parse and set NGFF metadata from `.zattrs`.""" @@ -745,9 +747,9 @@ def _check_shape(self, data_shape: tuple[int]): if data_shape[ch_axis] > num_ch: raise ValueError(msg) elif data_shape[ch_axis] < num_ch: - logging.warning(msg) + _logger.warning(msg) else: - logging.info( + _logger.info( "Dataset channel axis is not set. " "Skipping channel shape check." ) @@ -1340,7 +1342,7 @@ def __init__( def _parse_meta(self): if plate_meta := self.zattrs.get("plate"): - logging.debug(f"Loading HCS metadata from file: {plate_meta}") + _logger.debug(f"Loading HCS metadata from file: {plate_meta}") self.metadata = PlateMeta(**plate_meta) else: self._warn_invalid_meta() @@ -1357,13 +1359,13 @@ def _first_pos_attr(self, attr: str): well_grp = next(row_grp.groups())[1] pos_grp = next(well_grp.groups())[1] except StopIteration: - logging.warning(f"{msg} No position is found in the dataset.") + _logger.warning(f"{msg} No position is found in the dataset.") return try: pos = Position(pos_grp) setattr(self, attr, getattr(pos, attr)) except AttributeError: - logging.warning(f"{msg} Invalid metadata at the first position") + _logger.warning(f"{msg} Invalid metadata at the first position") def dump_meta(self, field_count: bool = False): """Dumps metadata JSON to the `.zattrs` file. @@ -1641,7 +1643,7 @@ def open_ome_zarr( raise FileExistsError(store_path) elif mode == "w": if os.path.exists(store_path): - logging.warning(f"Overwriting data at {store_path}") + _logger.warning(f"Overwriting data at {store_path}") else: raise ValueError(f"Invalid persistence mode '{mode}'.") root = _open_store(store_path, mode, version, synchronizer) diff --git a/iohub/reader.py b/iohub/reader.py index 2f83c023..708fb81e 100644 --- a/iohub/reader.py +++ b/iohub/reader.py @@ -1,47 +1,27 @@ from __future__ import annotations -import glob import logging -import os import sys import warnings +from pathlib import Path from typing import TYPE_CHECKING, Literal import natsort import tifffile as tiff import zarr -from iohub.multipagetiff import MicromanagerOmeTiffReader -from iohub.ndtiff import NDTiffReader +from iohub._deprecated.reader_base import ReaderBase +from iohub._deprecated.singlepagetiff import MicromanagerSequenceReader +from iohub._deprecated.zarrfile import ZarrReader +from iohub.fov import BaseFOVMapping +from iohub.mmstack import MMStack +from iohub.ndtiff import NDTiffDataset from iohub.ngff import NGFFNode, Plate, Position, open_ome_zarr -from iohub.reader_base import ReaderBase -from iohub.singlepagetiff import MicromanagerSequenceReader -from iohub.upti import UPTIReader -from iohub.zarrfile import ZarrReader if TYPE_CHECKING: from _typeshed import StrOrBytesPath -# replicate from aicsimageio logging mechanism -############################################################################### - -# modify the logging.ERROR level lower for more info -# CRITICAL -# ERROR -# WARNING -# INFO -# DEBUG -# NOTSET -# logging.basicConfig( -# level=logging.DEBUG, -# format="[%(levelname)4s: %(module)s:%(lineno)4s %(asctime)s] %(message)s" -# ) -# log = logging.getLogger(__name__) - -############################################################################### - - -# todo: add dim_order to all reader objects +_logger = logging.getLogger(__name__) def _find_ngff_version_in_zarr_group(group: zarr.Group): @@ -50,7 +30,7 @@ def _find_ngff_version_in_zarr_group(group: zarr.Group): return group.attrs[key].get("version") -def _check_zarr_data_type(src: str): +def _check_zarr_data_type(src: Path): try: root = zarr.open(src, "r") if version := _find_ngff_version_in_zarr_group(root): @@ -65,75 +45,66 @@ def _check_zarr_data_type(src: str): return "unknown" -def _check_single_page_tiff(src: str): - # pick parent directory in case a .tif file is selected - if src.endswith(".tif"): - src = os.path.dirname(src) - - files = glob.glob(os.path.join(src, "*.tif")) - if len(files) == 0: +def _check_single_page_tiff(src: Path): + if src.is_file(): + src = src.parent + files = src.glob("*.tif") + try: + next(files) + except StopIteration: sub_dirs = _get_sub_dirs(src) if sub_dirs: - path = os.path.join(src, sub_dirs[0]) - files = glob.glob(os.path.join(path, "*.tif")) - if len(files) > 0: - with tiff.TiffFile(os.path.join(path, files[0])) as tf: + files = (src / sub_dirs[0]).glob("*.tif") + try: + with tiff.TiffFile(next(files)) as tf: if ( len(tf.pages) == 1 ): # and tf.pages[0].is_multipage is False: return True + except StopIteration: + pass return False -def _check_multipage_tiff(src: str): - # pick parent directory in case a .tif file is selected - if src.endswith(".tif"): - src = os.path.dirname(src) - - files = glob.glob(os.path.join(src, "*.tif")) - if len(files) > 0: - with tiff.TiffFile(files[0]) as tf: - if len(tf.pages) > 1: - return True - elif tf.is_multipage is False and tf.is_ome is True: - return True +def _check_multipage_tiff(src: Path): + if src.is_file(): + src = src.parent + try: + file = next(src.glob("*.tif")) + except StopIteration: + return False + with tiff.TiffFile(file) as tf: + if len(tf.pages) > 1: + return True + elif tf.is_multipage is False and tf.is_ome is True: + return True return False -def _check_ndtiff(src: str): - # go two levels up in case a .tif file is selected - if src.endswith(".tif"): - src = os.path.abspath(os.path.join(src, "../..")) - - # shortcut, may not be foolproof - if os.path.exists(os.path.join(src, "Full resolution", "NDTiff.index")): +def _check_ndtiff(src: Path): + # select parent directory if a .tif file is selected + if src.is_file() and "tif" in src.suffixes: + if _check_ndtiff(src.parent) or _check_ndtiff(src.parent.parent): + return True + # ndtiff v2 + if Path(src, "Full resolution", "NDTiff.index").exists(): return True - elif os.path.exists(os.path.join(src, "NDTiff.index")): + # ndtiff v3 + elif Path(src, "NDTiff.index").exists(): return True return False -def _get_sub_dirs(f: str): - """ - subdir walk - from https://github.com/mehta-lab/reconstruct-order - - Parameters - ---------- - f: (str) - - Returns - ------- - sub_dir_name (list) natsorted list of subdirectories - """ - - sub_dir_path = glob.glob(os.path.join(f, "*/")) - sub_dir_name = [os.path.split(subdir[:-1])[1] for subdir in sub_dir_path] +def _get_sub_dirs(directory: Path) -> list[str]: + """ """ + sub_dir_name = [ + subdir.name for subdir in directory.iterdir() if subdir.is_dir() + ] # assert subDirName, 'No sub directories found' return natsort.natsorted(sub_dir_name) -def _infer_format(path: str): +def _infer_format(path: Path): extra_info = None if ngff_version := _check_zarr_data_type(path): data_type = "omezarr" @@ -152,14 +123,12 @@ def _infer_format(path: str): return (data_type, extra_info) -def read_micromanager( - path: str, +def read_images( + path: StrOrBytesPath, data_type: Literal[ "singlepagetiff", "ometiff", "ndtiff", "omezarr" ] = None, - extract_data: bool = False, - log_level: int = logging.WARNING, -): +) -> ReaderBase | BaseFOVMapping: """Read image arrays and metadata from a Micro-Manager dataset. Supported formats are Micro-Manager-acquired TIFF datasets (single-page TIFF, multi-page OME-TIFF, NDTIFF), @@ -167,40 +136,28 @@ def read_micromanager( Parameters ---------- - path : str + path : StrOrBytesPath File path, directory path to ome-tiff series, or Zarr root path data_type : Literal["singlepagetiff", "ometiff", "ndtiff", "omezarr"], optional Dataset format, by default None - extract_data : bool, optional - True if ome_series should be extracted immediately for TIFF datasets, - by default False - log_level : int, optional - One of 0, 10, 20, 30, 40, 50 for - NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL, respectively, - by default logging.WARNING Returns ------- - Reader - A child instance of ReaderBase + ReaderBase | BaseFOVMapping + Image collection object for the dataset """ - - logging.basicConfig( - level=log_level, - format="[%(levelname)4s: %(module)s:%(lineno)4s %(asctime)s] %(message)s", # noqa - ) - logging.getLogger(__name__) - + path = Path(path).resolve() # try to guess data type extra_info = None if not data_type: data_type, extra_info = _infer_format(path) + _logger.debug(f"Detected data type: {data_type} {extra_info}") # identify data structure type if data_type == "ometiff": - return MicromanagerOmeTiffReader(path, extract_data) + return MMStack(path) elif data_type == "singlepagetiff": - return MicromanagerSequenceReader(path, extract_data) + return MicromanagerSequenceReader(path) elif data_type == "omezarr": if extra_info is None: _, extra_info = _infer_format(path) @@ -209,7 +166,7 @@ def read_micromanager( warnings.warn( UserWarning( "For NGFF v0.4 datasets, `iohub.open_ome_zarr()` " - "is preferred over `iohub.read_micromanager()`. " + "is preferred over `iohub.read_images()`. " "Note that `open_ome_zarr()` will return " "an NGFFNode object instead of a ReaderBase instance." ) @@ -218,9 +175,7 @@ def read_micromanager( raise ValueError(f"NGFF version {extra_info} is not supported.") return ZarrReader(path, version=extra_info) elif data_type == "ndtiff": - return NDTiffReader(path) - elif data_type == "upti": - return UPTIReader(path, extract_data) + return NDTiffDataset(path) else: raise ValueError(f"Reader of type {data_type} is not implemented") @@ -237,19 +192,13 @@ def print_info(path: StrOrBytesPath, verbose=False): and full tree for HCS Plates in OME-Zarr, by default False """ - path = os.path.realpath(path) + path = Path(path).resolve() try: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=UserWarning, module="iohub" - ) - fmt, extra_info = _infer_format(path) - if fmt == "omezarr" and extra_info == "0.4": - reader = open_ome_zarr(path, mode="r") - else: - reader = read_micromanager( - path, data_type=fmt, log_level=logging.ERROR - ) + fmt, extra_info = _infer_format(path) + if fmt == "omezarr" and extra_info == "0.4": + reader = open_ome_zarr(path, mode="r") + else: + reader = read_images(path, data_type=fmt) except (ValueError, RuntimeError): print("Error: No compatible dataset is found.", file=sys.stderr) return @@ -261,30 +210,30 @@ def print_info(path: StrOrBytesPath, verbose=False): ch_msg = f"Channel names:\t\t {reader.channel_names}" code_msg = "\nThis datset can be opened with iohub in Python code:\n" msgs = [] - if isinstance(reader, ReaderBase): - zyx_scale = ( - reader.z_step_size, - reader.xy_pixel_size, - reader.xy_pixel_size, + if isinstance(reader, BaseFOVMapping): + _, first_fov = next(iter(reader)) + shape_msg = ", ".join( + [ + f"{a}={s}" + for s, a in zip(first_fov.shape, ("T", "C", "Z", "Y", "X")) + ] ) msgs.extend( [ sum_msg, fmt_msg, - f"Positions:\t\t {reader.get_num_positions()}", - f"Time points:\t\t {reader.shape[0]}", - f"Channels:\t\t {reader.shape[1]}", + f"FOVs:\t\t\t {len(reader)}", + f"FOV shape:\t\t {shape_msg}", ch_msg, - f"(Z, Y, X) shape:\t {reader.shape[2:]}", - f"(Z, Y, X) scale (um):\t {zyx_scale}", + f"(Z, Y, X) scale (um):\t {first_fov.zyx_scale}", ] ) if verbose: msgs.extend( [ code_msg, - ">>> from iohub import read_micromanager", - f">>> reader = read_micromanager('{path}')", + ">>> from iohub import read_images", + f">>> reader = read_images('{path}')", ] ) print(str.join("\n", msgs)) @@ -312,9 +261,12 @@ def print_info(path: StrOrBytesPath, verbose=False): if verbose: print("Zarr hierarchy:") reader.print_tree() - msgs.append(f"Positions:\t\t {len(list(reader.positions()))}") + positions = list(reader.positions()) + msgs.append(f"Positions:\t\t {len(positions)}") + msgs.append(f"Chunk size:\t\t {positions[0][1][0].chunks}") else: msgs.append(f"(Z, Y, X) scale (um):\t {tuple(reader.scale[2:])}") + msgs.append(f"Chunk size:\t\t {reader['0'].chunks}") if verbose: msgs.extend( [ diff --git a/setup.cfg b/setup.cfg index 3afffeee..28384572 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,13 +34,14 @@ setup_requires = setuptools_scm install_requires = pandas>=1.5.2 pydantic>=1.10.2, <2 - tifffile>=2023.2.3, <2023.3.15 + tifffile>=2024.1.30 natsort>=7.1.1 ndtiff>=2.2.1 - zarr>=2.13, <2.16 + zarr>=2.17.0, <3 tqdm pillow>=9.4.0 blosc2 + xarray>=2024.1.1 [options.extras_require] dev = diff --git a/tests/reader/__init__.py b/tests/_deprecated/__init__.py similarity index 100% rename from tests/reader/__init__.py rename to tests/_deprecated/__init__.py diff --git a/tests/_deprecated/test_singlepagetiff.py b/tests/_deprecated/test_singlepagetiff.py new file mode 100644 index 00000000..5a303608 --- /dev/null +++ b/tests/_deprecated/test_singlepagetiff.py @@ -0,0 +1,79 @@ +import numpy as np +import zarr + +from iohub._deprecated.singlepagetiff import MicromanagerSequenceReader +from tests.conftest import ( + mm2gamma_singlepage_tiffs, + mm2gamma_singlepage_tiffs_incomplete, + mm1422_singlepage_tiffs, +) + + +def pytest_generate_tests(metafunc): + if "single_page_tiff" in metafunc.fixturenames: + metafunc.parametrize( + "single_page_tiff", + mm2gamma_singlepage_tiffs + mm1422_singlepage_tiffs, + ) + + +def test_constructor(single_page_tiff): + """ + test that constructor parses metadata properly + no data extraction in this test + """ + mmr = MicromanagerSequenceReader(single_page_tiff, extract_data=False) + assert mmr.micromanager_metadata is not None + assert mmr.width > 0 + assert mmr.height > 0 + assert mmr.frames > 0 + assert mmr.slices > 0 + assert mmr.channels > 0 + + +def test_output_dims(single_page_tiff): + """ + test that output dimensions are always (t, c, z, y, x) + """ + mmr = MicromanagerSequenceReader(single_page_tiff, extract_data=False) + assert mmr.get_zarr(0).shape[0] == mmr.frames + assert mmr.get_zarr(0).shape[1] == mmr.channels + assert mmr.get_zarr(0).shape[2] == mmr.slices + assert mmr.get_zarr(0).shape[3] == mmr.height + assert mmr.get_zarr(0).shape[4] == mmr.width + + +def test_output_dims_incomplete(): + """ + test that output dimensions are correct for interrupted data + """ + mmr = MicromanagerSequenceReader( + mm2gamma_singlepage_tiffs_incomplete, extract_data=True + ) + assert mmr.get_zarr(0).shape[0] == mmr.frames + assert mmr.get_zarr(0).shape[1] == mmr.channels + assert mmr.get_zarr(0).shape[2] == mmr.slices + assert mmr.get_zarr(0).shape[3] == mmr.height + assert mmr.get_zarr(0).shape[4] == mmr.width + assert mmr.get_zarr(0).shape[0] == 11 + + +def test_get_zarr(single_page_tiff): + mmr = MicromanagerSequenceReader(single_page_tiff, extract_data=True) + for i in range(mmr.get_num_positions()): + z = mmr.get_zarr(i) + assert z.shape == mmr.shape + assert isinstance(z, zarr.core.Array) + + +def test_get_array(single_page_tiff): + mmr = MicromanagerSequenceReader(single_page_tiff, extract_data=True) + for i in range(mmr.get_num_positions()): + z = mmr.get_array(i) + assert z.shape == mmr.shape + assert isinstance(z, np.ndarray) + + +def test_get_num_positions(single_page_tiff): + mmr = MicromanagerSequenceReader(single_page_tiff, extract_data=True) + assert mmr.get_num_positions() >= 1 diff --git a/tests/reader/test_zarrfile.py b/tests/_deprecated/test_zarrfile.py similarity index 60% rename from tests/reader/test_zarrfile.py rename to tests/_deprecated/test_zarrfile.py index a8c9cb6e..2f55f856 100644 --- a/tests/reader/test_zarrfile.py +++ b/tests/_deprecated/test_zarrfile.py @@ -1,25 +1,20 @@ import numpy as np import zarr -from iohub.reader import read_micromanager -from iohub.zarrfile import ZarrReader +from iohub._deprecated.zarrfile import ZarrReader +from iohub.reader import read_images +from tests.conftest import mm2gamma_zarr_v01 -def test_constructor_mm2gamma(setup_test_data, setup_mm2gamma_zarr): + +def test_constructor_mm2gamma(): """ test that constructor parses metadata properly no data extraction in this test """ - - _ = setup_test_data - src = setup_mm2gamma_zarr - - reader = read_micromanager(src) - assert isinstance(reader, ZarrReader) - - mmr = ZarrReader(src) - - assert mmr.mm_meta is not None + mmr = read_images(mm2gamma_zarr_v01) + assert isinstance(mmr, ZarrReader) + assert mmr.micromanager_metadata is not None assert mmr.z_step_size is not None assert mmr.width > 0 assert mmr.height > 0 @@ -30,7 +25,6 @@ def test_constructor_mm2gamma(setup_test_data, setup_mm2gamma_zarr): assert mmr.columns is not None assert mmr.wells is not None assert mmr.hcs_meta is not None - # Check HCS metadata copy meta = mmr.hcs_meta assert "plate" in meta.keys() @@ -42,15 +36,11 @@ def test_constructor_mm2gamma(setup_test_data, setup_mm2gamma_zarr): assert meta["well"][0]["images"][0]["path"] == "Pos_000" -def test_output_dims_mm2gamma(setup_test_data, setup_mm2gamma_zarr): +def test_output_dims_mm2gamma(): """ test that output dimensions are always (t, c, z, y, x) """ - - _ = setup_test_data - src = setup_mm2gamma_zarr - mmr = ZarrReader(src) - + mmr = ZarrReader(mm2gamma_zarr_v01) assert mmr.get_array(0).shape[0] == mmr.frames assert mmr.get_array(0).shape[1] == mmr.channels assert mmr.get_array(0).shape[2] == mmr.slices @@ -58,45 +48,29 @@ def test_output_dims_mm2gamma(setup_test_data, setup_mm2gamma_zarr): assert mmr.get_array(0).shape[4] == mmr.width -def test_get_zarr_mm2gamma(setup_test_data, setup_mm2gamma_zarr): - - _ = setup_test_data - src = setup_mm2gamma_zarr - mmr = ZarrReader(src) - +def test_get_zarr_mm2gamma(): + mmr = ZarrReader(mm2gamma_zarr_v01) for i in range(mmr.get_num_positions()): z = mmr.get_zarr(i) assert isinstance(z, zarr.core.Array) -def test_get_array_mm2gamma(setup_test_data, setup_mm2gamma_zarr): - - _ = setup_test_data - src = setup_mm2gamma_zarr - mmr = ZarrReader(src) - +def test_get_array_mm2gamma(): + mmr = ZarrReader(mm2gamma_zarr_v01) for i in range(mmr.get_num_positions()): z = mmr.get_array(i) assert z.shape == mmr.shape assert isinstance(z, np.ndarray) -def test_get_image_mm2gamma(setup_test_data, setup_mm2gamma_zarr): - - _ = setup_test_data - src = setup_mm2gamma_zarr - mmr = ZarrReader(src) - +def test_get_image_mm2gamma(): + mmr = ZarrReader(mm2gamma_zarr_v01) for i in range(mmr.get_num_positions()): z = mmr.get_image(i, t=0, c=0, z=0) assert z.shape == (mmr.shape[-2], mmr.shape[-1]) assert isinstance(z, np.ndarray) -def test_get_num_positions_mm2gamma(setup_test_data, setup_mm2gamma_zarr): - - _ = setup_test_data - src = setup_mm2gamma_zarr - mmr = ZarrReader(src) - +def test_get_num_positions_mm2gamma(): + mmr = ZarrReader(mm2gamma_zarr_v01) assert mmr.get_num_positions() == 4 diff --git a/tests/clearcontrol/test_clearcontrol.py b/tests/clearcontrol/test_clearcontrol.py index d2196d80..57d2556f 100644 --- a/tests/clearcontrol/test_clearcontrol.py +++ b/tests/clearcontrol/test_clearcontrol.py @@ -20,25 +20,27 @@ def mock_clear_control_dataset_path(tmp_path: Path) -> Path: def test_blosc_buffer(tmp_path: Path) -> None: - buffer_path = tmp_path / "buffer.blc" in_array = np.random.randint(0, 5_000, size=(32, 32)) _array_to_blosc_buffer(in_array, buffer_path) - out_array = blosc_buffer_to_array(buffer_path, in_array.shape, in_array.dtype) + out_array = blosc_buffer_to_array( + buffer_path, in_array.shape, in_array.dtype + ) assert np.allclose(in_array, out_array) @pytest.mark.parametrize( "key", - [1, - (slice(None), 1), - (0, [1, 2]), - (-1, np.asarray([0, 3])), - (slice(1), -2), - (np.asarray(0),), - (0, 0, slice(32)), + [ + 1, + (slice(None), 1), + (0, [1, 2]), + (-1, np.asarray([0, 3])), + (slice(1), -2), + (np.asarray(0),), + (0, 0, slice(32)), ], ) def test_CCFOV_indexing( @@ -58,11 +60,27 @@ def test_CCFOV_metadata( mock_clear_control_dataset_path: Path, ) -> None: cc = ClearControlFOV(mock_clear_control_dataset_path) - expected_metadata = {"voxel_size_z": 1.0, "voxel_size_y": 0.25, "voxel_size_x": 0.25, "acquisition_type": "NA", "time_delta": 45.0} + expected_metadata = { + "voxel_size_z": 1.0, + "voxel_size_y": 0.25, + "voxel_size_x": 0.25, + "acquisition_type": "NA", + "time_delta": 45.0, + } metadata = cc.metadata() assert metadata == expected_metadata +def test_CCFOV_scales( + mock_clear_control_dataset_path: Path, +) -> None: + cc = ClearControlFOV(mock_clear_control_dataset_path) + zyx_scale = (1.0, 0.25, 0.25) + time_delta = 45.0 + assert zyx_scale == cc.zyx_scale + assert time_delta == cc.t_scale + + def test_CCFOV_cache( mock_clear_control_dataset_path: Path, ) -> None: @@ -79,7 +97,7 @@ def test_CCFOV_cache( assert np.array_equal(cc._cache_array, array) new_array = cc[1] - assert id(new_array) == id(array) # same reference, so cache worked + assert id(new_array) == id(array) # same reference, so cache worked cc.cache = False assert cc._cache_key is None diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 1d1fa150..b8ddf9c7 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,16 +1,31 @@ -import os -import pathlib import re -from tempfile import TemporaryDirectory from unittest.mock import patch from click.testing import CliRunner -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st +import pytest from iohub._version import __version__ from iohub.cli.cli import cli +from tests.conftest import ( + mm2gamma_ome_tiffs, + ndtiff_v2_datasets, + ndtiff_v3_labeled_positions, + hcs_ref, +) + + +def pytest_generate_tests(metafunc): + if "mm2gamma_ome_tiff" in metafunc.fixturenames: + metafunc.parametrize("mm2gamma_ome_tiff", mm2gamma_ome_tiffs) + if "verbose" in metafunc.fixturenames: + metafunc.parametrize("verbose", ["-v", False]) + if "ndtiff_dataset" in metafunc.fixturenames: + metafunc.parametrize( + "ndtiff_dataset", + ndtiff_v2_datasets + [ndtiff_v3_labeled_positions], + ) + def test_cli_entry(): runner = CliRunner() @@ -35,80 +50,57 @@ def test_cli_version(): assert str(__version__) in result.output -@given(verbose=st.booleans()) -@settings( - suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=2000 -) -def test_cli_info_mock(setup_test_data, setup_mm2gamma_ome_tiffs, verbose): - _, f1, f2 = setup_mm2gamma_ome_tiffs +def test_cli_info_mock(mm2gamma_ome_tiff, verbose): runner = CliRunner() + # resolve path with pathlib to be consistent with `click.Path` + # this will not normalize partition symbol to lower case on Windows + path = mm2gamma_ome_tiff.resolve() with patch("iohub.cli.cli.print_info") as mock: - cmd = ["info", f1, f2] + cmd = ["info", str(path)] if verbose: - cmd += ["-v"] + cmd.append(verbose) result = runner.invoke(cli, cmd) - # resolve path with pathlib to be consistent with `click.Path` - # this will not normalize partition symbol to lower case on Windows - mock.assert_called_with( - str(pathlib.Path(f2).resolve()), verbose=verbose - ) + mock.assert_called_with(path, verbose=bool(verbose)) assert result.exit_code == 0 assert "Reading" in result.output -@given(verbose=st.booleans()) -@settings( - suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=2000 -) -def test_cli_info_ndtiff( - setup_test_data, setup_pycromanager_test_data, verbose -): - _, _, data = setup_pycromanager_test_data +def test_cli_info_ndtiff(ndtiff_dataset, verbose): runner = CliRunner() - cmd = ["info", data] + cmd = ["info", str(ndtiff_dataset)] if verbose: - cmd += ["-v"] + cmd.append(verbose) result = runner.invoke(cli, cmd) assert result.exit_code == 0 - assert re.search(r"Positions:\s+2", result.output) + assert "Channel names" in result.output + assert re.search(r"FOVs:\s+\d", result.output) assert "scale (um)" in result.output -@given(verbose=st.booleans()) -def test_cli_info_ome_zarr(setup_test_data, setup_hcs_ref, verbose): +def test_cli_info_ome_zarr(verbose): runner = CliRunner() - cmd = ["info", setup_hcs_ref] + cmd = ["info", str(hcs_ref)] if verbose: - cmd += ["-v"] + cmd.append(verbose) result = runner.invoke(cli, cmd) assert result.exit_code == 0 assert re.search(r"Wells:\s+1", result.output) + assert ("Chunk size" in result.output) == bool(verbose) # Test on single position - result_pos = runner.invoke( - cli, ["info", os.path.join(setup_hcs_ref, "B", "03", "0")] - ) + result_pos = runner.invoke(cli, ["info", str(hcs_ref / "B" / "03" / "0")]) + assert "Channel names" in result_pos.output assert "scale (um)" in result_pos.output + assert "Chunk size" in result_pos.output -@given(f=st.booleans(), g=st.booleans(), s=st.booleans(), chk=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, s, chk, -): - _, _, input_dir = setup_mm2gamma_ome_tiffs +@pytest.mark.parametrize("grid_layout", ["-g", None]) +def test_cli_convert_ome_tiff(grid_layout, tmpdir): + dataset = mm2gamma_ome_tiffs[0] runner = CliRunner() - f = "-f ometiff" if f else "" - g = "-g" if g else "" - chk = "--check-image" if chk else "--no-check-image" - with TemporaryDirectory() as tmp_dir: - output_dir = os.path.join(tmp_dir, "converted.zarr") - cmd = ["convert", "-i", input_dir, "-o", output_dir, "-s", s, chk] - if f: - cmd += ["-f", "ometiff"] - if g: - cmd += ["-g"] - result = runner.invoke(cli, cmd) - assert result.exit_code == 0 + output_dir = tmpdir / "converted.zarr" + cmd = ["convert", "-i", str(dataset), "-o", output_dir] + if grid_layout: + cmd.append(grid_layout) + result = runner.invoke(cli, cmd) + assert result.exit_code == 0, result.output assert "Converting" in result.output diff --git a/tests/conftest.py b/tests/conftest.py index 043d154b..8d49c563 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,42 +1,16 @@ -import os -import random import shutil -from os.path import join as pjoin +from pathlib import Path import fsspec -import pytest from wget import download -MM2GAMMA_OMETIFF_SUBFOLDERS = { - "mm2.0-20201209_4p_2t_5z_1c_512k_1", - "mm2.0-20201209_4p_2t_5z_512k_1", - "mm2.0-20201209_4p_2t_3c_512k_1", - "mm2.0-20201209_4p_5z_3c_512k_1", - "mm2.0-20201209_4p_2t_512k_1", - "mm2.0-20201209_4p_3c_512k_1", - "mm2.0-20201209_4p_5z_512k_1", - "mm2.0-20201209_1t_5z_3c_512k_1", - "mm2.0-20201209_1t_5z_512k_1", - "mm2.0-20201209_2t_3c_512k_1", - "mm2.0-20201209_5z_3c_512k_1", - "mm2.0-20201209_4p_512k_1", - "mm2.0-20201209_2t_512k_1", - "mm2.0-20201209_3c_512k_1", - "mm2.0-20201209_5z_512k_1", - "mm2.0-20201209_100t_5z_3c_512k_1_nopositions", - "mm2.0-20201209_4p_20t_5z_3c_512k_1_multipositions", -} - - -@pytest.fixture(scope="session") -def setup_test_data(): - temp_folder = pjoin(os.getcwd(), ".pytest_temp") - test_data = pjoin(temp_folder, "test_data") - if not os.path.isdir(temp_folder): - os.mkdir(temp_folder) + +def download_data(): + """Download test datasets.""" + test_data = Path.cwd() / ".pytest_temp" / "test_data" + if not test_data.is_dir(): + Path.mkdir(test_data, parents=True) print("\nsetting up temp folder") - if not os.path.isdir(test_data): - os.mkdir(test_data) # Zenodo URL custom_url = ( @@ -48,201 +22,81 @@ def setup_test_data(): ome_hcs_url = "https://zenodo.org/record/8091756/files/20200812-CardiomyocyteDifferentiation14-Cycle1.zarr.zip" # noqa # download files to temp folder - if not os.listdir(test_data): + if not any(test_data.iterdir()): print("Downloading test files...") for url in (custom_url, ome_hcs_url): - output = pjoin(test_data, os.path.basename(url)) - download(url, out=output) + output = test_data / Path(url).name + download(url, out=str(output)) shutil.unpack_archive(output, extract_dir=test_data) ghfs = fsspec.filesystem( "github", org="micro-manager", repo="NDTiffStorage" ) - v3_lp = pjoin(test_data, "ndtiff_v3_labeled_positions") - os.mkdir(v3_lp) + v3_lp = test_data / "ndtiff_v3_labeled_positions" + Path.mkdir(v3_lp) ghfs.get( ghfs.ls("test_data/v3/labeled_positions_1"), - v3_lp, + str(v3_lp), recursive=True, ) - yield test_data - - -@pytest.fixture(scope="function") -def setup_mm2gamma_ome_tiffs(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_ome-tiffs" - ) - - subfolders = [ - f for f in os.listdir(test_data) if os.path.isdir(pjoin(test_data, f)) - ] - - # specific folder - one_folder = pjoin(test_data, subfolders[0]) - # random folder - rand_folder = pjoin(test_data, random.choice(subfolders)) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield test_data, one_folder, rand_folder - - -@pytest.fixture(scope="function") -def setup_mm2gamma_ome_tiff_hcs(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_ome-tiffs" - ) - - subfolders = [ - f for f in os.listdir(test_data) if os.path.isdir(pjoin(test_data, f)) - ] - # select datasets with multiple positioons; here they all have 4 positions - hcs_subfolders = [f for f in subfolders if '4p' in f] - - # specific folder - one_folder = pjoin(test_data, hcs_subfolders[0]) - # random folder - rand_folder = pjoin(test_data, random.choice(hcs_subfolders)) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield test_data, one_folder, rand_folder - - -@pytest.fixture(scope="function") -def setup_mm2gamma_ome_tiffs_incomplete(): - """ - This fixture returns a dataset with 11 timepoints - """ - - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_ometiff_incomplete" - ) - - src = pjoin(test_data, "mm2.0-20201209_20t_5z_3c_512k_incomplete_1") + return test_data - yield src +def subdirs(parent: Path, name: str) -> list[Path]: + return [d for d in (parent / name).iterdir() if d.is_dir()] -@pytest.fixture(scope="function") -def setup_mm2gamma_singlepage_tiffs(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_singlepage-tiffs" - ) - subfolders = [ - f for f in os.listdir(test_data) if os.path.isdir(pjoin(test_data, f)) - ] +test_datasets = download_data() - # specific folder - one_folder = pjoin(test_data, subfolders[0]) - # random folder - rand_folder = pjoin(test_data, random.choice(subfolders)) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield test_data, one_folder, rand_folder +mm2gamma_ome_tiffs = subdirs(test_datasets, "MM20_ome-tiffs") -@pytest.fixture(scope="function") -def setup_mm2gamma_singlepage_tiffs_incomplete(): - """ - This fixture returns a dataset with 11 timepoints - The MDA definition at start of the experiment specifies 20 timepoints - """ - - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_singlepage_incomplete" - ) - src = pjoin(test_data, "mm2.0-20201209_20t_5z_3c_512k_incomplete_1 2") +mm2gamma_ome_tiffs_hcs = [p for p in mm2gamma_ome_tiffs if "4p" in p.name] - yield src +# This is a dataset with 11 timepoints +# The MDA definition at start of the experiment specifies 20 timepoints +mm2gamma_ome_tiffs_incomplete = ( + test_datasets + / "MM20_ometiff_incomplete" + / "mm2.0-20201209_20t_5z_3c_512k_incomplete_1" +) -@pytest.fixture(scope="function") -def setup_mm1422_ome_tiffs(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM1422_ome-tiffs" - ) - subfolders = [ - f for f in os.listdir(test_data) if os.path.isdir(pjoin(test_data, f)) - ] +mm2gamma_singlepage_tiffs = subdirs(test_datasets, "MM20_singlepage-tiffs") - # specific folder - one_folder = pjoin(test_data, subfolders[0]) - # random folder - rand_folder = pjoin(test_data, random.choice(subfolders)) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield test_data, one_folder, rand_folder +# This is a dataset with 11 timepoints +# The MDA definition at start of the experiment specifies 20 timepoints +mm2gamma_singlepage_tiffs_incomplete = ( + test_datasets + / "MM20_singlepage_incomplete" + / "mm2.0-20201209_20t_5z_3c_512k_incomplete_1 2" +) -@pytest.fixture(scope="function") -def setup_mm1422_singlepage_tiffs(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM1422_singlepage-tiffs" - ) - subfolders = [ - f for f in os.listdir(test_data) if os.path.isdir(pjoin(test_data, f)) - ] +mm1422_ome_tiffs = subdirs(test_datasets, "MM1422_ome-tiffs") - # specific folder - one_folder = pjoin(test_data, subfolders[0]) - # random folder - rand_folder = pjoin(test_data, random.choice(subfolders)) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield test_data, one_folder, rand_folder +mm1422_singlepage_tiffs = subdirs(test_datasets, "MM1422_singlepage-tiffs") -@pytest.fixture(scope="function") -def setup_mm2gamma_zarr(): - test_data = pjoin(os.getcwd(), ".pytest_temp", "test_data", "MM20_zarr") - zp = pjoin(test_data, "mm2.0-20201209_4p_2t_5z_1c_512k_1.zarr") +mm2gamma_zarr_v01 = ( + test_datasets / "MM20_zarr" / "mm2.0-20201209_4p_2t_5z_1c_512k_1.zarr" +) - # return path to unzipped folder containing test images - # as well as specific folder paths - yield zp +hcs_ref = test_datasets / "20200812-CardiomyocyteDifferentiation14-Cycle1.zarr" -@pytest.fixture(scope="session") -def setup_hcs_ref(): - yield pjoin( - os.getcwd(), - ".pytest_temp", - "test_data", - "20200812-CardiomyocyteDifferentiation14-Cycle1.zarr", - ) +ndtiff_v2_datasets = subdirs(test_datasets, "MM20_pycromanager") -@pytest.fixture(scope="function") -def setup_pycromanager_test_data(): - test_data = pjoin( - os.getcwd(), ".pytest_temp", "test_data", "MM20_pycromanager" - ) - datasets = [ - "mm2.0-20210713_pm0.13.2_2c_1", - "mm2.0-20210713_pm0.13.2_2c_7z_1", - "mm2.0-20210713_pm0.13.2_2p_3t_2c_1", - "mm2.0-20210713_pm0.13.2_2p_3t_2c_7z_1", - "mm2.0-20210713_pm0.13.2_3t_2c_1", - "mm2.0-20210713_pm0.13.2_3t_2c_7z_1", - "mm2.0-20210713_pm0.13.2_3t_7z_1", - "mm2.0-20210713_pm0.13.2_5t_1", - "mm2.0-20210713_pm0.13.2_7z_1", - ] - - dataset_dirs = [pjoin(test_data, ds) for ds in datasets] - first_dir, rand_dir, ptcz_dir = ( - dataset_dirs[0], - random.choice(dataset_dirs), - dataset_dirs[3], - ) - yield first_dir, rand_dir, ptcz_dir +ndtiff_v2_ptcz = ( + test_datasets + / "MM20_pycromanager" + / "mm2.0-20210713_pm0.13.2_2p_3t_2c_7z_1" +) -@pytest.fixture(scope="function") -def ndtiff_v3_labeled_positions(setup_test_data): - yield pjoin(setup_test_data, "ndtiff_v3_labeled_positions") +ndtiff_v3_labeled_positions = test_datasets / "ndtiff_v3_labeled_positions" diff --git a/tests/mmstack/test_mmstack.py b/tests/mmstack/test_mmstack.py new file mode 100644 index 00000000..bd27facd --- /dev/null +++ b/tests/mmstack/test_mmstack.py @@ -0,0 +1,92 @@ +import re + +import pytest +from xarray import DataArray + +from iohub.mmstack import MMOmeTiffFOV, MMStack +from tests.conftest import ( + mm2gamma_ome_tiffs, + mm2gamma_ome_tiffs_incomplete, + mm1422_ome_tiffs, +) + + +def pytest_generate_tests(metafunc): + if "ome_tiff" in metafunc.fixturenames: + metafunc.parametrize("ome_tiff", mm2gamma_ome_tiffs + mm1422_ome_tiffs) + + +def test_mmstack_ctx(ome_tiff): + with MMStack(ome_tiff) as mmstack: + assert isinstance(mmstack, MMStack) + assert len(mmstack) > 0 + assert "MMStack" in mmstack.__repr__() + + +def test_mmstack_nonexisting(tmpdir): + with pytest.raises(FileNotFoundError): + MMStack(tmpdir / "nonexisting") + + +def test_mmstack_getitem(ome_tiff): + mmstack = MMStack(ome_tiff) + assert isinstance(mmstack["0"], MMOmeTiffFOV) + assert isinstance(mmstack[0], MMOmeTiffFOV) + for key, fov in mmstack: + assert isinstance(key, str) + assert isinstance(fov, MMOmeTiffFOV) + assert key in mmstack.__repr__() + assert key in fov.__repr__() + mmstack.close() + + +def test_mmstack_num_positions(ome_tiff): + with MMStack(ome_tiff) as mmstack: + p_match = re.search(r"_(\d+)p_", str(ome_tiff)) + assert len(mmstack) == int(p_match.group(1)) if p_match else 1 + + +def test_mmstack_num_timepoints(ome_tiff): + with MMStack(ome_tiff) as mmstack: + t_match = re.search(r"_(\d+)t_", str(ome_tiff)) + for _, fov in mmstack: + assert fov.shape[0] == int(t_match.group(1)) if t_match else 1 + + +def test_mmstack_num_timepoints_incomplete(): + with MMStack(mm2gamma_ome_tiffs_incomplete) as mmstack: + for name, fov in mmstack: + assert fov.shape[0] == 20 + if int(name) >= 11: + assert not fov[:].any() + else: + assert fov[:].any() + + +def test_mmstack_metadata(ome_tiff): + with MMStack(ome_tiff) as mmstack: + assert isinstance(mmstack.micromanager_metadata, dict) + assert mmstack.micromanager_metadata["Summary"] + assert mmstack.micromanager_summary + + +def test_fov_axes_names(ome_tiff): + mmstack = MMStack(ome_tiff) + for _, fov in mmstack: + axes_names = fov.axes_names + assert isinstance(axes_names, list) + assert len(axes_names) == 5 + assert all([isinstance(name, str) for name in axes_names]) + mmstack.close() + + +def test_fov_getitem(ome_tiff): + mmstack = MMStack(ome_tiff) + for _, fov in mmstack: + img: DataArray = fov[:] + assert isinstance(img, DataArray) + assert img.ndim == 5 + assert img[0, 0, 0, 0, 0] >= 0 + for ch in fov.channel_names: + assert img.sel(T=0, Z=0, C=ch, Y=0, X=0) >= 0 + mmstack.close() diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index a781bc5b..b280b9e7 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -28,6 +28,7 @@ _pad_shape, open_ome_zarr, ) +from tests.conftest import hcs_ref short_text_st = st.text(min_size=1, max_size=16) t_dim_st = st.integers(1, 4) @@ -421,6 +422,7 @@ def test_create_tiled(channel_names): grid_shape=tiles_rc_st, arr_name=short_alpha_numeric, ) +@settings(suppress_health_check=[HealthCheck.too_slow]) def test_make_tiles(channels_and_random_5d, grid_shape, arr_name): """Test `iohub.ngff.TiledPosition.make_tiles()` and `...get_tile()`""" with TemporaryDirectory() as temp_dir: @@ -549,10 +551,10 @@ def _temp_copy(src: StrPath): @given(wrong_channel_name=channel_names_st) -def test_get_channel_index(setup_test_data, setup_hcs_ref, wrong_channel_name): +def test_get_channel_index(wrong_channel_name): """Test `iohub.ngff.NGFFNode.get_channel_axis()`""" assume(wrong_channel_name != "DAPI") - with open_ome_zarr(setup_hcs_ref, layout="hcs", mode="r+") as dataset: + with open_ome_zarr(hcs_ref, layout="hcs", mode="r+") as dataset: assert dataset.get_channel_index("DAPI") == 0 with pytest.raises(ValueError): _ = dataset.get_channel_index(wrong_channel_name) @@ -562,12 +564,10 @@ 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: str, col: str, pos: str -): +def test_modify_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 _temp_copy(hcs_ref) as store_path: with open_ome_zarr(store_path, layout="hcs", mode="r+") as dataset: assert dataset.axes[0].name == "c" assert dataset.channel_names == ["DAPI"] @@ -633,11 +633,11 @@ def test_position_scale(channels_and_random_5d): assert dataset.scale == scale -def test_combine_fovs_to_hcs(setup_test_data, setup_hcs_ref): +def test_combine_fovs_to_hcs(): fovs = {} fov_paths = ("A/1/0", "B/1/0", "H/12/9") for path in fov_paths: - with open_ome_zarr(setup_hcs_ref) as hcs_store: + with open_ome_zarr(hcs_ref) as hcs_store: fovs[path] = hcs_store["B/03/0"] with TemporaryDirectory() as temp_dir: store_path = os.path.join(temp_dir, "combined.zarr") diff --git a/tests/reader/test_multipagetiff.py b/tests/reader/test_multipagetiff.py deleted file mode 100644 index 1396d75e..00000000 --- a/tests/reader/test_multipagetiff.py +++ /dev/null @@ -1,161 +0,0 @@ -import numpy as np -import zarr - -from iohub.multipagetiff import MicromanagerOmeTiffReader - - -def test_constructor_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - """ - test that constructor parses metadata properly - no data extraction in this test - """ - - # choose a specific folder - _ = setup_test_data - _, one_folder, _ = setup_mm2gamma_ome_tiffs - mmr = MicromanagerOmeTiffReader(one_folder, extract_data=False) - - assert mmr.mm_meta is not None - assert mmr.z_step_size is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - - -def test_output_dims_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - """ - test that output dimensions are always (t, c, z, y, x) - """ - - # choose a random folder - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - - -def test_output_dims_mm2gamma_incomplete( - setup_test_data, setup_mm2gamma_ome_tiffs_incomplete -): - """ - test that output dimensions are correct for interrupted data - """ - - # choose a random folder - _ = setup_test_data - folder = setup_mm2gamma_ome_tiffs_incomplete - mmr = MicromanagerOmeTiffReader(folder, extract_data=True) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - assert mmr.get_zarr(0).shape[0] == 11 - - -def test_get_zarr_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_get_array_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -def test_get_num_positions_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - assert mmr.get_num_positions() >= 1 - - -# repeat of above but using mm1.4.22 data - - -def test_constructor_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - """ - test that constructor parses metadata properly - no data extraction in this test - """ - - # choose a specific folder - _ = setup_test_data - _, one_folder, _ = setup_mm1422_ome_tiffs - mmr = MicromanagerOmeTiffReader(one_folder, extract_data=False) - - assert mmr.mm_meta is not None - assert mmr.z_step_size is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - - -def test_output_dims_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - """ - test that output dimensions are always (t, c, z, y, x) - """ - - # choose a random folder - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=False) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - - -def test_get_zarr_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_get_array_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -def test_get_num_positions_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = MicromanagerOmeTiffReader(rand_folder, extract_data=True) - assert mmr.get_num_positions() >= 1 diff --git a/tests/reader/test_pycromanager.py b/tests/reader/test_pycromanager.py deleted file mode 100644 index ffa971fb..00000000 --- a/tests/reader/test_pycromanager.py +++ /dev/null @@ -1,93 +0,0 @@ -import dask.array -import numpy as np - -from iohub.ndtiff import NDTiffReader - - -def test_constructor(setup_test_data, setup_pycromanager_test_data): - """ - test that the constructor parses metadata properly - """ - - _ = setup_test_data - # choose a random folder - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - mmr = NDTiffReader(rand_dir) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - - -def test_output_dims(setup_test_data, setup_pycromanager_test_data): - """ - test that output dimensions are always (t, c, z, y, x) - """ - _ = setup_test_data - - # run test on 3 random folders - for i in range(3): - _, rand_dir, _ = setup_pycromanager_test_data - mmr = NDTiffReader(rand_dir) - za = mmr.get_zarr(0) - - assert za.shape[0] == mmr.frames - assert za.shape[1] == mmr.channels - assert za.shape[2] == mmr.slices - assert za.shape[3] == mmr.height - assert za.shape[4] == mmr.width - - -def test_output_dims_incomplete(setup_test_data, setup_pycromanager_test_data): - # TODO - pass - - -def test_get_zarr(setup_test_data, setup_pycromanager_test_data): - _ = setup_test_data - # choose a random folder - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - - mmr = NDTiffReader(rand_dir) - arr = mmr.get_zarr(position=0) - assert arr.shape == mmr.shape - assert isinstance(arr, dask.array.Array) - - -def test_get_array(setup_test_data, setup_pycromanager_test_data): - _ = setup_test_data - # choose a random folder - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - - mmr = NDTiffReader(rand_dir) - arr = mmr.get_array(position=0) - assert arr.shape == mmr.shape - assert isinstance(arr, np.ndarray) - - -def test_get_num_positions(setup_test_data, setup_pycromanager_test_data): - _ = setup_test_data - # choose a random folder - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - - mmr = NDTiffReader(rand_dir) - assert mmr.get_num_positions() >= 1 - - -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 diff --git a/tests/reader/test_reader.py b/tests/reader/test_reader.py deleted file mode 100644 index 4edc2ad0..00000000 --- a/tests/reader/test_reader.py +++ /dev/null @@ -1,379 +0,0 @@ -import dask.array -import numpy as np -import pytest -import zarr - -from iohub.multipagetiff import MicromanagerOmeTiffReader -from iohub.ndtiff import NDTiffReader -from iohub.reader import read_micromanager -from iohub.singlepagetiff import MicromanagerSequenceReader - -# todo: consider tests for handling ometiff -# when singlepagetifff is specified (or vice versa) -# todo: consider tests for handling of positions -# when extract_data is True and False. - - -# test exceptions -def test_datatype(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - # choose a specific folder - _, one_folder, _ = setup_mm2gamma_ome_tiffs - with pytest.raises(ValueError): - _ = read_micromanager( - one_folder, data_type="unsupportedformat", extract_data=False - ) - - -# ===== test mm2gamma =========== # - - -# test ometiff reader -def test_ometiff_constructor_mm2gamma( - setup_test_data, setup_mm2gamma_ome_tiffs -): - _ = setup_test_data - # choose a specific folder - _, one_folder, _ = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(one_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - assert mmr.mm_meta is not None - assert mmr.stage_positions is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - if mmr.channels > 1: - assert mmr.channel_names is not None - if mmr.slices > 1: - assert mmr.z_step_size is not None - - -def test_ometiff_zarr_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - assert isinstance(mmr, MicromanagerOmeTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_ometiff_array_mm2gamma(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -# test sequence reader -def test_sequence_constructor_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - _ = setup_test_data - _, one_folder, _ = setup_mm2gamma_singlepage_tiffs - mmr = read_micromanager(one_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerSequenceReader) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - if mmr.channels > 1: - assert mmr.channel_names is not None - if mmr.slices > 1: - assert mmr.z_step_size is not None - - -def test_sequence_zarr_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerSequenceReader) - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_sequence_array_zarr_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerSequenceReader) - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -# test pycromanager reader -def test_pycromanager_constructor( - setup_test_data, setup_pycromanager_test_data -): - _ = setup_test_data - # choose a specific folder - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - mmr = read_micromanager(rand_dir, extract_data=False) - - assert isinstance(mmr, NDTiffReader) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - assert mmr.stage_positions is not None - if mmr.channels > 1: - assert mmr.channel_names is not None - if mmr.slices > 1: - assert mmr.z_step_size is not None - - -def test_pycromanager_get_zarr(setup_test_data, setup_pycromanager_test_data): - _ = setup_test_data - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - mmr = read_micromanager(rand_dir) - assert isinstance(mmr, NDTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, dask.array.Array) - - -def test_pycromanager_get_array(setup_test_data, setup_pycromanager_test_data): - _ = setup_test_data - first_dir, rand_dir, ptcz_dir = setup_pycromanager_test_data - mmr = read_micromanager(rand_dir) - assert isinstance(mmr, NDTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -# ===== test mm1.4.22 =========== # -def test_ometiff_constructor_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - _ = setup_test_data - # choose a specific folder - _, one_folder, _ = setup_mm1422_ome_tiffs - mmr = read_micromanager(one_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - assert mmr.mm_meta is not None - assert mmr.stage_positions is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - if mmr.channels > 1: - assert mmr.channel_names is not None - if mmr.slices > 1: - assert mmr.z_step_size is not None - - -def test_ometiff_zarr_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_ometiff_array_zarr_mm1422(setup_test_data, setup_mm1422_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm1422_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -# test sequence constructor -def test_sequence_constructor_mm1422( - setup_test_data, setup_mm1422_singlepage_tiffs -): - _ = setup_test_data - _, one_folder, _ = setup_mm1422_singlepage_tiffs - mmr = read_micromanager(one_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerSequenceReader) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - if mmr.channels > 1: - assert mmr.channel_names is not None - if mmr.slices > 1: - assert mmr.z_step_size is not None - - -def test_sequence_zarr_mm1422(setup_test_data, setup_mm1422_singlepage_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerSequenceReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_sequence_array_zarr_mm1422( - setup_test_data, setup_mm1422_singlepage_tiffs -): - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = read_micromanager(rand_folder, extract_data=True) - - assert isinstance(mmr, MicromanagerSequenceReader) - - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -# ===== test property setters =========== # -def test_height(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.height = 100 - assert mmr.height == mmr.height - - -def test_width(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.width = 100 - assert mmr.width == mmr.width - - -def test_frames(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.frames = 100 - assert mmr.frames == mmr.frames - - -def test_slices(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.slices = 100 - assert mmr.slices == mmr.slices - - -def test_channels(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.channels = 100 - assert mmr.channels == mmr.channels - - -def test_channel_names(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.channel_names = 100 - assert mmr.channel_names == mmr.channel_names - - -def test_mm_meta(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.mm_meta = {"newkey": "newval"} - assert mmr.mm_meta == mmr.mm_meta - - with pytest.raises(TypeError): - mmr.mm_meta = 1 - - -def test_stage_positions(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.stage_positions = ["pos one"] - assert mmr.stage_positions == mmr.stage_positions - - with pytest.raises(TypeError): - mmr.stage_positions = 1 - - -def test_z_step_size(setup_test_data, setup_mm2gamma_ome_tiffs): - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_ome_tiffs - mmr = read_micromanager(rand_folder, extract_data=False) - - assert isinstance(mmr, MicromanagerOmeTiffReader) - - mmr.z_step_size = 1.75 - assert mmr.z_step_size == mmr.z_step_size diff --git a/tests/reader/test_singlepagetiff.py b/tests/reader/test_singlepagetiff.py deleted file mode 100644 index 588003f1..00000000 --- a/tests/reader/test_singlepagetiff.py +++ /dev/null @@ -1,167 +0,0 @@ -import numpy as np -import zarr - -from iohub.singlepagetiff import MicromanagerSequenceReader - - -def test_constructor_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - """ - test that constructor parses metadata properly - no data extraction in this test - """ - - # choose a specific folder - _ = setup_test_data - _, one_folder, _ = setup_mm2gamma_singlepage_tiffs - mmr = MicromanagerSequenceReader(one_folder, extract_data=False) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - - -def test_output_dims_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - """ - test that output dimensions are always (t, c, z, y, x) - """ - - # choose a random folder - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=False) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - - -def test_output_dims_mm2gamma_incomplete( - setup_test_data, setup_mm2gamma_singlepage_tiffs_incomplete -): - """ - test that output dimensions are correct for interrupted data - """ - - # choose a random folder - _ = setup_test_data - folder = setup_mm2gamma_singlepage_tiffs_incomplete - mmr = MicromanagerSequenceReader(folder, extract_data=True) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - assert mmr.get_zarr(0).shape[0] == 11 - - -def test_get_zarr_mm2gamma(setup_test_data, setup_mm2gamma_singlepage_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_get_array_mm2gamma(setup_test_data, setup_mm2gamma_singlepage_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -def test_get_num_positions_mm2gamma( - setup_test_data, setup_mm2gamma_singlepage_tiffs -): - - _ = setup_test_data - _, _, rand_folder = setup_mm2gamma_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - assert mmr.get_num_positions() >= 1 - - -# repeat of above but using mm1.4.22 data - - -def test_constructor_mm1422(setup_test_data, setup_mm1422_singlepage_tiffs): - """ - test that constructor parses metadata properly - no data extraction in this test - """ - - # choose a specific folder - _ = setup_test_data - _, one_folder, _ = setup_mm1422_singlepage_tiffs - mmr = MicromanagerSequenceReader(one_folder, extract_data=False) - - assert mmr.mm_meta is not None - assert mmr.width > 0 - assert mmr.height > 0 - assert mmr.frames > 0 - assert mmr.slices > 0 - assert mmr.channels > 0 - - -def test_output_dims_mm1422(setup_test_data, setup_mm1422_singlepage_tiffs): - """ - test that output dimensions are always (t, c, z, y, x) - """ - - # choose a random folder - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=False) - - assert mmr.get_zarr(0).shape[0] == mmr.frames - assert mmr.get_zarr(0).shape[1] == mmr.channels - assert mmr.get_zarr(0).shape[2] == mmr.slices - assert mmr.get_zarr(0).shape[3] == mmr.height - assert mmr.get_zarr(0).shape[4] == mmr.width - - -def test_get_zarr_mm1422(setup_test_data, setup_mm1422_singlepage_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_zarr(i) - assert z.shape == mmr.shape - assert isinstance(z, zarr.core.Array) - - -def test_get_array_mm1422(setup_test_data, setup_mm1422_singlepage_tiffs): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - for i in range(mmr.get_num_positions()): - z = mmr.get_array(i) - assert z.shape == mmr.shape - assert isinstance(z, np.ndarray) - - -def test_get_num_positions_mm1422( - setup_test_data, setup_mm1422_singlepage_tiffs -): - - _ = setup_test_data - _, _, rand_folder = setup_mm1422_singlepage_tiffs - mmr = MicromanagerSequenceReader(rand_folder, extract_data=True) - assert mmr.get_num_positions() >= 1 diff --git a/tests/test_converter.py b/tests/test_converter.py index 504f6a93..7481423e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,98 +1,115 @@ import json import logging import os -from glob import glob -from tempfile import TemporaryDirectory +from pathlib import Path +from typing import Literal import numpy as np import pytest -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st from ndtiff import Dataset -from tifffile import TiffFile, TiffSequence +from tifffile import TiffFile from iohub.convert import TIFFConverter from iohub.ngff import Position, open_ome_zarr -from iohub.reader import ( - MicromanagerOmeTiffReader, - MicromanagerSequenceReader, - NDTiffReader, +from iohub.reader import MMStack, NDTiffDataset +from tests.conftest import ( + mm2gamma_ome_tiffs, + ndtiff_v2_datasets, + ndtiff_v3_labeled_positions, ) -CONVERTER_TEST_SETTINGS = settings( - suppress_health_check=[HealthCheck.function_scoped_fixture], - deadline=20000, -) -CONVERTER_TEST_GIVEN = dict( - grid_layout=st.booleans(), - scale_voxels=st.booleans(), -) +def pytest_generate_tests(metafunc): + if "mm2gamma_ome_tiff" in metafunc.fixturenames: + metafunc.parametrize("mm2gamma_ome_tiff", mm2gamma_ome_tiffs) + if "ndtiff_datasets" in metafunc.fixturenames: + metafunc.parametrize( + "ndtiff_datasets", + ndtiff_v2_datasets + [ndtiff_v3_labeled_positions], + ) + if "grid_layout" in metafunc.fixturenames: + metafunc.parametrize("grid_layout", [True, False]) + if "chunks" in metafunc.fixturenames: + metafunc.parametrize( + "chunks", ["XY", "XYZ", (1, 1, 3, 256, 256), None] + ) -def _check_scale_transform(position: Position, scale_voxels: bool): +def _check_scale_transform(position: Position) -> None: """Check scale transformation of the highest resolution level.""" tf = ( position.metadata.multiscales[0] .datasets[0] .coordinate_transformations[0] ) - if scale_voxels: - assert tf.type == "scale" - assert tf.scale[:2] == [1.0, 1.0] - else: - assert tf.type == "identity" - - -@given(**CONVERTER_TEST_GIVEN) -@settings(CONVERTER_TEST_SETTINGS) -def test_converter_ometiff( - setup_test_data, - setup_mm2gamma_ome_tiffs, - grid_layout, - scale_voxels, -): + assert tf.type == "scale" + assert tf.scale[:2] == [1.0, 1.0] + + +def _check_chunks( + position: Position, chunks: Literal["XY", "XYZ"] | tuple[int] | None +) -> None: + """Check chunk size of the highest resolution level.""" + img = position["0"] + match chunks: + case "XY": + assert img.chunks == (1,) * 3 + img.shape[-2:] + case "XYZ" | None: + assert img.chunks == (1,) * 2 + img.shape[-3:] + case tuple(): + assert img.chunks == chunks + case _: + assert False + + +def test_converter_ometiff(mm2gamma_ome_tiff, grid_layout, chunks, tmpdir): logging.getLogger("tifffile").setLevel(logging.ERROR) - _, _, data = setup_mm2gamma_ome_tiffs - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - converter = TIFFConverter( - data, - output, - grid_layout=grid_layout, - scale_voxels=scale_voxels, + output = tmpdir / "converted.zarr" + converter = TIFFConverter( + mm2gamma_ome_tiff, output, grid_layout=grid_layout, chunks=chunks + ) + assert isinstance(converter.reader, MMStack) + with TiffFile(next(mm2gamma_ome_tiff.glob("*.tif*"))) as tf: + raw_array = tf.asarray() + assert ( + converter.summary_metadata == tf.micromanager_metadata["Summary"] ) - assert isinstance(converter.reader, MicromanagerOmeTiffReader) - with TiffSequence(glob(os.path.join(data, "*.tif*"))) as ts: - raw_array = ts.asarray() - with TiffFile(ts[0]) as tf: - assert ( - converter.summary_metadata - == tf.micromanager_metadata["Summary"] + assert np.prod([d for d in converter.dim if d > 0]) == np.prod( + raw_array.shape + ) + assert list(converter.metadata.keys()) == [ + "iohub_version", + "Summary", + ] + converter() + with open_ome_zarr(output, mode="r") as result: + intensity = 0 + if grid_layout and converter.p > 1: + assert len(result) < converter.p + for pos_name, pos in result.positions(): + _check_scale_transform(pos) + _check_chunks(pos, chunks) + intensity += pos["0"][:].sum() + assert os.path.isfile( + os.path.join( + output, pos_name, "0", "image_plane_metadata.json" ) - assert np.prod([d for d in converter.dim if d > 0]) == np.prod( - raw_array.shape - ) - assert list(converter.metadata.keys()) == [ - "iohub_version", - "Summary", - ] - converter.run(check_image=True) - with open_ome_zarr(output, mode="r") as result: - intensity = 0 - for _, pos in result.positions(): - _check_scale_transform(pos, scale_voxels) - intensity += pos["0"][:].sum() + ) assert intensity == raw_array.sum() +@pytest.fixture(scope="module") +def example_ome_tiff() -> Path: + for d in mm2gamma_ome_tiffs: + if d.name == "mm2.0-20201209_4p_2t_5z_1c_512k_1": + return d + + @pytest.fixture(scope="function") def mock_hcs_ome_tiff_reader( - setup_mm2gamma_ome_tiffs, monkeypatch: pytest.MonkeyPatch + example_ome_tiff, monkeypatch: pytest.MonkeyPatch ): - all_ometiffs, _, _ = setup_mm2gamma_ome_tiffs # dataset with 4 positions without HCS site names - data = os.path.join(all_ometiffs, "mm2.0-20201209_4p_2t_5z_1c_512k_1") mock_stage_positions = [ {"Label": "A1-Site_0"}, {"Label": "A1-Site_1"}, @@ -101,19 +118,17 @@ def mock_hcs_ome_tiff_reader( ] expected_ngff_name = {"A/1/0", "A/1/1", "B/4/0", "H/12/0"} monkeypatch.setattr( - "iohub.convert.MicromanagerOmeTiffReader.stage_positions", + "iohub.convert.MMStack.stage_positions", mock_stage_positions, ) - return data, expected_ngff_name + return example_ome_tiff, expected_ngff_name @pytest.fixture(scope="function") def mock_non_hcs_ome_tiff_reader( - setup_mm2gamma_ome_tiffs, monkeypatch: pytest.MonkeyPatch + example_ome_tiff, monkeypatch: pytest.MonkeyPatch ): - all_ometiffs, _, _ = setup_mm2gamma_ome_tiffs # dataset with 4 positions without HCS site names - data = os.path.join(all_ometiffs, "mm2.0-20201209_4p_2t_5z_1c_512k_1") mock_stage_positions = [ {"Label": "0"}, {"Label": "1"}, @@ -121,145 +136,83 @@ def mock_non_hcs_ome_tiff_reader( {"Label": "3"}, ] monkeypatch.setattr( - "iohub.convert.MicromanagerOmeTiffReader.stage_positions", + "iohub.convert.MMStack.stage_positions", mock_stage_positions, ) - return data + return example_ome_tiff -def test_converter_ometiff_mock_hcs(setup_test_data, mock_hcs_ome_tiff_reader): +def test_converter_ometiff_mock_hcs(mock_hcs_ome_tiff_reader, tmpdir): data, expected_ngff_name = mock_hcs_ome_tiff_reader - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - converter = TIFFConverter(data, output, hcs_plate=True) - converter.run() - with open_ome_zarr(output, mode="r") as plate: - assert expected_ngff_name == { - name for name, _ in plate.positions() - } + output = tmpdir / "converted.zarr" + converter = TIFFConverter(data, output, hcs_plate=True) + converter() + with open_ome_zarr(output, mode="r") as plate: + assert expected_ngff_name == {name for name, _ in plate.positions()} -def test_converter_ometiff_mock_non_hcs(mock_non_hcs_ome_tiff_reader): +def test_converter_ometiff_mock_non_hcs(mock_non_hcs_ome_tiff_reader, tmpdir): data = mock_non_hcs_ome_tiff_reader - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - with pytest.raises(ValueError, match="HCS position labels"): - TIFFConverter(data, output, hcs_plate=True) + output = tmpdir / "converted.zarr" + with pytest.raises(ValueError, match="HCS position labels"): + TIFFConverter(data, output, hcs_plate=True) -def test_converter_ometiff_hcs_numerical( - setup_test_data, setup_mm2gamma_ome_tiff_hcs -): - _, data, _ = setup_mm2gamma_ome_tiff_hcs - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - converter = TIFFConverter(data, output, hcs_plate=True) - converter.run() - with open_ome_zarr(output, mode="r") as plate: - for name, _ in plate.positions(): - for segment in name.split("/"): - assert segment.isdigit() +def test_converter_ometiff_hcs_numerical(example_ome_tiff, tmpdir): + output = tmpdir / "converted.zarr" + converter = TIFFConverter(example_ome_tiff, output, hcs_plate=True) + converter() + with open_ome_zarr(output, mode="r") as plate: + for name, _ in plate.positions(): + for segment in name.split("/"): + assert segment.isdigit() -@given(**CONVERTER_TEST_GIVEN) -@settings(CONVERTER_TEST_SETTINGS) -def test_converter_ndtiff( - setup_test_data, - setup_pycromanager_test_data, - grid_layout, - scale_voxels, -): +def test_converter_ndtiff(ndtiff_datasets: Path, grid_layout, chunks, tmpdir): logging.getLogger("tifffile").setLevel(logging.ERROR) - _, _, data = setup_pycromanager_test_data - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - converter = TIFFConverter( - data, - output, - grid_layout=grid_layout, - scale_voxels=scale_voxels, - ) - assert isinstance(converter.reader, NDTiffReader) - raw_array = np.asarray(Dataset(data).as_array()) - assert np.prod([d for d in converter.dim if d > 0]) == np.prod( - raw_array.shape - ) - assert list(converter.metadata.keys()) == [ - "iohub_version", - "Summary", - ] - converter.run(check_image=True) - with open_ome_zarr(output, mode="r") as result: - intensity = 0 - for pos_name, pos in result.positions(): - _check_scale_transform(pos, scale_voxels) - intensity += pos["0"][:].sum() - assert os.path.isfile( - os.path.join( - output, pos_name, "0", "image_plane_metadata.json" - ) + output = tmpdir / "converted.zarr" + converter = TIFFConverter( + ndtiff_datasets, output, grid_layout=grid_layout, chunks=chunks + ) + assert isinstance(converter.reader, NDTiffDataset) + raw_array = np.asarray(Dataset(str(ndtiff_datasets)).as_array()) + assert np.prod([d for d in converter.dim if d > 0]) == np.prod( + raw_array.shape + ) + assert list(converter.metadata.keys()) == [ + "iohub_version", + "Summary", + ] + converter() + with open_ome_zarr(output, mode="r") as result: + intensity = 0 + for pos_name, pos in result.positions(): + _check_scale_transform(pos) + _check_chunks(pos, chunks) + intensity += pos["0"][:].sum() + assert os.path.isfile( + os.path.join( + output, pos_name, "0", "image_plane_metadata.json" ) - assert intensity == raw_array.sum() - with open( - os.path.join(output, pos_name, "0", "image_plane_metadata.json") - ) as f: - metadata = json.load(f) - assert len(metadata) == np.prod(raw_array.shape[1:-2]) - key = "0/0/0" - assert key in metadata - 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, - scale_voxels, - caplog, -): - logging.getLogger("tifffile").setLevel(logging.ERROR) - _, _, data = setup_mm2gamma_singlepage_tiffs - with TemporaryDirectory() as tmp_dir: - output = os.path.join(tmp_dir, "converted.zarr") - converter = TIFFConverter( - data, - output, - grid_layout=grid_layout, - scale_voxels=scale_voxels, - ) - assert isinstance(converter.reader, MicromanagerSequenceReader) - if scale_voxels: - assert "Pixel size detection is not supported" in caplog.text - with TiffSequence(glob(os.path.join(data, "**/*.tif*"))) as ts: - raw_array = ts.asarray() - assert np.prod([d for d in converter.dim if d > 0]) == np.prod( - raw_array.shape - ) - assert list(converter.metadata.keys()) == [ - "iohub_version", - "Summary", + ) + assert intensity == raw_array.sum() + with open( + os.path.join(output, pos_name, "0", "image_plane_metadata.json") + ) as f: + metadata = json.load(f) + key = "0/0/0" + assert key in metadata + assert "ElapsedTime-ms" in metadata[key] + + +def test_converter_ndtiff_v3_position_labels(tmpdir): + output = tmpdir / "converted.zarr" + converter = TIFFConverter(ndtiff_v3_labeled_positions, output) + converter() + with open_ome_zarr(output, mode="r") as result: + assert result.channel_names == ["Channel0"] + assert [name.split("/")[1] for name, _ in result.positions()] == [ + "Pos0", + "Pos1", + "Pos2", ] - converter.run(check_image=True) - with open_ome_zarr(output, mode="r") as result: - intensity = 0 - for _, pos in result.positions(): - intensity += pos["0"][:].sum() - assert intensity == raw_array.sum() diff --git a/tests/test_ndtiff.py b/tests/test_ndtiff.py new file mode 100644 index 00000000..dca898ea --- /dev/null +++ b/tests/test_ndtiff.py @@ -0,0 +1,80 @@ +import re + +import pytest +from xarray import DataArray + +from iohub.ndtiff import NDTiffDataset, NDTiffFOV +from tests.conftest import ndtiff_v2_datasets, ndtiff_v3_labeled_positions + + +def pytest_generate_tests(metafunc): + if "ndtiff_dataset" in metafunc.fixturenames: + metafunc.parametrize( + "ndtiff_dataset", + ndtiff_v2_datasets + [ndtiff_v3_labeled_positions], + ) + + +def test_dataset_ctx(ndtiff_dataset): + with NDTiffDataset(ndtiff_dataset) as dataset: + assert isinstance(dataset, NDTiffDataset) + assert len(dataset) > 0 + assert "NDTiffDataset" in dataset.__repr__() + + +def test_dataset_nonexisting(tmpdir): + with pytest.raises(FileNotFoundError): + NDTiffDataset(tmpdir / "nonexisting") + + +def test_dataset_metadata(ndtiff_dataset): + with NDTiffDataset(ndtiff_dataset) as dataset: + assert isinstance(dataset.micromanager_metadata, dict) + assert dataset.micromanager_metadata["Summary"] + assert isinstance(dataset.micromanager_summary, dict) + + +@pytest.mark.parametrize("ndtiff_v2", ndtiff_v2_datasets) +def test_dataset_getitem_v2(ndtiff_v2): + with NDTiffDataset(ndtiff_v2) as dataset: + assert isinstance(dataset["0"], NDTiffFOV) + assert isinstance(dataset[0], NDTiffFOV) + + +def test_dataset_v3_labeled_positions(): + dataset = NDTiffDataset(ndtiff_v3_labeled_positions) + assert len(dataset) == 3 + positions = ["Pos0", "Pos1", "Pos2"] + for (key, fov), name in zip(dataset, positions): + assert key == name + assert isinstance(fov, NDTiffFOV) + assert name in dataset.__repr__() + assert key in fov.__repr__() + with pytest.raises(KeyError): + dataset["0"] + dataset[0] + dataset.close() + + +def test_dataset_iter(ndtiff_dataset): + with NDTiffDataset(ndtiff_dataset) as dataset: + for key, fov in dataset: + assert isinstance(key, str) + assert isinstance(fov, NDTiffFOV) + + +def test_dataset_v2_num_positions(ndtiff_dataset): + with NDTiffDataset(ndtiff_dataset) as dataset: + p_match = re.search(r"_(\d+)p_", str(ndtiff_dataset)) + assert len(dataset) == int(p_match.group(1)) if p_match else 1 + + +def test_fov_getitem(ndtiff_dataset): + with NDTiffDataset(ndtiff_dataset) as dataset: + for _, fov in dataset: + img = fov[:] + assert isinstance(img, DataArray) + assert img.ndim == 5 + assert img[0, 0, 0, 0, 0] >= 0 + for ch in fov.channel_names: + assert img.sel(time=0, channel=ch, z=0, y=0, x=0) >= 0 diff --git a/tests/test_reader.py b/tests/test_reader.py new file mode 100644 index 00000000..cb04e977 --- /dev/null +++ b/tests/test_reader.py @@ -0,0 +1,38 @@ +import pytest + +from iohub._deprecated.singlepagetiff import MicromanagerSequenceReader +from iohub.mmstack import MMStack +from iohub.ndtiff import NDTiffDataset +from iohub.reader import read_images +from tests.conftest import ( + mm2gamma_ome_tiffs, + mm2gamma_singlepage_tiffs, + mm1422_ome_tiffs, + ndtiff_v2_datasets, + ndtiff_v3_labeled_positions, +) + + +def test_unsupported_datatype(tmpdir): + with pytest.raises(ValueError): + _ = read_images(tmpdir, data_type="unsupportedformat") + + +@pytest.mark.parametrize("data_path", mm2gamma_ome_tiffs + mm1422_ome_tiffs) +def test_detect_ome_tiff(data_path): + reader = read_images(data_path) + assert isinstance(reader, MMStack) + + +@pytest.mark.parametrize( + "data_path", ndtiff_v2_datasets + [ndtiff_v3_labeled_positions] +) +def test_detect_ndtiff(data_path): + reader = read_images(data_path) + assert isinstance(reader, NDTiffDataset) + + +@pytest.mark.parametrize("data_path", mm2gamma_singlepage_tiffs) +def test_detect_single_page_tiff(data_path): + reader = read_images(data_path) + assert isinstance(reader, MicromanagerSequenceReader)