diff --git a/README.md b/README.md index ae9d2b644..078b802e1 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,6 @@ A Python library for reading and writing image data with specific support for ha * `TIFF` * Any additional format supported by `imageio` -### Disclaimer: -This package is under heavy revision in preparation for version 3.0.0 release. The quick start below is representative -of how to interact with the package under 3.0.0 and not under the current stable release. - ## Quick Start ```python from aicsimageio import AICSImage, imread @@ -58,7 +54,6 @@ im.metadata # returns whichever metadata parser best suits the file format # Subsets or transposes of the image data can be requested: im.get_image_data(out_orientation="ZYX") # returns a 3d data block containing only the ZYX dimensions - ``` ## Notes @@ -66,6 +61,31 @@ im.get_image_data(out_orientation="ZYX") # returns a 3d data block containing o or `Scene`, `Time`, `Channel`, `Z`, `Y`, and `X`. * Each file format may use a different metadata parser it is dependent on the reader's implementation. +## Experimental +We include an experimental series reader which can force multiple images to act like a single `numpy.ndarray`. +Image dimension consistency is checked during reading of any image required to complete a slice operation. +All images must have consistent shape and whichever dimension is chosen to be used as the series dimension must be of +size one (1) for each image in the series. + +```python +from aicsimageio import AICSSeries + +# Combine a series of images and make them act like a single numpy ndarray +series = AICSSeries( + [ + "img1.tiff", + "img2.tiff", + "img3.tiff", + "img4.tiff" + ], + series_dim="T" +) + +# Get data out +series[0, :, :, :, :, :] # returns a 5D ndarray of TCZYX +series[:, 3, 3, :, :, :] # returns a 4D ndarray of SZYX +``` + ## Installation **Stable Release:** `pip install aicsimageio`
**Development Head:** `pip install git+https://github.com/AllenCellModeling/aicsimageio.git` diff --git a/aicsimageio/__init__.py b/aicsimageio/__init__.py index 9f1672fe0..052fbaa24 100644 --- a/aicsimageio/__init__.py +++ b/aicsimageio/__init__.py @@ -1,5 +1,6 @@ from .aics_image import AICSImage # noqa: F401 from .aics_image import imread # noqa: F401 +from .aics_series import AICSSeries # noqa: F401 # Do not edit this string manually, always use bumpversion # Details in CONTRIBUTING.md diff --git a/aicsimageio/aics_image.py b/aicsimageio/aics_image.py index 8d5899b8a..927315a7b 100644 --- a/aicsimageio/aics_image.py +++ b/aicsimageio/aics_image.py @@ -1,16 +1,19 @@ import logging -import typing -from typing import Optional, Type +from typing import Optional, Type, Union import numpy as np from . import constants, transforms, types -from .exceptions import InvalidDimensionOrderingError, UnsupportedFileFormatError -from .readers import CziReader, DefaultReader, NdArrayReader, OmeTiffReader, TiffReader +from .exceptions import (InvalidDimensionOrderingError, + UnsupportedFileFormatError) +from .readers import (CziReader, DefaultReader, NdArrayReader, OmeTiffReader, + TiffReader) from .readers.reader import Reader log = logging.getLogger(__name__) +############################################################################### + class AICSImage: """ @@ -85,7 +88,7 @@ class AICSImage: def __init__( self, - data: typing.Union[types.FileLike, np.ndarray], + data: Union[types.FileLike, np.ndarray], known_dims: Optional[str] = None, **kwargs, ): @@ -155,7 +158,7 @@ def data(self): ) return self._data - def size(self, dims: str = "STCZYX"): + def size(self, dims: str = constants.DEFAULT_DIMENSION_ORDER): """ Parameters ---------- @@ -166,7 +169,7 @@ def size(self, dims: str = "STCZYX"): Returns a tuple with the requested dimensions filled in """ dims = dims.upper() - if not (all(d in "STCZYX" for d in dims)): + if not (all(d in constants.DEFAULT_DIMENSION_ORDER for d in dims)): raise InvalidDimensionOrderingError(f"Invalid dimensions requested: {dims}") if not (all(d in self.dims for d in dims)): raise InvalidDimensionOrderingError(f"Invalid dimensions requested: {dims}") diff --git a/aicsimageio/aics_series.py b/aicsimageio/aics_series.py new file mode 100644 index 000000000..e2cd3be1a --- /dev/null +++ b/aicsimageio/aics_series.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import logging +from pathlib import Path +from typing import Iterable, List, Optional, Tuple, Union + +import numpy as np + +from . import AICSImage, constants, exceptions, types + +log = logging.getLogger(__name__) + +############################################################################### + + +class AICSSeries: + """ + AICSSeries takes an ordered iterable of image data types (files / bytestreams) of consistent dimensions and forces + them to act like they are a single image. The data for each image in the series is lazy loaded as are the images + themselves. This is beneficial when the images stacked together may be too large to fit in memory. + + Examples + -------- + series = AICSSeries(["img1.tiff", "img2.tiff"], "T") + series[0, :, :, 3, :, :] # returns a 4D array of TCYX + + series = AICSImage("directory_full_of_tiffs/", "Z") + series[0, 0, 0, 3, :, :] # returns a 2D array of YX + + Notes + ----- + * Because each image is lazy loaded, we validate on every image read. If the image has different dimensions or + sizes from the prior read image, an InvalidDimensionOrderingError or InconsitentDataShapeException is raised. + * This class may be a temporary stop gap until chunked image reading is added to the core `AICSImage` class and if + and when that occurs, this class will be deprecated. + * No metadata is checked for ensuring that the data in each image is consistent across them all. Example being, + channels in different indicies in different files across the series. + """ + + def __init__( + self, + images: Union[types.PathLike, Iterable[types.PathLike]], + series_dim: str + ): + """ + Constructor for AICSSeries class intended for providing an interface for image reading of a list of path like + references. + + Parameters + ---------- + images: Union[types.PathLike, Iterable[types.PathLike]] + Either a pathlike value to a directory with images or the ordered iterable set of pathlike objects to read + from for the series. + series_dim: str + Which dimension to override with the iterable images. + """ + # Check if provided a directory, if so, get the directory contents + if isinstance(images, (str, Path)): + # Expand and handle path + images = Path(images).expanduser().resolve(strict=True) + + # Ensure that it is actually a directory and not a single file + if not images.is_dir(): + raise NotADirectoryError( + f"Provided a single file: {images}. Did you mean to use `AICSImage`?" + ) + + # Set images to the contents of the directory + images = sorted(images.iterdir()) + + # Drop any files that start with `.` + # TODO: Is this actually a valid operation to do for the user or way to specific? + # TODO: Different option, find the most common suffix and use it as the base? + predrop_count = len(images) + images = [img for img in images if img.name[0] != "."] + log.debug(f"Dropped {predrop_count - len(images)} files that began with a `.`.") + + # At this point, images should have either been converted to a list of files if provided a directory + # Or was given an iterable of file like + if not isinstance(images, Iterable): + raise TypeError( + f"AICSSeries requires either a path to a directory or an iterable of files." + ) + + # Check that the iterable is more than one image + if len(images) <= 1: + raise ValueError(f"Provided a single file: {images}. Did you mean to use `AICSImage`?") + + # Now check that everything in the iterable is file like + # This will also raise an error if any of the file like paths was not found + images = [Path(img).expanduser().resolve(strict=True) for img in images] + + # Ensure that the series dim provided is part of the normal set and that only one dim is provided + series_dim = series_dim.upper() + if ( + not isinstance(series_dim, str) + or series_dim not in constants.DEFAULT_DIMENSION_ORDER + or len(series_dim) != 1 + ): + raise ValueError( + f"The series dimension must be a single character from the " + f"standard {constants.DEFAULT_DIMENSION_ORDER}. Received: '{series_dim}'." + ) + + # All easy checks are now complete, store the image list and the series dim + self._images = images + self._series_dim = series_dim + + # Set the initial state of the series for future validation + # _shape is for ndarray shape consistency + # _size is for fast access after read + self._shape = None + self._size = None + + def validate_series(self): + """ + Runs the validation conditions against every image provided to the series. + + Raises an InvalidDimensionOrderingError or InconsitentDataShapeException if any images fail validation. + """ + for img_path in self.images: + with AICSImage(img_path) as img: + # Set self._shape if not already set + if self._shape is None: + self._shape = img.size() + + # Ensure data consistency + self._ensure_valid_data_shape(img.size(), self.operating_index, self._shape) + + # Clean up memory + del img + + @property + def series_dim(self) -> str: + """ + Returns + ------- + series_dim: str + Which dimension is supposed to act like a normal array axis but is actually reading a series of images. + """ + return self._series_dim + + @property + def operating_index(self) -> int: + """ + Returns + ------- + operating_index: int + The dimension index of the series dimension. + """ + return constants.DEFAULT_DIMENSION_ORDER.index(self.series_dim) + + @property + def images(self) -> List[Path]: + """ + Returns + ------- + images: List[Path]: + The list of filepaths to images to be used for the series. + """ + return self._images + + @staticmethod + def _ensure_valid_data_shape( + data_shape: Tuple[int], + operating_index: int, + prior_shape: Optional[Tuple[int]] = None + ): + """ + Used to ensure that the data shape is `1` in whichever index is the operating index for the series. + Additionally, if provided a prior shape, will check for shape consistenting between the pair. + + Raises an InvalidDimensionOrderingError or InconsitentDataShapeException. + + Parameters + ---------- + data_shape: Tuple[int] + A tuple of six dimension sizes produced by `AICSImage.size()` for an image in the series. + operating_index: int + The operating index for the series. This is constants.DEFAULT_DIMENSION_ORDER.index(self.series_dim). + prior_shape: Optional[Tuple[int]] = None + An optional prior shape to ensure that the provided data shape is consistent with the prior. + """ + if data_shape[operating_index] != 1: + raise exceptions.InvalidDimensionOrderingError( + f"The read data shape is invalid for the current operating series dimension. Read shape: {data_shape}." + ) + + if prior_shape: + if data_shape != prior_shape: + raise exceptions.InconsitentDataShapeException( + f"The read data shape is inconsitent with the prior data shape. " + f"Read shape: {data_shape}, prior shape: {prior_shape}" + ) + + def size(self, dims: str = constants.DEFAULT_DIMENSION_ORDER) -> Tuple[int]: + """ + Parameters + ---------- + dims: str + A string containing a list of dimensions being requested. The default is to return the six standard dims. + + Returns + ------- + dims: Tuple[int] + Returns a tuple with the requested dimensions filled in + """ + # Only run if shape has never been retrieved before + if self._size is None: + with AICSImage(self.images[0]) as img: + # Get size + shape = img.size() + + # Check data shape + self._ensure_valid_data_shape(shape, self.operating_index) + + # Store shape for future checks + self._shape = shape + log.debug(f"Will hold all images to shape of: {self._shape}") + + # Replace the retrieved size at operating index with the length of the series + size = [] + for i, val in enumerate(shape): + if i == self.operating_index: + size.append(len(self.images)) + else: + size.append(val) + + # Set shape state + self._size = tuple(size) + + return tuple([self._size[constants.DEFAULT_DIMENSION_ORDER.index(c)] for c in dims]) + + @property + def size_x(self) -> int: + """ + Returns + ------- + size_x: int + The size of the x dimension. + """ + return self.size("X")[0] + + @property + def size_y(self) -> int: + """ + Returns + ------- + size_y: int + The size of the x dimension. + """ + return self.size("Y")[0] + + @property + def size_z(self) -> int: + """ + Returns + ------- + size_z: int + The size of the x dimension. + """ + return self.size("Z")[0] + + @property + def size_c(self) -> int: + """ + Returns + ------- + size_c: int + The size of the x dimension. + """ + return self.size("C")[0] + + @property + def size_t(self) -> int: + """ + Returns + ------- + size_t: int + The size of the x dimension. + """ + return self.size("T")[0] + + @property + def size_s(self) -> int: + """ + Returns + ------- + size_s: int + The size of the x dimension. + """ + return self.size("S")[0] + + def __getitem__(self, selections: Tuple[Union[slice, int]]) -> np.ndarray: + """ + Apply slice operations to the image series like a normal numpy.ndarray. + """ + # Easy check to make sure that length of selections is at most the length of dims (6) + if len(selections) > len(constants.DEFAULT_DIMENSION_ORDER): + raise IndexError(f"More operations provided than dimensions available.") + + # To maintain consistent behavior with numpy ndarray slicing behavior, if there are less operations than dims + # pad the selections with slice(None, None, None) + # Ex: series[0, 1, ] should pad to series[0, 1, :, :, :, :] + if len(selections) < len(constants.DEFAULT_DIMENSION_ORDER): + formatted_selections = [op for op in selections] + while len(formatted_selections) < len(constants.DEFAULT_DIMENSION_ORDER): + formatted_selections.append(slice(None, None, None)) + + # Cast to tuple and save as selections + selections = tuple(formatted_selections) + + # Final check that every operation is either an integer or a slice + for op in selections: + if not isinstance(op, (int, slice)): + raise TypeError( + f"Operations on __getitem__ must be a single value or a slice to get from the data. Received: {op}." + ) + + # Get the operations required for the operating index + to_read = self.images[selections[self.operating_index]] + + # Always convert to a list + if isinstance(to_read, Path): + to_read = [to_read] + + # Get the other operations required on each image by selection all operations except the one occuring on the + # operating index, additionally, keep track of what we expect the dims to be out of these operations + ops = [] + expected_dims = [] + for i, op in enumerate(selections): + if i != self.operating_index: + ops.append(op) + + if isinstance(op, slice): + expected_dims.append(constants.DEFAULT_DIMENSION_ORDER[i]) + + # Convert to Tuple + ops = tuple(ops) + + # Read and apply operations across series + read_data = [] + for img_to_read in to_read: + log.debug(f"Reading {img_to_read}...") + with AICSImage(img_to_read) as img: + # Set self._shape if not already set + if self._shape is None: + self._shape = img.size() + + # Ensure data consistency + self._ensure_valid_data_shape(img.size(), self.operating_index, self._shape) + + # Read and append + read_data.append( + # This will get us the other five dimensions + img.get_image_data( + constants.DEFAULT_DIMENSION_ORDER.replace(self.series_dim, ""), + copy=True + )[ops] + ) + + # Clean up after reading to save on memory + # TODO: Fix aicsimageio to do this clean up for us? I am not sure why it is being held onto + del img + + # Stack data on series dim axis if expected and return + if self.series_dim in expected_dims: + data = np.stack(read_data, axis=expected_dims.index(self.series_dim)) + + # Otherwise we know it is just a single image because operating axis didn't matter so pull that image data out + else: + data = read_data[0] + + # Clean up read data to save on memory + del read_data + + return data + + def __str__(self) -> str: + return ( + f"" + ) + + def __repr__(self) -> str: + return str(self) diff --git a/aicsimageio/exceptions.py b/aicsimageio/exceptions.py index 379af1fd3..45b93cf62 100644 --- a/aicsimageio/exceptions.py +++ b/aicsimageio/exceptions.py @@ -42,3 +42,16 @@ class ConflictingArgumentsError(Exception): This exception is returned when 2 arguments to the same function are in conflict. """ pass + + +class InconsitentDataShapeException(Exception): + """ + Intended to be thrown when data shapes between multiple arrays aren't equal. + """ + + def __init__(self, message: str, **kwargs): + super().__init__(**kwargs) + self.message = message + + def __str__(self): + return self.message diff --git a/aicsimageio/tests/resources/series_data_invalid/example.png b/aicsimageio/tests/resources/series_data_invalid/example.png new file mode 100644 index 000000000..04e55cbb5 Binary files /dev/null and b/aicsimageio/tests/resources/series_data_invalid/example.png differ diff --git a/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_10_z_1.ome.tiff b/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_10_z_1.ome.tiff new file mode 100644 index 000000000..f7a36c40d Binary files /dev/null and b/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_10_z_1.ome.tiff differ diff --git a/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_1_z_1.ome.tiff b/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_1_z_1.ome.tiff new file mode 100644 index 000000000..5db33ed9f Binary files /dev/null and b/aicsimageio/tests/resources/series_data_invalid/s_1_t_1_c_1_z_1.ome.tiff differ diff --git a/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(0).ome.tiff b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(0).ome.tiff new file mode 100644 index 000000000..5db33ed9f Binary files /dev/null and b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(0).ome.tiff differ diff --git a/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(1).ome.tiff b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(1).ome.tiff new file mode 100644 index 000000000..5db33ed9f Binary files /dev/null and b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(1).ome.tiff differ diff --git a/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(2).ome.tiff b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(2).ome.tiff new file mode 100644 index 000000000..5db33ed9f Binary files /dev/null and b/aicsimageio/tests/resources/series_data_valid/s_1_t_1_c_1_z_1_(2).ome.tiff differ diff --git a/aicsimageio/tests/test_aics_series.py b/aicsimageio/tests/test_aics_series.py new file mode 100644 index 000000000..99295ae4b --- /dev/null +++ b/aicsimageio/tests/test_aics_series.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from pathlib import Path + +import numpy as np +import pytest + +from aicsimageio import AICSImage, AICSSeries, constants, exceptions + +############################################################################### + +DATA_DIR = Path(__file__).parent / "resources" + +############################################################################### + + +@pytest.mark.parametrize("images, series_dim, expected_images", [ + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"] + ), + ( + DATA_DIR / "series_data_valid", + "T", + [ + DATA_DIR / "series_data_valid" / "s_1_t_1_c_1_z_1_(0).ome.tiff", + DATA_DIR / "series_data_valid" / "s_1_t_1_c_1_z_1_(1).ome.tiff", + DATA_DIR / "series_data_valid" / "s_1_t_1_c_1_z_1_(2).ome.tiff", + ] + ), + # Doesn't fail because no checks are done on init + ( + DATA_DIR / "series_data_invalid", + "T", + [ + DATA_DIR / "series_data_invalid" / "example.png", + DATA_DIR / "series_data_invalid" / "s_1_t_1_c_10_z_1.ome.tiff", + DATA_DIR / "series_data_invalid" / "s_1_t_1_c_1_z_1.ome.tiff", + ] + ), + pytest.param( + DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", + None, + None, + marks=pytest.mark.raises(exception=NotADirectoryError) + ), + pytest.param(1, None, None, marks=pytest.mark.raises(exception=TypeError)), + pytest.param([DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], None, None, marks=pytest.mark.raises(exception=ValueError)), + pytest.param(DATA_DIR / "series_data_valid", 1, None, marks=pytest.mark.raises(exception=AttributeError)), + pytest.param(DATA_DIR / "series_data_valid", "B", None, marks=pytest.mark.raises(exception=ValueError)), + pytest.param(DATA_DIR / "series_data_valid", "ABC", None, marks=pytest.mark.raises(exception=ValueError)) +]) +def test_aics_series_init(images, series_dim, expected_images): + series = AICSSeries(images, series_dim) + for i, img_path in enumerate(series.images): + assert img_path == expected_images[i] + + +@pytest.mark.parametrize("data_shape, operating_index, prior_shape", [ + ((2, 1, 4, 60, 480, 480), 1, None), + ((2, 2, 2, 1, 100, 100), 3, None), + pytest.param((2, 2, 2), 0, None, marks=pytest.mark.raises(exceptions=exceptions.InvalidDimensionOrderingError)), + ((2, 1, 4, 60, 480, 480), 1, (2, 1, 4, 60, 480, 480)), + pytest.param( + (2, 1, 2, 1, 100, 100), + 1, + (1, 1, 3), + marks=pytest.mark.raises(exceptions=exceptions.InconsitentDataShapeException) + ) +]) +def test_valid_data_shape(data_shape, operating_index, prior_shape): + AICSSeries._ensure_valid_data_shape(data_shape, operating_index, prior_shape) + + +@pytest.mark.parametrize("images, series_dim, dims, expected_size", [ + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + constants.DEFAULT_DIMENSION_ORDER, + (1, 2, 1, 1, 325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + "CZYX", + (1, 1, 325, 475) + ), + pytest.param( + [DATA_DIR / "s_3_t_1_c_3_z_5.ome.tiff", DATA_DIR / "s_3_t_1_c_3_z_5.ome.tiff"], + "Z", + None, + None, + marks=pytest.mark.raises(exception=exceptions.InvalidDimensionOrderingError) + ) +]) +def test_size(images, series_dim, dims, expected_size): + series = AICSSeries(images, series_dim) + assert series.size(dims) == expected_size + + +@pytest.mark.parametrize("images, series_dim, expected_sizes", [ + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (1, 2, 1, 1, 325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "S", + (2, 1, 1, 1, 325, 475) + ) +]) +def test_single_dim_size_properties(images, series_dim, expected_sizes): + series = AICSSeries(images, series_dim) + assert series.size_s == expected_sizes[0] + assert series.size_t == expected_sizes[1] + assert series.size_c == expected_sizes[2] + assert series.size_z == expected_sizes[3] + assert series.size_y == expected_sizes[4] + assert series.size_x == expected_sizes[5] + + +@pytest.mark.parametrize("images, series_dim", [ + ([DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], "T"), + (DATA_DIR / "series_data_valid", "T"), + # Fails because different size C dimensions + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff"], + "C", + marks=pytest.mark.raises(exception=exceptions.InvalidDimensionOrderingError) + ), + # Fails because different size C dimensions + pytest.param( + DATA_DIR / "series_data_invalid", + "C", + marks=pytest.mark.raises(exception=exceptions.InvalidDimensionOrderingError) + ), + # Fails because different image shapes + pytest.param( + DATA_DIR / "series_data_invalid", + "T", + marks=pytest.mark.raises(exception=exceptions.InconsitentDataShapeException) + ) +]) +def test_validate_series(images, series_dim): + series = AICSSeries(images, series_dim) + series.validate_series() + + +@pytest.mark.parametrize("images, series_dim, selections, expected_shape", [ + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, 0, 0, 0, slice(None, None, None), slice(None, None, None)), + (325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, slice(None, None, None), 0, 0, slice(None, None, None), slice(None, None, None)), + (2, 325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0,), + (2, 1, 1, 325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, 1, 0, ), + (1, 325, 475) + ), + ( + [DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff"], + "T", + (0, 1, ), + (10, 1, 1736, 1776,) + ), + ( + DATA_DIR / "series_data_valid", + "T", + (0, slice(None, None, None), 0, 0, ), + (3, 325, 475) + ), + # Fails because images found in directory have mixed shapes + pytest.param( + DATA_DIR / "series_data_invalid", + "T", + (0, slice(None, None, None), 0, 0, ), + None, + marks=pytest.mark.raises(exception=exceptions.InconsitentDataShapeException) + ), + # Fails because too many operations provided + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, 1, 0, 0, 0, 0, 0, 0), + None, + marks=pytest.mark.raises(exception=IndexError) + ), + # Fails because "hello" isn't a valid slice operation + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, 1, 0, "hello", ), + None, + marks=pytest.mark.raises(exception=TypeError) + ), + # Fails because requested index is out of range (for list of images) + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "T", + (0, 10, ), + None, + marks=pytest.mark.raises(exception=IndexError) + ), + # Fails because requested index is out of range (for actual data array) + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff"], + "C", + (0, 10, ), + None, + marks=pytest.mark.raises(exception=IndexError) + ), + # Fails because different overall shape changes + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff"], + "T", + (0, ), + None, + marks=pytest.mark.raises(exception=exceptions.InconsitentDataShapeException) + ), + # Fails because size of c changes + pytest.param( + [DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff"], + "C", + (0, ), + None, + marks=pytest.mark.raises(exception=exceptions.InvalidDimensionOrderingError) + ) +]) +def test_getitem_fast_checks(images, series_dim, selections, expected_shape): + series = AICSSeries(images, series_dim) + assert series[selections].shape == expected_shape + + +@pytest.mark.parametrize("image, selections", [ + ( + DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", + ( + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", + ( + 0, + 0, + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", + ( + 0, + slice(None, None, None), + 0, + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_1_z_1.ome.tiff", + ( + slice(None, None, None), + slice(None, None, None), + 0, + 0, + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff", + ( + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff", + ( + 0, + 0, + slice(None, None, None), + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff", + ( + 0, + slice(None, None, None), + 0, + slice(None, None, None), + slice(None, None, None), + slice(None, None, None) + ) + ), + ( + DATA_DIR / "s_1_t_1_c_10_z_1.ome.tiff", + ( + slice(None, None, None), + slice(None, None, None), + 0, + 0, + slice(None, None, None), + slice(None, None, None) + ) + ), +]) +def test_getitem_deep_equal(image, selections): + with AICSImage(image) as img: + data = img.get_image_data("SCZYX") + + stacked = np.stack([data, data], axis=1) + expected = stacked[selections] + + series = AICSSeries([image, image], "T") + assert np.array_equal(series[selections], expected)