From 0c21a7303a5d6fbf6f1da2bc6e7c9a56826163ba Mon Sep 17 00:00:00 2001 From: William Patton Date: Mon, 9 Dec 2024 20:29:56 -0800 Subject: [PATCH 01/13] add support for getting the aggregated scale/transform for individual arrays --- iohub/ngff/nodes.py | 164 +++++++++++++++++----------------------- tests/ngff/test_ngff.py | 132 +++++++++++++++++--------------- 2 files changed, 142 insertions(+), 154 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 32ec1e7f..fa6dec7b 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -58,9 +58,7 @@ def _open_store( synchronizer=None, ): if not os.path.isdir(store_path) and mode in ("r", "r+"): - raise FileNotFoundError( - f"Dataset directory not found at {store_path}." - ) + raise FileNotFoundError(f"Dataset directory not found at {store_path}.") if version != "0.4": _logger.warning( "IOHub is only tested against OME-NGFF v0.4. " @@ -70,14 +68,10 @@ def _open_store( else: dimension_separator = "/" try: - store = zarr.DirectoryStore( - store_path, dimension_separator=dimension_separator - ) + store = zarr.DirectoryStore(store_path, dimension_separator=dimension_separator) root = zarr.open_group(store, mode=mode, synchronizer=synchronizer) except Exception as e: - raise RuntimeError( - f"Cannot open Zarr root group at {store_path}" - ) from e + raise RuntimeError(f"Cannot open Zarr root group at {store_path}") from e return root @@ -108,9 +102,7 @@ def __init__( if channel_names: self._channel_names = channel_names elif not parse_meta: - raise ValueError( - "Channel names need to be provided or in metadata." - ) + raise ValueError("Channel names need to be provided or in metadata.") if axes: self.axes = axes self._group = group @@ -541,12 +533,8 @@ def _parse_meta(self): omero = self.zattrs.get("omero") if multiscales and omero: try: - self.metadata = ImagesMeta( - multiscales=multiscales, omero=omero - ) - self._channel_names = [ - c.label for c in self.metadata.omero.channels - ] + self.metadata = ImagesMeta(multiscales=multiscales, omero=omero) + self._channel_names = [c.label for c in self.metadata.omero.channels] self.axes = self.metadata.multiscales[0].axes except ValidationError: self._warn_invalid_meta() @@ -560,9 +548,7 @@ def dump_meta(self): @property def _storage_options(self): return { - "compressor": Blosc( - cname="zstd", clevel=1, shuffle=Blosc.BITSHUFFLE - ), + "compressor": Blosc(cname="zstd", clevel=1, shuffle=Blosc.BITSHUFFLE), "overwrite": self._overwrite, } @@ -599,8 +585,7 @@ def data(self): return self["0"] except KeyError: raise KeyError( - "There is no array named '0' " - f"in the group of: {self.array_keys()}" + "There is no array named '0' " f"in the group of: {self.array_keys()}" ) def __getitem__(self, key: int | str) -> ImageArray: @@ -624,9 +609,7 @@ def __setitem__(self, key, value: NDArray): """Write an up-to-5D image with default settings.""" key = normalize_storage_path(key) if not isinstance(value, np.ndarray): - raise TypeError( - f"Value must be a NumPy array. Got type {type(value)}." - ) + raise TypeError(f"Value must be a NumPy array. Got type {type(value)}.") self.create_image(key, value) def images(self) -> Generator[tuple[str, ImageArray]]: @@ -676,9 +659,7 @@ def create_image( if check_shape: self._check_shape(data.shape) img_arr = ImageArray( - self._group.array( - name, data, chunks=chunks, **self._storage_options - ) + self._group.array(name, data, chunks=chunks, **self._storage_options) ) self._create_image_meta(img_arr.basename, transform=transform) return img_arr @@ -761,8 +742,7 @@ def _check_shape(self, data_shape: tuple[int]): _logger.warning(msg) else: _logger.info( - "Dataset channel axis is not set. " - "Skipping channel shape check." + "Dataset channel axis is not set. " "Skipping channel shape check." ) def _create_image_meta( @@ -773,9 +753,7 @@ def _create_image_meta( ): if not transform: transform = [TransformationMeta(type="identity")] - dataset_meta = DatasetMeta( - path=name, coordinate_transformations=transform - ) + dataset_meta = DatasetMeta(path=name, coordinate_transformations=transform) if not hasattr(self, "metadata"): self.metadata = ImagesMeta( multiscales=[ @@ -784,18 +762,13 @@ def _create_image_meta( axes=self.axes, datasets=[dataset_meta], name=name, - coordinateTransformations=[ - TransformationMeta(type="identity") - ], + coordinateTransformations=[TransformationMeta(type="identity")], metadata=extra_meta, ) ], omero=self._omero_meta(id=0, name=self._group.basename), ) - elif ( - dataset_meta.path - not in self.metadata.multiscales[0].get_dataset_paths() - ): + elif dataset_meta.path not in self.metadata.multiscales[0].get_dataset_paths(): self.metadata.multiscales[0].datasets.append(dataset_meta) self.dump_meta() @@ -808,15 +781,11 @@ def _omero_meta( if not clims: clims = [None] * len(self.channel_names) channels = [] - for i, (channel_name, clim) in enumerate( - zip(self.channel_names, clims) - ): + for i, (channel_name, clim) in enumerate(zip(self.channel_names, clims)): if i == 0: first_chan = True channels.append( - channel_display_settings( - channel_name, clim=clim, first_chan=first_chan - ) + channel_display_settings(channel_name, clim=clim, first_chan=first_chan) ) omero_meta = OMEROMeta( version=self.version, @@ -836,8 +805,7 @@ def _find_axis(self, axis_type): def _get_channel_axis(self): if (ch_ax := self._find_axis("channel")) is None: raise KeyError( - "Axis 'channel' does not exist. " - "Please update `self.axes` first." + "Axis 'channel' does not exist. " "Please update `self.axes` first." ) else: return ch_ax @@ -866,14 +834,10 @@ def append_channel(self, chan_name: str, resize_arrays: bool = True): elif ch_ax == len(shape): shape = _pad_shape(tuple(shape), target=len(shape) + 1) else: - raise IndexError( - f"Cannot infer channel axis for shape {shape}." - ) + raise IndexError(f"Cannot infer channel axis for shape {shape}.") img.resize(shape) if "omero" in self.metadata.model_dump().keys(): - self.metadata.omero.channels.append( - channel_display_settings(chan_name) - ) + self.metadata.omero.channels.append(channel_display_settings(chan_name)) self.dump_meta() def rename_channel(self, old: str, new: str): @@ -937,18 +901,12 @@ def initialize_pyramid(self, levels: int) -> None: for level in range(1, levels): factor = 2**level - shape = array.shape[:-3] + _scale_integers( - array.shape[-3:], factor - ) + shape = array.shape[:-3] + _scale_integers(array.shape[-3:], factor) - chunks = _pad_shape( - _scale_integers(array.chunks, factor), len(shape) - ) + chunks = _pad_shape(_scale_integers(array.chunks, factor), len(shape)) transforms = deepcopy( - self.metadata.multiscales[0] - .datasets[0] - .coordinate_transformations + self.metadata.multiscales[0].datasets[0].coordinate_transformations ) for tr in transforms: if tr.type == "scale": @@ -970,9 +928,7 @@ def scale(self) -> list[float]: highest resolution scale. """ scale = [1] * self.data.ndim - transforms = ( - self.metadata.multiscales[0].datasets[0].coordinate_transformations - ) + transforms = self.metadata.multiscales[0].datasets[0].coordinate_transformations for trans in transforms: if trans.type == "scale": if len(trans.scale) != len(scale): @@ -990,9 +946,7 @@ def axis_names(self) -> list[str]: Returns lowercase axis names. """ - return [ - axis.name.lower() for axis in self.metadata.multiscales[0].axes - ] + return [axis.name.lower() for axis in self.metadata.multiscales[0].axes] def get_axis_index(self, axis_name: str) -> int: """ @@ -1010,6 +964,43 @@ def get_axis_index(self, axis_name: str) -> int: """ return self.axis_names.index(axis_name.lower()) + def get_transforms( + self, + image: str | Literal["*"], + ) -> tuple[TransformationMeta, TransformationMeta]: + """Get the total coordinate scale and translation metadata + for one image array or the whole FOV. + + Parameters + ---------- + image : str | Literal["*"] + Name of one image array (e.g. "0") to transform, + or "*" for the whole FOV + """ + transforms: list[TransformationMeta] = self.metadata.multiscales[0].coordinate_transformations + if image != "*" and image in self: + for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): + if dataset_meta.path == image: + transforms.extend( + self.metadata.multiscales[0] + .datasets[i] + .coordinate_transformations + ) + elif image != "*": + raise ValueError(f"Key {image} not recognized.") + + full_scale = np.array([1]*len(self.axes), dtype=float) + full_translation = np.array([0]*len(self.axes), dtype=float) + for transform in transforms: + if transform.type == "scale": + full_scale *= np.array(transform.scale) + elif transform.type == "translation": + full_translation += full_scale * np.array(transform.translation) + + return TransformationMeta( + type="scale", scale=tuple(full_scale) + ), TransformationMeta(type="translation", translation=tuple(full_translation)) + def set_transform( self, image: str | Literal["*"], @@ -1030,9 +1021,7 @@ def set_transform( if image == "*": self.metadata.multiscales[0].coordinate_transformations = transform elif image in self: - for i, dataset_meta in enumerate( - self.metadata.multiscales[0].datasets - ): + for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): if dataset_meta.path == image: self.metadata.multiscales[0].datasets[i] = DatasetMeta( path=image, coordinate_transformations=transform @@ -1061,9 +1050,7 @@ def set_scale( Value of the new scale. """ if len(self.metadata.multiscales) > 1: - raise NotImplementedError( - "Cannot set scale for multi-resolution images." - ) + raise NotImplementedError("Cannot set scale for multi-resolution images.") if new_scale <= 0: raise ValueError("New scale must be positive.") @@ -1078,9 +1065,7 @@ def set_scale( self.zattrs["iohub"] = iohub_dict # Update scale while preserving existing transforms - transforms = ( - self.metadata.multiscales[0].datasets[0].coordinate_transformations - ) + transforms = self.metadata.multiscales[0].datasets[0].coordinate_transformations # Replace default identity transform with scale if len(transforms) == 1 and transforms[0].type == "identity": transforms = [TransformationMeta(type="scale", scale=[1] * 5)] @@ -1206,9 +1191,7 @@ def _parse_meta(self): def dump_meta(self): """Dumps metadata JSON to the `.zattrs` file.""" - self.zattrs.update( - {"well": self.metadata.model_dump(**TO_DICT_SETTINGS)} - ) + self.zattrs.update({"well": self.metadata.model_dump(**TO_DICT_SETTINGS)}) def __getitem__(self, key: str): """Get a position member of the well. @@ -1389,8 +1372,7 @@ def from_positions( for name, src_pos in positions.items(): if not isinstance(src_pos, Position): raise TypeError( - f"Expected item type {type(Position)}, " - f"got {type(src_pos)}" + f"Expected item type {type(Position)}, " f"got {type(src_pos)}" ) name = normalize_storage_path(name) if name in plate.zgroup: @@ -1473,9 +1455,7 @@ def dump_meta(self, field_count: bool = False): """ if field_count: self.metadata.field_count = len(list(self.positions())) - self.zattrs.update( - {"plate": self.metadata.model_dump(**TO_DICT_SETTINGS)} - ) + self.zattrs.update({"plate": self.metadata.model_dump(**TO_DICT_SETTINGS)}) def _auto_idx( self, @@ -1563,9 +1543,7 @@ def create_well( self.metadata.wells.append(well_index_meta) # create new row if needed if row_name not in self: - row_grp = self.zgroup.create_group( - row_meta.name, overwrite=self._overwrite - ) + row_grp = self.zgroup.create_group(row_meta.name, overwrite=self._overwrite) if row_meta not in self.metadata.rows: self.metadata.rows.append(row_meta) else: @@ -1688,9 +1666,9 @@ def rename_well( self.zgroup.move(old, new) # update well metadata - old_well_index = [ - well_name.path for well_name in self.metadata.wells - ].index(old) + old_well_index = [well_name.path for well_name in self.metadata.wells].index( + old + ) self.metadata.wells[old_well_index].path = new new_well_names = [well.path for well in self.metadata.wells] diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index cf0ce4f0..9d5801ce 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -37,16 +37,10 @@ y_dim_st = st.integers(1, 32) x_dim_st = st.integers(1, 32) channel_names_st = c_dim_st.flatmap( - ( - lambda c_dim: st.lists( - short_text_st, min_size=c_dim, max_size=c_dim, unique=True - ) - ) + (lambda c_dim: st.lists(short_text_st, min_size=c_dim, max_size=c_dim, unique=True)) ) short_alpha_numeric = st.text( - alphabet=list( - string.ascii_lowercase + string.ascii_uppercase + string.digits - ), + alphabet=list(string.ascii_lowercase + string.ascii_uppercase + string.digits), min_size=1, max_size=16, ) @@ -90,9 +84,7 @@ def _channels_and_random_5d_shape_and_dtype(draw): @st.composite def _channels_and_random_5d(draw): - channel_names, shape, dtype = draw( - _channels_and_random_5d_shape_and_dtype() - ) + channel_names, shape, dtype = draw(_channels_and_random_5d_shape_and_dtype()) random_5d = draw(npst.arrays(dtype, shape=shape)) return channel_names, random_5d @@ -213,9 +205,7 @@ def _temp_ome_zarr_plate( channel_names=channel_names, ) for position in position_list: - pos = dataset.create_position( - position[0], position[1], position[2] - ) + pos = dataset.create_position(position[0], position[1], position[2]) pos.create_image(arr_name, image_5d, **kwargs) yield dataset finally: @@ -282,13 +272,9 @@ def test_ome_zarr_to_dask(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.data` to dask""" channel_names, random_5d = channels_and_random_5d with _temp_ome_zarr(random_5d, channel_names, "0") as dataset: - assert_array_almost_equal( - dataset.data.dask_array().compute(), random_5d - ) + assert_array_almost_equal(dataset.data.dask_array().compute(), random_5d) with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: - assert_array_almost_equal( - dataset[arr_name].dask_array().compute(), random_5d - ) + assert_array_almost_equal(dataset[arr_name].dask_array().compute(), random_5d) @given( @@ -324,9 +310,7 @@ def test_append_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.append_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr( - random_5d[:, :-1], channel_names[:-1], arr_name - ) as dataset: + with _temp_ome_zarr(random_5d[:, :-1], channel_names[:-1], arr_name) as dataset: dataset.append_channel(channel_names[-1], resize_arrays=True) dataset[arr_name][:, -1] = random_5d[:, -1] assert_array_almost_equal(dataset[arr_name][:], random_5d) @@ -419,16 +403,10 @@ def test_update_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.update_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr( - random_5d[:, :-1], channel_names[:-1], arr_name - ) as dataset: + with _temp_ome_zarr(random_5d[:, :-1], channel_names[:-1], arr_name) as dataset: for i, ch in enumerate(dataset.channel_names): - dataset.update_channel( - chan_name=ch, target=arr_name, data=random_5d[:, -1] - ) - assert_array_almost_equal( - dataset[arr_name][:, i], random_5d[:, -1] - ) + dataset.update_channel(chan_name=ch, target=arr_name, data=random_5d[:, -1]) + assert_array_almost_equal(dataset[arr_name][:, i], random_5d[:, -1]) @given( @@ -467,14 +445,10 @@ def test_set_transform_image(ch_shape_dtype, arr_name): dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) assert dataset.metadata.multiscales[0].datasets[ 0 - ].coordinate_transformations == [ - TransformationMeta(type="identity") - ] + ].coordinate_transformations == [TransformationMeta(type="identity")] dataset.set_transform(image=arr_name, transform=transform) assert ( - dataset.metadata.multiscales[0] - .datasets[0] - .coordinate_transformations + dataset.metadata.multiscales[0].datasets[0].coordinate_transformations == transform ) # read data with an external reader @@ -485,6 +459,57 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ] +@pytest.mark.parametrize( + "transforms", + [ + ( + [TransformationMeta(type="identity")], + TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), + TransformationMeta(type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0)), + ), + ( + [TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0))], + TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), + TransformationMeta(type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0)), + ), + ( + [TransformationMeta(type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0))], + TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), + TransformationMeta(type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0)), + ), + ( + [ + TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), + TransformationMeta(type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0)), + ], + TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), + TransformationMeta(type="translation", translation=(2.0, 2.0, 2.0, 2.0, 2.0)), + ), + ], +) +@given( + ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(), + arr_name=short_alpha_numeric, +) +def test_get_transform_image(transforms, ch_shape_dtype, arr_name): + """Test `iohub.ngff.Position.set_transform()`""" + transform, expected_scale, expected_translate = transforms + channel_names, shape, dtype = ch_shape_dtype + with TemporaryDirectory() as temp_dir: + store_path = os.path.join(temp_dir, "ome.zarr") + with open_ome_zarr( + store_path, layout="fov", mode="w-", channel_names=channel_names + ) as dataset: + dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) + assert dataset.metadata.multiscales[0].datasets[ + 0 + ].coordinate_transformations == [TransformationMeta(type="identity")] + dataset.set_transform(image=arr_name, transform=transform) + scale, translate = dataset.get_transforms(image=arr_name) + assert scale == expected_scale + assert translate == expected_translate + + @given( ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(), arr_name=short_alpha_numeric, @@ -501,15 +526,12 @@ def test_set_transform_fov(ch_shape_dtype, arr_name): store_path, layout="fov", mode="w-", channel_names=channel_names ) as dataset: dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) - assert dataset.metadata.multiscales[ - 0 - ].coordinate_transformations == [ + assert dataset.metadata.multiscales[0].coordinate_transformations == [ TransformationMeta(type="identity") ] dataset.set_transform(image="*", transform=transform) assert ( - dataset.metadata.multiscales[0].coordinate_transformations - == transform + dataset.metadata.multiscales[0].coordinate_transformations == transform ) # read data with plain zarr group = zarr.open(store_path) @@ -593,9 +615,7 @@ def test_make_tiles(channels_and_random_5d, grid_shape, arr_name): grid_shape[-2] * random_5d.shape[-2], grid_shape[-1] * random_5d.shape[-1], ) - assert tiles.tile_shape == _pad_shape( - random_5d.shape[-2:], target=5 - ) + assert tiles.tile_shape == _pad_shape(random_5d.shape[-2:], target=5) assert tiles.dtype == random_5d.dtype for args in ((1.01, 1), (0, 0, 0)): with pytest.raises(TypeError): @@ -724,9 +744,7 @@ def test_get_axis_index(): _ = position.get_axis_index("DOG") -@given( - row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric -) +@given(row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric) @settings(max_examples=16, deadline=2000) def test_modify_hcs_ref(row: str, col: str, pos: str): """Test `iohub.ngff.open_ome_zarr()`""" @@ -759,17 +777,11 @@ def test_create_well(row_names: list[str], col_names: list[str]): for row_name in row_names: for col_name in col_names: dataset.create_well(row_name, col_name) - assert [ - c["name"] for c in dataset.zattrs["plate"]["columns"] - ] == col_names - assert [ - r["name"] for r in dataset.zattrs["plate"]["rows"] - ] == row_names + assert [c["name"] for c in dataset.zattrs["plate"]["columns"]] == col_names + assert [r["name"] for r in dataset.zattrs["plate"]["rows"]] == row_names -@given( - row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric -) +@given(row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric) def test_create_position(row, col, pos): """Test `iohub.ngff.Plate.create_position()`""" with TemporaryDirectory() as temp_dir: @@ -790,9 +802,7 @@ def test_position_scale(channels_and_random_5d): channel_names, random_5d = channels_and_random_5d scale = list(range(1, 6)) transform = [TransformationMeta(type="scale", scale=scale)] - with _temp_ome_zarr( - random_5d, channel_names, "0", transform=transform - ) as dataset: + with _temp_ome_zarr(random_5d, channel_names, "0", transform=transform) as dataset: # round-trip test with the offical reader implementation assert dataset.scale == scale From bd77cd9c05359a5f494c23dfdb433e2538efa90e Mon Sep 17 00:00:00 2001 From: William Patton Date: Mon, 9 Dec 2024 20:35:32 -0800 Subject: [PATCH 02/13] Revert "formatting changes" This reverts the formatting changes that were accidentally included with commit 0c21a7303a5d6fbf6f1da2bc6e7c9a56826163ba. --- iohub/ngff/nodes.py | 127 +++++++++++++++++++++++++++++----------- tests/ngff/test_ngff.py | 81 ++++++++++++++++++------- 2 files changed, 154 insertions(+), 54 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index fa6dec7b..6352f846 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -58,7 +58,9 @@ def _open_store( synchronizer=None, ): if not os.path.isdir(store_path) and mode in ("r", "r+"): - raise FileNotFoundError(f"Dataset directory not found at {store_path}.") + raise FileNotFoundError( + f"Dataset directory not found at {store_path}." + ) if version != "0.4": _logger.warning( "IOHub is only tested against OME-NGFF v0.4. " @@ -68,10 +70,14 @@ def _open_store( else: dimension_separator = "/" try: - store = zarr.DirectoryStore(store_path, dimension_separator=dimension_separator) + store = zarr.DirectoryStore( + store_path, dimension_separator=dimension_separator + ) root = zarr.open_group(store, mode=mode, synchronizer=synchronizer) except Exception as e: - raise RuntimeError(f"Cannot open Zarr root group at {store_path}") from e + raise RuntimeError( + f"Cannot open Zarr root group at {store_path}" + ) from e return root @@ -102,7 +108,9 @@ def __init__( if channel_names: self._channel_names = channel_names elif not parse_meta: - raise ValueError("Channel names need to be provided or in metadata.") + raise ValueError( + "Channel names need to be provided or in metadata." + ) if axes: self.axes = axes self._group = group @@ -533,8 +541,12 @@ def _parse_meta(self): omero = self.zattrs.get("omero") if multiscales and omero: try: - self.metadata = ImagesMeta(multiscales=multiscales, omero=omero) - self._channel_names = [c.label for c in self.metadata.omero.channels] + self.metadata = ImagesMeta( + multiscales=multiscales, omero=omero + ) + self._channel_names = [ + c.label for c in self.metadata.omero.channels + ] self.axes = self.metadata.multiscales[0].axes except ValidationError: self._warn_invalid_meta() @@ -548,7 +560,9 @@ def dump_meta(self): @property def _storage_options(self): return { - "compressor": Blosc(cname="zstd", clevel=1, shuffle=Blosc.BITSHUFFLE), + "compressor": Blosc( + cname="zstd", clevel=1, shuffle=Blosc.BITSHUFFLE + ), "overwrite": self._overwrite, } @@ -585,7 +599,8 @@ def data(self): return self["0"] except KeyError: raise KeyError( - "There is no array named '0' " f"in the group of: {self.array_keys()}" + "There is no array named '0' " + f"in the group of: {self.array_keys()}" ) def __getitem__(self, key: int | str) -> ImageArray: @@ -609,7 +624,9 @@ def __setitem__(self, key, value: NDArray): """Write an up-to-5D image with default settings.""" key = normalize_storage_path(key) if not isinstance(value, np.ndarray): - raise TypeError(f"Value must be a NumPy array. Got type {type(value)}.") + raise TypeError( + f"Value must be a NumPy array. Got type {type(value)}." + ) self.create_image(key, value) def images(self) -> Generator[tuple[str, ImageArray]]: @@ -659,7 +676,9 @@ def create_image( if check_shape: self._check_shape(data.shape) img_arr = ImageArray( - self._group.array(name, data, chunks=chunks, **self._storage_options) + self._group.array( + name, data, chunks=chunks, **self._storage_options + ) ) self._create_image_meta(img_arr.basename, transform=transform) return img_arr @@ -742,7 +761,8 @@ def _check_shape(self, data_shape: tuple[int]): _logger.warning(msg) else: _logger.info( - "Dataset channel axis is not set. " "Skipping channel shape check." + "Dataset channel axis is not set. " + "Skipping channel shape check." ) def _create_image_meta( @@ -753,7 +773,9 @@ def _create_image_meta( ): if not transform: transform = [TransformationMeta(type="identity")] - dataset_meta = DatasetMeta(path=name, coordinate_transformations=transform) + dataset_meta = DatasetMeta( + path=name, coordinate_transformations=transform + ) if not hasattr(self, "metadata"): self.metadata = ImagesMeta( multiscales=[ @@ -762,13 +784,18 @@ def _create_image_meta( axes=self.axes, datasets=[dataset_meta], name=name, - coordinateTransformations=[TransformationMeta(type="identity")], + coordinateTransformations=[ + TransformationMeta(type="identity") + ], metadata=extra_meta, ) ], omero=self._omero_meta(id=0, name=self._group.basename), ) - elif dataset_meta.path not in self.metadata.multiscales[0].get_dataset_paths(): + elif ( + dataset_meta.path + not in self.metadata.multiscales[0].get_dataset_paths() + ): self.metadata.multiscales[0].datasets.append(dataset_meta) self.dump_meta() @@ -781,11 +808,15 @@ def _omero_meta( if not clims: clims = [None] * len(self.channel_names) channels = [] - for i, (channel_name, clim) in enumerate(zip(self.channel_names, clims)): + for i, (channel_name, clim) in enumerate( + zip(self.channel_names, clims) + ): if i == 0: first_chan = True channels.append( - channel_display_settings(channel_name, clim=clim, first_chan=first_chan) + channel_display_settings( + channel_name, clim=clim, first_chan=first_chan + ) ) omero_meta = OMEROMeta( version=self.version, @@ -805,7 +836,8 @@ def _find_axis(self, axis_type): def _get_channel_axis(self): if (ch_ax := self._find_axis("channel")) is None: raise KeyError( - "Axis 'channel' does not exist. " "Please update `self.axes` first." + "Axis 'channel' does not exist. " + "Please update `self.axes` first." ) else: return ch_ax @@ -834,10 +866,14 @@ def append_channel(self, chan_name: str, resize_arrays: bool = True): elif ch_ax == len(shape): shape = _pad_shape(tuple(shape), target=len(shape) + 1) else: - raise IndexError(f"Cannot infer channel axis for shape {shape}.") + raise IndexError( + f"Cannot infer channel axis for shape {shape}." + ) img.resize(shape) if "omero" in self.metadata.model_dump().keys(): - self.metadata.omero.channels.append(channel_display_settings(chan_name)) + self.metadata.omero.channels.append( + channel_display_settings(chan_name) + ) self.dump_meta() def rename_channel(self, old: str, new: str): @@ -901,12 +937,18 @@ def initialize_pyramid(self, levels: int) -> None: for level in range(1, levels): factor = 2**level - shape = array.shape[:-3] + _scale_integers(array.shape[-3:], factor) + shape = array.shape[:-3] + _scale_integers( + array.shape[-3:], factor + ) - chunks = _pad_shape(_scale_integers(array.chunks, factor), len(shape)) + chunks = _pad_shape( + _scale_integers(array.chunks, factor), len(shape) + ) transforms = deepcopy( - self.metadata.multiscales[0].datasets[0].coordinate_transformations + self.metadata.multiscales[0] + .datasets[0] + .coordinate_transformations ) for tr in transforms: if tr.type == "scale": @@ -928,7 +970,9 @@ def scale(self) -> list[float]: highest resolution scale. """ scale = [1] * self.data.ndim - transforms = self.metadata.multiscales[0].datasets[0].coordinate_transformations + transforms = ( + self.metadata.multiscales[0].datasets[0].coordinate_transformations + ) for trans in transforms: if trans.type == "scale": if len(trans.scale) != len(scale): @@ -946,7 +990,9 @@ def axis_names(self) -> list[str]: Returns lowercase axis names. """ - return [axis.name.lower() for axis in self.metadata.multiscales[0].axes] + return [ + axis.name.lower() for axis in self.metadata.multiscales[0].axes + ] def get_axis_index(self, axis_name: str) -> int: """ @@ -1021,7 +1067,9 @@ def set_transform( if image == "*": self.metadata.multiscales[0].coordinate_transformations = transform elif image in self: - for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): + for i, dataset_meta in enumerate( + self.metadata.multiscales[0].datasets + ): if dataset_meta.path == image: self.metadata.multiscales[0].datasets[i] = DatasetMeta( path=image, coordinate_transformations=transform @@ -1050,7 +1098,9 @@ def set_scale( Value of the new scale. """ if len(self.metadata.multiscales) > 1: - raise NotImplementedError("Cannot set scale for multi-resolution images.") + raise NotImplementedError( + "Cannot set scale for multi-resolution images." + ) if new_scale <= 0: raise ValueError("New scale must be positive.") @@ -1065,7 +1115,9 @@ def set_scale( self.zattrs["iohub"] = iohub_dict # Update scale while preserving existing transforms - transforms = self.metadata.multiscales[0].datasets[0].coordinate_transformations + transforms = ( + self.metadata.multiscales[0].datasets[0].coordinate_transformations + ) # Replace default identity transform with scale if len(transforms) == 1 and transforms[0].type == "identity": transforms = [TransformationMeta(type="scale", scale=[1] * 5)] @@ -1191,7 +1243,9 @@ def _parse_meta(self): def dump_meta(self): """Dumps metadata JSON to the `.zattrs` file.""" - self.zattrs.update({"well": self.metadata.model_dump(**TO_DICT_SETTINGS)}) + self.zattrs.update( + {"well": self.metadata.model_dump(**TO_DICT_SETTINGS)} + ) def __getitem__(self, key: str): """Get a position member of the well. @@ -1372,7 +1426,8 @@ def from_positions( for name, src_pos in positions.items(): if not isinstance(src_pos, Position): raise TypeError( - f"Expected item type {type(Position)}, " f"got {type(src_pos)}" + f"Expected item type {type(Position)}, " + f"got {type(src_pos)}" ) name = normalize_storage_path(name) if name in plate.zgroup: @@ -1455,7 +1510,9 @@ def dump_meta(self, field_count: bool = False): """ if field_count: self.metadata.field_count = len(list(self.positions())) - self.zattrs.update({"plate": self.metadata.model_dump(**TO_DICT_SETTINGS)}) + self.zattrs.update( + {"plate": self.metadata.model_dump(**TO_DICT_SETTINGS)} + ) def _auto_idx( self, @@ -1543,7 +1600,9 @@ def create_well( self.metadata.wells.append(well_index_meta) # create new row if needed if row_name not in self: - row_grp = self.zgroup.create_group(row_meta.name, overwrite=self._overwrite) + row_grp = self.zgroup.create_group( + row_meta.name, overwrite=self._overwrite + ) if row_meta not in self.metadata.rows: self.metadata.rows.append(row_meta) else: @@ -1666,9 +1725,9 @@ def rename_well( self.zgroup.move(old, new) # update well metadata - old_well_index = [well_name.path for well_name in self.metadata.wells].index( - old - ) + old_well_index = [ + well_name.path for well_name in self.metadata.wells + ].index(old) self.metadata.wells[old_well_index].path = new new_well_names = [well.path for well in self.metadata.wells] diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 9d5801ce..48cfddfb 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -37,10 +37,16 @@ y_dim_st = st.integers(1, 32) x_dim_st = st.integers(1, 32) channel_names_st = c_dim_st.flatmap( - (lambda c_dim: st.lists(short_text_st, min_size=c_dim, max_size=c_dim, unique=True)) + ( + lambda c_dim: st.lists( + short_text_st, min_size=c_dim, max_size=c_dim, unique=True + ) + ) ) short_alpha_numeric = st.text( - alphabet=list(string.ascii_lowercase + string.ascii_uppercase + string.digits), + alphabet=list( + string.ascii_lowercase + string.ascii_uppercase + string.digits + ), min_size=1, max_size=16, ) @@ -84,7 +90,9 @@ def _channels_and_random_5d_shape_and_dtype(draw): @st.composite def _channels_and_random_5d(draw): - channel_names, shape, dtype = draw(_channels_and_random_5d_shape_and_dtype()) + channel_names, shape, dtype = draw( + _channels_and_random_5d_shape_and_dtype() + ) random_5d = draw(npst.arrays(dtype, shape=shape)) return channel_names, random_5d @@ -205,7 +213,9 @@ def _temp_ome_zarr_plate( channel_names=channel_names, ) for position in position_list: - pos = dataset.create_position(position[0], position[1], position[2]) + pos = dataset.create_position( + position[0], position[1], position[2] + ) pos.create_image(arr_name, image_5d, **kwargs) yield dataset finally: @@ -272,9 +282,13 @@ def test_ome_zarr_to_dask(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.data` to dask""" channel_names, random_5d = channels_and_random_5d with _temp_ome_zarr(random_5d, channel_names, "0") as dataset: - assert_array_almost_equal(dataset.data.dask_array().compute(), random_5d) + assert_array_almost_equal( + dataset.data.dask_array().compute(), random_5d + ) with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: - assert_array_almost_equal(dataset[arr_name].dask_array().compute(), random_5d) + assert_array_almost_equal( + dataset[arr_name].dask_array().compute(), random_5d + ) @given( @@ -310,7 +324,9 @@ def test_append_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.append_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr(random_5d[:, :-1], channel_names[:-1], arr_name) as dataset: + with _temp_ome_zarr( + random_5d[:, :-1], channel_names[:-1], arr_name + ) as dataset: dataset.append_channel(channel_names[-1], resize_arrays=True) dataset[arr_name][:, -1] = random_5d[:, -1] assert_array_almost_equal(dataset[arr_name][:], random_5d) @@ -403,10 +419,16 @@ def test_update_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.update_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr(random_5d[:, :-1], channel_names[:-1], arr_name) as dataset: + with _temp_ome_zarr( + random_5d[:, :-1], channel_names[:-1], arr_name + ) as dataset: for i, ch in enumerate(dataset.channel_names): - dataset.update_channel(chan_name=ch, target=arr_name, data=random_5d[:, -1]) - assert_array_almost_equal(dataset[arr_name][:, i], random_5d[:, -1]) + dataset.update_channel( + chan_name=ch, target=arr_name, data=random_5d[:, -1] + ) + assert_array_almost_equal( + dataset[arr_name][:, i], random_5d[:, -1] + ) @given( @@ -445,10 +467,14 @@ def test_set_transform_image(ch_shape_dtype, arr_name): dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) assert dataset.metadata.multiscales[0].datasets[ 0 - ].coordinate_transformations == [TransformationMeta(type="identity")] + ].coordinate_transformations == [ + TransformationMeta(type="identity") + ] dataset.set_transform(image=arr_name, transform=transform) assert ( - dataset.metadata.multiscales[0].datasets[0].coordinate_transformations + dataset.metadata.multiscales[0] + .datasets[0] + .coordinate_transformations == transform ) # read data with an external reader @@ -526,12 +552,15 @@ def test_set_transform_fov(ch_shape_dtype, arr_name): store_path, layout="fov", mode="w-", channel_names=channel_names ) as dataset: dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) - assert dataset.metadata.multiscales[0].coordinate_transformations == [ + assert dataset.metadata.multiscales[ + 0 + ].coordinate_transformations == [ TransformationMeta(type="identity") ] dataset.set_transform(image="*", transform=transform) assert ( - dataset.metadata.multiscales[0].coordinate_transformations == transform + dataset.metadata.multiscales[0].coordinate_transformations + == transform ) # read data with plain zarr group = zarr.open(store_path) @@ -615,7 +644,9 @@ def test_make_tiles(channels_and_random_5d, grid_shape, arr_name): grid_shape[-2] * random_5d.shape[-2], grid_shape[-1] * random_5d.shape[-1], ) - assert tiles.tile_shape == _pad_shape(random_5d.shape[-2:], target=5) + assert tiles.tile_shape == _pad_shape( + random_5d.shape[-2:], target=5 + ) assert tiles.dtype == random_5d.dtype for args in ((1.01, 1), (0, 0, 0)): with pytest.raises(TypeError): @@ -744,7 +775,9 @@ def test_get_axis_index(): _ = position.get_axis_index("DOG") -@given(row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric) +@given( + row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric +) @settings(max_examples=16, deadline=2000) def test_modify_hcs_ref(row: str, col: str, pos: str): """Test `iohub.ngff.open_ome_zarr()`""" @@ -777,11 +810,17 @@ def test_create_well(row_names: list[str], col_names: list[str]): for row_name in row_names: for col_name in col_names: dataset.create_well(row_name, col_name) - assert [c["name"] for c in dataset.zattrs["plate"]["columns"]] == col_names - assert [r["name"] for r in dataset.zattrs["plate"]["rows"]] == row_names + assert [ + c["name"] for c in dataset.zattrs["plate"]["columns"] + ] == col_names + assert [ + r["name"] for r in dataset.zattrs["plate"]["rows"] + ] == row_names -@given(row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric) +@given( + row=short_alpha_numeric, col=short_alpha_numeric, pos=short_alpha_numeric +) def test_create_position(row, col, pos): """Test `iohub.ngff.Plate.create_position()`""" with TemporaryDirectory() as temp_dir: @@ -802,7 +841,9 @@ def test_position_scale(channels_and_random_5d): channel_names, random_5d = channels_and_random_5d scale = list(range(1, 6)) transform = [TransformationMeta(type="scale", scale=scale)] - with _temp_ome_zarr(random_5d, channel_names, "0", transform=transform) as dataset: + with _temp_ome_zarr( + random_5d, channel_names, "0", transform=transform + ) as dataset: # round-trip test with the offical reader implementation assert dataset.scale == scale From 6a490ff98e0f3d02ff4c798fe27e6c9646ac8da5 Mon Sep 17 00:00:00 2001 From: William Patton Date: Tue, 10 Dec 2024 12:29:42 -0800 Subject: [PATCH 03/13] address ziwen's comments --- iohub/ngff/nodes.py | 55 ++++++++++++++++++++++++++++++++--------- tests/ngff/test_ngff.py | 31 +++++++++++++++++------ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 6352f846..c161fbf9 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -1010,20 +1010,22 @@ def get_axis_index(self, axis_name: str) -> int: """ return self.axis_names.index(axis_name.lower()) - def get_transforms( + def get_effective_scale( self, image: str | Literal["*"], - ) -> tuple[TransformationMeta, TransformationMeta]: - """Get the total coordinate scale and translation metadata + ) -> TransformationMeta: + """Get the total coordinate scale metadata for one image array or the whole FOV. Parameters ---------- image : str | Literal["*"] - Name of one image array (e.g. "0") to transform, + Name of one image array (e.g. "0") to query, or "*" for the whole FOV """ - transforms: list[TransformationMeta] = self.metadata.multiscales[0].coordinate_transformations + transforms: list[TransformationMeta] = [t for t in self.metadata.multiscales[ + 0 + ].coordinate_transformations] if image != "*" and image in self: for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): if dataset_meta.path == image: @@ -1035,17 +1037,48 @@ def get_transforms( elif image != "*": raise ValueError(f"Key {image} not recognized.") - full_scale = np.array([1]*len(self.axes), dtype=float) - full_translation = np.array([0]*len(self.axes), dtype=float) + full_scale = np.ones(len(self.axes), dtype=float) for transform in transforms: if transform.type == "scale": full_scale *= np.array(transform.scale) - elif transform.type == "translation": - full_translation += full_scale * np.array(transform.translation) + + return TransformationMeta(type="scale", scale=tuple(full_scale)) + + def get_effective_translation( + self, + image: str | Literal["*"], + ) -> TransformationMeta: + """Get the total coordinate translation metadata + for one image array or the whole FOV. + + Parameters + ---------- + image : str | Literal["*"] + Name of one image array (e.g. "0") to query, + or "*" for the whole FOV + """ + transforms: list[TransformationMeta] = [ + t for t in self.metadata.multiscales[0].coordinate_transformations + ] + if image != "*" and image in self: + for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): + if dataset_meta.path == image: + transforms.extend( + self.metadata.multiscales[0] + .datasets[i] + .coordinate_transformations + ) + elif image != "*": + raise ValueError(f"Key {image} not recognized.") + + full_translation = np.zeros(len(self.axes), dtype=float) + for transform in transforms: + if transform.type == "translation": + full_translation += np.array(transform.translation) return TransformationMeta( - type="scale", scale=tuple(full_scale) - ), TransformationMeta(type="translation", translation=tuple(full_translation)) + type="translation", translation=tuple(full_translation) + ) def set_transform( self, diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 48cfddfb..7ef20b7a 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -491,25 +491,39 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ( [TransformationMeta(type="identity")], TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), - TransformationMeta(type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0)), + TransformationMeta( + type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) + ), ), ( [TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0))], TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), - TransformationMeta(type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0)), + TransformationMeta( + type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) + ), ), ( - [TransformationMeta(type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0))], + [ + TransformationMeta( + type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) + ) + ], TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), - TransformationMeta(type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0)), + TransformationMeta( + type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) + ), ), ( [ TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), - TransformationMeta(type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0)), + TransformationMeta( + type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) + ), ], TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), - TransformationMeta(type="translation", translation=(2.0, 2.0, 2.0, 2.0, 2.0)), + TransformationMeta( + type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) + ), ), ], ) @@ -531,7 +545,10 @@ def test_get_transform_image(transforms, ch_shape_dtype, arr_name): 0 ].coordinate_transformations == [TransformationMeta(type="identity")] dataset.set_transform(image=arr_name, transform=transform) - scale, translate = dataset.get_transforms(image=arr_name) + scale, translate = ( + dataset.get_effective_scale(image=arr_name), + dataset.get_effective_translation(image=arr_name), + ) assert scale == expected_scale assert translate == expected_translate From fc31a1098cfcb6f0b97153e91266ce7326189079 Mon Sep 17 00:00:00 2001 From: William Patton Date: Tue, 10 Dec 2024 12:31:32 -0800 Subject: [PATCH 04/13] black formatting changes --- iohub/ngff/nodes.py | 14 +++++++++----- tests/ngff/test_ngff.py | 14 +++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index c161fbf9..359ee262 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -1023,11 +1023,13 @@ def get_effective_scale( Name of one image array (e.g. "0") to query, or "*" for the whole FOV """ - transforms: list[TransformationMeta] = [t for t in self.metadata.multiscales[ - 0 - ].coordinate_transformations] + transforms: list[TransformationMeta] = [ + t for t in self.metadata.multiscales[0].coordinate_transformations + ] if image != "*" and image in self: - for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): + for i, dataset_meta in enumerate( + self.metadata.multiscales[0].datasets + ): if dataset_meta.path == image: transforms.extend( self.metadata.multiscales[0] @@ -1061,7 +1063,9 @@ def get_effective_translation( t for t in self.metadata.multiscales[0].coordinate_transformations ] if image != "*" and image in self: - for i, dataset_meta in enumerate(self.metadata.multiscales[0].datasets): + for i, dataset_meta in enumerate( + self.metadata.multiscales[0].datasets + ): if dataset_meta.path == image: transforms.extend( self.metadata.multiscales[0] diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 7ef20b7a..293c37ac 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -496,7 +496,11 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ), ), ( - [TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0))], + [ + TransformationMeta( + type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0) + ) + ], TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), TransformationMeta( type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) @@ -515,7 +519,9 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ), ( [ - TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), + TransformationMeta( + type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0) + ), TransformationMeta( type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) ), @@ -543,7 +549,9 @@ def test_get_transform_image(transforms, ch_shape_dtype, arr_name): dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) assert dataset.metadata.multiscales[0].datasets[ 0 - ].coordinate_transformations == [TransformationMeta(type="identity")] + ].coordinate_transformations == [ + TransformationMeta(type="identity") + ] dataset.set_transform(image=arr_name, transform=transform) scale, translate = ( dataset.get_effective_scale(image=arr_name), From d13942ca67a2306baf9c1b6e418c749caf485ba4 Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 10:47:40 -0800 Subject: [PATCH 05/13] update test_get_transform_image test name --- tests/ngff/test_ngff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 293c37ac..3b3ba1eb 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -537,7 +537,7 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(), arr_name=short_alpha_numeric, ) -def test_get_transform_image(transforms, ch_shape_dtype, arr_name): +def test_get_effective_transforms_image(transforms, ch_shape_dtype, arr_name): """Test `iohub.ngff.Position.set_transform()`""" transform, expected_scale, expected_translate = transforms channel_names, shape, dtype = ch_shape_dtype From 67848bc196a9ba822e618d7c239b01e82ab8d562 Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 10:53:08 -0800 Subject: [PATCH 06/13] factor out retrieving the list of all applicable transforms and improve docs --- iohub/ngff/nodes.py | 61 +++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 359ee262..effee389 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -1010,11 +1010,10 @@ def get_axis_index(self, axis_name: str) -> int: """ return self.axis_names.index(axis_name.lower()) - def get_effective_scale( - self, - image: str | Literal["*"], - ) -> TransformationMeta: - """Get the total coordinate scale metadata + def _get_all_transforms( + self, image: str | Literal["*"] + ) -> list[TransformationMeta]: + """Get all transforms metadata for one image array or the whole FOV. Parameters @@ -1022,6 +1021,11 @@ def get_effective_scale( image : str | Literal["*"] Name of one image array (e.g. "0") to query, or "*" for the whole FOV + + Returns + ------- + list[TransformationMeta] + All transforms applicable to this image or FOV. """ transforms: list[TransformationMeta] = [ t for t in self.metadata.multiscales[0].coordinate_transformations @@ -1038,6 +1042,28 @@ def get_effective_scale( ) elif image != "*": raise ValueError(f"Key {image} not recognized.") + return transforms + + def get_effective_scale( + self, + image: str | Literal["*"], + ) -> TransformationMeta: + """Get the effective coordinate scale metadata + for one image array or the whole FOV. + + Parameters + ---------- + image : str | Literal["*"] + Name of one image array (e.g. "0") to query, + or "*" for the whole FOV + + Returns + ------- + TransformationMeta + A single TransformationMeta object with the total + scale factor for the image or FOV. + """ + transforms = self._get_all_transforms(image) full_scale = np.ones(len(self.axes), dtype=float) for transform in transforms: @@ -1050,7 +1076,7 @@ def get_effective_translation( self, image: str | Literal["*"], ) -> TransformationMeta: - """Get the total coordinate translation metadata + """Get the effective coordinate translation metadata for one image array or the whole FOV. Parameters @@ -1058,23 +1084,14 @@ def get_effective_translation( image : str | Literal["*"] Name of one image array (e.g. "0") to query, or "*" for the whole FOV - """ - transforms: list[TransformationMeta] = [ - t for t in self.metadata.multiscales[0].coordinate_transformations - ] - if image != "*" and image in self: - for i, dataset_meta in enumerate( - self.metadata.multiscales[0].datasets - ): - if dataset_meta.path == image: - transforms.extend( - self.metadata.multiscales[0] - .datasets[i] - .coordinate_transformations - ) - elif image != "*": - raise ValueError(f"Key {image} not recognized.") + Returns + ------- + TransformationMeta + A single TransformationMeta object with the total + translation vector for the image or FOV. + """ + transforms = self._get_all_transforms(image) full_translation = np.zeros(len(self.axes), dtype=float) for transform in transforms: if transform.type == "translation": From 6fc5ad6616eeccc49d14dd84c60d616429abc9e3 Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 10:54:52 -0800 Subject: [PATCH 07/13] use get_effective_scale to simplify the scale property --- iohub/ngff/nodes.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index effee389..6452170c 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -969,19 +969,7 @@ def scale(self) -> list[float]: Helper function for scale transform metadata of highest resolution scale. """ - scale = [1] * self.data.ndim - transforms = ( - self.metadata.multiscales[0].datasets[0].coordinate_transformations - ) - for trans in transforms: - if trans.type == "scale": - if len(trans.scale) != len(scale): - raise RuntimeError( - f"Length of scale transformation {len(trans.scale)} " - f"does not match data dimension {len(scale)}." - ) - scale = [s1 * s2 for s1, s2 in zip(scale, trans.scale)] - return scale + return self.get_effective_scale("*").scale @property def axis_names(self) -> list[str]: From d3a8b43ab4bb501bc655f856ae67af322bcd0420 Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 12:03:31 -0800 Subject: [PATCH 08/13] use the first array's path in `scale` property to get the highest resolution image metadata --- iohub/ngff/nodes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 6452170c..49778c31 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -969,7 +969,9 @@ def scale(self) -> list[float]: Helper function for scale transform metadata of highest resolution scale. """ - return self.get_effective_scale("*").scale + return self.get_effective_scale( + self.metadata.multiscales[0].datasets[0].path + ).scale @property def axis_names(self) -> list[str]: From e3f6ea188cedcf9498a8f9a96c1d2f46f1e217d4 Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 12:25:04 -0800 Subject: [PATCH 09/13] Separate test into 2, and cover cases where both the fov and image have scale and translation transforms --- tests/ngff/test_ngff.py | 130 ++++++++++++++++++++++++---------------- 1 file changed, 78 insertions(+), 52 deletions(-) diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 3b3ba1eb..9eb0a634 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -485,61 +485,68 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ] -@pytest.mark.parametrize( - "transforms", - [ - ( - [TransformationMeta(type="identity")], - TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), - TransformationMeta( - type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) - ), - ), - ( - [ - TransformationMeta( - type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0) - ) - ], - TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), - TransformationMeta( - type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) - ), - ), - ( - [ - TransformationMeta( - type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) - ) - ], - TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), +input_transformations = [ + ([TransformationMeta(type="identity")], []), + ([TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0))], []), + ( + [ TransformationMeta( type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) + ) + ], + [], + ), + ( + [ + TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), + TransformationMeta( + type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) ), - ), - ( - [ - TransformationMeta( - type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0) - ), - TransformationMeta( - type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) - ), - ], + ], + [ TransformationMeta(type="scale", scale=(2.0, 2.0, 2.0, 2.0, 2.0)), TransformationMeta( type="translation", translation=(1.0, 1.0, 1.0, 1.0, 1.0) ), - ), + ], + ), +] +target_scales = [ + TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), + TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), + TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), + TransformationMeta(type="scale", scale=(4.0, 4.0, 4.0, 4.0, 4.0)), +] +target_translations = [ + TransformationMeta( + type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) + ), + TransformationMeta( + type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) + ), + TransformationMeta( + type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) + ), + TransformationMeta( + type="translation", translation=(2.0, 2.0, 2.0, 2.0, 2.0) + ), +] + + +@pytest.mark.parametrize( + "transforms", + [ + (saved, target) + for saved, target in zip(input_transformations, target_scales) ], ) @given( ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(), arr_name=short_alpha_numeric, ) -def test_get_effective_transforms_image(transforms, ch_shape_dtype, arr_name): +def test_get_effective_scale_image(transforms, ch_shape_dtype, arr_name): """Test `iohub.ngff.Position.set_transform()`""" - transform, expected_scale, expected_translate = transforms + (fov_transform, img_transform), expected_scale = transforms channel_names, shape, dtype = ch_shape_dtype with TemporaryDirectory() as temp_dir: store_path = os.path.join(temp_dir, "ome.zarr") @@ -547,18 +554,37 @@ def test_get_effective_transforms_image(transforms, ch_shape_dtype, arr_name): store_path, layout="fov", mode="w-", channel_names=channel_names ) as dataset: dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) - assert dataset.metadata.multiscales[0].datasets[ - 0 - ].coordinate_transformations == [ - TransformationMeta(type="identity") - ] - dataset.set_transform(image=arr_name, transform=transform) - scale, translate = ( - dataset.get_effective_scale(image=arr_name), - dataset.get_effective_translation(image=arr_name), - ) + dataset.set_transform(image="*", transform=fov_transform) + dataset.set_transform(image=arr_name, transform=img_transform) + scale = dataset.get_effective_scale(image=arr_name) assert scale == expected_scale - assert translate == expected_translate + + +@pytest.mark.parametrize( + "transforms", + [ + (saved, target) + for saved, target in zip(input_transformations, target_translations) + ], +) +@given( + ch_shape_dtype=_channels_and_random_5d_shape_and_dtype(), + arr_name=short_alpha_numeric, +) +def test_get_effective_translation_image(transforms, ch_shape_dtype, arr_name): + """Test `iohub.ngff.Position.set_transform()`""" + (fov_transform, img_transform), expected_translation = transforms + channel_names, shape, dtype = ch_shape_dtype + with TemporaryDirectory() as temp_dir: + store_path = os.path.join(temp_dir, "ome.zarr") + with open_ome_zarr( + store_path, layout="fov", mode="w-", channel_names=channel_names + ) as dataset: + dataset.create_zeros(name=arr_name, shape=shape, dtype=dtype) + dataset.set_transform(image="*", transform=fov_transform) + dataset.set_transform(image=arr_name, transform=img_transform) + translation = dataset.get_effective_translation(image=arr_name) + assert translation == expected_translation @given( From c96a0ccbce7442b18ca28a6fd27ef82206f922ca Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 12:27:24 -0800 Subject: [PATCH 10/13] Return effective scale and transform as simple lists of floats instead of TransformationMeta --- iohub/ngff/nodes.py | 22 ++++++++++------------ tests/ngff/test_ngff.py | 24 ++++++++---------------- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 49778c31..8089a211 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -971,7 +971,7 @@ def scale(self) -> list[float]: """ return self.get_effective_scale( self.metadata.multiscales[0].datasets[0].path - ).scale + ) @property def axis_names(self) -> list[str]: @@ -1037,7 +1037,7 @@ def _get_all_transforms( def get_effective_scale( self, image: str | Literal["*"], - ) -> TransformationMeta: + ) -> list[float]: """Get the effective coordinate scale metadata for one image array or the whole FOV. @@ -1049,9 +1049,9 @@ def get_effective_scale( Returns ------- - TransformationMeta - A single TransformationMeta object with the total - scale factor for the image or FOV. + list[float] + A list of floats representing the total scale + for the image or FOV for each axis. """ transforms = self._get_all_transforms(image) @@ -1060,7 +1060,7 @@ def get_effective_scale( if transform.type == "scale": full_scale *= np.array(transform.scale) - return TransformationMeta(type="scale", scale=tuple(full_scale)) + return list(full_scale) def get_effective_translation( self, @@ -1077,9 +1077,9 @@ def get_effective_translation( Returns ------- - TransformationMeta - A single TransformationMeta object with the total - translation vector for the image or FOV. + list[float] + A list of floats representing the total translation + for the image or FOV for each axis. """ transforms = self._get_all_transforms(image) full_translation = np.zeros(len(self.axes), dtype=float) @@ -1087,9 +1087,7 @@ def get_effective_translation( if transform.type == "translation": full_translation += np.array(transform.translation) - return TransformationMeta( - type="translation", translation=tuple(full_translation) - ) + return list(full_translation) def set_transform( self, diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 9eb0a634..a9705f09 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -512,24 +512,16 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ), ] target_scales = [ - TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), - TransformationMeta(type="scale", scale=(1.0, 2.0, 3.0, 4.0, 5.0)), - TransformationMeta(type="scale", scale=(1.0, 1.0, 1.0, 1.0, 1.0)), - TransformationMeta(type="scale", scale=(4.0, 4.0, 4.0, 4.0, 4.0)), + (1.0, 1.0, 1.0, 1.0, 1.0), + (1.0, 2.0, 3.0, 4.0, 5.0), + (1.0, 1.0, 1.0, 1.0, 1.0), + (4.0, 4.0, 4.0, 4.0, 4.0), ] target_translations = [ - TransformationMeta( - type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) - ), - TransformationMeta( - type="translation", translation=(0.0, 0.0, 0.0, 0.0, 0.0) - ), - TransformationMeta( - type="translation", translation=(1.0, 2.0, 3.0, 4.0, 5.0) - ), - TransformationMeta( - type="translation", translation=(2.0, 2.0, 2.0, 2.0, 2.0) - ), + (0.0, 0.0, 0.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 0.0, 0.0), + (1.0, 2.0, 3.0, 4.0, 5.0), + (2.0, 2.0, 2.0, 2.0, 2.0), ] From a50240755020d6ee1988112edd0719aabfac8e7a Mon Sep 17 00:00:00 2001 From: William Patton Date: Wed, 11 Dec 2024 12:31:57 -0800 Subject: [PATCH 11/13] Return plain floats instead of numpy floats --- iohub/ngff/nodes.py | 4 ++-- tests/ngff/test_ngff.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 8089a211..f05adbcf 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -1060,7 +1060,7 @@ def get_effective_scale( if transform.type == "scale": full_scale *= np.array(transform.scale) - return list(full_scale) + return [float(x) for x in full_scale] def get_effective_translation( self, @@ -1087,7 +1087,7 @@ def get_effective_translation( if transform.type == "translation": full_translation += np.array(transform.translation) - return list(full_translation) + return [float(x) for x in full_translation] def set_transform( self, diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index a9705f09..837c4ddb 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -512,16 +512,16 @@ def test_set_transform_image(ch_shape_dtype, arr_name): ), ] target_scales = [ - (1.0, 1.0, 1.0, 1.0, 1.0), - (1.0, 2.0, 3.0, 4.0, 5.0), - (1.0, 1.0, 1.0, 1.0, 1.0), - (4.0, 4.0, 4.0, 4.0, 4.0), + [1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 2.0, 3.0, 4.0, 5.0], + [1.0, 1.0, 1.0, 1.0, 1.0], + [4.0, 4.0, 4.0, 4.0, 4.0], ] target_translations = [ - (0.0, 0.0, 0.0, 0.0, 0.0), - (0.0, 0.0, 0.0, 0.0, 0.0), - (1.0, 2.0, 3.0, 4.0, 5.0), - (2.0, 2.0, 2.0, 2.0, 2.0), + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 2.0, 3.0, 4.0, 5.0], + [2.0, 2.0, 2.0, 2.0, 2.0], ] From 49fe3ae6fd928b6f161166fa0caac9b398142c18 Mon Sep 17 00:00:00 2001 From: William Patton Date: Thu, 19 Dec 2024 09:18:36 -0500 Subject: [PATCH 12/13] fix test docstring --- tests/ngff/test_ngff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index 837c4ddb..1e3ff9e5 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -537,7 +537,7 @@ def test_set_transform_image(ch_shape_dtype, arr_name): arr_name=short_alpha_numeric, ) def test_get_effective_scale_image(transforms, ch_shape_dtype, arr_name): - """Test `iohub.ngff.Position.set_transform()`""" + """Test `iohub.ngff.Position.get_effective_scale()`""" (fov_transform, img_transform), expected_scale = transforms channel_names, shape, dtype = ch_shape_dtype with TemporaryDirectory() as temp_dir: @@ -564,7 +564,7 @@ def test_get_effective_scale_image(transforms, ch_shape_dtype, arr_name): arr_name=short_alpha_numeric, ) def test_get_effective_translation_image(transforms, ch_shape_dtype, arr_name): - """Test `iohub.ngff.Position.set_transform()`""" + """Test `iohub.ngff.Position.get_effective_translation()`""" (fov_transform, img_transform), expected_translation = transforms channel_names, shape, dtype = ch_shape_dtype with TemporaryDirectory() as temp_dir: From c3b258b8b6aaf635797dc91f70e581671acea301 Mon Sep 17 00:00:00 2001 From: William Patton Date: Thu, 19 Dec 2024 09:26:03 -0500 Subject: [PATCH 13/13] Handle case where `self.metadata.multiscales[0].coordinate_transformations` is `None` --- iohub/ngff/nodes.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index f05adbcf..e96a1276 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -1017,9 +1017,17 @@ def _get_all_transforms( list[TransformationMeta] All transforms applicable to this image or FOV. """ - transforms: list[TransformationMeta] = [ - t for t in self.metadata.multiscales[0].coordinate_transformations - ] + transforms: list[TransformationMeta] = ( + [ + t + for t in self.metadata.multiscales[ + 0 + ].coordinate_transformations + ] + if self.metadata.multiscales[0].coordinate_transformations + is not None + else [] + ) if image != "*" and image in self: for i, dataset_meta in enumerate( self.metadata.multiscales[0].datasets