diff --git a/.gitignore b/.gitignore index 39d7dd00..20ab3635 100644 --- a/.gitignore +++ b/.gitignore @@ -45,10 +45,7 @@ uv.lock profile.speedscope.json # test datasets (e.g. Xenium ones) -# symlinks -data # data folder data/ tests/data .venv -.uv.lock diff --git a/src/spatialdata_io/readers/macsima.py b/src/spatialdata_io/readers/macsima.py index 40333960..ac5c5c44 100644 --- a/src/spatialdata_io/readers/macsima.py +++ b/src/spatialdata_io/readers/macsima.py @@ -126,12 +126,11 @@ def from_paths( ), ) imgs = [imread(img, **imread_kwargs) for img in valid_files] - for img, path in zip(imgs, valid_files, strict=True): - if img.shape[1:] != imgs[0].shape[1:]: - raise ValueError( - f"Images are not all the same size. Image {path} has shape {img.shape[1:]} while the first image " - f"{valid_files[0]} has shape {imgs[0].shape[1:]}" - ) + + # Pad images to same dimensions if necessary + if cls._check_for_differing_xy_dimensions(imgs): + imgs = cls._pad_images(imgs) + # create MultiChannelImage object with imgs and metadata output = cls(data=imgs, metadata=channel_metadata) return output @@ -221,6 +220,63 @@ def calc_scale_factors(self, default_scale_factor: int = 2) -> list[int]: def get_stack(self) -> da.Array: return da.stack(self.data, axis=0).squeeze(axis=1) + @staticmethod + def _check_for_differing_xy_dimensions(imgs: list[da.Array]) -> bool: + """Checks whether any of the images have differing extent in dimensions X and Y.""" + # Shape has order CYX + dims_x = [x.shape[2] for x in imgs] + dims_y = [x.shape[1] for x in imgs] + + dims_x_different = len(set(dims_x)) != 1 + dims_y_different = len(set(dims_y)) != 1 + + different_dimensions = any([dims_x_different, dims_y_different]) + + warnings.warn( + "Supplied images have different dimensions!", + UserWarning, + stacklevel=2, + ) + + return different_dimensions + + @staticmethod + def _pad_images(imgs: list[da.Array]) -> list[da.Array]: + """Pad all images to the same dimensions in X and Y with 0s. + + Padding is added only away from the origin: on the right side for X and at the + bottom for Y, so the top-left corner of each image stays aligned. + """ + dims_x_max = max([x.shape[2] for x in imgs]) + dims_y_max = max([x.shape[1] for x in imgs]) + + warnings.warn( + f"Padding images with 0s to same size of ({dims_y_max}, {dims_x_max})", + UserWarning, + stacklevel=2, + ) + + padded_imgs = [] + for img in imgs: + pad_y = dims_y_max - img.shape[1] + pad_x = dims_x_max - img.shape[2] + # Only pad if necessary + if (pad_y, pad_y) != (0, 0): + # Always pad to the right/bottom + pad_width = ( + # c axis: no pad + (0, 0), + # y axis: no pad near the origin (top), pad on the bottom + (0, pad_y), + # x axis: no pad near the origin (left), pad on the right + (0, pad_x), + ) + + img = da.pad(img, pad_width, mode="constant", constant_values=0) + padded_imgs.append(img) + + return padded_imgs + def macsima( path: str | Path, @@ -245,6 +301,8 @@ def macsima( This function reads images from a MACSima cyclic imaging experiment. MACSima data follows the OME-TIFF specificiation. All metadata is parsed from the OME metadata. The exact metadata schema can change between software versions of MACSiQView. As there is no public specification of the metadata fields used, please consider the provided test data sets as ground truth to guide development. + If images from different cycles differ in spatial dimensions, they are zero-padded on the right (X) and bottom (Y) to match + the largest dimensions, keeping the top-left origin aligned; a warning is emitted in that case. .. seealso:: @@ -332,6 +390,9 @@ def macsima( for p in path.iterdir() if p.is_dir() and (not filter_folder_names or not any(f in p.name for f in filter_folder_names)) ]: + if not len(list(p.glob("*.tif*"))): + warnings.warn(f"No tif files found in {p}, skipping it!", UserWarning, stacklevel=2) + continue sdatas[p.stem] = parse_processed_folder( path=p, imread_kwargs=imread_kwargs, diff --git a/tests/test_macsima.py b/tests/test_macsima.py index 72b33fbe..2557b5d2 100644 --- a/tests/test_macsima.py +++ b/tests/test_macsima.py @@ -1,4 +1,6 @@ +import contextlib import math +import os import shutil from copy import deepcopy from pathlib import Path @@ -93,7 +95,7 @@ def test_exception_on_no_valid_files(tmp_path: Path) -> None: # Write a tiff file without metadata height = 10 width = 10 - arr = np.zeros((height, width, 1), dtype=np.uint16) + arr = np.zeros((1, height, width), dtype=np.uint16) path_no_metadata = Path(tmp_path) / "tiff_no_metadata.tiff" imwrite(path_no_metadata, arr, metadata=None, description=None, software=None, datetime=None) @@ -101,6 +103,55 @@ def test_exception_on_no_valid_files(tmp_path: Path) -> None: macsima(tmp_path) +def test_multiple_subfolder_parsing_skips_emtpy_folders(tmp_path: Path) -> None: + parent_folder = tmp_path / "test_folder" + shutil.copytree("./data/OMAP23_small", parent_folder / "OMAP23_small") + os.makedirs(parent_folder / "empty_folder") + + with pytest.warns(UserWarning, match="No tif files found in .* skipping it"): + sdata = macsima(parent_folder, parsing_style="processed_multiple_folders") + assert len(sdata.images.keys()) == 1 + + +@pytest.mark.parametrize( + "dimensions,expected", + [ + (((10, 10), (10, 10)), False), + (((10, 10), (15, 10)), True), + (((10, 10), (10, 15)), True), + (((15, 10), (10, 15)), True), + ], +) +def test_check_differing_dimensions_works(dimensions: tuple[tuple[int, int], tuple[int, int]], expected: bool) -> None: + imgs = [] + for img_dim in dimensions: + arr = da.from_array(np.ones((1, img_dim[0], img_dim[1]), dtype=np.uint16)) + imgs.append(arr) + + ctx = ( + pytest.warns(UserWarning, match="Supplied images have different dimensions!") + if expected + else contextlib.nullcontext() + ) + with ctx: + assert MultiChannelImage._check_for_differing_xy_dimensions(imgs) == expected + + +def test_padding_on_differing_dimensions() -> None: + heights = [10, 10, 15, 20] + widths = [10, 15, 10, 20] + + imgs = [] + for height, width in zip(heights, widths, strict=True): + arr = da.from_array(np.ones((1, height, width), dtype=np.uint16)) + imgs.append(arr) + + with pytest.warns(UserWarning, match="Padding images with 0s to same size of \\(20, 20\\)"): + imgs_padded = MultiChannelImage._pad_images(imgs) + for img in imgs_padded: + assert img.shape == (1, 20, 20) + + @skip_if_below_python_version() @pytest.mark.parametrize( "dataset,expected",