From 2b2a31e3c82486ecee449041060fc62abf258ed2 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 01:34:17 +0000 Subject: [PATCH 01/67] Implement foreground mask Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 78 +++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 06b8cfa108..f5d35a226e 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -16,10 +16,11 @@ from abc import abstractmethod from collections.abc import Iterable from functools import partial -from typing import Any, Callable, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union from warnings import warn import numpy as np +import skimage import torch from monai.config import DtypeLike @@ -29,17 +30,10 @@ from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array from monai.transforms.utils_pytorch_numpy_unification import clip, percentile, where -from monai.utils import ( - convert_data_type, - convert_to_dst_type, - ensure_tuple, - ensure_tuple_rep, - ensure_tuple_size, - fall_back_tuple, -) from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends -from monai.utils.type_conversion import convert_to_tensor, get_equivalent_dtype +from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor, get_equivalent_dtype __all__ = [ "RandGaussianNoise", @@ -2161,3 +2155,67 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: img = IntensityRemap(self.kernel_size, self.R.choice([-self.slope, self.slope]))(img) return img + + +class ForegroundMask(Transform): + def __init__( + self, + threshold: Union[Dict, Callable, str, float] = "otsu", + hsv_threshold: Optional[Union[Dict, Callable, str, float]] = None, + high_value_foreground: bool = False, + ) -> None: + self.thresholds = {} + if isinstance(threshold, dict): + for mode, th in threshold.items(): + self._set_threshold(th, mode) + else: + self._set_threshold(threshold, "R") + self._set_threshold(threshold, "G") + self._set_threshold(threshold, "B") + if hsv_threshold is not None: + if isinstance(hsv_threshold, dict): + for mode, th in threshold.items(): + self._set_threshold(th, mode) + else: + self._set_threshold(hsv_threshold, "H") + self._set_threshold(hsv_threshold, "S") + self._set_threshold(hsv_threshold, "V") + + self.high_value_foreground = high_value_foreground + + def _set_threshold(self, threshold, mode): + if callable(threshold): + self.thresholds[mode] = threshold + elif isinstance(threshold, str): + self.thresholds[mode] = getattr(skimage.filters, "threshold_" + threshold.lower()) + elif isinstance(threshold, float): + self.thresholds[mode] = threshold + else: + ValueError( + f"`threshold` should be either a callable, string, or float number, {type(threshold)} was given." + ) + + def _get_threshold(self, image, mode): + threshold = self.thresholds.get(mode) + if callable(threshold): + return threshold(image) + return threshold + + def __call__(self, img_rgb: NdarrayOrTensor): + if self.high_value_foreground: + img_rgb = skimage.util.invert(img_rgb.invert) + + foreground = np.zeros_like(img_rgb[:1]) + for img, mode in zip(img_rgb, "RGB"): + threshold = self._get_threshold(img, mode) + if threshold: + foreground |= img < threshold + + if set(list("HSV")) & set(self.thresholds.keys()): + img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) + for img, mode in zip(img_hsv, "HSV"): + threshold = self._get_threshold(img, mode) + if threshold: + foreground |= img < threshold + + return foreground From 381ac738b24a1b5a436af95e7af240eaec260f2b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 01:34:36 +0000 Subject: [PATCH 02/67] Add unittests for foreground mask Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_foreground_mask.py | 43 +++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_foreground_mask.py diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py new file mode 100644 index 0000000000..6d1b543098 --- /dev/null +++ b/tests/test_foreground_mask.py @@ -0,0 +1,43 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.intensity.array import ForegroundMask +from monai.utils import set_determinism + +set_determinism(1234) + +A = np.random.randint(51, 128, (3, 3, 2)) +B = np.ones_like(A[:1]) +IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE2 = np.copy(IMAGE1) +IMAGE2[0] = 0 +IMAGE3 = np.copy(IMAGE2) +IMAGE3[1] = 0 +MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +TEST_CASE_1 = [{"threshold": "otsu"}, IMAGE1, MASK] +TEST_CASE_2 = [{"threshold": "otsu"}, IMAGE2, MASK] +TEST_CASE_3 = [{"threshold": "otsu"}, IMAGE3, MASK] + + +class TestForegroundMask(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_3]) + def test_foreground_mask(self, arguments, image, mask): + result = ForegroundMask(**arguments)(image) + np.testing.assert_allclose(result, mask) + + +if __name__ == "__main__": + unittest.main() From ba45b6283ffa05176ac87bd19830fd64bcbcdc92 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 13:40:10 +0000 Subject: [PATCH 03/67] Add several test cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 39 +++++++++++++------------ tests/test_foreground_mask.py | 44 ++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index f5d35a226e..ca35163c10 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -20,7 +20,6 @@ from warnings import warn import numpy as np -import skimage import torch from monai.config import DtypeLike @@ -33,8 +32,11 @@ from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils.module import optional_import from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor, get_equivalent_dtype +skimage, _ = optional_import("skimage") + __all__ = [ "RandGaussianNoise", "RandRicianNoise", @@ -2161,37 +2163,38 @@ class ForegroundMask(Transform): def __init__( self, threshold: Union[Dict, Callable, str, float] = "otsu", - hsv_threshold: Optional[Union[Dict, Callable, str, float]] = None, - high_value_foreground: bool = False, + hsv_threshold: Optional[Union[Dict, Callable, str, float, int]] = None, + invert: bool = False, ) -> None: self.thresholds = {} - if isinstance(threshold, dict): - for mode, th in threshold.items(): - self._set_threshold(th, mode) - else: - self._set_threshold(threshold, "R") - self._set_threshold(threshold, "G") - self._set_threshold(threshold, "B") + if threshold is not None: + if isinstance(threshold, dict): + for mode, th in threshold.items(): + self._set_threshold(th, mode) + else: + self._set_threshold(threshold, "R") + self._set_threshold(threshold, "G") + self._set_threshold(threshold, "B") if hsv_threshold is not None: if isinstance(hsv_threshold, dict): - for mode, th in threshold.items(): + for mode, th in hsv_threshold.items(): self._set_threshold(th, mode) else: self._set_threshold(hsv_threshold, "H") self._set_threshold(hsv_threshold, "S") self._set_threshold(hsv_threshold, "V") - self.high_value_foreground = high_value_foreground + self.invert = invert def _set_threshold(self, threshold, mode): if callable(threshold): self.thresholds[mode] = threshold elif isinstance(threshold, str): self.thresholds[mode] = getattr(skimage.filters, "threshold_" + threshold.lower()) - elif isinstance(threshold, float): - self.thresholds[mode] = threshold + elif isinstance(threshold, (float, int)): + self.thresholds[mode] = float(threshold) else: - ValueError( + raise ValueError( f"`threshold` should be either a callable, string, or float number, {type(threshold)} was given." ) @@ -2202,8 +2205,8 @@ def _get_threshold(self, image, mode): return threshold def __call__(self, img_rgb: NdarrayOrTensor): - if self.high_value_foreground: - img_rgb = skimage.util.invert(img_rgb.invert) + if self.invert: + img_rgb = skimage.util.invert(img_rgb) foreground = np.zeros_like(img_rgb[:1]) for img, mode in zip(img_rgb, "RGB"): @@ -2216,6 +2219,6 @@ def __call__(self, img_rgb: NdarrayOrTensor): for img, mode in zip(img_hsv, "HSV"): threshold = self._get_threshold(img, mode) if threshold: - foreground |= img < threshold + foreground |= img > threshold return foreground diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 6d1b543098..66c87249f9 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -15,25 +15,55 @@ from parameterized import parameterized from monai.transforms.intensity.array import ForegroundMask -from monai.utils import set_determinism +from monai.utils import optional_import, set_determinism +skimage, has_skimage = optional_import("skimage") set_determinism(1234) -A = np.random.randint(51, 128, (3, 3, 2)) +A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) B = np.ones_like(A[:1]) +MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) IMAGE2 = np.copy(IMAGE1) IMAGE2[0] = 0 -IMAGE3 = np.copy(IMAGE2) -IMAGE3[1] = 0 -MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) +TEST_CASE_0 = [{}, IMAGE1, MASK] TEST_CASE_1 = [{"threshold": "otsu"}, IMAGE1, MASK] TEST_CASE_2 = [{"threshold": "otsu"}, IMAGE2, MASK] -TEST_CASE_3 = [{"threshold": "otsu"}, IMAGE3, MASK] +TEST_CASE_3 = [{"threshold": 140}, IMAGE1, MASK] +TEST_CASE_4 = [{"threshold": "otsu", "invert": True}, IMAGE3, MASK] +TEST_CASE_5 = [{"threshold": 0.5}, MASK, np.logical_not(MASK)] +TEST_CASE_6 = [{"threshold": 140}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_7 = [{"threshold": {"R": "otsu", "G": "otsu", "B": "otsu"}}, IMAGE2, MASK] +TEST_CASE_8 = [{"threshold": {"R": 140, "G": "otsu", "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_9 = [{"threshold": {"R": 140, "G": skimage.filters.threshold_otsu, "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] +TEST_CASE_10 = [{"threshold": skimage.filters.threshold_mean}, IMAGE1, MASK] +TEST_CASE_11 = [{"threshold": None}, IMAGE1, np.zeros_like(MASK)] +TEST_CASE_12 = [{"threshold": None, "hsv_threshold": "otsu"}, IMAGE1, np.ones_like(MASK)] +TEST_CASE_13 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] +TEST_CASE_14 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] class TestForegroundMask(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_3]) + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + TEST_CASE_4, + TEST_CASE_5, + TEST_CASE_6, + TEST_CASE_7, + TEST_CASE_8, + TEST_CASE_9, + TEST_CASE_10, + TEST_CASE_11, + TEST_CASE_12, + TEST_CASE_13, + TEST_CASE_14, + ] + ) def test_foreground_mask(self, arguments, image, mask): result = ForegroundMask(**arguments)(image) np.testing.assert_allclose(result, mask) From 7294c87db9610e12f873e00bdd71f6308e5c3e77 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 14:13:22 +0000 Subject: [PATCH 04/67] Add ForegroundMaskd Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 20 +++++++++++++++-- monai/transforms/intensity/dictionary.py | 28 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index ca35163c10..663a8a2ea4 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2160,6 +2160,22 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: class ForegroundMask(Transform): + """ + Creates a binary mask that defines the foreground. + This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has + low values (dark) while the background is white. + + Args: + threshold: an int or a float number that defines the threshold for the input image. Or a callable that receives + each dimension of the image and calculate the threshold, or a string the defines such callable from + skimage.filter.threshold_xxxx. Also a dictionary can be passed that defines such thresholds for each channel. + like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean } + hsv_threshold: similar to threshold but for HSV color space ("H", "S", and "V"). + invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, + and vice-versa. + + """ + def __init__( self, threshold: Union[Dict, Callable, str, float] = "otsu", @@ -2170,7 +2186,7 @@ def __init__( if threshold is not None: if isinstance(threshold, dict): for mode, th in threshold.items(): - self._set_threshold(th, mode) + self._set_threshold(th, mode.upper()) else: self._set_threshold(threshold, "R") self._set_threshold(threshold, "G") @@ -2178,7 +2194,7 @@ def __init__( if hsv_threshold is not None: if isinstance(hsv_threshold, dict): for mode, th in hsv_threshold.items(): - self._set_threshold(th, mode) + self._set_threshold(th, mode.upper()) else: self._set_threshold(hsv_threshold, "H") self._set_threshold(hsv_threshold, "S") diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 67dc73f93e..7ac95df273 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -23,6 +23,7 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.intensity.array import ( AdjustContrast, + ForegroundMask, GaussianSharpen, GaussianSmooth, GibbsNoise, @@ -88,6 +89,7 @@ "RandCoarseDropoutd", "RandCoarseShuffled", "HistogramNormalized", + "ForegroundMaskd", "RandGaussianNoiseD", "RandGaussianNoiseDict", "ShiftIntensityD", @@ -146,6 +148,8 @@ "HistogramNormalizeDict", "RandKSpaceSpikeNoiseD", "RandKSpaceSpikeNoiseDict", + "ForegroundMaskD", + "ForegroundMaskDict", ] DEFAULT_POST_FIX = PostFix.meta() @@ -1654,6 +1658,29 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class ForegroundMaskd(MapTransform): + def __init__( + self, + keys: KeysCollection, + threshold: Union[Dict, Callable, str, float] = "otsu", + hsv_threshold: Optional[Union[Dict, Callable, str, float, int]] = None, + invert: bool = False, + new_key_prefix: Optional[str] = None, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.transform = ForegroundMask(threshold=threshold, hsv_threshold=hsv_threshold, invert=invert) + self.new_key_prefix = new_key_prefix + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + new_key = key if self.new_key_prefix is None else self.new_key_prefix + key + d[new_key] = self.transform(d[key]) + + return d + + RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised RandRicianNoiseD = RandRicianNoiseDict = RandRicianNoised ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd @@ -1683,3 +1710,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RandCoarseDropoutD = RandCoarseDropoutDict = RandCoarseDropoutd HistogramNormalizeD = HistogramNormalizeDict = HistogramNormalized RandCoarseShuffleD = RandCoarseShuffleDict = RandCoarseShuffled +ForegroundMaskD = ForegroundMaskDict = ForegroundMask From cb86da5191bd4dffb1739110bd6be7d41dfb58b5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 14:13:50 +0000 Subject: [PATCH 05/67] Add unittests for ForegroundMaskd Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_foreground_maskd.py | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_foreground_maskd.py diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py new file mode 100644 index 0000000000..c3a87581bd --- /dev/null +++ b/tests/test_foreground_maskd.py @@ -0,0 +1,81 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms.intensity.dictionary import ForegroundMaskd +from monai.utils import optional_import, set_determinism + +skimage, has_skimage = optional_import("skimage") +set_determinism(1234) + +A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) +B = np.ones_like(A[:1]) +MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE2 = np.copy(IMAGE1) +IMAGE2[0] = 0 +IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) +TEST_CASE_0 = [{"keys": "image"}, {"image": IMAGE1}, MASK] +TEST_CASE_1 = [{"keys": "image", "threshold": "otsu"}, {"image": IMAGE1}, MASK] +TEST_CASE_2 = [{"keys": "image", "threshold": "otsu"}, {"image": IMAGE2}, MASK] +TEST_CASE_3 = [{"keys": "image", "threshold": 140}, {"image": IMAGE1}, MASK] +TEST_CASE_4 = [{"keys": "image", "threshold": "otsu", "invert": True}, {"image": IMAGE3}, MASK] +TEST_CASE_5 = [{"keys": "image", "threshold": 0.5}, {"image": MASK}, np.logical_not(MASK)] +TEST_CASE_6 = [{"keys": "image", "threshold": 140}, {"image": IMAGE2}, np.ones_like(MASK)] +TEST_CASE_7 = [{"keys": "image", "threshold": {"R": "otsu", "G": "otsu", "B": "otsu"}}, {"image": IMAGE2}, MASK] +TEST_CASE_8 = [ + {"keys": "image", "threshold": {"R": 140, "G": "otsu", "B": "otsu"}}, + {"image": IMAGE2}, + np.ones_like(MASK), +] +TEST_CASE_9 = [ + {"keys": "image", "threshold": {"R": 140, "G": skimage.filters.threshold_otsu, "B": "otsu"}}, + {"image": IMAGE2}, + np.ones_like(MASK), +] +TEST_CASE_10 = [{"keys": "image", "threshold": skimage.filters.threshold_mean}, {"image": IMAGE1}, MASK] +TEST_CASE_11 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}, np.zeros_like(MASK)] +TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": "otsu"}, {"image": IMAGE1}, np.ones_like(MASK)] +TEST_CASE_13 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] +TEST_CASE_14 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] + + +class TestForegroundMaskd(unittest.TestCase): + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + TEST_CASE_4, + TEST_CASE_5, + TEST_CASE_6, + TEST_CASE_7, + TEST_CASE_8, + TEST_CASE_9, + TEST_CASE_10, + TEST_CASE_11, + TEST_CASE_12, + TEST_CASE_13, + TEST_CASE_14, + ] + ) + def test_foreground_mask(self, arguments, image, mask): + result = ForegroundMaskd(**arguments)(image)[arguments["keys"]] + np.testing.assert_allclose(result, mask) + + +if __name__ == "__main__": + unittest.main() From a2feb794cb736779174a695b8ba2fde6f5f6bbe2 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 14:15:44 +0000 Subject: [PATCH 06/67] Update init Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/__init__.py | 4 ++++ tests/test_foreground_maskd.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index c2385499b3..fa2c64f38e 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -81,6 +81,7 @@ from .intensity.array import ( AdjustContrast, DetectEnvelope, + ForegroundMask, GaussianSharpen, GaussianSmooth, GibbsNoise, @@ -117,6 +118,9 @@ AdjustContrastd, AdjustContrastD, AdjustContrastDict, + ForegroundMaskd, + ForegroundMaskD, + ForegroundMaskDict, GaussianSharpend, GaussianSharpenD, GaussianSharpenDict, diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index c3a87581bd..01256d7423 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -14,7 +14,7 @@ import numpy as np from parameterized import parameterized -from monai.transforms.intensity.dictionary import ForegroundMaskd +from monai.transforms import ForegroundMaskd from monai.utils import optional_import, set_determinism skimage, has_skimage = optional_import("skimage") From d271d98d0b712e3fa7ed4ee965b33a9c1dcd3680 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 26 May 2022 14:20:03 +0000 Subject: [PATCH 07/67] Update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 6 +++--- monai/transforms/intensity/dictionary.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 663a8a2ea4..e5b567739e 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2178,11 +2178,11 @@ class ForegroundMask(Transform): def __init__( self, - threshold: Union[Dict, Callable, str, float] = "otsu", + threshold: Union[Dict, Callable, str, float, int] = "otsu", hsv_threshold: Optional[Union[Dict, Callable, str, float, int]] = None, invert: bool = False, ) -> None: - self.thresholds = {} + self.thresholds: Dict[str, Union[Callable, float]] = {} if threshold is not None: if isinstance(threshold, dict): for mode, th in threshold.items(): @@ -2230,7 +2230,7 @@ def __call__(self, img_rgb: NdarrayOrTensor): if threshold: foreground |= img < threshold - if set(list("HSV")) & set(self.thresholds.keys()): + if set("HSV") & set(self.thresholds.keys()): img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) for img, mode in zip(img_hsv, "HSV"): threshold = self._get_threshold(img, mode) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 7ac95df273..ee23544dd9 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1659,6 +1659,24 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N class ForegroundMaskd(MapTransform): + """ + Creates a binary mask that defines the foreground. + This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has + low values (dark) while the background is white. + + Args: + keys: keys of the corresponding items to be transformed. + threshold: an int or a float number that defines the threshold for the input image. Or a callable that receives + each dimension of the image and calculate the threshold, or a string the defines such callable from + skimage.filter.threshold_xxxx. Also a dictionary can be passed that defines such thresholds for each channel. + like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean } + hsv_threshold: similar to threshold but for HSV color space ("H", "S", and "V"). + invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, + and vice-versa. + allow_missing_keys: do not raise exception if key is missing. + + """ + def __init__( self, keys: KeysCollection, From 32313ecb9503e458350d45b3fd47d7c174265205 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 28 May 2022 19:18:47 +0000 Subject: [PATCH 08/67] Update to less or equal for RGB threshold Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index e5b567739e..f461bb6295 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2228,13 +2228,15 @@ def __call__(self, img_rgb: NdarrayOrTensor): for img, mode in zip(img_rgb, "RGB"): threshold = self._get_threshold(img, mode) if threshold: - foreground |= img < threshold + foreground |= img <= threshold if set("HSV") & set(self.thresholds.keys()): img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) + hsv_foreground = np.zeros_like(img_rgb[:1]) for img, mode in zip(img_hsv, "HSV"): threshold = self._get_threshold(img, mode) if threshold: - foreground |= img > threshold + hsv_foreground |= img > threshold + foreground &= hsv_foreground return foreground From 4771712abfe88a3fd0ffa8f222c030938967ee81 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 28 May 2022 20:11:22 +0000 Subject: [PATCH 09/67] Update RGB and HSV mask combination Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index f461bb6295..cae447c1ba 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2161,9 +2161,9 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: class ForegroundMask(Transform): """ - Creates a binary mask that defines the foreground. + Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color sapce. This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has - low values (dark) while the background is white. + low values (dark) while the background has high values (white). Otherwise, set `invert` argument to `True`. Args: threshold: an int or a float number that defines the threshold for the input image. Or a callable that receives @@ -2224,12 +2224,14 @@ def __call__(self, img_rgb: NdarrayOrTensor): if self.invert: img_rgb = skimage.util.invert(img_rgb) - foreground = np.zeros_like(img_rgb[:1]) - for img, mode in zip(img_rgb, "RGB"): - threshold = self._get_threshold(img, mode) - if threshold: - foreground |= img <= threshold - + foregrounds = [] + if set("RGB") & set(self.thresholds.keys()): + rgb_foreground = np.zeros_like(img_rgb[:1]) + for img, mode in zip(img_rgb, "RGB"): + threshold = self._get_threshold(img, mode) + if threshold: + rgb_foreground |= img <= threshold + foregrounds.append(rgb_foreground) if set("HSV") & set(self.thresholds.keys()): img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) hsv_foreground = np.zeros_like(img_rgb[:1]) @@ -2237,6 +2239,8 @@ def __call__(self, img_rgb: NdarrayOrTensor): threshold = self._get_threshold(img, mode) if threshold: hsv_foreground |= img > threshold - foreground &= hsv_foreground + foregrounds.append(hsv_foreground) - return foreground + if foregrounds: + return np.stack(foregrounds).all(axis=0) + return np.zeros_like(img_rgb[:1]) From 7a72eb9344b8e88e1af7b91be1956f3c2a3cb10f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 28 May 2022 20:43:17 +0000 Subject: [PATCH 10/67] Add support for torch.Tensor Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 11 ++++++-- tests/test_foreground_mask.py | 44 ++++++++++++++--------------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index cae447c1ba..e97b017bbe 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2176,6 +2176,8 @@ class ForegroundMask(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, threshold: Union[Dict, Callable, str, float, int] = "otsu", @@ -2220,7 +2222,8 @@ def _get_threshold(self, image, mode): return threshold(image) return threshold - def __call__(self, img_rgb: NdarrayOrTensor): + def __call__(self, image: NdarrayOrTensor): + img_rgb, *_ = convert_data_type(image, np.ndarray) if self.invert: img_rgb = skimage.util.invert(img_rgb) @@ -2242,5 +2245,7 @@ def __call__(self, img_rgb: NdarrayOrTensor): foregrounds.append(hsv_foreground) if foregrounds: - return np.stack(foregrounds).all(axis=0) - return np.zeros_like(img_rgb[:1]) + mask = np.stack(foregrounds).all(axis=0) + else: + mask = np.zeros_like(img_rgb[:1]) + return convert_to_dst_type(src=mask, dst=image)[0] diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 66c87249f9..9d7602759e 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -16,6 +16,7 @@ from monai.transforms.intensity.array import ForegroundMask from monai.utils import optional_import, set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose skimage, has_skimage = optional_import("skimage") set_determinism(1234) @@ -43,30 +44,29 @@ TEST_CASE_13 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] TEST_CASE_14 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, *TEST_CASE_0]) + TESTS.append([p, *TEST_CASE_1]) + TESTS.append([p, *TEST_CASE_2]) + TESTS.append([p, *TEST_CASE_3]) + TESTS.append([p, *TEST_CASE_4]) + TESTS.append([p, *TEST_CASE_5]) + TESTS.append([p, *TEST_CASE_6]) + TESTS.append([p, *TEST_CASE_7]) + TESTS.append([p, *TEST_CASE_8]) + TESTS.append([p, *TEST_CASE_9]) + TESTS.append([p, *TEST_CASE_10]) + TESTS.append([p, *TEST_CASE_11]) + TESTS.append([p, *TEST_CASE_12]) + class TestForegroundMask(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASE_0, - TEST_CASE_1, - TEST_CASE_2, - TEST_CASE_3, - TEST_CASE_4, - TEST_CASE_5, - TEST_CASE_6, - TEST_CASE_7, - TEST_CASE_8, - TEST_CASE_9, - TEST_CASE_10, - TEST_CASE_11, - TEST_CASE_12, - TEST_CASE_13, - TEST_CASE_14, - ] - ) - def test_foreground_mask(self, arguments, image, mask): - result = ForegroundMask(**arguments)(image) - np.testing.assert_allclose(result, mask) + @parameterized.expand(TESTS) + def test_foreground_mask(self, in_type, arguments, image, mask): + input_image = in_type(image) + result = ForegroundMask(**arguments)(input_image) + assert_allclose(result, mask, type_test=False) if __name__ == "__main__": From d393e3540f3c48ffe6073032301c8e773f511add Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 28 May 2022 20:57:28 +0000 Subject: [PATCH 11/67] Update docsting and dict tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 14 ++++---- monai/transforms/intensity/dictionary.py | 14 ++++---- tests/test_foreground_maskd.py | 46 ++++++++++++------------ 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index e97b017bbe..7c1e353cf3 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2161,16 +2161,18 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: class ForegroundMask(Transform): """ - Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color sapce. + Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color space. This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has low values (dark) while the background has high values (white). Otherwise, set `invert` argument to `True`. Args: - threshold: an int or a float number that defines the threshold for the input image. Or a callable that receives - each dimension of the image and calculate the threshold, or a string the defines such callable from - skimage.filter.threshold_xxxx. Also a dictionary can be passed that defines such thresholds for each channel. - like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean } - hsv_threshold: similar to threshold but for HSV color space ("H", "S", and "V"). + threshold: an int or a float number that defines the threshold that values less than that are foreground. + It also can be a callable that receives each dimension of the image and calculate the threshold, + or a string the defines such callable from `skimage.filter.threshold_xxxx`. + Moreover, a dictionary can be passed that defines such thresholds for each channel, like + {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} + hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). + Unlike RBG, in HSV, value greater than `hsv_threshold` are considered foreground. invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, and vice-versa. diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index ee23544dd9..af685ab33f 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1660,17 +1660,19 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N class ForegroundMaskd(MapTransform): """ - Creates a binary mask that defines the foreground. + Creates a binary mask that defines the foreground based on thresholds in RGB or HSV color space. This transform receives an RGB (or grayscale) image where by default it is assumed that the foreground has low values (dark) while the background is white. Args: keys: keys of the corresponding items to be transformed. - threshold: an int or a float number that defines the threshold for the input image. Or a callable that receives - each dimension of the image and calculate the threshold, or a string the defines such callable from - skimage.filter.threshold_xxxx. Also a dictionary can be passed that defines such thresholds for each channel. - like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean } - hsv_threshold: similar to threshold but for HSV color space ("H", "S", and "V"). + threshold: an int or a float number that defines the threshold that values less than that are foreground. + It also can be a callable that receives each dimension of the image and calculate the threshold, + or a string the defines such callable from `skimage.filter.threshold_xxxx`. + Moreover, a dictionary can be passed that defines such thresholds for each channel, like + {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} + hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). + Unlike RBG, in HSV, value greater than `hsv_threshold` are considered foreground. invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, and vice-versa. allow_missing_keys: do not raise exception if key is missing. diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index 01256d7423..fb296a9866 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -16,6 +16,7 @@ from monai.transforms import ForegroundMaskd from monai.utils import optional_import, set_determinism +from tests.utils import TEST_NDARRAYS, assert_allclose skimage, has_skimage = optional_import("skimage") set_determinism(1234) @@ -51,30 +52,31 @@ TEST_CASE_13 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] TEST_CASE_14 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, *TEST_CASE_0]) + TESTS.append([p, *TEST_CASE_1]) + TESTS.append([p, *TEST_CASE_2]) + TESTS.append([p, *TEST_CASE_3]) + TESTS.append([p, *TEST_CASE_4]) + TESTS.append([p, *TEST_CASE_5]) + TESTS.append([p, *TEST_CASE_6]) + TESTS.append([p, *TEST_CASE_7]) + TESTS.append([p, *TEST_CASE_8]) + TESTS.append([p, *TEST_CASE_9]) + TESTS.append([p, *TEST_CASE_10]) + TESTS.append([p, *TEST_CASE_11]) + TESTS.append([p, *TEST_CASE_12]) + TESTS.append([p, *TEST_CASE_13]) + TESTS.append([p, *TEST_CASE_14]) + class TestForegroundMaskd(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASE_0, - TEST_CASE_1, - TEST_CASE_2, - TEST_CASE_3, - TEST_CASE_4, - TEST_CASE_5, - TEST_CASE_6, - TEST_CASE_7, - TEST_CASE_8, - TEST_CASE_9, - TEST_CASE_10, - TEST_CASE_11, - TEST_CASE_12, - TEST_CASE_13, - TEST_CASE_14, - ] - ) - def test_foreground_mask(self, arguments, image, mask): - result = ForegroundMaskd(**arguments)(image)[arguments["keys"]] - np.testing.assert_allclose(result, mask) + @parameterized.expand(TESTS) + def test_foreground_mask(self, in_type, arguments, data_dict, mask): + data_dict[arguments["keys"]] = in_type(data_dict[arguments["keys"]]) + result = ForegroundMaskd(**arguments)(data_dict)[arguments["keys"]] + assert_allclose(result, mask, type_test=False) if __name__ == "__main__": From 8902a8aa94ff9502639a0b122bd96e7e139dc41b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sat, 28 May 2022 21:03:12 +0000 Subject: [PATCH 12/67] Add docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index a93c48984c..854fd4dbb4 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -446,6 +446,13 @@ Intensity :members: :special-members: __call__ + +`ForegroundMask` +"""""""""""""""" +.. autoclass:: ForegroundMask + :members: + :special-members: __call__ + IO ^^ @@ -1327,6 +1334,11 @@ Intensity (Dict) :members: :special-members: __call__ +`ForegroundMaskd` +""""""""""""""""" +.. autoclass:: ForegroundMaskd + :members: + :special-members: __call__ IO (Dict) ^^^^^^^^^ From 4a8aa142a5057da39c59a5d65b46835f83d586d7 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sun, 29 May 2022 04:02:50 +0000 Subject: [PATCH 13/67] Add skipunless for skimage Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_foreground_mask.py | 1 + tests/test_foreground_maskd.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 9d7602759e..fca55d55ef 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -61,6 +61,7 @@ TESTS.append([p, *TEST_CASE_12]) +@unittest.skipUnless(has_skimage, "Requires sci-kit image") class TestForegroundMask(unittest.TestCase): @parameterized.expand(TESTS) def test_foreground_mask(self, in_type, arguments, image, mask): diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index fb296a9866..336ee9d574 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -71,6 +71,7 @@ TESTS.append([p, *TEST_CASE_14]) +@unittest.skipUnless(has_skimage, "Requires sci-kit image") class TestForegroundMaskd(unittest.TestCase): @parameterized.expand(TESTS) def test_foreground_mask(self, in_type, arguments, data_dict, mask): From 33fc743661ce9b56a3c08e6ee067d42d21f60c92 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sun, 29 May 2022 13:07:52 +0000 Subject: [PATCH 14/67] Exclude form min tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/min_tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/min_tests.py b/tests/min_tests.py index b52dc2a73d..cc35cf687f 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -53,6 +53,8 @@ def run_testsuit(): "test_ensure_channel_firstd", "test_fill_holes", "test_fill_holesd", + "test_foreground_mask", + "test_foreground_maskd", "test_global_mutual_information_loss", "test_handler_checkpoint_loader", "test_handler_checkpoint_saver", From aa1170676771581e77f353d80ee39032b51176a3 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Sun, 29 May 2022 15:48:42 +0000 Subject: [PATCH 15/67] fix a typo Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/dictionary.py | 2 +- tests/test_foreground_maskd.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index af685ab33f..5cc407ea01 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1730,4 +1730,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RandCoarseDropoutD = RandCoarseDropoutDict = RandCoarseDropoutd HistogramNormalizeD = HistogramNormalizeDict = HistogramNormalized RandCoarseShuffleD = RandCoarseShuffleDict = RandCoarseShuffled -ForegroundMaskD = ForegroundMaskDict = ForegroundMask +ForegroundMaskD = ForegroundMaskDict = ForegroundMaskd diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index 336ee9d574..282789dd44 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -14,7 +14,7 @@ import numpy as np from parameterized import parameterized -from monai.transforms import ForegroundMaskd +from monai.transforms.intensity.dictionary import ForegroundMaskd from monai.utils import optional_import, set_determinism from tests.utils import TEST_NDARRAYS, assert_allclose From de94b006d4508438cd8f96a0951183dddddb50a1 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 30 May 2022 19:14:47 +0000 Subject: [PATCH 16/67] Update no or wrong threshold cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 16 +++++++++------- tests/test_foreground_mask.py | 21 +++++++++++++++++---- tests/test_foreground_maskd.py | 21 ++++++++++++++++----- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 7c1e353cf3..c4fb09a64b 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -2168,7 +2168,8 @@ class ForegroundMask(Transform): Args: threshold: an int or a float number that defines the threshold that values less than that are foreground. It also can be a callable that receives each dimension of the image and calculate the threshold, - or a string the defines such callable from `skimage.filter.threshold_xxxx`. + or a string that defines such callable from `skimage.filter.threshold_...`. For the list of available + threshold functions, please refer to https://scikit-image.org/docs/stable/api/skimage.filters.html Moreover, a dictionary can be passed that defines such thresholds for each channel, like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). @@ -2205,6 +2206,10 @@ def __init__( self._set_threshold(hsv_threshold, "V") self.invert = invert + if self.thresholds.keys().isdisjoint(set("RGBHSV")): + raise ValueError( + f"Threshold for at least one channel of RGB or HSV needs to be set. {self.thresholds} is provided." + ) def _set_threshold(self, threshold, mode): if callable(threshold): @@ -2230,14 +2235,14 @@ def __call__(self, image: NdarrayOrTensor): img_rgb = skimage.util.invert(img_rgb) foregrounds = [] - if set("RGB") & set(self.thresholds.keys()): + if not self.thresholds.keys().isdisjoint(set("RGB")): rgb_foreground = np.zeros_like(img_rgb[:1]) for img, mode in zip(img_rgb, "RGB"): threshold = self._get_threshold(img, mode) if threshold: rgb_foreground |= img <= threshold foregrounds.append(rgb_foreground) - if set("HSV") & set(self.thresholds.keys()): + if not self.thresholds.keys().isdisjoint(set("HSV")): img_hsv = skimage.color.rgb2hsv(img_rgb, channel_axis=0) hsv_foreground = np.zeros_like(img_rgb[:1]) for img, mode in zip(img_hsv, "HSV"): @@ -2246,8 +2251,5 @@ def __call__(self, image: NdarrayOrTensor): hsv_foreground |= img > threshold foregrounds.append(hsv_foreground) - if foregrounds: - mask = np.stack(foregrounds).all(axis=0) - else: - mask = np.zeros_like(img_rgb[:1]) + mask = np.stack(foregrounds).all(axis=0) return convert_to_dst_type(src=mask, dst=image)[0] diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index fca55d55ef..0a511fe5b0 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -39,10 +39,11 @@ TEST_CASE_8 = [{"threshold": {"R": 140, "G": "otsu", "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] TEST_CASE_9 = [{"threshold": {"R": 140, "G": skimage.filters.threshold_otsu, "B": "otsu"}}, IMAGE2, np.ones_like(MASK)] TEST_CASE_10 = [{"threshold": skimage.filters.threshold_mean}, IMAGE1, MASK] -TEST_CASE_11 = [{"threshold": None}, IMAGE1, np.zeros_like(MASK)] -TEST_CASE_12 = [{"threshold": None, "hsv_threshold": "otsu"}, IMAGE1, np.ones_like(MASK)] -TEST_CASE_13 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] -TEST_CASE_14 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] +TEST_CASE_11 = [{"threshold": None, "hsv_threshold": "otsu"}, IMAGE1, np.ones_like(MASK)] +TEST_CASE_12 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] +TEST_CASE_13 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] +TEST_CASE_ERROR_1 = [{"threshold": None}, IMAGE1] +TEST_CASE_ERROR_2 = [{"threshold": {"K": 1}}, IMAGE1] TESTS = [] for p in TEST_NDARRAYS: @@ -59,6 +60,12 @@ TESTS.append([p, *TEST_CASE_10]) TESTS.append([p, *TEST_CASE_11]) TESTS.append([p, *TEST_CASE_12]) + TESTS.append([p, *TEST_CASE_13]) + +TESTS_ERROR = [] +for p in TEST_NDARRAYS: + TESTS_ERROR.append([p, *TEST_CASE_ERROR_1]) + TESTS_ERROR.append([p, *TEST_CASE_ERROR_2]) @unittest.skipUnless(has_skimage, "Requires sci-kit image") @@ -69,6 +76,12 @@ def test_foreground_mask(self, in_type, arguments, image, mask): result = ForegroundMask(**arguments)(input_image) assert_allclose(result, mask, type_test=False) + @parameterized.expand(TESTS_ERROR) + def test_foreground_mask_error(self, in_type, arguments, image): + input_image = in_type(image) + with self.assertRaises(ValueError): + ForegroundMask(**arguments)(input_image) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index 282789dd44..bf1df76af4 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -47,10 +47,11 @@ np.ones_like(MASK), ] TEST_CASE_10 = [{"keys": "image", "threshold": skimage.filters.threshold_mean}, {"image": IMAGE1}, MASK] -TEST_CASE_11 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}, np.zeros_like(MASK)] -TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": "otsu"}, {"image": IMAGE1}, np.ones_like(MASK)] -TEST_CASE_13 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] -TEST_CASE_14 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] +TEST_CASE_11 = [{"keys": "image", "threshold": None, "hsv_threshold": "otsu"}, {"image": IMAGE1}, np.ones_like(MASK)] +TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] +TEST_CASE_13 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] +TEST_CASE_ERROR_1 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}] +TEST_CASE_ERROR_2 = [{"keys": "image", "threshold": {"K": 1}}, {"image": IMAGE1}] TESTS = [] for p in TEST_NDARRAYS: @@ -68,7 +69,11 @@ TESTS.append([p, *TEST_CASE_11]) TESTS.append([p, *TEST_CASE_12]) TESTS.append([p, *TEST_CASE_13]) - TESTS.append([p, *TEST_CASE_14]) + +TESTS_ERROR = [] +for p in TEST_NDARRAYS: + TESTS_ERROR.append([p, *TEST_CASE_ERROR_1]) + TESTS_ERROR.append([p, *TEST_CASE_ERROR_2]) @unittest.skipUnless(has_skimage, "Requires sci-kit image") @@ -79,6 +84,12 @@ def test_foreground_mask(self, in_type, arguments, data_dict, mask): result = ForegroundMaskd(**arguments)(data_dict)[arguments["keys"]] assert_allclose(result, mask, type_test=False) + @parameterized.expand(TESTS_ERROR) + def test_foreground_mask_error(self, in_type, arguments, data_dict): + data_dict[arguments["keys"]] = in_type(data_dict[arguments["keys"]]) + with self.assertRaises(ValueError): + ForegroundMaskd(**arguments)(data_dict)[arguments["keys"]] + if __name__ == "__main__": unittest.main() From ebde248121f160b323237d68df890abca956618f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 30 May 2022 19:20:03 +0000 Subject: [PATCH 17/67] Update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/dictionary.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 5cc407ea01..eae603df4a 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1675,6 +1675,8 @@ class ForegroundMaskd(MapTransform): Unlike RBG, in HSV, value greater than `hsv_threshold` are considered foreground. invert: invert the intensity range of the input image, so that the dtype maximum is now the dtype minimum, and vice-versa. + new_key_prefix: this prefix be prepended to the key to create a new key for the output and keep the value of + key intact. By default not prefix is set and the corresponding array to the key will be replaced. allow_missing_keys: do not raise exception if key is missing. """ From 0bfb77b15f6f55647004440df93061aeebf14e1a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 30 May 2022 21:45:52 +0000 Subject: [PATCH 18/67] Implement MaskedPatchWSIDataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/__init__.py | 2 +- monai/data/wsi_datasets.py | 133 ++++++++++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 40ee3cfc29..293f058acf 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -104,7 +104,7 @@ worker_init_fn, zoom_affine, ) -from .wsi_datasets import PatchWSIDataset, SlidingPatchWSIDataset +from .wsi_datasets import MaskedPatchWSIDataset, PatchWSIDataset, SlidingPatchWSIDataset from .wsi_reader import BaseWSIReader, CuCIMWSIReader, OpenSlideWSIReader, WSIReader with contextlib.suppress(BaseException): diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 6fe5435d57..4df1f1e1d9 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -17,7 +17,7 @@ from monai.data import Dataset from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader -from monai.transforms import Randomizable, apply_transform +from monai.transforms import ForegroundMask, Randomizable, apply_transform from monai.utils import ensure_tuple_rep __all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset"] @@ -33,6 +33,9 @@ class PatchWSIDataset(Dataset): size: the size of patch to be extracted from the whole slide image. level: the level at which the patches to be extracted (default to 0). transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output matadata from the input data reader: the module to be used for loading whole slide imaging. If `reader` is - a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. @@ -59,6 +62,9 @@ def __init__( size: Optional[Union[int, Tuple[int, int]]] = None, level: Optional[int] = None, transform: Optional[Callable] = None, + include_label: bool = True, + center_location: bool = True, + additional_meta_keys: Optional[Sequence[str]] = None, reader="cuCIM", **kwargs, ): @@ -78,16 +84,19 @@ def __init__( # Setup the WSI reader self.wsi_reader: Union[WSIReader, BaseWSIReader] - self.backend = "" if isinstance(reader, str): - self.backend = reader.lower() - self.wsi_reader = WSIReader(backend=self.backend, level=level, **kwargs) + self.wsi_reader = WSIReader(backend=reader, level=level, **kwargs) elif inspect.isclass(reader) and issubclass(reader, BaseWSIReader): self.wsi_reader = reader(level=level, **kwargs) elif isinstance(reader, BaseWSIReader): self.wsi_reader = reader else: raise ValueError(f"Unsupported reader type: {reader}.") + self.backend = self.wsi_reader.backend + + self.include_label = include_label + self.center_location = center_location + self.additional_meta_keys = additional_meta_keys or [] # Initialized an empty whole slide image object dict self.wsi_object_dict: Dict = {} @@ -102,8 +111,11 @@ def _get_label(self, sample: Dict): return np.array(sample["label"], dtype=np.float32) def _get_location(self, sample: Dict): - size = self._get_size(sample) - return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + if self.center_location: + size = self._get_size(sample) + return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + else: + return sample["location"] def _get_level(self, sample: Dict): if self.level is None: @@ -131,12 +143,16 @@ def _transform(self, index: int): # Extract patch image and associated metadata image, metadata = self._get_data(sample) + output = {"image": image, "metadata": metadata} + + # Include label in the output + if self.include_label: + output["label"] = self._get_label(sample) - # Get the label - label = self._get_label(sample) + for key in self.additional_meta_keys: + metadata[key] = sample[key] - # Apply transforms and output - output = {"image": image, "label": label, "metadata": metadata} + # Apply transforms and return it return apply_transform(self.transform, output) if self.transform else output @@ -265,3 +281,100 @@ def _transform(self, index: int): # Create put all patch information together and apply transforms patch = {"image": image, "metadata": metadata} return apply_transform(self.transform, patch) if self.transform else patch + + +class MaskedPatchWSIDataset(Randomizable, PatchWSIDataset): + """ + This dataset extracts patches from whole slide images at the locations where foreground mask + at a given level is non-zero. + + Args: + data: the list of input samples including image, location, and label (see the note below for more details). + size: the size of patch to be extracted from the whole slide image. + level: the level at which the patches to be extracted (default to 0). + mask_level: the resolution level at which the mask is created. + transform: transforms to be executed on input data. + include_label: whether to load and include labels in the output + center_location: whether the input location information is the position of the center of the patch + additional_meta_keys: the list of keys for items to be copied to the output matadata from the input data + reader: the module to be used for loading whole slide imaging. Defaults to cuCIM. If `reader` is + + - a string, it defines the backend of `monai.data.WSIReader`. + - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, + - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. + + seed: random seed to randomly generate offsets. Defaults to 0. + kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class + + Note: + The input data has the following form as an example: + + .. code-block:: python + + [ + {"image": "path/to/image1.tiff"}, + {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + ] + + """ + + def __init__( + self, + data: Sequence, + size: Optional[Union[int, Tuple[int, int]]] = None, + level: Optional[int] = None, + mask_level: int = 7, + transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = ("mask_location", "mask_size"), + reader="cuCIM", + **kwargs, + ): + super().__init__( + data=data, + size=size, + level=level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) + self.mask_level = mask_level + # Create single sample for each patch (in a sliding window manner) + self.data = [] + for sample in data: + patch_samples = self._evaluate_patch_coordinates(sample) + self.data.extend(patch_samples) + + def _evaluate_patch_coordinates(self, sample): + """Define the location for each patch based on sliding-window approach""" + patch_size = self._get_size(sample) + level = self._get_level(sample) + + # load the image at level=mask_level + wsi_obj = self._get_wsi_object(sample) + wsi, _ = self.wsi_reader.get_data(wsi_obj, level=self.mask_level) + + # create the foreground tissue mask + mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) + + # get all indices for non-zero pixels of the foreground mask + mask_locations = np.vstack(mask.nonzero()).T + + # convert mask locations to image locations at level=0 + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + patch_locations = np.round((mask_locations + 0.5) * float(mask_ratio) - patch_size_0 // 2).astype(int) + + sample["size"] = patch_size + sample["level"] = level + sample["num_patches"] = len(patch_locations) + sample["mask_size"] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + return [ + {**sample, "location": np.array(loc), "mask_location": mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) + ] From 1c8a106da1c407e5c1a1d6faadbfd62a6e1dc8a9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 30 May 2022 21:46:17 +0000 Subject: [PATCH 19/67] Add unittests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_masked_patch_wsi_dataset.py | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/test_masked_patch_wsi_dataset.py diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py new file mode 100644 index 0000000000..6a1218ab29 --- /dev/null +++ b/tests/test_masked_patch_wsi_dataset.py @@ -0,0 +1,86 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from parameterized import parameterized + +from monai.data import MaskedPatchWSIDataset +from monai.utils import optional_import, set_determinism +from tests.utils import download_url_or_skip_test, testing_data_config + +set_determinism(0) + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, "level": 8, "size": (2, 2)}], "mask_level": 8}, + { + "num_patches": 4256, + "wsi_size": [32914, 46000], + "mask_level": 8, + "patch_level": 8, + "mask_size": (128, 179), + "patch_size": (2, 2), + }, +] + + +class MaskedPatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_gen_patches(self, input_parameters, expected): + dataset = MaskedPatchWSIDataset(reader=self.backend, **input_parameters) + self.assertEqual(len(dataset), expected["num_patches"]) + for i, sample in enumerate(dataset): + self.assertEqual(sample["metadata"]["patch"]["level"], expected["patch_level"]) + self.assertTupleEqual(sample["metadata"]["patch"]["size"], expected["patch_size"]) + self.assertTupleEqual(sample["image"].shape[1:], expected["patch_size"]) + self.assertTrue(sample["metadata"]["patch"]["location"][0] >= 0) + self.assertTrue(sample["metadata"]["patch"]["location"][0] < expected["wsi_size"][0]) + self.assertTrue(sample["metadata"]["patch"]["location"][1] >= 0) + self.assertTrue(sample["metadata"]["patch"]["location"][1] < expected["wsi_size"][1]) + if i > 10: + break + + +@skipUnless(has_cucim, "Requires cucim") +class TestSlidingPatchWSIDatasetCuCIM(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestSlidingPatchWSIDatasetOpenSlide(MaskedPatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From 641137314eb9b33d452f3cdb5c43f40d97aed05a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 02:53:15 +0000 Subject: [PATCH 20/67] Update the min verison for skimage to 0.19.0 Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/intensity/array.py | 4 ++-- monai/transforms/intensity/dictionary.py | 3 ++- requirements-dev.txt | 2 +- tests/test_foreground_mask.py | 4 ++-- tests/test_foreground_maskd.py | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index c4fb09a64b..e80f5bcb97 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -32,10 +32,10 @@ from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple -from monai.utils.module import optional_import +from monai.utils.module import min_version, optional_import from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor, get_equivalent_dtype -skimage, _ = optional_import("skimage") +skimage, _ = optional_import("skimage", "0.19.0", min_version) __all__ = [ "RandGaussianNoise", diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index eae603df4a..25cf261fe1 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1668,7 +1668,8 @@ class ForegroundMaskd(MapTransform): keys: keys of the corresponding items to be transformed. threshold: an int or a float number that defines the threshold that values less than that are foreground. It also can be a callable that receives each dimension of the image and calculate the threshold, - or a string the defines such callable from `skimage.filter.threshold_xxxx`. + or a string that defines such callable from `skimage.filter.threshold_...`. For the list of available + threshold functions, please refer to https://scikit-image.org/docs/stable/api/skimage.filters.html Moreover, a dictionary can be passed that defines such thresholds for each channel, like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). diff --git a/requirements-dev.txt b/requirements-dev.txt index ac8b3730d8..7bc06b8039 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ itk>=5.2 nibabel pillow!=8.3.0 # https://github.com/python-pillow/Pillow/issues/5571 tensorboard -scikit-image>=0.14.2 +scikit-image>=0.19.0 tqdm>=4.47.0 lmdb flake8>=3.8.1 diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 0a511fe5b0..60038cd1b1 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms.intensity.array import ForegroundMask -from monai.utils import optional_import, set_determinism +from monai.utils import min_version, optional_import, set_determinism from tests.utils import TEST_NDARRAYS, assert_allclose -skimage, has_skimage = optional_import("skimage") +skimage, has_skimage = optional_import("skimage", "0.19.0", min_version) set_determinism(1234) A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index bf1df76af4..c6b2f00e37 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -15,10 +15,10 @@ from parameterized import parameterized from monai.transforms.intensity.dictionary import ForegroundMaskd -from monai.utils import optional_import, set_determinism +from monai.utils import min_version, optional_import, set_determinism from tests.utils import TEST_NDARRAYS, assert_allclose -skimage, has_skimage = optional_import("skimage") +skimage, has_skimage = optional_import("skimage", "0.19.0", min_version) set_determinism(1234) A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) From 1b0b8c44553f45aa80209c98e88684a837444f3b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 12:50:06 +0000 Subject: [PATCH 21/67] Added 3D image test case Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_foreground_mask.py | 6 ++++++ tests/test_foreground_maskd.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 60038cd1b1..90145d67c9 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -22,9 +22,13 @@ set_determinism(1234) A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) +A3D = np.random.randint(64, 128, (3, 3, 2, 2)).astype(np.uint8) B = np.ones_like(A[:1]) +B3D = np.ones_like(A3D[:1]) MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +MASK3D = np.pad(B3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=0) IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE3D = np.pad(A3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=255) IMAGE2 = np.copy(IMAGE1) IMAGE2[0] = 0 IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) @@ -42,6 +46,7 @@ TEST_CASE_11 = [{"threshold": None, "hsv_threshold": "otsu"}, IMAGE1, np.ones_like(MASK)] TEST_CASE_12 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] TEST_CASE_13 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] +TEST_CASE_14 = [{}, IMAGE3D, MASK3D] TEST_CASE_ERROR_1 = [{"threshold": None}, IMAGE1] TEST_CASE_ERROR_2 = [{"threshold": {"K": 1}}, IMAGE1] @@ -61,6 +66,7 @@ TESTS.append([p, *TEST_CASE_11]) TESTS.append([p, *TEST_CASE_12]) TESTS.append([p, *TEST_CASE_13]) + TESTS.append([p, *TEST_CASE_14]) TESTS_ERROR = [] for p in TEST_NDARRAYS: diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index c6b2f00e37..c17d8eeca2 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -22,9 +22,13 @@ set_determinism(1234) A = np.random.randint(64, 128, (3, 3, 2)).astype(np.uint8) +A3D = np.random.randint(64, 128, (3, 3, 2, 2)).astype(np.uint8) B = np.ones_like(A[:1]) +B3D = np.ones_like(A3D[:1]) MASK = np.pad(B, ((0, 0), (2, 2), (2, 2)), constant_values=0) +MASK3D = np.pad(B3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=0) IMAGE1 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=255) +IMAGE3D = np.pad(A3D, ((0, 0), (2, 2), (2, 2), (2, 2)), constant_values=255) IMAGE2 = np.copy(IMAGE1) IMAGE2[0] = 0 IMAGE3 = np.pad(A, ((0, 0), (2, 2), (2, 2)), constant_values=0) @@ -50,6 +54,7 @@ TEST_CASE_11 = [{"keys": "image", "threshold": None, "hsv_threshold": "otsu"}, {"image": IMAGE1}, np.ones_like(MASK)] TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] TEST_CASE_13 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] +TEST_CASE_14 = [{"keys": "image"}, {"image": IMAGE3D}, MASK3D] TEST_CASE_ERROR_1 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}] TEST_CASE_ERROR_2 = [{"keys": "image", "threshold": {"K": 1}}, {"image": IMAGE1}] @@ -69,6 +74,7 @@ TESTS.append([p, *TEST_CASE_11]) TESTS.append([p, *TEST_CASE_12]) TESTS.append([p, *TEST_CASE_13]) + TESTS.append([p, *TEST_CASE_14]) TESTS_ERROR = [] for p in TEST_NDARRAYS: From 2520c60f4bcb0a211867879607b446c3c050fb25 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 12:55:48 +0000 Subject: [PATCH 22/67] Add another 3D test case Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_foreground_mask.py | 3 +++ tests/test_foreground_maskd.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/test_foreground_mask.py b/tests/test_foreground_mask.py index 90145d67c9..c18e87fe53 100644 --- a/tests/test_foreground_mask.py +++ b/tests/test_foreground_mask.py @@ -47,6 +47,8 @@ TEST_CASE_12 = [{"threshold": None, "hsv_threshold": {"S": "otsu"}}, IMAGE1, MASK] TEST_CASE_13 = [{"threshold": 100, "invert": True}, IMAGE1, np.logical_not(MASK)] TEST_CASE_14 = [{}, IMAGE3D, MASK3D] +TEST_CASE_15 = [{"hsv_threshold": {"S": 0.1}}, IMAGE3D, MASK3D] + TEST_CASE_ERROR_1 = [{"threshold": None}, IMAGE1] TEST_CASE_ERROR_2 = [{"threshold": {"K": 1}}, IMAGE1] @@ -67,6 +69,7 @@ TESTS.append([p, *TEST_CASE_12]) TESTS.append([p, *TEST_CASE_13]) TESTS.append([p, *TEST_CASE_14]) + TESTS.append([p, *TEST_CASE_15]) TESTS_ERROR = [] for p in TEST_NDARRAYS: diff --git a/tests/test_foreground_maskd.py b/tests/test_foreground_maskd.py index c17d8eeca2..3c8aa08d7f 100644 --- a/tests/test_foreground_maskd.py +++ b/tests/test_foreground_maskd.py @@ -55,6 +55,8 @@ TEST_CASE_12 = [{"keys": "image", "threshold": None, "hsv_threshold": {"S": "otsu"}}, {"image": IMAGE1}, MASK] TEST_CASE_13 = [{"keys": "image", "threshold": 100, "invert": True}, {"image": IMAGE1}, np.logical_not(MASK)] TEST_CASE_14 = [{"keys": "image"}, {"image": IMAGE3D}, MASK3D] +TEST_CASE_15 = [{"keys": "image", "hsv_threshold": {"S": 0.1}}, {"image": IMAGE3D}, MASK3D] + TEST_CASE_ERROR_1 = [{"keys": "image", "threshold": None}, {"image": IMAGE1}] TEST_CASE_ERROR_2 = [{"keys": "image", "threshold": {"K": 1}}, {"image": IMAGE1}] @@ -75,6 +77,7 @@ TESTS.append([p, *TEST_CASE_12]) TESTS.append([p, *TEST_CASE_13]) TESTS.append([p, *TEST_CASE_14]) + TESTS.append([p, *TEST_CASE_15]) TESTS_ERROR = [] for p in TEST_NDARRAYS: From 5de232eddcac0866bc86242b3e681caa05b1ac71 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 13:29:28 +0000 Subject: [PATCH 23/67] resolve conflicts Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 83 ++++++++++++++++-------- monai/transforms/intensity/array.py | 4 +- monai/transforms/intensity/dictionary.py | 3 +- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 4df1f1e1d9..b2d655ada3 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -18,9 +18,9 @@ from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader from monai.transforms import ForegroundMask, Randomizable, apply_transform -from monai.utils import ensure_tuple_rep +from monai.utils import ProbMapKeys, ensure_tuple_rep -__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset"] +__all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset", "MaskedPatchWSIDataset"] class PatchWSIDataset(Dataset): @@ -35,7 +35,7 @@ class PatchWSIDataset(Dataset): transform: transforms to be executed on input data. include_label: whether to load and include labels in the output center_location: whether the input location information is the position of the center of the patch - additional_meta_keys: the list of keys for items to be copied to the output matadata from the input data + additional_meta_keys: the list of keys for items to be copied to the output metadata from the input data reader: the module to be used for loading whole slide imaging. If `reader` is - a string, it defines the backend of `monai.data.WSIReader`. Defaults to cuCIM. @@ -197,15 +197,34 @@ def __init__( data: Sequence, size: Optional[Union[int, Tuple[int, int]]] = None, level: Optional[int] = None, + mask_level: int = 0, overlap: Union[Tuple[float, float], float] = 0.0, offset: Union[Tuple[int, int], int, str] = (0, 0), offset_limits: Optional[Union[Tuple[Tuple[int, int], Tuple[int, int]], Tuple[int, int]]] = None, transform: Optional[Callable] = None, + include_label: bool = False, + center_location: bool = False, + additional_meta_keys: Sequence[str] = ( + ProbMapKeys.LOCATION.value, + ProbMapKeys.SIZE.value, + ProbMapKeys.COUNT.value, + ), reader="cuCIM", seed: int = 0, **kwargs, ): - super().__init__(data=data, size=size, level=level, transform=transform, reader=reader, **kwargs) + super().__init__( + data=data, + size=size, + level=level, + transform=transform, + include_label=include_label, + center_location=center_location, + additional_meta_keys=additional_meta_keys, + reader=reader, + **kwargs, + ) + self.mask_level = mask_level self.overlap = overlap self.set_random_state(seed) # Set the offset config @@ -237,9 +256,11 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] - for sample in data: - sliding_samples = self._evaluate_patch_coordinates(sample) - self.data.extend(sliding_samples) + self.image_data = data + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) + self.data.extend(patch_samples) + print(f"{self.image_data=}") def _get_offset(self, sample): if self.random_offset: @@ -250,8 +271,8 @@ def _get_offset(self, sample): return tuple(self.R.randint(low, high) for low, high in offset_limits) return self.offset - def _evaluate_patch_coordinates(self, sample): - """Define the location for each patch based on sliding-window approach""" + def _evaluate_patch_locations(self, sample): + """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) level = self._get_level(sample) start_pos = self._get_offset(sample) @@ -265,25 +286,32 @@ def _evaluate_patch_coordinates(self, sample): image_size=wsi_size, patch_size=patch_size_, start_pos=start_pos, overlap=self.overlap, padded=False ) ) - sample["size"] = patch_size - sample["level"] = level n_patches = len(locations) - return [{**sample, "location": loc, "num_patches": n_patches} for loc in locations] - - def _get_location(self, sample: Dict): - return sample["location"] + mask_size = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + sample["size"] = np.array(patch_size) + sample["level"] = level + sample[ProbMapKeys.COUNT.value] = n_patches + sample[ProbMapKeys.SIZE.value] = mask_size + self.image_data[ProbMapKeys.COUNT.value] = n_patches + self.image_data[ProbMapKeys.SIZE.value] = mask_size + return [ + { + **sample, + "location": np.array(loc), + ProbMapKeys.LOCATION.value: self.downsample_center(loc, patch_size, ratio), + } + for loc in locations + ] - def _transform(self, index: int): - # Get a single entry of data - sample: Dict = self.data[index] - # Extract patch image and associated metadata - image, metadata = self._get_data(sample) - # Create put all patch information together and apply transforms - patch = {"image": image, "metadata": metadata} - return apply_transform(self.transform, patch) if self.transform else patch + def downsample_center(self, location: Tuple[int, int], patch_size: Tuple[int, int], ratio: float) -> np.ndarray: + """ + For a given location at level=0, evaluate the corresponding center position of patch at level=`level` + """ + center_location = [int((l + p // 2) / ratio) for l, p in zip(location, patch_size)] + return np.array(center_location) -class MaskedPatchWSIDataset(Randomizable, PatchWSIDataset): +class MaskedPatchWSIDataset(PatchWSIDataset): """ This dataset extracts patches from whole slide images at the locations where foreground mask at a given level is non-zero. @@ -303,7 +331,6 @@ class MaskedPatchWSIDataset(Randomizable, PatchWSIDataset): - a class (inherited from `BaseWSIReader`), it is initialized and set as wsi_reader, - an instance of a a class inherited from `BaseWSIReader`, it is set as the wsi_reader. - seed: random seed to randomly generate offsets. Defaults to 0. kwargs: additional arguments to pass to `WSIReader` or provided whole slide reader class Note: @@ -327,7 +354,11 @@ def __init__( transform: Optional[Callable] = None, include_label: bool = False, center_location: bool = False, - additional_meta_keys: Sequence[str] = ("mask_location", "mask_size"), + additional_meta_keys: Sequence[str] = ( + ProbMapKeys.LOCATION.value, + ProbMapKeys.SIZE.value, + ProbMapKeys.COUNT.value, + ), reader="cuCIM", **kwargs, ): diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index c4fb09a64b..e80f5bcb97 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -32,10 +32,10 @@ from monai.utils.deprecate_utils import deprecated_arg from monai.utils.enums import TransformBackends from monai.utils.misc import ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple -from monai.utils.module import optional_import +from monai.utils.module import min_version, optional_import from monai.utils.type_conversion import convert_data_type, convert_to_dst_type, convert_to_tensor, get_equivalent_dtype -skimage, _ = optional_import("skimage") +skimage, _ = optional_import("skimage", "0.19.0", min_version) __all__ = [ "RandGaussianNoise", diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index eae603df4a..25cf261fe1 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1668,7 +1668,8 @@ class ForegroundMaskd(MapTransform): keys: keys of the corresponding items to be transformed. threshold: an int or a float number that defines the threshold that values less than that are foreground. It also can be a callable that receives each dimension of the image and calculate the threshold, - or a string the defines such callable from `skimage.filter.threshold_xxxx`. + or a string that defines such callable from `skimage.filter.threshold_...`. For the list of available + threshold functions, please refer to https://scikit-image.org/docs/stable/api/skimage.filters.html Moreover, a dictionary can be passed that defines such thresholds for each channel, like {"R": 100, "G": "otsu", "B": skimage.filter.threshold_mean} hsv_threshold: similar to threshold but HSV color space ("H", "S", and "V"). From 6e95fb910066aab2d7a3304dfb022d550d443b9e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 May 2022 13:33:11 +0000 Subject: [PATCH 24/67] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_masked_patch_wsi_dataset.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index 6a1218ab29..29a069467e 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -13,12 +13,11 @@ import unittest from unittest import skipUnless -import numpy as np from parameterized import parameterized from monai.data import MaskedPatchWSIDataset from monai.utils import optional_import, set_determinism -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import testing_data_config set_determinism(0) From 3f013575e11d545d5a82d5db41654466cbe23b0b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 13:47:03 +0000 Subject: [PATCH 25/67] Add docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/data.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/data.rst b/docs/source/data.rst index 7f59e587ec..0de5e0c347 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -312,6 +312,11 @@ PatchWSIDataset .. autoclass:: monai.data.PatchWSIDataset :members: +MaskedPatchWSIDataset +~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: monai.data.MaskedPatchWSIDataset + :members: + SlidingPatchWSIDataset ~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.SlidingPatchWSIDataset From cfb7cd4f14e5543bdc24b95b59038562b3ba4b06 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 15:34:49 +0000 Subject: [PATCH 26/67] deprecate dataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/data/datasets.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monai/apps/pathology/data/datasets.py b/monai/apps/pathology/data/datasets.py index 71f3214ea4..a25e54e999 100644 --- a/monai/apps/pathology/data/datasets.py +++ b/monai/apps/pathology/data/datasets.py @@ -17,11 +17,12 @@ from monai.data import Dataset, SmartCacheDataset from monai.data.image_reader import WSIReader -from monai.utils import ensure_tuple_rep +from monai.utils import deprecated, ensure_tuple_rep __all__ = ["PatchWSIDataset", "SmartCachePatchWSIDataset", "MaskedInferenceWSIDataset"] +@deprecated(since="0.8", msg_suffix="use `monai.data.PatchWSIDataset` instead.") class PatchWSIDataset(Dataset): """ This dataset reads whole slide images, extracts regions, and creates patches. @@ -103,6 +104,7 @@ def __getitem__(self, index): return patches +@deprecated(since="0.8", msg_suffix="use `monai.data.SmartCacheDataset` with `monai.data.PatchWSIDataset` instead.") class SmartCachePatchWSIDataset(SmartCacheDataset): """Add SmartCache functionality to `PatchWSIDataset`. @@ -177,6 +179,7 @@ def __init__( ) +@deprecated(since="0.8", msg_suffix="use `monai.data.MaskedPatchWSIDataset` instead.") class MaskedInferenceWSIDataset(Dataset): """ This dataset load the provided foreground masks at an arbitrary resolution level, From 22917e20dd270ef1b28d0f63f90d517618e14371 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 15:46:04 +0000 Subject: [PATCH 27/67] Pull the enum from prob map PR Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/utils/__init__.py | 1 + monai/utils/enums.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index cd8555d173..0d5d8bf92d 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -33,6 +33,7 @@ MetricReduction, NumpyPadMode, PostFix, + ProbMapKeys, PytorchPadMode, SkipMode, TraceKeys, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 50b55560f9..9fb4e480f6 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -332,6 +332,18 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] +class ProbMapKeys(Enum): + """ + The keys to be used for generating the probability maps from patches + """ + + LOCATION = "mask_location" + SIZE = "mask_size" + COUNT = "num_patches" + PATH = "path" + PRE_PATH = "image" + + class GridPatchSort(Enum): """ The sorting method for the generated patches in `GridPatch` From a1d180e221e65d8779b132617b8710b50de51bcf Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 17:26:23 +0000 Subject: [PATCH 28/67] pull wsi_reader form prob map branch Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 955651999a..434edfd9cc 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -140,8 +140,10 @@ def get_metadata( "backend": self.backend, "original_channel_dim": 0, "spatial_shape": np.asarray(patch.shape[1:]), - "wsi": {"path": self.get_file_path(wsi)}, - "patch": {"location": location, "size": size, "level": level}, + "path": self.get_file_path(wsi), + "patch_location": np.asarray(location), + "patch_size": np.asarray(size), + "patch_level": level, } return metadata @@ -232,16 +234,16 @@ def get_data( "backend": each_meta["backend"], "original_channel_dim": each_meta["original_channel_dim"], "spatial_shape": each_meta["spatial_shape"], - "wsi": [each_meta["wsi"]], - "patch": [each_meta["patch"]], } + for k in ["path", "patch_size", "patch_level", "patch_location"]: + metadata[k] = [each_meta[k]] else: if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: raise ValueError("original_channel_dim is not consistent across wsi objects.") if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): raise ValueError("spatial_shape is not consistent across wsi objects.") - metadata["wsi"].append(each_meta["wsi"]) - metadata["patch"].append(each_meta["patch"]) + for k in ["path", "patch_size", "patch_level", "patch_location"]: + metadata[k].append(each_meta[k]) return _stack_images(patch_list, metadata), metadata From d2c68db2a04ebf38a9c84da7a8bb71bc1aa3c747 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 19:02:38 +0000 Subject: [PATCH 29/67] Replace all wsi patch key related with WSIPatchKeys Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 160 +++++++++++------------- monai/data/wsi_reader.py | 18 +-- monai/transforms/spatial/array.py | 6 +- monai/utils/__init__.py | 1 + monai/utils/enums.py | 16 ++- tests/test_masked_patch_wsi_dataset.py | 19 +-- tests/test_patch_wsi_dataset_new.py | 38 ++++-- tests/test_sliding_patch_wsi_dataset.py | 91 ++++++++------ tests/test_wsireader_new.py | 40 ++++-- 9 files changed, 217 insertions(+), 172 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 1d4afb1bf1..a3668738a9 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -18,7 +18,8 @@ from monai.data.utils import iter_patch_position from monai.data.wsi_reader import BaseWSIReader, WSIReader from monai.transforms import ForegroundMask, Randomizable, apply_transform -from monai.utils import ProbMapKeys, ensure_tuple_rep +from monai.utils import CommonKeys, ProbMapKeys, ensure_tuple_rep +from monai.utils.enums import WSIPatchKeys __all__ = ["PatchWSIDataset", "SlidingPatchWSIDataset", "MaskedPatchWSIDataset"] @@ -50,8 +51,8 @@ class PatchWSIDataset(Dataset): .. code-block:: python [ - {"image": "path/to/image1.tiff", "location": [200, 500], "label": 0}, - {"image": "path/to/image2.tiff", "location": [100, 700], "size": [20, 20], "level": 2, "label": 1} + {"image": "path/to/image1.tiff", "patch_location": [200, 500], "label": 0}, + {"image": "path/to/image2.tiff", "patch_location": [100, 700], "patch_size": [20, 20], "level_size": 2, "label": 1} ] """ @@ -59,8 +60,8 @@ class PatchWSIDataset(Dataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, transform: Optional[Callable] = None, include_label: bool = True, center_location: bool = True, @@ -71,23 +72,23 @@ def __init__( super().__init__(data, transform) # Ensure patch size is a two dimensional tuple - if size is None: - self.size = None + if patch_size is None: + self.patch_size = None else: - self.size = ensure_tuple_rep(size, 2) + self.patch_size = ensure_tuple_rep(patch_size, 2) # Create a default level that override all levels if it is not None - self.level = level + self.patch_level = patch_level # Set the default WSIReader's level to 0 if level is not provided - if level is None: - level = 0 + if patch_level is None: + patch_level = 0 # Setup the WSI reader self.wsi_reader: Union[WSIReader, BaseWSIReader] if isinstance(reader, str): - self.wsi_reader = WSIReader(backend=reader, level=level, **kwargs) + self.wsi_reader = WSIReader(backend=reader, level=patch_level, **kwargs) elif inspect.isclass(reader) and issubclass(reader, BaseWSIReader): - self.wsi_reader = reader(level=level, **kwargs) + self.wsi_reader = reader(level=patch_level, **kwargs) elif isinstance(reader, BaseWSIReader): self.wsi_reader = reader else: @@ -102,30 +103,30 @@ def __init__( self.wsi_object_dict: Dict = {} def _get_wsi_object(self, sample: Dict): - image_path = sample["image"] + image_path = sample[CommonKeys.IMAGE] if image_path not in self.wsi_object_dict: self.wsi_object_dict[image_path] = self.wsi_reader.read(image_path) return self.wsi_object_dict[image_path] def _get_label(self, sample: Dict): - return np.array(sample["label"], dtype=np.float32) + return np.array(sample[CommonKeys.LABEL], dtype=np.float32) def _get_location(self, sample: Dict): if self.center_location: size = self._get_size(sample) - return [sample["location"][i] - size[i] // 2 for i in range(len(size))] + return [sample[WSIPatchKeys.LOCATION][i] - size[i] // 2 for i in range(len(size))] else: - return sample["location"] + return sample[WSIPatchKeys.LOCATION] def _get_level(self, sample: Dict): - if self.level is None: - return sample.get("level", 0) - return self.level + if self.patch_level is None: + return sample.get(WSIPatchKeys.LEVEL, 0) + return self.patch_level def _get_size(self, sample: Dict): - if self.size is None: - return ensure_tuple_rep(sample.get("size"), 2) - return self.size + if self.patch_size is None: + return ensure_tuple_rep(sample.get(WSIPatchKeys.SIZE), 2) + return self.patch_size def _get_data(self, sample: Dict): # Don't store OpenSlide objects to avoid issues with OpenSlide internal cache @@ -143,11 +144,11 @@ def _transform(self, index: int): # Extract patch image and associated metadata image, metadata = self._get_data(sample) - output = {"image": image, "metadata": metadata} + output = {CommonKeys.IMAGE: image, CommonKeys.METADATA: metadata} # Include label in the output if self.include_label: - output["label"] = self._get_label(sample) + output[CommonKeys.LABEL] = self._get_label(sample) for key in self.additional_meta_keys: metadata[key] = sample[key] @@ -187,7 +188,7 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): [ {"image": "path/to/image1.tiff"}, - {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} ] """ @@ -195,8 +196,8 @@ class SlidingPatchWSIDataset(Randomizable, PatchWSIDataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, mask_level: int = 0, overlap: Union[Tuple[float, float], float] = 0.0, offset: Union[Tuple[int, int], int, str] = (0, 0), @@ -205,9 +206,9 @@ def __init__( include_label: bool = False, center_location: bool = False, additional_meta_keys: Sequence[str] = ( - ProbMapKeys.LOCATION.value, - ProbMapKeys.SIZE.value, - ProbMapKeys.COUNT.value, + ProbMapKeys.LOCATION, + ProbMapKeys.SIZE, + ProbMapKeys.COUNT, ), reader="cuCIM", seed: int = 0, @@ -215,8 +216,8 @@ def __init__( ): super().__init__( data=data, - size=size, - level=level, + patch_size=patch_size, + patch_level=patch_level, transform=transform, include_label=include_label, center_location=center_location, @@ -260,7 +261,6 @@ def __init__( for sample in self.image_data: patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) - print(f"{self.image_data=}") def _get_offset(self, sample): if self.random_offset: @@ -274,42 +274,35 @@ def _get_offset(self, sample): def _evaluate_patch_locations(self, sample): """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) - level = self._get_level(sample) - offset = self._get_offset(sample) - + patch_level = self._get_level(sample) wsi_obj = self._get_wsi_object(sample) + + # calculate the locations wsi_size = self.wsi_reader.get_size(wsi_obj, 0) - downsample = self.wsi_reader.get_downsample_ratio(wsi_obj, level) - patch_size_ = tuple(p * downsample for p in patch_size) # patch size at level 0 - locations = list( - iter_patch_position( - image_size=wsi_size, patch_size=patch_size_, start_pos=offset, overlap=self.overlap, padded=False + mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) + patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 + offset = self._get_offset(sample) + patch_locations = np.array( + list( + iter_patch_position( + image_size=wsi_size, patch_size=patch_size_0, start_pos=offset, overlap=self.overlap, padded=False + ) ) ) - n_patches = len(locations) - mask_size = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) - sample["size"] = np.array(patch_size) - sample["level"] = level - sample[ProbMapKeys.COUNT.value] = n_patches - sample[ProbMapKeys.SIZE.value] = mask_size - self.image_data[ProbMapKeys.COUNT.value] = n_patches - self.image_data[ProbMapKeys.SIZE.value] = mask_size + # convert locations to mask_location + mask_locations = np.round((patch_locations + patch_size_0 // 2) / float(mask_ratio)) + + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE] = patch_size + sample[WSIPatchKeys.LEVEL] = patch_level + sample[ProbMapKeys.COUNT] = len(patch_locations) + sample[ProbMapKeys.SIZE] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - { - **sample, - "location": np.array(loc), - ProbMapKeys.LOCATION.value: self.downsample_center(loc, patch_size, ratio), - } - for loc in locations + {**sample, WSIPatchKeys.LOCATION: np.array(loc), ProbMapKeys.LOCATION: mask_loc} + for loc, mask_loc in zip(patch_locations, mask_locations) ] - def downsample_center(self, location: Tuple[int, int], patch_size: Tuple[int, int], ratio: float) -> np.ndarray: - """ - For a given location at level=0, evaluate the corresponding center position of patch at level=`level` - """ - center_location = [int((l + p // 2) / ratio) for l, p in zip(location, patch_size)] - return np.array(center_location) - class MaskedPatchWSIDataset(PatchWSIDataset): """ @@ -340,7 +333,7 @@ class MaskedPatchWSIDataset(PatchWSIDataset): [ {"image": "path/to/image1.tiff"}, - {"image": "path/to/image2.tiff", "size": [20, 20], "level": 2} + {"image": "path/to/image2.tiff", "patch_size": [20, 20], "patch_level": 2} ] """ @@ -348,24 +341,24 @@ class MaskedPatchWSIDataset(PatchWSIDataset): def __init__( self, data: Sequence, - size: Optional[Union[int, Tuple[int, int]]] = None, - level: Optional[int] = None, + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + patch_level: Optional[int] = None, mask_level: int = 7, transform: Optional[Callable] = None, include_label: bool = False, center_location: bool = False, additional_meta_keys: Sequence[str] = ( - ProbMapKeys.LOCATION.value, - ProbMapKeys.SIZE.value, - ProbMapKeys.COUNT.value, + ProbMapKeys.LOCATION, + ProbMapKeys.SIZE, + ProbMapKeys.COUNT, ), reader="cuCIM", **kwargs, ): super().__init__( data=data, - size=size, - level=level, + patch_size=patch_size, + patch_level=patch_level, transform=transform, include_label=include_label, center_location=center_location, @@ -381,31 +374,30 @@ def __init__( self.data.extend(patch_samples) def _evaluate_patch_coordinates(self, sample): - """Define the location for each patch based on sliding-window approach""" + """Calculate the location for each patch based on the mask at different resolution level""" patch_size = self._get_size(sample) - level = self._get_level(sample) - - # load the image at level=mask_level + patch_level = self._get_level(sample) wsi_obj = self._get_wsi_object(sample) + + # load the entire image at level=mask_level wsi, _ = self.wsi_reader.get_data(wsi_obj, level=self.mask_level) - # create the foreground tissue mask + # create the foreground tissue mask and get all indices for non-zero pixels mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) - - # get all indices for non-zero pixels of the foreground mask mask_locations = np.vstack(mask.nonzero()).T # convert mask locations to image locations at level=0 mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) - patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, level) + patch_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, patch_level) patch_size_0 = np.array([p * patch_ratio for p in patch_size]) # patch size at level 0 patch_locations = np.round((mask_locations + 0.5) * float(mask_ratio) - patch_size_0 // 2).astype(int) - sample["size"] = patch_size - sample["level"] = level - sample["num_patches"] = len(patch_locations) - sample["mask_size"] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + # fill out samples with location and metadata + sample[WSIPatchKeys.SIZE] = patch_size + sample[WSIPatchKeys.LEVEL] = patch_level + sample[ProbMapKeys.COUNT] = len(patch_locations) + sample[ProbMapKeys.SIZE] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - {**sample, "location": np.array(loc), "mask_location": mask_loc} + {**sample, WSIPatchKeys.LOCATION: np.array(loc), ProbMapKeys.LOCATION: mask_loc} for loc, mask_loc in zip(patch_locations, mask_locations) ] diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 434edfd9cc..eda36bdd93 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -19,7 +19,7 @@ from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format from monai.transforms.utility.array import AsChannelFirst -from monai.utils import ensure_tuple, optional_import, require_pkg +from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") OpenSlide, _ = optional_import("openslide", name="OpenSlide") @@ -140,10 +140,10 @@ def get_metadata( "backend": self.backend, "original_channel_dim": 0, "spatial_shape": np.asarray(patch.shape[1:]), - "path": self.get_file_path(wsi), - "patch_location": np.asarray(location), - "patch_size": np.asarray(size), - "patch_level": level, + WSIPatchKeys.PATH: self.get_file_path(wsi), + WSIPatchKeys.LOCATION: np.asarray(location), + WSIPatchKeys.SIZE: np.asarray(size), + WSIPatchKeys.LEVEL: level, } return metadata @@ -235,15 +235,15 @@ def get_data( "original_channel_dim": each_meta["original_channel_dim"], "spatial_shape": each_meta["spatial_shape"], } - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k] = [each_meta[k]] + for key in WSIPatchKeys: + metadata[key] = [each_meta[key]] else: if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: raise ValueError("original_channel_dim is not consistent across wsi objects.") if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): raise ValueError("spatial_shape is not consistent across wsi objects.") - for k in ["path", "patch_size", "patch_level", "patch_location"]: - metadata[k].append(each_meta[k]) + for key in WSIPatchKeys: + metadata[key].append(each_meta[key]) return _stack_images(patch_list, metadata), metadata diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index eb854c8d23..37469b6b1e 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2662,11 +2662,11 @@ def __init__( self.num_patches = num_patches self.sort_fn: Optional[Callable] if isinstance(sort_fn, str): - if sort_fn == GridPatchSort.RANDOM.value: + if sort_fn == GridPatchSort.RANDOM: self.sort_fn = np.random.random - elif sort_fn == GridPatchSort.MIN.value: + elif sort_fn == GridPatchSort.MIN: self.sort_fn = self.get_patch_sum - elif sort_fn == GridPatchSort.MAX.value: + elif sort_fn == GridPatchSort.MAX: self.sort_fn = self.get_negative_patch_sum else: raise ValueError( diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 0d5d8bf92d..8a597f5182 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -32,6 +32,7 @@ Method, MetricReduction, NumpyPadMode, + WSIPatchKeys, PostFix, ProbMapKeys, PytorchPadMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 9fb4e480f6..db1b681714 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -273,6 +273,7 @@ class CommonKeys: LABEL = "label" PRED = "pred" LOSS = "loss" + METADATA = "metadata" class PostFix: @@ -332,7 +333,7 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] -class ProbMapKeys(Enum): +class ProbMapKeys: """ The keys to be used for generating the probability maps from patches """ @@ -344,7 +345,7 @@ class ProbMapKeys(Enum): PRE_PATH = "image" -class GridPatchSort(Enum): +class GridPatchSort: """ The sorting method for the generated patches in `GridPatch` """ @@ -352,3 +353,14 @@ class GridPatchSort(Enum): RANDOM = "random" MIN = "min" MAX = "max" + + +class WSIPatchKeys: + """ + The keys to be used for metadata of patches extracted from whole slide images + """ + + LOCATION = "patch_location" + LEVEL = "patch_level" + SIZE = "patch_size" + PATH = "path" diff --git a/tests/test_masked_patch_wsi_dataset.py b/tests/test_masked_patch_wsi_dataset.py index 29a069467e..c1d202f8f4 100644 --- a/tests/test_masked_patch_wsi_dataset.py +++ b/tests/test_masked_patch_wsi_dataset.py @@ -13,10 +13,11 @@ import unittest from unittest import skipUnless +from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import MaskedPatchWSIDataset -from monai.utils import optional_import, set_determinism +from monai.utils import WSIPatchKeys, optional_import, set_determinism from tests.utils import testing_data_config set_determinism(0) @@ -35,7 +36,7 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "level": 8, "size": (2, 2)}], "mask_level": 8}, + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (2, 2)}], "mask_level": 8}, { "num_patches": 4256, "wsi_size": [32914, 46000], @@ -56,13 +57,13 @@ def test_gen_patches(self, input_parameters, expected): dataset = MaskedPatchWSIDataset(reader=self.backend, **input_parameters) self.assertEqual(len(dataset), expected["num_patches"]) for i, sample in enumerate(dataset): - self.assertEqual(sample["metadata"]["patch"]["level"], expected["patch_level"]) - self.assertTupleEqual(sample["metadata"]["patch"]["size"], expected["patch_size"]) - self.assertTupleEqual(sample["image"].shape[1:], expected["patch_size"]) - self.assertTrue(sample["metadata"]["patch"]["location"][0] >= 0) - self.assertTrue(sample["metadata"]["patch"]["location"][0] < expected["wsi_size"][0]) - self.assertTrue(sample["metadata"]["patch"]["location"][1] >= 0) - self.assertTrue(sample["metadata"]["patch"]["location"][1] < expected["wsi_size"][1]) + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected["patch_size"]) + assert_array_equal(sample["image"].shape[1:], expected["patch_size"]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][0] < expected["wsi_size"][0]) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] >= 0) + self.assertTrue(sample["metadata"][WSIPatchKeys.LOCATION][1] < expected["wsi_size"][1]) if i > 10: break diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py index d128d45262..65e65035c4 100644 --- a/tests/test_patch_wsi_dataset_new.py +++ b/tests/test_patch_wsi_dataset_new.py @@ -35,41 +35,41 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1], "level": 0}], "size": (1, 1)}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": (1, 1), "level": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "size": 1, "label": [1]}]}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "size": 1, "level": 0}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "size": 1}, + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, ] TEST_CASE_4 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, ], - "size": 1, + "patch_size": 1, }, [ {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, @@ -80,8 +80,20 @@ TEST_CASE_5 = [ { "data": [ - {"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]], "size": 1, "level": 1}, - {"image": FILE_PATH, "location": [100, 100], "label": [[[1, 0], [0, 0]]], "size": 1, "level": 1}, + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, ] }, [ @@ -129,9 +141,9 @@ def test_read_patches_class(self, input_parameters, expected): @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) def test_read_patches_object(self, input_parameters, expected): if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("level", 0)) + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("level", 0)) + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) else: raise ValueError("Unsupported backend: {self.backend}") dataset = PatchWSIDataset(reader=reader, **input_parameters) diff --git a/tests/test_sliding_patch_wsi_dataset.py b/tests/test_sliding_patch_wsi_dataset.py index 1eaa0292c5..5f2a2c0d55 100644 --- a/tests/test_sliding_patch_wsi_dataset.py +++ b/tests/test_sliding_patch_wsi_dataset.py @@ -14,10 +14,11 @@ from unittest import skipUnless import numpy as np +from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import SlidingPatchWSIDataset -from monai.utils import optional_import, set_determinism +from monai.utils import WSIPatchKeys, optional_import, set_determinism from tests.utils import download_url_or_skip_test, testing_data_config set_determinism(0) @@ -41,7 +42,7 @@ ARRAY_SMALL_1 = np.random.randint(low=0, high=255, size=(3, 5, 5), dtype=np.uint8) TEST_CASE_SMALL_0 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2)}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -51,7 +52,7 @@ ] TEST_CASE_SMALL_1 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}]}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}]}, [ {"image": ARRAY_SMALL_0[:, :2, :2]}, {"image": ARRAY_SMALL_0[:, :2, 2:]}, @@ -61,7 +62,7 @@ ] TEST_CASE_SMALL_2 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (2, 2), "overlap": 0.5}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (2, 2), "overlap": 0.5}, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 1:3]}, @@ -76,7 +77,7 @@ ] TEST_CASE_SMALL_3 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}], "size": (3, 3), "overlap": 2.0 / 3.0}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}], "patch_size": (3, 3), "overlap": 2.0 / 3.0}, [ {"image": ARRAY_SMALL_0[:, :3, :3]}, {"image": ARRAY_SMALL_0[:, :3, 1:]}, @@ -86,7 +87,13 @@ ] TEST_CASE_SMALL_4 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0}, {"image": FILE_PATH_SMALL_1, "level": 0}], "size": (2, 2)}, + { + "data": [ + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0}, + ], + "patch_size": (2, 2), + }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, {"image": ARRAY_SMALL_0[:, 0:2, 2:4]}, @@ -102,8 +109,8 @@ TEST_CASE_SMALL_5 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}, - {"image": FILE_PATH_SMALL_1, "level": 0, "size": (3, 3)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (3, 3)}, ] }, [ @@ -118,11 +125,11 @@ TEST_CASE_SMALL_6 = [ { "data": [ - {"image": FILE_PATH_SMALL_0, "level": 1, "size": (1, 1)}, - {"image": FILE_PATH_SMALL_1, "level": 2, "size": (4, 4)}, + {"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 1, WSIPatchKeys.SIZE: (1, 1)}, + {"image": FILE_PATH_SMALL_1, WSIPatchKeys.LEVEL: 2, WSIPatchKeys.SIZE: (4, 4)}, ], - "size": (2, 2), - "level": 0, + "patch_size": (2, 2), + "patch_level": 0, }, [ {"image": ARRAY_SMALL_0[:, 0:2, 0:2]}, @@ -138,18 +145,22 @@ TEST_CASE_SMALL_7 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": (1, 0)}, + {"data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": (1, 0)}, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_8 = [ - {"data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], "offset": "random", "offset_limits": (0, 2)}, + { + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], + "offset": "random", + "offset_limits": (0, 2), + }, [{"image": ARRAY_SMALL_0[:, 1:3, :2]}, {"image": ARRAY_SMALL_0[:, 1:3, 2:]}], ] TEST_CASE_SMALL_9 = [ { - "data": [{"image": FILE_PATH_SMALL_0, "level": 0, "size": (2, 2)}], + "data": [{"image": FILE_PATH_SMALL_0, WSIPatchKeys.LEVEL: 0, WSIPatchKeys.SIZE: (2, 2)}], "offset": "random", "offset_limits": ((0, 3), (0, 2)), }, @@ -157,37 +168,37 @@ ] TEST_CASE_LARGE_0 = [ - {"data": [{"image": FILE_PATH, "level": 8, "size": (64, 50)}]}, + {"data": [{"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}]}, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, ], ] TEST_CASE_LARGE_1 = [ { "data": [ - {"image": FILE_PATH, "level": 8, "size": (64, 50)}, - {"image": FILE_PATH, "level": 7, "size": (125, 110)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 8, WSIPatchKeys.SIZE: (64, 50)}, + {"image": FILE_PATH, WSIPatchKeys.LEVEL: 7, WSIPatchKeys.SIZE: (125, 110)}, ] }, [ - {"step_loc": (0, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 0), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 1), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (1, 2), "size": (64, 50), "level": 8, "ratio": 257.06195068359375}, - {"step_loc": (0, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (0, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 0), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 1), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, - {"step_loc": (1, 2), "size": (125, 110), "level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 0), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 1), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (1, 2), "patch_size": (64, 50), "patch_level": 8, "ratio": 257.06195068359375}, + {"step_loc": (0, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (0, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 0), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 1), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, + {"step_loc": (1, 2), "patch_size": (125, 110), "patch_level": 7, "ratio": 128.10186767578125}, ], ] @@ -233,11 +244,11 @@ def test_read_patches_large(self, input_parameters, expected): dataset = SlidingPatchWSIDataset(reader=self.backend, **input_parameters) self.assertEqual(len(dataset), len(expected)) for i, sample in enumerate(dataset): - self.assertEqual(sample["metadata"]["patch"]["level"], expected[i]["level"]) - self.assertTupleEqual(sample["metadata"]["patch"]["size"], expected[i]["size"]) - steps = [round(expected[i]["ratio"] * s) for s in expected[i]["size"]] + self.assertEqual(sample["metadata"][WSIPatchKeys.LEVEL], expected[i]["patch_level"]) + assert_array_equal(sample["metadata"][WSIPatchKeys.SIZE], expected[i]["patch_size"]) + steps = [round(expected[i]["ratio"] * s) for s in expected[i]["patch_size"]] expected_location = tuple(expected[i]["step_loc"][j] * steps[j] for j in range(len(steps))) - self.assertTupleEqual(sample["metadata"]["patch"]["location"], expected_location) + assert_array_equal(sample["metadata"][WSIPatchKeys.LOCATION], expected_location) @skipUnless(has_cucim, "Requires cucim") diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 4faec53978..35aaccad26 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -128,10 +128,10 @@ def test_read_whole_image(self, file_path, level, expected_shape): img, meta = reader.get_data(img_obj) self.assertTupleEqual(img.shape, expected_shape) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch"]["level"], level) - self.assertTupleEqual(meta["patch"]["size"], expected_shape[1:]) - self.assertTupleEqual(meta["patch"]["location"], (0, 0)) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], level) + assert_array_equal(meta["patch_size"], expected_shape[1:]) + assert_array_equal(meta["patch_location"], (0, 0)) @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) def test_read_region(self, file_path, patch_info, expected_img): @@ -150,10 +150,10 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"]["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch"]["level"], patch_info["level"]) - self.assertTupleEqual(meta["patch"]["size"], expected_img.shape[1:]) - self.assertTupleEqual(meta["patch"]["location"], patch_info["location"]) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], patch_info["level"]) + assert_array_equal(meta["patch_size"], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"], patch_info["location"]) @parameterized.expand([TEST_CASE_3]) def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): @@ -172,10 +172,10 @@ def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["wsi"][0]["path"], str(os.path.abspath(file_path_list[0]))) - self.assertEqual(meta["patch"][0]["level"], patch_info["level"]) - self.assertTupleEqual(meta["patch"][0]["size"], expected_img.shape[1:]) - self.assertTupleEqual(meta["patch"][0]["location"], patch_info["location"]) + self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch_level"][0], patch_info["level"]) + assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"][0], patch_info["location"]) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") @@ -225,6 +225,22 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte torch.testing.assert_allclose(s, expected_spatial_shape) self.assertTupleEqual(data["image"].shape, expected_shape) + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) + batch_size = 2 + data_loader = DataLoader(dataset, batch_size=batch_size) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + torch.testing.assert_allclose(s, expected_spatial_shape) + self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) + @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): From a5986ca39cf9f199b7be06106602223ef13f42a6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 21:09:31 +0000 Subject: [PATCH 30/67] Update channel last for wsireader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 20 ++++----- monai/data/wsi_reader.py | 85 +++++++++++++++++++------------------- monai/utils/enums.py | 6 +-- 3 files changed, 56 insertions(+), 55 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index a3668738a9..0884c2a2ce 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -294,12 +294,12 @@ def _evaluate_patch_locations(self, sample): mask_locations = np.round((patch_locations + patch_size_0 // 2) / float(mask_ratio)) # fill out samples with location and metadata - sample[WSIPatchKeys.SIZE] = patch_size - sample[WSIPatchKeys.LEVEL] = patch_level - sample[ProbMapKeys.COUNT] = len(patch_locations) - sample[ProbMapKeys.SIZE] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - {**sample, WSIPatchKeys.LOCATION: np.array(loc), ProbMapKeys.LOCATION: mask_loc} + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} for loc, mask_loc in zip(patch_locations, mask_locations) ] @@ -393,11 +393,11 @@ def _evaluate_patch_coordinates(self, sample): patch_locations = np.round((mask_locations + 0.5) * float(mask_ratio) - patch_size_0 // 2).astype(int) # fill out samples with location and metadata - sample[WSIPatchKeys.SIZE] = patch_size - sample[WSIPatchKeys.LEVEL] = patch_level - sample[ProbMapKeys.COUNT] = len(patch_locations) - sample[ProbMapKeys.SIZE] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + sample[WSIPatchKeys.SIZE.value] = patch_size + sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.COUNT.value] = len(patch_locations) + sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ - {**sample, WSIPatchKeys.LOCATION: np.array(loc), ProbMapKeys.LOCATION: mask_loc} + {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} for loc, mask_loc in zip(patch_locations, mask_locations) ] diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index eda36bdd93..411875a399 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,7 +18,7 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst +from monai.transforms.utility.array import AsChannelFirst, AsChannelLast from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") @@ -56,9 +56,10 @@ class BaseWSIReader(ImageReader): supported_suffixes: List[str] = [] backend = "" - def __init__(self, level: int, **kwargs): + def __init__(self, level: int, channel_last: bool = False, **kwargs): super().__init__() self.level = level + self.channel_last = channel_last self.kwargs = kwargs self.metadata: Dict[Any, Any] = {} @@ -140,10 +141,10 @@ def get_metadata( "backend": self.backend, "original_channel_dim": 0, "spatial_shape": np.asarray(patch.shape[1:]), - WSIPatchKeys.PATH: self.get_file_path(wsi), - WSIPatchKeys.LOCATION: np.asarray(location), - WSIPatchKeys.SIZE: np.asarray(size), - WSIPatchKeys.LEVEL: level, + WSIPatchKeys.PATH.value: self.get_file_path(wsi), + WSIPatchKeys.LOCATION.value: np.asarray(location), + WSIPatchKeys.SIZE.value: np.asarray(size), + WSIPatchKeys.LEVEL.value: level, } return metadata @@ -173,7 +174,7 @@ def get_data( and second element is a dictionary of metadata """ patch_list: List = [] - metadata = {} + metadata_list: List = [] # CuImage object is iterable, so ensure_tuple won't work on single object if not isinstance(wsi, List): wsi = [wsi] @@ -195,7 +196,7 @@ def get_data( # Verify size if size is None: if location != (0, 0): - raise ValueError("Patch size should be defined to exctract patches.") + raise ValueError("Patch size should be defined to extract patches.") size = self.get_size(each_wsi, level) else: if size[0] <= 0 or size[1] <= 0: @@ -220,31 +221,19 @@ def get_data( raise ValueError( f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " ) - # Create a list of patches - patch_list.append(patch) - - # Set patch-related metadata - each_meta = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) - - if len(wsi) == 1: - metadata = each_meta - else: - if not metadata: - metadata = { - "backend": each_meta["backend"], - "original_channel_dim": each_meta["original_channel_dim"], - "spatial_shape": each_meta["spatial_shape"], - } - for key in WSIPatchKeys: - metadata[key] = [each_meta[key]] - else: - if metadata["original_channel_dim"] != each_meta["original_channel_dim"]: - raise ValueError("original_channel_dim is not consistent across wsi objects.") - if any(metadata["spatial_shape"] != each_meta["spatial_shape"]): - raise ValueError("spatial_shape is not consistent across wsi objects.") - for key in WSIPatchKeys: - metadata[key].append(each_meta[key]) + # Get patch-related metadata + metadata: dict = self.get_metadata(wsi=each_wsi, patch=patch, location=location, size=size, level=level) + # Create a list of patches and metadata + patch_list.append(patch) + metadata_list.append(metadata) + if len(wsi) > 1: + if len({m["original_channel_dim"] for m in metadata_list}) > 1: + raise ValueError("original_channel_dim is not consistent across wsi objects.") + if len({tuple(m["spatial_shape"]) for m in metadata_list}) > 1: + raise ValueError("spatial_shape is not consistent across wsi objects.") + for key in WSIPatchKeys: + metadata[key] = [m[key] for m in metadata_list] return _stack_images(patch_list, metadata), metadata def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool: @@ -267,12 +256,14 @@ class WSIReader(BaseWSIReader): Args: backend: the name of backend whole slide image reader library, the default is cuCIM. level: the level at which patches are extracted. + channel_last: if True, the returned image will have color channel as the last dimension. + if False, the image will have color channel as the first dimension. Defaults to False, kwargs: additional arguments to be passed to the backend library """ - def __init__(self, backend="cucim", level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, backend="cucim", level: int = 0, channel_last: bool = False, **kwargs): + super().__init__(level, channel_last, **kwargs) self.backend = backend.lower() self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": @@ -360,6 +351,8 @@ class CuCIMWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_last: if True, the returned image will have color channel as the last dimension. + if False, the image will have color channel as the first dimension. Defaults to False, kwargs: additional args for `cucim.CuImage` module: https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h @@ -368,8 +361,8 @@ class CuCIMWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_last: bool = False, **kwargs): + super().__init__(level, channel_last, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -457,8 +450,11 @@ def get_patch( # Convert to numpy patch = np.asarray(patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make it channel first or last + if self.channel_last: + patch = AsChannelLast()(patch) # type: ignore + else: + patch = AsChannelFirst()(patch) # type: ignore # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode in "RGB": @@ -479,6 +475,8 @@ class OpenSlideWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. + channel_last: if True, the returned image will have color channel as the last dimension. + if False, the image will have color channel as the first dimension. Defaults to False, kwargs: additional args for `openslide.OpenSlide` module. """ @@ -486,8 +484,8 @@ class OpenSlideWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__(self, level: int = 0, **kwargs): - super().__init__(level, **kwargs) + def __init__(self, level: int = 0, channel_last: bool = False, **kwargs): + super().__init__(level, channel_last, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -577,7 +575,10 @@ def get_patch( # Convert to numpy patch = np.asarray(pil_patch, dtype=dtype) - # Make it channel first - patch = AsChannelFirst()(patch) # type: ignore + # Make it channel first or last + if self.channel_last: + patch = AsChannelLast()(patch) # type: ignore + else: + patch = AsChannelFirst()(patch) # type: ignore return patch diff --git a/monai/utils/enums.py b/monai/utils/enums.py index db1b681714..215cc521cd 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -333,7 +333,7 @@ class BoxModeName(Enum): CCCWHD = "cccwhd" # [xcenter, ycenter, zcenter, xsize, ysize, zsize] -class ProbMapKeys: +class ProbMapKeys(str, Enum): """ The keys to be used for generating the probability maps from patches """ @@ -345,7 +345,7 @@ class ProbMapKeys: PRE_PATH = "image" -class GridPatchSort: +class GridPatchSort(str, Enum): """ The sorting method for the generated patches in `GridPatch` """ @@ -355,7 +355,7 @@ class GridPatchSort: MAX = "max" -class WSIPatchKeys: +class WSIPatchKeys(str, Enum): """ The keys to be used for metadata of patches extracted from whole slide images """ From a782bc5952bb1cdf3aa63cbe42f14b1afbf99cf6 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 21:43:19 +0000 Subject: [PATCH 31/67] formmating Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 12 ++---------- monai/utils/__init__.py | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 0884c2a2ce..a6df2eee95 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -205,11 +205,7 @@ def __init__( transform: Optional[Callable] = None, include_label: bool = False, center_location: bool = False, - additional_meta_keys: Sequence[str] = ( - ProbMapKeys.LOCATION, - ProbMapKeys.SIZE, - ProbMapKeys.COUNT, - ), + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.SIZE, ProbMapKeys.COUNT), reader="cuCIM", seed: int = 0, **kwargs, @@ -347,11 +343,7 @@ def __init__( transform: Optional[Callable] = None, include_label: bool = False, center_location: bool = False, - additional_meta_keys: Sequence[str] = ( - ProbMapKeys.LOCATION, - ProbMapKeys.SIZE, - ProbMapKeys.COUNT, - ), + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.SIZE, ProbMapKeys.COUNT), reader="cuCIM", **kwargs, ): diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 8a597f5182..33b2a5fa2a 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -32,7 +32,6 @@ Method, MetricReduction, NumpyPadMode, - WSIPatchKeys, PostFix, ProbMapKeys, PytorchPadMode, @@ -41,6 +40,7 @@ TransformBackends, UpsampleMode, Weight, + WSIPatchKeys, ) from .jupyter_utils import StatusMembers, ThreadContainer from .misc import ( From 45169eb65a03dd4a7fd0e6572bbfea5bb8bf1c6a Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 21:47:32 +0000 Subject: [PATCH 32/67] Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 9ac7de8599..2eb2537b49 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -1350,11 +1350,8 @@ Intensity (Dict) `ForegroundMaskd` """"""""""""""""" -<<<<<<< HEAD -======= .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/ForegroundMaskd.png :alt: example of ForegroundMaskd ->>>>>>> a737d5855a2afdcf6c3bbf7d9a39cd2ef61337c1 .. autoclass:: ForegroundMaskd :members: :special-members: __call__ From 2944e091b98206eb6df781eb10c4b17767f50d54 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 22:05:59 +0000 Subject: [PATCH 33/67] Fix a typo Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index a6df2eee95..3de7853ecd 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -52,7 +52,7 @@ class PatchWSIDataset(Dataset): [ {"image": "path/to/image1.tiff", "patch_location": [200, 500], "label": 0}, - {"image": "path/to/image2.tiff", "patch_location": [100, 700], "patch_size": [20, 20], "level_size": 2, "label": 1} + {"image": "path/to/image2.tiff", "patch_location": [100, 700], "patch_size": [20, 20], "patch_level": 2, "label": 1} ] """ From ad4a3f184242a8ef3be90e719ef7c25800562d58 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 31 May 2022 23:35:29 +0000 Subject: [PATCH 34/67] Update channel_last and add test cases Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 40 +++++++++++++++++++------------------ tests/test_wsireader_new.py | 28 ++++++++++++++++++++------ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 411875a399..fd1044bc80 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,7 +18,7 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst, AsChannelLast +from monai.transforms.utility.array import AsChannelFirst from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") @@ -60,6 +60,7 @@ def __init__(self, level: int, channel_last: bool = False, **kwargs): super().__init__() self.level = level self.channel_last = channel_last + self.channel_dim = -1 if self.channel_last else 0 self.kwargs = kwargs self.metadata: Dict[Any, Any] = {} @@ -139,8 +140,8 @@ def get_metadata( """ metadata: Dict = { "backend": self.backend, - "original_channel_dim": 0, - "spatial_shape": np.asarray(patch.shape[1:]), + "original_channel_dim": self.channel_dim, + "spatial_shape": np.asarray(patch.shape[:-1] if self.channel_last else patch.shape[1:]), WSIPatchKeys.PATH.value: self.get_file_path(wsi), WSIPatchKeys.LOCATION.value: np.asarray(location), WSIPatchKeys.SIZE.value: np.asarray(size), @@ -211,15 +212,19 @@ def get_data( f"The image dimension should be 3 but has {patch.ndim}. " "`WSIReader` is designed to work only with 2D images with color channel." ) + self.channel_dim = -1 if self.channel_last else 0 # Check if there are four color channels for RGBA - if mode == "RGBA" and patch.shape[0] != 4: - raise ValueError( - f"The image is expected to have four color channels in '{mode}' mode but has {patch.shape[0]}." - ) + if mode == "RGBA": + if patch.shape[self.channel_dim] != 4: + raise ValueError( + f"The image is expected to have four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}." + ) # Check if there are three color channels for RGB - elif mode in "RGB" and patch.shape[0] != 3: + elif mode in "RGB" and patch.shape[self.channel_dim] != 3: raise ValueError( - f"The image is expected to have three color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) # Get patch-related metadata @@ -267,9 +272,9 @@ def __init__(self, backend="cucim", level: int = 0, channel_last: bool = False, self.backend = backend.lower() self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": - self.reader = CuCIMWSIReader(level=level, **kwargs) + self.reader = CuCIMWSIReader(level=level, channel_last=channel_last, **kwargs) elif self.backend == "openslide": - self.reader = OpenSlideWSIReader(level=level, **kwargs) + self.reader = OpenSlideWSIReader(level=level, channel_last=channel_last, **kwargs) else: raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.") self.supported_suffixes = self.reader.supported_suffixes @@ -451,16 +456,15 @@ def get_patch( patch = np.asarray(patch, dtype=dtype) # Make it channel first or last - if self.channel_last: - patch = AsChannelLast()(patch) # type: ignore - else: + if not self.channel_last: patch = AsChannelFirst()(patch) # type: ignore # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode in "RGB": - if patch.shape[0] not in [3, 4]: + if patch.shape[self.channel_dim] not in [3, 4]: raise ValueError( - f"The image is expected to have three or four color channels in '{mode}' mode but has {patch.shape[0]}. " + f"The image is expected to have three or four color channels in '{mode}' mode but has " + f"{patch.shape[self.channel_dim]}. " ) patch = patch[:3] @@ -576,9 +580,7 @@ def get_patch( patch = np.asarray(pil_patch, dtype=dtype) # Make it channel first or last - if self.channel_last: - patch = AsChannelLast()(patch) # type: ignore - else: + if not self.channel_last: patch = AsChannelFirst()(patch) # type: ignore return patch diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 35aaccad26..86b7b2a506 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -46,17 +46,33 @@ TEST_CASE_1 = [ FILE_PATH, + {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, + {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ + FILE_PATH, + {"channel_last": True}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), +] + +TEST_CASE_4 = [ + FILE_PATH, + {"channel_last": True}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), +] + +TEST_CASE_MULTI_WSI = [ [FILE_PATH, FILE_PATH], {"location": (0, 0), "size": (2, 1), "level": 2}, np.concatenate( @@ -68,6 +84,7 @@ ), ] + TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW @@ -133,11 +150,10 @@ def test_read_whole_image(self, file_path, level, expected_shape): assert_array_equal(meta["patch_size"], expected_shape[1:]) assert_array_equal(meta["patch_location"], (0, 0)) - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_read_region(self, file_path, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path, **kwargs) as img_obj: + with reader.read(file_path) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] @@ -152,10 +168,10 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertEqual(meta["backend"], self.backend) self.assertEqual(meta["path"], str(os.path.abspath(file_path))) self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], expected_img.shape[1:]) + assert_array_equal(meta["patch_size"], patch_info["size"]) assert_array_equal(meta["patch_location"], patch_info["location"]) - @parameterized.expand([TEST_CASE_3]) + @parameterized.expand([TEST_CASE_MULTI_WSI]) def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) From a2391043d979a777b892f772d8da7db24adc7762 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 00:47:46 +0000 Subject: [PATCH 35/67] add image_data to masked dataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 15 +++++++++++---- monai/handlers/__init__.py | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 3de7853ecd..9760717dcd 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -221,7 +221,6 @@ def __init__( reader=reader, **kwargs, ) - self.mask_level = mask_level self.overlap = overlap self.set_random_state(seed) # Set the offset config @@ -251,6 +250,7 @@ def __init__( else: self.offset = ensure_tuple_rep(offset, 2) + self.mask_level = mask_level # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data @@ -358,14 +358,18 @@ def __init__( reader=reader, **kwargs, ) + self.mask_level = mask_level # Create single sample for each patch (in a sliding window manner) self.data = [] - for sample in data: - patch_samples = self._evaluate_patch_coordinates(sample) + self.image_data = data + print(f"{self.image_data=}") + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) + print(f"{self.data[:2]=}") - def _evaluate_patch_coordinates(self, sample): + def _evaluate_patch_locations(self, sample): """Calculate the location for each patch based on the mask at different resolution level""" patch_size = self._get_size(sample) patch_level = self._get_level(sample) @@ -377,6 +381,9 @@ def _evaluate_patch_coordinates(self, sample): # create the foreground tissue mask and get all indices for non-zero pixels mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) mask_locations = np.vstack(mask.nonzero()).T + print(f"{wsi.shape=}") + print(f"{mask.shape=}") + print(f"{mask_locations.shape=}") # convert mask locations to image locations at level=0 mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 43fd634b49..cffbe46391 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -26,6 +26,7 @@ from .nvtx_handlers import MarkHandler, RangeHandler, RangePopHandler, RangePushHandler from .parameter_scheduler import ParamSchedulerHandler from .postprocessing import PostProcessing +from .probability_maps import ProbMapProducer from .regression_metrics import MeanAbsoluteError, MeanSquaredError, PeakSignalToNoiseRatio, RootMeanSquaredError from .roc_auc import ROCAUC from .smartcache_handler import SmartCacheHandler From 1c3bd38b3d5b097129ca027e204c94e7ef3af23d Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 00:51:21 +0000 Subject: [PATCH 36/67] Pull prob map Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 5 -- monai/handlers/probability_maps.py | 121 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 monai/handlers/probability_maps.py diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 9760717dcd..dc460d309a 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -363,11 +363,9 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data - print(f"{self.image_data=}") for sample in self.image_data: patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) - print(f"{self.data[:2]=}") def _evaluate_patch_locations(self, sample): """Calculate the location for each patch based on the mask at different resolution level""" @@ -381,9 +379,6 @@ def _evaluate_patch_locations(self, sample): # create the foreground tissue mask and get all indices for non-zero pixels mask = np.squeeze(ForegroundMask(hsv_threshold={"S": "otsu"})(wsi)) mask_locations = np.vstack(mask.nonzero()).T - print(f"{wsi.shape=}") - print(f"{mask.shape=}") - print(f"{mask_locations.shape=}") # convert mask locations to image locations at level=0 mask_ratio = self.wsi_reader.get_downsample_ratio(wsi_obj, self.mask_level) diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py new file mode 100644 index 0000000000..03588e605b --- /dev/null +++ b/monai/handlers/probability_maps.py @@ -0,0 +1,121 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import threading +from typing import TYPE_CHECKING, Dict, Optional + +import numpy as np + +from monai.config import DtypeLike, IgniteInfo +from monai.utils import ProbMapKeys, min_version, optional_import + +Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") +if TYPE_CHECKING: + from ignite.engine import Engine +else: + Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") + + +class ProbMapProducer: + """ + Event handler triggered on completing every iteration to calculate and save the probability map + """ + + def __init__( + self, + output_dir: str = "./", + output_postfix: str = "", + prob_key: str = "pred", + dtype: DtypeLike = np.float64, + name: Optional[str] = None, + ) -> None: + """ + Args: + output_dir: output directory to save probability maps. + output_postfix: a string appended to all output file names. + prob_key: the key associated to the probability output of the model + dtype: the data type in which the probability map is stored. Default np.float64. + name: identifier of logging.logger to use, defaulting to `engine.logger`. + + """ + self.logger = logging.getLogger(name) + self._name = name + self.output_dir = output_dir + self.output_postfix = output_postfix + self.prob_key = prob_key + self.dtype = dtype + self.prob_map: Dict[str, np.ndarray] = {} + self.counter: Dict[str, int] = {} + self.num_done_images: int = 0 + self.num_images: int = 0 + self.lock = threading.Lock() + + def attach(self, engine: Engine) -> None: + """ + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + + image_data = engine.data_loader.dataset.image_data # type: ignore + self.num_images = len(image_data) + + # Initialized probability maps for all the images + for sample in image_data: + name = sample[ProbMapKeys.PRE_PATH.value] + self.counter[name] = sample[ProbMapKeys.COUNT.value] + self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE.value], dtype=self.dtype) + + if self._name is None: + self.logger = engine.logger + if not engine.has_event_handler(self, Events.ITERATION_COMPLETED): + engine.add_event_handler(Events.ITERATION_COMPLETED, self) + if not engine.has_event_handler(self.finalize, Events.COMPLETED): + engine.add_event_handler(Events.COMPLETED, self.finalize) + + def __call__(self, engine: Engine) -> None: + """ + This method assumes self.batch_transform will extract metadata from the input batch. + + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + if not isinstance(engine.state.batch, dict) or not isinstance(engine.state.output, dict): + raise ValueError("engine.state.batch and engine.state.output must be dictionaries.") + names = engine.state.batch["metadata"][ProbMapKeys.PATH.value] + locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION.value] + probs = engine.state.output[self.prob_key] + for name, loc, prob in zip(names, locs, probs): + self.prob_map[name][loc] = prob + with self.lock: + self.counter[name] -= 1 + if self.counter[name] == 0: + self.save_prob_map(name) + + def save_prob_map(self, name: str) -> None: + """ + This method save the probability map for an image, when its inference is finished, + and delete that probability map from memory. + + Args: + name: the name of image to be saved. + """ + file_path = os.path.join(self.output_dir, name) + np.save(file_path + self.output_postfix + ".npy", self.prob_map[name]) + + self.num_done_images += 1 + self.logger.info(f"Inference of '{name}' is done [{self.num_done_images}/{self.num_images}]!") + del self.prob_map[name] + del self.counter[name] + + def finalize(self, engine: Engine): + self.logger.info(f"Probability map is created for {self.num_done_images}/{self.num_images} images!") From b437de8ad8fb5fdd1934a0a3267cf1872963e9b9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 01:36:11 +0000 Subject: [PATCH 37/67] Update ProbMapKeys.NAME Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 18 +++++++++++------- monai/handlers/probability_maps.py | 10 +++++----- monai/utils/enums.py | 3 +-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index dc460d309a..c0db7767c8 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -254,8 +254,9 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data - for sample in self.image_data: - patch_samples = self._evaluate_patch_locations(sample) + self.counter = 0 + for i, sample in enumerate(self.image_data): + patch_samples = self._evaluate_patch_locations(sample, i) self.data.extend(patch_samples) def _get_offset(self, sample): @@ -267,7 +268,7 @@ def _get_offset(self, sample): return tuple(self.R.randint(low, high) for low, high in offset_limits) return self.offset - def _evaluate_patch_locations(self, sample): + def _evaluate_patch_locations(self, sample, image_name): """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) patch_level = self._get_level(sample) @@ -292,6 +293,7 @@ def _evaluate_patch_locations(self, sample): # fill out samples with location and metadata sample[WSIPatchKeys.SIZE.value] = patch_size sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = image_name sample[ProbMapKeys.COUNT.value] = len(patch_locations) sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ @@ -343,7 +345,7 @@ def __init__( transform: Optional[Callable] = None, include_label: bool = False, center_location: bool = False, - additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.SIZE, ProbMapKeys.COUNT), + additional_meta_keys: Sequence[str] = (ProbMapKeys.LOCATION, ProbMapKeys.NAME), reader="cuCIM", **kwargs, ): @@ -363,11 +365,12 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data - for sample in self.image_data: - patch_samples = self._evaluate_patch_locations(sample) + self.counter = 0 + for i, sample in enumerate(self.image_data): + patch_samples = self._evaluate_patch_locations(sample, str(i)) self.data.extend(patch_samples) - def _evaluate_patch_locations(self, sample): + def _evaluate_patch_locations(self, sample, image_name): """Calculate the location for each patch based on the mask at different resolution level""" patch_size = self._get_size(sample) patch_level = self._get_level(sample) @@ -389,6 +392,7 @@ def _evaluate_patch_locations(self, sample): # fill out samples with location and metadata sample[WSIPatchKeys.SIZE.value] = patch_size sample[WSIPatchKeys.LEVEL.value] = patch_level + sample[ProbMapKeys.NAME.value] = image_name sample[ProbMapKeys.COUNT.value] = len(patch_locations) sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ diff --git a/monai/handlers/probability_maps.py b/monai/handlers/probability_maps.py index 03588e605b..5c15704d50 100644 --- a/monai/handlers/probability_maps.py +++ b/monai/handlers/probability_maps.py @@ -71,9 +71,9 @@ def attach(self, engine: Engine) -> None: # Initialized probability maps for all the images for sample in image_data: - name = sample[ProbMapKeys.PRE_PATH.value] - self.counter[name] = sample[ProbMapKeys.COUNT.value] - self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE.value], dtype=self.dtype) + name = sample[ProbMapKeys.NAME] + self.counter[name] = sample[ProbMapKeys.COUNT] + self.prob_map[name] = np.zeros(sample[ProbMapKeys.SIZE], dtype=self.dtype) if self._name is None: self.logger = engine.logger @@ -91,8 +91,8 @@ def __call__(self, engine: Engine) -> None: """ if not isinstance(engine.state.batch, dict) or not isinstance(engine.state.output, dict): raise ValueError("engine.state.batch and engine.state.output must be dictionaries.") - names = engine.state.batch["metadata"][ProbMapKeys.PATH.value] - locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION.value] + names = engine.state.batch["metadata"][ProbMapKeys.NAME] + locs = engine.state.batch["metadata"][ProbMapKeys.LOCATION] probs = engine.state.output[self.prob_key] for name, loc, prob in zip(names, locs, probs): self.prob_map[name][loc] = prob diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 215cc521cd..e7a2d51cfb 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -341,8 +341,7 @@ class ProbMapKeys(str, Enum): LOCATION = "mask_location" SIZE = "mask_size" COUNT = "num_patches" - PATH = "path" - PRE_PATH = "image" + NAME = "name" class GridPatchSort(str, Enum): From 238daaba80569bbdfec763f3ecdd97ac17f0f9f5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:20:04 +0000 Subject: [PATCH 38/67] Update GridPatch and iter_patch to support no padding Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/utils.py | 29 ++++++++----- monai/transforms/spatial/array.py | 59 ++++++++++++++------------ monai/transforms/spatial/dictionary.py | 2 +- monai/utils/__init__.py | 1 + monai/utils/enums.py | 31 ++++++++++++++ 5 files changed, 83 insertions(+), 39 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 93f96feae9..ffb0ecac86 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -285,19 +285,28 @@ def iter_patch( patch_size_ = get_valid_patch_size(arr.shape, patch_size) start_pos = ensure_tuple_size(start_pos, arr.ndim) + # set padded flag to false if pad mode is None + padded = True if mode else False # pad image by maximum values needed to ensure patches are taken from inside an image - arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) - - # choose a start position in the padded image - start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) - - # choose a size to iterate over which is smaller than the actual padded image to prevent producing - # patches which are only in the padded regions - iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + if padded: + arrpad = np.pad(arr, tuple((p, p) for p in patch_size_), look_up_option(mode, NumpyPadMode).value, **pad_opts) + # choose a start position in the padded image + start_pos_padded = tuple(s + p for s, p in zip(start_pos, patch_size_)) + + # choose a size to iterate over which is smaller than the actual padded image to prevent producing + # patches which are only in the padded regions + iter_size = tuple(s + p for s, p in zip(arr.shape, patch_size_)) + else: + arrpad = arr + start_pos_padded = start_pos + iter_size = arr.shape - for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap): + for slices in iter_patch_slices(iter_size, patch_size_, start_pos_padded, overlap, padded=padded): # compensate original image padding - coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + if padded: + coords_no_pad = tuple((coord.start - p, coord.stop - p) for coord, p in zip(slices, patch_size_)) + else: + coords_no_pad = tuple((coord.start, coord.stop) for coord in slices) yield arrpad[slices], np.asarray(coords_no_pad) # data and coords (in numpy; works with torch loader) # copy back data from the padded image if required diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 37469b6b1e..7dec80653b 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -62,7 +62,7 @@ pytorch_after, ) from monai.utils.deprecate_utils import deprecated_arg -from monai.utils.enums import GridPatchSort, TransformBackends +from monai.utils.enums import GridPatchFilter, GridPatchSort, TransformBackends from monai.utils.misc import ImageMetaKey as Key from monai.utils.module import look_up_option from monai.utils.type_conversion import convert_data_type @@ -2637,6 +2637,7 @@ class GridPatch(Transform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. + filter_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2647,42 +2648,35 @@ class GridPatch(Transform): def __init__( self, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + filter_fn: Optional[Callable] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): self.patch_size = ensure_tuple(patch_size) - self.offset = ensure_tuple(offset) - self.pad_mode: NumpyPadMode = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) + if offset: + self.offset = ensure_tuple(offset) + else: + self.offset = (0,) * len(self.patch_size) + self.pad_mode: Optional[NumpyPadMode] = ( + convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else pad_mode + ) self.pad_kwargs = pad_kwargs self.overlap = overlap self.num_patches = num_patches self.sort_fn: Optional[Callable] if isinstance(sort_fn, str): - if sort_fn == GridPatchSort.RANDOM: - self.sort_fn = np.random.random - elif sort_fn == GridPatchSort.MIN: - self.sort_fn = self.get_patch_sum - elif sort_fn == GridPatchSort.MAX: - self.sort_fn = self.get_negative_patch_sum - else: - raise ValueError( - f'sort_fn should be one of the following values, "{sort_fn}" was given:', - [enum.value for enum in GridPatchSort], - ) + self.sort_fn = GridPatchSort.get_sort_fn(sort_fn) else: self.sort_fn = sort_fn + self.filter_fn = filter_fn or self.one_fn @staticmethod - def get_patch_sum(x): - return x[0].sum() - - @staticmethod - def get_negative_patch_sum(x): - return -x[0].sum() + def one_fn(x): + return True def __call__(self, array: NdarrayOrTensor): # create the patch iterator which sweeps the image row-by-row @@ -2696,19 +2690,26 @@ def __call__(self, array: NdarrayOrTensor): mode=self.pad_mode, **self.pad_kwargs, ) + if self.sort_fn is not None: - output = sorted(patch_iterator, key=self.sort_fn) - else: - output = list(patch_iterator) + patch_iterator = sorted(patch_iterator, key=self.sort_fn) + + output = [ + (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices, dst=array)[0]) + for patch, slices in patch_iterator + if self.filter_fn(patch) + ] + if self.num_patches: output = output[: self.num_patches] if len(output) < self.num_patches: - patch = np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)) - slices = np.zeros((3, len(self.patch_size))) + patch = convert_to_dst_type( + src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), + dst=array, + )[0] + slices = convert_to_dst_type(src=np.zeros((3, len(self.patch_size))), dst=array)[0] output += [(patch, slices)] * (self.num_patches - len(output)) - output = [convert_to_dst_type(src=patch, dst=array)[0] for patch in output] - return output @@ -2746,6 +2747,7 @@ def __init__( num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + filter_fn: Optional[Callable] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): @@ -2755,6 +2757,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + filter_fn=filter_fn, pad_mode=pad_mode, **pad_kwargs, ) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 4a38dbbb59..b7bcbb971b 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -2240,7 +2240,7 @@ def __init__( self, keys: KeysCollection, patch_size: Sequence[int], - offset: Sequence[int] = (), + offset: Optional[Sequence[int]] = None, num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 33b2a5fa2a..f41bba652f 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -22,6 +22,7 @@ CommonKeys, DiceCEReduction, ForwardMode, + GridPatchFilter, GridPatchSort, GridSampleMode, GridSamplePadMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index e7a2d51cfb..ee07cdcf3e 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import random from enum import Enum from typing import Optional @@ -353,6 +354,36 @@ class GridPatchSort(str, Enum): MIN = "min" MAX = "max" + @staticmethod + def min_fn(x): + return x[0].sum() + + @staticmethod + def max_fn(x): + return -x[0].sum() + + @staticmethod + def get_sort_fn(sort_fn): + if sort_fn == GridPatchSort.RANDOM: + return random.random + elif sort_fn == GridPatchSort.MIN: + return GridPatchSort.min_fn + elif sort_fn == GridPatchSort.MAX: + return GridPatchSort.max_fn + else: + raise ValueError( + f'sort_fn should be one of the following values, "{sort_fn}" was given:', + [e.value for e in GridPatchSort], + ) + + +class GridPatchFilter(str, Enum): + """ + The sorting method for the generated patches in `GridPatch` + """ + + IDENTITY = lambda x: 1 + class WSIPatchKeys(str, Enum): """ From a77975ef13e12909e31b475aaec86112054718a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:20:41 +0000 Subject: [PATCH 39/67] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/transforms/spatial/array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 7dec80653b..aa5c7087e7 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -62,7 +62,7 @@ pytorch_after, ) from monai.utils.deprecate_utils import deprecated_arg -from monai.utils.enums import GridPatchFilter, GridPatchSort, TransformBackends +from monai.utils.enums import GridPatchSort, TransformBackends from monai.utils.misc import ImageMetaKey as Key from monai.utils.module import look_up_option from monai.utils.type_conversion import convert_data_type From 4d0ea64b4d7b52a89e2fe54d934df392133b4e1b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:27:40 +0000 Subject: [PATCH 40/67] Remove enum filter Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/utils.py | 2 +- monai/transforms/spatial/array.py | 4 +--- monai/utils/__init__.py | 1 - monai/utils/enums.py | 8 -------- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index ffb0ecac86..f0188bbfba 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -247,7 +247,7 @@ def iter_patch( start_pos: Sequence[int] = (), overlap: Union[Sequence[float], float] = 0.0, copy_back: bool = True, - mode: Union[NumpyPadMode, str] = NumpyPadMode.WRAP, + mode: Optional[Union[NumpyPadMode, str]] = NumpyPadMode.WRAP, **pad_opts: Dict, ): """ diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index aa5c7087e7..3830256f04 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2661,9 +2661,7 @@ def __init__( self.offset = ensure_tuple(offset) else: self.offset = (0,) * len(self.patch_size) - self.pad_mode: Optional[NumpyPadMode] = ( - convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else pad_mode - ) + self.pad_mode: Optional[NumpyPadMode] = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else None self.pad_kwargs = pad_kwargs self.overlap = overlap self.num_patches = num_patches diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index f41bba652f..33b2a5fa2a 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -22,7 +22,6 @@ CommonKeys, DiceCEReduction, ForwardMode, - GridPatchFilter, GridPatchSort, GridSampleMode, GridSamplePadMode, diff --git a/monai/utils/enums.py b/monai/utils/enums.py index ee07cdcf3e..11d1981b99 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -377,14 +377,6 @@ def get_sort_fn(sort_fn): ) -class GridPatchFilter(str, Enum): - """ - The sorting method for the generated patches in `GridPatch` - """ - - IDENTITY = lambda x: 1 - - class WSIPatchKeys(str, Enum): """ The keys to be used for metadata of patches extracted from whole slide images From be00a4249c9c6047387d947e4dcccc23b6976b51 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 15:49:24 +0000 Subject: [PATCH 41/67] Update filter_fn for dicts and update docstings: Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/spatial/array.py | 15 +++++++++------ monai/transforms/spatial/dictionary.py | 20 ++++++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 3830256f04..dd6657e520 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2637,8 +2637,9 @@ class GridPatch(Transform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - filter_fn: a callable or string that defines the order of the patches to be returned. If it is a callable, it - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ @@ -2693,7 +2694,7 @@ def __call__(self, array: NdarrayOrTensor): patch_iterator = sorted(patch_iterator, key=self.sort_fn) output = [ - (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices, dst=array)[0]) + (convert_to_dst_type(src=patch, dst=array)[0], convert_to_dst_type(src=slices[..., 0], dst=array)[0]) for patch, slices in patch_iterator if self.filter_fn(patch) ] @@ -2705,8 +2706,8 @@ def __call__(self, array: NdarrayOrTensor): src=np.full((array.shape[0], *self.patch_size), self.pad_kwargs.get("constant_values", 0)), dst=array, )[0] - slices = convert_to_dst_type(src=np.zeros((3, len(self.patch_size))), dst=array)[0] - output += [(patch, slices)] * (self.num_patches - len(output)) + start_location = convert_to_dst_type(src=np.zeros((len(self.patch_size), 1)), dst=array)[0] + output += [(patch, start_location)] * (self.num_patches - len(output)) return output @@ -2730,7 +2731,9 @@ class RandGridPatch(GridPatch, RandomizableTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. """ diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index b7bcbb971b..d25d5b3a64 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -2220,7 +2220,9 @@ class GridPatchd(MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2228,7 +2230,7 @@ class GridPatchd(MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of upper left patch) @@ -2244,6 +2246,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + filter_fn: Optional[Callable] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2255,6 +2258,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + filter_fn=filter_fn, pad_mode=pad_mode, **pad_kwargs, ) @@ -2272,7 +2276,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item + new_dict["patch_location"] = patch[0][1][..., 0] # use the starting coordinate of the first item new_dict["patch_size"] = self.patcher.patch_size new_dict["num_patches"] = num_patches new_dict["offset"] = self.patcher.offset @@ -2300,7 +2304,9 @@ class RandGridPatchd(RandomizableTransform, MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - pad_mode: refer to NumpyPadMode and PytorchPadMode. Defaults to ``"constant"``. + filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + Defaults to no filtering. + pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2308,7 +2314,7 @@ class RandGridPatchd(RandomizableTransform, MapTransform): a list of dictionaries, each of which contains the all the original key/value with the values for `keys` replaced by the patches. It also add the following new keys: - "slices": slices from the image that defines the patch, + "patch_location": the starting location of the patch in the image, "patch_size": size of the extracted patch "num_patches": total number of patches in the image "offset": the amount of offset for the patches in the image (starting position of the first patch) @@ -2326,6 +2332,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, + filter_fn: Optional[Callable] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2338,6 +2345,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, + filter_fn=filter_fn, pad_mode=pad_mode, **pad_kwargs, ) @@ -2368,7 +2376,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["slices"] = patch[0][1] # use the coordinate of the first item + new_dict["patch_location"] = patch[0][1] # use the starting coordinate of the first item new_dict["patch_size"] = self.patcher.patch_size new_dict["num_patches"] = num_patches new_dict["offset"] = self.patcher.offset From 748d79342810c2340efcd4054de156698fc17381 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 16:16:16 +0000 Subject: [PATCH 42/67] Fix typos Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/apps/pathology/transforms/spatial/array.py | 2 +- monai/transforms/spatial/dictionary.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py index 34cabac50c..ce22b86d49 100644 --- a/monai/apps/pathology/transforms/spatial/array.py +++ b/monai/apps/pathology/transforms/spatial/array.py @@ -211,7 +211,7 @@ def __call__(self, image: NdarrayOrTensor) -> NdarrayOrTensor: constant_values=self.background_val, ) - # extact tiles + # extract tiles x_step, y_step = self.step, self.step h_tile, w_tile = self.tile_size, self.tile_size c_image, h_image, w_image = img_np.shape diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d25d5b3a64..f096f598ca 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -2170,7 +2170,7 @@ class GridSplitd(MapTransform): keys: keys of the corresponding items to be transformed. grid: a tuple define the shape of the grid upon which the image is split. Defaults to (2, 2) size: a tuple or an integer that defines the output patch sizes, - or a dictionary that define it seperately for each key, like {"image": 3, "mask", (2, 2)}. + or a dictionary that define it separately for each key, like {"image": 3, "mask", (2, 2)}. If it's an integer, the value will be repeated for each dimension. The default is None, where the patch size will be inferred from the grid shape. allow_missing_keys: don't raise exception if key is missing. From e9fde98e21a25e7fe37e8c39fa74f8e3ad0d56f3 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 16:23:02 +0000 Subject: [PATCH 43/67] Change name value for prob map Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 8887ddbd44..462cf6d1cb 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -10,6 +10,7 @@ # limitations under the License. import inspect +import os from typing import Callable, Dict, Optional, Sequence, Tuple, Union import numpy as np @@ -256,9 +257,8 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data - self.counter = 0 - for i, sample in enumerate(self.image_data): - patch_samples = self._evaluate_patch_locations(sample, i) + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) def _get_offset(self, sample): @@ -270,7 +270,7 @@ def _get_offset(self, sample): return tuple(self.R.randint(low, high) for low, high in offset_limits) return self.offset - def _evaluate_patch_locations(self, sample, image_name): + def _evaluate_patch_locations(self, sample): """Calculate the location for each patch in a sliding-window manner""" patch_size = self._get_size(sample) patch_level = self._get_level(sample) @@ -295,7 +295,7 @@ def _evaluate_patch_locations(self, sample, image_name): # fill out samples with location and metadata sample[WSIPatchKeys.SIZE.value] = patch_size sample[WSIPatchKeys.LEVEL.value] = patch_level - sample[ProbMapKeys.NAME.value] = image_name + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) sample[ProbMapKeys.COUNT.value] = len(patch_locations) sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ @@ -367,12 +367,11 @@ def __init__( # Create single sample for each patch (in a sliding window manner) self.data = [] self.image_data = data - self.counter = 0 - for i, sample in enumerate(self.image_data): - patch_samples = self._evaluate_patch_locations(sample, str(i)) + for sample in self.image_data: + patch_samples = self._evaluate_patch_locations(sample) self.data.extend(patch_samples) - def _evaluate_patch_locations(self, sample, image_name): + def _evaluate_patch_locations(self, sample): """Calculate the location for each patch based on the mask at different resolution level""" patch_size = self._get_size(sample) patch_level = self._get_level(sample) @@ -394,7 +393,7 @@ def _evaluate_patch_locations(self, sample, image_name): # fill out samples with location and metadata sample[WSIPatchKeys.SIZE.value] = patch_size sample[WSIPatchKeys.LEVEL.value] = patch_level - sample[ProbMapKeys.NAME.value] = image_name + sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) sample[ProbMapKeys.COUNT.value] = len(patch_locations) sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) return [ From 787b3adcfae53dc73fe1bd464a1041f3469bb742 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 17:02:46 +0000 Subject: [PATCH 44/67] Update prob map unittest Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_handler_prob_map_producer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_handler_prob_map_producer.py b/tests/test_handler_prob_map_producer.py index 38ab7044b2..399657ac60 100644 --- a/tests/test_handler_prob_map_producer.py +++ b/tests/test_handler_prob_map_producer.py @@ -42,7 +42,11 @@ def __init__(self, name, size): ] ) self.image_data = [ - {"image": name, ProbMapKeys.COUNT.value: size, ProbMapKeys.SIZE.value: np.array([size, size])} + { + ProbMapKeys.NAME.value: name, + ProbMapKeys.COUNT.value: size, + ProbMapKeys.SIZE.value: np.array([size, size]), + } ] def __getitem__(self, index): @@ -50,7 +54,7 @@ def __getitem__(self, index): "image": np.zeros((3, 2, 2)), ProbMapKeys.COUNT.value: self.data[index][ProbMapKeys.COUNT.value], "metadata": { - "path": self.data[index]["image"], + ProbMapKeys.NAME.value: self.data[index]["image"], ProbMapKeys.SIZE.value: self.data[index][ProbMapKeys.SIZE.value], ProbMapKeys.LOCATION.value: self.data[index][ProbMapKeys.LOCATION.value], }, From 14d206d5fa645f11e3f4759337bbe4c17a0d5b73 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 1 Jun 2022 19:57:15 +0000 Subject: [PATCH 45/67] Read mask shape directly Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_datasets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/data/wsi_datasets.py b/monai/data/wsi_datasets.py index 462cf6d1cb..689e25d8ca 100644 --- a/monai/data/wsi_datasets.py +++ b/monai/data/wsi_datasets.py @@ -395,7 +395,7 @@ def _evaluate_patch_locations(self, sample): sample[WSIPatchKeys.LEVEL.value] = patch_level sample[ProbMapKeys.NAME.value] = os.path.basename(sample[CommonKeys.IMAGE]) sample[ProbMapKeys.COUNT.value] = len(patch_locations) - sample[ProbMapKeys.SIZE.value] = np.array(self.wsi_reader.get_size(wsi_obj, self.mask_level)) + sample[ProbMapKeys.SIZE.value] = mask.shape return [ {**sample, WSIPatchKeys.LOCATION.value: np.array(loc), ProbMapKeys.LOCATION.value: mask_loc} for loc, mask_loc in zip(patch_locations, mask_locations) From 835fa1e2db085231bc4eb4dc8ccb449fc21499c0 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 2 Jun 2022 00:22:26 +0000 Subject: [PATCH 46/67] Add threshold for filtering to grid patch Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/spatial/array.py | 23 ++++++++++++++++------- monai/transforms/spatial/dictionary.py | 12 ++++++------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 464d03df4e..f18902f5dc 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2637,7 +2637,7 @@ class GridPatch(Transform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2653,7 +2653,7 @@ def __init__( num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, - filter_fn: Optional[Callable] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): @@ -2671,12 +2671,21 @@ def __init__( self.sort_fn = GridPatchSort.get_sort_fn(sort_fn) else: self.sort_fn = sort_fn - self.filter_fn = filter_fn or self.one_fn + + self.threshold = threshold + if threshold: + self.filter_fn = self.threshold_fn + else: + self.filter_fn = self.one_fn @staticmethod - def one_fn(x): + def one_fn(patch): return True + @staticmethod + def threshold_fn(patch): + return patch.sum() < self.threshold + def __call__(self, array: NdarrayOrTensor): # create the patch iterator which sweeps the image row-by-row array_np, *_ = convert_data_type(array, np.ndarray) @@ -2731,7 +2740,7 @@ class RandGridPatch(GridPatch, RandomizableTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. pad_kwargs: other arguments for the `np.pad` or `torch.pad` function. @@ -2748,7 +2757,7 @@ def __init__( num_patches: Optional[int] = None, overlap: Union[Sequence[float], float] = 0.0, sort_fn: Optional[Union[Callable, str]] = None, - filter_fn: Optional[Callable] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, **pad_kwargs, ): @@ -2758,7 +2767,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, - filter_fn=filter_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index f096f598ca..4cf66e6b0e 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -2220,7 +2220,7 @@ class GridPatchd(MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. @@ -2246,7 +2246,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, - filter_fn: Optional[Callable] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2258,7 +2258,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, - filter_fn=filter_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) @@ -2304,7 +2304,7 @@ class RandGridPatchd(RandomizableTransform, MapTransform): which are, respectively, the minimum and maximum of the sum of intensities of a patch across all dimensions and channels. Also "random" creates a random order of patches. By default no sorting is being done and patches are returned in a row-major order. - filter_fn: a callable that receives each patch and returns a boolean to keep the patch (True) or not (False). + threshold: a value to keep only the patches whose sum of intensities are less than the threshold. Defaults to no filtering. pad_mode: refer to NumpyPadMode and PytorchPadMode. If None, no padding will be applied. Defaults to ``"constant"``. allow_missing_keys: don't raise exception if key is missing. @@ -2332,7 +2332,7 @@ def __init__( num_patches: Optional[int] = None, overlap: float = 0.0, sort_fn: Optional[Union[Callable, str]] = None, - filter_fn: Optional[Callable] = None, + threshold: Optional[float] = None, pad_mode: Union[NumpyPadMode, PytorchPadMode, str] = NumpyPadMode.CONSTANT, allow_missing_keys: bool = False, **pad_kwargs, @@ -2345,7 +2345,7 @@ def __init__( num_patches=num_patches, overlap=overlap, sort_fn=sort_fn, - filter_fn=filter_fn, + threshold=threshold, pad_mode=pad_mode, **pad_kwargs, ) From d16f120e8368de422cee3127a5dbfcfe7d1e4863 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 2 Jun 2022 00:28:52 +0000 Subject: [PATCH 47/67] Add unittest for threshold Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/transforms/spatial/array.py | 3 +-- tests/test_grid_patch.py | 2 ++ tests/test_grid_patchd.py | 3 ++- tests/test_rand_grid_patch.py | 2 ++ tests/test_rand_grid_patchd.py | 2 ++ 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index f18902f5dc..d8c694c93d 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2682,8 +2682,7 @@ def __init__( def one_fn(patch): return True - @staticmethod - def threshold_fn(patch): + def threshold_fn(self, patch): return patch.sum() < self.threshold def __call__(self, array: NdarrayOrTensor): diff --git a/tests/test_grid_patch.py b/tests/test_grid_patch.py index c1d73f262f..8a105afcd2 100644 --- a/tests/test_grid_patch.py +++ b/tests/test_grid_patch.py @@ -44,6 +44,7 @@ A, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] @@ -61,6 +62,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatch(unittest.TestCase): diff --git a/tests/test_grid_patchd.py b/tests/test_grid_patchd.py index a9eec8a2f6..8f1e238b42 100644 --- a/tests/test_grid_patchd.py +++ b/tests/test_grid_patchd.py @@ -44,7 +44,7 @@ {"image": A}, [np.zeros((3, 3, 3)), np.pad(A[:, :1, 1:4], ((0, 0), (2, 0), (0, 0)), mode="constant")], ] - +TEST_CASE_13 = [{"patch_size": (2, 2), "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -61,6 +61,7 @@ TEST_SINGLE.append([p, *TEST_CASE_10]) TEST_SINGLE.append([p, *TEST_CASE_11]) TEST_SINGLE.append([p, *TEST_CASE_12]) + TEST_SINGLE.append([p, *TEST_CASE_13]) class TestGridPatchd(unittest.TestCase): diff --git a/tests/test_rand_grid_patch.py b/tests/test_rand_grid_patch.py index 36da899982..3957dc1ce8 100644 --- a/tests/test_rand_grid_patch.py +++ b/tests/test_rand_grid_patch.py @@ -55,6 +55,7 @@ A, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, A, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatch(unittest.TestCase): diff --git a/tests/test_rand_grid_patchd.py b/tests/test_rand_grid_patchd.py index 6f89a3d155..656fbd9e36 100644 --- a/tests/test_rand_grid_patchd.py +++ b/tests/test_rand_grid_patchd.py @@ -55,6 +55,7 @@ {"image": A}, [np.pad(A[:, :2, 1:], ((0, 0), (1, 0), (0, 0)), mode="constant", constant_values=255)], ] +TEST_CASE_10 = [{"patch_size": (2, 2), "min_offset": 0, "max_offset": 0, "threshold": 50.0}, {"image": A}, [A11]] TEST_SINGLE = [] for p in TEST_NDARRAYS: @@ -68,6 +69,7 @@ TEST_SINGLE.append([p, *TEST_CASE_7]) TEST_SINGLE.append([p, *TEST_CASE_8]) TEST_SINGLE.append([p, *TEST_CASE_9]) + TEST_SINGLE.append([p, *TEST_CASE_10]) class TestRandGridPatchd(unittest.TestCase): From 48a8e0247a0b5acb3c9d87a8c1e53c6300ddaa65 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 2 Jun 2022 13:32:31 +0000 Subject: [PATCH 48/67] Address reviews Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/utils.py | 6 ++-- monai/data/wsi_reader.py | 49 ++++++++++++-------------- monai/transforms/spatial/array.py | 5 +-- monai/transforms/spatial/dictionary.py | 13 +++---- monai/utils/enums.py | 1 + tests/test_wsireader_new.py | 10 +++--- 6 files changed, 39 insertions(+), 45 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index f0188bbfba..6f18e566e0 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -262,10 +262,8 @@ def iter_patch( overlap: the amount of overlap of neighboring patches in each dimension (a value between 0.0 and 1.0). If only one float number is given, it will be applied to all dimensions. Defaults to 0.0. copy_back: if True data from the yielded patches is copied back to `arr` once the generator completes - mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``, - ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} - One of the listed string values or a user supplied function. Defaults to ``"wrap"``. - See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + mode: One of the listed string values in ``monai.utils.NumpyPadMode`` or ``monai.utils.PytorchPadMode``, + or a user supplied function. If None, no wrapping is performed. Defaults to ``"wrap"``. pad_opts: padding options, see `numpy.pad` Yields: diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 76799a9880..81104820e6 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -56,11 +56,10 @@ class BaseWSIReader(ImageReader): supported_suffixes: List[str] = [] backend = "" - def __init__(self, level: int, channel_last: bool = False, **kwargs): + def __init__(self, level: int, channel_dim: int = 0, **kwargs): super().__init__() self.level = level - self.channel_last = channel_last - self.channel_dim = -1 if self.channel_last else 0 + self.channel_dim = channel_dim self.kwargs = kwargs self.metadata: Dict[Any, Any] = {} @@ -138,10 +137,14 @@ def get_metadata( level: the level number. Defaults to 0 """ + if self.channel_dim >= len(patch.shape) or self.channel_dim < -len(patch.shape): + ValueError(f"The desired channel_dim ({channel_dim}) is out of bound for image shape: {patch.shape}") + channel_dim = self.channel_dim + (len(patch.shape) if self.channel_dim < 0 else 0) metadata: Dict = { "backend": self.backend, - "original_channel_dim": self.channel_dim, - "spatial_shape": np.asarray(patch.shape[:-1] if self.channel_last else patch.shape[1:]), + "original_channel_dim": channel_dim, + "spatial_shape": np.array(patch.shape[:channel_dim] + patch.shape[channel_dim + 1 :]), + "num_patches": 1, WSIPatchKeys.PATH.value: self.get_file_path(wsi), WSIPatchKeys.LOCATION.value: np.asarray(location), WSIPatchKeys.SIZE.value: np.asarray(size), @@ -212,7 +215,6 @@ def get_data( f"The image dimension should be 3 but has {patch.ndim}. " "`WSIReader` is designed to work only with 2D images with color channel." ) - self.channel_dim = -1 if self.channel_last else 0 # Check if there are four color channels for RGBA if mode == "RGBA": if patch.shape[self.channel_dim] != 4: @@ -260,20 +262,19 @@ class WSIReader(BaseWSIReader): Args: backend: the name of backend whole slide image reader library, the default is cuCIM. level: the level at which patches are extracted. - channel_last: if True, the returned image will have color channel as the last dimension. - if False, the image will have color channel as the first dimension. Defaults to False, + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional arguments to be passed to the backend library """ - def __init__(self, backend="cucim", level: int = 0, channel_last: bool = False, **kwargs): - super().__init__(level, channel_last, **kwargs) + def __init__(self, backend="cucim", level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) self.backend = backend.lower() self.reader: Union[CuCIMWSIReader, OpenSlideWSIReader] if self.backend == "cucim": - self.reader = CuCIMWSIReader(level=level, channel_last=channel_last, **kwargs) + self.reader = CuCIMWSIReader(level=level, channel_dim=channel_dim, **kwargs) elif self.backend == "openslide": - self.reader = OpenSlideWSIReader(level=level, channel_last=channel_last, **kwargs) + self.reader = OpenSlideWSIReader(level=level, channel_dim=channel_dim, **kwargs) else: raise ValueError(f"The supported backends are cucim and openslide, '{self.backend}' was given.") self.supported_suffixes = self.reader.supported_suffixes @@ -355,8 +356,7 @@ class CuCIMWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. - channel_last: if True, the returned image will have color channel as the last dimension. - if False, the image will have color channel as the first dimension. Defaults to False, + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `cucim.CuImage` module: https://github.com/rapidsai/cucim/blob/main/cpp/include/cucim/cuimage.h @@ -365,8 +365,8 @@ class CuCIMWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "cucim" - def __init__(self, level: int = 0, channel_last: bool = False, **kwargs): - super().__init__(level, channel_last, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -454,9 +454,8 @@ def get_patch( # Convert to numpy patch = np.asarray(patch, dtype=dtype) - # Make it channel first or last - if not self.channel_last: - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) # Check if the color channel is 3 (RGB) or 4 (RGBA) if mode in "RGB": @@ -478,8 +477,7 @@ class OpenSlideWSIReader(BaseWSIReader): Args: level: the whole slide image level at which the image is extracted. (default=0) This is overridden if the level argument is provided in `get_data`. - channel_last: if True, the returned image will have color channel as the last dimension. - if False, the image will have color channel as the first dimension. Defaults to False, + channel_dim: the desired dimension for color channel. Default to 0 (channel first). kwargs: additional args for `openslide.OpenSlide` module. """ @@ -487,8 +485,8 @@ class OpenSlideWSIReader(BaseWSIReader): supported_suffixes = ["tif", "tiff", "svs"] backend = "openslide" - def __init__(self, level: int = 0, channel_last: bool = False, **kwargs): - super().__init__(level, channel_last, **kwargs) + def __init__(self, level: int = 0, channel_dim: int = 0, **kwargs): + super().__init__(level, channel_dim, **kwargs) @staticmethod def get_level_count(wsi) -> int: @@ -578,8 +576,7 @@ def get_patch( # Convert to numpy patch = np.asarray(pil_patch, dtype=dtype) - # Make it channel first or last - if not self.channel_last: - patch = AsChannelFirst()(patch) # type: ignore + # Make the channel to desired dimensions + patch = np.moveaxis(patch, -1, self.channel_dim) return patch diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index d8c694c93d..e157959a90 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -2658,10 +2658,7 @@ def __init__( **pad_kwargs, ): self.patch_size = ensure_tuple(patch_size) - if offset: - self.offset = ensure_tuple(offset) - else: - self.offset = (0,) * len(self.patch_size) + self.offset = ensure_tuple(offset) if offset else (0,) * len(self.patch_size) self.pad_mode: Optional[NumpyPadMode] = convert_pad_mode(dst=np.zeros(1), mode=pad_mode) if pad_mode else None self.pad_kwargs = pad_kwargs self.overlap = overlap diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 4cf66e6b0e..a1dd60b6a0 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -62,6 +62,7 @@ InterpolateMode, NumpyPadMode, PytorchPadMode, + WSIPatchKeys, ensure_tuple, ensure_tuple_rep, fall_back_tuple, @@ -2276,9 +2277,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["patch_location"] = patch[0][1][..., 0] # use the starting coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output @@ -2376,9 +2377,9 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> List[Dict]: new_dict[k] = deepcopy(d[k]) # fill additional metadata new_dict["original_spatial_shape"] = original_spatial_shape - new_dict["patch_location"] = patch[0][1] # use the starting coordinate of the first item - new_dict["patch_size"] = self.patcher.patch_size - new_dict["num_patches"] = num_patches + new_dict[WSIPatchKeys.LOCATION] = patch[0][1] # use the starting coordinate of the first item + new_dict[WSIPatchKeys.SIZE] = self.patcher.patch_size + new_dict[WSIPatchKeys.COUNT] = num_patches new_dict["offset"] = self.patcher.offset output.append(new_dict) return output diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 11d1981b99..51d2772414 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -385,4 +385,5 @@ class WSIPatchKeys(str, Enum): LOCATION = "patch_location" LEVEL = "patch_level" SIZE = "patch_size" + COUNT = "num_patches" PATH = "path" diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index 86b7b2a506..bfe771ed6d 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -23,7 +23,7 @@ from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import download_url_or_skip_test, testing_data_config, assert_allclose cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -60,14 +60,14 @@ TEST_CASE_3 = [ FILE_PATH, - {"channel_last": True}, + {"channel_dim": -1}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), ] TEST_CASE_4 = [ FILE_PATH, - {"channel_last": True}, + {"channel_dim": 2}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), ] @@ -238,7 +238,7 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, expected_shape) @parameterized.expand([TEST_CASE_TRANSFORM_0]) @@ -254,7 +254,7 @@ def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, e data_loader = DataLoader(dataset, batch_size=batch_size) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) From f54a4f908b1ea600d2e683b9b1c5641a181771e3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Jun 2022 13:33:54 +0000 Subject: [PATCH 49/67] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/data/wsi_reader.py | 1 - tests/test_wsireader_new.py | 1 - 2 files changed, 2 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 81104820e6..a346ee4008 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,7 +18,6 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index bfe771ed6d..24b8ff5dc8 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -14,7 +14,6 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized From 74e2b69eafd50918a1511e46b0d4993ae966c0cd Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 2 Jun 2022 14:12:23 +0000 Subject: [PATCH 50/67] Formatting Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/wsi_reader.py | 5 ++--- tests/test_wsireader_new.py | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/monai/data/wsi_reader.py b/monai/data/wsi_reader.py index 81104820e6..0bb2de987c 100644 --- a/monai/data/wsi_reader.py +++ b/monai/data/wsi_reader.py @@ -18,7 +18,6 @@ from monai.config import DtypeLike, PathLike from monai.data.image_reader import ImageReader, _stack_images from monai.data.utils import is_supported_format -from monai.transforms.utility.array import AsChannelFirst from monai.utils import WSIPatchKeys, ensure_tuple, optional_import, require_pkg CuImage, _ = optional_import("cucim", name="CuImage") @@ -138,8 +137,8 @@ def get_metadata( """ if self.channel_dim >= len(patch.shape) or self.channel_dim < -len(patch.shape): - ValueError(f"The desired channel_dim ({channel_dim}) is out of bound for image shape: {patch.shape}") - channel_dim = self.channel_dim + (len(patch.shape) if self.channel_dim < 0 else 0) + ValueError(f"The desired channel_dim ({self.channel_dim}) is out of bound for image shape: {patch.shape}") + channel_dim: int = self.channel_dim + (len(patch.shape) if self.channel_dim < 0 else 0) metadata: Dict = { "backend": self.backend, "original_channel_dim": channel_dim, diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py index bfe771ed6d..8330603db0 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_new.py @@ -14,7 +14,6 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized @@ -23,7 +22,7 @@ from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config, assert_allclose +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") From cb3b2f257f8bb0969427f6a9ff282300e3a76dec Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 3 Jun 2022 03:36:20 +0000 Subject: [PATCH 51/67] Update min test exclusion Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/min_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/min_tests.py b/tests/min_tests.py index 898d1b7b00..091c0dc701 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -161,7 +161,7 @@ def run_testsuit(): "test_vitautoenc", "test_write_metrics_reports", "test_wsireader", - "test_wsireader_new", + "test_wsireader_deprecated", "test_zoom", "test_zoom_affine", "test_zoomd", From 8d46f6a556b2a056280a1286f6b7ffdf26a45a77 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 3 Jun 2022 03:43:08 +0000 Subject: [PATCH 52/67] Update test file names for deprecated component and update prob map deprecation Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- .../pathology/handlers/prob_map_producer.py | 5 +- tests/test_patch_wsi_dataset.py | 315 +++++++----------- tests/test_patch_wsi_dataset_deprecated.py | 256 ++++++++++++++ tests/test_patch_wsi_dataset_new.py | 181 ---------- tests/test_wsireader.py | 134 +++++--- ...er_new.py => test_wsireader_deprecated.py} | 134 +++----- 6 files changed, 514 insertions(+), 511 deletions(-) create mode 100644 tests/test_patch_wsi_dataset_deprecated.py delete mode 100644 tests/test_patch_wsi_dataset_new.py rename tests/{test_wsireader_new.py => test_wsireader_deprecated.py} (63%) diff --git a/monai/apps/pathology/handlers/prob_map_producer.py b/monai/apps/pathology/handlers/prob_map_producer.py index 62507dc0cb..d5b1b50c47 100644 --- a/monai/apps/pathology/handlers/prob_map_producer.py +++ b/monai/apps/pathology/handlers/prob_map_producer.py @@ -27,7 +27,10 @@ @deprecated( since="0.8", - msg_suffix="use `monai.handler.ProbMapProducer` (with `monai.data.wsi_dataset.SlidingPatchWSIDataset`) instead.", + msg_suffix=( + "use `monai.handler.ProbMapProducer` (with `monai.data.wsi_dataset.MaskedPatchWSIDataset` or " + "`monai.data.wsi_dataset.SlidingPatchWSIDataset`) instead." + ), ) class ProbMapProducer: """ diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 20d7f22988..65e65035c4 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -17,13 +17,17 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.apps.pathology.data import PatchWSIDataset +from monai.data import PatchWSIDataset +from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader from monai.utils import optional_import from tests.utils import download_url_or_skip_test, testing_data_config -_cucim, has_cim = optional_import("cucim") -has_cim = has_cim and hasattr(_cucim, "CuImage") -_, has_osl = optional_import("openslide") +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec FILE_KEY = "wsi_img" FILE_URL = testing_data_config("images", FILE_KEY, "url") @@ -31,225 +35,146 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_0_L2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] - - TEST_CASE_1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] - -TEST_CASE_1_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 0, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -TEST_CASE_1_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] TEST_CASE_2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, ] TEST_CASE_3 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], -] - -TEST_CASE_OPENSLIDE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 0, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, ] - -TEST_CASE_OPENSLIDE_0_L2 = [ +TEST_CASE_4 = [ { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), + "data": [ + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + ], "patch_size": 1, - "level": 2, - "image_reader_name": "OpenSlide", }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], ] -TEST_CASE_OPENSLIDE_1 = [ +TEST_CASE_5 = [ { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "OpenSlide", + "data": [ + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + ] }, [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, ], ] -class TestPatchWSIDataset(unittest.TestCase): - def setUp(self): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - @parameterized.expand( - [ - TEST_CASE_0, - TEST_CASE_0_L1, - TEST_CASE_0_L2, - TEST_CASE_1, - TEST_CASE_1_L0, - TEST_CASE_1_L1, - TEST_CASE_2, - TEST_CASE_3, - ] - ) - @skipUnless(has_cim, "Requires CuCIM") - def test_read_patches_cucim(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) - - @parameterized.expand( - [ - TEST_CASE_OPENSLIDE_0, - TEST_CASE_OPENSLIDE_0_L0, - TEST_CASE_OPENSLIDE_0_L1, - TEST_CASE_OPENSLIDE_0_L2, - TEST_CASE_OPENSLIDE_1, - ] - ) - @skipUnless(has_osl, "Requires OpenSlide") - def test_read_patches_openslide(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): # noqa: N802 + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class PatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_str(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_class(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader + elif self.backend == "cucim": + reader = CuCIMWSIReader + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_object(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) + elif self.backend == "cucim": + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_read_patches_str_multi(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + for i in range(len(dataset)): + self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) + + +@skipUnless(has_cucim, "Requires cucim") +class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" if __name__ == "__main__": diff --git a/tests/test_patch_wsi_dataset_deprecated.py b/tests/test_patch_wsi_dataset_deprecated.py new file mode 100644 index 0000000000..20d7f22988 --- /dev/null +++ b/tests/test_patch_wsi_dataset_deprecated.py @@ -0,0 +1,256 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.apps.pathology.data import PatchWSIDataset +from monai.utils import optional_import +from tests.utils import download_url_or_skip_test, testing_data_config + +_cucim, has_cim = optional_import("cucim") +has_cim = has_cim and hasattr(_cucim, "CuImage") +_, has_osl = optional_import("openslide") + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_0_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_0_L2 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + + +TEST_CASE_1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] + + +TEST_CASE_1_L0 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 0, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] + + +TEST_CASE_1_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] +TEST_CASE_2 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_3 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], +] + +TEST_CASE_OPENSLIDE_0 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_0_L0 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 0, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_0_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + + +TEST_CASE_OPENSLIDE_0_L2 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 2, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "OpenSlide", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] + + +class TestPatchWSIDataset(unittest.TestCase): + def setUp(self): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_0_L1, + TEST_CASE_0_L2, + TEST_CASE_1, + TEST_CASE_1_L0, + TEST_CASE_1_L1, + TEST_CASE_2, + TEST_CASE_3, + ] + ) + @skipUnless(has_cim, "Requires CuCIM") + def test_read_patches_cucim(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) + + @parameterized.expand( + [ + TEST_CASE_OPENSLIDE_0, + TEST_CASE_OPENSLIDE_0_L0, + TEST_CASE_OPENSLIDE_0_L1, + TEST_CASE_OPENSLIDE_0_L2, + TEST_CASE_OPENSLIDE_1, + ] + ) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_patches_openslide(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py deleted file mode 100644 index 65e65035c4..0000000000 --- a/tests/test_patch_wsi_dataset_new.py +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.data import PatchWSIDataset -from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, testing_data_config - -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] -TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, -] - -TEST_CASE_4 = [ - { - "data": [ - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, - ], - "patch_size": 1, - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], -] - -TEST_CASE_5 = [ - { - "data": [ - { - "image": FILE_PATH, - "patch_location": [0, 0], - "label": [[[0, 1], [1, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - { - "image": FILE_PATH, - "patch_location": [100, 100], - "label": [[[1, 0], [0, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - ] - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], -] - - -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class PatchWSIDatasetTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_str(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_class(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader - elif self.backend == "cucim": - reader = CuCIMWSIReader - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_object(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) - elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_read_patches_str_multi(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - for i in range(len(dataset)): - self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) - - -@skipUnless(has_cucim, "Requires cucim") -class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires openslide") -class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 5d092c4ce5..8330603db0 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -14,16 +14,15 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import DataLoader, Dataset -from monai.data.image_reader import WSIReader +from monai.data.wsi_reader import WSIReader from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -46,37 +45,42 @@ TEST_CASE_1 = [ FILE_PATH, + {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, + {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ FILE_PATH, - {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, - np.array( - [ - [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], - [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], - ] - ), + {"channel_dim": -1}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), ] TEST_CASE_4 = [ FILE_PATH, - {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, - np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), + {"channel_dim": 2}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), ] -TEST_CASE_5 = [ - FILE_PATH, - {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, - np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), +TEST_CASE_MULTI_WSI = [ + [FILE_PATH, FILE_PATH], + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.concatenate( + [ + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + ], + axis=0, + ), ] @@ -108,6 +112,20 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): return filename +def save_gray_tiff(array: np.ndarray, filename: str): + """ + Save numpy array into a TIFF file + + Args: + array: numpy ndarray with any shape + filename: the filename to be used for the tiff file. + """ + img_gray = array + imwrite(filename, img_gray, shape=img_gray.shape) + + return filename + + @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): # noqa: N802 hash_type = testing_data_config("images", FILE_KEY, "hash_type") @@ -123,37 +141,56 @@ class Tests(unittest.TestCase): def test_read_whole_image(self, file_path, level, expected_shape): reader = WSIReader(self.backend, level=level) with reader.read(file_path) as img_obj: - img = reader.get_data(img_obj)[0] + img, meta = reader.get_data(img_obj) self.assertTupleEqual(img.shape, expected_shape) - - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5]) - def test_read_region(self, file_path, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], level) + assert_array_equal(meta["patch_size"], expected_shape[1:]) + assert_array_equal(meta["patch_location"], (0, 0)) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path, **kwargs) as img_obj: + with reader.read(file_path) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] else: # Read twice to check multiple calls - img = reader.get_data(img_obj, **patch_info)[0] + img, meta = reader.get_data(img_obj, **patch_info) img2 = reader.get_data(img_obj, **patch_info)[0] self.assertTupleEqual(img.shape, img2.shape) self.assertIsNone(assert_array_equal(img, img2)) self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) - - @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) - def test_read_patches(self, file_path, patch_info, expected_img): - reader = WSIReader(self.backend) - with reader.read(file_path) as img_obj: - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj, **patch_info)[0] - else: - img = reader.get_data(img_obj, **patch_info)[0] - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], patch_info["level"]) + assert_array_equal(meta["patch_size"], patch_info["size"]) + assert_array_equal(meta["patch_location"], patch_info["location"]) + + @parameterized.expand([TEST_CASE_MULTI_WSI]) + def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + reader = WSIReader(self.backend, **kwargs) + img_obj_list = reader.read(file_path_list, **kwargs) + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj_list, **patch_info)[0] + else: + # Read twice to check multiple calls + img, meta = reader.get_data(img_obj_list, **patch_info) + img2 = reader.get_data(img_obj_list, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch_level"][0], patch_info["level"]) + assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"][0], patch_info["location"]) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") @@ -200,9 +237,25 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, expected_shape) + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) + batch_size = 2 + data_loader = DataLoader(dataset, batch_size=batch_size) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + assert_allclose(s, expected_spatial_shape, type_test=False) + self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) + @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): @@ -211,19 +264,12 @@ def setUpClass(cls): cls.backend = "cucim" -@skipUnless(has_osl, "Requires OpenSlide") +@skipUnless(has_osl, "Requires openslide") class TestOpenSlide(WSIReaderTests.Tests): @classmethod def setUpClass(cls): cls.backend = "openslide" -@skipUnless(has_tiff, "Requires TiffFile") -class TestTiffFile(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "tifffile" - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_deprecated.py similarity index 63% rename from tests/test_wsireader_new.py rename to tests/test_wsireader_deprecated.py index 8330603db0..5d092c4ce5 100644 --- a/tests/test_wsireader_new.py +++ b/tests/test_wsireader_deprecated.py @@ -14,15 +14,16 @@ from unittest import skipUnless import numpy as np +import torch from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import DataLoader, Dataset -from monai.data.wsi_reader import WSIReader +from monai.data.image_reader import WSIReader from monai.transforms import Compose, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config +from tests.utils import download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -45,42 +46,37 @@ TEST_CASE_1 = [ FILE_PATH, - {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, - {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ FILE_PATH, - {"channel_dim": -1}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, - np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), + {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, + np.array( + [ + [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], + [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], + ] + ), ] TEST_CASE_4 = [ FILE_PATH, - {"channel_dim": 2}, - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), + {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, + np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), ] -TEST_CASE_MULTI_WSI = [ - [FILE_PATH, FILE_PATH], - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.concatenate( - [ - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - ], - axis=0, - ), +TEST_CASE_5 = [ + FILE_PATH, + {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, + np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] @@ -112,20 +108,6 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): return filename -def save_gray_tiff(array: np.ndarray, filename: str): - """ - Save numpy array into a TIFF file - - Args: - array: numpy ndarray with any shape - filename: the filename to be used for the tiff file. - """ - img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape) - - return filename - - @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): # noqa: N802 hash_type = testing_data_config("images", FILE_KEY, "hash_type") @@ -141,56 +123,37 @@ class Tests(unittest.TestCase): def test_read_whole_image(self, file_path, level, expected_shape): reader = WSIReader(self.backend, level=level) with reader.read(file_path) as img_obj: - img, meta = reader.get_data(img_obj) + img = reader.get_data(img_obj)[0] self.assertTupleEqual(img.shape, expected_shape) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], level) - assert_array_equal(meta["patch_size"], expected_shape[1:]) - assert_array_equal(meta["patch_location"], (0, 0)) - - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - def test_read_region(self, file_path, kwargs, patch_info, expected_img): + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5]) + def test_read_region(self, file_path, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path) as img_obj: + with reader.read(file_path, **kwargs) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] else: # Read twice to check multiple calls - img, meta = reader.get_data(img_obj, **patch_info) + img = reader.get_data(img_obj, **patch_info)[0] img2 = reader.get_data(img_obj, **patch_info)[0] self.assertTupleEqual(img.shape, img2.shape) self.assertIsNone(assert_array_equal(img, img2)) self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], patch_info["size"]) - assert_array_equal(meta["patch_location"], patch_info["location"]) - - @parameterized.expand([TEST_CASE_MULTI_WSI]) - def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} - reader = WSIReader(self.backend, **kwargs) - img_obj_list = reader.read(file_path_list, **kwargs) - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj_list, **patch_info)[0] - else: - # Read twice to check multiple calls - img, meta = reader.get_data(img_obj_list, **patch_info) - img2 = reader.get_data(img_obj_list, **patch_info)[0] - self.assertTupleEqual(img.shape, img2.shape) - self.assertIsNone(assert_array_equal(img, img2)) - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) - self.assertEqual(meta["patch_level"][0], patch_info["level"]) - assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) - assert_array_equal(meta["patch_location"][0], patch_info["location"]) + + @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) + def test_read_patches(self, file_path, patch_info, expected_img): + reader = WSIReader(self.backend) + with reader.read(file_path) as img_obj: + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") @@ -237,25 +200,9 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) + torch.testing.assert_allclose(s, expected_spatial_shape) self.assertTupleEqual(data["image"].shape, expected_shape) - @parameterized.expand([TEST_CASE_TRANSFORM_0]) - def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): - train_transform = Compose( - [ - LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), - ToTensord(keys=["image"]), - ] - ) - dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) - batch_size = 2 - data_loader = DataLoader(dataset, batch_size=batch_size) - data: dict = first(data_loader) - for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) - self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) - @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): @@ -264,12 +211,19 @@ def setUpClass(cls): cls.backend = "cucim" -@skipUnless(has_osl, "Requires openslide") +@skipUnless(has_osl, "Requires OpenSlide") class TestOpenSlide(WSIReaderTests.Tests): @classmethod def setUpClass(cls): cls.backend = "openslide" +@skipUnless(has_tiff, "Requires TiffFile") +class TestTiffFile(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "tifffile" + + if __name__ == "__main__": unittest.main() From 21131896ff31fcd0243c97b0f931bfab397b27b4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jun 2022 13:45:50 +0000 Subject: [PATCH 53/67] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_patch_wsi_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 65e65035c4..fee8a03068 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -104,7 +104,7 @@ @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) From f50df9c62fcf47b6c32ff38b4f56e5da2c2845fe Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 6 Jun 2022 13:51:10 +0000 Subject: [PATCH 54/67] Remove noqa: N802 Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 65e65035c4..fee8a03068 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -104,7 +104,7 @@ @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): # noqa: N802 +def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") hash_val = testing_data_config("images", FILE_KEY, "hash_val") download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) From 1ab0c4ad7e0ee4589b58e4d6af4a19fe232b7fd9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 27 Jul 2022 14:04:53 +0000 Subject: [PATCH 55/67] Deprecate image_reader.WSIReader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/data/image_reader.py | 3 ++- tests/{test_wsireader.py => test_wsireader_deprecated.py} | 0 2 files changed, 2 insertions(+), 1 deletion(-) rename tests/{test_wsireader.py => test_wsireader_deprecated.py} (100%) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index e666720606..2d47c8bfd1 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -28,7 +28,7 @@ orientation_ras_lps, ) from monai.transforms.utility.array import EnsureChannelFirst -from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import, require_pkg +from monai.utils import deprecated, ensure_tuple, ensure_tuple_rep, optional_import, require_pkg if TYPE_CHECKING: import itk @@ -1204,6 +1204,7 @@ def _get_spatial_shape(self, img): return np.asarray((img.width, img.height)) +@deprecated(since="0.8", msg_suffix="use `monai.wsi_reader.WSIReader` instead.") class WSIReader(ImageReader): """ Read whole slide images and extract patches. diff --git a/tests/test_wsireader.py b/tests/test_wsireader_deprecated.py similarity index 100% rename from tests/test_wsireader.py rename to tests/test_wsireader_deprecated.py From 985dfcafbcfa3fe6ba596dac95e7f40246c632ee Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 27 Jul 2022 14:05:57 +0000 Subject: [PATCH 56/67] Rename from _new Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/{test_wsireader_new.py => test_wsireader.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_wsireader_new.py => test_wsireader.py} (100%) diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader.py similarity index 100% rename from tests/test_wsireader_new.py rename to tests/test_wsireader.py From d499773c0b95b6279cc9ab830d167a012ed706bd Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 27 Jul 2022 14:10:15 +0000 Subject: [PATCH 57/67] Revert test_patch_wsi_dataset naming Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset.py | 315 +++++++++++++-------- tests/test_patch_wsi_dataset_deprecated.py | 256 ----------------- tests/test_patch_wsi_dataset_new.py | 181 ++++++++++++ 3 files changed, 376 insertions(+), 376 deletions(-) delete mode 100644 tests/test_patch_wsi_dataset_deprecated.py create mode 100644 tests/test_patch_wsi_dataset_new.py diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index fee8a03068..20d7f22988 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -17,17 +17,13 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.data import PatchWSIDataset -from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader +from monai.apps.pathology.data import PatchWSIDataset from monai.utils import optional_import from tests.utils import download_url_or_skip_test, testing_data_config -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec +_cucim, has_cim = optional_import("cucim") +has_cim = has_cim and hasattr(_cucim, "CuImage") +_, has_osl = optional_import("openslide") FILE_KEY = "wsi_img" FILE_URL = testing_data_config("images", FILE_KEY, "url") @@ -35,146 +31,225 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] + + TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], ] + +TEST_CASE_1_L0 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 0, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] + + +TEST_CASE_1_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], ] -TEST_CASE_4 = [ +TEST_CASE_OPENSLIDE_0 = [ { - "data": [ - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, - ], + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), "patch_size": 1, + "image_reader_name": "OpenSlide", }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_5 = [ +TEST_CASE_OPENSLIDE_0_L0 = [ { - "data": [ - { - "image": FILE_PATH, - "patch_location": [0, 0], - "label": [[[0, 1], [1, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - { - "image": FILE_PATH, - "patch_location": [100, 100], - "label": [[[1, 0], [0, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - ] + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 0, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_0_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + + +TEST_CASE_OPENSLIDE_0_L2 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 2, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "OpenSlide", }, [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, ], ] -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class PatchWSIDatasetTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_str(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_class(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader - elif self.backend == "cucim": - reader = CuCIMWSIReader - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_object(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) - elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_read_patches_str_multi(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - for i in range(len(dataset)): - self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) - - -@skipUnless(has_cucim, "Requires cucim") -class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires openslide") -class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" +class TestPatchWSIDataset(unittest.TestCase): + def setUp(self): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_0_L1, + TEST_CASE_0_L2, + TEST_CASE_1, + TEST_CASE_1_L0, + TEST_CASE_1_L1, + TEST_CASE_2, + TEST_CASE_3, + ] + ) + @skipUnless(has_cim, "Requires CuCIM") + def test_read_patches_cucim(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) + + @parameterized.expand( + [ + TEST_CASE_OPENSLIDE_0, + TEST_CASE_OPENSLIDE_0_L0, + TEST_CASE_OPENSLIDE_0_L1, + TEST_CASE_OPENSLIDE_0_L2, + TEST_CASE_OPENSLIDE_1, + ] + ) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_patches_openslide(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) if __name__ == "__main__": diff --git a/tests/test_patch_wsi_dataset_deprecated.py b/tests/test_patch_wsi_dataset_deprecated.py deleted file mode 100644 index 20d7f22988..0000000000 --- a/tests/test_patch_wsi_dataset_deprecated.py +++ /dev/null @@ -1,256 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.apps.pathology.data import PatchWSIDataset -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, testing_data_config - -_cucim, has_cim = optional_import("cucim") -has_cim = has_cim and hasattr(_cucim, "CuImage") -_, has_osl = optional_import("openslide") - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -TEST_CASE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_0_L2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - - -TEST_CASE_1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -TEST_CASE_1_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 0, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -TEST_CASE_1_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] -TEST_CASE_2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_3 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], -] - -TEST_CASE_OPENSLIDE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 0, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - - -TEST_CASE_OPENSLIDE_0_L2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 2, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "OpenSlide", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -class TestPatchWSIDataset(unittest.TestCase): - def setUp(self): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - @parameterized.expand( - [ - TEST_CASE_0, - TEST_CASE_0_L1, - TEST_CASE_0_L2, - TEST_CASE_1, - TEST_CASE_1_L0, - TEST_CASE_1_L1, - TEST_CASE_2, - TEST_CASE_3, - ] - ) - @skipUnless(has_cim, "Requires CuCIM") - def test_read_patches_cucim(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) - - @parameterized.expand( - [ - TEST_CASE_OPENSLIDE_0, - TEST_CASE_OPENSLIDE_0_L0, - TEST_CASE_OPENSLIDE_0_L1, - TEST_CASE_OPENSLIDE_0_L2, - TEST_CASE_OPENSLIDE_1, - ] - ) - @skipUnless(has_osl, "Requires OpenSlide") - def test_read_patches_openslide(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py new file mode 100644 index 0000000000..fee8a03068 --- /dev/null +++ b/tests/test_patch_wsi_dataset_new.py @@ -0,0 +1,181 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import PatchWSIDataset +from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader +from monai.utils import optional_import +from tests.utils import download_url_or_skip_test, testing_data_config + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] +TEST_CASE_1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_3 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, +] + +TEST_CASE_4 = [ + { + "data": [ + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + ], + "patch_size": 1, + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + +TEST_CASE_5 = [ + { + "data": [ + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + ] + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + + +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class PatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_str(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_class(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader + elif self.backend == "cucim": + reader = CuCIMWSIReader + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_object(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) + elif self.backend == "cucim": + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_read_patches_str_multi(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + for i in range(len(dataset)): + self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) + + +@skipUnless(has_cucim, "Requires cucim") +class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From 11b93ad82b5fca951c9728f6d7693a34e68b2815 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 27 Jul 2022 14:10:58 +0000 Subject: [PATCH 58/67] Depreccate test_patch_wsi_dataset Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- ..._patch_wsi_dataset.py => test_patch_wsi_dataset_deprecated.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_patch_wsi_dataset.py => test_patch_wsi_dataset_deprecated.py} (100%) diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset_deprecated.py similarity index 100% rename from tests/test_patch_wsi_dataset.py rename to tests/test_patch_wsi_dataset_deprecated.py From f8285b08c26ef18e306bbe96f01fca4fcd527bcb Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Wed, 27 Jul 2022 14:11:23 +0000 Subject: [PATCH 59/67] Rename from _new Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- .../{test_patch_wsi_dataset_new.py => test_patch_wsi_dataset.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_patch_wsi_dataset_new.py => test_patch_wsi_dataset.py} (100%) diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset.py similarity index 100% rename from tests/test_patch_wsi_dataset_new.py rename to tests/test_patch_wsi_dataset.py From 2f1d91ab7b020ce5d2294e87e5a86710bef3db2f Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 4 Aug 2022 21:41:15 +0000 Subject: [PATCH 60/67] Revert file names Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_wsireader_deprecated.py | 230 ------------------ ...est_wsireader.py => test_wsireader_new.py} | 0 2 files changed, 230 deletions(-) delete mode 100644 tests/test_wsireader_deprecated.py rename tests/{test_wsireader.py => test_wsireader_new.py} (100%) diff --git a/tests/test_wsireader_deprecated.py b/tests/test_wsireader_deprecated.py deleted file mode 100644 index a0a076b682..0000000000 --- a/tests/test_wsireader_deprecated.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -import torch -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.data import DataLoader, Dataset -from monai.data.image_reader import WSIReader -from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord -from monai.utils import first, optional_import -from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config - -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -HEIGHT = 32914 -WIDTH = 46000 - -TEST_CASE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] - -TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] - -TEST_CASE_1 = [ - FILE_PATH, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, - np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), -] - -TEST_CASE_2 = [ - FILE_PATH, - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), -] - -TEST_CASE_3 = [ - FILE_PATH, - {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, - np.array( - [ - [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], - [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], - ] - ), -] - -TEST_CASE_4 = [ - FILE_PATH, - {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, - np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), -] - -TEST_CASE_5 = [ - FILE_PATH, - {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, - np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), -] - - -TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW - -TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW - -TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel -TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel -TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels -TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color - - -def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): - """ - Save numpy array into a TIFF RGB/RGBA file - - Args: - array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image - filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename. - mode: RGB or RGBA - """ - if mode == "RGBA": - array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8) - - img_rgb = array.transpose(1, 2, 0) - imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16)) - - return filename - - -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class WSIReaderTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0]) - def test_read_whole_image(self, file_path, level, expected_shape): - reader = WSIReader(self.backend, level=level) - with reader.read(file_path) as img_obj: - img = reader.get_data(img_obj)[0] - self.assertTupleEqual(img.shape, expected_shape) - - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5]) - def test_read_region(self, file_path, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} - reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path, **kwargs) as img_obj: - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj, **patch_info)[0] - else: - # Read twice to check multiple calls - img = reader.get_data(img_obj, **patch_info)[0] - img2 = reader.get_data(img_obj, **patch_info)[0] - self.assertTupleEqual(img.shape, img2.shape) - self.assertIsNone(assert_array_equal(img, img2)) - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - - @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) - def test_read_patches(self, file_path, patch_info, expected_img): - reader = WSIReader(self.backend) - with reader.read(file_path) as img_obj: - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj, **patch_info)[0] - else: - img = reader.get_data(img_obj, **patch_info)[0] - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - - @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) - @skipUnless(has_tiff, "Requires tifffile.") - def test_read_rgba(self, img_expected): - # skip for OpenSlide since not working with images without tiles - if self.backend == "openslide": - return - image = {} - reader = WSIReader(self.backend) - for mode in ["RGB", "RGBA"]: - file_path = save_rgba_tiff( - img_expected, - os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"), - mode=mode, - ) - with reader.read(file_path) as img_obj: - image[mode], _ = reader.get_data(img_obj) - - self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) - self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) - - @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) - @skipUnless(has_tiff, "Requires tifffile.") - def test_read_malformats(self, img_expected): - if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): - # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 - return - reader = WSIReader(self.backend) - file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") - imwrite(file_path, img_expected, shape=img_expected.shape) - with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): - with reader.read(file_path) as img_obj: - reader.get_data(img_obj) - - @parameterized.expand([TEST_CASE_TRANSFORM_0]) - def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape): - train_transform = Compose( - [ - LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), - FromMetaTensord(keys=["image"]), - ToTensord(keys=["image"]), - ] - ) - dataset = Dataset([{"image": file_path}], transform=train_transform) - data_loader = DataLoader(dataset) - data: dict = first(data_loader) - for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) - self.assertTupleEqual(data["image"].shape, expected_shape) - - -@skipUnless(has_cucim, "Requires cucim") -class TestCuCIM(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires OpenSlide") -class TestOpenSlide(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" - - -@skipUnless(has_tiff, "Requires TiffFile") -class TestTiffFile(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "tifffile" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_wsireader.py b/tests/test_wsireader_new.py similarity index 100% rename from tests/test_wsireader.py rename to tests/test_wsireader_new.py From f04bb5c3d4820042d924301b38dfb9d0f57f9189 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 5 Aug 2022 13:20:57 +0000 Subject: [PATCH 61/67] Revert file names Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/min_tests.py | 1 - tests/test_wsireader.py | 277 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 tests/test_wsireader.py diff --git a/tests/min_tests.py b/tests/min_tests.py index dbf06ba02f..18e7b136fe 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -162,7 +162,6 @@ def run_testsuit(): "test_vitautoenc", "test_write_metrics_reports", "test_wsireader", - "test_wsireader_deprecated", "test_zoom", "test_zoom_affine", "test_zoomd", diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py new file mode 100644 index 0000000000..0d5e5892e6 --- /dev/null +++ b/tests/test_wsireader.py @@ -0,0 +1,277 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import DataLoader, Dataset +from monai.data.wsi_reader import WSIReader +from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord +from monai.utils import first, optional_import +from monai.utils.enums import PostFix +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +HEIGHT = 32914 +WIDTH = 46000 + +TEST_CASE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] + +TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] + +TEST_CASE_1 = [ + FILE_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + +TEST_CASE_2 = [ + FILE_PATH, + {}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), +] + +TEST_CASE_3 = [ + FILE_PATH, + {"channel_dim": -1}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), +] + +TEST_CASE_4 = [ + FILE_PATH, + {"channel_dim": 2}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), +] + +TEST_CASE_MULTI_WSI = [ + [FILE_PATH, FILE_PATH], + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.concatenate( + [ + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + ], + axis=0, + ), +] + + +TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW + +TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW + +TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel +TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel +TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels +TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color + + +def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): + """ + Save numpy array into a TIFF RGB/RGBA file + + Args: + array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image + filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename. + mode: RGB or RGBA + """ + if mode == "RGBA": + array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8) + + img_rgb = array.transpose(1, 2, 0) + imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16)) + + return filename + + +def save_gray_tiff(array: np.ndarray, filename: str): + """ + Save numpy array into a TIFF file + + Args: + array: numpy ndarray with any shape + filename: the filename to be used for the tiff file. + """ + img_gray = array + imwrite(filename, img_gray, shape=img_gray.shape) + + return filename + + +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class WSIReaderTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_read_whole_image(self, file_path, level, expected_shape): + reader = WSIReader(self.backend, level=level) + with reader.read(file_path) as img_obj: + img, meta = reader.get_data(img_obj) + self.assertTupleEqual(img.shape, expected_shape) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], level) + assert_array_equal(meta["patch_size"], expected_shape[1:]) + assert_array_equal(meta["patch_location"], (0, 0)) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): + reader = WSIReader(self.backend, **kwargs) + with reader.read(file_path) as img_obj: + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + # Read twice to check multiple calls + img, meta = reader.get_data(img_obj, **patch_info) + img2 = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], patch_info["level"]) + assert_array_equal(meta["patch_size"], patch_info["size"]) + assert_array_equal(meta["patch_location"], patch_info["location"]) + + @parameterized.expand([TEST_CASE_MULTI_WSI]) + def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + reader = WSIReader(self.backend, **kwargs) + img_obj_list = reader.read(file_path_list, **kwargs) + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj_list, **patch_info)[0] + else: + # Read twice to check multiple calls + img, meta = reader.get_data(img_obj_list, **patch_info) + img2 = reader.get_data(img_obj_list, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch_level"][0], patch_info["level"]) + assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"][0], patch_info["location"]) + + @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_rgba(self, img_expected): + # skip for OpenSlide since not working with images without tiles + if self.backend == "openslide": + return + image = {} + reader = WSIReader(self.backend) + for mode in ["RGB", "RGBA"]: + file_path = save_rgba_tiff( + img_expected, + os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"), + mode=mode, + ) + with reader.read(file_path) as img_obj: + image[mode], _ = reader.get_data(img_obj) + + self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) + self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) + + @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_malformats(self, img_expected): + if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): + # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 + return + reader = WSIReader(self.backend) + file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") + imwrite(file_path, img_expected, shape=img_expected.shape) + with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): + with reader.read(file_path) as img_obj: + reader.get_data(img_obj) + + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}], transform=train_transform) + data_loader = DataLoader(dataset) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + assert_allclose(s, expected_spatial_shape, type_test=False) + self.assertTupleEqual(data["image"].shape, expected_shape) + + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) + batch_size = 2 + data_loader = DataLoader(dataset, batch_size=batch_size) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + assert_allclose(s, expected_spatial_shape, type_test=False) + self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) + + +@skipUnless(has_cucim, "Requires cucim") +class TestCuCIM(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestOpenSlide(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From 6458501ff80ccb6c106e99d8422c29fac0c36232 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 12:53:54 +0000 Subject: [PATCH 62/67] Restove wsireader test: Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_wsireader.py | 135 +++++++++++++--------------------------- 1 file changed, 44 insertions(+), 91 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 0d5e5892e6..a0a076b682 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -14,15 +14,16 @@ from unittest import skipUnless import numpy as np +import torch from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import DataLoader, Dataset -from monai.data.wsi_reader import WSIReader +from monai.data.image_reader import WSIReader from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config +from tests.utils import download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -45,42 +46,37 @@ TEST_CASE_1 = [ FILE_PATH, - {}, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] TEST_CASE_2 = [ FILE_PATH, - {}, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] TEST_CASE_3 = [ FILE_PATH, - {"channel_dim": -1}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, - np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), + {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, + np.array( + [ + [[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[239, 239], [239, 239]]], + [[[242, 242], [242, 243]], [[242, 242], [242, 243]], [[242, 242], [242, 243]]], + ] + ), ] TEST_CASE_4 = [ FILE_PATH, - {"channel_dim": 2}, - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), + {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, + np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), ] -TEST_CASE_MULTI_WSI = [ - [FILE_PATH, FILE_PATH], - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.concatenate( - [ - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - ], - axis=0, - ), +TEST_CASE_5 = [ + FILE_PATH, + {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, + np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] @@ -112,20 +108,6 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): return filename -def save_gray_tiff(array: np.ndarray, filename: str): - """ - Save numpy array into a TIFF file - - Args: - array: numpy ndarray with any shape - filename: the filename to be used for the tiff file. - """ - img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape) - - return filename - - @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") @@ -141,56 +123,37 @@ class Tests(unittest.TestCase): def test_read_whole_image(self, file_path, level, expected_shape): reader = WSIReader(self.backend, level=level) with reader.read(file_path) as img_obj: - img, meta = reader.get_data(img_obj) + img = reader.get_data(img_obj)[0] self.assertTupleEqual(img.shape, expected_shape) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], level) - assert_array_equal(meta["patch_size"], expected_shape[1:]) - assert_array_equal(meta["patch_location"], (0, 0)) - - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - def test_read_region(self, file_path, kwargs, patch_info, expected_img): + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5]) + def test_read_region(self, file_path, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path) as img_obj: + with reader.read(file_path, **kwargs) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): reader.get_data(img_obj, **patch_info)[0] else: # Read twice to check multiple calls - img, meta = reader.get_data(img_obj, **patch_info) + img = reader.get_data(img_obj, **patch_info)[0] img2 = reader.get_data(img_obj, **patch_info)[0] self.assertTupleEqual(img.shape, img2.shape) self.assertIsNone(assert_array_equal(img, img2)) self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], patch_info["size"]) - assert_array_equal(meta["patch_location"], patch_info["location"]) - - @parameterized.expand([TEST_CASE_MULTI_WSI]) - def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} - reader = WSIReader(self.backend, **kwargs) - img_obj_list = reader.read(file_path_list, **kwargs) - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj_list, **patch_info)[0] - else: - # Read twice to check multiple calls - img, meta = reader.get_data(img_obj_list, **patch_info) - img2 = reader.get_data(img_obj_list, **patch_info)[0] - self.assertTupleEqual(img.shape, img2.shape) - self.assertIsNone(assert_array_equal(img, img2)) - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) - self.assertEqual(meta["patch_level"][0], patch_info["level"]) - assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) - assert_array_equal(meta["patch_location"][0], patch_info["location"]) + + @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) + def test_read_patches(self, file_path, patch_info, expected_img): + reader = WSIReader(self.backend) + with reader.read(file_path) as img_obj: + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + img = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") @@ -238,26 +201,9 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) + torch.testing.assert_allclose(s, expected_spatial_shape) self.assertTupleEqual(data["image"].shape, expected_shape) - @parameterized.expand([TEST_CASE_TRANSFORM_0]) - def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): - train_transform = Compose( - [ - LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), - FromMetaTensord(keys=["image"]), - ToTensord(keys=["image"]), - ] - ) - dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) - batch_size = 2 - data_loader = DataLoader(dataset, batch_size=batch_size) - data: dict = first(data_loader) - for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) - self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) - @skipUnless(has_cucim, "Requires cucim") class TestCuCIM(WSIReaderTests.Tests): @@ -266,12 +212,19 @@ def setUpClass(cls): cls.backend = "cucim" -@skipUnless(has_osl, "Requires openslide") +@skipUnless(has_osl, "Requires OpenSlide") class TestOpenSlide(WSIReaderTests.Tests): @classmethod def setUpClass(cls): cls.backend = "openslide" +@skipUnless(has_tiff, "Requires TiffFile") +class TestTiffFile(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "tifffile" + + if __name__ == "__main__": unittest.main() From 3af782f6d1ba243f07a98455a40635bd8c88888b Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 13:12:20 +0000 Subject: [PATCH 63/67] Merge new and deprecated wsireader tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_wsireader.py | 230 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 212 insertions(+), 18 deletions(-) diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index a0a076b682..dba74df0c3 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -14,16 +14,16 @@ from unittest import skipUnless import numpy as np -import torch from numpy.testing import assert_array_equal from parameterized import parameterized from monai.data import DataLoader, Dataset -from monai.data.image_reader import WSIReader +from monai.data.image_reader import WSIReader as WSIReaderDeprecated +from monai.data.wsi_reader import WSIReader from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord from monai.utils import first, optional_import from monai.utils.enums import PostFix -from tests.utils import download_url_or_skip_test, testing_data_config +from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config cucim, has_cucim = optional_import("cucim") has_cucim = has_cucim and hasattr(cucim, "CuImage") @@ -44,19 +44,19 @@ TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] -TEST_CASE_1 = [ +TEST_CASE_DEP_1 = [ FILE_PATH, {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), ] -TEST_CASE_2 = [ +TEST_CASE_DEP_2 = [ FILE_PATH, {"location": (0, 0), "size": (2, 1), "level": 2}, np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), ] -TEST_CASE_3 = [ +TEST_CASE_DEP_3 = [ FILE_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 2}, np.array( @@ -67,18 +67,58 @@ ), ] -TEST_CASE_4 = [ +TEST_CASE_DEP_4 = [ FILE_PATH, {"location": (0, 0), "size": (8, 8), "level": 2, "grid_shape": (2, 1), "patch_size": 1}, np.array([[[[239]], [[239]], [[239]]], [[[243]], [[243]], [[243]]]]), ] -TEST_CASE_5 = [ +TEST_CASE_DEP_5 = [ FILE_PATH, {"location": (HEIGHT - 2, WIDTH - 2), "level": 0, "grid_shape": (1, 1)}, np.array([[[239, 239], [239, 239]], [[239, 239], [239, 239]], [[237, 237], [237, 237]]]), ] +TEST_CASE_1 = [ + FILE_PATH, + {}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), +] + +TEST_CASE_2 = [ + FILE_PATH, + {}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), +] + +TEST_CASE_3 = [ + FILE_PATH, + {"channel_dim": -1}, + {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, + np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), +] + +TEST_CASE_4 = [ + FILE_PATH, + {"channel_dim": 2}, + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), +] + +TEST_CASE_MULTI_WSI = [ + [FILE_PATH, FILE_PATH], + {"location": (0, 0), "size": (2, 1), "level": 2}, + np.concatenate( + [ + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), + ], + axis=0, + ), +] + TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW @@ -108,6 +148,20 @@ def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): return filename +def save_gray_tiff(array: np.ndarray, filename: str): + """ + Save numpy array into a TIFF file + + Args: + array: numpy ndarray with any shape + filename: the filename to be used for the tiff file. + """ + img_gray = array + imwrite(filename, img_gray, shape=img_gray.shape) + + return filename + + @skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") def setUpModule(): hash_type = testing_data_config("images", FILE_KEY, "hash_type") @@ -115,21 +169,21 @@ def setUpModule(): download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) -class WSIReaderTests: +class WSIReaderDeprecatedTests: class Tests(unittest.TestCase): backend = None @parameterized.expand([TEST_CASE_0]) def test_read_whole_image(self, file_path, level, expected_shape): - reader = WSIReader(self.backend, level=level) + reader = WSIReaderDeprecated(self.backend, level=level) with reader.read(file_path) as img_obj: img = reader.get_data(img_obj)[0] self.assertTupleEqual(img.shape, expected_shape) - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_5]) + @parameterized.expand([TEST_CASE_DEP_1, TEST_CASE_DEP_2, TEST_CASE_DEP_5]) def test_read_region(self, file_path, patch_info, expected_img): kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} - reader = WSIReader(self.backend, **kwargs) + reader = WSIReaderDeprecated(self.backend, **kwargs) with reader.read(file_path, **kwargs) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): @@ -143,9 +197,9 @@ def test_read_region(self, file_path, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) - @parameterized.expand([TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand([TEST_CASE_DEP_3, TEST_CASE_DEP_4]) def test_read_patches(self, file_path, patch_info, expected_img): - reader = WSIReader(self.backend) + reader = WSIReaderDeprecated(self.backend) with reader.read(file_path) as img_obj: if self.backend == "tifffile": with self.assertRaises(ValueError): @@ -155,6 +209,115 @@ def test_read_patches(self, file_path, patch_info, expected_img): self.assertTupleEqual(img.shape, expected_img.shape) self.assertIsNone(assert_array_equal(img, expected_img)) + @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_rgba(self, img_expected): + # skip for OpenSlide since not working with images without tiles + if self.backend == "openslide": + return + image = {} + reader = WSIReaderDeprecated(self.backend) + for mode in ["RGB", "RGBA"]: + file_path = save_rgba_tiff( + img_expected, + os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"), + mode=mode, + ) + with reader.read(file_path) as img_obj: + image[mode], _ = reader.get_data(img_obj) + + self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) + self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) + + @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) + @skipUnless(has_tiff, "Requires tifffile.") + def test_read_malformats(self, img_expected): + if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): + # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 + return + reader = WSIReaderDeprecated(self.backend) + file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") + imwrite(file_path, img_expected, shape=img_expected.shape) + with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): + with reader.read(file_path) as img_obj: + reader.get_data(img_obj) + + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReaderDeprecated, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}], transform=train_transform) + data_loader = DataLoader(dataset) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + assert_allclose(s, expected_spatial_shape, type_test=False) + self.assertTupleEqual(data["image"].shape, expected_shape) + + +class WSIReaderTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0]) + def test_read_whole_image(self, file_path, level, expected_shape): + reader = WSIReader(self.backend, level=level) + with reader.read(file_path) as img_obj: + img, meta = reader.get_data(img_obj) + self.assertTupleEqual(img.shape, expected_shape) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], level) + assert_array_equal(meta["patch_size"], expected_shape[1:]) + assert_array_equal(meta["patch_location"], (0, 0)) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_read_region(self, file_path, kwargs, patch_info, expected_img): + reader = WSIReader(self.backend, **kwargs) + with reader.read(file_path) as img_obj: + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj, **patch_info)[0] + else: + # Read twice to check multiple calls + img, meta = reader.get_data(img_obj, **patch_info) + img2 = reader.get_data(img_obj, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"], str(os.path.abspath(file_path))) + self.assertEqual(meta["patch_level"], patch_info["level"]) + assert_array_equal(meta["patch_size"], patch_info["size"]) + assert_array_equal(meta["patch_location"], patch_info["location"]) + + @parameterized.expand([TEST_CASE_MULTI_WSI]) + def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): + kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} + reader = WSIReader(self.backend, **kwargs) + img_obj_list = reader.read(file_path_list, **kwargs) + if self.backend == "tifffile": + with self.assertRaises(ValueError): + reader.get_data(img_obj_list, **patch_info)[0] + else: + # Read twice to check multiple calls + img, meta = reader.get_data(img_obj_list, **patch_info) + img2 = reader.get_data(img_obj_list, **patch_info)[0] + self.assertTupleEqual(img.shape, img2.shape) + self.assertIsNone(assert_array_equal(img, img2)) + self.assertTupleEqual(img.shape, expected_img.shape) + self.assertIsNone(assert_array_equal(img, expected_img)) + self.assertEqual(meta["backend"], self.backend) + self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) + self.assertEqual(meta["patch_level"][0], patch_info["level"]) + assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) + assert_array_equal(meta["patch_location"][0], patch_info["location"]) + @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) @skipUnless(has_tiff, "Requires tifffile.") def test_read_rgba(self, img_expected): @@ -201,30 +364,61 @@ def test_with_dataloader(self, file_path, level, expected_spatial_shape, expecte data_loader = DataLoader(dataset) data: dict = first(data_loader) for s in data[PostFix.meta("image")]["spatial_shape"]: - torch.testing.assert_allclose(s, expected_spatial_shape) + assert_allclose(s, expected_spatial_shape, type_test=False) self.assertTupleEqual(data["image"].shape, expected_shape) + @parameterized.expand([TEST_CASE_TRANSFORM_0]) + def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): + train_transform = Compose( + [ + LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), + FromMetaTensord(keys=["image"]), + ToTensord(keys=["image"]), + ] + ) + dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) + batch_size = 2 + data_loader = DataLoader(dataset, batch_size=batch_size) + data: dict = first(data_loader) + for s in data[PostFix.meta("image")]["spatial_shape"]: + assert_allclose(s, expected_spatial_shape, type_test=False) + self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) + @skipUnless(has_cucim, "Requires cucim") -class TestCuCIM(WSIReaderTests.Tests): +class TestCuCIMDeprecated(WSIReaderDeprecatedTests.Tests): @classmethod def setUpClass(cls): cls.backend = "cucim" @skipUnless(has_osl, "Requires OpenSlide") -class TestOpenSlide(WSIReaderTests.Tests): +class TestOpenSlideDeprecated(WSIReaderDeprecatedTests.Tests): @classmethod def setUpClass(cls): cls.backend = "openslide" @skipUnless(has_tiff, "Requires TiffFile") -class TestTiffFile(WSIReaderTests.Tests): +class TestTiffFileDeprecated(WSIReaderDeprecatedTests.Tests): @classmethod def setUpClass(cls): cls.backend = "tifffile" +@skipUnless(has_cucim, "Requires cucim") +class TestCuCIM(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestOpenSlide(WSIReaderTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + if __name__ == "__main__": unittest.main() From 0d595b6794a02baa729ab5113725bbaf9a104db9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 13:15:23 +0000 Subject: [PATCH 64/67] Restore patch wsi dataset testsr Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset.py | 315 +++++++++++++-------- tests/test_patch_wsi_dataset_deprecated.py | 256 ----------------- tests/test_patch_wsi_dataset_new.py | 181 ++++++++++++ 3 files changed, 376 insertions(+), 376 deletions(-) delete mode 100644 tests/test_patch_wsi_dataset_deprecated.py create mode 100644 tests/test_patch_wsi_dataset_new.py diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index fee8a03068..20d7f22988 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -17,17 +17,13 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.data import PatchWSIDataset -from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader +from monai.apps.pathology.data import PatchWSIDataset from monai.utils import optional_import from tests.utils import download_url_or_skip_test, testing_data_config -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec +_cucim, has_cim = optional_import("cucim") +has_cim = has_cim and hasattr(_cucim, "CuImage") +_, has_osl = optional_import("openslide") FILE_KEY = "wsi_img" FILE_URL = testing_data_config("images", FILE_KEY, "url") @@ -35,146 +31,225 @@ FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] + + TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], ] + +TEST_CASE_1_L0 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 0, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] + + +TEST_CASE_1_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "level": 1, + "image_reader_name": "cuCIM", + }, + [ + {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, + ], +] TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], + "region_size": 1, + "grid_shape": 1, + "patch_size": 1, + "image_reader_name": "cuCIM", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], ] -TEST_CASE_4 = [ +TEST_CASE_OPENSLIDE_0 = [ { - "data": [ - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, - ], + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), "patch_size": 1, + "image_reader_name": "OpenSlide", }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_5 = [ +TEST_CASE_OPENSLIDE_0_L0 = [ { - "data": [ - { - "image": FILE_PATH, - "patch_location": [0, 0], - "label": [[[0, 1], [1, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - { - "image": FILE_PATH, - "patch_location": [100, 100], - "label": [[[1, 0], [0, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - ] + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 0, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_0_L1 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 1, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + + +TEST_CASE_OPENSLIDE_0_L2 = [ + { + "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], + "region_size": (1, 1), + "grid_shape": (1, 1), + "patch_size": 1, + "level": 2, + "image_reader_name": "OpenSlide", + }, + [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], +] + +TEST_CASE_OPENSLIDE_1 = [ + { + "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], + "region_size": (8, 8), + "grid_shape": (2, 2), + "patch_size": 1, + "image_reader_name": "OpenSlide", }, [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, + {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, ], ] -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class PatchWSIDatasetTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_str(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_class(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader - elif self.backend == "cucim": - reader = CuCIMWSIReader - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_object(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) - elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_read_patches_str_multi(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - for i in range(len(dataset)): - self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) - - -@skipUnless(has_cucim, "Requires cucim") -class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires openslide") -class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" +class TestPatchWSIDataset(unittest.TestCase): + def setUp(self): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_0_L1, + TEST_CASE_0_L2, + TEST_CASE_1, + TEST_CASE_1_L0, + TEST_CASE_1_L1, + TEST_CASE_2, + TEST_CASE_3, + ] + ) + @skipUnless(has_cim, "Requires CuCIM") + def test_read_patches_cucim(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) + + @parameterized.expand( + [ + TEST_CASE_OPENSLIDE_0, + TEST_CASE_OPENSLIDE_0_L0, + TEST_CASE_OPENSLIDE_0_L1, + TEST_CASE_OPENSLIDE_0_L2, + TEST_CASE_OPENSLIDE_1, + ] + ) + @skipUnless(has_osl, "Requires OpenSlide") + def test_read_patches_openslide(self, input_parameters, expected): + dataset = PatchWSIDataset(**input_parameters) + samples = dataset[0] + for i in range(len(samples)): + self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) if __name__ == "__main__": diff --git a/tests/test_patch_wsi_dataset_deprecated.py b/tests/test_patch_wsi_dataset_deprecated.py deleted file mode 100644 index 20d7f22988..0000000000 --- a/tests/test_patch_wsi_dataset_deprecated.py +++ /dev/null @@ -1,256 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.apps.pathology.data import PatchWSIDataset -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, testing_data_config - -_cucim, has_cim = optional_import("cucim") -has_cim = has_cim and hasattr(_cucim, "CuImage") -_, has_osl = optional_import("openslide") - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -TEST_CASE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_0_L2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - - -TEST_CASE_1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -TEST_CASE_1_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 0, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -TEST_CASE_1_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "level": 1, - "image_reader_name": "cuCIM", - }, - [ - {"image": np.array([[[248]], [[246]], [[249]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[196]], [[187]], [[192]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[243]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] -TEST_CASE_2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_3 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], - "region_size": 1, - "grid_shape": 1, - "patch_size": 1, - "image_reader_name": "cuCIM", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], -] - -TEST_CASE_OPENSLIDE_0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L0 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 0, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_0_L1 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 1, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - - -TEST_CASE_OPENSLIDE_0_L2 = [ - { - "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], - "region_size": (1, 1), - "grid_shape": (1, 1), - "patch_size": 1, - "level": 2, - "image_reader_name": "OpenSlide", - }, - [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], -] - -TEST_CASE_OPENSLIDE_1 = [ - { - "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], - "region_size": (8, 8), - "grid_shape": (2, 2), - "patch_size": 1, - "image_reader_name": "OpenSlide", - }, - [ - {"image": np.array([[[247]], [[245]], [[248]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[245]], [[247]], [[244]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[0]]])}, - {"image": np.array([[[246]], [[246]], [[246]]], dtype=np.uint8), "label": np.array([[[1]]])}, - ], -] - - -class TestPatchWSIDataset(unittest.TestCase): - def setUp(self): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - @parameterized.expand( - [ - TEST_CASE_0, - TEST_CASE_0_L1, - TEST_CASE_0_L2, - TEST_CASE_1, - TEST_CASE_1_L0, - TEST_CASE_1_L1, - TEST_CASE_2, - TEST_CASE_3, - ] - ) - @skipUnless(has_cim, "Requires CuCIM") - def test_read_patches_cucim(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) - - @parameterized.expand( - [ - TEST_CASE_OPENSLIDE_0, - TEST_CASE_OPENSLIDE_0_L0, - TEST_CASE_OPENSLIDE_0_L1, - TEST_CASE_OPENSLIDE_0_L2, - TEST_CASE_OPENSLIDE_1, - ] - ) - @skipUnless(has_osl, "Requires OpenSlide") - def test_read_patches_openslide(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) - samples = dataset[0] - for i in range(len(samples)): - self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(samples[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(samples[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py new file mode 100644 index 0000000000..fee8a03068 --- /dev/null +++ b/tests/test_patch_wsi_dataset_new.py @@ -0,0 +1,181 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest import skipUnless + +import numpy as np +from numpy.testing import assert_array_equal +from parameterized import parameterized + +from monai.data import PatchWSIDataset +from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader +from monai.utils import optional_import +from tests.utils import download_url_or_skip_test, testing_data_config + +cucim, has_cucim = optional_import("cucim") +has_cucim = has_cucim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec + +FILE_KEY = "wsi_img" +FILE_URL = testing_data_config("images", FILE_KEY, "url") +base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" +FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) + +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] +TEST_CASE_1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_3 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, +] + +TEST_CASE_4 = [ + { + "data": [ + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + ], + "patch_size": 1, + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + +TEST_CASE_5 = [ + { + "data": [ + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + ] + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + + +@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class PatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_str(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_class(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader + elif self.backend == "cucim": + reader = CuCIMWSIReader + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_object(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) + elif self.backend == "cucim": + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_read_patches_str_multi(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + for i in range(len(dataset)): + self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) + + +@skipUnless(has_cucim, "Requires cucim") +class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + +if __name__ == "__main__": + unittest.main() From 925803138c25b27475c2928a578fc1a077d73281 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 13:25:42 +0000 Subject: [PATCH 65/67] Merge new and deprecated patch wsi dataset tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset.py | 217 ++++++++++++++++++++++++++------ 1 file changed, 180 insertions(+), 37 deletions(-) diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 20d7f22988..81913267dc 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -17,20 +17,25 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.apps.pathology.data import PatchWSIDataset +from monai.apps.pathology.data import PatchWSIDataset as PatchWSIDatasetDeprecated +from monai.data import PatchWSIDataset +from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader from monai.utils import optional_import from tests.utils import download_url_or_skip_test, testing_data_config -_cucim, has_cim = optional_import("cucim") -has_cim = has_cim and hasattr(_cucim, "CuImage") -_, has_osl = optional_import("openslide") +cucim, has_cim = optional_import("cucim") +has_cim = has_cim and hasattr(cucim, "CuImage") +openslide, has_osl = optional_import("openslide") +imwrite, has_tiff = optional_import("tifffile", name="imwrite") +_, has_codec = optional_import("imagecodecs") +has_tiff = has_tiff and has_codec FILE_KEY = "wsi_img" FILE_URL = testing_data_config("images", FILE_KEY, "url") base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) -TEST_CASE_0 = [ +TEST_CASE_DEP_0 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -41,7 +46,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_0_L1 = [ +TEST_CASE_DEP_0_L1 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -53,7 +58,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_0_L2 = [ +TEST_CASE_DEP_0_L2 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -66,7 +71,7 @@ ] -TEST_CASE_1 = [ +TEST_CASE_DEP_1 = [ { "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], "region_size": (8, 8), @@ -83,7 +88,7 @@ ] -TEST_CASE_1_L0 = [ +TEST_CASE_DEP_1_L0 = [ { "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], "region_size": (8, 8), @@ -101,7 +106,7 @@ ] -TEST_CASE_1_L1 = [ +TEST_CASE_DEP_1_L1 = [ { "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], "region_size": (8, 8), @@ -117,7 +122,7 @@ {"image": np.array([[[246]], [[242]], [[243]]], dtype=np.uint8), "label": np.array([[[1]]])}, ], ] -TEST_CASE_2 = [ +TEST_CASE_DEP_2 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": 1, @@ -128,7 +133,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_3 = [ +TEST_CASE_DEP_3 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "region_size": 1, @@ -139,7 +144,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}], ] -TEST_CASE_OPENSLIDE_0 = [ +TEST_CASE_DEP_OPENSLIDE_0 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -150,7 +155,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_OPENSLIDE_0_L0 = [ +TEST_CASE_DEP_OPENSLIDE_0_L0 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -162,7 +167,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_OPENSLIDE_0_L1 = [ +TEST_CASE_DEP_OPENSLIDE_0_L1 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -175,7 +180,7 @@ ] -TEST_CASE_OPENSLIDE_0_L2 = [ +TEST_CASE_DEP_OPENSLIDE_0_L2 = [ { "data": [{"image": FILE_PATH, "location": [0, 0], "label": [1]}], "region_size": (1, 1), @@ -187,7 +192,7 @@ [{"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1]]])}], ] -TEST_CASE_OPENSLIDE_1 = [ +TEST_CASE_DEP_OPENSLIDE_1 = [ { "data": [{"image": FILE_PATH, "location": [10004, 20004], "label": [0, 0, 0, 1]}], "region_size": (8, 8), @@ -204,27 +209,98 @@ ] -class TestPatchWSIDataset(unittest.TestCase): - def setUp(self): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) +TEST_CASE_0 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_0_L1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] +TEST_CASE_0_L2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] +TEST_CASE_1 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_2 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, +] + +TEST_CASE_3 = [ + {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, +] + +TEST_CASE_4 = [ + { + "data": [ + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, + {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, + ], + "patch_size": 1, + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + +TEST_CASE_5 = [ + { + "data": [ + { + "image": FILE_PATH, + "patch_location": [0, 0], + "label": [[[0, 1], [1, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + { + "image": FILE_PATH, + "patch_location": [100, 100], + "label": [[[1, 0], [0, 0]]], + "patch_size": 1, + "patch_level": 1, + }, + ] + }, + [ + {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, + {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, + ], +] + + +@skipUnless(has_cim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") +def setUpModule(): + hash_type = testing_data_config("images", FILE_KEY, "hash_type") + hash_val = testing_data_config("images", FILE_KEY, "hash_val") + download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) + + +class TestPatchWSIDatasetDeprecated(unittest.TestCase): @parameterized.expand( [ - TEST_CASE_0, - TEST_CASE_0_L1, - TEST_CASE_0_L2, - TEST_CASE_1, - TEST_CASE_1_L0, - TEST_CASE_1_L1, - TEST_CASE_2, - TEST_CASE_3, + TEST_CASE_DEP_0, + TEST_CASE_DEP_0_L1, + TEST_CASE_DEP_0_L2, + TEST_CASE_DEP_1, + TEST_CASE_DEP_1_L0, + TEST_CASE_DEP_1_L1, + TEST_CASE_DEP_2, + TEST_CASE_DEP_3, ] ) @skipUnless(has_cim, "Requires CuCIM") def test_read_patches_cucim(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) + dataset = PatchWSIDatasetDeprecated(**input_parameters) samples = dataset[0] for i in range(len(samples)): self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) @@ -234,16 +310,16 @@ def test_read_patches_cucim(self, input_parameters, expected): @parameterized.expand( [ - TEST_CASE_OPENSLIDE_0, - TEST_CASE_OPENSLIDE_0_L0, - TEST_CASE_OPENSLIDE_0_L1, - TEST_CASE_OPENSLIDE_0_L2, - TEST_CASE_OPENSLIDE_1, + TEST_CASE_DEP_OPENSLIDE_0, + TEST_CASE_DEP_OPENSLIDE_0_L0, + TEST_CASE_DEP_OPENSLIDE_0_L1, + TEST_CASE_DEP_OPENSLIDE_0_L2, + TEST_CASE_DEP_OPENSLIDE_1, ] ) @skipUnless(has_osl, "Requires OpenSlide") def test_read_patches_openslide(self, input_parameters, expected): - dataset = PatchWSIDataset(**input_parameters) + dataset = PatchWSIDatasetDeprecated(**input_parameters) samples = dataset[0] for i in range(len(samples)): self.assertTupleEqual(samples[i]["label"].shape, expected[i]["label"].shape) @@ -252,5 +328,72 @@ def test_read_patches_openslide(self, input_parameters, expected): self.assertIsNone(assert_array_equal(samples[i]["image"], expected[i]["image"])) +class PatchWSIDatasetTests: + class Tests(unittest.TestCase): + backend = None + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_str(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_class(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader + elif self.backend == "cucim": + reader = CuCIMWSIReader + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_read_patches_object(self, input_parameters, expected): + if self.backend == "openslide": + reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) + elif self.backend == "cucim": + reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) + else: + raise ValueError("Unsupported backend: {self.backend}") + dataset = PatchWSIDataset(reader=reader, **input_parameters) + sample = dataset[0] + self.assertTupleEqual(sample["label"].shape, expected["label"].shape) + self.assertTupleEqual(sample["image"].shape, expected["image"].shape) + self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) + self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) + + @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) + def test_read_patches_str_multi(self, input_parameters, expected): + dataset = PatchWSIDataset(reader=self.backend, **input_parameters) + for i in range(len(dataset)): + self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) + self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) + self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) + self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) + + +@skipUnless(has_cim, "Requires cucim") +class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "cucim" + + +@skipUnless(has_osl, "Requires openslide") +class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): + @classmethod + def setUpClass(cls): + cls.backend = "openslide" + + if __name__ == "__main__": unittest.main() From 050ef6b2c2c9a6f6a3584dde74f31a58350421ec Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 13:31:39 +0000 Subject: [PATCH 66/67] Deprecate unit tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset.py | 3 ++- tests/test_wsireader.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 81913267dc..b08e380162 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -20,7 +20,7 @@ from monai.apps.pathology.data import PatchWSIDataset as PatchWSIDatasetDeprecated from monai.data import PatchWSIDataset from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader -from monai.utils import optional_import +from monai.utils import deprecated, optional_import from tests.utils import download_url_or_skip_test, testing_data_config cucim, has_cim = optional_import("cucim") @@ -285,6 +285,7 @@ def setUpModule(): download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) +@deprecated(since="0.8", msg_suffix="use tests for `monai.data.PatchWSIDataset` instead, `PatchWSIDatasetTests`.") class TestPatchWSIDatasetDeprecated(unittest.TestCase): @parameterized.expand( [ diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index dba74df0c3..1576a16de4 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -21,7 +21,7 @@ from monai.data.image_reader import WSIReader as WSIReaderDeprecated from monai.data.wsi_reader import WSIReader from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord -from monai.utils import first, optional_import +from monai.utils import deprecated, first, optional_import from monai.utils.enums import PostFix from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config @@ -169,6 +169,7 @@ def setUpModule(): download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) +@deprecated(since="0.8", msg_suffix="use tests for `monai.wsi_reader.WSIReader` instead, `WSIReaderTests`.") class WSIReaderDeprecatedTests: class Tests(unittest.TestCase): backend = None From 61d3818900346d6e28b02258e4c81f86e4c3cd22 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 8 Aug 2022 13:32:31 +0000 Subject: [PATCH 67/67] Remove redundant tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- tests/test_patch_wsi_dataset_new.py | 181 ------------------ tests/test_wsireader_new.py | 277 ---------------------------- 2 files changed, 458 deletions(-) delete mode 100644 tests/test_patch_wsi_dataset_new.py delete mode 100644 tests/test_wsireader_new.py diff --git a/tests/test_patch_wsi_dataset_new.py b/tests/test_patch_wsi_dataset_new.py deleted file mode 100644 index fee8a03068..0000000000 --- a/tests/test_patch_wsi_dataset_new.py +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.data import PatchWSIDataset -from monai.data.wsi_reader import CuCIMWSIReader, OpenSlideWSIReader -from monai.utils import optional_import -from tests.utils import download_url_or_skip_test, testing_data_config - -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -TEST_CASE_0 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1], "patch_level": 0}], "patch_size": (1, 1)}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_0_L1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_0_L2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": (1, 1), "patch_level": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] -TEST_CASE_1 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "patch_size": 1, "label": [1]}]}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_2 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [1]}], "patch_size": 1, "patch_level": 0}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([1])}, -] - -TEST_CASE_3 = [ - {"data": [{"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}], "patch_size": 1}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, -] - -TEST_CASE_4 = [ - { - "data": [ - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[0, 1], [1, 0]]]}, - {"image": FILE_PATH, "patch_location": [0, 0], "label": [[[1, 0], [0, 0]]]}, - ], - "patch_size": 1, - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], -] - -TEST_CASE_5 = [ - { - "data": [ - { - "image": FILE_PATH, - "patch_location": [0, 0], - "label": [[[0, 1], [1, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - { - "image": FILE_PATH, - "patch_location": [100, 100], - "label": [[[1, 0], [0, 0]]], - "patch_size": 1, - "patch_level": 1, - }, - ] - }, - [ - {"image": np.array([[[239]], [[239]], [[239]]], dtype=np.uint8), "label": np.array([[[0, 1], [1, 0]]])}, - {"image": np.array([[[243]], [[243]], [[243]]], dtype=np.uint8), "label": np.array([[[1, 0], [0, 0]]])}, - ], -] - - -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class PatchWSIDatasetTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_str(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_class(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader - elif self.backend == "cucim": - reader = CuCIMWSIReader - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_0, TEST_CASE_0_L1, TEST_CASE_0_L2, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_read_patches_object(self, input_parameters, expected): - if self.backend == "openslide": - reader = OpenSlideWSIReader(level=input_parameters.get("patch_level", 0)) - elif self.backend == "cucim": - reader = CuCIMWSIReader(level=input_parameters.get("patch_level", 0)) - else: - raise ValueError("Unsupported backend: {self.backend}") - dataset = PatchWSIDataset(reader=reader, **input_parameters) - sample = dataset[0] - self.assertTupleEqual(sample["label"].shape, expected["label"].shape) - self.assertTupleEqual(sample["image"].shape, expected["image"].shape) - self.assertIsNone(assert_array_equal(sample["label"], expected["label"])) - self.assertIsNone(assert_array_equal(sample["image"], expected["image"])) - - @parameterized.expand([TEST_CASE_4, TEST_CASE_5]) - def test_read_patches_str_multi(self, input_parameters, expected): - dataset = PatchWSIDataset(reader=self.backend, **input_parameters) - for i in range(len(dataset)): - self.assertTupleEqual(dataset[i]["label"].shape, expected[i]["label"].shape) - self.assertTupleEqual(dataset[i]["image"].shape, expected[i]["image"].shape) - self.assertIsNone(assert_array_equal(dataset[i]["label"], expected[i]["label"])) - self.assertIsNone(assert_array_equal(dataset[i]["image"], expected[i]["image"])) - - -@skipUnless(has_cucim, "Requires cucim") -class TestPatchWSIDatasetCuCIM(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires openslide") -class TestPatchWSIDatasetOpenSlide(PatchWSIDatasetTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_wsireader_new.py b/tests/test_wsireader_new.py deleted file mode 100644 index 0d5e5892e6..0000000000 --- a/tests/test_wsireader_new.py +++ /dev/null @@ -1,277 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from unittest import skipUnless - -import numpy as np -from numpy.testing import assert_array_equal -from parameterized import parameterized - -from monai.data import DataLoader, Dataset -from monai.data.wsi_reader import WSIReader -from monai.transforms import Compose, FromMetaTensord, LoadImaged, ToTensord -from monai.utils import first, optional_import -from monai.utils.enums import PostFix -from tests.utils import assert_allclose, download_url_or_skip_test, testing_data_config - -cucim, has_cucim = optional_import("cucim") -has_cucim = has_cucim and hasattr(cucim, "CuImage") -openslide, has_osl = optional_import("openslide") -imwrite, has_tiff = optional_import("tifffile", name="imwrite") -_, has_codec = optional_import("imagecodecs") -has_tiff = has_tiff and has_codec - -FILE_KEY = "wsi_img" -FILE_URL = testing_data_config("images", FILE_KEY, "url") -base_name, extension = os.path.basename(f"{FILE_URL}"), ".tiff" -FILE_PATH = os.path.join(os.path.dirname(__file__), "testing_data", "temp_" + base_name + extension) - -HEIGHT = 32914 -WIDTH = 46000 - -TEST_CASE_0 = [FILE_PATH, 2, (3, HEIGHT // 4, WIDTH // 4)] - -TEST_CASE_TRANSFORM_0 = [FILE_PATH, 4, (HEIGHT // 16, WIDTH // 16), (1, 3, HEIGHT // 16, WIDTH // 16)] - -TEST_CASE_1 = [ - FILE_PATH, - {}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, - np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), -] - -TEST_CASE_2 = [ - FILE_PATH, - {}, - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), -] - -TEST_CASE_3 = [ - FILE_PATH, - {"channel_dim": -1}, - {"location": (HEIGHT // 2, WIDTH // 2), "size": (2, 1), "level": 0}, - np.moveaxis(np.array([[[246], [246]], [[246], [246]], [[246], [246]]]), 0, -1), -] - -TEST_CASE_4 = [ - FILE_PATH, - {"channel_dim": 2}, - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.moveaxis(np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), 0, -1), -] - -TEST_CASE_MULTI_WSI = [ - [FILE_PATH, FILE_PATH], - {"location": (0, 0), "size": (2, 1), "level": 2}, - np.concatenate( - [ - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - np.array([[[239], [239]], [[239], [239]], [[239], [239]]]), - ], - axis=0, - ), -] - - -TEST_CASE_RGB_0 = [np.ones((3, 2, 2), dtype=np.uint8)] # CHW - -TEST_CASE_RGB_1 = [np.ones((3, 100, 100), dtype=np.uint8)] # CHW - -TEST_CASE_ERROR_0C = [np.ones((16, 16), dtype=np.uint8)] # no color channel -TEST_CASE_ERROR_1C = [np.ones((16, 16, 1), dtype=np.uint8)] # one color channel -TEST_CASE_ERROR_2C = [np.ones((16, 16, 2), dtype=np.uint8)] # two color channels -TEST_CASE_ERROR_3D = [np.ones((16, 16, 16, 3), dtype=np.uint8)] # 3D + color - - -def save_rgba_tiff(array: np.ndarray, filename: str, mode: str): - """ - Save numpy array into a TIFF RGB/RGBA file - - Args: - array: numpy ndarray with the shape of CxHxW and C==3 representing a RGB image - filename: the filename to be used for the tiff file. '_RGB.tiff' or '_RGBA.tiff' will be appended to this filename. - mode: RGB or RGBA - """ - if mode == "RGBA": - array = np.concatenate([array, 255 * np.ones_like(array[0])[np.newaxis]]).astype(np.uint8) - - img_rgb = array.transpose(1, 2, 0) - imwrite(filename, img_rgb, shape=img_rgb.shape, tile=(16, 16)) - - return filename - - -def save_gray_tiff(array: np.ndarray, filename: str): - """ - Save numpy array into a TIFF file - - Args: - array: numpy ndarray with any shape - filename: the filename to be used for the tiff file. - """ - img_gray = array - imwrite(filename, img_gray, shape=img_gray.shape) - - return filename - - -@skipUnless(has_cucim or has_osl or has_tiff, "Requires cucim, openslide, or tifffile!") -def setUpModule(): - hash_type = testing_data_config("images", FILE_KEY, "hash_type") - hash_val = testing_data_config("images", FILE_KEY, "hash_val") - download_url_or_skip_test(FILE_URL, FILE_PATH, hash_type=hash_type, hash_val=hash_val) - - -class WSIReaderTests: - class Tests(unittest.TestCase): - backend = None - - @parameterized.expand([TEST_CASE_0]) - def test_read_whole_image(self, file_path, level, expected_shape): - reader = WSIReader(self.backend, level=level) - with reader.read(file_path) as img_obj: - img, meta = reader.get_data(img_obj) - self.assertTupleEqual(img.shape, expected_shape) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], level) - assert_array_equal(meta["patch_size"], expected_shape[1:]) - assert_array_equal(meta["patch_location"], (0, 0)) - - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) - def test_read_region(self, file_path, kwargs, patch_info, expected_img): - reader = WSIReader(self.backend, **kwargs) - with reader.read(file_path) as img_obj: - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj, **patch_info)[0] - else: - # Read twice to check multiple calls - img, meta = reader.get_data(img_obj, **patch_info) - img2 = reader.get_data(img_obj, **patch_info)[0] - self.assertTupleEqual(img.shape, img2.shape) - self.assertIsNone(assert_array_equal(img, img2)) - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"], str(os.path.abspath(file_path))) - self.assertEqual(meta["patch_level"], patch_info["level"]) - assert_array_equal(meta["patch_size"], patch_info["size"]) - assert_array_equal(meta["patch_location"], patch_info["location"]) - - @parameterized.expand([TEST_CASE_MULTI_WSI]) - def test_read_region_multi_wsi(self, file_path_list, patch_info, expected_img): - kwargs = {"name": None, "offset": None} if self.backend == "tifffile" else {} - reader = WSIReader(self.backend, **kwargs) - img_obj_list = reader.read(file_path_list, **kwargs) - if self.backend == "tifffile": - with self.assertRaises(ValueError): - reader.get_data(img_obj_list, **patch_info)[0] - else: - # Read twice to check multiple calls - img, meta = reader.get_data(img_obj_list, **patch_info) - img2 = reader.get_data(img_obj_list, **patch_info)[0] - self.assertTupleEqual(img.shape, img2.shape) - self.assertIsNone(assert_array_equal(img, img2)) - self.assertTupleEqual(img.shape, expected_img.shape) - self.assertIsNone(assert_array_equal(img, expected_img)) - self.assertEqual(meta["backend"], self.backend) - self.assertEqual(meta["path"][0], str(os.path.abspath(file_path_list[0]))) - self.assertEqual(meta["patch_level"][0], patch_info["level"]) - assert_array_equal(meta["patch_size"][0], expected_img.shape[1:]) - assert_array_equal(meta["patch_location"][0], patch_info["location"]) - - @parameterized.expand([TEST_CASE_RGB_0, TEST_CASE_RGB_1]) - @skipUnless(has_tiff, "Requires tifffile.") - def test_read_rgba(self, img_expected): - # skip for OpenSlide since not working with images without tiles - if self.backend == "openslide": - return - image = {} - reader = WSIReader(self.backend) - for mode in ["RGB", "RGBA"]: - file_path = save_rgba_tiff( - img_expected, - os.path.join(os.path.dirname(__file__), "testing_data", f"temp_tiff_image_{mode}.tiff"), - mode=mode, - ) - with reader.read(file_path) as img_obj: - image[mode], _ = reader.get_data(img_obj) - - self.assertIsNone(assert_array_equal(image["RGB"], img_expected)) - self.assertIsNone(assert_array_equal(image["RGBA"], img_expected)) - - @parameterized.expand([TEST_CASE_ERROR_0C, TEST_CASE_ERROR_1C, TEST_CASE_ERROR_2C, TEST_CASE_ERROR_3D]) - @skipUnless(has_tiff, "Requires tifffile.") - def test_read_malformats(self, img_expected): - if self.backend == "cucim" and (len(img_expected.shape) < 3 or img_expected.shape[2] == 1): - # Until cuCIM addresses https://github.com/rapidsai/cucim/issues/230 - return - reader = WSIReader(self.backend) - file_path = os.path.join(os.path.dirname(__file__), "testing_data", "temp_tiff_image_gray.tiff") - imwrite(file_path, img_expected, shape=img_expected.shape) - with self.assertRaises((RuntimeError, ValueError, openslide.OpenSlideError if has_osl else ValueError)): - with reader.read(file_path) as img_obj: - reader.get_data(img_obj) - - @parameterized.expand([TEST_CASE_TRANSFORM_0]) - def test_with_dataloader(self, file_path, level, expected_spatial_shape, expected_shape): - train_transform = Compose( - [ - LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), - FromMetaTensord(keys=["image"]), - ToTensord(keys=["image"]), - ] - ) - dataset = Dataset([{"image": file_path}], transform=train_transform) - data_loader = DataLoader(dataset) - data: dict = first(data_loader) - for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) - self.assertTupleEqual(data["image"].shape, expected_shape) - - @parameterized.expand([TEST_CASE_TRANSFORM_0]) - def test_with_dataloader_batch(self, file_path, level, expected_spatial_shape, expected_shape): - train_transform = Compose( - [ - LoadImaged(keys=["image"], reader=WSIReader, backend=self.backend, level=level), - FromMetaTensord(keys=["image"]), - ToTensord(keys=["image"]), - ] - ) - dataset = Dataset([{"image": file_path}, {"image": file_path}], transform=train_transform) - batch_size = 2 - data_loader = DataLoader(dataset, batch_size=batch_size) - data: dict = first(data_loader) - for s in data[PostFix.meta("image")]["spatial_shape"]: - assert_allclose(s, expected_spatial_shape, type_test=False) - self.assertTupleEqual(data["image"].shape, (batch_size, *expected_shape[1:])) - - -@skipUnless(has_cucim, "Requires cucim") -class TestCuCIM(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "cucim" - - -@skipUnless(has_osl, "Requires openslide") -class TestOpenSlide(WSIReaderTests.Tests): - @classmethod - def setUpClass(cls): - cls.backend = "openslide" - - -if __name__ == "__main__": - unittest.main()