From a566dc69d19c7437a9b6b7dcd5dbfd7a9f077bab Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 23 Jul 2021 09:52:22 +0100 Subject: [PATCH 01/89] fixes randbiasfield (#2645) Signed-off-by: Wenqi Li --- monai/transforms/intensity/array.py | 33 +++++++++++------------------ tests/test_random_bias_field.py | 17 ++++++--------- tests/test_random_bias_fieldd.py | 18 +++++++--------- 3 files changed, 26 insertions(+), 42 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 52774f75db..65c114abcd 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -431,22 +431,19 @@ def __init__( self.coeff_range = coeff_range self.dtype = dtype - def _generate_random_field( - self, - spatial_shape: Tuple[int, ...], - rank: int, - degree: int, - coeff: Tuple[int, ...], - ): + self._coeff = [1.0] + + def _generate_random_field(self, spatial_shape: Sequence[int], degree: int, coeff: Sequence[float]): """ products of polynomials as bias field estimations """ + rank = len(spatial_shape) coeff_mat = np.zeros((degree + 1,) * rank) coords = [np.linspace(-1.0, 1.0, dim, dtype=np.float32) for dim in spatial_shape] if rank == 2: coeff_mat[np.tril_indices(degree + 1)] = coeff - field = np.polynomial.legendre.leggrid2d(coords[0], coords[1], coeff_mat) - elif rank == 3: + return np.polynomial.legendre.leggrid2d(coords[0], coords[1], coeff_mat) + if rank == 3: pts: List[List[int]] = [[0, 0, 0]] for i in range(degree + 1): for j in range(degree + 1 - i): @@ -456,16 +453,12 @@ def _generate_random_field( pts = pts[1:] np_pts = np.stack(pts) coeff_mat[np_pts[:, 0], np_pts[:, 1], np_pts[:, 2]] = coeff - field = np.polynomial.legendre.leggrid3d(coords[0], coords[1], coords[2], coeff_mat) - else: - raise NotImplementedError("only supports 2D or 3D fields") - return field + return np.polynomial.legendre.leggrid3d(coords[0], coords[1], coords[2], coeff_mat) + raise NotImplementedError("only supports 2D or 3D fields") def randomize(self, data: np.ndarray) -> None: super().randomize(None) - self.spatial_shape = data.shape[1:] - self.rank = len(self.spatial_shape) - n_coeff = int(np.prod([(self.degree + k) / k for k in range(1, self.rank + 1)])) + n_coeff = int(np.prod([(self.degree + k) / k for k in range(1, len(data.shape[1:]) + 1)])) self._coeff = self.R.uniform(*self.coeff_range, n_coeff).tolist() def __call__(self, img: np.ndarray): @@ -475,17 +468,15 @@ def __call__(self, img: np.ndarray): self.randomize(data=img) if not self._do_transform: return img - num_channels = img.shape[0] + num_channels, *spatial_shape = img.shape _bias_fields = np.stack( [ - self._generate_random_field( - spatial_shape=self.spatial_shape, rank=self.rank, degree=self.degree, coeff=self._coeff - ) + self._generate_random_field(spatial_shape=spatial_shape, degree=self.degree, coeff=self._coeff) for _ in range(num_channels) ], axis=0, ) - return (img * _bias_fields).astype(self.dtype) + return (img * np.exp(_bias_fields)).astype(self.dtype) class NormalizeIntensity(Transform): diff --git a/tests/test_random_bias_field.py b/tests/test_random_bias_field.py index 16b4ab6917..5aeeb79874 100644 --- a/tests/test_random_bias_field.py +++ b/tests/test_random_bias_field.py @@ -18,17 +18,12 @@ TEST_CASES_2D = [{}, (3, 32, 32)] TEST_CASES_3D = [{}, (3, 32, 32, 32)] -TEST_CASES_2D_ZERO_RANGE = [{"coeff_range": (0.0, 0.0)}, (3, 32, 32)] -TEST_CASES_2D_ONES = [{"coeff_range": (1.0, 1.0)}, np.asarray([[[2, -2], [2, 10]]])] +TEST_CASES_2D_ZERO_RANGE = [{"coeff_range": (0.0, 0.0)}, (2, 3, 3)] +TEST_CASES_2D_ONES = [{"coeff_range": (1.0, 1.0)}, np.asarray([[[7.389056, 0.1353353], [7.389056, 22026.46]]])] class TestRandBiasField(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASES_2D, - TEST_CASES_3D, - ] - ) + @parameterized.expand([TEST_CASES_2D, TEST_CASES_3D]) def test_output_shape(self, class_args, img_shape): for degree in [1, 2, 3]: bias_field = RandBiasField(degree=degree, **class_args) @@ -44,16 +39,16 @@ def test_output_shape(self, class_args, img_shape): @parameterized.expand([TEST_CASES_2D_ZERO_RANGE]) def test_zero_range(self, class_args, img_shape): bias_field = RandBiasField(**class_args) - img = np.random.rand(*img_shape) + img = np.ones(img_shape) output = bias_field(img) - np.testing.assert_equal(output, np.zeros(img_shape)) + np.testing.assert_allclose(output, np.ones(img_shape), rtol=1e-3) @parameterized.expand([TEST_CASES_2D_ONES]) def test_one_range_input(self, class_args, expected): bias_field = RandBiasField(**class_args) img = np.ones([1, 2, 2]) output = bias_field(img) - np.testing.assert_equal(output, expected.astype(bias_field.dtype)) + np.testing.assert_allclose(output, expected.astype(bias_field.dtype), rtol=1e-3) def test_zero_prob(self): bias_field = RandBiasField(prob=0.0) diff --git a/tests/test_random_bias_fieldd.py b/tests/test_random_bias_fieldd.py index 136eb41f2e..aa2e206de9 100644 --- a/tests/test_random_bias_fieldd.py +++ b/tests/test_random_bias_fieldd.py @@ -19,16 +19,14 @@ TEST_CASES_2D = [{}, (3, 32, 32)] TEST_CASES_3D = [{}, (3, 32, 32, 32)] TEST_CASES_2D_ZERO_RANGE = [{"coeff_range": (0.0, 0.0)}, (3, 32, 32)] -TEST_CASES_2D_ONES = [{"coeff_range": (1.0, 1.0)}, np.asarray([[[2, -2], [2, 10]]])] +TEST_CASES_2D_ONES = [ + {"coeff_range": (1.0, 1.0)}, + np.asarray([[[7.3890562e00, 1.3533528e-01], [7.3890562e00, 2.2026465e04]]]), +] class TestRandBiasFieldd(unittest.TestCase): - @parameterized.expand( - [ - TEST_CASES_2D, - TEST_CASES_3D, - ] - ) + @parameterized.expand([TEST_CASES_2D, TEST_CASES_3D]) def test_output_shape(self, class_args, img_shape): key = "img" bias_field = RandBiasFieldd(keys=[key], **class_args) @@ -41,9 +39,9 @@ def test_output_shape(self, class_args, img_shape): def test_zero_range(self, class_args, img_shape): key = "img" bias_field = RandBiasFieldd(keys=[key], **class_args) - img = np.random.rand(*img_shape) + img = np.ones(img_shape) output = bias_field({key: img}) - np.testing.assert_equal(output[key], np.zeros(img_shape)) + np.testing.assert_allclose(output[key], np.ones(img_shape)) @parameterized.expand([TEST_CASES_2D_ONES]) def test_one_range_input(self, class_args, expected): @@ -51,7 +49,7 @@ def test_one_range_input(self, class_args, expected): bias_field = RandBiasFieldd(keys=[key], **class_args) img = np.ones([1, 2, 2]) output = bias_field({key: img}) - np.testing.assert_equal(output[key], expected.astype(bias_field.rand_bias_field.dtype)) + np.testing.assert_allclose(output[key], expected.astype(bias_field.rand_bias_field.dtype), rtol=1e-3) def test_zero_prob(self): key = "img" From 325775bceb1bfd5e45e971823a3fe764dbdb3f5c Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 26 Jul 2021 10:05:09 +0800 Subject: [PATCH 02/89] 1979 Enhance TTA to align with Invertd args and support no label (#2654) * [DLMED] enhance TTA Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma --- monai/data/test_time_augmentation.py | 43 +++++++++++++++--------- monai/handlers/transform_inverter.py | 4 --- monai/transforms/post/dictionary.py | 4 --- tests/test_handler_transform_inverter.py | 2 -- tests/test_invertd.py | 1 - tests/test_testtimeaugmentation.py | 4 +-- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/monai/data/test_time_augmentation.py b/monai/data/test_time_augmentation.py index 7e80a286bf..33239ea924 100644 --- a/monai/data/test_time_augmentation.py +++ b/monai/data/test_time_augmentation.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from copy import deepcopy from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union import numpy as np @@ -21,7 +22,7 @@ from monai.transforms.inverse import InvertibleTransform from monai.transforms.inverse_batch_transform import BatchInverseTransform from monai.transforms.transform import Randomizable -from monai.transforms.utils import allow_missing_keys_mode +from monai.transforms.utils import allow_missing_keys_mode, convert_inverse_interp_mode from monai.utils.enums import CommonKeys, InverseKeys from monai.utils.module import optional_import @@ -61,11 +62,11 @@ class TestTimeAugmentation: inferrer_fn: function to use to perform inference. device: device on which to perform inference. image_key: key used to extract image from input dictionary. - label_key: key used to extract label from input dictionary. - meta_keys: explicitly indicate the key of the expected meta data dictionary. - for example, for data with key `label`, the metadata by default is in `label_meta_dict`. + orig_key: the key of the original input data in the dict. will get the applied transform information + for this input data, then invert them for the expected data with `image_key`. + orig_meta_keys: the key of the meta data of original input data, will get the `affine`, `data_shape`, etc. the meta data is a dictionary object which contains: filename, original_shape, etc. - if None, will try to construct meta_keys by `key_{meta_key_postfix}`. + if None, will try to construct meta_keys by `{orig_key}_{meta_key_postfix}`. meta_key_postfix: use `key_{postfix}` to to fetch the meta data according to the key data, default is `meta_dict`, the meta data is a dictionary object. For example, to handle key `image`, read/write affine matrices from the @@ -96,8 +97,9 @@ def __init__( inferrer_fn: Callable, device: Union[str, torch.device] = "cpu", image_key=CommonKeys.IMAGE, - label_key=CommonKeys.LABEL, - meta_keys: Optional[str] = None, + orig_key=CommonKeys.LABEL, + nearest_interp: bool = True, + orig_meta_keys: Optional[str] = None, meta_key_postfix="meta_dict", return_full_data: bool = False, progress: bool = True, @@ -108,8 +110,9 @@ def __init__( self.inferrer_fn = inferrer_fn self.device = device self.image_key = image_key - self.label_key = label_key - self.meta_keys = meta_keys + self.orig_key = orig_key + self.nearest_interp = nearest_interp + self.orig_meta_keys = orig_meta_keys self.meta_key_postfix = meta_key_postfix self.return_full_data = return_full_data self.progress = progress @@ -160,7 +163,7 @@ def __call__( ds = Dataset(data_in, self.transform) dl = DataLoader(ds, self.num_workers, batch_size=self.batch_size, collate_fn=pad_list_data_collate) - label_transform_key = self.label_key + InverseKeys.KEY_SUFFIX + transform_key = self.orig_key + InverseKeys.KEY_SUFFIX # create inverter inverter = BatchInverseTransform(self.transform, dl, collate_fn=list_data_collate) @@ -178,19 +181,27 @@ def __call__( if isinstance(batch_output, np.ndarray): batch_output = torch.Tensor(batch_output) + transform_info = batch_data[transform_key] + if self.nearest_interp: + transform_info = convert_inverse_interp_mode( + trans_info=deepcopy(transform_info), + mode="nearest", + align_corners=None, + ) + # create a dictionary containing the inferred batch and their transforms - inferred_dict = {self.label_key: batch_output, label_transform_key: batch_data[label_transform_key]} + inferred_dict = {self.orig_key: batch_output, transform_key: transform_info} # if meta dict is present, add that too (required for some inverse transforms) - label_meta_dict_key = self.meta_keys or f"{self.label_key}_{self.meta_key_postfix}" - if label_meta_dict_key in batch_data: - inferred_dict[label_meta_dict_key] = batch_data[label_meta_dict_key] + meta_dict_key = self.orig_meta_keys or f"{self.orig_key}_{self.meta_key_postfix}" + if meta_dict_key in batch_data: + inferred_dict[meta_dict_key] = batch_data[meta_dict_key] - # do inverse transformation (allow missing keys as only inverting label) + # do inverse transformation (allow missing keys as only inverting the orig_key) with allow_missing_keys_mode(self.transform): # type: ignore inv_batch = inverter(inferred_dict) # append - outputs.append(inv_batch[self.label_key]) + outputs.append(inv_batch[self.orig_key]) # output output: np.ndarray = np.concatenate(outputs) diff --git a/monai/handlers/transform_inverter.py b/monai/handlers/transform_inverter.py index 4cf234241d..c6f38e4afd 100644 --- a/monai/handlers/transform_inverter.py +++ b/monai/handlers/transform_inverter.py @@ -87,9 +87,6 @@ def __init__( each matches to the `output_keys` data. post_func: post processing for the inverted data, should be a callable function. it also can be a list of callable, each matches to the `output_keys` data. - num_workers: number of workers when run data loader for inverse transforms, - default to 0 as only run one iteration and multi-processing may be even slower. - Set to `None`, to use the `num_workers` of the input transform data loader. """ self.inverter = Invertd( @@ -103,7 +100,6 @@ def __init__( to_tensor=to_tensor, device=device, post_func=post_func, - num_workers=num_workers, ) self.output_keys = ensure_tuple(output_keys) self.meta_keys = ensure_tuple_rep(None, len(self.output_keys)) if meta_keys is None else ensure_tuple(meta_keys) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 5a9bcfb7de..6cba08948b 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -429,7 +429,6 @@ def __init__( to_tensor: Union[bool, Sequence[bool]] = True, device: Union[Union[str, torch.device], Sequence[Union[str, torch.device]]] = "cpu", post_func: Union[Callable, Sequence[Callable]] = lambda x: x, - num_workers: Optional[int] = 0, allow_missing_keys: bool = False, ) -> None: """ @@ -465,9 +464,6 @@ def __init__( each matches to the `keys` data. post_func: post processing for the inverted data, should be a callable function. it also can be a list of callable, each matches to the `keys` data. - num_workers: number of workers when run data loader for inverse transforms, - default to 0 as only run one iteration and multi-processing may be even slower. - Set to `None`, to use the `num_workers` of the input transform data loader. allow_missing_keys: don't raise exception if key is missing. """ diff --git a/tests/test_handler_transform_inverter.py b/tests/test_handler_transform_inverter.py index f2e75a7153..385311eba7 100644 --- a/tests/test_handler_transform_inverter.py +++ b/tests/test_handler_transform_inverter.py @@ -95,7 +95,6 @@ def _train_func(engine, batch): nearest_interp=True, to_tensor=[True, False], device="cpu", - num_workers=0 if sys.platform == "darwin" or torch.cuda.is_available() else 2, ).attach(engine) # test different nearest interpolation values @@ -108,7 +107,6 @@ def _train_func(engine, batch): meta_key_postfix="meta_dict", nearest_interp=[True, False], post_func=[lambda x: x + 10, lambda x: x], - num_workers=0 if sys.platform == "darwin" or torch.cuda.is_available() else 2, ).attach(engine) engine.run(loader, max_epochs=1) diff --git a/tests/test_invertd.py b/tests/test_invertd.py index 6ba98ee919..5b98653f0a 100644 --- a/tests/test_invertd.py +++ b/tests/test_invertd.py @@ -84,7 +84,6 @@ def test_invert(self): nearest_interp=True, to_tensor=[True, False, False], device="cpu", - num_workers=0 if sys.platform == "darwin" or torch.cuda.is_available() else 2, ) # execute 1 epoch diff --git a/tests/test_testtimeaugmentation.py b/tests/test_testtimeaugmentation.py index ab9c7c4c18..66d7627971 100644 --- a/tests/test_testtimeaugmentation.py +++ b/tests/test_testtimeaugmentation.py @@ -148,13 +148,13 @@ def test_single_transform(self): def test_image_no_label(self): transforms = RandFlipd(["image"], prob=1.0) - tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, label_key="image") + tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, orig_key="image") tta(self.get_data(1, (20, 20), include_label=False)) @unittest.skipUnless(has_nib, "Requires nibabel") def test_requires_meta_dict(self): transforms = Compose([RandFlipd("image"), Spacingd("image", pixdim=1.0)]) - tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, label_key="image") + tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x, orig_key="image") tta(self.get_data(1, (20, 20), include_label=False)) From 141e9dbb5e1d9516e8cb742ccb0b9a2f482e5bec Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 27 Jul 2021 00:43:02 +0800 Subject: [PATCH 03/89] 2635 Enhance doc-string of UNet for spatial size (#2641) * [DLMED] enhance doc-string for spatial shape Signed-off-by: Nic Ma * [DLMED] add ill test case Signed-off-by: Nic Ma * [DLMED] update test error Signed-off-by: Nic Ma * update docstring and add more test cases Signed-off-by: Yiheng Wang * [DLMED] add link to doc-string Signed-off-by: Nic Ma * [DLMED] enhance doc-string Signed-off-by: Nic Ma Co-authored-by: Yiheng Wang Co-authored-by: Wenqi Li --- monai/networks/nets/unet.py | 37 +++++++++++++++++----- tests/test_unet.py | 62 ++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/monai/networks/nets/unet.py b/monai/networks/nets/unet.py index f3742d05b5..9e3538c2b3 100644 --- a/monai/networks/nets/unet.py +++ b/monai/networks/nets/unet.py @@ -9,7 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Sequence, Union +import warnings +from typing import Sequence, Tuple, Union import torch import torch.nn as nn @@ -35,8 +36,8 @@ def __init__( kernel_size: Union[Sequence[int], int] = 3, up_kernel_size: Union[Sequence[int], int] = 3, num_res_units: int = 0, - act=Act.PRELU, - norm=Norm.INSTANCE, + act: Union[Tuple, str] = Act.PRELU, + norm: Union[Tuple, str] = Norm.INSTANCE, dropout=0.0, ) -> None: """ @@ -49,17 +50,39 @@ def __init__( dimensions: number of spatial dimensions. in_channels: number of input channels. out_channels: number of output channels. - channels: sequence of channels. Top block first. - strides: convolution stride. - kernel_size: convolution kernel size. Defaults to 3. - up_kernel_size: upsampling convolution kernel size. Defaults to 3. + channels: sequence of channels. Top block first. The length of `channels` should be no less than 2. + strides: sequence of convolution strides. The length of `stride` should equal to `len(channels) - 1`. + kernel_size: convolution kernel size, the value(s) should be odd. If sequence, + its length should equal to dimensions. Defaults to 3. + up_kernel_size: upsampling convolution kernel size, the value(s) should be odd. If sequence, + its length should equal to dimensions. Defaults to 3. num_res_units: number of residual units. Defaults to 0. act: activation type and arguments. Defaults to PReLU. norm: feature normalization type and arguments. Defaults to instance norm. dropout: dropout ratio. Defaults to no dropout. + + Note: The acceptable spatial size of input data depends on the parameters of the network, + to set appropriate spatial size, please check the tutorial for more details: + https://github.com/Project-MONAI/tutorials/blob/master/modules/UNet_input_size_constrains.ipynb. + Typically, applying `resize`, `pad` or `crop` transforms can help adjust the spatial size of input data. + """ super().__init__() + if len(channels) < 2: + raise ValueError("the length of `channels` should be no less than 2.") + delta = len(strides) - len(channels) + if delta < -1: + raise ValueError("the length of `strides` should equal to `len(channels) - 1`.") + if delta >= 0: + warnings.warn(f"`len(strides) >= len(channels)`, the last {delta + 1} values of strides will not be used.") + if isinstance(kernel_size, Sequence): + if len(kernel_size) != dimensions: + raise ValueError("the length of `kernel_size` should equal to `dimensions`.") + if isinstance(up_kernel_size, Sequence): + if len(up_kernel_size) != dimensions: + raise ValueError("the length of `up_kernel_size` should equal to `dimensions`.") + self.dimensions = dimensions self.in_channels = in_channels self.out_channels = out_channels diff --git a/tests/test_unet.py b/tests/test_unet.py index 7bf2c0c920..4091c4e9d7 100644 --- a/tests/test_unet.py +++ b/tests/test_unet.py @@ -117,6 +117,49 @@ CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6] +ILL_CASES = [ + [ + { # len(channels) < 2 + "dimensions": 2, + "in_channels": 1, + "out_channels": 3, + "channels": (16,), + "strides": (2, 2), + "num_res_units": 0, + } + ], + [ + { # len(strides) < len(channels) - 1 + "dimensions": 2, + "in_channels": 1, + "out_channels": 3, + "channels": (8, 8, 8), + "strides": (2,), + "num_res_units": 0, + } + ], + [ + { # len(kernel_size) = 3, dimensions = 2 + "dimensions": 2, + "in_channels": 1, + "out_channels": 3, + "channels": (8, 8, 8), + "strides": (2, 2), + "kernel_size": (3, 3, 3), + } + ], + [ + { # len(up_kernel_size) = 2, dimensions = 3 + "dimensions": 3, + "in_channels": 1, + "out_channels": 3, + "channels": (8, 8, 8), + "strides": (2, 2), + "up_kernel_size": (3, 3), + } + ], +] + class TestUNET(unittest.TestCase): @parameterized.expand(CASES) @@ -141,9 +184,26 @@ def test_script_without_running_stats(self): num_res_units=0, norm=("batch", {"track_running_stats": False}), ) - test_data = torch.randn(16, 1, 16, 8) + test_data = torch.randn(16, 1, 16, 4) test_script_save(net, test_data) + def test_ill_input_shape(self): + net = UNet( + dimensions=2, + in_channels=1, + out_channels=3, + channels=(16, 32, 64), + strides=(2, 2), + ) + with eval_mode(net): + with self.assertRaisesRegex(RuntimeError, "Sizes of tensors must match"): + net.forward(torch.randn(2, 1, 16, 5)) + + @parameterized.expand(ILL_CASES) + def test_ill_input_hyper_params(self, input_param): + with self.assertRaises(ValueError): + net = UNet(**input_param) + if __name__ == "__main__": unittest.main() From 8d4f45f1eee74e08920056d95b3b4a10c314fb0a Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 27 Jul 2021 15:08:07 +0100 Subject: [PATCH 04/89] new theme (#2657) Signed-off-by: Wenqi Li --- docs/_static/custom.css | 4 +++- docs/requirements.txt | 4 ++-- docs/source/api.rst | 20 ++++++++++++++++ docs/source/conf.py | 48 +++++++++++++++++++++++++++---------- docs/source/contrib.rst | 7 ++++++ docs/source/highlights.md | 2 +- docs/source/index.rst | 29 +++++++--------------- docs/source/installation.md | 2 +- docs/source/whatsnew.rst | 10 ++++++++ monai/data/image_reader.py | 12 ++++++---- 10 files changed, 94 insertions(+), 44 deletions(-) create mode 100644 docs/source/api.rst create mode 100644 docs/source/contrib.rst create mode 100644 docs/source/whatsnew.rst diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 45875841f8..e0a3457ca6 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,2 +1,4 @@ @import url('https://fonts.googleapis.com/css?family=Lekton:700|Roboto&display=swap'); -body{font-family:'Roboto',sans-serif;}.wy-side-nav-search>div.version{color:#222;}a:visited{color:#0285b0;}.wy-menu-vertical a:visited{color:#d9d9d9;}.wy-menu-vertical p.caption{color:#7cccc7;} +body{font-family:'Roboto',sans-serif;}.wy-menu-vertical p.caption{color:#7cccc7;} +*{font-variant-ligatures: none;}.autoclasstoc td {padding:0.2rem;line-height:normal;} +dl.field-list>dt{word-break: normal} diff --git a/docs/requirements.txt b/docs/requirements.txt index 9d9cdebb3e..00dd4d2c1e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -9,8 +9,8 @@ scikit-image>=0.14.2 tensorboard commonmark==0.9.1 recommonmark==0.6.0 -Sphinx==3.5.3 -sphinx-rtd-theme==0.5.2 +Sphinx +pydata-sphinx-theme sphinxcontrib-applehelp sphinxcontrib-devhelp sphinxcontrib-htmlhelp diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000000..0596a25514 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,20 @@ +:github_url: https://github.com/Project-MONAI/MONAI + +API Reference +============= + +.. toctree:: + :maxdepth: 1 + + apps + transforms + losses + networks + metrics + optimizers + data + engines + inferers + handlers + visualize + utils diff --git a/docs/source/conf.py b/docs/source/conf.py index 780a2d9a6d..7efebfb8d2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,8 +11,8 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os -import sys import subprocess +import sys sys.path.insert(0, os.path.abspath("..")) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) @@ -68,12 +68,6 @@ def generate_apidocs(*args): ) -def setup(app): - # Hook to allow for automatic generation of API docs - # before doc deployment begins. - app.connect("builder-inited", generate_apidocs) - - # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be @@ -94,8 +88,10 @@ def setup(app): autoclass_content = "both" add_module_names = True +source_encoding = "utf-8" autosectionlabel_prefix_document = True napoleon_use_param = True +napoleon_include_init_with_doc = True set_type_checking_flag = True # Add any paths that contain templates here, relative to this directory. @@ -106,29 +102,55 @@ def setup(app): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "pydata_sphinx_theme" # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme_options = { + "external_links": [{"url": "https://github.com/Project-MONAI/tutorials", "name": "Tutorials"}], "collapse_navigation": True, - "display_version": True, - "sticky_navigation": True, # Set to False to disable the sticky nav while scrolling. - "logo_only": True, # if we have a html_logo below, this shows /only/ the logo with no title text - "style_nav_header_background": "#FBFBFB", + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/project-monai/monai", + "icon": "fab fa-github-square", + }, + { + "name": "Twitter", + "url": "https://twitter.com/projectmonai", + "icon": "fab fa-twitter-square", + }, + ], + "collapse_navigation": True, + "navigation_depth": 3, + "show_toc_level": 1, + "footer_items": ["copyright"], + "navbar_align": "content", } html_context = { - "display_github": True, "github_user": "Project-MONAI", "github_repo": "MONAI", "github_version": "dev", + "doc_path": "docs/", "conf_py_path": "/docs/", + "VERSION": version, } html_scaled_image_link = False html_show_sourcelink = True html_favicon = "../images/favicon.ico" html_logo = "../images/MONAI-logo-color.png" +html_sidebars = {"**": ["search-field", "sidebar-nav-bs"]} +pygments_style = "sphinx" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["../_static"] html_css_files = ["custom.css"] +html_title = f"{project} {version} Documentation" + +# -- Auto-convert markdown pages to demo -------------------------------------- + + +def setup(app): + # Hook to allow for automatic generation of API docs + # before doc deployment begins. + app.connect("builder-inited", generate_apidocs) diff --git a/docs/source/contrib.rst b/docs/source/contrib.rst new file mode 100644 index 0000000000..8f50824bf9 --- /dev/null +++ b/docs/source/contrib.rst @@ -0,0 +1,7 @@ +:github_url: https://github.com/Project-MONAI/MONAI + +Development +=========== + +For guidance on making a contribution to MONAI, see the `contributing guidelines +`_. diff --git a/docs/source/highlights.md b/docs/source/highlights.md index 61935fd3dc..141c0846d1 100644 --- a/docs/source/highlights.md +++ b/docs/source/highlights.md @@ -1,4 +1,4 @@ -# Modules overview +# Modules Overview MONAI aims at supporting deep learning in medical image analysis at multiple granularities. This figure shows a typical example of the end-to-end workflow: diff --git a/docs/source/index.rst b/docs/source/index.rst index 30671427a4..76ba003c8d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,26 +45,14 @@ Technical documentation is available at `docs.monai.io `_ :maxdepth: 1 :caption: Feature highlights - whatsnew_0_6.md - whatsnew_0_5.md + whatsnew highlights.md .. toctree:: :maxdepth: 1 - :caption: APIs - - apps - transforms - losses - networks - metrics - optimizers - data - engines - inferers - handlers - visualize - utils + :caption: API Reference + + api .. toctree:: :maxdepth: 1 @@ -72,12 +60,11 @@ Technical documentation is available at `docs.monai.io `_ installation +.. toctree:: + :maxdepth: 1 + :caption: Contributing -Contributing ------------- - -For guidance on making a contribution to MONAI, see the `contributing guidelines -`_. + contrib Links diff --git a/docs/source/installation.md b/docs/source/installation.md index d8dddff205..08ab109142 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -1,4 +1,4 @@ -# Installation guide +# Installation Guide ## Table of Contents 1. [From PyPI](#from-pypi) diff --git a/docs/source/whatsnew.rst b/docs/source/whatsnew.rst new file mode 100644 index 0000000000..daed871e14 --- /dev/null +++ b/docs/source/whatsnew.rst @@ -0,0 +1,10 @@ +:github_url: https://github.com/Project-MONAI/MONAI + +What's New +========== + +.. toctree:: + :maxdepth: 1 + + whatsnew_0_6.md + whatsnew_0_5.md diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 11ed768eb7..0c736a548d 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -131,14 +131,16 @@ class ITKReader(ImageReader): Args: channel_dim: the channel dimension of the input image, default is None. - This is used to set `original_channel_dim` in the meta data, `EnsureChannelFirstD` reads this field. - If None, `original_channel_dim` will be either `no_channel` or `-1`. - - Nifti file is usually "channel last", so there is no need to specify this argument. - - PNG file usually has `GetNumberOfComponentsPerPixel()==3`, so there is no need to specify this argument. + This is used to set original_channel_dim in the meta data, EnsureChannelFirstD reads this field. + If None, original_channel_dim will be either `no_channel` or `-1`. + + - Nifti file is usually "channel last", so there is no need to specify this argument. + - PNG file usually has `GetNumberOfComponentsPerPixel()==3`, so there is no need to specify this argument. + series_name: the name of the DICOM series if there are multiple ones. used when loading DICOM series. kwargs: additional args for `itk.imread` API. more details about available args: - https://github.com/InsightSoftwareConsortium/ITK/blob/master/Wrapping/Generators/Python/itkExtras.py + https://github.com/InsightSoftwareConsortium/ITK/blob/master/Wrapping/Generators/Python/itk/support/extras.py """ From aaeebd62e297b64ee7b5e3fccd415ed0aacba4cf Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 27 Jul 2021 23:58:23 +0800 Subject: [PATCH 05/89] 2635 enhance UNet doc for the typical use case (#2659) * [DLMED] enhance doc-string Signed-off-by: Nic Ma * [DLMED] enhance the sanity check Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/networks/nets/unet.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/monai/networks/nets/unet.py b/monai/networks/nets/unet.py index 9e3538c2b3..158b154042 100644 --- a/monai/networks/nets/unet.py +++ b/monai/networks/nets/unet.py @@ -64,18 +64,21 @@ def __init__( Note: The acceptable spatial size of input data depends on the parameters of the network, to set appropriate spatial size, please check the tutorial for more details: https://github.com/Project-MONAI/tutorials/blob/master/modules/UNet_input_size_constrains.ipynb. - Typically, applying `resize`, `pad` or `crop` transforms can help adjust the spatial size of input data. + Typically, when using a stride of 2 in down / up sampling, the output dimensions are either half of the + input when downsampling, or twice when upsampling. In this case with N numbers of layers in the network, + the inputs must have spatial dimensions that are all multiples of 2^N. + Usually, applying `resize`, `pad` or `crop` transforms can help adjust the spatial size of input data. """ super().__init__() if len(channels) < 2: raise ValueError("the length of `channels` should be no less than 2.") - delta = len(strides) - len(channels) - if delta < -1: + delta = len(strides) - (len(channels) - 1) + if delta < 0: raise ValueError("the length of `strides` should equal to `len(channels) - 1`.") - if delta >= 0: - warnings.warn(f"`len(strides) >= len(channels)`, the last {delta + 1} values of strides will not be used.") + if delta > 0: + warnings.warn(f"`len(strides) > len(channels) - 1`, the last {delta} values of strides will not be used.") if isinstance(kernel_size, Sequence): if len(kernel_size) != dimensions: raise ValueError("the length of `kernel_size` should equal to `dimensions`.") From c4833f598763457ad8247ffa987631ff9ff08367 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 28 Jul 2021 01:55:29 +0800 Subject: [PATCH 06/89] 2648 add `RandCoarseDropout` transform (#2658) * [DLMED] add RandCoarseDropout Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] add dict version transform Signed-off-by: Nic Ma * [DLMED] updated according to comments Signed-off-by: Nic Ma Co-authored-by: Wenqi Li --- docs/source/transforms.rst | 24 +++++-- monai/transforms/__init__.py | 4 ++ monai/transforms/intensity/array.py | 68 ++++++++++++++++++ monai/transforms/intensity/dictionary.py | 85 ++++++++++++++++++++++- monai/transforms/spatial/array.py | 10 +-- monai/transforms/spatial/dictionary.py | 10 +-- tests/test_rand_coarse_dropout.py | 73 ++++++++++++++++++++ tests/test_rand_coarse_dropoutd.py | 87 ++++++++++++++++++++++++ 8 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 tests/test_rand_coarse_dropout.py create mode 100644 tests/test_rand_coarse_dropoutd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index fcd9adba94..962e1f3769 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -274,41 +274,47 @@ Intensity :special-members: __call__ `RandHistogramShift` -""""""""""""""""""""" +"""""""""""""""""""" .. autoclass:: RandHistogramShift :members: :special-members: __call__ `DetectEnvelope` -""""""""""""""""""""" +"""""""""""""""" .. autoclass:: DetectEnvelope :members: :special-members: __call__ `GibbsNoise` -"""""""""""""" +"""""""""""" .. autoclass:: GibbsNoise :members: :special-members: __call__ `RandGibbsNoise` -""""""""""""""""" +"""""""""""""""" .. autoclass:: RandGibbsNoise :members: :special-members: __call__ `KSpaceSpikeNoise` -"""""""""""""""""""" +"""""""""""""""""" .. autoclass:: KSpaceSpikeNoise :members: :special-members: __call__ `RandKSpaceSpikeNoise` -"""""""""""""""""""""""" +"""""""""""""""""""""" .. autoclass:: RandKSpaceSpikeNoise :members: :special-members: __call__ +`RandCoarseDropout` +""""""""""""""""""" + .. autoclass:: RandCoarseDropout + :members: + :special-members: __call__ + IO ^^ @@ -889,6 +895,12 @@ Intensity (Dict) :members: :special-members: __call__ +`RandCoarseDropoutd` +"""""""""""""""""""" +.. autoclass:: RandCoarseDropoutd + :members: + :special-members: __call__ + IO (Dict) ^^^^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 21cfce2b82..45eecd266c 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -88,6 +88,7 @@ NormalizeIntensity, RandAdjustContrast, RandBiasField, + RandCoarseDropout, RandGaussianNoise, RandGaussianSharpen, RandGaussianSmooth, @@ -134,6 +135,9 @@ RandBiasFieldd, RandBiasFieldD, RandBiasFieldDict, + RandCoarseDropoutd, + RandCoarseDropoutD, + RandCoarseDropoutDict, RandGaussianNoised, RandGaussianNoiseD, RandGaussianNoiseDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 65c114abcd..dfbac7465c 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -21,6 +21,7 @@ import torch from monai.config import DtypeLike +from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.transform import RandomizableTransform, Transform from monai.transforms.utils import rescale_array @@ -31,6 +32,7 @@ ensure_tuple, ensure_tuple_rep, ensure_tuple_size, + fall_back_tuple, ) __all__ = [ @@ -61,6 +63,7 @@ "RandGibbsNoise", "KSpaceSpikeNoise", "RandKSpaceSpikeNoise", + "RandCoarseDropout", ] @@ -1603,3 +1606,68 @@ def _to_numpy(self, img: Union[np.ndarray, torch.Tensor]) -> Tuple[np.ndarray, t return img.cpu().detach().numpy(), img.device else: return img, torch.device("cpu") + + +class RandCoarseDropout(RandomizableTransform): + """ + Randomly coarse dropout regions in the image, then fill in the rectangular regions with specified value. + Refer to: https://arxiv.org/abs/1708.04552 and: + https://albumentations.ai/docs/api_reference/augmentations/transforms/ + #albumentations.augmentations.transforms.CoarseDropout. + + Args: + holes: number of regions to dropout, if `max_holes` is not None, use this arg as the minimum number to + randomly select the expected number of regions. + spatial_size: spatial size of the regions to dropout, if `max_spatial_size` is not None, use this arg + as the minimum spatial size to randomly select size for every region. + if some components of the `spatial_size` are non-positive values, the transform will use the + corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted + to `(32, 64)` if the second spatial dimension size of img is `64`. + fill_value: target value to fill the dropout regions. + max_holes: if not None, define the maximum number to randomly select the expected number of regions. + max_spatial_size: if not None, define the maximum spatial size to randomly select size for every region. + if some components of the `max_spatial_size` are non-positive values, the transform will use the + corresponding components of input img size. For example, `max_spatial_size=(32, -1)` will be adapted + to `(32, 64)` if the second spatial dimension size of img is `64`. + prob: probability of applying the transform. + + """ + + def __init__( + self, + holes: int, + spatial_size: Union[Sequence[int], int], + fill_value: Union[float, int] = 0, + max_holes: Optional[int] = None, + max_spatial_size: Optional[Union[Sequence[int], int]] = None, + prob: float = 0.1, + ) -> None: + RandomizableTransform.__init__(self, prob) + if holes < 1: + raise ValueError("number of holes must be greater than 0.") + self.holes = holes + self.spatial_size = spatial_size + self.fill_value = fill_value + self.max_holes = max_holes + self.max_spatial_size = max_spatial_size + self.hole_coords: List = [] + + def randomize(self, img_size: Sequence[int]) -> None: + super().randomize(None) + size = fall_back_tuple(self.spatial_size, img_size) + self.hole_coords = [] # clear previously computed coords + num_holes = self.holes if self.max_holes is None else self.R.randint(self.holes, self.max_holes + 1) + for _ in range(num_holes): + if self.max_spatial_size is not None: + max_size = fall_back_tuple(self.max_spatial_size, img_size) + size = tuple(self.R.randint(low=size[i], high=max_size[i] + 1) for i in range(len(img_size))) + valid_size = get_valid_patch_size(img_size, size) + self.hole_coords.append((slice(None),) + get_random_patch(img_size, valid_size, self.R)) + + def __call__(self, img: np.ndarray): + self.randomize(img.shape[1:]) + if self._do_transform: + for h in self.hole_coords: + img[h] = self.fill_value + + return img diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index ae0b83e0ea..49f20ea419 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -22,6 +22,7 @@ import torch from monai.config import DtypeLike, KeysCollection +from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.intensity.array import ( AdjustContrast, GaussianSharpen, @@ -41,7 +42,7 @@ ThresholdIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform -from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size +from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple __all__ = [ "RandGaussianNoised", @@ -69,6 +70,7 @@ "KSpaceSpikeNoised", "RandKSpaceSpikeNoised", "RandHistogramShiftd", + "RandCoarseDropoutd", "RandGaussianNoiseD", "RandGaussianNoiseDict", "ShiftIntensityD", @@ -117,13 +119,16 @@ "RandHistogramShiftDict", "RandRicianNoiseD", "RandRicianNoiseDict", + "RandCoarseDropoutD", + "RandCoarseDropoutDict", ] class RandGaussianNoised(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandGaussianNoise`. - Add Gaussian noise to image. This transform assumes all the expected fields have same shape. + Add Gaussian noise to image. This transform assumes all the expected fields have same shape, if want to add + different noise for every field, please use this transform separately. Args: keys: keys of the corresponding items to be transformed. @@ -172,7 +177,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda class RandRicianNoised(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandRicianNoise`. - Add Rician noise to image. This transform assumes all the expected fields have same shape. + Add Rician noise to image. This transform assumes all the expected fields have same shape, if want to add + different noise for every field, please use this transform separately. Args: keys: Keys of the corresponding items to be transformed. @@ -1324,6 +1330,78 @@ def _to_numpy(self, d: Union[torch.Tensor, np.ndarray]) -> np.ndarray: return d_numpy +class RandCoarseDropoutd(RandomizableTransform, MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.RandCoarseDropout`. + Expect all the data specified by `keys` have same spatial shape and will randomly dropout the same regions + for every key, if want to dropout differently for every key, please use this transform separately. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + holes: number of regions to dropout, if `max_holes` is not None, use this arg as the minimum number to + randomly select the expected number of regions. + spatial_size: spatial size of the regions to dropout, if `max_spatial_size` is not None, use this arg + as the minimum spatial size to randomly select size for every region. + if some components of the `spatial_size` are non-positive values, the transform will use the + corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted + to `(32, 64)` if the second spatial dimension size of img is `64`. + fill_value: target value to fill the dropout regions. + max_holes: if not None, define the maximum number to randomly select the expected number of regions. + max_spatial_size: if not None, define the maximum spatial size to randomly select size for every region. + if some components of the `max_spatial_size` are non-positive values, the transform will use the + corresponding components of input img size. For example, `max_spatial_size=(32, -1)` will be adapted + to `(32, 64)` if the second spatial dimension size of img is `64`. + prob: probability of applying the transform. + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + holes: int, + spatial_size: Union[Sequence[int], int], + fill_value: Union[float, int] = 0, + max_holes: Optional[int] = None, + max_spatial_size: Optional[Union[Sequence[int], int]] = None, + prob: float = 0.1, + allow_missing_keys: bool = False, + ): + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + if holes < 1: + raise ValueError("number of holes must be greater than 0.") + self.holes = holes + self.spatial_size = spatial_size + self.fill_value = fill_value + self.max_holes = max_holes + self.max_spatial_size = max_spatial_size + self.hole_coords: List = [] + + def randomize(self, img_size: Sequence[int]) -> None: + super().randomize(None) + size = fall_back_tuple(self.spatial_size, img_size) + self.hole_coords = [] # clear previously computed coords + num_holes = self.holes if self.max_holes is None else self.R.randint(self.holes, self.max_holes + 1) + for _ in range(num_holes): + if self.max_spatial_size is not None: + max_size = fall_back_tuple(self.max_spatial_size, img_size) + size = tuple(self.R.randint(low=size[i], high=max_size[i] + 1) for i in range(len(img_size))) + valid_size = get_valid_patch_size(img_size, size) + self.hole_coords.append((slice(None),) + get_random_patch(img_size, valid_size, self.R)) + + def __call__(self, data): + d = dict(data) + # expect all the specified keys have same spatial shape + self.randomize(d[self.keys[0]].shape[1:]) + if self._do_transform: + for key in self.key_iterator(d): + for h in self.hole_coords: + d[key][h] = self.fill_value + return d + + RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised RandRicianNoiseD = RandRicianNoiseDict = RandRicianNoised ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd @@ -1349,3 +1427,4 @@ def _to_numpy(self, d: Union[torch.Tensor, np.ndarray]) -> np.ndarray: GibbsNoiseD = GibbsNoiseDict = GibbsNoised KSpaceSpikeNoiseD = KSpaceSpikeNoiseDict = KSpaceSpikeNoised RandKSpaceSpikeNoiseD = RandKSpaceSpikeNoiseDict = RandKSpaceSpikeNoised +RandCoarseDropoutD = RandCoarseDropoutDict = RandCoarseDropoutd diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 37dd9b47c6..06b98cdd2e 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -337,7 +337,7 @@ class Resize(Transform): Args: spatial_size: expected shape of spatial dimensions after resize operation. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} @@ -1297,7 +1297,7 @@ def __init__( spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"bilinear"``, ``"nearest"``} @@ -1390,7 +1390,7 @@ def __init__( spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"bilinear"``, ``"nearest"``} @@ -1553,7 +1553,7 @@ def __init__( spatial_size: specifying output image spatial size [h, w]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"bilinear"``, ``"nearest"``} @@ -1681,7 +1681,7 @@ def __init__( spatial_size: specifying output image spatial size [h, w, d]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, 32, -1)` will be adapted to `(32, 32, 64)` if the third spatial dimension size of img is `64`. mode: {``"bilinear"``, ``"nearest"``} diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index b961ef7c92..2c9cac8438 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -500,7 +500,7 @@ class Resized(MapTransform, InvertibleTransform): keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` spatial_size: expected shape of spatial dimensions after resize operation. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} @@ -589,7 +589,7 @@ def __init__( spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. mode: {``"bilinear"``, ``"nearest"``} @@ -695,7 +695,7 @@ def __init__( spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. prob: probability of returning a randomized affine grid. @@ -860,7 +860,7 @@ def __init__( spatial_size: specifying output image spatial size [h, w]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. prob: probability of returning a randomized affine grid. @@ -980,7 +980,7 @@ def __init__( spatial_size: specifying output image spatial size [h, w, d]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. - if the components of the `spatial_size` are non-positive values, the transform will use the + if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, 32, -1)` will be adapted to `(32, 32, 64)` if the third spatial dimension size of img is `64`. prob: probability of returning a randomized affine grid. diff --git a/tests/test_rand_coarse_dropout.py b/tests/test_rand_coarse_dropout.py new file mode 100644 index 0000000000..235a391567 --- /dev/null +++ b/tests/test_rand_coarse_dropout.py @@ -0,0 +1,73 @@ +# Copyright 2020 - 2021 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 import RandCoarseDropout +from monai.utils import fall_back_tuple + +TEST_CASE_0 = [ + {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), + (3, 3, 3, 4), +] + +TEST_CASE_1 = [ + {"holes": 1, "spatial_size": [1, 2, 3], "fill_value": 5, "max_holes": 5, "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), + (3, 3, 3, 4), +] + +TEST_CASE_2 = [ + {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "max_spatial_size": [4, 4, 3], "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), + (3, 3, 3, 4), +] + +TEST_CASE_3 = [ + {"holes": 2, "spatial_size": [2, -1, 2], "fill_value": 5, "max_spatial_size": [4, 4, -1], "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), + (3, 3, 3, 4), +] + + +class TestRandCoarseDropout(unittest.TestCase): + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_value(self, input_param, input_data, expected_shape): + dropout = RandCoarseDropout(**input_param) + result = dropout(input_data) + holes = input_param.get("holes") + max_holes = input_param.get("max_holes") + spatial_size = fall_back_tuple(input_param.get("spatial_size"), input_data.shape[1:]) + max_spatial_size = fall_back_tuple(input_param.get("max_spatial_size"), input_data.shape[1:]) + + if max_holes is None: + self.assertEqual(len(dropout.hole_coords), holes) + else: + self.assertGreaterEqual(len(dropout.hole_coords), holes) + self.assertLessEqual(len(dropout.hole_coords), max_holes) + + for h in dropout.hole_coords: + data = result[h] + np.testing.assert_allclose(data, input_param.get("fill_value", 0)) + if max_spatial_size is None: + self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) + else: + for d, s, m in zip(data.shape[1:], spatial_size, max_spatial_size): + self.assertGreaterEqual(d, s) + self.assertLessEqual(d, m) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_coarse_dropoutd.py b/tests/test_rand_coarse_dropoutd.py new file mode 100644 index 0000000000..d189a80f56 --- /dev/null +++ b/tests/test_rand_coarse_dropoutd.py @@ -0,0 +1,87 @@ +# Copyright 2020 - 2021 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 import RandCoarseDropoutd +from monai.utils import fall_back_tuple + +TEST_CASE_0 = [ + {"keys": "img", "holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "prob": 1.0}, + {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, + (3, 3, 3, 4), +] + +TEST_CASE_1 = [ + {"keys": "img", "holes": 1, "spatial_size": [1, 2, 3], "fill_value": 5, "max_holes": 5, "prob": 1.0}, + {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, + (3, 3, 3, 4), +] + +TEST_CASE_2 = [ + { + "keys": "img", + "holes": 2, + "spatial_size": [2, 2, 2], + "fill_value": 5, + "max_spatial_size": [4, 4, 3], + "prob": 1.0, + }, + {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, + (3, 3, 3, 4), +] + +TEST_CASE_3 = [ + { + "keys": "img", + "holes": 2, + "spatial_size": [2, -1, 2], + "fill_value": 5, + "max_spatial_size": [4, 4, -1], + "prob": 1.0, + }, + {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, + (3, 3, 3, 4), +] + + +class TestRandCoarseDropoutd(unittest.TestCase): + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_value(self, input_param, input_data, expected_shape): + dropout = RandCoarseDropoutd(**input_param) + result = dropout(input_data)["img"] + holes = input_param.get("holes") + max_holes = input_param.get("max_holes") + spatial_size = fall_back_tuple(input_param.get("spatial_size"), input_data["img"].shape[1:]) + max_spatial_size = fall_back_tuple(input_param.get("max_spatial_size"), input_data["img"].shape[1:]) + + if max_holes is None: + self.assertEqual(len(dropout.hole_coords), holes) + else: + self.assertGreaterEqual(len(dropout.hole_coords), holes) + self.assertLessEqual(len(dropout.hole_coords), max_holes) + + for h in dropout.hole_coords: + data = result[h] + np.testing.assert_allclose(data, input_param.get("fill_value", 0)) + if max_spatial_size is None: + self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) + else: + for d, s, m in zip(data.shape[1:], spatial_size, max_spatial_size): + self.assertGreaterEqual(d, s) + self.assertLessEqual(d, m) + + +if __name__ == "__main__": + unittest.main() From 35e45df3176daecfbbac058c74fb2ba9e18b63b1 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Wed, 28 Jul 2021 16:04:12 +0100 Subject: [PATCH 07/89] add citation metadata (#2663) Signed-off-by: Wenqi Li --- CITATION.cff | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000..a3f88fb2f6 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,17 @@ +# YAML 1.2 +# Metadata for citation of this software according to the CFF format (https://citation-file-format.github.io/) +# +--- +title: "MONAI: Medical Open Network for AI" +abstract: "AI Toolkit for Healthcare Imaging" +authors: + - family-names: + given-names: "MONAI Consortium" +date-released: 2020-03-28 +version: "0.6.0" +doi: "10.5281/zenodo.4323058" +license: "Apache-2.0" +repository-code: "https://github.com/Project-MONAI/MONAI" +cff-version: "1.1.0" +message: "If you use this software, please cite it using these metadata." +... From d8613b2c6ad9512ecce4ef324a327d7f64540a86 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 29 Jul 2021 08:01:41 +0800 Subject: [PATCH 08/89] 2648 Add LongestRescale transform (#2662) * [DLMED] init the transform Signed-off-by: Nic Ma * [DLMED] update doc-string Signed-off-by: Nic Ma * [DLMED] complete array transform Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] add dict transform and inverse tests Signed-off-by: Nic Ma * [DLMED] fix mypy type Signed-off-by: Nic Ma * [DLMED] change to enhance Resize transform Signed-off-by: Nic Ma * [DLMED] fix CI tests Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] fix TTA Signed-off-by: Nic Ma * [DLMED] remove tests Signed-off-by: Nic Ma * [DLMED] fix mypy error Signed-off-by: Nic Ma --- monai/transforms/spatial/array.py | 41 ++++++++++++++++++-------- monai/transforms/spatial/dictionary.py | 14 +++++++-- tests/test_inverse.py | 13 +++----- tests/test_resize.py | 13 ++++++++ tests/test_resized.py | 25 +++++++++++++++- 5 files changed, 81 insertions(+), 25 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 06b98cdd2e..d9c10cf9c0 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -14,6 +14,7 @@ """ import warnings +from math import ceil from typing import Any, List, Optional, Sequence, Tuple, Union import numpy as np @@ -340,6 +341,11 @@ class Resize(Transform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. + size_mode: should be "all" or "longest", if "all", will use `spatial_size` for all the spatial dims, + if "longest", rescale the image so that only the longest side is equal to specified `spatial_size`, + which must be an int number in this case, keeping the aspect ratio of the initial image, refer to: + https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/ + #albumentations.augmentations.geometric.resize.LongestMaxSize. mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate @@ -351,10 +357,12 @@ class Resize(Transform): def __init__( self, spatial_size: Union[Sequence[int], int], + size_mode: str = "all", mode: Union[InterpolateMode, str] = InterpolateMode.AREA, align_corners: Optional[bool] = None, ) -> None: - self.spatial_size = ensure_tuple(spatial_size) + self.size_mode = look_up_option(size_mode, ["all", "longest"]) + self.spatial_size = spatial_size self.mode: InterpolateMode = look_up_option(mode, InterpolateMode) self.align_corners = align_corners @@ -378,20 +386,27 @@ def __call__( ValueError: When ``self.spatial_size`` length is less than ``img`` spatial dimensions. """ - input_ndim = img.ndim - 1 # spatial ndim - output_ndim = len(self.spatial_size) - if output_ndim > input_ndim: - input_shape = ensure_tuple_size(img.shape, output_ndim + 1, 1) - img = img.reshape(input_shape) - elif output_ndim < input_ndim: - raise ValueError( - "len(spatial_size) must be greater or equal to img spatial dimensions, " - f"got spatial_size={output_ndim} img={input_ndim}." - ) - spatial_size = fall_back_tuple(self.spatial_size, img.shape[1:]) + if self.size_mode == "all": + input_ndim = img.ndim - 1 # spatial ndim + output_ndim = len(ensure_tuple(self.spatial_size)) + if output_ndim > input_ndim: + input_shape = ensure_tuple_size(img.shape, output_ndim + 1, 1) + img = img.reshape(input_shape) + elif output_ndim < input_ndim: + raise ValueError( + "len(spatial_size) must be greater or equal to img spatial dimensions, " + f"got spatial_size={output_ndim} img={input_ndim}." + ) + spatial_size_ = fall_back_tuple(self.spatial_size, img.shape[1:]) + else: # for the "longest" mode + img_size = img.shape[1:] + if not isinstance(self.spatial_size, int): + raise ValueError("spatial_size must be an int number if size_mode is 'longest'.") + scale = self.spatial_size / max(img_size) + spatial_size_ = tuple(ceil(s * scale) for s in img_size) resized = torch.nn.functional.interpolate( # type: ignore input=torch.as_tensor(np.ascontiguousarray(img), dtype=torch.float).unsqueeze(0), - size=spatial_size, + size=spatial_size_, mode=look_up_option(self.mode if mode is None else mode, InterpolateMode).value, align_corners=self.align_corners if align_corners is None else align_corners, ) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 2c9cac8438..0d65fdfa29 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -503,6 +503,11 @@ class Resized(MapTransform, InvertibleTransform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. + size_mode: should be "all" or "longest", if "all", will use `spatial_size` for all the spatial dims, + if "longest", rescale the image so that only the longest side is equal to specified `spatial_size`, + which must be an int number in this case, keeping the aspect ratio of the initial image, refer to: + https://albumentations.ai/docs/api_reference/augmentations/geometric/resize/ + #albumentations.augmentations.geometric.resize.LongestMaxSize. mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``, ``"area"``} The interpolation mode. Defaults to ``"area"``. See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate @@ -518,6 +523,7 @@ def __init__( self, keys: KeysCollection, spatial_size: Union[Sequence[int], int], + size_mode: str = "all", mode: InterpolateModeSequence = InterpolateMode.AREA, align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, allow_missing_keys: bool = False, @@ -525,7 +531,7 @@ def __init__( super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) - self.resizer = Resize(spatial_size=spatial_size) + self.resizer = Resize(spatial_size=spatial_size, size_mode=size_mode) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) @@ -549,7 +555,11 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar mode = transform[InverseKeys.EXTRA_INFO]["mode"] align_corners = transform[InverseKeys.EXTRA_INFO]["align_corners"] # Create inverse transform - inverse_transform = Resize(orig_size, mode, None if align_corners == "none" else align_corners) + inverse_transform = Resize( + spatial_size=orig_size, + mode=mode, + align_corners=None if align_corners == "none" else align_corners, + ) # Apply inverse transform d[key] = inverse_transform(d[key]) # Remove the applied transform diff --git a/tests/test_inverse.py b/tests/test_inverse.py index fd1afbd857..a1c171200f 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -249,15 +249,6 @@ ) ) -TESTS.append( - ( - "Flipd 3d", - "3D", - 0, - Flipd(KEYS, [1, 2]), - ) -) - TESTS.append( ( "RandFlipd 3d", @@ -319,6 +310,10 @@ TESTS.append(("Resized 3d", "3D", 5e-2, Resized(KEYS, [201, 150, 78]))) +TESTS.append(("Resized longest 2d", "2D", 2e-1, Resized(KEYS, 47, "longest", "area"))) + +TESTS.append(("Resized longest 3d", "3D", 5e-2, Resized(KEYS, 201, "longest", "trilinear", True))) + TESTS.append( ( diff --git a/tests/test_resize.py b/tests/test_resize.py index 22a68bcf85..2f54dcc04f 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -18,6 +18,12 @@ from monai.transforms import Resize from tests.utils import NumpyImageTestCase2D +TEST_CASE_0 = [{"spatial_size": 15}, (6, 11, 15)] + +TEST_CASE_1 = [{"spatial_size": 15, "mode": "area"}, (6, 11, 15)] + +TEST_CASE_2 = [{"spatial_size": 6, "mode": "trilinear", "align_corners": True}, (3, 5, 6)] + class TestResize(NumpyImageTestCase2D): def test_invalid_inputs(self): @@ -50,6 +56,13 @@ def test_correct_results(self, spatial_size, mode): out = resize(self.imt[0]) np.testing.assert_allclose(out, expected, atol=0.9) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2]) + def test_longest_shape(self, input_param, expected_shape): + input_data = np.random.randint(0, 2, size=[3, 4, 7, 10]) + input_param["size_mode"] = "longest" + result = Resize(**input_param)(input_data) + np.testing.assert_allclose(result.shape[1:], expected_shape) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_resized.py b/tests/test_resized.py index d89c866af3..6c4f31c9c8 100644 --- a/tests/test_resized.py +++ b/tests/test_resized.py @@ -18,6 +18,17 @@ from monai.transforms import Resized from tests.utils import NumpyImageTestCase2D +TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 11, 15)] + +TEST_CASE_1 = [{"keys": "img", "spatial_size": 15, "mode": "area"}, (6, 11, 15)] + +TEST_CASE_2 = [{"keys": "img", "spatial_size": 6, "mode": "trilinear", "align_corners": True}, (3, 5, 6)] + +TEST_CASE_3 = [ + {"keys": ["img", "label"], "spatial_size": 6, "mode": ["trilinear", "nearest"], "align_corners": [True, None]}, + (3, 5, 6), +] + class TestResized(NumpyImageTestCase2D): def test_invalid_inputs(self): @@ -31,7 +42,7 @@ def test_invalid_inputs(self): @parameterized.expand([((32, -1), "area"), ((64, 64), "area"), ((32, 32, 32), "area"), ((256, 256), "bilinear")]) def test_correct_results(self, spatial_size, mode): - resize = Resized("img", spatial_size, mode) + resize = Resized("img", spatial_size, mode=mode) _order = 0 if mode.endswith("linear"): _order = 1 @@ -48,6 +59,18 @@ def test_correct_results(self, spatial_size, mode): out = resize({"img": self.imt[0]})["img"] np.testing.assert_allclose(out, expected, atol=0.9) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_longest_shape(self, input_param, expected_shape): + input_data = { + "img": np.random.randint(0, 2, size=[3, 4, 7, 10]), + "label": np.random.randint(0, 2, size=[3, 4, 7, 10]), + } + input_param["size_mode"] = "longest" + rescaler = Resized(**input_param) + result = rescaler(input_data) + for k in rescaler.keys: + np.testing.assert_allclose(result[k].shape[1:], expected_shape) + if __name__ == "__main__": unittest.main() From 30ce63d847be5e661ce19bd57e28b487dc876575 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+behxyz@users.noreply.github.com> Date: Fri, 30 Jul 2021 04:30:20 -0400 Subject: [PATCH 09/89] Stain normalization (#2666) * added stain norm and tests Signed-off-by: Neha Srivathsa * import changes Signed-off-by: Neha Srivathsa * changed stain extraction tests Signed-off-by: Neha Srivathsa * edited stain norm tests Signed-off-by: Neha Srivathsa * convert floats to float32 Signed-off-by: Neha Srivathsa * added uint8 assumption to docstring Signed-off-by: Neha Srivathsa * add error case Signed-off-by: Neha Srivathsa * formatting change Signed-off-by: Neha Srivathsa * modify tests wrt cupy import Signed-off-by: Neha Srivathsa * minor change to pass lint test Signed-off-by: Neha Srivathsa * import changes Signed-off-by: Neha Srivathsa * refactored classes Signed-off-by: Neha Srivathsa * Restructure and rename transforms Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * added dict transform Signed-off-by: Neha Srivathsa * Move stain_extractor to init Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Exclude pathology transform tests from mini tests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix type checking for cupy ndarray Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Include pathology transform tests Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update to cupy 9.0.0 Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Remove exact version for cupy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * add to docs Signed-off-by: Neha Srivathsa * Organize into stain dir Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add/update init files Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Transit all from cupy to numpy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update imports Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update test cases for numpy Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Rename to NormalizeHEStains and NormalizeHEStainsD Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add dictionary variant names Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix typing and formatting Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update test cases Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix clip max Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix var typing Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a typing issue Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update default values, and change D to d Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add image value check Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Add test cases for negative and invalid values Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Co-authored-by: Neha Srivathsa Co-authored-by: nsrivathsa <81264348+nsrivathsa@users.noreply.github.com> --- docs/source/apps.rst | 12 + monai/apps/pathology/__init__.py | 9 + monai/apps/pathology/transforms/__init__.py | 20 ++ .../pathology/transforms/stain/__init__.py | 20 ++ .../apps/pathology/transforms/stain/array.py | 196 ++++++++++++++ .../pathology/transforms/stain/dictionary.py | 111 ++++++++ tests/test_pathology_he_stain.py | 243 ++++++++++++++++++ tests/test_pathology_he_stain_dict.py | 227 ++++++++++++++++ 8 files changed, 838 insertions(+) create mode 100644 monai/apps/pathology/transforms/__init__.py create mode 100644 monai/apps/pathology/transforms/stain/__init__.py create mode 100644 monai/apps/pathology/transforms/stain/array.py create mode 100644 monai/apps/pathology/transforms/stain/dictionary.py create mode 100644 tests/test_pathology_he_stain.py create mode 100644 tests/test_pathology_he_stain_dict.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index f9f7a4159c..959e42d6f9 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -98,3 +98,15 @@ Clara MMARs .. autofunction:: compute_isolated_tumor_cells .. autoclass:: PathologyProbNMS :members: + +.. automodule:: monai.apps.pathology.transforms.stain.array +.. autoclass:: ExtractHEStains + :members: +.. autoclass:: NormalizeHEStains + :members: + +.. automodule:: monai.apps.pathology.transforms.stain.dictionary +.. autoclass:: ExtractHEStainsd + :members: +.. autoclass:: NormalizeHEStainsd + :members: diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 203e1a80d7..0ada8fe51b 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -12,4 +12,13 @@ from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset from .handlers import ProbMapProducer from .metrics import LesionFROC +from .transforms.stain.array import ExtractHEStains, NormalizeHEStains +from .transforms.stain.dictionary import ( + ExtractHEStainsd, + ExtractHEStainsD, + ExtractHEStainsDict, + NormalizeHEStainsd, + NormalizeHEStainsD, + NormalizeHEStainsDict, +) from .utils import PathologyProbNMS, compute_isolated_tumor_cells, compute_multi_instance_mask diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py new file mode 100644 index 0000000000..0df016244b --- /dev/null +++ b/monai/apps/pathology/transforms/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2020 - 2021 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. + +from .stain.array import ExtractHEStains, NormalizeHEStains +from .stain.dictionary import ( + ExtractHEStainsd, + ExtractHEStainsD, + ExtractHEStainsDict, + NormalizeHEStainsd, + NormalizeHEStainsD, + NormalizeHEStainsDict, +) diff --git a/monai/apps/pathology/transforms/stain/__init__.py b/monai/apps/pathology/transforms/stain/__init__.py new file mode 100644 index 0000000000..824f40a579 --- /dev/null +++ b/monai/apps/pathology/transforms/stain/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2020 - 2021 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. + +from .array import ExtractHEStains, NormalizeHEStains +from .dictionary import ( + ExtractHEStainsd, + ExtractHEStainsD, + ExtractHEStainsDict, + NormalizeHEStainsd, + NormalizeHEStainsD, + NormalizeHEStainsDict, +) diff --git a/monai/apps/pathology/transforms/stain/array.py b/monai/apps/pathology/transforms/stain/array.py new file mode 100644 index 0000000000..ccddc6b243 --- /dev/null +++ b/monai/apps/pathology/transforms/stain/array.py @@ -0,0 +1,196 @@ +# Copyright 2020 - 2021 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. + +from typing import Union + +import numpy as np + +from monai.transforms.transform import Transform + + +class ExtractHEStains(Transform): + """Class to extract a target stain from an image, using stain deconvolution (see Note). + + Args: + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) + and pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15 + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to (1.9705, 1.0308). + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python + """ + + def __init__( + self, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), + ) -> None: + self.tli = tli + self.alpha = alpha + self.beta = beta + self.max_cref = np.array(max_cref) + + def _deconvolution_extract_stain(self, image: np.ndarray) -> np.ndarray: + """Perform Stain Deconvolution and return stain matrix for the image. + + Args: + img: uint8 RGB image to perform stain deconvolution on + + Return: + he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) + """ + # check image type and vlues + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") + if image.min() < 0: + raise ValueError("Image should not have negative values.") + if image.max() > 255: + raise ValueError("Image should not have values greater than 255.") + + # reshape image and calculate absorbance + image = image.reshape((-1, 3)) + image = image.astype(np.float32) + 1.0 + absorbance = -np.log(image.clip(max=self.tli) / self.tli) + + # remove transparent pixels + absorbance_hat = absorbance[np.all(absorbance > self.beta, axis=1)] + if len(absorbance_hat) == 0: + raise ValueError("All pixels of the input image are below the absorbance threshold.") + + # compute eigenvectors + _, eigvecs = np.linalg.eigh(np.cov(absorbance_hat.T).astype(np.float32)) + + # project on the plane spanned by the eigenvectors corresponding to the two largest eigenvalues + t_hat = absorbance_hat.dot(eigvecs[:, 1:3]) + + # find the min and max vectors and project back to absorbance space + phi = np.arctan2(t_hat[:, 1], t_hat[:, 0]) + min_phi = np.percentile(phi, self.alpha) + max_phi = np.percentile(phi, 100 - self.alpha) + v_min = eigvecs[:, 1:3].dot(np.array([(np.cos(min_phi), np.sin(min_phi))], dtype=np.float32).T) + v_max = eigvecs[:, 1:3].dot(np.array([(np.cos(max_phi), np.sin(max_phi))], dtype=np.float32).T) + + # a heuristic to make the vector corresponding to hematoxylin first and the one corresponding to eosin second + if v_min[0] > v_max[0]: + he = np.array((v_min[:, 0], v_max[:, 0]), dtype=np.float32).T + else: + he = np.array((v_max[:, 0], v_min[:, 0]), dtype=np.float32).T + + return he + + def __call__(self, image: np.ndarray) -> np.ndarray: + """Perform stain extraction. + + Args: + image: uint8 RGB image to extract stain from + + return: + target_he: H&E absorbance matrix for the image (first column is H, second column is E, rows are RGB values) + """ + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") + + target_he = self._deconvolution_extract_stain(image) + return target_he + + +class NormalizeHEStains(Transform): + """Class to normalize patches/images to a reference or target image stain (see Note). + + Performs stain deconvolution of the source image using the ExtractHEStains + class, to obtain the stain matrix and calculate the stain concentration matrix + for the image. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain matrix provided. If no target stain provided, a default + reference stain is used. Similarly, if no maximum stain concentrations are provided, a + reference maximum stain concentrations matrix is used. + + Args: + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) and + pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15. + target_he: target stain matrix. Defaults to ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)). + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to [1.9705, 1.0308]. + + Note: + For more information refer to: + - the original paper: Macenko et al., 2009 http://wwwx.cs.unc.edu/~mn/sites/default/files/macenko2009.pdf + - the previous implementations: + + - MATLAB: https://github.com/mitkovetta/staining-normalization + - Python: https://github.com/schaugf/HEnorm_python + """ + + def __init__( + self, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + target_he: Union[tuple, np.ndarray] = ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)), + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), + ) -> None: + self.tli = tli + self.target_he = np.array(target_he) + self.max_cref = np.array(max_cref) + self.stain_extractor = ExtractHEStains(tli=self.tli, alpha=alpha, beta=beta, max_cref=self.max_cref) + + def __call__(self, image: np.ndarray) -> np.ndarray: + """Perform stain normalization. + + Args: + image: uint8 RGB image/patch to be stain normalized, pixel values between 0 and 255 + + Return: + image_norm: stain normalized image/patch + """ + # check image type and vlues + if not isinstance(image, np.ndarray): + raise TypeError("Image must be of type numpy.ndarray.") + if image.min() < 0: + raise ValueError("Image should not have negative values.") + if image.max() > 255: + raise ValueError("Image should not have values greater than 255.") + + # extract stain of the image + he = self.stain_extractor(image) + + # reshape image and calculate absorbance + h, w, _ = image.shape + image = image.reshape((-1, 3)) + image = image.astype(np.float32) + 1.0 + absorbance = -np.log(image.clip(max=self.tli) / self.tli) + + # rows correspond to channels (RGB), columns to absorbance values + y = np.reshape(absorbance, (-1, 3)).T + + # determine concentrations of the individual stains + conc = np.linalg.lstsq(he, y, rcond=None)[0] + + # normalize stain concentrations + max_conc = np.array([np.percentile(conc[0, :], 99), np.percentile(conc[1, :], 99)], dtype=np.float32) + tmp = np.divide(max_conc, self.max_cref, dtype=np.float32) + image_c = np.divide(conc, tmp[:, np.newaxis], dtype=np.float32) + + image_norm: np.ndarray = np.multiply(self.tli, np.exp(-self.target_he.dot(image_c)), dtype=np.float32) + image_norm[image_norm > 255] = 254 + image_norm = np.reshape(image_norm.T, (h, w, 3)).astype(np.uint8) + return image_norm diff --git a/monai/apps/pathology/transforms/stain/dictionary.py b/monai/apps/pathology/transforms/stain/dictionary.py new file mode 100644 index 0000000000..976af1e7c7 --- /dev/null +++ b/monai/apps/pathology/transforms/stain/dictionary.py @@ -0,0 +1,111 @@ +# Copyright 2020 - 2021 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. +""" +A collection of dictionary-based wrappers around the pathology transforms +defined in :py:class:`monai.apps.pathology.transforms.array`. + +Class names are ended with 'd' to denote dictionary-based transforms. +""" + +from typing import Dict, Hashable, Mapping, Union + +import numpy as np + +from monai.config import KeysCollection +from monai.transforms.transform import MapTransform + +from .array import ExtractHEStains, NormalizeHEStains + + +class ExtractHEStainsd(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.ExtractHEStains`. + Class to extract a target stain from an image, using stain deconvolution. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) + and pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15 + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to (1.9705, 1.0308). + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.extractor = ExtractHEStains(tli=tli, alpha=alpha, beta=beta, max_cref=max_cref) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.extractor(d[key]) + return d + + +class NormalizeHEStainsd(MapTransform): + """Dictionary-based wrapper of :py:class:`monai.apps.pathology.transforms.NormalizeHEStains`. + + Class to normalize patches/images to a reference or target image stain. + + Performs stain deconvolution of the source image using the ExtractHEStains + class, to obtain the stain matrix and calculate the stain concentration matrix + for the image. Then, performs the inverse Beer-Lambert transform to recreate the + patch using the target H&E stain matrix provided. If no target stain provided, a default + reference stain is used. Similarly, if no maximum stain concentrations are provided, a + reference maximum stain concentrations matrix is used. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + tli: transmitted light intensity. Defaults to 240. + alpha: tolerance in percentile for the pseudo-min (alpha percentile) and + pseudo-max (100 - alpha percentile). Defaults to 1. + beta: absorbance threshold for transparent pixels. Defaults to 0.15. + target_he: target stain matrix. Defaults to None. + max_cref: reference maximum stain concentrations for Hematoxylin & Eosin (H&E). + Defaults to None. + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + tli: float = 240, + alpha: float = 1, + beta: float = 0.15, + target_he: Union[tuple, np.ndarray] = ((0.5626, 0.2159), (0.7201, 0.8012), (0.4062, 0.5581)), + max_cref: Union[tuple, np.ndarray] = (1.9705, 1.0308), + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.normalizer = NormalizeHEStains(tli=tli, alpha=alpha, beta=beta, target_he=target_he, max_cref=max_cref) + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.normalizer(d[key]) + return d + + +ExtractHEStainsDict = ExtractHEStainsD = ExtractHEStainsd +NormalizeHEStainsDict = NormalizeHEStainsD = NormalizeHEStainsd diff --git a/tests/test_pathology_he_stain.py b/tests/test_pathology_he_stain.py new file mode 100644 index 0000000000..1d74f485e9 --- /dev/null +++ b/tests/test_pathology_he_stain.py @@ -0,0 +1,243 @@ +# Copyright 2020 - 2021 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.apps.pathology.transforms import ExtractHEStains, NormalizeHEStains + +# None inputs +EXTRACT_STAINS_TEST_CASE_0 = (None,) +EXTRACT_STAINS_TEST_CASE_00 = (None, None) +NORMALIZE_STAINS_TEST_CASE_0 = (None,) +NORMALIZE_STAINS_TEST_CASE_00: tuple = ({}, None, None) + +# input pixels with negative values +NEGATIVE_VALUE_TEST_CASE = [np.full((3, 2, 3), -1)] + +# input pixels with greater than 255 values +INVALID_VALUE_TEST_CASE = [np.full((3, 2, 3), 256)] + +# input pixels all transparent and below the beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [np.full((3, 2, 3), 100)] + +# input pixels uniformly filled (different value), but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_3 = [np.full((3, 2, 3), 150)] + +# input pixels uniformly filled with zeros, leading to two identical stains extracted +EXTRACT_STAINS_TEST_CASE_4 = [ + np.zeros((3, 2, 3)), + np.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), +] + +# input pixels not uniformly filled, leading to two different stains extracted +EXTRACT_STAINS_TEST_CASE_5 = [ + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), +] + + +# input pixels all transparent and below the beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled with zeros, and target stain matrix provided +NORMALIZE_STAINS_TEST_CASE_2 = [{"target_he": np.full((3, 2), 1)}, np.zeros((3, 2, 3)), np.full((3, 2, 3), 11)] + +# input pixels uniformly filled with zeros, and target stain matrix not provided +NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + np.zeros((3, 2, 3)), + np.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# input pixels not uniformly filled +NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": np.full((3, 2), 1)}, + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), +] + + +class TestExtractHEStains(unittest.TestCase): + @parameterized.expand( + [ + NEGATIVE_VALUE_TEST_CASE, + INVALID_VALUE_TEST_CASE, + EXTRACT_STAINS_TEST_CASE_0, + EXTRACT_STAINS_TEST_CASE_1, + ] + ) + def test_transparent_image(self, image): + """ + Test HE stain extraction on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + if image is None: + with self.assertRaises(TypeError): + ExtractHEStains()(image) + else: + with self.assertRaises(ValueError): + ExtractHEStains()(image) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + def test_identical_result_vectors(self, image): + """ + Test HE stain extraction on input images that are + uniformly filled with pixels that have absorbance above the + beta absorbance threshold. Since input image is uniformly filled, + the two extracted stains should have the same RGB values. So, + we assert that the first column is equal to the second column + of the returned stain matrix. + """ + if image is None: + with self.assertRaises(TypeError): + ExtractHEStains()(image) + else: + result = ExtractHEStains()(image) + np.testing.assert_array_equal(result[:, 0], result[:, 1]) + + @parameterized.expand( + [ + EXTRACT_STAINS_TEST_CASE_00, + EXTRACT_STAINS_TEST_CASE_4, + EXTRACT_STAINS_TEST_CASE_5, + ] + ) + def test_result_value(self, image, expected_data): + """ + Test that an input image returns an expected stain matrix. + + For test case 4: + - a uniformly filled input image should result in + eigenvectors [[1,0,0],[0,1,0],[0,0,1]] + - phi should be an array containing only values of + arctan(1) since the ratio between the eigenvectors + corresponding to the two largest eigenvalues is 1 + - maximum phi and minimum phi should thus be arctan(1) + - thus, maximum vector and minimum vector should be + [[0],[0.70710677],[0.70710677]] + - the resulting extracted stain should be + [[0,0],[0.70710678,0.70710678],[0.70710678,0.70710678]] + + For test case 5: + - the non-uniformly filled input image should result in + eigenvectors [[0,0,1],[1,0,0],[0,1,0]] + - maximum phi and minimum phi should thus be 0.785 and + 0.188 respectively + - thus, maximum vector and minimum vector should be + [[0.18696113],[0],[0.98236734]] and + [[0.70710677],[0],[0.70710677]] respectively + - the resulting extracted stain should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] + """ + if image is None: + with self.assertRaises(TypeError): + ExtractHEStains()(image) + else: + result = ExtractHEStains()(image) + np.testing.assert_allclose(result, expected_data) + + +class TestNormalizeHEStains(unittest.TestCase): + @parameterized.expand( + [ + NEGATIVE_VALUE_TEST_CASE, + INVALID_VALUE_TEST_CASE, + NORMALIZE_STAINS_TEST_CASE_0, + NORMALIZE_STAINS_TEST_CASE_1, + ] + ) + def test_transparent_image(self, image): + """ + Test HE stain normalization on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStains()(image) + else: + with self.assertRaises(ValueError): + NormalizeHEStains()(image) + + @parameterized.expand( + [ + NORMALIZE_STAINS_TEST_CASE_00, + NORMALIZE_STAINS_TEST_CASE_2, + NORMALIZE_STAINS_TEST_CASE_3, + NORMALIZE_STAINS_TEST_CASE_4, + ] + ) + def test_result_value(self, argments, image, expected_data): + """ + Test that an input image returns an expected normalized image. + + For test case 2: + - This case tests calling the stain normalizer, after the + _deconvolution_extract_conc function. This is because the normalized + concentration returned for each pixel is the same as the reference + maximum stain concentrations in the case that the image is uniformly + filled, as in this test case. This is because the maximum concentration + for each stain is the same as each pixel's concentration. + - Thus, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be a matrix of + dims (3, 2, 3), with all values 11. + + For test case 3: + - This case also tests calling the stain normalizer, after the + _deconvolution_extract_conc function returns the image concentration + matrix. + - As in test case 2, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target default stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be [[[63, 25, 60], [63, 25, 60]], + [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]] + + For test case 4: + - For this non-uniformly filled image, the stain extracted should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]], as validated for the + ExtractHEStains class. Solving the linear least squares problem (since + absorbance matrix = stain matrix * concentration matrix), we obtain the concentration + matrix that should be [[-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508], + [5.8022, 0, 0, 0, 0, 0]] + - Normalizing the concentration matrix, taking the matrix product of the + target stain matrix and the concentration matrix, using the inverse + Beer-Lambert transform to obtain the RGB image from the absorbance + image, and finally converting to uint8, we get that the stain normalized + image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], + [[33, 33, 33], [33, 33, 33]]] + """ + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStains()(image) + else: + result = NormalizeHEStains(**argments)(image) + np.testing.assert_allclose(result, expected_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pathology_he_stain_dict.py b/tests/test_pathology_he_stain_dict.py new file mode 100644 index 0000000000..8d51579cb2 --- /dev/null +++ b/tests/test_pathology_he_stain_dict.py @@ -0,0 +1,227 @@ +# Copyright 2020 - 2021 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.apps.pathology.transforms import ExtractHEStainsD, NormalizeHEStainsD + +# None inputs +EXTRACT_STAINS_TEST_CASE_0 = (None,) +EXTRACT_STAINS_TEST_CASE_00 = (None, None) +NORMALIZE_STAINS_TEST_CASE_0 = (None,) +NORMALIZE_STAINS_TEST_CASE_00: tuple = ({}, None, None) + +# input pixels all transparent and below the beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled, but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_2 = [np.full((3, 2, 3), 100)] + +# input pixels uniformly filled (different value), but above beta absorbance threshold +EXTRACT_STAINS_TEST_CASE_3 = [np.full((3, 2, 3), 150)] + +# input pixels uniformly filled with zeros, leading to two identical stains extracted +EXTRACT_STAINS_TEST_CASE_4 = [ + np.zeros((3, 2, 3)), + np.array([[0.0, 0.0], [0.70710678, 0.70710678], [0.70710678, 0.70710678]]), +] + +# input pixels not uniformly filled, leading to two different stains extracted +EXTRACT_STAINS_TEST_CASE_5 = [ + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[0.70710677, 0.18696113], [0.0, 0.0], [0.70710677, 0.98236734]]), +] + +# input pixels all transparent and below the beta absorbance threshold +NORMALIZE_STAINS_TEST_CASE_1 = [np.full((3, 2, 3), 240)] + +# input pixels uniformly filled with zeros, and target stain matrix provided +NORMALIZE_STAINS_TEST_CASE_2 = [{"target_he": np.full((3, 2), 1)}, np.zeros((3, 2, 3)), np.full((3, 2, 3), 11)] + +# input pixels uniformly filled with zeros, and target stain matrix not provided +NORMALIZE_STAINS_TEST_CASE_3 = [ + {}, + np.zeros((3, 2, 3)), + np.array([[[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]]), +] + +# input pixels not uniformly filled +NORMALIZE_STAINS_TEST_CASE_4 = [ + {"target_he": np.full((3, 2), 1)}, + np.array([[[100, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [0, 0, 0]]]), + np.array([[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]]]), +] + + +class TestExtractHEStainsD(unittest.TestCase): + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain extraction on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + ExtractHEStainsD([key])({key: image}) + + @parameterized.expand([EXTRACT_STAINS_TEST_CASE_0, EXTRACT_STAINS_TEST_CASE_2, EXTRACT_STAINS_TEST_CASE_3]) + def test_identical_result_vectors(self, image): + """ + Test HE stain extraction on input images that are + uniformly filled with pixels that have absorbance above the + beta absorbance threshold. Since input image is uniformly filled, + the two extracted stains should have the same RGB values. So, + we assert that the first column is equal to the second column + of the returned stain matrix. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_array_equal(result[key][:, 0], result[key][:, 1]) + + @parameterized.expand( + [ + EXTRACT_STAINS_TEST_CASE_00, + EXTRACT_STAINS_TEST_CASE_4, + EXTRACT_STAINS_TEST_CASE_5, + ] + ) + def test_result_value(self, image, expected_data): + """ + Test that an input image returns an expected stain matrix. + + For test case 4: + - a uniformly filled input image should result in + eigenvectors [[1,0,0],[0,1,0],[0,0,1]] + - phi should be an array containing only values of + arctan(1) since the ratio between the eigenvectors + corresponding to the two largest eigenvalues is 1 + - maximum phi and minimum phi should thus be arctan(1) + - thus, maximum vector and minimum vector should be + [[0],[0.70710677],[0.70710677]] + - the resulting extracted stain should be + [[0,0],[0.70710678,0.70710678],[0.70710678,0.70710678]] + + For test case 5: + - the non-uniformly filled input image should result in + eigenvectors [[0,0,1],[1,0,0],[0,1,0]] + - maximum phi and minimum phi should thus be 0.785 and + 0.188 respectively + - thus, maximum vector and minimum vector should be + [[0.18696113],[0],[0.98236734]] and + [[0.70710677],[0],[0.70710677]] respectively + - the resulting extracted stain should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + ExtractHEStainsD([key])({key: image}) + else: + result = ExtractHEStainsD([key])({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +class TestNormalizeHEStainsD(unittest.TestCase): + @parameterized.expand([NORMALIZE_STAINS_TEST_CASE_0, NORMALIZE_STAINS_TEST_CASE_1]) + def test_transparent_image(self, image): + """ + Test HE stain normalization on an image that comprises + only transparent pixels - pixels with absorbance below the + beta absorbance threshold. A ValueError should be raised, + since once the transparent pixels are removed, there are no + remaining pixels to compute eigenvectors. + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + with self.assertRaises(ValueError): + NormalizeHEStainsD([key])({key: image}) + + @parameterized.expand( + [ + NORMALIZE_STAINS_TEST_CASE_00, + NORMALIZE_STAINS_TEST_CASE_2, + NORMALIZE_STAINS_TEST_CASE_3, + NORMALIZE_STAINS_TEST_CASE_4, + ] + ) + def test_result_value(self, argments, image, expected_data): + """ + Test that an input image returns an expected normalized image. + + For test case 2: + - This case tests calling the stain normalizer, after the + _deconvolution_extract_conc function. This is because the normalized + concentration returned for each pixel is the same as the reference + maximum stain concentrations in the case that the image is uniformly + filled, as in this test case. This is because the maximum concentration + for each stain is the same as each pixel's concentration. + - Thus, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be a matrix of + dims (3, 2, 3), with all values 11. + + For test case 3: + - This case also tests calling the stain normalizer, after the + _deconvolution_extract_conc function returns the image concentration + matrix. + - As in test case 2, the normalized concentration matrix should be a (2, 6) matrix + with the first row having all values of 1.9705, second row all 1.0308. + - Taking the matrix product of the target default stain matrix and the concentration + matrix, then using the inverse Beer-Lambert transform to obtain the RGB + image from the absorbance image, and finally converting to uint8, + we get that the stain normalized image should be [[[63, 25, 60], [63, 25, 60]], + [[63, 25, 60], [63, 25, 60]], [[63, 25, 60], [63, 25, 60]]] + + For test case 4: + - For this non-uniformly filled image, the stain extracted should be + [[0.70710677,0.18696113],[0,0],[0.70710677,0.98236734]], as validated for the + ExtractHEStains class. Solving the linear least squares problem (since + absorbance matrix = stain matrix * concentration matrix), we obtain the concentration + matrix that should be [[-0.3101, 7.7508, 7.7508, 7.7508, 7.7508, 7.7508], + [5.8022, 0, 0, 0, 0, 0]] + - Normalizing the concentration matrix, taking the matrix product of the + target stain matrix and the concentration matrix, using the inverse + Beer-Lambert transform to obtain the RGB image from the absorbance + image, and finally converting to uint8, we get that the stain normalized + image should be [[[87, 87, 87], [33, 33, 33]], [[33, 33, 33], [33, 33, 33]], + [[33, 33, 33], [33, 33, 33]]] + """ + key = "image" + if image is None: + with self.assertRaises(TypeError): + NormalizeHEStainsD([key])({key: image}) + else: + result = NormalizeHEStainsD([key], **argments)({key: image}) + np.testing.assert_allclose(result[key], expected_data) + + +if __name__ == "__main__": + unittest.main() From 390fe7f4d2fb1352d051220ed62e30afd6975b21 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Fri, 30 Jul 2021 18:16:45 +0800 Subject: [PATCH 10/89] 2583 Add DatasetCalculator (#2616) * Add DatasetCalculator Signed-off-by: Yiheng Wang * update docstring Signed-off-by: Yiheng Wang * use multiprocessing Signed-off-by: Yiheng Wang * update to use dataset and other places Signed-off-by: Yiheng Wang * update to support array return Signed-off-by: Yiheng Wang * update with new testcases and change name Signed-off-by: Yiheng Wang * update min test Signed-off-by: Yiheng Wang * update unittest Signed-off-by: Yiheng Wang * fix vstack error Signed-off-by: Yiheng Wang --- docs/source/data.rst | 4 + monai/data/__init__.py | 1 + monai/data/dataset_summary.py | 182 ++++++++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_dataset_summary.py | 90 +++++++++++++++++ 5 files changed, 278 insertions(+) create mode 100644 monai/data/dataset_summary.py create mode 100644 tests/test_dataset_summary.py diff --git a/docs/source/data.rst b/docs/source/data.rst index a5c3509fc9..022f7877d1 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -182,6 +182,10 @@ DistributedWeightedRandomSampler ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. autoclass:: monai.data.DistributedWeightedRandomSampler +DatasetSummary +~~~~~~~~~~~~~~ +.. autoclass:: monai.data.DatasetSummary + Decathlon Datalist ~~~~~~~~~~~~~~~~~~ .. autofunction:: monai.data.load_decathlon_datalist diff --git a/monai/data/__init__.py b/monai/data/__init__.py index af42627f5f..fca170335b 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -23,6 +23,7 @@ SmartCacheDataset, ZipDataset, ) +from .dataset_summary import DatasetSummary from .decathlon_datalist import load_decathlon_datalist, load_decathlon_properties from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter from .image_dataset import ImageDataset diff --git a/monai/data/dataset_summary.py b/monai/data/dataset_summary.py new file mode 100644 index 0000000000..a8598eb6c8 --- /dev/null +++ b/monai/data/dataset_summary.py @@ -0,0 +1,182 @@ +# Copyright 2020 - 2021 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. + +from itertools import chain +from typing import List, Optional + +import numpy as np +import torch + +from monai.data.dataloader import DataLoader +from monai.data.dataset import Dataset + + +class DatasetSummary: + """ + This class provides a way to calculate a reasonable output voxel spacing according to + the input dataset. The achieved values can used to resample the input in 3d segmentation tasks + (like using as the `pixdim` parameter in `monai.transforms.Spacingd`). + In addition, it also supports to count the mean, std, min and max intensities of the input, + and these statistics are helpful for image normalization + (like using in `monai.transforms.ScaleIntensityRanged` and `monai.transforms.NormalizeIntensityd`). + + The algorithm for calculation refers to: + `Automated Design of Deep Learning Methods for Biomedical Image Segmentation `_. + + """ + + def __init__( + self, + dataset: Dataset, + image_key: Optional[str] = "image", + label_key: Optional[str] = "label", + meta_key_postfix: str = "meta_dict", + num_workers: int = 0, + **kwargs, + ): + """ + Args: + dataset: dataset from which to load the data. + image_key: key name of images (default: ``image``). + label_key: key name of labels (default: ``label``). + meta_key_postfix: use `{image_key}_{meta_key_postfix}` to fetch the meta data from dict, + the meta data is a dictionary object (default: ``meta_dict``). + num_workers: how many subprocesses to use for data loading. + ``0`` means that the data will be loaded in the main process (default: ``0``). + kwargs: other parameters (except batch_size) for DataLoader (this class forces to use ``batch_size=1``). + + """ + + self.data_loader = DataLoader(dataset=dataset, batch_size=1, num_workers=num_workers, **kwargs) + + self.image_key = image_key + self.label_key = label_key + if image_key: + self.meta_key = "{}_{}".format(image_key, meta_key_postfix) + self.all_meta_data: List = [] + + def collect_meta_data(self): + """ + This function is used to collect the meta data for all images of the dataset. + """ + if not self.meta_key: + raise ValueError("To collect meta data for the dataset, `meta_key` should exist.") + + for data in self.data_loader: + self.all_meta_data.append(data[self.meta_key]) + + def get_target_spacing(self, spacing_key: str = "pixdim", anisotropic_threshold: int = 3, percentile: float = 10.0): + """ + Calculate the target spacing according to all spacings. + If the target spacing is very anisotropic, + decrease the spacing value of the maximum axis according to percentile. + So far, this function only supports NIFTI images which store spacings in headers with key "pixdim". After loading + with `monai.DataLoader`, "pixdim" is in the form of `torch.Tensor` with size `(batch_size, 8)`. + + Args: + spacing_key: key of spacing in meta data (default: ``pixdim``). + anisotropic_threshold: threshold to decide if the target spacing is anisotropic (default: ``3``). + percentile: for anisotropic target spacing, use the percentile of all spacings of the anisotropic axis to + replace that axis. + + """ + if len(self.all_meta_data) == 0: + self.collect_meta_data() + if spacing_key not in self.all_meta_data[0]: + raise ValueError("The provided spacing_key is not in self.all_meta_data.") + + all_spacings = torch.cat([data[spacing_key][:, 1:4] for data in self.all_meta_data], dim=0).numpy() + + target_spacing = np.median(all_spacings, axis=0) + if max(target_spacing) / min(target_spacing) >= anisotropic_threshold: + largest_axis = np.argmax(target_spacing) + target_spacing[largest_axis] = np.percentile(all_spacings[:, largest_axis], percentile) + + output = list(target_spacing) + + return tuple(output) + + def calculate_statistics(self, foreground_threshold: int = 0): + """ + This function is used to calculate the maximum, minimum, mean and standard deviation of intensities of + the input dataset. + + Args: + foreground_threshold: the threshold to distinguish if a voxel belongs to foreground, this parameter + is used to select the foreground of images for calculation. Normally, `label > 0` means the corresponding + voxel belongs to foreground, thus if you need to calculate the statistics for whole images, you can set + the threshold to ``-1`` (default: ``0``). + + """ + voxel_sum = torch.as_tensor(0.0) + voxel_square_sum = torch.as_tensor(0.0) + voxel_max, voxel_min = [], [] + voxel_ct = 0 + + for data in self.data_loader: + if self.image_key and self.label_key: + image, label = data[self.image_key], data[self.label_key] + else: + image, label = data + + voxel_max.append(image.max().item()) + voxel_min.append(image.min().item()) + + image_foreground = image[torch.where(label > foreground_threshold)] + voxel_ct += len(image_foreground) + voxel_sum += image_foreground.sum() + voxel_square_sum += torch.square(image_foreground).sum() + + self.data_max, self.data_min = max(voxel_max), min(voxel_min) + self.data_mean = (voxel_sum / voxel_ct).item() + self.data_std = (torch.sqrt(voxel_square_sum / voxel_ct - self.data_mean ** 2)).item() + + def calculate_percentiles( + self, + foreground_threshold: int = 0, + sampling_flag: bool = True, + interval: int = 10, + min_percentile: float = 0.5, + max_percentile: float = 99.5, + ): + """ + This function is used to calculate the percentiles of intensities (and median) of the input dataset. To get + the required values, all voxels need to be accumulated. To reduce the memory used, this function can be set + to accumulate only a part of the voxels. + + Args: + foreground_threshold: the threshold to distinguish if a voxel belongs to foreground, this parameter + is used to select the foreground of images for calculation. Normally, `label > 0` means the corresponding + voxel belongs to foreground, thus if you need to calculate the statistics for whole images, you can set + the threshold to ``-1`` (default: ``0``). + sampling_flag: whether to sample only a part of the voxels (default: ``True``). + interval: the sampling interval for accumulating voxels (default: ``10``). + min_percentile: minimal percentile (default: ``0.5``). + max_percentile: maximal percentile (default: ``99.5``). + + """ + all_intensities = [] + for data in self.data_loader: + if self.image_key and self.label_key: + image, label = data[self.image_key], data[self.label_key] + else: + image, label = data + + intensities = image[torch.where(label > foreground_threshold)].tolist() + if sampling_flag: + intensities = intensities[::interval] + all_intensities.append(intensities) + + all_intensities = list(chain(*all_intensities)) + self.data_min_percentile, self.data_max_percentile = np.percentile( + all_intensities, [min_percentile, max_percentile] + ) + self.data_median = np.median(all_intensities) diff --git a/tests/min_tests.py b/tests/min_tests.py index 1cd54f35d0..1f53569cd9 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -111,6 +111,7 @@ def run_testsuit(): "test_handler_metrics_saver", "test_handler_metrics_saver_dist", "test_handler_classification_saver_dist", + "test_dataset_summary", "test_deepgrow_transforms", "test_deepgrow_interaction", "test_deepgrow_dataset", diff --git a/tests/test_dataset_summary.py b/tests/test_dataset_summary.py new file mode 100644 index 0000000000..5307bc7e66 --- /dev/null +++ b/tests/test_dataset_summary.py @@ -0,0 +1,90 @@ +# Copyright 2020 - 2021 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 glob +import os +import tempfile +import unittest + +import nibabel as nib +import numpy as np + +from monai.data import Dataset, DatasetSummary, create_test_image_3d +from monai.transforms import LoadImaged +from monai.utils import set_determinism + + +class TestDatasetSummary(unittest.TestCase): + def test_spacing_intensity(self): + set_determinism(seed=0) + with tempfile.TemporaryDirectory() as tempdir: + + for i in range(5): + im, seg = create_test_image_3d(32, 32, 32, num_seg_classes=1, num_objs=3, rad_max=6, channel_dim=0) + n = nib.Nifti1Image(im, np.eye(4)) + nib.save(n, os.path.join(tempdir, f"img{i:d}.nii.gz")) + n = nib.Nifti1Image(seg, np.eye(4)) + nib.save(n, os.path.join(tempdir, f"seg{i:d}.nii.gz")) + + train_images = sorted(glob.glob(os.path.join(tempdir, "img*.nii.gz"))) + train_labels = sorted(glob.glob(os.path.join(tempdir, "seg*.nii.gz"))) + data_dicts = [ + {"image": image_name, "label": label_name} for image_name, label_name in zip(train_images, train_labels) + ] + + dataset = Dataset(data=data_dicts, transform=LoadImaged(keys=["image", "label"])) + + calculator = DatasetSummary(dataset, num_workers=4) + + target_spacing = calculator.get_target_spacing() + self.assertEqual(target_spacing, (1.0, 1.0, 1.0)) + calculator.calculate_statistics() + np.testing.assert_allclose(calculator.data_mean, 0.892599, rtol=1e-5, atol=1e-5) + np.testing.assert_allclose(calculator.data_std, 0.131731, rtol=1e-5, atol=1e-5) + calculator.calculate_percentiles(sampling_flag=True, interval=2) + self.assertEqual(calculator.data_max_percentile, 1.0) + np.testing.assert_allclose(calculator.data_min_percentile, 0.556411, rtol=1e-5, atol=1e-5) + + def test_anisotropic_spacing(self): + with tempfile.TemporaryDirectory() as tempdir: + + pixdims = [ + [1.0, 1.0, 5.0], + [1.0, 1.0, 4.0], + [1.0, 1.0, 4.5], + [1.0, 1.0, 2.0], + [1.0, 1.0, 1.0], + ] + for i in range(5): + im, seg = create_test_image_3d(32, 32, 32, num_seg_classes=1, num_objs=3, rad_max=6, channel_dim=0) + n = nib.Nifti1Image(im, np.eye(4)) + n.header["pixdim"][1:4] = pixdims[i] + nib.save(n, os.path.join(tempdir, f"img{i:d}.nii.gz")) + n = nib.Nifti1Image(seg, np.eye(4)) + n.header["pixdim"][1:4] = pixdims[i] + nib.save(n, os.path.join(tempdir, f"seg{i:d}.nii.gz")) + + train_images = sorted(glob.glob(os.path.join(tempdir, "img*.nii.gz"))) + train_labels = sorted(glob.glob(os.path.join(tempdir, "seg*.nii.gz"))) + data_dicts = [ + {"image": image_name, "label": label_name} for image_name, label_name in zip(train_images, train_labels) + ] + + dataset = Dataset(data=data_dicts, transform=LoadImaged(keys=["image", "label"])) + + calculator = DatasetSummary(dataset, num_workers=4) + + target_spacing = calculator.get_target_spacing(anisotropic_threshold=4.0, percentile=20.0) + np.testing.assert_allclose(target_spacing, (1.0, 1.0, 1.8)) + + +if __name__ == "__main__": + unittest.main() From 607a31a9cbcb28e755480a6e0b2df9859416b8c8 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 31 Jul 2021 00:31:21 +0800 Subject: [PATCH 11/89] 2648 Enhance RandLambda to execute deterministic transforms for random part of dataset (#2667) * [DLMED] add RandCompose Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] change to enhance RandLambda Signed-off-by: Nic Ma * [DLMED] remove RandCompose Signed-off-by: Nic Ma * [DLMED] fix format Signed-off-by: Nic Ma * [DLMED] enhance doc Signed-off-by: Nic Ma * [DLMED] add inverse operation Signed-off-by: Nic Ma * [DLMED] add more tests Signed-off-by: Nic Ma * [DLMED] fix subprogress issue Signed-off-by: Nic Ma --- docs/source/transforms.rst | 6 ++ monai/transforms/__init__.py | 1 + monai/transforms/utility/array.py | 25 ++++++++- monai/transforms/utility/dictionary.py | 76 +++++++++++++++++++++++--- tests/test_inverse.py | 12 ++++ tests/test_rand_lambda.py | 53 ++++++++++++++++++ tests/test_rand_lambdad.py | 9 +++ 7 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 tests/test_rand_lambda.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 962e1f3769..01b1cb00bb 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -604,6 +604,12 @@ Utility :members: :special-members: __call__ +`RandLambda` +"""""""""""" +.. autoclass:: RandLambda + :members: + :special-members: __call__ + `LabelToMask` """"""""""""" .. autoclass:: LabelToMask diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 45eecd266c..487a995e5e 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -323,6 +323,7 @@ LabelToMask, Lambda, MapLabelValue, + RandLambda, RemoveRepeatedChannel, RepeatChannel, SimulateDelay, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 7f06f119c2..4e0141652f 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -23,7 +23,7 @@ import torch from monai.config import DtypeLike, NdarrayTensor -from monai.transforms.transform import Randomizable, Transform +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( convert_to_numpy, convert_to_tensor, @@ -58,6 +58,7 @@ "DataStats", "SimulateDelay", "Lambda", + "RandLambda", "LabelToMask", "FgBgToIndices", "ClassesToIndices", @@ -617,6 +618,28 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor], func: Optional[Callable raise ValueError("Incompatible values: func=None and self.func=None.") +class RandLambda(Lambda, RandomizableTransform): + """ + Randomizable version :py:class:`monai.transforms.Lambda`, the input `func` may contain random logic, + or randomly execute the function based on `prob`. + + Args: + func: Lambda/function to be applied. + prob: probability of executing the random function, default to 1.0, with 100% probability to execute. + + For more details, please check :py:class:`monai.transforms.Lambda`. + + """ + + def __init__(self, func: Optional[Callable] = None, prob: float = 1.0) -> None: + Lambda.__init__(self=self, func=func) + RandomizableTransform.__init__(self=self, prob=prob) + + def __call__(self, img: Union[np.ndarray, torch.Tensor], func: Optional[Callable] = None): + self.randomize(img) + return super().__call__(img=img, func=func) if self._do_transform else img + + class LabelToMask(Transform): """ Convert labels to mask for other tasks. A typical usage is to convert segmentation labels diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 6fa672e6c4..75be9685c4 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -24,8 +24,9 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor +from monai.data.utils import no_collation from monai.transforms.inverse import InvertibleTransform -from monai.transforms.transform import MapTransform, Randomizable +from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform from monai.transforms.utility.array import ( AddChannel, AsChannelFirst, @@ -833,7 +834,7 @@ def __call__(self, data): return d -class Lambdad(MapTransform): +class Lambdad(MapTransform, InvertibleTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.Lambda`. @@ -852,51 +853,110 @@ class Lambdad(MapTransform): See also: :py:class:`monai.transforms.compose.MapTransform` func: Lambda/function to be applied. It also can be a sequence of Callable, each element corresponds to a key in ``keys``. + inv_func: Lambda/function of inverse operation if want to invert transforms, default to `lambda x: x`. + It also can be a sequence of Callable, each element corresponds to a key in ``keys``. overwrite: whether to overwrite the original data in the input dictionary with lamdbda function output. default to True. it also can be a sequence of bool, each element corresponds to a key in ``keys``. allow_missing_keys: don't raise exception if key is missing. + + Note: The inverse operation doesn't allow to define `extra_info` or access other information, such as the + image's original size. If need these complicated information, please write a new InvertibleTransform directly. + """ def __init__( self, keys: KeysCollection, func: Union[Sequence[Callable], Callable], + inv_func: Union[Sequence[Callable], Callable] = no_collation, overwrite: Union[Sequence[bool], bool] = True, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) self.func = ensure_tuple_rep(func, len(self.keys)) + self.inv_func = ensure_tuple_rep(inv_func, len(self.keys)) self.overwrite = ensure_tuple_rep(overwrite, len(self.keys)) self._lambd = Lambda() + def _transform(self, data: Any, func: Callable): + return self._lambd(data, func=func) + def __call__(self, data): d = dict(data) for key, func, overwrite in self.key_iterator(d, self.func, self.overwrite): - ret = self._lambd(d[key], func=func) + ret = self._transform(data=d[key], func=func) + if overwrite: + d[key] = ret + self.push_transform(d, key) + return d + + def _inverse_transform(self, transform_info: Dict, data: Any, func: Callable): + return self._lambd(data, func=func) + + def inverse(self, data): + d = deepcopy(dict(data)) + for key, inv_func, overwrite in self.key_iterator(d, self.inv_func, self.overwrite): + transform = self.get_most_recent_transform(d, key) + ret = self._inverse_transform(transform_info=transform, data=d[key], func=inv_func) if overwrite: d[key] = ret + self.pop_transform(d, key) return d -class RandLambdad(Lambdad, Randomizable): +class RandLambdad(Lambdad, RandomizableTransform): """ - Randomizable version :py:class:`monai.transforms.Lambdad`, the input `func` contains random logic. - It's a randomizable transform so `CacheDataset` will not execute it and cache the results. + Randomizable version :py:class:`monai.transforms.Lambdad`, the input `func` may contain random logic, + or randomly execute the function based on `prob`. so `CacheDataset` will not execute it and cache the results. Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` func: Lambda/function to be applied. It also can be a sequence of Callable, each element corresponds to a key in ``keys``. + inv_func: Lambda/function of inverse operation if want to invert transforms, default to `lambda x: x`. + It also can be a sequence of Callable, each element corresponds to a key in ``keys``. overwrite: whether to overwrite the original data in the input dictionary with lamdbda function output. default to True. it also can be a sequence of bool, each element corresponds to a key in ``keys``. + prob: probability of executing the random function, default to 1.0, with 100% probability to execute. + note that all the data specified by `keys` will share the same random probability to execute or not. + allow_missing_keys: don't raise exception if key is missing. For more details, please check :py:class:`monai.transforms.Lambdad`. + Note: The inverse operation doesn't allow to define `extra_info` or access other information, such as the + image's original size. If need these complicated information, please write a new InvertibleTransform directly. + """ - def randomize(self, data: Any) -> None: - pass + def __init__( + self, + keys: KeysCollection, + func: Union[Sequence[Callable], Callable], + inv_func: Union[Sequence[Callable], Callable] = no_collation, + overwrite: Union[Sequence[bool], bool] = True, + prob: float = 1.0, + allow_missing_keys: bool = False, + ) -> None: + Lambdad.__init__( + self=self, + keys=keys, + func=func, + inv_func=inv_func, + overwrite=overwrite, + allow_missing_keys=allow_missing_keys, + ) + RandomizableTransform.__init__(self=self, prob=prob, do_transform=True) + + def _transform(self, data: Any, func: Callable): + return self._lambd(data, func=func) if self._do_transform else data + + def __call__(self, data): + self.randomize(data) + return super().__call__(data) + + def _inverse_transform(self, transform_info: Dict, data: Any, func: Callable): + return self._lambd(data, func=func) if transform_info[InverseKeys.DO_TRANSFORM] else data class LabelToMaskd(MapTransform): diff --git a/tests/test_inverse.py b/tests/test_inverse.py index a1c171200f..f2470d47fd 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -35,6 +35,7 @@ DivisiblePadd, Flipd, InvertibleTransform, + Lambdad, LoadImaged, Orientationd, RandAffined, @@ -42,6 +43,7 @@ RandCropByLabelClassesd, RandCropByPosNegLabeld, RandFlipd, + RandLambdad, Randomizable, RandRotate90d, RandRotated, @@ -314,6 +316,16 @@ TESTS.append(("Resized longest 3d", "3D", 5e-2, Resized(KEYS, 201, "longest", "trilinear", True))) +TESTS.append(("Lambdad 2d", "2D", 5e-2, Lambdad(KEYS, func=lambda x: x + 5, inv_func=lambda x: x - 5, overwrite=True))) + +TESTS.append( + ( + "RandLambdad 3d", + "3D", + 5e-2, + RandLambdad(KEYS, func=lambda x: x * 10, inv_func=lambda x: x / 10, overwrite=True, prob=0.5), + ) +) TESTS.append( ( diff --git a/tests/test_rand_lambda.py b/tests/test_rand_lambda.py new file mode 100644 index 0000000000..bf537883cf --- /dev/null +++ b/tests/test_rand_lambda.py @@ -0,0 +1,53 @@ +# Copyright 2020 - 2021 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 monai.transforms.transform import Randomizable +from monai.transforms.utility.array import RandLambda + + +class RandTest(Randomizable): + """ + randomisable transform for testing. + """ + + def randomize(self, data=None): + self._a = self.R.random() + + def __call__(self, data): + self.randomize() + return data + self._a + + +class TestRandLambda(unittest.TestCase): + def test_rand_lambdad_identity(self): + img = np.zeros((10, 10)) + + test_func = RandTest() + test_func.set_random_state(seed=134) + expected = test_func(img) + test_func.set_random_state(seed=134) + ret = RandLambda(func=test_func)(img) + np.testing.assert_allclose(expected, ret) + ret = RandLambda(func=test_func, prob=0.0)(img) + np.testing.assert_allclose(img, ret) + + trans = RandLambda(func=test_func, prob=0.5) + trans.set_random_state(seed=123) + ret = trans(img) + np.testing.assert_allclose(img, ret) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_lambdad.py b/tests/test_rand_lambdad.py index a450b67413..0a127839b8 100644 --- a/tests/test_rand_lambdad.py +++ b/tests/test_rand_lambdad.py @@ -42,6 +42,15 @@ def test_rand_lambdad_identity(self): ret = RandLambdad(keys=["img", "prop"], func=test_func, overwrite=[True, False])(data) np.testing.assert_allclose(expected["img"], ret["img"]) np.testing.assert_allclose(expected["prop"], ret["prop"]) + ret = RandLambdad(keys=["img", "prop"], func=test_func, prob=0.0)(data) + np.testing.assert_allclose(data["img"], ret["img"]) + np.testing.assert_allclose(data["prop"], ret["prop"]) + + trans = RandLambdad(keys=["img", "prop"], func=test_func, prob=0.5) + trans.set_random_state(seed=123) + ret = trans(data) + np.testing.assert_allclose(data["img"], ret["img"]) + np.testing.assert_allclose(data["prop"], ret["prop"]) if __name__ == "__main__": From 7f42efe0ab55730c302aba87bcd9bf75c4bfc496 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 30 Jul 2021 14:14:06 -0400 Subject: [PATCH 12/89] Restructure pathology transforms (#2671) * Restructure transforms into a directory Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> * Fix a typo Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Co-authored-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- monai/apps/pathology/handlers/__init__.py | 12 ++++++++++++ .../{handlers.py => handlers/prob_map_producer.py} | 0 2 files changed, 12 insertions(+) create mode 100644 monai/apps/pathology/handlers/__init__.py rename monai/apps/pathology/{handlers.py => handlers/prob_map_producer.py} (100%) diff --git a/monai/apps/pathology/handlers/__init__.py b/monai/apps/pathology/handlers/__init__.py new file mode 100644 index 0000000000..3a788ffa26 --- /dev/null +++ b/monai/apps/pathology/handlers/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2020 - 2021 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. + +from .prob_map_producer import ProbMapProducer diff --git a/monai/apps/pathology/handlers.py b/monai/apps/pathology/handlers/prob_map_producer.py similarity index 100% rename from monai/apps/pathology/handlers.py rename to monai/apps/pathology/handlers/prob_map_producer.py From 4f7371ae4e89f4c1d73652de4144425781a180f0 Mon Sep 17 00:00:00 2001 From: Yaniel Cabrera <32556695+yanielc@users.noreply.github.com> Date: Mon, 2 Aug 2021 16:51:17 +0300 Subject: [PATCH 13/89] Updated RandKSpaceSpikeNoised. Collected Fourier mappings. (#2665) * Moved fourier functions to their own class. Modified RandKSpaceSpikeNoised. 1. Allow RandKSpaceSpikeNoised to work with arbitrary keys. 2. Introduced Fourier transform to keep the forward/backward fourier mappings. Signed-off-by: Yaniel Cabrera * removed old code Signed-off-by: Yaniel Cabrera * Ignore torch.fft tests if not present Ignore tests with versions of Pytorch which lack the module fft. Signed-off-by: Yaniel Cabrera * update Signed-off-by: Yaniel Cabrera * typing update Signed-off-by: Yaniel Cabrera * added unit test for Fourier Signed-off-by: Yaniel Cabrera * added unit test for Fourier Signed-off-by: Yaniel Cabrera * fixing black Signed-off-by: Yaniel Cabrera --- docs/source/transforms.rst | 4 + monai/transforms/__init__.py | 10 +- monai/transforms/intensity/array.py | 163 ++++++++--------------- monai/transforms/intensity/dictionary.py | 47 ++++--- monai/transforms/transform.py | 50 ++++++- tests/test_fourier.py | 70 ++++++++++ tests/test_gibbs_noise.py | 3 + tests/test_gibbs_noised.py | 3 + tests/test_k_space_spike_noise.py | 3 + tests/test_k_space_spike_noised.py | 3 + tests/test_rand_gibbs_noise.py | 3 + tests/test_rand_gibbs_noised.py | 3 + tests/test_rand_k_space_spike_noise.py | 3 + tests/test_rand_k_space_spike_noised.py | 35 +++-- 14 files changed, 249 insertions(+), 151 deletions(-) create mode 100644 tests/test_fourier.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 01b1cb00bb..1c3ee288a1 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -53,6 +53,10 @@ Generic Interfaces .. autoclass:: Decollated :members: +`Fourier` +^^^^^^^^^^^^^ +.. autoclass:: Fourier + :members: Vanilla Transforms ------------------ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 487a995e5e..20e29d5aa9 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -306,7 +306,15 @@ ZoomD, ZoomDict, ) -from .transform import MapTransform, Randomizable, RandomizableTransform, ThreadUnsafe, Transform, apply_transform +from .transform import ( + Fourier, + MapTransform, + Randomizable, + RandomizableTransform, + ThreadUnsafe, + Transform, + apply_transform, +) from .utility.array import ( AddChannel, AddExtremePointsChannel, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index dfbac7465c..4533f333ce 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -23,7 +23,7 @@ from monai.config import DtypeLike from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter -from monai.transforms.transform import RandomizableTransform, Transform +from monai.transforms.transform import Fourier, RandomizableTransform, Transform from monai.transforms.utils import rescale_array from monai.utils import ( PT_BEFORE_1_7, @@ -1196,7 +1196,7 @@ def _randomize(self, _: Any) -> None: self.sampled_alpha = self.R.uniform(self.alpha[0], self.alpha[1]) -class GibbsNoise(Transform): +class GibbsNoise(Transform, Fourier): """ The transform applies Gibbs noise to 2D/3D MRI images. Gibbs artifacts are one of the common type of type artifacts appearing in MRI scans. @@ -1204,15 +1204,17 @@ class GibbsNoise(Transform): The transform is applied to all the channels in the data. For general information on Gibbs artifacts, please refer to: - https://pubs.rsna.org/doi/full/10.1148/rg.313105115 - https://pubs.rsna.org/doi/full/10.1148/radiographics.22.4.g02jl14949 + `An Image-based Approach to Understanding the Physics of MR Artifacts + `_. + + `The AAPM/RSNA Physics Tutorial for Residents + `_ Args: - alpha (float): Parametrizes the intensity of the Gibbs noise filter applied. Takes + alpha: Parametrizes the intensity of the Gibbs noise filter applied. Takes values in the interval [0,1] with alpha = 0 acting as the identity mapping. - as_tensor_output: if true return torch.Tensor, else return np.array. default: True. - + as_tensor_output: if true return torch.Tensor, else return np.array. Default: True. """ def __init__(self, alpha: float = 0.5, as_tensor_output: bool = True) -> None: @@ -1221,47 +1223,22 @@ def __init__(self, alpha: float = 0.5, as_tensor_output: bool = True) -> None: raise AssertionError("alpha must take values in the interval [0,1].") self.alpha = alpha self.as_tensor_output = as_tensor_output - self._device = torch.device("cpu") def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, np.ndarray]: n_dims = len(img.shape[1:]) - # convert to ndarray to work with np.fft - _device = None - if isinstance(img, torch.Tensor): - _device = img.device - img = img.cpu().detach().numpy() - + if isinstance(img, np.ndarray): + img = torch.Tensor(img) # FT - k = self._shift_fourier(img, n_dims) + k = self.shift_fourier(img, n_dims) # build and apply mask k = self._apply_mask(k) # map back - img = self._inv_shift_fourier(k, n_dims) - return torch.Tensor(img).to(_device or self._device) if self.as_tensor_output else img - - def _shift_fourier(self, x: Union[np.ndarray, torch.Tensor], n_dims: int) -> np.ndarray: - """ - Applies fourier transform and shifts its output. - Only the spatial dimensions get transformed. + img = self.inv_shift_fourier(k, n_dims) - Args: - x (np.ndarray): tensor to fourier transform. - """ - out: np.ndarray = np.fft.fftshift(np.fft.fftn(x, axes=tuple(range(-n_dims, 0))), axes=tuple(range(-n_dims, 0))) - return out + return img if self.as_tensor_output else img.cpu().detach().numpy() - def _inv_shift_fourier(self, k: Union[np.ndarray, torch.Tensor], n_dims: int) -> np.ndarray: - """ - Applies inverse shift and fourier transform. Only the spatial - dimensions are transformed. - """ - out: np.ndarray = np.fft.ifftn( - np.fft.ifftshift(k, axes=tuple(range(-n_dims, 0))), axes=tuple(range(-n_dims, 0)) - ).real - return out - - def _apply_mask(self, k: np.ndarray) -> np.ndarray: + def _apply_mask(self, k: torch.Tensor) -> torch.Tensor: """Builds and applies a mask on the spatial dimensions. Args: @@ -1287,11 +1264,11 @@ def _apply_mask(self, k: np.ndarray) -> np.ndarray: mask = np.repeat(mask[None], k.shape[0], axis=0) # apply binary mask - k_masked: np.ndarray = k * mask + k_masked = k * torch.tensor(mask, device=k.device) return k_masked -class KSpaceSpikeNoise(Transform): +class KSpaceSpikeNoise(Transform, Fourier): """ Apply localized spikes in `k`-space at the given locations and intensities. Spike (Herringbone) artifact is a type of data acquisition artifact which @@ -1354,7 +1331,7 @@ def __init__( def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, np.ndarray]: """ Args: - img (np.array or torch.tensor): image with dimensions (C, H, W) or (C, H, W, D) + img: image with dimensions (C, H, W) or (C, H, W, D) """ # checking that tuples in loc are consistent with img size self._check_indices(img) @@ -1368,22 +1345,17 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, n_dims = len(img.shape[1:]) - # convert to ndarray to work with np.fft - if isinstance(img, torch.Tensor): - device = img.device - img = img.cpu().detach().numpy() - else: - device = torch.device("cpu") - + if isinstance(img, np.ndarray): + img = torch.Tensor(img) # FT - k = self._shift_fourier(img, n_dims) - log_abs = np.log(np.absolute(k) + 1e-10) - phase = np.angle(k) + k = self.shift_fourier(img, n_dims) + log_abs = torch.log(torch.absolute(k) + 1e-10) + phase = torch.angle(k) k_intensity = self.k_intensity # default log intensity if k_intensity is None: - k_intensity = tuple(np.mean(log_abs, axis=tuple(range(-n_dims, 0))) * 2.5) + k_intensity = tuple(torch.mean(log_abs, dim=tuple(range(-n_dims, 0))) * 2.5) # highlight if isinstance(self.loc[0], Sequence): @@ -1392,9 +1364,10 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, else: self._set_spike(log_abs, self.loc, k_intensity) # map back - k = np.exp(log_abs) * np.exp(1j * phase) - img = self._inv_shift_fourier(k, n_dims) - return torch.Tensor(img, device=device) if self.as_tensor_output else img + k = torch.exp(log_abs) * torch.exp(1j * phase) + img = self.inv_shift_fourier(k, n_dims) + + return img if self.as_tensor_output else img.cpu().detach().numpy() def _check_indices(self, img) -> None: """Helper method to check consistency of self.loc and input image. @@ -1414,14 +1387,14 @@ def _check_indices(self, img) -> None: f"The index value at position {i} of one of the tuples in loc = {self.loc} is out of bounds for current image." ) - def _set_spike(self, k: np.ndarray, idx: Tuple, val: Union[Sequence[float], float]): + def _set_spike(self, k: torch.Tensor, idx: Tuple, val: Union[Sequence[float], float]): """ Helper function to introduce a given intensity at given location. Args: - k (np.array): intensity array to alter. - idx (tuple): index of location where to apply change. - val (float): value of intensity to write in. + k: intensity array to alter. + idx: index of location where to apply change. + val: value of intensity to write in. """ if len(k.shape) == len(idx): if isinstance(val, Sequence): @@ -1429,33 +1402,12 @@ def _set_spike(self, k: np.ndarray, idx: Tuple, val: Union[Sequence[float], floa else: k[idx] = val elif len(k.shape) == 4 and len(idx) == 3: - k[:, idx[0], idx[1], idx[2]] = val + k[:, idx[0], idx[1], idx[2]] = val # type: ignore elif len(k.shape) == 3 and len(idx) == 2: - k[:, idx[0], idx[1]] = val - - def _shift_fourier(self, x: Union[np.ndarray, torch.Tensor], n_dims: int) -> np.ndarray: - """ - Applies fourier transform and shifts its output. - Only the spatial dimensions get transformed. - - Args: - x (np.ndarray): tensor to fourier transform. - """ - out: np.ndarray = np.fft.fftshift(np.fft.fftn(x, axes=tuple(range(-n_dims, 0))), axes=tuple(range(-n_dims, 0))) - return out + k[:, idx[0], idx[1]] = val # type: ignore - def _inv_shift_fourier(self, k: Union[np.ndarray, torch.Tensor], n_dims: int) -> np.ndarray: - """ - Applies inverse shift and fourier transform. Only the spatial - dimensions are transformed. - """ - out: np.ndarray = np.fft.ifftn( - np.fft.ifftshift(k, axes=tuple(range(-n_dims, 0))), axes=tuple(range(-n_dims, 0)) - ).real - return out - -class RandKSpaceSpikeNoise(RandomizableTransform): +class RandKSpaceSpikeNoise(RandomizableTransform, Fourier): """ Naturalistic data augmentation via spike artifacts. The transform applies localized spikes in `k`-space, and it is the random version of @@ -1476,7 +1428,7 @@ class RandKSpaceSpikeNoise(RandomizableTransform): channels at once, or channel-wise if ``channel_wise = True``. intensity_range: pass a tuple (a, b) to sample the log-intensity from the interval (a, b) - uniformly for all channels. Or pass sequence of intervals + uniformly for all channels. Or pass sequence of intevals ((a0, b0), (a1, b1), ...) to sample for each respective channel. In the second case, the number of 2-tuples must match the number of channels. @@ -1521,7 +1473,7 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, Apply transform to `img`. Assumes data is in channel-first form. Args: - img (np.array or torch.tensor): image with dimensions (C, H, W) or (C, H, W, D) + img: image with dimensions (C, H, W) or (C, H, W, D) """ if self.intensity_range is not None: if isinstance(self.intensity_range[0], Sequence) and len(self.intensity_range) != img.shape[0]: @@ -1532,19 +1484,20 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, self.sampled_k_intensity = [] self.sampled_locs = [] - # convert to ndarray to work with np.fft - x, device = self._to_numpy(img) - intensity_range = self._make_sequence(x) - self._randomize(x, intensity_range) + if not isinstance(img, torch.Tensor): + img = torch.Tensor(img) + + intensity_range = self._make_sequence(img) + self._randomize(img, intensity_range) - # build/apply transform only if there are spike locations + # build/appy transform only if there are spike locations if self.sampled_locs: transform = KSpaceSpikeNoise(self.sampled_locs, self.sampled_k_intensity, self.as_tensor_output) - return transform(x) + return transform(img) - return torch.Tensor(x, device=device) if self.as_tensor_output else x + return img if self.as_tensor_output else img.detach().numpy() - def _randomize(self, img: np.ndarray, intensity_range: Sequence[Sequence[float]]) -> None: + def _randomize(self, img: torch.Tensor, intensity_range: Sequence[Sequence[float]]) -> None: """ Helper method to sample both the location and intensity of the spikes. When not working channel wise (channel_wise=False) it use the random @@ -1568,11 +1521,11 @@ def _randomize(self, img: np.ndarray, intensity_range: Sequence[Sequence[float]] spatial = tuple(self.R.randint(0, k) for k in img.shape[1:]) self.sampled_locs = [(i,) + spatial for i in range(img.shape[0])] if isinstance(intensity_range[0], Sequence): - self.sampled_k_intensity = [self.R.uniform(*p) for p in intensity_range] # type: ignore + self.sampled_k_intensity = [self.R.uniform(p[0], p[1]) for p in intensity_range] else: - self.sampled_k_intensity = [self.R.uniform(*self.intensity_range)] * len(img) # type: ignore + self.sampled_k_intensity = [self.R.uniform(intensity_range[0], intensity_range[1])] * len(img) # type: ignore - def _make_sequence(self, x: np.ndarray) -> Sequence[Sequence[float]]: + def _make_sequence(self, x: torch.Tensor) -> Sequence[Sequence[float]]: """ Formats the sequence of intensities ranges to Sequence[Sequence[float]]. """ @@ -1586,27 +1539,21 @@ def _make_sequence(self, x: np.ndarray) -> Sequence[Sequence[float]]: # set default range if one not provided return self._set_default_range(x) - def _set_default_range(self, x: np.ndarray) -> Sequence[Sequence[float]]: + def _set_default_range(self, img: torch.Tensor) -> Sequence[Sequence[float]]: """ Sets default intensity ranges to be sampled. Args: - x (np.ndarray): tensor to fourier transform. + img: image to transform. """ - n_dims = len(x.shape[1:]) + n_dims = len(img.shape[1:]) - k = np.fft.fftshift(np.fft.fftn(x, axes=tuple(range(-n_dims, 0))), axes=tuple(range(-n_dims, 0))) - log_abs = np.log(np.absolute(k) + 1e-10) - shifted_means = np.mean(log_abs, axis=tuple(range(-n_dims, 0))) * 2.5 + k = self.shift_fourier(img, n_dims) + log_abs = torch.log(torch.absolute(k) + 1e-10) + shifted_means = torch.mean(log_abs, dim=tuple(range(-n_dims, 0))) * 2.5 intensity_sequence = tuple((i * 0.95, i * 1.1) for i in shifted_means) return intensity_sequence - def _to_numpy(self, img: Union[np.ndarray, torch.Tensor]) -> Tuple[np.ndarray, torch.device]: - if isinstance(img, torch.Tensor): - return img.cpu().detach().numpy(), img.device - else: - return img, torch.device("cpu") - class RandCoarseDropout(RandomizableTransform): """ diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 49f20ea419..c24f7b67ca 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1234,16 +1234,14 @@ class RandKSpaceSpikeNoised(RandomizableTransform, MapTransform): prob: probability to add spike artifact to each item in the dictionary provided it is realized that the noise will be applied to the dictionary. - img_intensity_range: Intensity - range to sample for ``"image"`` key. Pass a tuple `(a, b)` to sample - the log-intensity from the interval `(a, b)` uniformly for all - channels. Or pass sequence of intervals `((a0, b0), (a1, b1), ...)` - to sample for each respective channel. In the second case, the - number of 2-tuples must match the number of channels. - Default ranges is `(0.95x, 1.10x)` where `x` is the mean - log-intensity for each channel. - label_intensity_range: Intensity range to sample for ``"label"`` key. Same - as behavior as ``img_intensity_range`` but ``"label"`` key. + intensity_ranges: Dictionary with intensity + ranges to sample for each key. Given a dictionary value of `(a, b)` the + transform will sample the log-intensity from the interval `(a, b)` uniformly for all + channels of the respective key. If a sequence of intevals `((a0, b0), (a1, b1), ...)` + is given, then the transform will sample from each interval for each + respective channel. In the second case, the number of 2-tuples must + match the number of channels. Default ranges is `(0.95x, 1.10x)` + where `x` is the mean log-intensity for each channel. channel_wise: treat each channel independently. True by default. common_sampling: If ``True`` same values for location and log-intensity @@ -1257,7 +1255,7 @@ class RandKSpaceSpikeNoised(RandomizableTransform, MapTransform): To apply `k`-space spikes randomly on the image only, with probability 0.5, and log-intensity sampled from the interval [13, 15] for each channel independently, one uses - ``RandKSpaceSpikeNoised("image", prob=0.5, img_intensity_range=(13,15), channel_wise=True)``. + ``RandKSpaceSpikeNoised("image", prob=0.5, intensity_ranges={"image":(13,15)}, channel_wise=True)``. """ def __init__( @@ -1265,8 +1263,7 @@ def __init__( keys: KeysCollection, global_prob: float = 1.0, prob: float = 0.1, - img_intensity_range: Optional[Sequence[Union[Sequence[float], float]]] = None, - label_intensity_range: Optional[Sequence[Union[Sequence[float], float]]] = None, + intensity_ranges: Optional[Mapping[Hashable, Sequence[Union[Sequence[float], float]]]] = None, channel_wise: bool = True, common_sampling: bool = False, common_seed: int = 42, @@ -1281,8 +1278,15 @@ def __init__( self.common_seed = common_seed self.as_tensor_output = as_tensor_output # the spikes artifact is amplitude dependent so we instantiate one per key - self.t_img = RandKSpaceSpikeNoise(prob, img_intensity_range, channel_wise, self.as_tensor_output) - self.t_label = RandKSpaceSpikeNoise(prob, label_intensity_range, channel_wise, self.as_tensor_output) + self.transforms = {} + if isinstance(intensity_ranges, Mapping): + for k in self.keys: + self.transforms[k] = RandKSpaceSpikeNoise( + prob, intensity_ranges[k], channel_wise, self.as_tensor_output + ) + else: + for k in self.keys: + self.transforms[k] = RandKSpaceSpikeNoise(prob, None, channel_wise, self.as_tensor_output) def __call__( self, data: Mapping[Hashable, Union[torch.Tensor, np.ndarray]] @@ -1297,13 +1301,12 @@ def __call__( # In case the same spikes are desired for both image and label. if self.common_sampling: - self.t_img.set_random_state(self.common_seed) - self.t_label.set_random_state(self.common_seed) + for k in self.keys: + self.transforms[k].set_random_state(self.common_seed) - for key in self.key_iterator(d): + for key, t in self.key_iterator(d, self.transforms): if self._do_transform: - transform = self.t_img if key == "image" else self.t_label - d[key] = transform(d[key]) + d[key] = self.transforms[t](d[key]) else: if isinstance(d[key], np.ndarray) and self.as_tensor_output: d[key] = torch.Tensor(d[key]) @@ -1321,8 +1324,8 @@ def set_rand_state(self, seed: Optional[int] = None, state: Optional[np.random.R state: set the random state with a `np.random.RandomState` object.""" self.set_random_state(seed, state) - self.t_img.set_random_state(seed, state) - self.t_label.set_random_state(seed, state) + for key in self.keys: + self.transforms[key].set_random_state(seed, state) def _to_numpy(self, d: Union[torch.Tensor, np.ndarray]) -> np.ndarray: if isinstance(d, torch.Tensor): diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 681c0ba9ec..97cc2f21fc 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -23,7 +23,15 @@ from monai.config import KeysCollection from monai.utils import MAX_SEED, ensure_tuple -__all__ = ["ThreadUnsafe", "apply_transform", "Randomizable", "RandomizableTransform", "Transform", "MapTransform"] +__all__ = [ + "ThreadUnsafe", + "apply_transform", + "Randomizable", + "RandomizableTransform", + "Transform", + "MapTransform", + "Fourier", +] ReturnType = TypeVar("ReturnType") @@ -365,3 +373,43 @@ def key_iterator( yield (key,) + tuple(_ex_iters) if extra_iterables else key elif not self.allow_missing_keys: raise KeyError(f"Key was missing ({key}) and allow_missing_keys==False") + + +class Fourier: + """ + Helper class storing Fourier mappings + """ + + @staticmethod + def shift_fourier(x: torch.Tensor, n_dims: int) -> torch.Tensor: + """ + Applies fourier transform and shifts the zero-frequency component to the + center of the spectrum. Only the spatial dimensions get transformed. + + Args: + x: Image to transform. + n_dims: Number of spatial dimensions. + Returns + k: K-space data. + """ + k: torch.Tensor = torch.fft.fftshift( + torch.fft.fftn(x, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) + ) + return k + + @staticmethod + def inv_shift_fourier(k: torch.Tensor, n_dims: int) -> torch.Tensor: + """ + Applies inverse shift and fourier transform. Only the spatial + dimensions are transformed. + + Args: + k: K-space data. + n_dims: Number of spatial dimensions. + Returns: + x: Tensor in image space. + """ + x: torch.Tensor = torch.fft.ifftn( + torch.fft.ifftshift(k, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) + ).real + return x diff --git a/tests/test_fourier.py b/tests/test_fourier.py new file mode 100644 index 0000000000..488bf0cbf9 --- /dev/null +++ b/tests/test_fourier.py @@ -0,0 +1,70 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.data.synthetic import create_test_image_2d, create_test_image_3d +from monai.transforms import Fourier +from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule + +TEST_CASES = [((128, 64),), ((64, 48, 80),)] +# for shape in ((128, 64), (64, 48, 80)): +# TEST_CASES.append(shape) + + +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") +class TestFourier(unittest.TestCase): + def setUp(self): + set_determinism(0) + super().setUp() + + def tearDown(self): + set_determinism(None) + + @staticmethod + def get_data(img_shape): + create_test_image = create_test_image_2d if len(img_shape) == 2 else create_test_image_3d + im = create_test_image(*img_shape, num_objs=4, rad_max=20, noise_max=0.0, num_seg_classes=5)[0][None] + return torch.Tensor(im) + + @parameterized.expand(TEST_CASES) + def test_forward(self, img_shape): + n_dims = len(img_shape[1:]) + x = self.get_data(img_shape) + t = Fourier() + out = t.shift_fourier(x, n_dims) + + expect = torch.fft.fftshift(torch.fft.fftn(x, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0))) + + np.testing.assert_allclose(out, expect) + + @parameterized.expand(TEST_CASES) + def test_backward(self, img_shape): + n_dims = len(img_shape[1:]) + x = self.get_data(img_shape) + t = Fourier() + out = t.inv_shift_fourier(x, n_dims) + + expect = torch.fft.ifftn( + torch.fft.ifftshift(x, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) + ).real + + np.testing.assert_allclose(out, expect) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gibbs_noise.py b/tests/test_gibbs_noise.py index 83cba56938..264e2e630a 100644 --- a/tests/test_gibbs_noise.py +++ b/tests/test_gibbs_noise.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import GibbsNoise from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -27,6 +28,8 @@ TEST_CASES.append((shape, as_tensor_output, as_tensor_input)) +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestGibbsNoise(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_gibbs_noised.py b/tests/test_gibbs_noised.py index 0e02feb341..8ad4839338 100644 --- a/tests/test_gibbs_noised.py +++ b/tests/test_gibbs_noised.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import GibbsNoised from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -29,6 +30,8 @@ KEYS = ["im", "label"] +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestGibbsNoised(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_k_space_spike_noise.py b/tests/test_k_space_spike_noise.py index 53661d5fcb..bb6d05e676 100644 --- a/tests/test_k_space_spike_noise.py +++ b/tests/test_k_space_spike_noise.py @@ -20,6 +20,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import KSpaceSpikeNoise from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -28,6 +29,8 @@ TEST_CASES.append((shape, as_tensor_output, as_tensor_input)) +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestKSpaceSpikeNoise(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py index e5d2dfb6f8..e891bd4568 100644 --- a/tests/test_k_space_spike_noised.py +++ b/tests/test_k_space_spike_noised.py @@ -20,6 +20,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import KSpaceSpikeNoised from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -30,6 +31,8 @@ KEYS = ["image", "label"] +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestKSpaceSpikeNoised(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_rand_gibbs_noise.py b/tests/test_rand_gibbs_noise.py index 94948c5a0d..a0701d09c3 100644 --- a/tests/test_rand_gibbs_noise.py +++ b/tests/test_rand_gibbs_noise.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import RandGibbsNoise from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -27,6 +28,8 @@ TEST_CASES.append((shape, as_tensor_output, as_tensor_input)) +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestRandGibbsNoise(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_rand_gibbs_noised.py b/tests/test_rand_gibbs_noised.py index 986f4c02ae..72188a93b5 100644 --- a/tests/test_rand_gibbs_noised.py +++ b/tests/test_rand_gibbs_noised.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import RandGibbsNoised from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -29,6 +30,8 @@ KEYS = ["im", "label"] +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestRandGibbsNoised(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_rand_k_space_spike_noise.py b/tests/test_rand_k_space_spike_noise.py index ba9156c5b2..71f7e36d9b 100644 --- a/tests/test_rand_k_space_spike_noise.py +++ b/tests/test_rand_k_space_spike_noise.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import KSpaceSpikeNoise, RandKSpaceSpikeNoise from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -28,6 +29,8 @@ TEST_CASES.append((shape, as_tensor_output, as_tensor_input, channel_wise)) +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestRandKSpaceSpikeNoise(unittest.TestCase): def setUp(self): set_determinism(0) diff --git a/tests/test_rand_k_space_spike_noised.py b/tests/test_rand_k_space_spike_noised.py index 3cb49f1c08..d61b83e2d5 100644 --- a/tests/test_rand_k_space_spike_noised.py +++ b/tests/test_rand_k_space_spike_noised.py @@ -19,6 +19,7 @@ from monai.data.synthetic import create_test_image_2d, create_test_image_3d from monai.transforms import RandKSpaceSpikeNoised from monai.utils.misc import set_determinism +from tests.utils import SkipIfBeforePyTorchVersion, SkipIfNoModule TEST_CASES = [] for shape in ((128, 64), (64, 48, 80)): @@ -29,6 +30,8 @@ KEYS = ["image", "label"] +@SkipIfBeforePyTorchVersion((1, 8)) +@SkipIfNoModule("torch.fft") class TestKSpaceSpikeNoised(unittest.TestCase): def setUp(self): set_determinism(0) @@ -50,13 +53,12 @@ def test_same_result(self, im_shape, as_tensor_output, as_tensor_input): data = self.get_data(im_shape, as_tensor_input) - intensity_range = (13, 15) + intensity_ranges = {"image": (13, 15), "label": (13, 15)} t = RandKSpaceSpikeNoised( KEYS, global_prob=1.0, prob=1.0, - img_intensity_range=intensity_range, - label_intensity_range=intensity_range, + intensity_ranges=intensity_ranges, channel_wise=True, as_tensor_output=as_tensor_output, ) @@ -73,13 +75,12 @@ def test_same_result(self, im_shape, as_tensor_output, as_tensor_input): @parameterized.expand(TEST_CASES) def test_0_prob(self, im_shape, as_tensor_output, as_tensor_input): data = self.get_data(im_shape, as_tensor_input) - intensity_range = (13, 15) + intensity_ranges = {"image": (13, 15), "label": (13, 15)} t1 = RandKSpaceSpikeNoised( KEYS, global_prob=0.0, prob=1.0, - img_intensity_range=intensity_range, - label_intensity_range=intensity_range, + intensity_ranges=intensity_ranges, channel_wise=True, as_tensor_output=as_tensor_output, ) @@ -88,8 +89,7 @@ def test_0_prob(self, im_shape, as_tensor_output, as_tensor_input): KEYS, global_prob=0.0, prob=1.0, - img_intensity_range=intensity_range, - label_intensity_range=intensity_range, + intensity_ranges=intensity_ranges, channel_wise=True, as_tensor_output=as_tensor_output, ) @@ -104,23 +104,21 @@ def test_0_prob(self, im_shape, as_tensor_output, as_tensor_input): def test_intensity(self, im_shape, as_tensor_output, as_tensor_input): data = self.get_data(im_shape, as_tensor_input) - image_range = (15, 15.1) - label_range = (14, 14.1) + intensity_ranges = {"image": (13, 13.1), "label": (13, 13.1)} t = RandKSpaceSpikeNoised( KEYS, global_prob=1.0, prob=1.0, - img_intensity_range=image_range, - label_intensity_range=label_range, + intensity_ranges=intensity_ranges, channel_wise=True, as_tensor_output=True, ) _ = t(data) - self.assertGreaterEqual(t.t_img.sampled_k_intensity[0], 15) - self.assertLessEqual(t.t_img.sampled_k_intensity[0], 15.1) - self.assertGreaterEqual(t.t_label.sampled_k_intensity[0], 14) - self.assertLessEqual(t.t_label.sampled_k_intensity[0], 14.1) + self.assertGreaterEqual(t.transforms["image"].sampled_k_intensity[0], 13) + self.assertLessEqual(t.transforms["image"].sampled_k_intensity[0], 13.1) + self.assertGreaterEqual(t.transforms["label"].sampled_k_intensity[0], 13) + self.assertLessEqual(t.transforms["label"].sampled_k_intensity[0], 13.1) @parameterized.expand(TEST_CASES) def test_same_transformation(self, im_shape, _, as_tensor_input): @@ -128,14 +126,13 @@ def test_same_transformation(self, im_shape, _, as_tensor_input): # use same image for both dictionary entries to check same trans is applied to them data = {KEYS[0]: deepcopy(data[KEYS[0]]), KEYS[1]: deepcopy(data[KEYS[0]])} - image_range = label_range = (15, 15.1) + intensity_ranges = {"image": (13, 15), "label": (13, 15)} # use common_sampling = True to ask for the same transformation t = RandKSpaceSpikeNoised( KEYS, global_prob=1.0, prob=1.0, - img_intensity_range=image_range, - label_intensity_range=label_range, + intensity_ranges=intensity_ranges, channel_wise=True, common_sampling=True, as_tensor_output=True, From 9ecb0e158b8ea8babc48300962923903823caf7b Mon Sep 17 00:00:00 2001 From: Tom Vercauteren Date: Tue, 3 Aug 2021 11:00:46 +0200 Subject: [PATCH 14/89] Change MONAI author from a person to en entity (#2690) This is to address an issue in the bibtex conversion. Signed-off-by: Tom Vercauteren --- CITATION.cff | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index a3f88fb2f6..8cbf686ce6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,8 +5,7 @@ title: "MONAI: Medical Open Network for AI" abstract: "AI Toolkit for Healthcare Imaging" authors: - - family-names: - given-names: "MONAI Consortium" + - name: "MONAI Consortium" date-released: 2020-03-28 version: "0.6.0" doi: "10.5281/zenodo.4323058" From 482ff5d9fbfa01a693272d0671c81a2159b8fdb8 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 4 Aug 2021 08:13:12 +0800 Subject: [PATCH 15/89] 2679 Add IntensityStats transform to record intensity statistics into meta data (#2685) * [DLMED] add IntensityStats transform Signed-off-by: Nic Ma * [DLMED] add unit tests Signed-off-by: Nic Ma * [DLMED] add dict version transform Signed-off-by: Nic Ma * [DLMED] enhance ShiftIntensity transform Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] change to utility Signed-off-by: Nic Ma * [DLMED] adjust to look_up_option Signed-off-by: Nic Ma * [DLMED] add multi-processing test Signed-off-by: Nic Ma * [DLMED] add mask option Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix pickle issue Signed-off-by: Nic Ma * [DLMED] enhance error message Signed-off-by: Nic Ma * [DLMED] add pickle test Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] enhance pickle test Signed-off-by: Nic Ma Co-authored-by: monai-bot --- docs/source/transforms.rst | 15 +++++ monai/transforms/__init__.py | 4 ++ monai/transforms/intensity/array.py | 22 ++++-- monai/transforms/intensity/dictionary.py | 77 ++++++++++++++++++--- monai/transforms/utility/array.py | 82 +++++++++++++++++++++- monai/transforms/utility/dictionary.py | 73 ++++++++++++++++++++ tests/test_intensity_stats.py | 72 ++++++++++++++++++++ tests/test_intensity_statsd.py | 86 ++++++++++++++++++++++++ tests/test_rand_shift_intensity.py | 2 +- tests/test_rand_shift_intensityd.py | 13 +++- tests/test_shift_intensityd.py | 12 +++- 11 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 tests/test_intensity_stats.py create mode 100644 tests/test_intensity_statsd.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 1c3ee288a1..6a25c62c49 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -662,6 +662,13 @@ Utility :members: :special-members: __call__ +`IntensityStats` +"""""""""""""""" + .. autoclass:: IntensityStats + :members: + :special-members: __call__ + + Dictionary Transforms --------------------- @@ -911,6 +918,7 @@ Intensity (Dict) :members: :special-members: __call__ + IO (Dict) ^^^^^^^^^ @@ -1265,6 +1273,13 @@ Utility (Dict) :members: :special-members: __call__ +`IntensityStatsd` +""""""""""""""""" +.. autoclass:: IntensityStatsd + :members: + :special-members: __call__ + + Transform Adaptors ------------------ .. automodule:: monai.transforms.adaptors diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 20e29d5aa9..cf9198dbf5 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -328,6 +328,7 @@ EnsureType, FgBgToIndices, Identity, + IntensityStats, LabelToMask, Lambda, MapLabelValue, @@ -390,6 +391,9 @@ Identityd, IdentityD, IdentityDict, + IntensityStatsd, + IntensityStatsD, + IntensityStatsDict, LabelToMaskd, LabelToMaskD, LabelToMaskDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 4533f333ce..14b3e54459 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -187,11 +187,13 @@ class ShiftIntensity(Transform): def __init__(self, offset: float) -> None: self.offset = offset - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray, offset: Optional[float] = None) -> np.ndarray: """ Apply the transform to `img`. """ - return np.asarray((img + self.offset), dtype=img.dtype) + + offset = self.offset if offset is None else offset + return np.asarray((img + offset), dtype=img.dtype) class RandShiftIntensity(RandomizableTransform): @@ -214,20 +216,26 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) self._offset = self.offsets[0] + self._shfiter = ShiftIntensity(self._offset) def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) super().randomize(None) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: np.ndarray, factor: Optional[float] = None) -> np.ndarray: """ Apply the transform to `img`. + + Args: + img: input image to shift intensity. + factor: a factor to multiply the random offset, then shift. + can be some image specific value at runtime, like: max(img), etc. + """ self.randomize() if not self._do_transform: return img - shifter = ShiftIntensity(self._offset) - return shifter(img) + return self._shfiter(img, self._offset if factor is None else self._offset * factor) class StdShiftIntensity(Transform): @@ -1457,7 +1465,7 @@ def __init__( self.intensity_range = intensity_range self.channel_wise = channel_wise self.as_tensor_output = as_tensor_output - self.sampled_k_intensity: List[float] = [] + self.sampled_k_intensity: List = [] self.sampled_locs: List[Tuple] = [] if intensity_range is not None: @@ -1523,7 +1531,7 @@ def _randomize(self, img: torch.Tensor, intensity_range: Sequence[Sequence[float if isinstance(intensity_range[0], Sequence): self.sampled_k_intensity = [self.R.uniform(p[0], p[1]) for p in intensity_range] else: - self.sampled_k_intensity = [self.R.uniform(intensity_range[0], intensity_range[1])] * len(img) # type: ignore + self.sampled_k_intensity = [self.R.uniform(intensity_range[0], intensity_range[1])] * len(img) def _make_sequence(self, x: torch.Tensor) -> Sequence[Sequence[float]]: """ diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index c24f7b67ca..e43aa1e2b3 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -42,7 +42,7 @@ ThresholdIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform -from monai.utils import dtype_torch_to_numpy, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils import dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple __all__ = [ "RandGaussianNoised", @@ -232,21 +232,53 @@ class ShiftIntensityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ShiftIntensity`. """ - def __init__(self, keys: KeysCollection, offset: float, allow_missing_keys: bool = False) -> None: + def __init__( + self, + keys: KeysCollection, + offset: float, + factor_key: Optional[str] = None, + meta_keys: Optional[KeysCollection] = None, + meta_key_postfix: str = "meta_dict", + allow_missing_keys: bool = False, + ) -> None: """ Args: keys: keys of the corresponding items to be transformed. See also: :py:class:`monai.transforms.compose.MapTransform` offset: offset value to shift the intensity of image. + factor_key: if not None, use it as the key to extract a value from the corresponding + meta data dictionary of `key` at runtime, and multiply the `offset` to shift intensity. + Usually, `IntensityStatsd` transform can pre-compute statistics of intensity values + and store in the meta data. + it also can be a sequence of strings, map to `keys`. + meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + used to extract the factor value is `factor_key` is not None. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, original_shape, etc. + it can be a sequence of string, map to the `keys`. + if None, will try to construct meta_keys by `key_{meta_key_postfix}`. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to to fetch the meta data according + to the key data, default is `meta_dict`, the meta data is a dictionary object. + used to extract the factor value is `factor_key` is not None. allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) + self.factor_key = ensure_tuple_rep(factor_key, len(self.keys)) + self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) + if len(self.keys) != len(self.meta_keys): + raise ValueError("meta_keys should have the same length as keys.") + self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) self.shifter = ShiftIntensity(offset) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data) -> Dict[Hashable, np.ndarray]: d = dict(data) - for key in self.key_iterator(d): - d[key] = self.shifter(d[key]) + for key, factor_key, meta_key, meta_key_postfix in self.key_iterator( + d, self.factor_key, self.meta_keys, self.meta_key_postfix + ): + meta_key = meta_key or f"{key}_{meta_key_postfix}" + factor: Optional[float] = d[meta_key].get(factor_key) if meta_key in d else None + offset = None if factor is None else self.shifter.offset * factor + d[key] = self.shifter(d[key], offset=offset) return d @@ -259,6 +291,9 @@ def __init__( self, keys: KeysCollection, offsets: Union[Tuple[float, float], float], + factor_key: Optional[str] = None, + meta_keys: Optional[KeysCollection] = None, + meta_key_postfix: str = "meta_dict", prob: float = 0.1, allow_missing_keys: bool = False, ) -> None: @@ -268,6 +303,20 @@ def __init__( See also: :py:class:`monai.transforms.compose.MapTransform` offsets: offset range to randomly shift. if single number, offset value is picked from (-offsets, offsets). + factor_key: if not None, use it as the key to extract a value from the corresponding + meta data dictionary of `key` at runtime, and multiply the random `offset` to shift intensity. + Usually, `IntensityStatsd` transform can pre-compute statistics of intensity values + and store in the meta data. + it also can be a sequence of strings, map to `keys`. + meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + used to extract the factor value is `factor_key` is not None. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, original_shape, etc. + it can be a sequence of string, map to the `keys`. + if None, will try to construct meta_keys by `key_{meta_key_postfix}`. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to to fetch the meta data according + to the key data, default is `meta_dict`, the meta data is a dictionary object. + used to extract the factor value is `factor_key` is not None. prob: probability of rotating. (Default 0.1, with 10% probability it returns a rotated array.) allow_missing_keys: don't raise exception if key is missing. @@ -282,19 +331,29 @@ def __init__( raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) self._offset = self.offsets[0] + self.factor_key = ensure_tuple_rep(factor_key, len(self.keys)) + self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) + if len(self.keys) != len(self.meta_keys): + raise ValueError("meta_keys should have the same length as keys.") + self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) + self.shifter = ShiftIntensity(self._offset) def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) super().randomize(None) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data) -> Dict[Hashable, np.ndarray]: d = dict(data) self.randomize() if not self._do_transform: return d - shifter = ShiftIntensity(self._offset) - for key in self.key_iterator(d): - d[key] = shifter(d[key]) + for key, factor_key, meta_key, meta_key_postfix in self.key_iterator( + d, self.factor_key, self.meta_keys, self.meta_key_postfix + ): + meta_key = meta_key or f"{key}_{meta_key_postfix}" + factor: Optional[float] = d[meta_key].get(factor_key) if meta_key in d else None + offset = self._offset if factor is None else self._offset * factor + d[key] = self.shifter(d[key], offset=offset) return d diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 4e0141652f..3de2408abd 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -17,7 +17,7 @@ import sys import time import warnings -from typing import Callable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -32,7 +32,7 @@ map_binary_to_indices, map_classes_to_indices, ) -from monai.utils import ensure_tuple, issequenceiterable, min_version, optional_import +from monai.utils import ensure_tuple, issequenceiterable, look_up_option, min_version, optional_import PILImageImage, has_pil = optional_import("PIL.Image", name="Image") pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") @@ -66,6 +66,7 @@ "AddExtremePointsChannel", "TorchVision", "MapLabelValue", + "IntensityStats", ] @@ -938,3 +939,80 @@ def __call__(self, img: np.ndarray): np.place(out_flat, img_flat == o, t) return out_flat.reshape(img.shape) + + +class IntensityStats(Transform): + """ + Compute statistics for the intensity values of input image and store into the meta data dictionary. + For example: if `ops=[lambda x: np.mean(x), "max"]` and `key_prefix="orig"`, may generate below stats: + `{"orig_custom_0": 1.5, "orig_max": 3.0}`. + + Args: + ops: expected operations to compute statistics for the intensity. + if a string, will map to the predefined operations, supported: ["mean", "median", "max", "min", "std"] + mapping to `np.nanmean`, `np.nanmedian`, `np.nanmax`, `np.nanmin`, `np.nanstd`. + if a callable function, will execute the function on input image. + key_prefix: the prefix to combine with `ops` name to generate the key to store the results in the + meta data dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" + as the key, where index counts from 0. + channel_wise: whether to compute statistics for every channel of input image separately. + if True, return a list of values for every operation, default to False. + + """ + + def __init__(self, ops: Sequence[Union[str, Callable]], key_prefix: str, channel_wise: bool = False) -> None: + self.ops = ensure_tuple(ops) + self.key_prefix = key_prefix + self.channel_wise = channel_wise + + def __call__( + self, + img: np.ndarray, + meta_data: Optional[Dict] = None, + mask: Optional[np.ndarray] = None, + ) -> Tuple[np.ndarray, Dict]: + """ + Compute statistics for the intensity of input image. + + Args: + img: input image to compute intensity stats. + meta_data: meta data dictionary to store the statistics data, if None, will create an empty dictionary. + mask: if not None, mask the image to extract only the interested area to compute statistics. + mask must have the same shape as input `img`. + + """ + if meta_data is None: + meta_data = {} + + img_: np.ndarray = img + if mask is not None: + if mask.shape != img.shape or mask.dtype != bool: + raise TypeError("mask must be bool array with the same shape as input `img`.") + img_ = img[mask] + + supported_ops = { + "mean": lambda x: np.nanmean(x), + "median": lambda x: np.nanmedian(x), + "max": lambda x: np.nanmax(x), + "min": lambda x: np.nanmin(x), + "std": lambda x: np.nanstd(x), + } + + def _compute(op: Callable, data: np.ndarray): + if self.channel_wise: + return [op(c) for c in data] + else: + return op(data) + + custom_index = 0 + for o in self.ops: + if isinstance(o, str): + o = look_up_option(o, supported_ops.keys()) + meta_data[self.key_prefix + "_" + o] = _compute(supported_ops[o], img_) + elif callable(o): + meta_data[self.key_prefix + "_custom_" + str(custom_index)] = _compute(o, img_) + custom_index += 1 + else: + raise ValueError("ops must be key string for predefined operations or callable function.") + + return img, meta_data diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 75be9685c4..fb9963601d 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -39,6 +39,7 @@ EnsureType, FgBgToIndices, Identity, + IntensityStats, LabelToMask, Lambda, MapLabelValue, @@ -101,6 +102,9 @@ "IdentityD", "IdentityDict", "Identityd", + "IntensityStatsd", + "IntensityStatsD", + "IntensityStatsDict", "LabelToMaskD", "LabelToMaskDict", "LabelToMaskd", @@ -1282,6 +1286,74 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda return d +class IntensityStatsd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.IntensityStats`. + Compute statistics for the intensity values of input image and store into the meta data dictionary. + For example: if `ops=[lambda x: np.mean(x), "max"]` and `key_prefix="orig"`, may generate below stats: + `{"orig_custom_0": 1.5, "orig_max": 3.0}`. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + ops: expected operations to compute statistics for the intensity. + if a string, will map to the predefined operations, supported: ["mean", "median", "max", "min", "std"] + mapping to `np.nanmean`, `np.nanmedian`, `np.nanmax`, `np.nanmin`, `np.nanstd`. + if a callable function, will execute the function on input image. + key_prefix: the prefix to combine with `ops` name to generate the key to store the results in the + meta data dictionary. if some `ops` are callable functions, will use "{key_prefix}_custom_{index}" + as the key, where index counts from 0. + mask_keys: if not None, specify the mask array for the image to extract only the interested area to compute + statistics, mask must have the same shape as the image. + it should be a sequence of strings or None, map to the `keys`. + channel_wise: whether to compute statistics for every channel of input image separately. + if True, return a list of values for every operation, default to False. + meta_keys: explicitly indicate the key of the corresponding meta data dictionary. + used to store the computed statistics to the meta dict. + for example, for data with key `image`, the metadata by default is in `image_meta_dict`. + the meta data is a dictionary object which contains: filename, original_shape, etc. + it can be a sequence of string, map to the `keys`. + if None, will try to construct meta_keys by `key_{meta_key_postfix}`. + meta_key_postfix: if meta_keys is None, use `key_{postfix}` to to fetch the meta data according + to the key data, default is `meta_dict`, the meta data is a dictionary object. + used to store the computed statistics to the meta dict. + allow_missing_keys: don't raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + ops: Sequence[Union[str, Callable]], + key_prefix: str, + mask_keys: Optional[KeysCollection] = None, + channel_wise: bool = False, + meta_keys: Optional[KeysCollection] = None, + meta_key_postfix: str = "meta_dict", + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.stats = IntensityStats(ops=ops, key_prefix=key_prefix, channel_wise=channel_wise) + self.mask_keys = ensure_tuple_rep(None, len(self.keys)) if mask_keys is None else ensure_tuple(mask_keys) + self.meta_keys = ensure_tuple_rep(None, len(self.keys)) if meta_keys is None else ensure_tuple(meta_keys) + if len(self.keys) != len(self.meta_keys): + raise ValueError("meta_keys should have the same length as keys.") + self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) + + def __call__(self, data) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key, mask_key, meta_key, meta_key_postfix in self.key_iterator( + d, self.mask_keys, self.meta_keys, self.meta_key_postfix + ): + meta_key = meta_key or f"{key}_{meta_key_postfix}" + d[key], d[meta_key] = self.stats( + img=d[key], + meta_data=d.get(meta_key), + mask=d.get(mask_key) if mask_key is not None else None, + ) + return d + + IdentityD = IdentityDict = Identityd AsChannelFirstD = AsChannelFirstDict = AsChannelFirstd AsChannelLastD = AsChannelLastDict = AsChannelLastd @@ -1316,3 +1388,4 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda RandTorchVisionD = RandTorchVisionDict = RandTorchVisiond RandLambdaD = RandLambdaDict = RandLambdad MapLabelValueD = MapLabelValueDict = MapLabelValued +IntensityStatsD = IntensityStatsDict = IntensityStatsd diff --git a/tests/test_intensity_stats.py b/tests/test_intensity_stats.py new file mode 100644 index 0000000000..059271e442 --- /dev/null +++ b/tests/test_intensity_stats.py @@ -0,0 +1,72 @@ +# Copyright 2020 - 2021 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 import IntensityStats + +TEST_CASE_1 = [ + {"ops": ["max", "mean"], "key_prefix": "orig"}, + np.array([[[0.0, 1.0], [2.0, 3.0]]]), + {"affine": None}, + {"orig_max": 3.0, "orig_mean": 1.5}, +] + +TEST_CASE_2 = [ + {"ops": "std", "key_prefix": "orig"}, + np.array([[[0.0, 1.0], [2.0, 3.0]]]), + None, + {"orig_std": 1.118034}, +] + +TEST_CASE_3 = [ + {"ops": [lambda x: np.mean(x), "max", lambda x: np.min(x)], "key_prefix": "orig"}, + np.array([[[0.0, 1.0], [2.0, 3.0]]]), + None, + {"orig_custom_0": 1.5, "orig_max": 3.0, "orig_custom_1": 0.0}, +] + +TEST_CASE_4 = [ + {"ops": ["max", "mean"], "key_prefix": "orig", "channel_wise": True}, + np.array([[[0.0, 1.0], [2.0, 3.0]], [[4.0, 5.0], [6.0, 7.0]]]), + {"affine": None}, + {"orig_max": [3.0, 7.0], "orig_mean": [1.5, 5.5]}, +] + +TEST_CASE_5 = [ + {"ops": ["max", "mean"], "key_prefix": "orig"}, + np.array([[[0.0, 1.0], [2.0, 3.0]]]), + {"affine": None}, + {"orig_max": 3.0, "orig_mean": 1.5}, +] + + +class TestIntensityStats(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + def test_value(self, input_param, img, meta_dict, expected): + _, meta_dict = IntensityStats(**input_param)(img, meta_dict) + for k, v in expected.items(): + self.assertTrue(k in meta_dict) + np.testing.assert_allclose(v, meta_dict[k], atol=1e-3) + + def test_mask(self): + img = np.array([[[0.0, 1.0], [2.0, 3.0]]]) + mask = np.array([[[1, 0], [1, 0]]], dtype=bool) + img, meta_dict = IntensityStats(ops=["max", "mean"], key_prefix="orig")(img, mask=mask) + np.testing.assert_allclose(meta_dict["orig_max"], 2.0, atol=1e-3) + np.testing.assert_allclose(meta_dict["orig_mean"], 1.0, atol=1e-3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_intensity_statsd.py b/tests/test_intensity_statsd.py new file mode 100644 index 0000000000..8c8bc8795a --- /dev/null +++ b/tests/test_intensity_statsd.py @@ -0,0 +1,86 @@ +# Copyright 2020 - 2021 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 sys +import unittest + +import numpy as np +import torch.multiprocessing as mp +from parameterized import parameterized + +from monai.data import DataLoader, Dataset +from monai.transforms import IntensityStatsd + +TEST_CASE_1 = [ + {"keys": "img", "ops": ["max", "mean"], "key_prefix": "orig", "meta_keys": "test_meta"}, + {"img": np.array([[[0.0, 1.0], [2.0, 3.0]]]), "test_meta": {"affine": None}}, + "test_meta", + {"orig_max": 3.0, "orig_mean": 1.5}, +] + +TEST_CASE_2 = [ + {"keys": "img", "ops": "std", "key_prefix": "orig"}, + {"img": np.array([[[0.0, 1.0], [2.0, 3.0]]])}, + "img_meta_dict", + {"orig_std": 1.118034}, +] + +TEST_CASE_3 = [ + {"keys": "img", "ops": [lambda x: np.mean(x), "max", lambda x: np.min(x)], "key_prefix": "orig"}, + {"img": np.array([[[0.0, 1.0], [2.0, 3.0]]])}, + "img_meta_dict", + {"orig_custom_0": 1.5, "orig_max": 3.0, "orig_custom_1": 0.0}, +] + +TEST_CASE_4 = [ + {"keys": "img", "ops": ["max", "mean"], "key_prefix": "orig", "channel_wise": True, "meta_key_postfix": "meta"}, + {"img": np.array([[[0.0, 1.0], [2.0, 3.0]], [[4.0, 5.0], [6.0, 7.0]]]), "img_meta": {"affine": None}}, + "img_meta", + {"orig_max": [3.0, 7.0], "orig_mean": [1.5, 5.5]}, +] + + +class TestIntensityStatsd(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_value(self, input_param, data, meta_key, expected): + meta = IntensityStatsd(**input_param)(data)[meta_key] + for k, v in expected.items(): + self.assertTrue(k in meta) + np.testing.assert_allclose(v, meta[k], atol=1e-3) + + def test_dataloader(self): + dataset = Dataset( + data=[{"img": np.array([[[0.0, 1.0], [2.0, 3.0]]])}, {"img": np.array([[[0.0, 1.0], [2.0, 3.0]]])}], + transform=IntensityStatsd(keys="img", ops=["max", "mean"], key_prefix="orig"), + ) + # set num workers = 0 for mac / win + num_workers = 2 if sys.platform == "linux" else 0 + dataloader = DataLoader(dataset=dataset, num_workers=num_workers, batch_size=2) + orig_method = mp.get_start_method() + mp.set_start_method("spawn", force=True) + + for d in dataloader: + meta = d["img_meta_dict"] + np.testing.assert_allclose(meta["orig_max"], [3.0, 3.0], atol=1e-3) + np.testing.assert_allclose(meta["orig_mean"], [1.5, 1.5], atol=1e-3) + # restore the mp method + mp.set_start_method(orig_method, force=True) + + def test_mask(self): + data = {"img": np.array([[[0.0, 1.0], [2.0, 3.0]]]), "img_mask": np.array([[[1, 0], [1, 0]]], dtype=bool)} + stats = IntensityStatsd(keys="img", ops=["max", "mean"], mask_keys="img_mask", key_prefix="orig") + meta = stats(data)["img_meta_dict"] + np.testing.assert_allclose(meta["orig_max"], 2.0, atol=1e-3) + np.testing.assert_allclose(meta["orig_mean"], 1.0, atol=1e-3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_shift_intensity.py b/tests/test_rand_shift_intensity.py index ba54510bc3..4c4dd87dfe 100644 --- a/tests/test_rand_shift_intensity.py +++ b/tests/test_rand_shift_intensity.py @@ -21,7 +21,7 @@ class TestRandShiftIntensity(NumpyImageTestCase2D): def test_value(self): shifter = RandShiftIntensity(offsets=1.0, prob=1.0) shifter.set_random_state(seed=0) - result = shifter(self.imt) + result = shifter(self.imt, factor=1.0) np.random.seed(0) expected = self.imt + np.random.uniform(low=-1.0, high=1.0) np.testing.assert_allclose(result, expected) diff --git a/tests/test_rand_shift_intensityd.py b/tests/test_rand_shift_intensityd.py index 0c6f25e7b5..71cfd8fc50 100644 --- a/tests/test_rand_shift_intensityd.py +++ b/tests/test_rand_shift_intensityd.py @@ -13,7 +13,7 @@ import numpy as np -from monai.transforms import RandShiftIntensityd +from monai.transforms import IntensityStatsd, RandShiftIntensityd from tests.utils import NumpyImageTestCase2D @@ -27,6 +27,17 @@ def test_value(self): expected = self.imt + np.random.uniform(low=-1.0, high=1.0) np.testing.assert_allclose(result[key], expected) + def test_factor(self): + key = "img" + stats = IntensityStatsd(keys=key, ops="max", key_prefix="orig") + shifter = RandShiftIntensityd(keys=[key], offsets=1.0, factor_key=["orig_max"], prob=1.0) + data = {key: self.imt, key + "_meta_dict": {"affine": None}} + shifter.set_random_state(seed=0) + result = shifter(stats(data)) + np.random.seed(0) + expected = self.imt + np.random.uniform(low=-1.0, high=1.0) * np.nanmax(self.imt) + np.testing.assert_allclose(result[key], expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_shift_intensityd.py b/tests/test_shift_intensityd.py index 752cf4b8d2..71cfffc9c5 100644 --- a/tests/test_shift_intensityd.py +++ b/tests/test_shift_intensityd.py @@ -13,7 +13,7 @@ import numpy as np -from monai.transforms import ShiftIntensityd +from monai.transforms import IntensityStatsd, ShiftIntensityd from tests.utils import NumpyImageTestCase2D @@ -25,6 +25,16 @@ def test_value(self): expected = self.imt + 1.0 np.testing.assert_allclose(result[key], expected) + def test_factor(self): + key = "img" + stats = IntensityStatsd(keys=key, ops="max", key_prefix="orig") + shifter = ShiftIntensityd(keys=[key], offset=1.0, factor_key=["orig_max"]) + data = {key: self.imt, key + "_meta_dict": {"affine": None}} + + result = shifter(stats(data)) + expected = self.imt + 1.0 * np.nanmax(self.imt) + np.testing.assert_allclose(result[key], expected) + if __name__ == "__main__": unittest.main() From f6ad4ba5c2a6ecd8ab0ca18da1c20b0112a18d87 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 4 Aug 2021 17:55:41 +0800 Subject: [PATCH 16/89] 2687 2688 Enhance doc-strings of Affine related transforms (#2693) * [DLMED] update doc-strings of Affine transforms Signed-off-by: Nic Ma * [DLMED] fix format issue Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] remove coord link Signed-off-by: Nic Ma --- monai/transforms/spatial/array.py | 178 +++++++++++++++++-------- monai/transforms/spatial/dictionary.py | 101 ++++++++++---- monai/transforms/utils.py | 14 +- 3 files changed, 204 insertions(+), 89 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index d9c10cf9c0..9e78e18f85 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -946,23 +946,23 @@ class AffineGrid(Transform): Affine transforms on the coordinates. Args: - rotate_params: angle range in radians. rotate_params[0] with be used to generate the 1st rotation - parameter from `uniform[-rotate_params[0], rotate_params[0])`. Similarly, `rotate_params[1]` and - `rotate_params[2]` are used in 3D affine for the range of 2nd and 3rd axes. - shear_params: shear_params[0] with be used to generate the 1st shearing parameter from - `uniform[-shear_params[0], shear_params[0])`. Similarly, `shear_params[1]` to - `shear_params[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. - translate_params : translate_params[0] with be used to generate the 1st shift parameter from - `uniform[-translate_params[0], translate_params[0])`. Similarly, `translate_params[1]` - to `translate_params[N]` controls the range of the uniform distribution used to generate - the 2nd to N-th parameter. - scale_params: scale_params[0] with be used to generate the 1st scaling factor from - `uniform[-scale_params[0], scale_params[0]) + 1.0`. Similarly, `scale_params[1]` to - `scale_params[N]` controls the range of the uniform distribution used to generate the 2nd to - N-th parameter. - as_tensor_output: whether to output tensor instead of numpy array. - defaults to True. + rotate_params: a rotation angle in radians, a scalar for 2D image, a tuple of 3 floats for 3D. + Defaults to no rotation. + shear_params: shearing factors for affine matrix, take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. + translate_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Translation is in + pixel/voxel relative to the center of the input image. Defaults to no translation. + scale_params: scale factor for every spatial dims. a tuple of 2 floats for 2D, + a tuple of 3 floats for 3D. Defaults to `1.0`. + as_tensor_output: whether to output tensor instead of numpy array, defaults to True. device: device to store the output grid data. affine: If applied, ignore the params (`rotate_params`, etc.) and use the supplied matrix. Should be square with each side = num of image spatial @@ -1041,6 +1041,7 @@ def __call__( class RandAffineGrid(Randomizable, Transform): """ Generate randomised affine grid. + """ def __init__( @@ -1054,16 +1055,28 @@ def __init__( ) -> None: """ Args: - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, + take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select voxels to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). as_tensor_output: whether to output tensor instead of numpy array. defaults to True. device: device to store the output grid data. @@ -1284,6 +1297,8 @@ def __call__( class Affine(Transform): """ Transform ``img`` given the affine parameters. + A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. + """ def __init__( @@ -1305,10 +1320,20 @@ def __init__( Args: rotate_params: a rotation angle in radians, a scalar for 2D image, a tuple of 3 floats for 3D. Defaults to no rotation. - shear_params: a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. + shear_params: shearing factors for affine matrix, take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. translate_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Translation is in pixel/voxel relative to the center of the input image. Defaults to no translation. - scale_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Defaults to no scaling. + scale_params: scale factor for every spatial dims. a tuple of 2 floats for 2D, + a tuple of 3 floats for 3D. Defaults to `1.0`. spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -1372,6 +1397,8 @@ def __call__( class RandAffine(RandomizableTransform): """ Random affine transform. + A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. + """ def __init__( @@ -1392,16 +1419,28 @@ def __init__( Args: prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, + take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select pixel/voxel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -1530,7 +1569,9 @@ def __call__( class Rand2DElastic(RandomizableTransform): """ - Random elastic deformation and affine in 2D + Random elastic deformation and affine in 2D. + A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. + """ def __init__( @@ -1555,16 +1596,26 @@ def __init__( prob: probability of returning a randomized elastic transform. defaults to 0.1, with 10% chance returns a randomized elastic transform, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D) for affine matrix, take a 2D affine as example:: + + [ + [1.0, params[0], 0.0], + [params[1], 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select pixel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). spatial_size: specifying output image spatial size [h, w]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -1656,7 +1707,9 @@ def __call__( class Rand3DElastic(RandomizableTransform): """ - Random elastic deformation and affine in 3D + Random elastic deformation and affine in 3D. + A tutorial is available: https://github.com/Project-MONAI/tutorials/blob/0.6.0/modules/transforms_demo_2d.ipynb. + """ def __init__( @@ -1683,16 +1736,27 @@ def __init__( prob: probability of returning a randomized elastic transform. defaults to 0.1, with 10% chance returns a randomized elastic transform, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 6 floats for 3D) for affine matrix, take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select voxel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). spatial_size: specifying output image spatial size [h, w, d]. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 0d65fdfa29..a7eeceacf9 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -592,10 +592,20 @@ def __init__( keys: keys of the corresponding items to be transformed. rotate_params: a rotation angle in radians, a scalar for 2D image, a tuple of 3 floats for 3D. Defaults to no rotation. - shear_params: a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. + shear_params: shearing factors for affine matrix, take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing. translate_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Translation is in pixel/voxel relative to the center of the input image. Defaults to no translation. - scale_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Defaults to no scaling. + scale_params: scale factor for every spatial dims. a tuple of 2 floats for 2D, + a tuple of 3 floats for 3D. Defaults to `1.0`. spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -710,16 +720,28 @@ def __init__( to `(32, 64)` if the second spatial dimension size of img is `64`. prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, + take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select pixel/voxel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -876,16 +898,26 @@ def __init__( prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D) for affine matrix, take a 2D affine as example:: + + [ + [1.0, params[0], 0.0], + [params[1], 1.0, 0.0], + [0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select pixel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample @@ -996,16 +1028,27 @@ def __init__( prob: probability of returning a randomized affine grid. defaults to 0.1, with 10% chance returns a randomized grid, otherwise returns a ``spatial_size`` centered area extracted from the input image. - rotate_range: angle range in radians. If element `i` is iterable, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the ith dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can - be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range - `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 - and nothing for the remaining dimensions. - shear_range: shear_range with format matching `rotate_range`. - translate_range: translate_range with format matching `rotate_range`. - scale_range: scaling_range with format matching `rotate_range`. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1). + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 6 floats for 3D) for affine matrix, take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select voxel to translate for every spatial dims. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). mode: {``"bilinear"``, ``"nearest"``} Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 2da7b688cb..6dd2d2539f 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -607,7 +607,15 @@ def create_shear(spatial_dims: int, coefs: Union[Sequence[float], float]) -> np. Args: spatial_dims: spatial rank - coefs: shearing factors, defaults to 0. + coefs: shearing factors, a tuple of 2 floats for 2D, a tuple of 6 floats for 3D), + take a 3D affine as example:: + + [ + [1.0, coefs[0], coefs[1], 0.0], + [coefs[2], 1.0, coefs[3], 0.0], + [coefs[4], coefs[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] Raises: NotImplementedError: When ``spatial_dims`` is not one of [2, 3]. @@ -635,7 +643,7 @@ def create_scale(spatial_dims: int, scaling_factor: Union[Sequence[float], float Args: spatial_dims: spatial rank - scaling_factor: scaling factors, defaults to 1. + scaling_factor: scaling factors for every spatial dim, defaults to 1. """ scaling_factor = ensure_tuple_size(scaling_factor, dim=spatial_dims, pad_val=1.0) return np.diag(scaling_factor[:spatial_dims] + (1.0,)) @@ -647,7 +655,7 @@ def create_translate(spatial_dims: int, shift: Union[Sequence[float], float]) -> Args: spatial_dims: spatial rank - shift: translate factors, defaults to 0. + shift: translate pixel/voxel for every spatial dim, defaults to 0. """ shift = ensure_tuple(shift) affine = np.eye(spatial_dims + 1) From 03a837218739e41ea26322e977e0b4b0cbc0f3de Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 5 Aug 2021 21:20:05 +0800 Subject: [PATCH 17/89] [DLMED] update int method (#2696) Signed-off-by: Nic Ma --- monai/transforms/spatial/array.py | 3 +-- tests/test_resize.py | 11 ++++++++--- tests/test_resized.py | 8 ++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 9e78e18f85..dcbc7aa2f6 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -14,7 +14,6 @@ """ import warnings -from math import ceil from typing import Any, List, Optional, Sequence, Tuple, Union import numpy as np @@ -403,7 +402,7 @@ def __call__( if not isinstance(self.spatial_size, int): raise ValueError("spatial_size must be an int number if size_mode is 'longest'.") scale = self.spatial_size / max(img_size) - spatial_size_ = tuple(ceil(s * scale) for s in img_size) + spatial_size_ = tuple(int(round(s * scale)) for s in img_size) resized = torch.nn.functional.interpolate( # type: ignore input=torch.as_tensor(np.ascontiguousarray(img), dtype=torch.float).unsqueeze(0), size=spatial_size_, diff --git a/tests/test_resize.py b/tests/test_resize.py index 2f54dcc04f..e5ec5dd1a9 100644 --- a/tests/test_resize.py +++ b/tests/test_resize.py @@ -18,11 +18,11 @@ from monai.transforms import Resize from tests.utils import NumpyImageTestCase2D -TEST_CASE_0 = [{"spatial_size": 15}, (6, 11, 15)] +TEST_CASE_0 = [{"spatial_size": 15}, (6, 10, 15)] -TEST_CASE_1 = [{"spatial_size": 15, "mode": "area"}, (6, 11, 15)] +TEST_CASE_1 = [{"spatial_size": 15, "mode": "area"}, (6, 10, 15)] -TEST_CASE_2 = [{"spatial_size": 6, "mode": "trilinear", "align_corners": True}, (3, 5, 6)] +TEST_CASE_2 = [{"spatial_size": 6, "mode": "trilinear", "align_corners": True}, (2, 4, 6)] class TestResize(NumpyImageTestCase2D): @@ -63,6 +63,11 @@ def test_longest_shape(self, input_param, expected_shape): result = Resize(**input_param)(input_data) np.testing.assert_allclose(result.shape[1:], expected_shape) + def test_longest_infinite_decimals(self): + resize = Resize(spatial_size=1008, size_mode="longest", mode="bilinear", align_corners=False) + ret = resize(np.random.randint(0, 2, size=[1, 2544, 3032])) + self.assertTupleEqual(ret.shape, (1, 846, 1008)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_resized.py b/tests/test_resized.py index 6c4f31c9c8..930faf00eb 100644 --- a/tests/test_resized.py +++ b/tests/test_resized.py @@ -18,15 +18,15 @@ from monai.transforms import Resized from tests.utils import NumpyImageTestCase2D -TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 11, 15)] +TEST_CASE_0 = [{"keys": "img", "spatial_size": 15}, (6, 10, 15)] -TEST_CASE_1 = [{"keys": "img", "spatial_size": 15, "mode": "area"}, (6, 11, 15)] +TEST_CASE_1 = [{"keys": "img", "spatial_size": 15, "mode": "area"}, (6, 10, 15)] -TEST_CASE_2 = [{"keys": "img", "spatial_size": 6, "mode": "trilinear", "align_corners": True}, (3, 5, 6)] +TEST_CASE_2 = [{"keys": "img", "spatial_size": 6, "mode": "trilinear", "align_corners": True}, (2, 4, 6)] TEST_CASE_3 = [ {"keys": ["img", "label"], "spatial_size": 6, "mode": ["trilinear", "nearest"], "align_corners": [True, None]}, - (3, 5, 6), + (2, 4, 6), ] From 4298b143133b2fc634972b49b6029548e23d6444 Mon Sep 17 00:00:00 2001 From: Mohammad Adil Date: Fri, 6 Aug 2021 02:58:52 -0700 Subject: [PATCH 18/89] Increase timeout of tests (#2711) Signed-off-by: Mohammad Adil --- tests/test_highresnet.py | 2 +- tests/test_integration_determinism.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_highresnet.py b/tests/test_highresnet.py index 83248ad85f..61af529b63 100644 --- a/tests/test_highresnet.py +++ b/tests/test_highresnet.py @@ -53,7 +53,7 @@ def test_shape(self, input_param, input_shape, expected_shape): result = net.forward(torch.randn(input_shape).to(device)) self.assertEqual(result.shape, expected_shape) - @TimedCall(seconds=400, force_quit=True) + @TimedCall(seconds=800, force_quit=True) def test_script(self): input_param, input_shape, expected_shape = TEST_CASE_1 net = HighResNet(**input_param) diff --git a/tests/test_integration_determinism.py b/tests/test_integration_determinism.py index 4947610484..e077420420 100644 --- a/tests/test_integration_determinism.py +++ b/tests/test_integration_determinism.py @@ -75,7 +75,7 @@ def setUp(self): def tearDown(self): set_determinism(seed=None) - @TimedCall(seconds=30) + @TimedCall(seconds=150) def test_training(self): set_determinism(seed=0) loss, step = run_test(device=self.device) From 62425d7d8809d129b4f35a51f425d02d325bd816 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 6 Aug 2021 15:20:29 +0100 Subject: [PATCH 19/89] 2499 adds 2d/3d support of patchembedding, ViT, and unetr model (#2698) * 2d/3d patchembedding Signed-off-by: Wenqi Li * minor updates for selfattention Signed-off-by: Wenqi Li * 2d vit Signed-off-by: Wenqi Li * fixes type hint Signed-off-by: Wenqi Li * update unetr Signed-off-by: Wenqi Li * fixes unit test Signed-off-by: Wenqi Li --- monai/networks/blocks/mlp.py | 2 +- monai/networks/blocks/patchembedding.py | 73 ++++++++++++----------- monai/networks/blocks/selfattention.py | 16 ++--- monai/networks/blocks/transformerblock.py | 4 +- monai/networks/blocks/unetr_block.py | 11 ++-- monai/networks/nets/unetr.py | 60 +++++++++---------- monai/networks/nets/vit.py | 28 +++++---- tests/test_mlp.py | 2 +- tests/test_patchembedding.py | 30 +++++----- tests/test_selfattention.py | 4 +- tests/test_transformerblock.py | 4 +- tests/test_unetr.py | 51 ++++++++-------- tests/test_unetr_block.py | 48 +++++++-------- tests/test_vit.py | 29 +++++---- 14 files changed, 180 insertions(+), 182 deletions(-) diff --git a/monai/networks/blocks/mlp.py b/monai/networks/blocks/mlp.py index b108188605..11b5e6fc15 100644 --- a/monai/networks/blocks/mlp.py +++ b/monai/networks/blocks/mlp.py @@ -35,7 +35,7 @@ def __init__( super().__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") self.linear1 = nn.Linear(hidden_size, mlp_dim) self.linear2 = nn.Linear(mlp_dim, hidden_size) diff --git a/monai/networks/blocks/patchembedding.py b/monai/networks/blocks/patchembedding.py index 1f312e9126..c1fcfa9af7 100644 --- a/monai/networks/blocks/patchembedding.py +++ b/monai/networks/blocks/patchembedding.py @@ -11,31 +11,42 @@ import math -from typing import Tuple, Union +from typing import Sequence, Union +import numpy as np import torch import torch.nn as nn -from monai.utils import optional_import +from monai.networks.layers import Conv +from monai.utils import ensure_tuple_rep, optional_import +from monai.utils.module import look_up_option Rearrange, _ = optional_import("einops.layers.torch", name="Rearrange") +SUPPORTED_EMBEDDING_TYPES = {"conv", "perceptron"} class PatchEmbeddingBlock(nn.Module): """ A patch embedding block, based on: "Dosovitskiy et al., An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale " + + Example:: + + >>> from monai.networks.blocks import PatchEmbeddingBlock + >>> PatchEmbeddingBlock(in_channels=4, img_size=32, patch_size=8, hidden_size=32, num_heads=4, pos_embed="conv") + """ def __init__( self, in_channels: int, - img_size: Tuple[int, int, int], - patch_size: Tuple[int, int, int], + img_size: Union[Sequence[int], int], + patch_size: Union[Sequence[int], int], hidden_size: int, num_heads: int, pos_embed: str, dropout_rate: float = 0.0, + spatial_dims: int = 3, ) -> None: """ Args: @@ -46,47 +57,44 @@ def __init__( num_heads: number of attention heads. pos_embed: position embedding layer type. dropout_rate: faction of the input units to drop. + spatial_dims: number of spatial dimensions. + """ - super().__init__() + super(PatchEmbeddingBlock, self).__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") if hidden_size % num_heads != 0: - raise AssertionError("hidden size should be divisible by num_heads.") + raise ValueError("hidden size should be divisible by num_heads.") + + self.pos_embed = look_up_option(pos_embed, SUPPORTED_EMBEDDING_TYPES) + img_size = ensure_tuple_rep(img_size, spatial_dims) + patch_size = ensure_tuple_rep(patch_size, spatial_dims) for m, p in zip(img_size, patch_size): if m < p: - raise AssertionError("patch_size should be smaller than img_size.") + raise ValueError("patch_size should be smaller than img_size.") + if self.pos_embed == "perceptron" and m % p != 0: + raise ValueError("patch_size should be divisible by img_size for perceptron.") + self.n_patches = np.prod([im_d // p_d for im_d, p_d in zip(img_size, patch_size)]) + self.patch_dim = in_channels * np.prod(patch_size) - if pos_embed not in ["conv", "perceptron"]: - raise KeyError(f"Position embedding layer of type {pos_embed} is not supported.") - - if pos_embed == "perceptron": - if img_size[0] % patch_size[0] != 0: - raise AssertionError("img_size should be divisible by patch_size for perceptron patch embedding.") - - self.n_patches = ( - (img_size[0] // patch_size[0]) * (img_size[1] // patch_size[1]) * (img_size[2] // patch_size[2]) - ) - self.patch_dim = in_channels * patch_size[0] * patch_size[1] * patch_size[2] - - self.pos_embed = pos_embed - self.patch_embeddings: Union[nn.Conv3d, nn.Sequential] + self.patch_embeddings: nn.Module if self.pos_embed == "conv": - self.patch_embeddings = nn.Conv3d( + self.patch_embeddings = Conv[Conv.CONV, spatial_dims]( in_channels=in_channels, out_channels=hidden_size, kernel_size=patch_size, stride=patch_size ) elif self.pos_embed == "perceptron": + # for 3d: "b c (h p1) (w p2) (d p3)-> b (h w d) (p1 p2 p3 c)" + chars = (("h", "p1"), ("w", "p2"), ("d", "p3"))[:spatial_dims] + from_chars = "b c " + " ".join(f"({k} {v})" for k, v in chars) + to_chars = f"b ({' '.join([c[0] for c in chars])}) ({' '.join([c[1] for c in chars])} c)" + axes_len = {f"p{i+1}": p for i, p in enumerate(patch_size)} self.patch_embeddings = nn.Sequential( - Rearrange( - "b c (h p1) (w p2) (d p3)-> b (h w d) (p1 p2 p3 c)", - p1=patch_size[0], - p2=patch_size[1], - p3=patch_size[2], - ), + Rearrange(f"{from_chars} -> {to_chars}", **axes_len), nn.Linear(self.patch_dim, hidden_size), ) self.position_embeddings = nn.Parameter(torch.zeros(1, self.n_patches, hidden_size)) @@ -121,12 +129,9 @@ def norm_cdf(x): return tensor def forward(self, x): + x = self.patch_embeddings(x) if self.pos_embed == "conv": - x = self.patch_embeddings(x) - x = x.flatten(2) - x = x.transpose(-1, -2) - elif self.pos_embed == "perceptron": - x = self.patch_embeddings(x) + x = x.flatten(2).transpose(-1, -2) embeddings = x + self.position_embeddings embeddings = self.dropout(embeddings) return embeddings diff --git a/monai/networks/blocks/selfattention.py b/monai/networks/blocks/selfattention.py index bd5bbfa072..9dc45cccc8 100644 --- a/monai/networks/blocks/selfattention.py +++ b/monai/networks/blocks/selfattention.py @@ -14,7 +14,7 @@ from monai.utils import optional_import -einops, has_einops = optional_import("einops") +einops, _ = optional_import("einops") class SABlock(nn.Module): @@ -37,13 +37,13 @@ def __init__( """ - super().__init__() + super(SABlock, self).__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") if hidden_size % num_heads != 0: - raise AssertionError("hidden size should be divisible by num_heads.") + raise ValueError("hidden size should be divisible by num_heads.") self.num_heads = num_heads self.out_proj = nn.Linear(hidden_size, hidden_size) @@ -52,17 +52,13 @@ def __init__( self.drop_weights = nn.Dropout(dropout_rate) self.head_dim = hidden_size // num_heads self.scale = self.head_dim ** -0.5 - if has_einops: - self.rearrange = einops.rearrange - else: - raise ValueError('"Requires einops.') def forward(self, x): - q, k, v = self.rearrange(self.qkv(x), "b h (qkv l d) -> qkv b l h d", qkv=3, l=self.num_heads) + q, k, v = einops.rearrange(self.qkv(x), "b h (qkv l d) -> qkv b l h d", qkv=3, l=self.num_heads) att_mat = (torch.einsum("blxd,blyd->blxy", q, k) * self.scale).softmax(dim=-1) att_mat = self.drop_weights(att_mat) x = torch.einsum("bhxy,bhyd->bhxd", att_mat, v) - x = self.rearrange(x, "b h l d -> b l (h d)") + x = einops.rearrange(x, "b h l d -> b l (h d)") x = self.out_proj(x) x = self.drop_output(x) return x diff --git a/monai/networks/blocks/transformerblock.py b/monai/networks/blocks/transformerblock.py index 3dd80f58ad..c7a948ed76 100644 --- a/monai/networks/blocks/transformerblock.py +++ b/monai/networks/blocks/transformerblock.py @@ -40,10 +40,10 @@ def __init__( super().__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") if hidden_size % num_heads != 0: - raise AssertionError("hidden size should be divisible by num_heads.") + raise ValueError("hidden_size should be divisible by num_heads.") self.mlp = MLPBlock(hidden_size, mlp_dim, dropout_rate) self.norm1 = nn.LayerNorm(hidden_size) diff --git a/monai/networks/blocks/unetr_block.py b/monai/networks/blocks/unetr_block.py index 20c39f6240..a0852d05e0 100644 --- a/monai/networks/blocks/unetr_block.py +++ b/monai/networks/blocks/unetr_block.py @@ -28,9 +28,8 @@ def __init__( self, spatial_dims: int, in_channels: int, - out_channels: int, # type: ignore + out_channels: int, kernel_size: Union[Sequence[int], int], - stride: Union[Sequence[int], int], upsample_kernel_size: Union[Sequence[int], int], norm_name: Union[Tuple, str], res_block: bool = False, @@ -41,7 +40,6 @@ def __init__( in_channels: number of input channels. out_channels: number of output channels. kernel_size: convolution kernel size. - stride: convolution stride. upsample_kernel_size: convolution kernel size for transposed convolution layers. norm_name: feature normalization type and arguments. res_block: bool argument to determine if residual block is used. @@ -148,7 +146,7 @@ def __init__( is_transposed=True, ), UnetResBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=out_channels, out_channels=out_channels, kernel_size=kernel_size, @@ -173,7 +171,7 @@ def __init__( is_transposed=True, ), UnetBasicBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=out_channels, out_channels=out_channels, kernel_size=kernel_size, @@ -257,5 +255,4 @@ def __init__( ) def forward(self, inp): - out = self.layer(inp) - return out + return self.layer(inp) diff --git a/monai/networks/nets/unetr.py b/monai/networks/nets/unetr.py index 1ac9c9ee49..ed49847515 100644 --- a/monai/networks/nets/unetr.py +++ b/monai/networks/nets/unetr.py @@ -9,13 +9,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Tuple, Union +from typing import Sequence, Tuple, Union import torch.nn as nn from monai.networks.blocks.dynunet_block import UnetOutBlock from monai.networks.blocks.unetr_block import UnetrBasicBlock, UnetrPrUpBlock, UnetrUpBlock from monai.networks.nets.vit import ViT +from monai.utils import ensure_tuple_rep class UNETR(nn.Module): @@ -28,7 +29,7 @@ def __init__( self, in_channels: int, out_channels: int, - img_size: Tuple[int, int, int], + img_size: Union[Sequence[int], int], feature_size: int = 16, hidden_size: int = 768, mlp_dim: int = 3072, @@ -38,6 +39,7 @@ def __init__( conv_block: bool = False, res_block: bool = True, dropout_rate: float = 0.0, + spatial_dims: int = 3, ) -> None: """ Args: @@ -53,35 +55,33 @@ def __init__( conv_block: bool argument to determine if convolutional block is used. res_block: bool argument to determine if residual block is used. dropout_rate: faction of the input units to drop. + spatial_dims: number of spatial dims. Examples:: # for single channel input 4-channel output with patch size of (96,96,96), feature size of 32 and batch norm >>> net = UNETR(in_channels=1, out_channels=4, img_size=(96,96,96), feature_size=32, norm_name='batch') + # for single channel input 4-channel output with patch size of (96,96), feature size of 32 and batch norm + >>> net = UNETR(in_channels=1, out_channels=4, img_size=96, feature_size=32, norm_name='batch', spatial_dims=2) + # for 4-channel input 3-channel output with patch size of (128,128,128), conv position embedding and instance norm >>> net = UNETR(in_channels=4, out_channels=3, img_size=(128,128,128), pos_embed='conv', norm_name='instance') """ - super().__init__() + super(UNETR, self).__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") if hidden_size % num_heads != 0: - raise AssertionError("hidden size should be divisible by num_heads.") - - if pos_embed not in ["conv", "perceptron"]: - raise KeyError(f"Position embedding layer of type {pos_embed} is not supported.") + raise ValueError("hidden_size should be divisible by num_heads.") self.num_layers = 12 - self.patch_size = (16, 16, 16) - self.feat_size = ( - img_size[0] // self.patch_size[0], - img_size[1] // self.patch_size[1], - img_size[2] // self.patch_size[2], - ) + img_size = ensure_tuple_rep(img_size, spatial_dims) + self.patch_size = ensure_tuple_rep(16, spatial_dims) + self.feat_size = tuple(img_d // p_d for img_d, p_d in zip(img_size, self.patch_size)) self.hidden_size = hidden_size self.classification = False self.vit = ViT( @@ -95,9 +95,10 @@ def __init__( pos_embed=pos_embed, classification=self.classification, dropout_rate=dropout_rate, + spatial_dims=spatial_dims, ) self.encoder1 = UnetrBasicBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=in_channels, out_channels=feature_size, kernel_size=3, @@ -106,7 +107,7 @@ def __init__( res_block=res_block, ) self.encoder2 = UnetrPrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=hidden_size, out_channels=feature_size * 2, num_layer=2, @@ -118,7 +119,7 @@ def __init__( res_block=res_block, ) self.encoder3 = UnetrPrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=hidden_size, out_channels=feature_size * 4, num_layer=1, @@ -130,7 +131,7 @@ def __init__( res_block=res_block, ) self.encoder4 = UnetrPrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=hidden_size, out_channels=feature_size * 8, num_layer=0, @@ -142,50 +143,48 @@ def __init__( res_block=res_block, ) self.decoder5 = UnetrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=hidden_size, out_channels=feature_size * 8, - stride=1, kernel_size=3, upsample_kernel_size=2, norm_name=norm_name, res_block=res_block, ) self.decoder4 = UnetrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=feature_size * 8, out_channels=feature_size * 4, - stride=1, kernel_size=3, upsample_kernel_size=2, norm_name=norm_name, res_block=res_block, ) self.decoder3 = UnetrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=feature_size * 4, out_channels=feature_size * 2, - stride=1, kernel_size=3, upsample_kernel_size=2, norm_name=norm_name, res_block=res_block, ) self.decoder2 = UnetrUpBlock( - spatial_dims=3, + spatial_dims=spatial_dims, in_channels=feature_size * 2, out_channels=feature_size, - stride=1, kernel_size=3, upsample_kernel_size=2, norm_name=norm_name, res_block=res_block, ) - self.out = UnetOutBlock(spatial_dims=3, in_channels=feature_size, out_channels=out_channels) # type: ignore + self.out = UnetOutBlock(spatial_dims=spatial_dims, in_channels=feature_size, out_channels=out_channels) def proj_feat(self, x, hidden_size, feat_size): - x = x.view(x.size(0), feat_size[0], feat_size[1], feat_size[2], hidden_size) - x = x.permute(0, 4, 1, 2, 3).contiguous() + new_view = (x.size(0), *feat_size, hidden_size) + x = x.view(new_view) + new_axes = (0, len(x.shape) - 1) + tuple(d + 1 for d in range(len(feat_size))) + x = x.permute(new_axes).contiguous() return x def forward(self, x_in): @@ -202,5 +201,4 @@ def forward(self, x_in): dec2 = self.decoder4(dec3, enc3) dec1 = self.decoder3(dec2, enc2) out = self.decoder2(dec1, enc1) - logits = self.out(out) - return logits + return self.out(out) diff --git a/monai/networks/nets/vit.py b/monai/networks/nets/vit.py index 3e90a36757..0fd55cac62 100644 --- a/monai/networks/nets/vit.py +++ b/monai/networks/nets/vit.py @@ -10,7 +10,7 @@ # limitations under the License. -from typing import Tuple +from typing import Sequence, Union import torch.nn as nn @@ -27,8 +27,8 @@ class ViT(nn.Module): def __init__( self, in_channels: int, - img_size: Tuple[int, int, int], - patch_size: Tuple[int, int, int], + img_size: Union[Sequence[int], int], + patch_size: Union[Sequence[int], int], hidden_size: int = 768, mlp_dim: int = 3072, num_layers: int = 12, @@ -37,6 +37,7 @@ def __init__( classification: bool = False, num_classes: int = 2, dropout_rate: float = 0.0, + spatial_dims: int = 3, ) -> None: """ Args: @@ -51,6 +52,7 @@ def __init__( classification: bool argument to determine if classification is used. num_classes: number of classes if classification is used. dropout_rate: faction of the input units to drop. + spatial_dims: number of spatial dimensions. Examples:: @@ -58,24 +60,28 @@ def __init__( >>> net = ViT(in_channels=1, img_size=(96,96,96), pos_embed='conv') # for 3-channel with patch size of (128,128,128), 24 layers and classification backbone - >>> net = ViT(in_channels=3, img_size=(128,128,128), pos_embed='conv', classification= True) + >>> net = ViT(in_channels=3, img_size=(128,128,128), pos_embed='conv', classification=True) """ - super().__init__() + super(ViT, self).__init__() if not (0 <= dropout_rate <= 1): - raise AssertionError("dropout_rate should be between 0 and 1.") + raise ValueError("dropout_rate should be between 0 and 1.") if hidden_size % num_heads != 0: - raise AssertionError("hidden size should be divisible by num_heads.") - - if pos_embed not in ["conv", "perceptron"]: - raise KeyError(f"Position embedding layer of type {pos_embed} is not supported.") + raise ValueError("hidden_size should be divisible by num_heads.") self.classification = classification self.patch_embedding = PatchEmbeddingBlock( - in_channels, img_size, patch_size, hidden_size, num_heads, pos_embed, dropout_rate + in_channels=in_channels, + img_size=img_size, + patch_size=patch_size, + hidden_size=hidden_size, + num_heads=num_heads, + pos_embed=pos_embed, + dropout_rate=dropout_rate, + spatial_dims=spatial_dims, ) self.blocks = nn.ModuleList( [TransformerBlock(hidden_size, mlp_dim, num_heads, dropout_rate) for i in range(num_layers)] diff --git a/tests/test_mlp.py b/tests/test_mlp.py index efc8db74c2..7a93f81ec3 100644 --- a/tests/test_mlp.py +++ b/tests/test_mlp.py @@ -44,7 +44,7 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): MLPBlock(hidden_size=128, mlp_dim=512, dropout_rate=5.0) diff --git a/tests/test_patchembedding.py b/tests/test_patchembedding.py index 5283153880..6c9ac78a99 100644 --- a/tests/test_patchembedding.py +++ b/tests/test_patchembedding.py @@ -12,7 +12,6 @@ import unittest from unittest import skipUnless -import numpy as np import torch from parameterized import parameterized @@ -23,31 +22,30 @@ einops, has_einops = optional_import("einops") TEST_CASE_PATCHEMBEDDINGBLOCK = [] -for dropout_rate in np.linspace(0, 1, 2): +for dropout_rate in (0.5,): for in_channels in [1, 4]: for hidden_size in [360, 768]: for img_size in [96, 128]: for patch_size in [8, 16]: for num_heads in [8, 12]: for pos_embed in ["conv", "perceptron"]: - for classification in ["False", "True"]: - if classification: - out = (2, (img_size // patch_size) ** 3 + 1, hidden_size) - else: - out = (2, (img_size // patch_size) ** 3, hidden_size) + # for classification in (False, True): # TODO: add classification tests + for nd in (2, 3): test_case = [ { "in_channels": in_channels, - "img_size": (img_size, img_size, img_size), - "patch_size": (patch_size, patch_size, patch_size), + "img_size": (img_size,) * nd, + "patch_size": (patch_size,) * nd, "hidden_size": hidden_size, "num_heads": num_heads, "pos_embed": pos_embed, "dropout_rate": dropout_rate, }, - (2, in_channels, img_size, *([img_size] * 2)), - (2, (img_size // patch_size) ** 3, hidden_size), + (2, in_channels, *([img_size] * nd)), + (2, (img_size // patch_size) ** nd, hidden_size), ] + if nd == 2: + test_case[0]["spatial_dims"] = 2 # type: ignore TEST_CASE_PATCHEMBEDDINGBLOCK.append(test_case) @@ -61,7 +59,7 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): PatchEmbeddingBlock( in_channels=1, img_size=(128, 128, 128), @@ -72,7 +70,7 @@ def test_ill_arg(self): dropout_rate=5.0, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): PatchEmbeddingBlock( in_channels=1, img_size=(32, 32, 32), @@ -83,7 +81,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): PatchEmbeddingBlock( in_channels=1, img_size=(96, 96, 96), @@ -94,7 +92,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): PatchEmbeddingBlock( in_channels=1, img_size=(97, 97, 97), @@ -105,7 +103,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(KeyError): + with self.assertRaises(ValueError): PatchEmbeddingBlock( in_channels=4, img_size=(96, 96, 96), diff --git a/tests/test_selfattention.py b/tests/test_selfattention.py index 2430b82c9b..3d561aac2f 100644 --- a/tests/test_selfattention.py +++ b/tests/test_selfattention.py @@ -49,10 +49,10 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): SABlock(hidden_size=128, num_heads=12, dropout_rate=6.0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): SABlock(hidden_size=620, num_heads=8, dropout_rate=0.4) diff --git a/tests/test_transformerblock.py b/tests/test_transformerblock.py index 24d16c77aa..616e3e7ec9 100644 --- a/tests/test_transformerblock.py +++ b/tests/test_transformerblock.py @@ -46,10 +46,10 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): TransformerBlock(hidden_size=128, num_heads=12, mlp_dim=2048, dropout_rate=4.0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): TransformerBlock(hidden_size=622, num_heads=8, mlp_dim=3072, dropout_rate=0.4) diff --git a/tests/test_unetr.py b/tests/test_unetr.py index cd50cb487c..d19ed2ca59 100644 --- a/tests/test_unetr.py +++ b/tests/test_unetr.py @@ -28,27 +28,28 @@ for mlp_dim in [3072]: for norm_name in ["instance"]: for pos_embed in ["perceptron"]: - for conv_block in [True]: - for res_block in [False]: - test_case = [ - { - "in_channels": in_channels, - "out_channels": out_channels, - "img_size": (img_size, img_size, img_size), - "hidden_size": hidden_size, - "feature_size": feature_size, - "norm_name": norm_name, - "mlp_dim": mlp_dim, - "num_heads": num_heads, - "pos_embed": pos_embed, - "dropout_rate": dropout_rate, - "conv_block": conv_block, - "res_block": res_block, - }, - (2, in_channels, img_size, *([img_size] * 2)), - (2, out_channels, img_size, *([img_size] * 2)), - ] - TEST_CASE_UNETR.append(test_case) + for nd in (2, 3): + test_case = [ + { + "in_channels": in_channels, + "out_channels": out_channels, + "img_size": (img_size,) * nd, + "hidden_size": hidden_size, + "feature_size": feature_size, + "norm_name": norm_name, + "mlp_dim": mlp_dim, + "num_heads": num_heads, + "pos_embed": pos_embed, + "dropout_rate": dropout_rate, + "conv_block": True, + "res_block": False, + }, + (2, in_channels, *([img_size] * nd)), + (2, out_channels, *([img_size] * nd)), + ] + if nd == 2: + test_case[0]["spatial_dims"] = 2 # type: ignore + TEST_CASE_UNETR.append(test_case) class TestPatchEmbeddingBlock(unittest.TestCase): @@ -60,7 +61,7 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): UNETR( in_channels=1, out_channels=3, @@ -74,7 +75,7 @@ def test_ill_arg(self): dropout_rate=5.0, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): UNETR( in_channels=1, out_channels=4, @@ -88,7 +89,7 @@ def test_ill_arg(self): dropout_rate=0.5, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): UNETR( in_channels=1, out_channels=3, @@ -102,7 +103,7 @@ def test_ill_arg(self): dropout_rate=0.4, ) - with self.assertRaises(KeyError): + with self.assertRaises(ValueError): UNETR( in_channels=1, out_channels=4, diff --git a/tests/test_unetr_block.py b/tests/test_unetr_block.py index 0b22838fae..7546918a2c 100644 --- a/tests/test_unetr_block.py +++ b/tests/test_unetr_block.py @@ -20,7 +20,7 @@ from tests.utils import test_script_save TEST_CASE_UNETR_BASIC_BLOCK = [] -for spatial_dims in range(2, 4): +for spatial_dims in range(1, 4): for kernel_size in [1, 3]: for stride in [2]: for norm_name in [("GROUP", {"num_groups": 16}), ("batch", {"track_running_stats": False}), "instance"]: @@ -45,34 +45,32 @@ TEST_UP_BLOCK = [] in_channels, out_channels = 4, 2 -for spatial_dims in range(2, 4): +for spatial_dims in range(1, 4): for kernel_size in [1, 3]: - for stride in [1, 2]: - for res_block in [False, True]: - for norm_name in ["instance"]: - for in_size in [15, 16]: - out_size = in_size * stride - test_case = [ - { - "spatial_dims": spatial_dims, - "in_channels": in_channels, - "out_channels": out_channels, - "kernel_size": kernel_size, - "norm_name": norm_name, - "stride": stride, - "res_block": res_block, - "upsample_kernel_size": stride, - }, - (1, in_channels, *([in_size] * spatial_dims)), - (1, out_channels, *([out_size] * spatial_dims)), - (1, out_channels, *([in_size * stride] * spatial_dims)), - ] - TEST_UP_BLOCK.append(test_case) + for res_block in [False, True]: + for norm_name in ["instance"]: + for in_size in [15, 16]: + out_size = in_size * stride + test_case = [ + { + "spatial_dims": spatial_dims, + "in_channels": in_channels, + "out_channels": out_channels, + "kernel_size": kernel_size, + "norm_name": norm_name, + "res_block": res_block, + "upsample_kernel_size": stride, + }, + (1, in_channels, *([in_size] * spatial_dims)), + (1, out_channels, *([out_size] * spatial_dims)), + (1, out_channels, *([in_size * stride] * spatial_dims)), + ] + TEST_UP_BLOCK.append(test_case) TEST_PRUP_BLOCK = [] in_channels, out_channels = 4, 2 -for spatial_dims in range(2, 4): +for spatial_dims in range(1, 4): for kernel_size in [1, 3]: for upsample_kernel_size in [2, 3]: for stride in [1, 2]: @@ -81,7 +79,7 @@ for in_size in [15, 16]: for num_layer in [0, 2]: in_size_tmp = in_size - for _num in range(num_layer + 1): + for _ in range(num_layer + 1): out_size = in_size_tmp * upsample_kernel_size in_size_tmp = out_size test_case = [ diff --git a/tests/test_vit.py b/tests/test_vit.py index 0d0d58093b..0dce73b0cb 100644 --- a/tests/test_vit.py +++ b/tests/test_vit.py @@ -28,28 +28,27 @@ for num_layers in [4]: for num_classes in [2]: for pos_embed in ["conv"]: - for classification in ["False"]: - if classification: - out = (2, num_classes) - else: - out = (2, (img_size // patch_size) ** 3, hidden_size) # type: ignore + # for classification in [False, True]: # TODO: test classification + for nd in (2, 3): test_case = [ { "in_channels": in_channels, - "img_size": (img_size, img_size, img_size), - "patch_size": (patch_size, patch_size, patch_size), + "img_size": (img_size,) * nd, + "patch_size": (patch_size,) * nd, "hidden_size": hidden_size, "mlp_dim": mlp_dim, "num_layers": num_layers, "num_heads": num_heads, "pos_embed": pos_embed, - "classification": classification, + "classification": False, "num_classes": num_classes, "dropout_rate": dropout_rate, }, - (2, in_channels, img_size, *([img_size] * 2)), - out, + (2, in_channels, *([img_size] * nd)), + (2, (img_size // patch_size) ** nd, hidden_size), ] + if nd == 2: + test_case[0]["spatial_dims"] = 2 # type: ignore TEST_CASE_Vit.append(test_case) @@ -62,7 +61,7 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(result.shape, expected_shape) def test_ill_arg(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): ViT( in_channels=1, img_size=(128, 128, 128), @@ -76,7 +75,7 @@ def test_ill_arg(self): dropout_rate=5.0, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): ViT( in_channels=1, img_size=(32, 32, 32), @@ -90,7 +89,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): ViT( in_channels=1, img_size=(96, 96, 96), @@ -104,7 +103,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): ViT( in_channels=1, img_size=(97, 97, 97), @@ -118,7 +117,7 @@ def test_ill_arg(self): dropout_rate=0.3, ) - with self.assertRaises(KeyError): + with self.assertRaises(ValueError): ViT( in_channels=4, img_size=(96, 96, 96), From 945e21cbbb9fea1b04ed3c76b7189a5296b5defa Mon Sep 17 00:00:00 2001 From: Sebastian Penhouet Date: Sat, 7 Aug 2021 15:38:02 +0200 Subject: [PATCH 20/89] [2678] Add transform to fill holes and to filter (#2692) * Add transform to fill holes and to filter (#2678) Signed-off-by: Sebastian Penhouet * Change name of label filter class (#2678) Signed-off-by: Sebastian Penhouet * Change fill holes to growing logic (#2678) Signed-off-by: Sebastian Penhouet * Fix missing entry in min_tests (#2678) Signed-off-by: Sebastian Penhouet * Fix review comments (#2678) Signed-off-by: Sebastian Penhouet * Remove batch dim and add one-hot handling (#2678) Signed-off-by: Sebastian Penhouet * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: Sebastian Penhouet --- docs/source/transforms.rst | 24 ++ monai/transforms/__init__.py | 26 +- monai/transforms/post/array.py | 140 ++++++++- monai/transforms/post/dictionary.py | 104 +++++- monai/transforms/utils.py | 111 +++++-- tests/min_tests.py | 74 ++--- tests/test_fill_holes.py | 297 ++++++++++++++++++ .../test_keep_largest_connected_component.py | 21 +- tests/test_label_filter.py | 127 ++++++++ tests/utils.py | 38 +++ 10 files changed, 864 insertions(+), 98 deletions(-) create mode 100644 tests/test_fill_holes.py create mode 100644 tests/test_label_filter.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 6a25c62c49..8a880ff151 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -356,6 +356,18 @@ Post-processing :members: :special-members: __call__ +`LabelFilter` +""""""""""""" +.. autoclass:: LabelFilter + :members: + :special-members: __call__ + +`FillHoles` +""""""""""" +.. autoclass:: FillHoles + :members: + :special-members: __call__ + `LabelToContour` """""""""""""""" .. autoclass:: LabelToContour @@ -955,6 +967,18 @@ Post-processing (Dict) :members: :special-members: __call__ +`LabelFilterd` +"""""""""""""" +.. autoclass:: LabelFilterd + :members: + :special-members: __call__ + +`FillHolesd` +"""""""""""" +.. autoclass:: FillHolesd + :members: + :special-members: __call__ + `LabelToContourd` """"""""""""""""" .. autoclass:: LabelToContourd diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index cf9198dbf5..7f2873cc85 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -194,40 +194,48 @@ from .post.array import ( Activations, AsDiscrete, + FillHoles, KeepLargestConnectedComponent, + LabelFilter, LabelToContour, MeanEnsemble, ProbNMS, VoteEnsemble, ) from .post.dictionary import ( - Activationsd, ActivationsD, + Activationsd, ActivationsDict, - AsDiscreted, AsDiscreteD, + AsDiscreted, AsDiscreteDict, Ensembled, - Invertd, + FillHolesD, + FillHolesd, + FillHolesDict, InvertD, + Invertd, InvertDict, - KeepLargestConnectedComponentd, KeepLargestConnectedComponentD, + KeepLargestConnectedComponentd, KeepLargestConnectedComponentDict, - LabelToContourd, + LabelFilterD, + LabelFilterd, + LabelFilterDict, LabelToContourD, + LabelToContourd, LabelToContourDict, - MeanEnsembled, MeanEnsembleD, + MeanEnsembled, MeanEnsembleDict, - ProbNMSd, ProbNMSD, + ProbNMSd, ProbNMSDict, - SaveClassificationd, SaveClassificationD, + SaveClassificationd, SaveClassificationDict, - VoteEnsembled, VoteEnsembleD, + VoteEnsembled, VoteEnsembleDict, ) from .spatial.array import ( diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 397b14e2e2..a33fce785e 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -14,26 +14,29 @@ """ import warnings -from typing import Callable, Optional, Sequence, Union +from typing import Callable, Iterable, Optional, Sequence, Union import numpy as np import torch import torch.nn.functional as F +from monai.config import NdarrayTensor from monai.networks import one_hot from monai.networks.layers import GaussianFilter from monai.transforms.transform import Transform -from monai.transforms.utils import get_largest_connected_component_mask +from monai.transforms.utils import fill_holes, get_largest_connected_component_mask from monai.utils import ensure_tuple __all__ = [ "Activations", "AsDiscrete", + "FillHoles", "KeepLargestConnectedComponent", + "LabelFilter", "LabelToContour", "MeanEnsemble", - "VoteEnsemble", "ProbNMS", + "VoteEnsemble", ] @@ -289,6 +292,137 @@ def __call__(self, img: torch.Tensor) -> torch.Tensor: return output +class LabelFilter: + """ + This transform filters out labels and can be used as a processing step to view only certain labels. + + The list of applied labels defines which labels will be kept. + + Note: + All labels which do not match the `applied_labels` are set to the background label (0). + + For example: + + Use LabelFilter with applied_labels=[1, 5, 9]:: + + [1, 2, 3] [1, 0, 0] + [4, 5, 6] => [0, 5 ,0] + [7, 8, 9] [0, 0, 9] + """ + + def __init__(self, applied_labels: Union[Iterable[int], int]) -> None: + """ + Initialize the LabelFilter class with the labels to filter on. + + Args: + applied_labels: Label(s) to filter on. + """ + self.applied_labels = ensure_tuple(applied_labels) + + def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + """ + Filter the image on the `applied_labels`. + + Args: + img: Pytorch tensor or numpy array of any shape. + + Raises: + NotImplementedError: The provided image was not a Pytorch Tensor or numpy array. + + Returns: + Pytorch tensor or numpy array of the same shape as the input. + """ + if isinstance(img, np.ndarray): + return np.asarray(np.where(np.isin(img, self.applied_labels), img, 0)) + elif isinstance(img, torch.Tensor): + img_arr = img.detach().cpu().numpy() + img_arr = self(img_arr) + return torch.as_tensor(img_arr, device=img.device) + else: + raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") + + +class FillHoles(Transform): + r""" + This transform fills holes in the image and can be used to remove artifacts inside segments. + + An enclosed hole is defined as a background pixel/voxel which is only enclosed by a single class. + The definition of enclosed can be defined with the connectivity parameter:: + + 1-connectivity 2-connectivity diagonal connection close-up + + [ ] [ ] [ ] [ ] [ ] + | \ | / | <- hop 2 + [ ]--[x]--[ ] [ ]--[x]--[ ] [x]--[ ] + | / | \ hop 1 + [ ] [ ] [ ] [ ] + + It is possible to define for which labels the hole filling should be applied. + The input image is assumed to be a PyTorch Tensor or numpy array with shape [C, spatial_dim1[, spatial_dim2, ...]]. + If C = 1, then the values correspond to expected labels. + If C > 1, then a one-hot-encoding is expected where the index of C matches the label indexing. + + Note: + + The label 0 will be treated as background and the enclosed holes will be set to the neighboring class label. + + The performance of this method heavily depends on the number of labels. + It is a bit faster if the list of `applied_labels` is provided. + Limiting the number of `applied_labels` results in a big decrease in processing time. + + For example: + + Use FillHoles with default parameters:: + + [1, 1, 1, 2, 2, 2, 3, 3] [1, 1, 1, 2, 2, 2, 3, 3] + [1, 0, 1, 2, 0, 0, 3, 0] => [1, 1 ,1, 2, 0, 0, 3, 0] + [1, 1, 1, 2, 2, 2, 3, 3] [1, 1, 1, 2, 2, 2, 3, 3] + + The hole in label 1 is fully enclosed and therefore filled with label 1. + The background label near label 2 and 3 is not fully enclosed and therefore not filled. + """ + + def __init__( + self, applied_labels: Optional[Union[Iterable[int], int]] = None, connectivity: Optional[int] = None + ) -> None: + """ + Initialize the connectivity and limit the labels for which holes are filled. + + Args: + applied_labels: Labels for which to fill holes. Defaults to None, that is filling holes for all labels. + connectivity: Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. + Accepted values are ranging from 1 to input.ndim. Defaults to a full connectivity of ``input.ndim``. + """ + super().__init__() + self.applied_labels = ensure_tuple(applied_labels) if applied_labels else None + self.connectivity = connectivity + + def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + """ + Fill the holes in the provided image. + + Note: + The value 0 is assumed as background label. + + Args: + img: Pytorch Tensor or numpy array of shape [C, spatial_dim1[, spatial_dim2, ...]]. + + Raises: + NotImplementedError: The provided image was not a Pytorch Tensor or numpy array. + + Returns: + Pytorch Tensor or numpy array of shape [C, spatial_dim1[, spatial_dim2, ...]]. + """ + if isinstance(img, np.ndarray): + return fill_holes(img, self.applied_labels, self.connectivity) + elif isinstance(img, torch.Tensor): + img_arr = img.detach().cpu().numpy() + img_arr = self(img_arr) + return torch.as_tensor(img_arr, device=img.device) + else: + raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") + + class LabelToContour(Transform): """ Return the contour of binary input images that only compose of 0 and 1, with Laplace kernel diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 6cba08948b..0d9be131fc 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -17,18 +17,20 @@ import warnings from copy import deepcopy -from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Union +from typing import Any, Callable, Dict, Hashable, Iterable, List, Mapping, Optional, Sequence, Union import numpy as np import torch -from monai.config import KeysCollection +from monai.config import KeysCollection, NdarrayTensor from monai.data.csv_saver import CSVSaver from monai.transforms.inverse import InvertibleTransform from monai.transforms.post.array import ( Activations, AsDiscrete, + FillHoles, KeepLargestConnectedComponent, + LabelFilter, LabelToContour, MeanEnsemble, ProbNMS, @@ -41,34 +43,40 @@ from monai.utils.enums import InverseKeys __all__ = [ - "Activationsd", - "AsDiscreted", - "KeepLargestConnectedComponentd", - "LabelToContourd", - "Ensembled", - "MeanEnsembled", - "VoteEnsembled", "ActivationsD", "ActivationsDict", + "Activationsd", "AsDiscreteD", "AsDiscreteDict", + "AsDiscreted", + "Ensembled", + "FillHolesD", + "FillHolesDict", + "FillHolesd", "InvertD", "InvertDict", "Invertd", "KeepLargestConnectedComponentD", "KeepLargestConnectedComponentDict", + "KeepLargestConnectedComponentd", + "LabelFilterD", + "LabelFilterDict", + "LabelFilterd", "LabelToContourD", "LabelToContourDict", + "LabelToContourd", "MeanEnsembleD", "MeanEnsembleDict", - "VoteEnsembleD", - "VoteEnsembleDict", - "ProbNMSd", + "MeanEnsembled", "ProbNMSD", "ProbNMSDict", - "SaveClassificationd", + "ProbNMSd", "SaveClassificationD", "SaveClassificationDict", + "SaveClassificationd", + "VoteEnsembleD", + "VoteEnsembleDict", + "VoteEnsembled", ] @@ -208,6 +216,70 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc return d +class LabelFilterd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.LabelFilter`. + """ + + def __init__( + self, + keys: KeysCollection, + applied_labels: Union[Sequence[int], int], + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + applied_labels: Label(s) to filter on. + allow_missing_keys: don't raise exception if key is missing. + + """ + super().__init__(keys, allow_missing_keys) + self.converter = LabelFilter(applied_labels) + + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.converter(d[key]) + return d + + +class FillHolesd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.FillHoles`. + """ + + def __init__( + self, + keys: KeysCollection, + applied_labels: Optional[Union[Iterable[int], int]] = None, + connectivity: Optional[int] = None, + allow_missing_keys: bool = False, + ) -> None: + """ + Initialize the connectivity and limit the labels for which holes are filled. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + applied_labels (Optional[Union[Iterable[int], int]], optional): Labels for which to fill holes. Defaults to None, + that is filling holes for all labels. + connectivity (int, optional): Maximum number of orthogonal hops to consider a pixel/voxel as a neighbor. + Accepted values are ranging from 1 to input.ndim. Defaults to a full + connectivity of ``input.ndim``. + allow_missing_keys: don't raise exception if key is missing. + """ + super().__init__(keys, allow_missing_keys) + self.converter = FillHoles(applied_labels=applied_labels, connectivity=connectivity) + + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.converter(d[key]) + return d + + class LabelToContourd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.LabelToContour`. @@ -620,10 +692,12 @@ def get_saver(self): ActivationsD = ActivationsDict = Activationsd AsDiscreteD = AsDiscreteDict = AsDiscreted +FillHolesD = FillHolesDict = FillHolesd +InvertD = InvertDict = Invertd KeepLargestConnectedComponentD = KeepLargestConnectedComponentDict = KeepLargestConnectedComponentd +LabelFilterD = LabelFilterDict = LabelFilterd LabelToContourD = LabelToContourDict = LabelToContourd MeanEnsembleD = MeanEnsembleDict = MeanEnsembled ProbNMSD = ProbNMSDict = ProbNMSd -VoteEnsembleD = VoteEnsembleDict = VoteEnsembled -InvertD = InvertDict = Invertd SaveClassificationD = SaveClassificationDict = SaveClassificationd +VoteEnsembleD = VoteEnsembleDict = VoteEnsembled diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 6dd2d2539f..366e2d245e 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -14,7 +14,7 @@ import re import warnings from contextlib import contextmanager -from typing import Callable, List, Optional, Sequence, Tuple, Union +from typing import Callable, Iterable, List, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -37,43 +37,45 @@ ) measure, _ = optional_import("skimage.measure", "0.14.2", min_version) +ndimage, _ = optional_import("scipy.ndimage") cp, has_cp = optional_import("cupy") cp_ndarray, _ = optional_import("cupy", name="ndarray") __all__ = [ - "rand_choice", - "img_bounds", - "in_bounds", - "is_empty", - "is_positive", - "zero_margins", - "rescale_array", - "rescale_instance_array", - "rescale_array_int_max", - "copypaste_arrays", + "allow_missing_keys_mode", "compute_divisible_spatial_size", - "resize_center", - "map_binary_to_indices", - "map_classes_to_indices", - "weighted_patch_samples", - "generate_pos_neg_label_crop_centers", - "generate_label_classes_crop_centers", - "create_grid", + "convert_inverse_interp_mode", + "convert_to_numpy", + "convert_to_tensor", + "copypaste_arrays", "create_control_grid", + "create_grid", "create_rotate", - "create_shear", "create_scale", + "create_shear", "create_translate", + "extreme_points_to_image", + "fill_holes", + "generate_label_classes_crop_centers", + "generate_pos_neg_label_crop_centers", "generate_spatial_bounding_box", - "get_largest_connected_component_mask", "get_extreme_points", - "extreme_points_to_image", + "get_largest_connected_component_mask", + "img_bounds", + "in_bounds", + "is_empty", + "is_positive", + "map_binary_to_indices", + "map_classes_to_indices", "map_spatial_axes", - "allow_missing_keys_mode", - "convert_inverse_interp_mode", - "convert_to_tensor", - "convert_to_numpy", + "rand_choice", + "rescale_array", + "rescale_array_int_max", + "rescale_instance_array", + "resize_center", "tensor_to_numpy", + "weighted_patch_samples", + "zero_margins", ] @@ -732,6 +734,65 @@ def get_largest_connected_component_mask(img: torch.Tensor, connectivity: Option return torch.as_tensor(largest_cc, device=img.device) +def fill_holes( + img_arr: np.ndarray, applied_labels: Optional[Iterable[int]] = None, connectivity: Optional[int] = None +) -> np.ndarray: + """ + Fill the holes in the provided image. + + The label 0 will be treated as background and the enclosed holes will be set to the neighboring class label. + What is considered to be an enclosed hole is defined by the connectivity. + Holes on the edge are always considered to be open (not enclosed). + + Note: + + The performance of this method heavily depends on the number of labels. + It is a bit faster if the list of `applied_labels` is provided. + Limiting the number of `applied_labels` results in a big decrease in processing time. + + If the image is one-hot-encoded, then the `applied_labels` need to match the channel index. + + Args: + img_arr: numpy array of shape [C, spatial_dim1[, spatial_dim2, ...]]. + applied_labels: Labels for which to fill holes. Defaults to None, + that is filling holes for all labels. + connectivity: Maximum number of orthogonal hops to + consider a pixel/voxel as a neighbor. Accepted values are ranging from 1 to input.ndim. + Defaults to a full connectivity of ``input.ndim``. + + Returns: + numpy array of shape [C, spatial_dim1[, spatial_dim2, ...]]. + """ + channel_axis = 0 + num_channels = img_arr.shape[channel_axis] + is_one_hot = num_channels > 1 + spatial_dims = img_arr.ndim - 1 + structure = ndimage.generate_binary_structure(spatial_dims, connectivity or spatial_dims) + + # Get labels if not provided. Exclude background label. + applied_labels = set(applied_labels or (range(num_channels) if is_one_hot else np.unique(img_arr))) + background_label = 0 + applied_labels.discard(background_label) + + for label in applied_labels: + tmp = np.zeros(img_arr.shape[1:], dtype=bool) + ndimage.binary_dilation( + tmp, + structure=structure, + iterations=-1, + mask=np.logical_not(img_arr[label]) if is_one_hot else img_arr[0] != label, + origin=0, + border_value=1, + output=tmp, + ) + if is_one_hot: + img_arr[label] = np.logical_not(tmp) + else: + img_arr[0, np.logical_not(tmp)] = label + + return img_arr + + def get_extreme_points( img: np.ndarray, rand_state: Optional[np.random.RandomState] = None, background: int = 0, pert: float = 0.0 ) -> List[Tuple[int, ...]]: diff --git a/tests/min_tests.py b/tests/min_tests.py index 1f53569cd9..afe88f7433 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -31,59 +31,81 @@ def run_testsuit(): "test_arraydataset", "test_cachedataset", "test_cachedataset_parallel", + "test_cachedataset_persistent_workers", + "test_cachentransdataset", + "test_csv_dataset", + "test_csv_iterable_dataset", "test_dataset", + "test_dataset_summary", + "test_deepgrow_dataset", + "test_deepgrow_interaction", + "test_deepgrow_transforms", "test_detect_envelope", "test_efficientnet", - "test_iterable_dataset", "test_ensemble_evaluator", + "test_ensure_channel_first", + "test_ensure_channel_firstd", + "test_fill_holes", "test_handler_checkpoint_loader", "test_handler_checkpoint_saver", "test_handler_classification_saver", - "test_handler_lr_scheduler", + "test_handler_classification_saver_dist", "test_handler_confusion_matrix", "test_handler_confusion_matrix_dist", - "test_handler_hausdorff_distance", + "test_handler_decollate_batch", + "test_handler_early_stop", "test_handler_garbage_collector", + "test_handler_hausdorff_distance", + "test_handler_lr_scheduler", "test_handler_mean_dice", + "test_handler_metrics_saver", + "test_handler_metrics_saver_dist", + "test_handler_parameter_scheduler", + "test_handler_post_processing", "test_handler_prob_map_producer", "test_handler_regression_metrics", "test_handler_regression_metrics_dist", "test_handler_rocauc", "test_handler_rocauc_dist", - "test_handler_parameter_scheduler", "test_handler_segmentation_saver", "test_handler_smartcache", "test_handler_stats", "test_handler_surface_distance", "test_handler_tb_image", "test_handler_tb_stats", + "test_handler_transform_inverter", "test_handler_validation", "test_hausdorff_distance", "test_header_correct", "test_hilbert_transform", + "test_image_dataset", "test_img2tensorboard", "test_integration_segmentation_3d", "test_integration_sliding_window", "test_integration_unet_2d", "test_integration_workflows", "test_integration_workflows_gan", + "test_invertd", + "test_iterable_dataset", "test_keep_largest_connected_component", "test_keep_largest_connected_componentd", + "test_label_filter", "test_lltm", "test_lmdbdataset", "test_load_image", "test_load_imaged", "test_load_spacing_orientation", "test_mednistdataset", - "test_image_dataset", + "test_mlp", "test_nifti_header_revise", "test_nifti_rw", "test_nifti_saver", + "test_occlusion_sensitivity", "test_orientation", "test_orientationd", "test_parallel_execution", + "test_patchembedding", "test_persistentdataset", - "test_cachentransdataset", "test_pil_reader", "test_plot_2d_or_3d_image", "test_png_rw", @@ -92,50 +114,30 @@ def run_testsuit(): "test_rand_rotated", "test_rand_zoom", "test_rand_zoomd", + "test_randtorchvisiond", "test_resize", "test_resized", "test_rotate", "test_rotated", + "test_save_image", + "test_save_imaged", + "test_selfattention", + "test_senet", "test_smartcachedataset", "test_spacing", "test_spacingd", - "test_senet", "test_surface_distance", - "test_zoom", - "test_zoom_affine", - "test_zoomd", - "test_occlusion_sensitivity", + "test_testtimeaugmentation", "test_torchvision", "test_torchvisiond", - "test_randtorchvisiond", - "test_handler_metrics_saver", - "test_handler_metrics_saver_dist", - "test_handler_classification_saver_dist", - "test_dataset_summary", - "test_deepgrow_transforms", - "test_deepgrow_interaction", - "test_deepgrow_dataset", - "test_save_image", - "test_save_imaged", - "test_ensure_channel_first", - "test_ensure_channel_firstd", - "test_handler_early_stop", - "test_handler_transform_inverter", - "test_testtimeaugmentation", - "test_cachedataset_persistent_workers", - "test_invertd", - "test_handler_post_processing", - "test_write_metrics_reports", - "test_csv_dataset", - "test_csv_iterable_dataset", - "test_mlp", - "test_patchembedding", - "test_selfattention", "test_transformerblock", "test_unetr", "test_unetr_block", "test_vit", - "test_handler_decollate_batch", + "test_write_metrics_reports", + "test_zoom", + "test_zoom_affine", + "test_zoomd", ] assert sorted(exclude_cases) == sorted(set(exclude_cases)), f"Duplicated items in {exclude_cases}" diff --git a/tests/test_fill_holes.py b/tests/test_fill_holes.py new file mode 100644 index 0000000000..294bbd8c87 --- /dev/null +++ b/tests/test_fill_holes.py @@ -0,0 +1,297 @@ +# Copyright 2020 - 2021 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 torch +from parameterized import parameterized + +from monai.transforms import FillHoles +from tests.utils import allclose, clone + +grid_1_raw = [ + [1, 1, 1], + [1, 0, 1], + [1, 1, 1], +] + +grid_2_raw = [ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0], +] + +grid_3_raw = [ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], +] + +grid_4_raw = [ + [0, 1, 0], + [1, 1, 1], + [0, 1, 0], +] + +grid_1 = torch.tensor([grid_1_raw]) + +grid_2 = torch.tensor([grid_2_raw]) + +grid_3 = torch.tensor([grid_3_raw]) + +grid_4 = torch.tensor([grid_4_raw]) + +grid_5 = torch.tensor( + [ + [ + [1, 1, 1], + [1, 0, 0], + [1, 1, 1], + ] + ] +) + +grid_6 = torch.tensor( + [ + [ + [1, 1, 2, 2, 2], + [1, 0, 2, 0, 2], + [1, 1, 2, 2, 2], + ] + ] +) + +grid_7 = torch.tensor( + [ + [ + [1, 1, 2, 2, 2], + [1, 0, 2, 2, 2], + [1, 1, 2, 2, 2], + ] + ] +) + +TEST_CASE_0 = [ + "enclosed_default_full_connectivity_default_applied_labels", + {}, + grid_1, + grid_3, +] + +TEST_CASE_1 = [ + "enclosed_full_connectivity_default_applied_labels", + {"connectivity": 2}, + grid_1, + grid_3, +] + +TEST_CASE_2 = [ + "enclosed_full_connectivity_applied_labels_same_single", + {"connectivity": 2, "applied_labels": 1}, + grid_1, + grid_3, +] + +TEST_CASE_3 = [ + "enclosed_full_connectivity_applied_labels_same_list", + {"connectivity": 2, "applied_labels": [1]}, + grid_1, + grid_3, +] + +TEST_CASE_4 = [ + "enclosed_full_connectivity_applied_labels_other_single", + {"connectivity": 2, "applied_labels": 2}, + grid_1, + grid_1, +] + +TEST_CASE_5 = [ + "enclosed_full_connectivity_applied_labels_other_list", + {"connectivity": 2, "applied_labels": [2]}, + grid_1, + grid_1, +] + +TEST_CASE_6 = [ + "enclosed_full_connectivity_applied_labels_same_and_other", + {"connectivity": 2, "applied_labels": [1, 2]}, + grid_1, + grid_3, +] + +TEST_CASE_7 = [ + "enclosed_connectivity_1_default_applied_labels", + {"connectivity": 1}, + grid_1, + grid_3, +] + +TEST_CASE_8 = [ + "enclosed_connectivity_1_default_applied_labels", + {"connectivity": 1}, + grid_2, + grid_4, +] + +TEST_CASE_9 = [ + "open_full_connectivity_default_applied_labels", + {"connectivity": 2}, + grid_2, + grid_2, +] + +TEST_CASE_10 = [ + "open_to_edge_connectivity_1_default_applied_labels", + {"connectivity": 1}, + grid_5, + grid_5, +] + +TEST_CASE_11 = [ + "open_to_other_label_connectivity_1_default_applied_labels", + {"connectivity": 1}, + grid_6, + grid_7, +] + +TEST_CASE_12 = [ + "open_to_other_label_connectivity_1_applied_labels_other", + {"connectivity": 1, "applied_labels": 1}, + grid_6, + grid_6, +] + +TEST_CASE_13 = [ + "numpy_enclosed_default_full_connectivity_default_applied_labels", + {}, + grid_1.cpu().numpy(), + grid_3.cpu().numpy(), +] + +TEST_CASE_14 = [ + "3D_enclosed_full_connectivity_default_applied_labels", + {"connectivity": 3}, + torch.tensor([[grid_3_raw, grid_1_raw, grid_3_raw]]), + torch.tensor([[grid_3_raw, grid_3_raw, grid_3_raw]]), +] + +TEST_CASE_15 = [ + "3D_enclosed_connectivity_1_default_applied_labels", + {"connectivity": 1}, + torch.tensor([[grid_4_raw, grid_2_raw, grid_4_raw]]), + torch.tensor([[grid_4_raw, grid_4_raw, grid_4_raw]]), +] + +TEST_CASE_16 = [ + "3D_open_full_connectivity_default_applied_labels", + {"connectivity": 3}, + torch.tensor([[grid_4_raw, grid_2_raw, grid_4_raw]]), + torch.tensor([[grid_4_raw, grid_2_raw, grid_4_raw]]), +] + +TEST_CASE_17 = [ + "3D_open_to_edge_connectivity_1_default_applied_labels", + {"connectivity": 1}, + torch.tensor([[grid_1_raw, grid_1_raw, grid_3_raw]]), + torch.tensor([[grid_1_raw, grid_1_raw, grid_3_raw]]), +] + +TEST_CASE_18 = [ + "enclosed_full_connectivity_applied_labels_with_background", + {"connectivity": 2, "applied_labels": [0, 1]}, + grid_1, + grid_3, +] + +TEST_CASE_19 = [ + "enclosed_full_connectivity_applied_labels_only_background", + {"connectivity": 2, "applied_labels": [0]}, + grid_1, + grid_1, +] + +TEST_CASE_20 = [ + "one-hot_enclosed_connectivity_1_default_applied_labels", + {"connectivity": 1}, + torch.tensor([grid_1_raw, grid_1_raw, grid_2_raw]), + torch.tensor([grid_1_raw, grid_3_raw, grid_4_raw]), +] + +TEST_CASE_21 = [ + "one-hot_enclosed_connectivity_1_applied_labels_2", + {"connectivity": 1, "applied_labels": [2]}, + torch.tensor([grid_1_raw, grid_1_raw, grid_2_raw]), + torch.tensor([grid_1_raw, grid_1_raw, grid_4_raw]), +] + +TEST_CASE_22 = [ + "one-hot_full_connectivity_applied_labels_2", + {"connectivity": 2}, + torch.tensor([grid_1_raw, grid_1_raw, grid_2_raw]), + torch.tensor([grid_1_raw, grid_3_raw, grid_2_raw]), +] + +VALID_CASES = [ + 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, + TEST_CASE_15, + TEST_CASE_16, + TEST_CASE_17, + TEST_CASE_18, + TEST_CASE_19, + TEST_CASE_20, + TEST_CASE_21, + TEST_CASE_22, +] + +ITEST_CASE_1 = ["invalid_image_data_type", {}, [[[[1, 1, 1]]]], NotImplementedError] + +INVALID_CASES = [ITEST_CASE_1] + + +class TestFillHoles(unittest.TestCase): + @parameterized.expand(VALID_CASES) + def test_correct_results(self, _, args, input_image, expected): + converter = FillHoles(**args) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + result = converter(clone(input_image).cuda()) + assert allclose(result, expected.cuda()) + else: + result = converter(clone(input_image)) + assert allclose(result, expected) + + @parameterized.expand(INVALID_CASES) + def test_raise_exception(self, _, args, input_image, expected_error): + with self.assertRaises(expected_error): + converter = FillHoles(**args) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + _ = converter(clone(input_image).cuda()) + else: + _ = converter(clone(input_image)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_keep_largest_connected_component.py b/tests/test_keep_largest_connected_component.py index a8835329ba..670dd2d2ee 100644 --- a/tests/test_keep_largest_connected_component.py +++ b/tests/test_keep_largest_connected_component.py @@ -15,6 +15,7 @@ from parameterized import parameterized from monai.transforms import KeepLargestConnectedComponent +from tests.utils import allclose, clone grid_1 = torch.tensor([[[0, 0, 1, 0, 0], [0, 2, 1, 1, 1], [1, 2, 1, 0, 0], [1, 2, 0, 1, 0], [2, 2, 0, 0, 2]]]) grid_2 = torch.tensor([[[0, 0, 0, 0, 1], [0, 0, 1, 1, 1], [1, 0, 1, 1, 2], [1, 0, 1, 2, 2], [0, 0, 0, 0, 1]]]) @@ -322,23 +323,23 @@ class TestKeepLargestConnectedComponent(unittest.TestCase): @parameterized.expand(VALID_CASES) - def test_correct_results(self, _, args, tensor, expected): + def test_correct_results(self, _, args, input_image, expected): converter = KeepLargestConnectedComponent(**args) - if torch.cuda.is_available(): - result = converter(tensor.clone().cuda()) - assert torch.allclose(result, expected.cuda()) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + result = converter(clone(input_image).cuda()) + assert allclose(result, expected.cuda()) else: - result = converter(tensor.clone()) - assert torch.allclose(result, expected) + result = converter(clone(input_image)) + assert allclose(result, expected) @parameterized.expand(INVALID_CASES) - def test_raise_exception(self, _, args, tensor, expected_error): + def test_raise_exception(self, _, args, input_image, expected_error): with self.assertRaises(expected_error): converter = KeepLargestConnectedComponent(**args) - if torch.cuda.is_available(): - _ = converter(tensor.clone().cuda()) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + _ = converter(clone(input_image).cuda()) else: - _ = converter(tensor.clone()) + _ = converter(clone(input_image).clone()) if __name__ == "__main__": diff --git a/tests/test_label_filter.py b/tests/test_label_filter.py new file mode 100644 index 0000000000..9165fddc40 --- /dev/null +++ b/tests/test_label_filter.py @@ -0,0 +1,127 @@ +# Copyright 2020 - 2021 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 torch +from parameterized import parameterized + +from monai.transforms import LabelFilter +from tests.utils import allclose, clone + +grid_1 = torch.tensor( + [ + [ + [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ] + ] + ] +) + + +TEST_CASE_0 = [ + "filter_single_label", + {"applied_labels": 3}, + grid_1, + torch.tensor( + [ + [ + [ + [0, 0, 3], + [0, 0, 0], + [0, 0, 0], + ] + ] + ] + ), +] + + +TEST_CASE_1 = [ + "filter_single_label_list", + {"applied_labels": [3]}, + grid_1, + torch.tensor( + [ + [ + [ + [0, 0, 3], + [0, 0, 0], + [0, 0, 0], + ] + ] + ] + ), +] + +TEST_CASE_2 = [ + "filter_multi_label", + {"applied_labels": [3, 5, 8]}, + grid_1, + torch.tensor( + [ + [ + [ + [0, 0, 3], + [0, 5, 0], + [0, 8, 0], + ] + ] + ] + ), +] + +TEST_CASE_3 = [ + "filter_all", + {"applied_labels": [1, 2, 3, 4, 5, 6, 7, 8, 9]}, + grid_1, + grid_1, +] + +VALID_CASES = [ + TEST_CASE_0, + TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, +] + +ITEST_CASE_1 = ["invalid_image_data_type", {"applied_labels": 1}, [[[[1, 1, 1]]]], NotImplementedError] + +INVALID_CASES = [ITEST_CASE_1] + + +class TestLabelFilter(unittest.TestCase): + @parameterized.expand(VALID_CASES) + def test_correct_results(self, _, args, input_image, expected): + converter = LabelFilter(**args) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + result = converter(clone(input_image).cuda()) + assert allclose(result, expected.cuda()) + else: + result = converter(clone(input_image)) + assert allclose(result, expected) + + @parameterized.expand(INVALID_CASES) + def test_raise_exception(self, _, args, input_image, expected_error): + with self.assertRaises(expected_error): + converter = LabelFilter(**args) + if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): + _ = converter(clone(input_image).cuda()) + else: + _ = converter(clone(input_image)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils.py b/tests/utils.py index ce280a13f0..c3f604f12e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import datetime import functools import importlib @@ -29,6 +30,7 @@ import torch import torch.distributed as dist +from monai.config import NdarrayTensor from monai.config.deviceconfig import USE_COMPILED from monai.data import create_test_image_2d, create_test_image_3d from monai.utils import ensure_tuple, optional_import, set_determinism @@ -39,6 +41,42 @@ quick_test_var = "QUICKTEST" +def clone(data: NdarrayTensor) -> NdarrayTensor: + """ + Clone data independent of type. + + Args: + data (NdarrayTensor): This can be a Pytorch Tensor or numpy array. + + Returns: + Any: Cloned data object + """ + return copy.deepcopy(data) + + +def allclose(a: NdarrayTensor, b: NdarrayTensor) -> bool: + """ + Check if all values of two data objects are close. + + Note: + This method also checks that both data objects are either Pytorch Tensors or numpy arrays. + + Args: + a (NdarrayTensor): Pytorch Tensor or numpy array for comparison + b (NdarrayTensor): Pytorch Tensor or numpy array to compare against + + Returns: + bool: If both data objects are close. + """ + if isinstance(a, np.ndarray) and isinstance(b, np.ndarray): + return np.allclose(a, b) + + if isinstance(a, torch.Tensor) and isinstance(b, torch.Tensor): + return torch.allclose(a, b) + + return False + + def test_pretrained_networks(network, input_param, device): try: net = network(**input_param).to(device) From 1ffa909146a947e99bef4345f16e6a52f50ba06b Mon Sep 17 00:00:00 2001 From: "deepsource-autofix[bot]" <62050782+deepsource-autofix[bot]@users.noreply.github.com> Date: Sat, 7 Aug 2021 15:36:22 +0000 Subject: [PATCH 21/89] Remove unnecessary use of comprehension (#2719) * Remove unnecessary use of comprehension * Remove unnecessary comprehension (#2718) Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> * Refactor unnecessary `else` / `elif` when `if` block has a `return` statement (#2717) * Refactor unnecessary `else` / `elif` when `if` block has a `return` statement * Unnecessary `else`/`elif` used after `raise` PYL-R1720 Signed-off-by: Wenqi Li Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li * Replace ternary syntax with if expression (#2716) * Replace ternary syntax with if expression * fixes Duplicate dictionary keys PYL-W0109 Signed-off-by: Wenqi Li * remove Unused variable data_ Signed-off-by: Wenqi Li Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: Wenqi Li * [MONAI] python code formatting Signed-off-by: monai-bot * fixes scaler type Signed-off-by: Wenqi Li Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com> Co-authored-by: monai-bot --- docs/source/conf.py | 1 - monai/data/csv_saver.py | 1 - monai/data/utils.py | 2 +- monai/engines/workflow.py | 1 + monai/handlers/utils.py | 2 +- monai/networks/blocks/fcn.py | 29 ++++++++++--------------- monai/transforms/croppad/array.py | 2 +- monai/transforms/croppad/dictionary.py | 2 +- monai/transforms/intensity/array.py | 5 ++--- monai/transforms/io/array.py | 2 +- monai/transforms/post/array.py | 10 ++++----- monai/transforms/utility/array.py | 3 +-- monai/transforms/utils.py | 10 ++++----- monai/utils/deprecated.py | 5 ++--- monai/utils/dist.py | 2 +- monai/utils/jupyter_utils.py | 3 +-- tests/test_gibbs_noised.py | 2 +- tests/test_k_space_spike_noised.py | 2 +- tests/test_rand_gibbs_noised.py | 2 +- tests/test_rand_k_space_spike_noised.py | 2 +- 20 files changed, 37 insertions(+), 51 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 7efebfb8d2..324be8a0fd 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -106,7 +106,6 @@ def generate_apidocs(*args): # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme_options = { "external_links": [{"url": "https://github.com/Project-MONAI/tutorials", "name": "Tutorials"}], - "collapse_navigation": True, "icon_links": [ { "name": "GitHub", diff --git a/monai/data/csv_saver.py b/monai/data/csv_saver.py index c79cd1016a..62f407bfd5 100644 --- a/monai/data/csv_saver.py +++ b/monai/data/csv_saver.py @@ -87,7 +87,6 @@ def save(self, data: Union[torch.Tensor, np.ndarray], meta_data: Optional[Dict] """ save_key = meta_data[Key.FILENAME_OR_OBJ] if meta_data else str(self._data_index) self._data_index += 1 - data_: np.ndarray if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() self._cache_dict[save_key] = np.asarray(data, dtype=float) diff --git a/monai/data/utils.py b/monai/data/utils.py index 94c8582e9a..737b2f84b5 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -403,7 +403,7 @@ def _detect_batch_size(batch_data: Sequence): dict_batch[k] = v return dict_batch - elif isinstance(batch_data, list): + if isinstance(batch_data, list): batch_size = _detect_batch_size(batch_data) list_batch = [] for b in batch_data: diff --git a/monai/engines/workflow.py b/monai/engines/workflow.py index 4e1834a625..ffb8ce05b3 100644 --- a/monai/engines/workflow.py +++ b/monai/engines/workflow.py @@ -149,6 +149,7 @@ def set_sampler_epoch(engine: Engine): self.prepare_batch = prepare_batch self.metric_cmp_fn = metric_cmp_fn self.amp = amp + self.scaler: Optional[torch.cuda.amp.GradScaler] = None if event_names is None: event_names = [IterationEvents] diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index 793683f2c5..bedc340daa 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -259,7 +259,7 @@ def from_engine(keys: KeysCollection, first: bool = False): def _wrapper(data): if isinstance(data, dict): return tuple(data[k] for k in keys) - elif isinstance(data, list) and isinstance(data[0], dict): + if isinstance(data, list) and isinstance(data[0], dict): # if data is a list of dictionaries, extract expected keys and construct lists, # if `first=True`, only extract keys from the first item of the list ret = [data[0][k] if first else [i[k] for i in data] for k in keys] diff --git a/monai/networks/blocks/fcn.py b/monai/networks/blocks/fcn.py index aa6d69fad0..d84e506774 100644 --- a/monai/networks/blocks/fcn.py +++ b/monai/networks/blocks/fcn.py @@ -191,25 +191,18 @@ def forward(self, x: torch.Tensor): fs3 = self.refine8(self.up_conv(fs2) + gcfm4) fs4 = self.refine9(self.up_conv(fs3) + gcfm5) return self.refine10(self.up_conv(fs4)) - else: - fs1 = self.refine6( - F.interpolate(gcfm1, fm3.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm2 - ) - fs2 = self.refine7(F.interpolate(fs1, fm2.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm3) - fs3 = self.refine8( - F.interpolate(fs2, pool_x.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm4 - ) - fs4 = self.refine9( - F.interpolate(fs3, conv_x.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm5 - ) - return self.refine10( - F.interpolate( - fs4, - org_input.size()[2:], - mode=self.upsample_mode, - align_corners=True, - ) + fs1 = self.refine6(F.interpolate(gcfm1, fm3.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm2) + fs2 = self.refine7(F.interpolate(fs1, fm2.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm3) + fs3 = self.refine8(F.interpolate(fs2, pool_x.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm4) + fs4 = self.refine9(F.interpolate(fs3, conv_x.size()[2:], mode=self.upsample_mode, align_corners=True) + gcfm5) + return self.refine10( + F.interpolate( + fs4, + org_input.size()[2:], + mode=self.upsample_mode, + align_corners=True, ) + ) class MCFCN(FCN): diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index fe482270f0..240836ce0b 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -402,7 +402,7 @@ def randomize(self, img_size: Sequence[int]) -> None: self._size = fall_back_tuple(self.roi_size, img_size) if self.random_size: max_size = img_size if self.max_roi_size is None else fall_back_tuple(self.max_roi_size, img_size) - if any([i > j for i, j in zip(self._size, max_size)]): + if any(i > j for i, j in zip(self._size, max_size)): raise ValueError(f"min ROI size: {self._size} is bigger than max ROI size: {max_size}.") self._size = tuple((self.R.randint(low=self._size[i], high=max_size[i] + 1) for i in range(len(img_size)))) if self.random_center: diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 346071aa3b..4a2ae32607 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -539,7 +539,7 @@ def randomize(self, img_size: Sequence[int]) -> None: self._size = fall_back_tuple(self.roi_size, img_size) if self.random_size: max_size = img_size if self.max_roi_size is None else fall_back_tuple(self.max_roi_size, img_size) - if any([i > j for i, j in zip(self._size, max_size)]): + if any(i > j for i, j in zip(self._size, max_size)): raise ValueError(f"min ROI size: {self._size} is bigger than max ROI size: {max_size}.") self._size = [self.R.randint(low=self._size[i], high=max_size[i] + 1) for i in range(len(img_size))] if self.random_center: diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 14b3e54459..ca4f1ef388 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1330,7 +1330,7 @@ def __init__( raise AssertionError( "If a sequence is passed to k_intensity, then a sequence of locations must be passed to loc" ) - elif len(k_intensity) != len(loc): + if len(k_intensity) != len(loc): raise AssertionError("There must be one intensity_factor value for each tuple of indices in loc.") if isinstance(self.loc[0], Sequence) and k_intensity is not None: if not isinstance(self.k_intensity, Sequence): @@ -1541,8 +1541,7 @@ def _make_sequence(self, x: torch.Tensor) -> Sequence[Sequence[float]]: if not isinstance(self.intensity_range[0], Sequence): intensity_range = (ensure_tuple(self.intensity_range),) * x.shape[0] return intensity_range - else: - return ensure_tuple(self.intensity_range) + return ensure_tuple(self.intensity_range) else: # set default range if one not provided return self._set_default_range(x) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 2c1a3c89ff..a8e9ed1e7c 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -45,7 +45,7 @@ def switch_endianness(data, new="<"): """ if isinstance(data, np.ndarray): # default to system endian - sys_native = ((sys.byteorder == "little") and "<") or ">" + sys_native = "<" if (sys.byteorder == "little") else ">" current_ = sys_native if data.dtype.byteorder not in ("<", ">") else data.dtype.byteorder if new not in ("<", ">"): raise NotImplementedError(f"Not implemented option new={new}.") diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index a33fce785e..c7558eddc3 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -334,12 +334,11 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: """ if isinstance(img, np.ndarray): return np.asarray(np.where(np.isin(img, self.applied_labels), img, 0)) - elif isinstance(img, torch.Tensor): + if isinstance(img, torch.Tensor): img_arr = img.detach().cpu().numpy() img_arr = self(img_arr) return torch.as_tensor(img_arr, device=img.device) - else: - raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") + raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") class FillHoles(Transform): @@ -415,12 +414,11 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: """ if isinstance(img, np.ndarray): return fill_holes(img, self.applied_labels, self.connectivity) - elif isinstance(img, torch.Tensor): + if isinstance(img, torch.Tensor): img_arr = img.detach().cpu().numpy() img_arr = self(img_arr) return torch.as_tensor(img_arr, device=img.device) - else: - raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") + raise NotImplementedError(f"{self.__class__} can not handle data of type {type(img)}.") class LabelToContour(Transform): diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 3de2408abd..fe73c6189c 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -1001,8 +1001,7 @@ def __call__( def _compute(op: Callable, data: np.ndarray): if self.channel_wise: return [op(c) for c in data] - else: - return op(data) + return op(data) custom_index = 0 for o in self.ops: diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 366e2d245e..800a779651 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -490,7 +490,7 @@ def generate_label_classes_crop_centers( ratios_: List[Union[float, int]] = ([1] * len(indices)) if ratios is None else ratios if len(ratios_) != len(indices): raise ValueError("random crop radios must match the number of indices of classes.") - if any([i < 0 for i in ratios_]): + if any(i < 0 for i in ratios_): raise ValueError("ratios should not contain negative number.") # ensure indices are numpy array @@ -1043,7 +1043,7 @@ def convert_to_tensor(data): """ if isinstance(data, torch.Tensor): return data.contiguous() - elif isinstance(data, np.ndarray): + if isinstance(data, np.ndarray): # skip array of string classes and object, refer to: # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 if re.search(r"[SaUO]", data.dtype.str) is None: @@ -1107,11 +1107,11 @@ def tensor_to_numpy(data): if isinstance(data, torch.Tensor): # invert Tensor to numpy, if scalar data, convert to number return data.item() if data.ndim == 0 else np.ascontiguousarray(data.detach().cpu().numpy()) - elif isinstance(data, dict): + if isinstance(data, dict): return {k: tensor_to_numpy(v) for k, v in data.items()} - elif isinstance(data, list): + if isinstance(data, list): return [tensor_to_numpy(i) for i in data] - elif isinstance(data, tuple): + if isinstance(data, tuple): return tuple(tensor_to_numpy(i) for i in data) return data diff --git a/monai/utils/deprecated.py b/monai/utils/deprecated.py index 4cf99f4b67..4c6b2db108 100644 --- a/monai/utils/deprecated.py +++ b/monai/utils/deprecated.py @@ -100,9 +100,8 @@ def _wrapper(*args, **kwargs): if is_func: return _wrapper - else: - obj.__init__ = _wrapper - return obj + obj.__init__ = _wrapper + return obj return _decorator diff --git a/monai/utils/dist.py b/monai/utils/dist.py index 5cb365e088..beb958a5c8 100644 --- a/monai/utils/dist.py +++ b/monai/utils/dist.py @@ -34,7 +34,7 @@ def get_dist_device(): backend = dist.get_backend() if backend == "nccl" and torch.cuda.is_available(): return torch.device(f"cuda:{torch.cuda.current_device()}") - elif backend == "gloo": + if backend == "gloo": return torch.device("cpu") return None diff --git a/monai/utils/jupyter_utils.py b/monai/utils/jupyter_utils.py index b86f9f442c..26487083b1 100644 --- a/monai/utils/jupyter_utils.py +++ b/monai/utils/jupyter_utils.py @@ -224,8 +224,7 @@ def _get_loss(data): if isinstance(output, list): return _get_loss(output[0]) - else: - return _get_loss(output) + return _get_loss(output) class StatusMembers(Enum): diff --git a/tests/test_gibbs_noised.py b/tests/test_gibbs_noised.py index 8ad4839338..558556489a 100644 --- a/tests/test_gibbs_noised.py +++ b/tests/test_gibbs_noised.py @@ -45,7 +45,7 @@ def get_data(im_shape, as_tensor_input): create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TEST_CASES) def test_same_result(self, im_shape, as_tensor_output, as_tensor_input): diff --git a/tests/test_k_space_spike_noised.py b/tests/test_k_space_spike_noised.py index e891bd4568..616662b3cd 100644 --- a/tests/test_k_space_spike_noised.py +++ b/tests/test_k_space_spike_noised.py @@ -47,7 +47,7 @@ def get_data(im_shape, as_tensor_input): ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [im[None] for im in ims] ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TEST_CASES) def test_same_result(self, im_shape, as_tensor_output, as_tensor_input): diff --git a/tests/test_rand_gibbs_noised.py b/tests/test_rand_gibbs_noised.py index 72188a93b5..b778bffdda 100644 --- a/tests/test_rand_gibbs_noised.py +++ b/tests/test_rand_gibbs_noised.py @@ -45,7 +45,7 @@ def get_data(im_shape, as_tensor_input): create_test_image = create_test_image_2d if len(im_shape) == 2 else create_test_image_3d ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TEST_CASES) def test_0_prob(self, im_shape, as_tensor_output, as_tensor_input): diff --git a/tests/test_rand_k_space_spike_noised.py b/tests/test_rand_k_space_spike_noised.py index d61b83e2d5..1056ebf163 100644 --- a/tests/test_rand_k_space_spike_noised.py +++ b/tests/test_rand_k_space_spike_noised.py @@ -46,7 +46,7 @@ def get_data(im_shape, as_tensor_input): ims = create_test_image(*im_shape, rad_max=20, noise_max=0.0, num_seg_classes=5) ims = [im[None] for im in ims] ims = [torch.Tensor(im) for im in ims] if as_tensor_input else ims - return {k: v for k, v in zip(KEYS, ims)} + return dict(zip(KEYS, ims)) @parameterized.expand(TEST_CASES) def test_same_result(self, im_shape, as_tensor_output, as_tensor_input): From 2f1c7a5d1b47c8dd21681dbe1b67213aa3278cd7 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 9 Aug 2021 20:49:17 +0800 Subject: [PATCH 22/89] [DLMED] add select_fn for MaskIntensity (#2726) Signed-off-by: Nic Ma --- monai/transforms/intensity/array.py | 37 ++++++++++++------------ monai/transforms/intensity/dictionary.py | 15 ++++++---- tests/test_mask_intensity.py | 11 ++++++- tests/test_mask_intensityd.py | 12 +++++++- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index ca4f1ef388..c14f2b242f 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -14,7 +14,7 @@ """ from collections.abc import Iterable -from typing import Any, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, List, Optional, Sequence, Tuple, Union from warnings import warn import numpy as np @@ -24,7 +24,7 @@ from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.transform import Fourier, RandomizableTransform, Transform -from monai.transforms.utils import rescale_array +from monai.transforms.utils import is_positive, rescale_array from monai.utils import ( PT_BEFORE_1_7, InvalidPyTorchVersionError, @@ -789,19 +789,23 @@ class MaskIntensity(Transform): """ Mask the intensity values of input image with the specified mask data. Mask data must have the same spatial size as the input image, and all - the intensity values of input image corresponding to `0` in the mask - data will be set to `0`, others will keep the original value. + the intensity values of input image corresponding to the selected values + in the mask data will keep the original value, others will be set to `0`. Args: mask_data: if `mask_data` is single channel, apply to every channel of input image. if multiple channels, the number of channels must - match the input data. `mask_data` will be converted to `bool` values - by `mask_data > 0` before applying transform to input image. + match the input data. the intensity values of input image corresponding + to the selected values in the mask data will keep the original value, + others will be set to `0`. + select_fn: function to select valid values of the `mask_data`, default is + to select `values > 0`. """ - def __init__(self, mask_data: Optional[np.ndarray]) -> None: + def __init__(self, mask_data: Optional[np.ndarray], select_fn: Callable = is_positive) -> None: self.mask_data = mask_data + self.select_fn = select_fn def __call__(self, img: np.ndarray, mask_data: Optional[np.ndarray] = None) -> np.ndarray: """ @@ -816,21 +820,18 @@ def __call__(self, img: np.ndarray, mask_data: Optional[np.ndarray] = None) -> n - ValueError: When ``mask_data`` and ``img`` channels differ and ``mask_data`` is not single channel. """ - if self.mask_data is None and mask_data is None: - raise ValueError("Unknown mask_data.") - mask_data_ = np.array([[1]]) - if self.mask_data is not None and mask_data is None: - mask_data_ = self.mask_data > 0 - if mask_data is not None: - mask_data_ = mask_data > 0 - mask_data_ = np.asarray(mask_data_) - if mask_data_.shape[0] != 1 and mask_data_.shape[0] != img.shape[0]: + mask_data = self.mask_data if mask_data is None else mask_data + if mask_data is None: + raise ValueError("must provide the mask_data when initializing the transform or at runtime.") + + mask_data = np.asarray(self.select_fn(mask_data)) + if mask_data.shape[0] != 1 and mask_data.shape[0] != img.shape[0]: raise ValueError( "When mask_data is not single channel, mask_data channels must match img, " - f"got img={img.shape[0]} mask_data={mask_data_.shape[0]}." + f"got img channels={img.shape[0]} mask_data channels={mask_data.shape[0]}." ) - return np.asarray(img * mask_data_) + return np.asarray(img * mask_data) class SavitzkyGolaySmooth(Transform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index e43aa1e2b3..19323e2020 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -16,7 +16,7 @@ """ from collections.abc import Iterable -from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np import torch @@ -42,6 +42,7 @@ ThresholdIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform +from monai.transforms.utils import is_positive from monai.utils import dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple __all__ = [ @@ -808,11 +809,14 @@ class MaskIntensityd(MapTransform): See also: :py:class:`monai.transforms.compose.MapTransform` mask_data: if mask data is single channel, apply to every channel of input image. if multiple channels, the channel number must - match input data. mask_data will be converted to `bool` values - by `mask_data > 0` before applying transform to input image. - if None, will extract the mask data from input data based on `mask_key`. + match input data. the intensity values of input image corresponding + to the selected values in the mask data will keep the original value, + others will be set to `0`. if None, will extract the mask data from + input data based on `mask_key`. mask_key: the key to extract mask data from input dictionary, only works when `mask_data` is None. + select_fn: function to select valid values of the `mask_data`, default is + to select `values > 0`. allow_missing_keys: don't raise exception if key is missing. """ @@ -822,10 +826,11 @@ def __init__( keys: KeysCollection, mask_data: Optional[np.ndarray] = None, mask_key: Optional[str] = None, + select_fn: Callable = is_positive, allow_missing_keys: bool = False, ) -> None: super().__init__(keys, allow_missing_keys) - self.converter = MaskIntensity(mask_data) + self.converter = MaskIntensity(mask_data=mask_data, select_fn=select_fn) self.mask_key = mask_key if mask_data is None else None def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: diff --git a/tests/test_mask_intensity.py b/tests/test_mask_intensity.py index 3131abe8bf..da9eda6416 100644 --- a/tests/test_mask_intensity.py +++ b/tests/test_mask_intensity.py @@ -34,9 +34,18 @@ np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 4, 0], [0, 5, 0], [0, 6, 0]]]), ] +TEST_CASE_4 = [ + { + "mask_data": np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]), + "select_fn": lambda x: np.where((x > 3) & (x < 7), True, False), + }, + np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3]], [[4, 4, 4], [5, 5, 5], [6, 6, 6]]]), + np.array([[[0, 0, 0], [2, 2, 2], [0, 0, 0]], [[0, 0, 0], [5, 5, 5], [0, 0, 0]]]), +] + class TestMaskIntensity(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_value(self, argments, image, expected_data): result = MaskIntensity(**argments)(image) np.testing.assert_allclose(result, expected_data) diff --git a/tests/test_mask_intensityd.py b/tests/test_mask_intensityd.py index 0d08952db2..c21e26eba6 100644 --- a/tests/test_mask_intensityd.py +++ b/tests/test_mask_intensityd.py @@ -43,9 +43,19 @@ np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 4, 0], [0, 5, 0], [0, 6, 0]]]), ] +TEST_CASE_5 = [ + { + "keys": "img", + "mask_data": np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]), + "select_fn": lambda x: np.where((x > 3) & (x < 7), True, False), + }, + {"img": np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3]], [[4, 4, 4], [5, 5, 5], [6, 6, 6]]])}, + np.array([[[0, 0, 0], [2, 2, 2], [0, 0, 0]], [[0, 0, 0], [5, 5, 5], [0, 0, 0]]]), +] + class TestMaskIntensityd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) def test_value(self, argments, image, expected_data): result = MaskIntensityd(**argments)(image) np.testing.assert_allclose(result["img"], expected_data) From ce4960de29ae784d4ad35cee0b327ea4750f1cb9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 10 Aug 2021 18:36:57 -0400 Subject: [PATCH 23/89] Reorganize datasets and metrics in pathology (#2742) Signed-off-by: Behrooz <3968947+behxyz@users.noreply.github.com> Co-authored-by: Behrooz <3968947+behxyz@users.noreply.github.com> --- docs/source/apps.rst | 2 +- monai/apps/pathology/__init__.py | 2 +- monai/apps/pathology/data/__init__.py | 12 ++++++++++++ monai/apps/pathology/{ => data}/datasets.py | 0 monai/apps/pathology/metrics/__init__.py | 12 ++++++++++++ .../pathology/{metrics.py => metrics/lesion_froc.py} | 0 tests/test_masked_inference_wsi_dataset.py | 2 +- tests/test_patch_wsi_dataset.py | 2 +- tests/test_smartcache_patch_wsi_dataset.py | 2 +- 9 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 monai/apps/pathology/data/__init__.py rename monai/apps/pathology/{ => data}/datasets.py (100%) create mode 100644 monai/apps/pathology/metrics/__init__.py rename monai/apps/pathology/{metrics.py => metrics/lesion_froc.py} (100%) diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 959e42d6f9..1a2efeff48 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -77,7 +77,7 @@ Clara MMARs `Pathology` ----------- -.. automodule:: monai.apps.pathology.datasets +.. automodule:: monai.apps.pathology.data .. autoclass:: PatchWSIDataset :members: .. autoclass:: SmartCachePatchWSIDataset diff --git a/monai/apps/pathology/__init__.py b/monai/apps/pathology/__init__.py index 0ada8fe51b..80f32403ea 100644 --- a/monai/apps/pathology/__init__.py +++ b/monai/apps/pathology/__init__.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCacheDataset +from .data import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCachePatchWSIDataset from .handlers import ProbMapProducer from .metrics import LesionFROC from .transforms.stain.array import ExtractHEStains, NormalizeHEStains diff --git a/monai/apps/pathology/data/__init__.py b/monai/apps/pathology/data/__init__.py new file mode 100644 index 0000000000..64556b6f6e --- /dev/null +++ b/monai/apps/pathology/data/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2020 - 2021 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. + +from .datasets import MaskedInferenceWSIDataset, PatchWSIDataset, SmartCachePatchWSIDataset diff --git a/monai/apps/pathology/datasets.py b/monai/apps/pathology/data/datasets.py similarity index 100% rename from monai/apps/pathology/datasets.py rename to monai/apps/pathology/data/datasets.py diff --git a/monai/apps/pathology/metrics/__init__.py b/monai/apps/pathology/metrics/__init__.py new file mode 100644 index 0000000000..ad62df524a --- /dev/null +++ b/monai/apps/pathology/metrics/__init__.py @@ -0,0 +1,12 @@ +# Copyright 2020 - 2021 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. + +from .lesion_froc import LesionFROC diff --git a/monai/apps/pathology/metrics.py b/monai/apps/pathology/metrics/lesion_froc.py similarity index 100% rename from monai/apps/pathology/metrics.py rename to monai/apps/pathology/metrics/lesion_froc.py diff --git a/tests/test_masked_inference_wsi_dataset.py b/tests/test_masked_inference_wsi_dataset.py index 27e64c2d7c..361c17e106 100644 --- a/tests/test_masked_inference_wsi_dataset.py +++ b/tests/test_masked_inference_wsi_dataset.py @@ -17,7 +17,7 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.apps.pathology.datasets import MaskedInferenceWSIDataset +from monai.apps.pathology.data import MaskedInferenceWSIDataset from monai.apps.utils import download_url from monai.utils import optional_import from tests.utils import skip_if_quick diff --git a/tests/test_patch_wsi_dataset.py b/tests/test_patch_wsi_dataset.py index 7c34997872..f775f28376 100644 --- a/tests/test_patch_wsi_dataset.py +++ b/tests/test_patch_wsi_dataset.py @@ -17,7 +17,7 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.apps.pathology.datasets import PatchWSIDataset +from monai.apps.pathology.data import PatchWSIDataset from monai.apps.utils import download_url from monai.utils import optional_import diff --git a/tests/test_smartcache_patch_wsi_dataset.py b/tests/test_smartcache_patch_wsi_dataset.py index 876a30a3b8..c484e5fc69 100644 --- a/tests/test_smartcache_patch_wsi_dataset.py +++ b/tests/test_smartcache_patch_wsi_dataset.py @@ -17,7 +17,7 @@ from numpy.testing import assert_array_equal from parameterized import parameterized -from monai.apps.pathology.datasets import SmartCachePatchWSIDataset +from monai.apps.pathology.data import SmartCachePatchWSIDataset from monai.apps.utils import download_url from monai.utils import optional_import From 33184faf76e2a843fe08de98d2ed3f76ac937113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xa9aX=20=E3=83=84?= Date: Wed, 11 Aug 2021 14:45:18 +0530 Subject: [PATCH 24/89] Update Mish to default PyTorch 1.9 version (#2733) * Update Mish to default PyTorch 1.9 version --- monai/networks/blocks/activation.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/monai/networks/blocks/activation.py b/monai/networks/blocks/activation.py index f6a04e830e..ef2d19b550 100644 --- a/monai/networks/blocks/activation.py +++ b/monai/networks/blocks/activation.py @@ -12,6 +12,19 @@ import torch from torch import nn +from monai.utils import optional_import + +if optional_import("torch.nn.functional", name="mish")[1]: + + def monai_mish(x): + return torch.nn.functional.mish(x, inplace=True) + + +else: + + def monai_mish(x): + return x * torch.tanh(torch.nn.functional.softplus(x)) + class Swish(nn.Module): r"""Applies the element-wise function: @@ -30,6 +43,8 @@ class Swish(nn.Module): Examples:: + >>> import torch + >>> from monai.networks.layers.factories import Act >>> m = Act['swish']() >>> input = torch.randn(2) >>> output = m(input) @@ -85,6 +100,8 @@ class MemoryEfficientSwish(nn.Module): Examples:: + >>> import torch + >>> from monai.networks.layers.factories import Act >>> m = Act['memswish']() >>> input = torch.randn(2) >>> output = m(input) @@ -111,10 +128,12 @@ class Mish(nn.Module): Examples:: + >>> import torch + >>> from monai.networks.layers.factories import Act >>> m = Act['mish']() >>> input = torch.randn(2) >>> output = m(input) """ - def forward(self, input: torch.Tensor) -> torch.Tensor: - return input * torch.tanh(torch.nn.functional.softplus(input)) + def forward(self, input: torch.Tensor): + return monai_mish(input) From f9bc713c5ee0cfc48a58536a7790f029a69272ed Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 12 Aug 2021 03:23:23 +0800 Subject: [PATCH 25/89] 2691 Add HistogramIntensity transform (#2738) * [DLMED] add Histogram normalize Signed-off-by: Nic Ma --- docs/source/transforms.rst | 12 ++++++ monai/transforms/__init__.py | 5 +++ monai/transforms/intensity/array.py | 45 +++++++++++++++++++++- monai/transforms/intensity/dictionary.py | 48 ++++++++++++++++++++++++ monai/transforms/utils.py | 44 ++++++++++++++++++++++ tests/test_histogram_normalize.py | 47 +++++++++++++++++++++++ tests/test_histogram_normalized.py | 47 +++++++++++++++++++++++ 7 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 tests/test_histogram_normalize.py create mode 100644 tests/test_histogram_normalized.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 8a880ff151..f97be395d1 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -319,6 +319,12 @@ Intensity :members: :special-members: __call__ +`HistogramNormalize` +"""""""""""""""""""" + .. autoclass:: HistogramNormalize + :members: + :special-members: __call__ + IO ^^ @@ -930,6 +936,12 @@ Intensity (Dict) :members: :special-members: __call__ +`HistogramNormalized` +""""""""""""""""""""" + .. autoclass:: HistogramNormalized + :members: + :special-members: __call__ + IO (Dict) ^^^^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 7f2873cc85..390b85a1b8 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -83,6 +83,7 @@ GaussianSharpen, GaussianSmooth, GibbsNoise, + HistogramNormalize, KSpaceSpikeNoise, MaskIntensity, NormalizeIntensity, @@ -120,6 +121,9 @@ GibbsNoised, GibbsNoiseD, GibbsNoiseDict, + HistogramNormalized, + HistogramNormalizeD, + HistogramNormalizeDict, KSpaceSpikeNoised, KSpaceSpikeNoiseD, KSpaceSpikeNoiseDict, @@ -467,6 +471,7 @@ create_scale, create_shear, create_translate, + equalize_hist, extreme_points_to_image, generate_label_classes_crop_centers, generate_pos_neg_label_crop_centers, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index c14f2b242f..258d896eb6 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -24,7 +24,7 @@ from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.transform import Fourier, RandomizableTransform, Transform -from monai.transforms.utils import is_positive, rescale_array +from monai.transforms.utils import equalize_hist, is_positive, rescale_array from monai.utils import ( PT_BEFORE_1_7, InvalidPyTorchVersionError, @@ -64,6 +64,7 @@ "KSpaceSpikeNoise", "RandKSpaceSpikeNoise", "RandCoarseDropout", + "HistogramNormalize", ] @@ -1626,3 +1627,45 @@ def __call__(self, img: np.ndarray): img[h] = self.fill_value return img + + +class HistogramNormalize(Transform): + """ + Apply the histogram normalization to input image. + Refer to: https://github.com/facebookresearch/CovidPrognosis/blob/master/covidprognosis/data/transforms.py#L83. + + Args: + num_bins: number of the bins to use in histogram, default to `256`. for more details: + https://numpy.org/doc/stable/reference/generated/numpy.histogram.html. + min: the min value to normalize input image, default to `0`. + max: the max value to normalize input image, default to `255`. + mask: if provided, must be ndarray of bools or 0s and 1s, and same shape as `image`. + only points at which `mask==True` are used for the equalization. + can also provide the mask along with img at runtime. + dtype: data type of the output, default to `float32`. + + """ + + def __init__( + self, + num_bins: int = 256, + min: int = 0, + max: int = 255, + mask: Optional[np.ndarray] = None, + dtype: DtypeLike = np.float32, + ) -> None: + self.num_bins = num_bins + self.min = min + self.max = max + self.mask = mask + self.dtype = dtype + + def __call__(self, img: np.ndarray, mask: Optional[np.ndarray] = None) -> np.ndarray: + return equalize_hist( + img=img, + mask=mask if mask is not None else self.mask, + num_bins=self.num_bins, + min=self.min, + max=self.max, + dtype=self.dtype, + ) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 19323e2020..bc5534b402 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -28,6 +28,7 @@ GaussianSharpen, GaussianSmooth, GibbsNoise, + HistogramNormalize, KSpaceSpikeNoise, MaskIntensity, NormalizeIntensity, @@ -72,6 +73,7 @@ "RandKSpaceSpikeNoised", "RandHistogramShiftd", "RandCoarseDropoutd", + "HistogramNormalized", "RandGaussianNoiseD", "RandGaussianNoiseDict", "ShiftIntensityD", @@ -122,6 +124,8 @@ "RandRicianNoiseDict", "RandCoarseDropoutD", "RandCoarseDropoutDict", + "HistogramNormalizeD", + "HistogramNormalizeDict", ] @@ -1469,6 +1473,49 @@ def __call__(self, data): return d +class HistogramNormalized(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.HistogramNormalize`. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + num_bins: number of the bins to use in histogram, default to `256`. for more details: + https://numpy.org/doc/stable/reference/generated/numpy.histogram.html. + min: the min value to normalize input image, default to `255`. + max: the max value to normalize input image, default to `255`. + mask: if provided, must be ndarray of bools or 0s and 1s, and same shape as `image`. + only points at which `mask==True` are used for the equalization. + can also provide the mask by `mask_key` at runtime. + mask_key: if mask is None, will try to get the mask with `mask_key`. + dtype: data type of the output, default to `float32`. + allow_missing_keys: do not raise exception if key is missing. + + """ + + def __init__( + self, + keys: KeysCollection, + num_bins: int = 256, + min: int = 0, + max: int = 255, + mask: Optional[np.ndarray] = None, + mask_key: Optional[str] = None, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.transform = HistogramNormalize(num_bins=num_bins, min=min, max=max, mask=mask, dtype=dtype) + self.mask_key = mask_key if mask is None else None + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.transform(d[key], d[self.mask_key]) if self.mask_key is not None else self.transform(d[key]) + + return d + + RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised RandRicianNoiseD = RandRicianNoiseDict = RandRicianNoised ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd @@ -1495,3 +1542,4 @@ def __call__(self, data): KSpaceSpikeNoiseD = KSpaceSpikeNoiseDict = KSpaceSpikeNoised RandKSpaceSpikeNoiseD = RandKSpaceSpikeNoiseDict = RandKSpaceSpikeNoised RandCoarseDropoutD = RandCoarseDropoutDict = RandCoarseDropoutd +HistogramNormalizeD = HistogramNormalizeDict = HistogramNormalized diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 800a779651..e996d7c9ea 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -40,6 +40,7 @@ ndimage, _ = optional_import("scipy.ndimage") cp, has_cp = optional_import("cupy") cp_ndarray, _ = optional_import("cupy", name="ndarray") +exposure, has_skimage = optional_import("skimage.exposure") __all__ = [ "allow_missing_keys_mode", @@ -76,6 +77,7 @@ "tensor_to_numpy", "weighted_patch_samples", "zero_margins", + "equalize_hist", ] @@ -1115,3 +1117,45 @@ def tensor_to_numpy(data): return tuple(tensor_to_numpy(i) for i in data) return data + + +def equalize_hist( + img: np.ndarray, + mask: Optional[np.ndarray] = None, + num_bins: int = 256, + min: int = 0, + max: int = 255, + dtype: DtypeLike = np.float32, +) -> np.ndarray: + """ + Utility to equalize input image based on the histogram. + If `skimage` installed, will leverage `skimage.exposure.histogram`, otherwise, use + `np.histogram` instead. + + Args: + img: input image to equalize. + mask: if provided, must be ndarray of bools or 0s and 1s, and same shape as `image`. + only points at which `mask==True` are used for the equalization. + num_bins: number of the bins to use in histogram, default to `256`. for more details: + https://numpy.org/doc/stable/reference/generated/numpy.histogram.html. + min: the min value to normalize input image, default to `0`. + max: the max value to normalize input image, default to `255`. + dtype: data type of the output, default to `float32`. + + """ + orig_shape = img.shape + hist_img = img[np.array(mask, dtype=bool)] if mask is not None else img + if has_skimage: + hist, bins = exposure.histogram(hist_img.flatten(), num_bins) + else: + hist, bins = np.histogram(hist_img.flatten(), num_bins) + bins = (bins[:-1] + bins[1:]) / 2 + + cum = hist.cumsum() + # normalize the cumulative result + cum = rescale_array(arr=cum, minv=min, maxv=max) + + # apply linear interpolation + img = np.interp(img.flatten(), bins, cum) + + return img.reshape(orig_shape).astype(dtype) diff --git a/tests/test_histogram_normalize.py b/tests/test_histogram_normalize.py new file mode 100644 index 0000000000..b69fb1d927 --- /dev/null +++ b/tests/test_histogram_normalize.py @@ -0,0 +1,47 @@ +# Copyright 2020 - 2021 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 import HistogramNormalize + +TEST_CASE_1 = [ + {"num_bins": 4, "min": 1, "max": 5, "mask": np.array([1, 1, 1, 1, 1, 0])}, + np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]), + np.array([1.0, 1.5, 2.5, 4.0, 5.0, 5.0]), +] + +TEST_CASE_2 = [ + {"num_bins": 4, "max": 4, "dtype": np.uint8}, + np.array([0.0, 1.0, 2.0, 3.0, 4.0]), + np.array([0, 0, 1, 3, 4]), +] + +TEST_CASE_3 = [ + {"num_bins": 256, "max": 255, "dtype": np.uint8}, + np.array([[[100.0, 200.0], [150.0, 250.0]]]), + np.array([[[0, 170], [70, 255]]]), +] + + +class TestHistogramNormalize(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_value(self, argments, image, expected_data): + result = HistogramNormalize(**argments)(image) + np.testing.assert_allclose(result, expected_data) + self.assertEqual(result.dtype, argments.get("dtype", np.float32)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_histogram_normalized.py b/tests/test_histogram_normalized.py new file mode 100644 index 0000000000..68647e82fb --- /dev/null +++ b/tests/test_histogram_normalized.py @@ -0,0 +1,47 @@ +# Copyright 2020 - 2021 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 import HistogramNormalized + +TEST_CASE_1 = [ + {"keys": "img", "num_bins": 4, "min": 1, "max": 5, "mask_key": "mask"}, + {"img": np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]), "mask": np.array([1, 1, 1, 1, 1, 0])}, + np.array([1.0, 1.5, 2.5, 4.0, 5.0, 5.0]), +] + +TEST_CASE_2 = [ + {"keys": "img", "num_bins": 4, "max": 4, "dtype": np.uint8}, + {"img": np.array([0.0, 1.0, 2.0, 3.0, 4.0])}, + np.array([0, 0, 1, 3, 4]), +] + +TEST_CASE_3 = [ + {"keys": "img", "num_bins": 256, "max": 255, "dtype": np.uint8}, + {"img": np.array([[[100.0, 200.0], [150.0, 250.0]]])}, + np.array([[[0, 170], [70, 255]]]), +] + + +class TestHistogramNormalized(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_value(self, argments, image, expected_data): + result = HistogramNormalized(**argments)(image)["img"] + np.testing.assert_allclose(result, expected_data) + self.assertEqual(result.dtype, argments.get("dtype", np.float32)) + + +if __name__ == "__main__": + unittest.main() From 8726dd5dbfd44c01f3d5ae10bf2b1067fb8deb54 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Thu, 12 Aug 2021 11:40:27 -0400 Subject: [PATCH 26/89] Nvtx transform (#2713) --- docs/source/transforms.rst | 29 +++++++ monai/transforms/__init__.py | 26 ++++++ monai/transforms/nvtx.py | 125 ++++++++++++++++++++++++++++ tests/test_nvtx_transform.py | 154 +++++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 monai/transforms/nvtx.py create mode 100644 tests/test_nvtx_transform.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index f97be395d1..7f970dfb15 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -341,6 +341,35 @@ IO :members: :special-members: __call__ + +NVIDIA Tool Extension (NVTX) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +`RangePush` +""""""""""" +.. autoclass:: RangePush + +`RandRangePush` +""""""""""""""" +.. autoclass:: RandRangePush + +`RangePop` +"""""""""" +.. autoclass:: RangePop + +`RandRangePop` +"""""""""""""" +.. autoclass:: RandRangePop + +`Mark` +"""""" +.. autoclass:: Mark + +`RandMark` +"""""""""" +.. autoclass:: RandMark + + Post-processing ^^^^^^^^^^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 390b85a1b8..33d7fba26e 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -195,6 +195,32 @@ from .inverse_batch_transform import BatchInverseTransform, Decollated from .io.array import LoadImage, SaveImage from .io.dictionary import LoadImaged, LoadImageD, LoadImageDict, SaveImaged, SaveImageD, SaveImageDict +from .nvtx import ( + Mark, + Markd, + MarkD, + MarkDict, + RandMark, + RandMarkd, + RandMarkD, + RandMarkDict, + RandRangePop, + RandRangePopd, + RandRangePopD, + RandRangePopDict, + RandRangePush, + RandRangePushd, + RandRangePushD, + RandRangePushDict, + RangePop, + RangePopd, + RangePopD, + RangePopDict, + RangePush, + RangePushd, + RangePushD, + RangePushDict, +) from .post.array import ( Activations, AsDiscrete, diff --git a/monai/transforms/nvtx.py b/monai/transforms/nvtx.py new file mode 100644 index 0000000000..12c03dc028 --- /dev/null +++ b/monai/transforms/nvtx.py @@ -0,0 +1,125 @@ +# Copyright 2020 - 2021 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. +""" +Wrapper around NVIDIA Tools Extension for profiling MONAI transformations +""" + +from monai.transforms.transform import RandomizableTransform, Transform +from monai.utils import optional_import + +_nvtx, _ = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") + +__all__ = [ + "Mark", + "Markd", + "MarkD", + "MarkDict", + "RandMark", + "RandMarkd", + "RandMarkD", + "RandMarkDict", + "RandRangePop", + "RandRangePopd", + "RandRangePopD", + "RandRangePopDict", + "RandRangePush", + "RandRangePushd", + "RandRangePushD", + "RandRangePushDict", + "RangePop", + "RangePopd", + "RangePopD", + "RangePopDict", + "RangePush", + "RangePushd", + "RangePushD", + "RangePushDict", +] + + +class RangePush(Transform): + """ + Pushes a range onto a stack of nested range span. + Stores zero-based depth of the range that is started. + + Args: + msg: ASCII message to associate with range + """ + + def __init__(self, msg: str) -> None: + self.msg = msg + self.depth = None + + def __call__(self, data): + self.depth = _nvtx.rangePushA(self.msg) + return data + + +class RandRangePush(RangePush, RandomizableTransform): + """ + Pushes a range onto a stack of nested range span (RandomizableTransform). + Stores zero-based depth of the range that is started. + + Args: + msg: ASCII message to associate with range + """ + + +class RangePop(Transform): + """ + Pops a range off of a stack of nested range spans. + Stores zero-based depth of the range that is ended. + """ + + def __call__(self, data): + _nvtx.rangePop() + return data + + +class RandRangePop(RangePop, RandomizableTransform): + """ + Pops a range off of a stack of nested range spans (RandomizableTransform). + Stores zero-based depth of the range that is ended. + """ + + +class Mark(Transform): + """ + Mark an instantaneous event that occurred at some point. + + Args: + msg: ASCII message to associate with the event. + """ + + def __init__(self, msg: str) -> None: + self.msg = msg + + def __call__(self, data): + _nvtx.markA(self.msg) + return data + + +class RandMark(Mark, RandomizableTransform): + """ + Mark an instantaneous event that occurred at some point. + (RandomizableTransform) + + Args: + msg: ASCII message to associate with the event. + """ + + +MarkDict = MarkD = Markd = Mark +RandMarkDict = RandMarkD = RandMarkd = RandMark +RandRangePopDict = RandRangePopD = RandRangePopd = RandRangePop +RandRangePushDict = RandRangePushD = RandRangePushd = RandRangePush +RangePopDict = RangePopD = RangePopd = RangePop +RangePushDict = RangePushD = RangePushd = RangePush diff --git a/tests/test_nvtx_transform.py b/tests/test_nvtx_transform.py new file mode 100644 index 0000000000..d1887377ba --- /dev/null +++ b/tests/test_nvtx_transform.py @@ -0,0 +1,154 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.transforms import Compose, Flip, RandFlip, RandFlipD, Randomizable, ToTensor, ToTensorD +from monai.transforms.nvtx import ( + Mark, + MarkD, + RandMark, + RandMarkD, + RandRangePop, + RandRangePopD, + RandRangePush, + RandRangePushD, + RangePop, + RangePopD, + RangePush, + RangePushD, +) +from monai.utils import optional_import + +_, has_nvtx = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") + + +TEST_CASE_ARRAY_0 = [ + np.random.randn(3, 3), +] +TEST_CASE_ARRAY_1 = [ + np.random.randn(3, 10, 10), +] +TEST_CASE_DICT_0 = [ + {"image": np.random.randn(3, 3)}, +] +TEST_CASE_DICT_1 = [ + {"image": np.random.randn(3, 10, 10)}, +] + + +class TestNVTXTransforms(unittest.TestCase): + @parameterized.expand( + [ + TEST_CASE_ARRAY_0, + TEST_CASE_ARRAY_1, + TEST_CASE_DICT_0, + TEST_CASE_DICT_1, + ] + ) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_nvtx_transfroms_alone(self, input): + transforms = Compose( + [ + Mark("Mark: Transform Starts!"), + RangePush("Range: RandFlipD"), + RangePop(), + RandRangePush("Range: ToTensorD"), + RandRangePop(), + RandMark("Mark: Transform Ends!"), + ] + ) + output = transforms(input) + self.assertEqual(id(input), id(output)) + + # Check if chain of randomizable/non-randomizable transforms is not broken + for tran in transforms.transforms: + if isinstance(tran, Randomizable): + self.assertIsInstance(tran, RangePush) + break + + @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_nvtx_transfroms_array(self, input): + transforms = Compose( + [ + RandMark("Mark: Transform Starts!"), + RandRangePush("Range: RandFlip"), + RandFlip(prob=0.0), + RandRangePop(), + RangePush("Range: ToTensor"), + ToTensor(), + RangePop(), + Mark("Mark: Transform Ends!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output, torch.Tensor) + np.testing.assert_array_equal(input, output) + + transforms = Compose( + [ + RandMark("Mark: Transform Starts!"), + RandRangePush("Range: RandFlip"), + RandFlip(prob=1.0), + RandRangePop(), + RangePush("Range: ToTensor"), + ToTensor(), + RangePop(), + Mark("Mark: Transform Ends!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output, torch.Tensor) + np.testing.assert_array_equal(input, Flip()(output.numpy())) + + @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_nvtx_transfromsd(self, input): + transforms = Compose( + [ + RandMarkD("Mark: Transform Starts!"), + RandRangePushD("Range: RandFlipD"), + RandFlipD(keys="image", prob=0.0), + RandRangePopD(), + RangePushD("Range: ToTensorD"), + ToTensorD(keys=("image")), + RangePopD(), + MarkD("Mark: Transform Ends!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output["image"], torch.Tensor) + np.testing.assert_array_equal(input["image"], output["image"]) + + transforms = Compose( + [ + RandMarkD("Mark: Transform Starts!"), + RandRangePushD("Range: RandFlipD"), + RandFlipD(keys="image", prob=1.0), + RandRangePopD(), + RangePushD("Range: ToTensorD"), + ToTensorD(keys=("image")), + RangePopD(), + MarkD("Mark: Transform Ends!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output["image"], torch.Tensor) + np.testing.assert_array_equal(input["image"], Flip()(output["image"].numpy())) + + +if __name__ == "__main__": + unittest.main() From a6cf9b6a844557358c5c1b99a845cfcb3e33c2d7 Mon Sep 17 00:00:00 2001 From: Lyndon Boone Date: Thu, 12 Aug 2021 14:21:05 -0400 Subject: [PATCH 27/89] [WIP] OneOf Transform (#2551) * Added OneOf class Signed-off-by: Lyndon Boone * Clean up OneOf constructor Signed-off-by: Lyndon Boone * add flatten, len and unit test Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * Added unit tests and inverse method Signed-off-by: Lyndon Boone * rename test Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * flatten tests Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * add inverse Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> Co-authored-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/__init__.py | 2 +- monai/transforms/compose.py | 105 +++++++++++++++++++- tests/test_one_of.py | 181 +++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 tests/test_one_of.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 33d7fba26e..99d9c2b8b8 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -10,7 +10,7 @@ # limitations under the License. from .adaptors import FunctionSignature, adaptor, apply_alias, to_kwargs -from .compose import Compose +from .compose import Compose, OneOf from .croppad.array import ( BorderPad, BoundingRect, diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index b380f7d42a..8737abd0fa 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -13,7 +13,7 @@ """ import warnings -from typing import Any, Callable, Optional, Sequence, Union +from typing import Any, Callable, Mapping, Optional, Sequence, Union import numpy as np @@ -28,8 +28,9 @@ apply_transform, ) from monai.utils import MAX_SEED, ensure_tuple, get_seed +from monai.utils.enums import InverseKeys -__all__ = ["Compose"] +__all__ = ["Compose", "OneOf"] class Compose(Randomizable, InvertibleTransform): @@ -143,7 +144,7 @@ def flatten(self): """ new_transforms = [] for t in self.transforms: - if isinstance(t, Compose): + if isinstance(t, Compose) and not isinstance(t, OneOf): new_transforms += t.flatten().transforms else: new_transforms.append(t) @@ -168,3 +169,101 @@ def inverse(self, data): for t in reversed(invertible_transforms): data = apply_transform(t.inverse, data, self.map_items, self.unpack_items) return data + + +class OneOf(Compose): + """ + ``OneOf`` provides the ability to radomly choose one transform out of a + list of callables with predfined probabilities for each. + + Args: + transforms: sequence of callables. + weights: probabilities corresponding to each callable in transforms. + Probabilities are normalized to sum to one. + + OneOf inherits from Compose and uses args map_items and unpack_items in + the same way. + """ + + def __init__( + self, + transforms: Optional[Union[Sequence[Callable], Callable]] = None, + weights: Optional[Union[Sequence[float], float]] = None, + map_items: bool = True, + unpack_items: bool = False, + ) -> None: + super().__init__(transforms, map_items, unpack_items) + if len(self.transforms) == 0: + weights = [] + elif weights is None or isinstance(weights, float): + weights = [1.0 / len(self.transforms)] * len(self.transforms) + if len(weights) != len(self.transforms): + raise AssertionError("transforms and weights should be same size if both specified as sequences.") + self.weights = ensure_tuple(self._normalize_probabilities(weights)) + + def _normalize_probabilities(self, weights): + if len(weights) == 0: + return weights + else: + weights = np.array(weights) + if np.any(weights < 0): + raise AssertionError("Probabilities must be greater than or equal to zero.") + if np.all(weights == 0): + raise AssertionError("At least one probability must be greater than zero.") + weights = weights / weights.sum() + return list(weights) + + def flatten(self): + transforms = [] + weights = [] + for t, w in zip(self.transforms, self.weights): + # if nested, probability is the current weight multiplied by the nested weights, + # and so on recursively + if isinstance(t, OneOf): + tr = t.flatten() + for t_, w_ in zip(tr.transforms, tr.weights): + transforms.append(t_) + weights.append(w_ * w) + else: + transforms.append(t) + weights.append(w) + return OneOf(transforms, weights, self.map_items, self.unpack_items) + + def __call__(self, data): + if len(self.transforms) == 0: + return data + else: + index = self.R.multinomial(1, self.weights).argmax() + _transform = self.transforms[index] + data = apply_transform(_transform, data, self.map_items, self.unpack_items) + # if the data is a mapping (dictionary), append the OneOf transform to the end + if isinstance(data, Mapping): + for key in data.keys(): + if key + InverseKeys.KEY_SUFFIX in data: + self.push_transform(data, key, extra_info={"index": index}) + return data + + def inverse(self, data): + if len(self.transforms) == 0: + return data + if not isinstance(data, Mapping): + raise RuntimeError("Inverse only implemented for Mapping (dictionary) data") + + # loop until we get an index and then break (since they'll all be the same) + index = None + for key in data.keys(): + if key + InverseKeys.KEY_SUFFIX in data: + # get the index of the applied OneOf transform + index = self.get_most_recent_transform(data, key)[InverseKeys.EXTRA_INFO]["index"] + # and then remove the OneOf transform + self.pop_transform(data, key) + if index is None: + raise RuntimeError("No invertible transforms have been applied") + + # if applied transform is not InvertibleTransform, throw error + _transform = self.transforms[index] + if not isinstance(_transform, InvertibleTransform): + raise RuntimeError(f"Applied OneOf transform is not invertible (applied index: {index}).") + + # apply the inverse + return _transform.inverse(data) diff --git a/tests/test_one_of.py b/tests/test_one_of.py new file mode 100644 index 0000000000..d45d0f3f61 --- /dev/null +++ b/tests/test_one_of.py @@ -0,0 +1,181 @@ +# Copyright 2020 - 2021 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 +from copy import deepcopy + +from parameterized import parameterized + +from monai.transforms import InvertibleTransform, OneOf, Transform +from monai.transforms.compose import Compose +from monai.transforms.transform import MapTransform +from monai.utils.enums import InverseKeys + + +class X(Transform): + def __call__(self, x): + return x + + +class Y(Transform): + def __call__(self, x): + return x + + +class A(Transform): + def __call__(self, x): + return x + 1 + + +class B(Transform): + def __call__(self, x): + return x + 2 + + +class C(Transform): + def __call__(self, x): + return x + 3 + + +class MapBase(MapTransform): + def __init__(self, keys): + super().__init__(keys) + self.fwd_fn, self.inv_fn = None, None + + def __call__(self, data): + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + d[key] = self.fwd_fn(d[key]) + return d + + +class NonInv(MapBase): + def __init__(self, keys): + super().__init__(keys) + self.fwd_fn = lambda x: x * 2 + + +class Inv(MapBase, InvertibleTransform): + def __call__(self, data): + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + d[key] = self.fwd_fn(d[key]) + self.push_transform(d, key) + return d + + def inverse(self, data): + d = deepcopy(dict(data)) + for key in self.key_iterator(d): + d[key] = self.inv_fn(d[key]) + self.pop_transform(d, key) + return d + + +class InvA(Inv): + def __init__(self, keys): + super().__init__(keys) + self.fwd_fn = lambda x: x + 1 + self.inv_fn = lambda x: x - 1 + + +class InvB(Inv): + def __init__(self, keys): + super().__init__(keys) + self.fwd_fn = lambda x: x + 100 + self.inv_fn = lambda x: x - 100 + + +TESTS = [ + ((X(), Y(), X()), (1, 2, 1), (0.25, 0.5, 0.25)), +] + +KEYS = ["x", "y"] +TEST_INVERSES = [ + (OneOf((InvA(KEYS), InvB(KEYS))), True), + (OneOf((OneOf((InvA(KEYS), InvB(KEYS))), OneOf((InvB(KEYS), InvA(KEYS))))), True), + (OneOf((Compose((InvA(KEYS), InvB(KEYS))), Compose((InvB(KEYS), InvA(KEYS))))), True), + (OneOf((NonInv(KEYS), NonInv(KEYS))), False), +] + + +class TestOneOf(unittest.TestCase): + @parameterized.expand(TESTS) + def test_normalize_weights(self, transforms, input_weights, expected_weights): + tr = OneOf(transforms, input_weights) + self.assertTupleEqual(tr.weights, expected_weights) + + def test_no_weights_arg(self): + p = OneOf((X(), Y(), X(), Y())) + expected_weights = (0.25,) * 4 + self.assertTupleEqual(p.weights, expected_weights) + + def test_len_and_flatten(self): + p1 = OneOf((X(), Y()), (1, 3)) # 0.25, 0.75 + p2 = OneOf((Y(), Y()), (2, 2)) # 0.5. 0.5 + p = OneOf((p1, p2, X()), (1, 2, 1)) # 0.25, 0.5, 0.25 + expected_order = (X, Y, Y, Y, X) + expected_weights = (0.25 * 0.25, 0.25 * 0.75, 0.5 * 0.5, 0.5 * 0.5, 0.25) + self.assertEqual(len(p), len(expected_order)) + self.assertTupleEqual(p.flatten().weights, expected_weights) + + def test_compose_flatten_does_not_affect_one_of(self): + p = Compose([A(), B(), OneOf([C(), Inv(KEYS), Compose([X(), Y()])])]) + f = p.flatten() + # in this case the flattened transform should be the same. + + def _match(a, b): + self.assertEqual(type(a), type(b)) + for a_, b_ in zip(a.transforms, b.transforms): + self.assertEqual(type(a_), type(b_)) + if isinstance(a_, (Compose, OneOf)): + _match(a_, b_) + + _match(p, f) + + @parameterized.expand(TEST_INVERSES) + def test_inverse(self, transform, should_be_ok): + data = {k: (i + 1) * 10.0 for i, k in enumerate(KEYS)} + fwd_data = transform(data) + if not should_be_ok: + with self.assertRaises(RuntimeError): + transform.inverse(fwd_data) + return + + for k in KEYS: + t = fwd_data[k + InverseKeys.KEY_SUFFIX][-1] + # make sure the OneOf index was stored + self.assertEqual(t[InverseKeys.CLASS_NAME], OneOf.__name__) + # make sure index exists and is in bounds + self.assertTrue(0 <= t[InverseKeys.EXTRA_INFO]["index"] < len(transform)) + + # call the inverse + fwd_inv_data = transform.inverse(fwd_data) + + for k in KEYS: + # check transform was removed + self.assertTrue(len(fwd_inv_data[k + InverseKeys.KEY_SUFFIX]) < len(fwd_data[k + InverseKeys.KEY_SUFFIX])) + # check data is same as original (and different from forward) + self.assertEqual(fwd_inv_data[k], data[k]) + self.assertNotEqual(fwd_inv_data[k], fwd_data[k]) + + def test_one_of(self): + p = OneOf((A(), B(), C()), (1, 2, 1)) + counts = [0] * 3 + for _i in range(10000): + out = p(1.0) + counts[int(out - 2)] += 1 + self.assertAlmostEqual(counts[0] / 10000, 0.25, delta=1.0) + self.assertAlmostEqual(counts[1] / 10000, 0.50, delta=1.0) + self.assertAlmostEqual(counts[2] / 10000, 0.25, delta=1.0) + + +if __name__ == "__main__": + unittest.main() From a7d45741cca2235038ad01bf654e104e563355f3 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 13 Aug 2021 01:59:30 +0100 Subject: [PATCH 28/89] 2743 - no extension check for user specified reader (#2751) * no extension check for user specified reader; allow Path object --- docs/source/data.rst | 7 +- monai/data/image_reader.py | 47 +++++++-- monai/data/nifti_saver.py | 3 +- monai/data/png_saver.py | 3 +- monai/data/utils.py | 6 +- monai/transforms/__init__.py | 2 +- monai/transforms/io/array.py | 169 +++++++++++++++++++----------- monai/transforms/io/dictionary.py | 37 +++++-- tests/test_load_image.py | 49 ++++++++- tests/test_load_imaged.py | 9 +- tests/test_nifti_endianness.py | 4 + tests/test_nifti_saver.py | 3 +- tests/test_png_saver.py | 3 +- 13 files changed, 244 insertions(+), 98 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 022f7877d1..6e7f5e2773 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -74,7 +74,7 @@ Generic Interfaces .. autoclass:: ImageDataset :members: :special-members: __getitem__ - + `NPZDictItemDataset` ~~~~~~~~~~~~~~~~~~~~ .. autoclass:: NPZDictItemDataset @@ -108,6 +108,11 @@ Patch-based dataset Image reader ------------ +ImageReader +~~~~~~~~~~~ +.. autoclass:: ImageReader + :members: + ITKReader ~~~~~~~~~ .. autoclass:: ITKReader diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 0c736a548d..1e3a89eb31 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -45,16 +45,30 @@ class ImageReader(ABC): - """Abstract class to define interface APIs to load image files. - users need to call `read` to load image and then use `get_data` - to get the image data and properties from meta data. + """ + An abstract class defines APIs to load image files. + + Typical usage of an implementation of this class is: + + .. code-block:: python + + image_reader = MyImageReader() + img_obj = image_reader.read(path_to_image) + img_data, meta_data = image_reader.get_data(img_obj) + + - The `read` call converts image filenames into image objects, + - The `get_data` call fetches the image data, as well as meta data. + - A reader should implement `verify_suffix` with the logic of checking the input filename + by the filename extensions. """ @abstractmethod def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: """ - Verify whether the specified file or files format is supported by current reader. + Verify whether the specified `filename` is supported by the current reader. + This method should return True if the reader is able to read the format suggested by the + `filename`. Args: filename: file name or a list of file names to read. @@ -67,7 +81,7 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: def read(self, data: Union[Sequence[str], str], **kwargs) -> Union[Sequence[Any], Any]: """ Read image data from specified file or files. - Note that it returns the raw data, so different readers return different image data type. + Note that it returns a data object or a sequence of data objects. Args: data: file name or a list of file names to read. @@ -80,7 +94,8 @@ def read(self, data: Union[Sequence[str], str], **kwargs) -> Union[Sequence[Any] def get_data(self, img) -> Tuple[np.ndarray, Dict]: """ Extract data array and meta data from loaded image and return them. - This function must return 2 objects, first is numpy array of image data, second is dict of meta data. + This function must return two objects, the first is a numpy array of image data, + the second is a dictionary of meta data. Args: img: an image object loaded from an image file or a list of image objects. @@ -124,7 +139,7 @@ def _stack_images(image_list: List, meta_dict: Dict): class ITKReader(ImageReader): """ Load medical images based on ITK library. - All the supported image formats can be found: + All the supported image formats can be found at: https://github.com/InsightSoftwareConsortium/ITK/tree/master/Modules/IO The loaded data array will be in C order, for example, a 3D image NumPy array index order will be `CDWH`. @@ -396,7 +411,10 @@ def _get_meta_dict(self, img) -> Dict: """ # swap to little endian as PyTorch doesn't support big endian - header = img.header.as_byteswapped("<") + try: + header = img.header.as_byteswapped("<") + except ValueError: + header = img.header return dict(header) def _get_affine(self, img): @@ -419,11 +437,18 @@ def _get_spatial_shape(self, img): """ # swap to little endian as PyTorch doesn't support big endian - header = img.header.as_byteswapped("<") - ndim = header["dim"][0] + try: + header = img.header.as_byteswapped("<") + except ValueError: + header = img.header + dim = header.get("dim", None) + if dim is None: + dim = header.get("dims") # mgh format? + dim = np.insert(dim, 0, 3) + ndim = dim[0] spatial_rank = min(ndim, 3) # the img data should have no channel dim or the last dim is channel - return np.asarray(header["dim"][1 : spatial_rank + 1]) + return np.asarray(dim[1 : spatial_rank + 1]) def _get_array_data(self, img): """ diff --git a/monai/data/nifti_saver.py b/monai/data/nifti_saver.py index 2aa9b44058..b7067def73 100644 --- a/monai/data/nifti_saver.py +++ b/monai/data/nifti_saver.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path from typing import Dict, Optional, Union import numpy as np @@ -36,7 +37,7 @@ class NiftiSaver: def __init__( self, - output_dir: str = "./", + output_dir: Union[Path, str] = "./", output_postfix: str = "seg", output_ext: str = ".nii.gz", resample: bool = True, diff --git a/monai/data/png_saver.py b/monai/data/png_saver.py index d0aa787850..e6fb641cca 100644 --- a/monai/data/png_saver.py +++ b/monai/data/png_saver.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path from typing import Dict, Optional, Union import numpy as np @@ -33,7 +34,7 @@ class PNGSaver: def __init__( self, - output_dir: str = "./", + output_dir: Union[Path, str] = "./", output_postfix: str = "seg", output_ext: str = ".png", resample: bool = True, diff --git a/monai/data/utils.py b/monai/data/utils.py index 737b2f84b5..25b3c24e4a 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -19,7 +19,7 @@ from copy import deepcopy from functools import reduce from itertools import product, starmap -from pathlib import PurePath +from pathlib import Path, PurePath from typing import Any, Dict, Generator, Iterable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np @@ -492,6 +492,8 @@ def correct_nifti_header_if_necessary(img_nii): Args: img_nii: nifti image object """ + if img_nii.header.get("dim") is None: + return img_nii # not nifti? dim = img_nii.header["dim"][0] if dim >= 5: return img_nii # do nothing for high-dimensional array @@ -677,7 +679,7 @@ def to_affine_nd(r: Union[np.ndarray, int], affine: np.ndarray) -> np.ndarray: def create_file_basename( postfix: str, input_file_name: str, - folder_path: str, + folder_path: Union[Path, str], data_root_dir: str = "", separate_folder: bool = True, patch_index: Optional[int] = None, diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 99d9c2b8b8..d15b8866e5 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -193,7 +193,7 @@ ) from .inverse import InvertibleTransform from .inverse_batch_transform import BatchInverseTransform, Decollated -from .io.array import LoadImage, SaveImage +from .io.array import SUPPORTED_READERS, LoadImage, SaveImage from .io.dictionary import LoadImaged, LoadImageD, LoadImageDict, SaveImaged, SaveImageD, SaveImageDict from .nvtx import ( Mark, diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index a8e9ed1e7c..b8e2f75508 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -13,7 +13,11 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ +import inspect +import logging import sys +import warnings +from pathlib import Path from typing import Dict, List, Optional, Sequence, Union import numpy as np @@ -32,7 +36,14 @@ nib, _ = optional_import("nibabel") Image, _ = optional_import("PIL.Image") -__all__ = ["LoadImage", "SaveImage"] +__all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS"] + +SUPPORTED_READERS = { + "itkreader": ITKReader, + "numpyreader": NumpyReader, + "pilreader": PILReader, + "nibabelreader": NibabelReader, +} def switch_endianness(data, new="<"): @@ -57,87 +68,104 @@ def switch_endianness(data, new="<"): data = [switch_endianness(x, new) for x in data] elif isinstance(data, dict): data = {k: switch_endianness(v, new) for k, v in data.items()} - elif isinstance(data, (bool, str, float, int, type(None))): - pass - else: - raise AssertionError(f"Unknown type: {type(data).__name__}") + elif not isinstance(data, (bool, str, float, int, type(None))): + raise RuntimeError(f"Unknown type: {type(data).__name__}") return data class LoadImage(Transform): """ Load image file or files from provided path based on reader. - Automatically choose readers based on the supported suffixes and in below order: - - User specified reader at runtime when call this loader. - - Registered readers from the latest to the first in list. - - Default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (others -> ITKReader). + If reader is not specified, this class automatically chooses readers + based on the supported suffixes and in the following order: + + - User-specified reader at runtime when calling this loader. + - User-specified reader in the constructor of `LoadImage`. + - Readers from the last to the first in the registered list. + - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), + (npz, npy -> NumpyReader), (others -> ITKReader). + + See also: + + - tutorial: https://github.com/Project-MONAI/tutorials/blob/master/modules/load_medical_images.ipynb """ - def __init__( - self, - reader: Optional[Union[ImageReader, str]] = None, - image_only: bool = False, - dtype: DtypeLike = np.float32, - *args, - **kwargs, - ) -> None: + def __init__(self, reader=None, image_only: bool = False, dtype: DtypeLike = np.float32, *args, **kwargs) -> None: """ Args: - reader: register reader to load image file and meta data, if None, still can register readers - at runtime or use the default readers. If a string of reader name provided, will construct - a reader object with the `*args` and `**kwargs` parameters, supported reader name: "NibabelReader", - "PILReader", "ITKReader", "NumpyReader". + reader: reader to load image file and meta data + + - if `reader` is None, a default set of `SUPPORTED_READERS` will be used. + - if `reader` is a string, the corresponding item in `SUPPORTED_READERS` will be used, + and a reader instance will be constructed with the `*args` and `**kwargs` parameters. + the supported reader names are: "nibabelreader", "pilreader", "itkreader", "numpyreader". + - if `reader` is a reader class/instance, it will be registered to this loader accordingly. + image_only: if True return only the image volume, otherwise return image data array and header dict. dtype: if not None convert the loaded image to this data type. args: additional parameters for reader if providing a reader name. kwargs: additional parameters for reader if providing a reader name. Note: - The transform returns image data array if `image_only` is True, - or a tuple of two elements containing the data array, and the meta data in a dict format otherwise. + + - The transform returns an image data array if `image_only` is True, + or a tuple of two elements containing the data array, and the meta data in a dictionary format otherwise. + - If `reader` is specified, the loader will attempt to use the specified readers and the default supported + readers. This might introduce overheads when handling the exceptions of trying the incompatible loaders. + In this case, it is therefore recommended to set the most appropriate reader as + the last item of the `reader` parameter. """ - # set predefined readers as default - self.readers: List[ImageReader] = [ITKReader(), NumpyReader(), PILReader(), NibabelReader()] - if reader is not None: - if isinstance(reader, str): - supported_readers = { - "nibabelreader": NibabelReader, - "pilreader": PILReader, - "itkreader": ITKReader, - "numpyreader": NumpyReader, - } - the_reader = look_up_option(reader.lower(), supported_readers) - self.register(the_reader(*args, **kwargs)) - else: - self.register(reader) + self.auto_select = reader is None self.image_only = image_only self.dtype = dtype - def register(self, reader: ImageReader) -> List[ImageReader]: + self.readers: List[ImageReader] = [] + for r in SUPPORTED_READERS: # set predefined readers as default + try: + self.register(SUPPORTED_READERS[r](*args, **kwargs)) + except TypeError: # the reader doesn't have the corresponding args/kwargs + logging.getLogger(self.__class__.__name__).debug( + f"{r} is not supported with the given parameters {args} {kwargs}." + ) + self.register(SUPPORTED_READERS[r]()) + if reader is None: + return # no user-specified reader, no need to register + + for _r in ensure_tuple(reader): + if isinstance(_r, str): + the_reader = look_up_option(_r.lower(), SUPPORTED_READERS) + try: + self.register(the_reader(*args, **kwargs)) + except TypeError: # the reader doesn't have the corresponding args/kwargs + warnings.warn(f"{r} is not supported with the given parameters {args} {kwargs}.") + self.register(the_reader()) + elif inspect.isclass(_r): + self.register(_r(*args, **kwargs)) + else: + self.register(_r) # reader instance, ignoring the constructor args/kwargs + return + + def register(self, reader: ImageReader): """ - Register image reader to load image file and meta data, latest registered reader has higher priority. - Return all the registered image readers. + Register image reader to load image file and meta data. Args: - reader: registered reader to load image file and meta data based on suffix, - if all registered readers can't match suffix at runtime, use the default readers. + reader: reader instance to be registered with this loader. """ if not isinstance(reader, ImageReader): - raise ValueError(f"reader must be ImageReader object, but got {type(reader)}.") + warnings.warn(f"Preferably the reader should inherit ImageReader, but got {type(reader)}.") self.readers.append(reader) - return self.readers - def __call__( - self, - filename: Union[Sequence[str], str], - reader: Optional[ImageReader] = None, - ): + def __call__(self, filename: Union[Sequence[str], str, Path, Sequence[Path]], reader: Optional[ImageReader] = None): """ + Load image file and meta data from the given filename(s). + If `reader` is not specified, this class automatically chooses readers based on the + reversed order of registered readers `self.readers`. + Args: filename: path file or file-like object or a list of files. will save the filename to meta_data with key `filename_or_obj`. @@ -145,21 +173,34 @@ def __call__( reader: runtime reader to load image file and meta data. """ - if reader is None or not reader.verify_suffix(filename): - for r in reversed(self.readers): - if r.verify_suffix(filename): - reader = r - break - - if reader is None: + filename = tuple(str(s) for s in ensure_tuple(filename)) # allow Path objects + img = None + if reader is not None: + img = reader.read(filename) # runtime specified reader + else: + for reader in self.readers[::-1]: + if self.auto_select: # rely on the filename extension to choose the reader + if reader.verify_suffix(filename): + img = reader.read(filename) + break + else: # try the user designated readers + try: + img = reader.read(filename) + except Exception as e: + logging.getLogger(self.__class__.__name__).debug( + f"{reader.__class__.__name__}: unable to load {filename}.\n" f"Error: {e}" + ) + else: + break + + if img is None or reader is None: raise RuntimeError( - f"can not find suitable reader for this file: {filename}. \ - Please install dependency libraries: (nii, nii.gz) -> Nibabel, (png, jpg, bmp) -> PIL, \ - (npz, npy) -> Numpy, others -> ITK. Refer to the installation instruction: \ - https://docs.monai.io/en/latest/installation.html#installing-the-recommended-dependencies." + f"can not find a suitable reader for file: {filename}.\n" + " Please install the reader libraries, see also the installation instructions:\n" + " https://docs.monai.io/en/latest/installation.html#installing-the-recommended-dependencies.\n" + f" The current registered: {self.readers}.\n" ) - img = reader.read(filename) img_array, meta_data = reader.get_data(img) img_array = img_array.astype(self.dtype) @@ -241,7 +282,7 @@ class SaveImage(Transform): def __init__( self, - output_dir: str = "./", + output_dir: Union[Path, str] = "./", output_postfix: str = "trans", output_ext: str = ".nii.gz", resample: bool = True, @@ -256,7 +297,7 @@ def __init__( print_log: bool = True, ) -> None: self.saver: Union[NiftiSaver, PNGSaver] - if output_ext in (".nii.gz", ".nii"): + if output_ext in {".nii.gz", ".nii"}: self.saver = NiftiSaver( output_dir=output_dir, output_postfix=output_postfix, diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index db043848c7..764e20f838 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -15,6 +15,7 @@ Class names are ended with 'd' to denote dictionary-based transforms. """ +from pathlib import Path from typing import Optional, Union import numpy as np @@ -38,17 +39,31 @@ class LoadImaged(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.LoadImage`, - must load image and metadata together. If loading a list of files in one key, - stack them together and add a new dimension as the first dimension, and use the - meta data of the first image to represent the stacked result. Note that the affine - transform of all the stacked images should be same. The output metadata field will - be created as ``meta_keys`` or ``key_{meta_key_postfix}``. + It can load both image data and metadata. When loading a list of files in one key, + the arrays will be stacked and a new dimension will be added as the first dimension + In this case, the meta data of the first image will be used to represent the stacked result. + The affine transform of all the stacked images should be same. + The output metadata field will be created as ``meta_keys`` or ``key_{meta_key_postfix}``. - It can automatically choose readers based on the supported suffixes and in below order: - - User specified reader at runtime when call this loader. - - Registered readers from the latest to the first in list. - - Default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (others -> ITKReader). + If reader is not specified, this class automatically chooses readers + based on the supported suffixes and in the following order: + + - User-specified reader at runtime when calling this loader. + - User-specified reader in the constructor of `LoadImage`. + - Readers from the last to the first in the registered list. + - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), + (npz, npy -> NumpyReader), (others -> ITKReader). + + Note: + + - If `reader` is specified, the loader will attempt to use the specified readers and the default supported + readers. This might introduce overheads when handling the exceptions of trying the incompatible loaders. + In this case, it is therefore recommended to set the most appropriate reader as + the last item of the `reader` parameter. + + See also: + + - tutorial: https://github.com/Project-MONAI/tutorials/blob/master/modules/load_medical_images.ipynb """ @@ -209,7 +224,7 @@ def __init__( keys: KeysCollection, meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = "meta_dict", - output_dir: str = "./", + output_dir: Union[Path, str] = "./", output_postfix: str = "trans", output_ext: str = ".nii.gz", resample: bool = True, diff --git a/tests/test_load_image.py b/tests/test_load_image.py index 7b325e7565..2aa6eced65 100644 --- a/tests/test_load_image.py +++ b/tests/test_load_image.py @@ -12,6 +12,7 @@ import os import tempfile import unittest +from pathlib import Path import itk import nibabel as nib @@ -22,6 +23,23 @@ from monai.data import ITKReader, NibabelReader from monai.transforms import LoadImage + +class _MiniReader: + """a test case customised reader""" + + def __init__(self, is_compatible=False): + self.is_compatible = is_compatible + + def verify_suffix(self, _name): + return self.is_compatible + + def read(self, name): + return name + + def get_data(self, _obj): + return np.zeros((1, 1, 1)), {"name": "my test"} + + TEST_CASE_1 = [{"image_only": True}, ["test_image.nii.gz"], (128, 128, 128)] TEST_CASE_2 = [{"image_only": False}, ["test_image.nii.gz"], (128, 128, 128)] @@ -32,12 +50,24 @@ (3, 128, 128, 128), ] +TEST_CASE_3_1 = [ # .mgz format + {"image_only": True, "reader": "nibabelreader"}, + ["test_image.mgz", "test_image2.mgz", "test_image3.mgz"], + (3, 128, 128, 128), +] + TEST_CASE_4 = [ {"image_only": False}, ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], (3, 128, 128, 128), ] +TEST_CASE_4_1 = [ # additional parameter + {"image_only": False, "mmap": False}, + ["test_image.nii.gz", "test_image2.nii.gz", "test_image3.nii.gz"], + (3, 128, 128, 128), +] + TEST_CASE_5 = [ {"reader": NibabelReader(mmap=False), "image_only": False}, ["test_image.nii.gz"], @@ -74,7 +104,9 @@ class TestLoadImage(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand( + [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_3_1, TEST_CASE_4, TEST_CASE_4_1, TEST_CASE_5] + ) def test_nibabel_reader(self, input_param, filenames, expected_shape): test_image = np.random.rand(128, 128, 128) with tempfile.TemporaryDirectory() as tempdir: @@ -135,7 +167,7 @@ def test_itk_reader_multichannel(self): filename = os.path.join(tempdir, "test_image.png") itk_np_view = itk.image_view_from_array(test_image, is_vector=True) itk.imwrite(itk_np_view, filename) - result, header = LoadImage(reader=ITKReader())(filename) + result, header = LoadImage(reader=ITKReader())(Path(filename)) self.assertTupleEqual(tuple(header["spatial_shape"]), (224, 256)) np.testing.assert_allclose(result[:, :, 0], test_image[:, :, 0].T) @@ -169,7 +201,6 @@ def test_register(self): def test_kwargs(self): spatial_size = (32, 64, 128) - expected_shape = (128, 64, 32) test_image = np.random.rand(*spatial_size) with tempfile.TemporaryDirectory() as tempdir: filename = os.path.join(tempdir, "test_image.nii.gz") @@ -187,6 +218,18 @@ def test_kwargs(self): np.testing.assert_allclose(header["spatial_shape"], header_raw["spatial_shape"]) self.assertTupleEqual(result.shape, result_raw.shape) + def test_my_reader(self): + """test customised readers""" + out = LoadImage(reader=_MiniReader, is_compatible=True)("test") + self.assertEqual(out[1]["name"], "my test") + out = LoadImage(reader=_MiniReader, is_compatible=False)("test") + self.assertEqual(out[1]["name"], "my test") + for item in (_MiniReader, _MiniReader(is_compatible=False)): + out = LoadImage(reader=item)("test") + self.assertEqual(out[1]["name"], "my test") + out = LoadImage()("test", reader=_MiniReader(is_compatible=False)) + self.assertEqual(out[1]["name"], "my test") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_load_imaged.py b/tests/test_load_imaged.py index 2877b1cd57..ca5b56a7d9 100644 --- a/tests/test_load_imaged.py +++ b/tests/test_load_imaged.py @@ -12,6 +12,7 @@ import os import tempfile import unittest +from pathlib import Path import itk import nibabel as nib @@ -52,7 +53,7 @@ def test_register(self): loader = LoadImaged(keys="img") loader.register(ITKReader()) - result = loader({"img": filename}) + result = loader({"img": Path(filename)}) self.assertTupleEqual(tuple(result["img_meta_dict"]["spatial_shape"]), spatial_size[::-1]) self.assertTupleEqual(result["img"].shape, spatial_size[::-1]) @@ -69,6 +70,12 @@ def test_channel_dim(self): self.assertTupleEqual(tuple(result["img_meta_dict"]["spatial_shape"]), (32, 64, 128)) self.assertTupleEqual(result["img"].shape, (3, 32, 64, 128)) + def test_no_file(self): + with self.assertRaises(RuntimeError): + LoadImaged(keys="img")({"img": "unknown"}) + with self.assertRaises(RuntimeError): + LoadImaged(keys="img", reader="nibabelreader")({"img": "unknown"}) + class TestConsistency(unittest.TestCase): def _cmp(self, filename, shape, ch_shape, reader_1, reader_2, outname, ext): diff --git a/tests/test_nifti_endianness.py b/tests/test_nifti_endianness.py index 39cbed7795..bf0f27b9ca 100644 --- a/tests/test_nifti_endianness.py +++ b/tests/test_nifti_endianness.py @@ -12,6 +12,7 @@ import os import tempfile import unittest +from pathlib import Path from typing import TYPE_CHECKING, List, Tuple from unittest.case import skipUnless @@ -85,6 +86,9 @@ def test_switch(self): # verify data types with self.assertRaises(NotImplementedError): switch_endianness(np.zeros((2, 1)), "=") + with self.assertRaises(RuntimeError): + switch_endianness(Path("test"), "<") + @skipUnless(has_pil, "Requires PIL") def test_pil(self): tempdir = tempfile.mkdtemp() diff --git a/tests/test_nifti_saver.py b/tests/test_nifti_saver.py index f48374a61c..c07084172f 100644 --- a/tests/test_nifti_saver.py +++ b/tests/test_nifti_saver.py @@ -12,6 +12,7 @@ import os import tempfile import unittest +from pathlib import Path import numpy as np import torch @@ -24,7 +25,7 @@ class TestNiftiSaver(unittest.TestCase): def test_saved_content(self): with tempfile.TemporaryDirectory() as tempdir: - saver = NiftiSaver(output_dir=tempdir, output_postfix="seg", output_ext=".nii.gz") + saver = NiftiSaver(output_dir=Path(tempdir), output_postfix="seg", output_ext=".nii.gz") meta_data = {"filename_or_obj": ["testfile" + str(i) + ".nii" for i in range(8)]} saver.save_batch(torch.zeros(8, 1, 2, 2), meta_data) diff --git a/tests/test_png_saver.py b/tests/test_png_saver.py index dbc41dfd75..f8ea1df54b 100644 --- a/tests/test_png_saver.py +++ b/tests/test_png_saver.py @@ -12,6 +12,7 @@ import os import tempfile import unittest +from pathlib import Path import torch @@ -33,7 +34,7 @@ def test_saved_content(self): def test_saved_content_three_channel(self): with tempfile.TemporaryDirectory() as tempdir: - saver = PNGSaver(output_dir=tempdir, output_postfix="seg", output_ext=".png", scale=255) + saver = PNGSaver(output_dir=Path(tempdir), output_postfix="seg", output_ext=".png", scale=255) meta_data = {"filename_or_obj": ["testfile" + str(i) + ".jpg" for i in range(8)]} saver.save_batch(torch.randint(1, 200, (8, 3, 2, 2)), meta_data) From 2ca97103a5d0340e4f61cc6733ef2cf26fb86c7e Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 13 Aug 2021 14:51:24 +0800 Subject: [PATCH 29/89] 2746 Add round support to AsDiscrete transform (#2753) * [DLMED] add round_values Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma * [DLMED] update according to comments Signed-off-by: Nic Ma --- monai/transforms/post/array.py | 17 +++++++++++++++-- monai/transforms/post/dictionary.py | 10 ++++++++-- tests/test_as_discrete.py | 9 ++++++++- tests/test_as_discreted.py | 9 ++++++++- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index c7558eddc3..7b3e7b4fd2 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -25,7 +25,7 @@ from monai.networks.layers import GaussianFilter from monai.transforms.transform import Transform from monai.transforms.utils import fill_holes, get_largest_connected_component_mask -from monai.utils import ensure_tuple +from monai.utils import ensure_tuple, look_up_option __all__ = [ "Activations", @@ -112,7 +112,8 @@ class AsDiscrete(Transform): - execute `argmax` for input logits values. - threshold input value to 0.0 or 1.0. - - convert input value to One-Hot format + - convert input value to One-Hot format. + - round the value to the closest integer. Args: argmax: whether to execute argmax function on input data before transform. @@ -125,6 +126,8 @@ class AsDiscrete(Transform): Defaults to ``False``. logit_thresh: the threshold value for thresholding operation.. Defaults to ``0.5``. + rounding: if not None, round the data according to the specified option, + available options: ["torchrounding"]. """ @@ -135,12 +138,14 @@ def __init__( n_classes: Optional[int] = None, threshold_values: bool = False, logit_thresh: float = 0.5, + rounding: Optional[str] = None, ) -> None: self.argmax = argmax self.to_onehot = to_onehot self.n_classes = n_classes self.threshold_values = threshold_values self.logit_thresh = logit_thresh + self.rounding = rounding def __call__( self, @@ -150,6 +155,7 @@ def __call__( n_classes: Optional[int] = None, threshold_values: Optional[bool] = None, logit_thresh: Optional[float] = None, + rounding: Optional[str] = None, ) -> torch.Tensor: """ Args: @@ -165,6 +171,8 @@ def __call__( Defaults to ``self.threshold_values``. logit_thresh: the threshold value for thresholding operation.. Defaults to ``self.logit_thresh``. + rounding: if not None, round the data according to the specified option, + available options: ["torchrounding"]. """ if argmax or self.argmax: @@ -179,6 +187,11 @@ def __call__( if threshold_values or self.threshold_values: img = img >= (self.logit_thresh if logit_thresh is None else logit_thresh) + rounding = self.rounding if rounding is None else rounding + if rounding is not None: + rounding = look_up_option(rounding, ["torchrounding"]) + img = torch.round(img) + return img.float() diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 0d9be131fc..d4e039339b 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -134,6 +134,7 @@ def __init__( n_classes: Optional[Union[Sequence[int], int]] = None, threshold_values: Union[Sequence[bool], bool] = False, logit_thresh: Union[Sequence[float], float] = 0.5, + rounding: Union[Sequence[Optional[str]], Optional[str]] = None, allow_missing_keys: bool = False, ) -> None: """ @@ -150,6 +151,9 @@ def __init__( it also can be a sequence of bool, each element corresponds to a key in ``keys``. logit_thresh: the threshold value for thresholding operation, default is 0.5. it also can be a sequence of float, each element corresponds to a key in ``keys``. + rounding: if not None, round the data according to the specified option, + available options: ["torchrounding"]. it also can be a sequence of str or None, + each element corresponds to a key in ``keys``. allow_missing_keys: don't raise exception if key is missing. """ @@ -159,12 +163,13 @@ def __init__( self.n_classes = ensure_tuple_rep(n_classes, len(self.keys)) self.threshold_values = ensure_tuple_rep(threshold_values, len(self.keys)) self.logit_thresh = ensure_tuple_rep(logit_thresh, len(self.keys)) + self.rounding = ensure_tuple_rep(rounding, len(self.keys)) self.converter = AsDiscrete() def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key, argmax, to_onehot, n_classes, threshold_values, logit_thresh in self.key_iterator( - d, self.argmax, self.to_onehot, self.n_classes, self.threshold_values, self.logit_thresh + for key, argmax, to_onehot, n_classes, threshold_values, logit_thresh, rounding in self.key_iterator( + d, self.argmax, self.to_onehot, self.n_classes, self.threshold_values, self.logit_thresh, self.rounding ): d[key] = self.converter( d[key], @@ -173,6 +178,7 @@ def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torc n_classes, threshold_values, logit_thresh, + rounding, ) return d diff --git a/tests/test_as_discrete.py b/tests/test_as_discrete.py index ea806be139..b87fafd8f3 100644 --- a/tests/test_as_discrete.py +++ b/tests/test_as_discrete.py @@ -44,9 +44,16 @@ (3,), ] +TEST_CASE_5 = [ + {"rounding": "torchrounding"}, + torch.tensor([[[0.123, 1.345], [2.567, 3.789]]]), + torch.tensor([[[0.0, 1.0], [3.0, 4.0]]]), + (1, 2, 2), +] + class TestAsDiscrete(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) def test_value_shape(self, input_param, img, out, expected_shape): result = AsDiscrete(**input_param)(img) torch.testing.assert_allclose(result, out) diff --git a/tests/test_as_discreted.py b/tests/test_as_discreted.py index d6a6f3c2a4..ac594f0daa 100644 --- a/tests/test_as_discreted.py +++ b/tests/test_as_discreted.py @@ -58,9 +58,16 @@ (2, 1, 2), ] +TEST_CASE_4 = [ + {"keys": "pred", "rounding": "torchrounding"}, + {"pred": torch.tensor([[[0.123, 1.345], [2.567, 3.789]]])}, + {"pred": torch.tensor([[[0.0, 1.0], [3.0, 4.0]]])}, + (1, 2, 2), +] + class TestAsDiscreted(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_value_shape(self, input_param, test_input, output, expected_shape): result = AsDiscreted(**input_param)(test_input) torch.testing.assert_allclose(result["pred"], output["pred"]) From 6f12e18af50659516c31742c40133d31d97b39f2 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 13 Aug 2021 19:11:03 +0800 Subject: [PATCH 30/89] 2749 Fix ThreadDataLoader reset (#2750) * [DLMED] fix continuously run Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot * [DLMED] remove out-date comment Signed-off-by: Nic Ma Co-authored-by: monai-bot --- monai/data/thread_buffer.py | 6 ++---- tests/test_thread_buffer.py | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index 8ea71e3555..2901335bd5 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -87,8 +87,6 @@ class ThreadDataLoader(DataLoader): def __init__(self, dataset: Dataset, num_workers: int = 0, **kwargs): super().__init__(dataset, num_workers, **kwargs) - # ThreadBuffer will use the inherited __iter__ instead of the one defined below - self.buffer = ThreadBuffer(super().__iter__()) - def __iter__(self): - yield from self.buffer + buffer = ThreadBuffer(super().__iter__()) + yield from buffer diff --git a/tests/test_thread_buffer.py b/tests/test_thread_buffer.py index 1b3ebb910d..507b6909be 100644 --- a/tests/test_thread_buffer.py +++ b/tests/test_thread_buffer.py @@ -48,6 +48,8 @@ def test_dataloader(self): for d in dataloader: self.assertEqual(d["image"][0], "spleen_19.nii.gz") self.assertEqual(d["image"][1], "spleen_31.nii.gz") + + for d in dataloader: self.assertEqual(d["label"][0], "spleen_label_19.nii.gz") self.assertEqual(d["label"][1], "spleen_label_31.nii.gz") From e91065c966ec7c8599ac6bb5487aace36a3133e3 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 13 Aug 2021 21:44:54 +0800 Subject: [PATCH 31/89] [DLMED] enhance partition_data_classes (#2761) Signed-off-by: Nic Ma --- monai/data/utils.py | 2 +- tests/test_partition_dataset_classes.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/monai/data/utils.py b/monai/data/utils.py index 25b3c24e4a..aab23217dc 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -960,7 +960,7 @@ def partition_dataset_classes( [[2, 8, 4, 1, 3, 6, 5, 11, 12], [10, 13, 7, 9, 14]] """ - if not classes or len(classes) != len(data): + if not issequenceiterable(classes) or len(classes) != len(data): raise ValueError(f"length of classes {classes} must match the dataset length {len(data)}.") datasets = [] class_indices = defaultdict(list) diff --git a/tests/test_partition_dataset_classes.py b/tests/test_partition_dataset_classes.py index 0e28b8f76a..3aef47107a 100644 --- a/tests/test_partition_dataset_classes.py +++ b/tests/test_partition_dataset_classes.py @@ -11,6 +11,7 @@ import unittest +import numpy as np from parameterized import parameterized from monai.data import partition_dataset_classes @@ -59,8 +60,8 @@ TEST_CASE_4 = [ { - "data": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], - "classes": [2, 0, 2, 1, 3, 2, 2, 0, 2, 0, 3, 3, 1, 3], + "data": np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]), + "classes": np.array([2, 0, 2, 1, 3, 2, 2, 0, 2, 0, 3, 3, 1, 3]), "ratios": [1, 2], "num_partitions": None, "shuffle": True, From 8b5ea0f97da9591569b5c6d23ab6bcfd483331e5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 13 Aug 2021 12:26:49 -0400 Subject: [PATCH 32/89] NVTX Range Transform (#2756) * Implement Range Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add tests for Range Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs for Range Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docstring Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add RandRange to tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 8 ++++ monai/transforms/__init__.py | 8 ++++ monai/transforms/nvtx.py | 53 ++++++++++++++++++++-- tests/test_nvtx_transform.py | 88 ++++++++++++++++++++++++++++++------ 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 7f970dfb15..a1bafaf103 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -361,6 +361,14 @@ NVIDIA Tool Extension (NVTX) """""""""""""" .. autoclass:: RandRangePop +`Range` +""""""" +.. autoclass:: Range + +`RandRange` +""""""""""" +.. autoclass:: RandRange + `Mark` """""" .. autoclass:: Mark diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index d15b8866e5..f259ff86bc 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -204,6 +204,10 @@ RandMarkd, RandMarkD, RandMarkDict, + RandRange, + RandRanged, + RandRangeD, + RandRangeDict, RandRangePop, RandRangePopd, RandRangePopD, @@ -212,6 +216,10 @@ RandRangePushd, RandRangePushD, RandRangePushDict, + Range, + Ranged, + RangeD, + RangeDict, RangePop, RangePopd, RangePopD, diff --git a/monai/transforms/nvtx.py b/monai/transforms/nvtx.py index 12c03dc028..a500eb3c90 100644 --- a/monai/transforms/nvtx.py +++ b/monai/transforms/nvtx.py @@ -12,6 +12,8 @@ Wrapper around NVIDIA Tools Extension for profiling MONAI transformations """ +from typing import Optional + from monai.transforms.transform import RandomizableTransform, Transform from monai.utils import optional_import @@ -26,6 +28,10 @@ "RandMarkd", "RandMarkD", "RandMarkDict", + "RandRange", + "RandRanged", + "RandRangeD", + "RandRangeDict", "RandRangePop", "RandRangePopd", "RandRangePopD", @@ -34,6 +40,10 @@ "RandRangePushd", "RandRangePushD", "RandRangePushDict", + "Range", + "Ranged", + "RangeD", + "RangeDict", "RangePop", "RangePopd", "RangePopD", @@ -91,6 +101,36 @@ class RandRangePop(RangePop, RandomizableTransform): """ +class Range(Transform): + """ + Pushes an NVTX range before a transform, and pops it afterwards. + Stores zero-based depth of the range that is started. + + Args: + msg: ASCII message to associate with range + """ + + def __init__(self, transform: Transform, msg: Optional[str] = None) -> None: + if msg is None: + msg = type(transform).__name__ + self.msg = msg + self.transform = transform + self.depth = None + + def __call__(self, data): + self.depth = _nvtx.rangePushA(self.msg) + data = self.transform(data) + _nvtx.rangePop() + return data + + +class RandRange(Range, RandomizableTransform): + """ + Pushes an NVTX range at the before a transfrom, and pops it afterwards.(RandomizableTransform). + Stores zero-based depth of the range that is ended. + """ + + class Mark(Transform): """ Mark an instantaneous event that occurred at some point. @@ -117,9 +157,14 @@ class RandMark(Mark, RandomizableTransform): """ -MarkDict = MarkD = Markd = Mark -RandMarkDict = RandMarkD = RandMarkd = RandMark -RandRangePopDict = RandRangePopD = RandRangePopd = RandRangePop +RangePushDict = RangePushD = RangePushd = RangePush RandRangePushDict = RandRangePushD = RandRangePushd = RandRangePush + RangePopDict = RangePopD = RangePopd = RangePop -RangePushDict = RangePushD = RangePushd = RangePush +RandRangePopDict = RandRangePopD = RandRangePopd = RandRangePop + +RangeDict = RangeD = Ranged = Range +RandRangeDict = RandRangeD = RandRanged = RandRange + +MarkDict = MarkD = Markd = Mark +RandMarkDict = RandMarkD = RandMarkd = RandMark diff --git a/tests/test_nvtx_transform.py b/tests/test_nvtx_transform.py index d1887377ba..01f2e80d26 100644 --- a/tests/test_nvtx_transform.py +++ b/tests/test_nvtx_transform.py @@ -21,10 +21,14 @@ MarkD, RandMark, RandMarkD, + RandRange, + RandRangeD, RandRangePop, RandRangePopD, RandRangePush, RandRangePushD, + Range, + RangeD, RangePop, RangePopD, RangePush, @@ -62,12 +66,12 @@ class TestNVTXTransforms(unittest.TestCase): def test_nvtx_transfroms_alone(self, input): transforms = Compose( [ - Mark("Mark: Transform Starts!"), + Mark("Mark: Transforms Start!"), RangePush("Range: RandFlipD"), RangePop(), RandRangePush("Range: ToTensorD"), RandRangePop(), - RandMark("Mark: Transform Ends!"), + RandMark("Mark: Transforms End!"), ] ) output = transforms(input) @@ -82,32 +86,33 @@ def test_nvtx_transfroms_alone(self, input): @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") def test_nvtx_transfroms_array(self, input): + # with prob == 0.0 transforms = Compose( [ - RandMark("Mark: Transform Starts!"), + RandMark("Mark: Transforms Start!"), RandRangePush("Range: RandFlip"), RandFlip(prob=0.0), RandRangePop(), RangePush("Range: ToTensor"), ToTensor(), RangePop(), - Mark("Mark: Transform Ends!"), + Mark("Mark: Transforms End!"), ] ) output = transforms(input) self.assertIsInstance(output, torch.Tensor) np.testing.assert_array_equal(input, output) - + # with prob == 1.0 transforms = Compose( [ - RandMark("Mark: Transform Starts!"), + RandMark("Mark: Transforms Start!"), RandRangePush("Range: RandFlip"), RandFlip(prob=1.0), RandRangePop(), RangePush("Range: ToTensor"), ToTensor(), RangePop(), - Mark("Mark: Transform Ends!"), + Mark("Mark: Transforms End!"), ] ) output = transforms(input) @@ -116,33 +121,90 @@ def test_nvtx_transfroms_array(self, input): @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") - def test_nvtx_transfromsd(self, input): + def test_nvtx_transfroms_dict(self, input): + # with prob == 0.0 transforms = Compose( [ - RandMarkD("Mark: Transform Starts!"), + RandMarkD("Mark: Transforms (p=0) Start!"), RandRangePushD("Range: RandFlipD"), RandFlipD(keys="image", prob=0.0), RandRangePopD(), RangePushD("Range: ToTensorD"), ToTensorD(keys=("image")), RangePopD(), - MarkD("Mark: Transform Ends!"), + MarkD("Mark: Transforms (p=0) End!"), ] ) output = transforms(input) self.assertIsInstance(output["image"], torch.Tensor) np.testing.assert_array_equal(input["image"], output["image"]) - + # with prob == 1.0 transforms = Compose( [ - RandMarkD("Mark: Transform Starts!"), + RandMarkD("Mark: Transforms (p=1) Start!"), RandRangePushD("Range: RandFlipD"), RandFlipD(keys="image", prob=1.0), RandRangePopD(), RangePushD("Range: ToTensorD"), ToTensorD(keys=("image")), RangePopD(), - MarkD("Mark: Transform Ends!"), + MarkD("Mark: Transforms (p=1) End!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output["image"], torch.Tensor) + np.testing.assert_array_equal(input["image"], Flip()(output["image"].numpy())) + + @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_nvtx_range_array(self, input): + # with prob == 0.0 + transforms = Compose( + [ + RandMark("Mark: Transforms (p=0) Start!"), + RandRange(RandFlip(prob=0.0)), + Range(ToTensor()), + Mark("Mark: Transforms (p=0) End!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output, torch.Tensor) + np.testing.assert_array_equal(input, output) + # with prob == 1.0 + transforms = Compose( + [ + RandMark("Mark: Transforms (p=1) Start!"), + RandRange(RandFlip(prob=1.0)), + Range(ToTensor()), + Mark("Mark: Transforms (p=1) End!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output, torch.Tensor) + np.testing.assert_array_equal(input, Flip()(output.numpy())) + + @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_nvtx_range_dict(self, input): + # with prob == 0.0 + transforms = Compose( + [ + RandMarkD("Mark: Transforms (p=0) Start!"), + RandRangeD(RandFlipD(keys="image", prob=0.0)), + RangeD(ToTensorD(keys=("image"))), + MarkD("Mark: Transforms (p=0) End!"), + ] + ) + output = transforms(input) + self.assertIsInstance(output["image"], torch.Tensor) + np.testing.assert_array_equal(input["image"], output["image"]) + # with prob == 1.0 + transforms = Compose( + [ + RandMarkD("Mark: Transforms (p=1) Start!"), + RandRangeD(RandFlipD(keys="image", prob=1.0)), + RangeD(ToTensorD(keys=("image"))), + MarkD("Mark: Transforms (p=1) End!"), ] ) output = transforms(input) From 5fe8440746bf6436df04372278d9d6a1c6fb6745 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Sat, 14 Aug 2021 07:24:58 +0800 Subject: [PATCH 33/89] [DLMED] enhance the doc of transform (#2767) Signed-off-by: Nic Ma --- monai/transforms/transform.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index 97cc2f21fc..ac371c5782 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -205,7 +205,8 @@ class Transform(ABC): thread-unsafe transforms should inherit :py:class:`monai.transforms.ThreadUnsafe`. #. ``data`` content unused by this transform may still be used in the subsequent transforms in a composed transform. - #. storing too much information in ``data`` may not scale. + #. storing too much information in ``data`` may cause some memory issue or IPC sync issue, + especially in the multi-processing environment of PyTorch DataLoader. See Also From 7ea09bf0aa7604a7dcb96794699915bbcf6cdab9 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sat, 14 Aug 2021 16:51:05 +0800 Subject: [PATCH 34/89] Fix `UpSample` issue and enhance `UpCat` (#2766) * add unetdecoder Signed-off-by: Yiheng Wang * remove decoder Signed-off-by: Yiheng Wang * recover mis deleted lines Signed-off-by: Yiheng Wang * fix black error Signed-off-by: Yiheng Wang --- monai/networks/blocks/upsample.py | 12 ++++++++---- monai/networks/nets/basic_unet.py | 29 +++++++++++++++++++++++++---- monai/networks/nets/dynunet.py | 2 +- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/monai/networks/blocks/upsample.py b/monai/networks/blocks/upsample.py index f3c680f050..5320611ce6 100644 --- a/monai/networks/blocks/upsample.py +++ b/monai/networks/blocks/upsample.py @@ -60,21 +60,21 @@ def __init__( thus if size is defined, `scale_factor` will not be used. Defaults to None. mode: {``"deconv"``, ``"nontrainable"``, ``"pixelshuffle"``}. Defaults to ``"deconv"``. - pre_conv: a conv block applied before upsampling. Defaults to None. + pre_conv: a conv block applied before upsampling. Defaults to "default". When ``conv_block`` is ``"default"``, one reserved conv layer will be utilized when Only used in the "nontrainable" or "pixelshuffle" mode. interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``} - Only used when ``mode`` is ``UpsampleMode.NONTRAINABLE``. + Only used in the "nontrainable" mode. If ends with ``"linear"`` will use ``spatial dims`` to determine the correct interpolation. This corresponds to linear, bilinear, trilinear for 1D, 2D, and 3D respectively. The interpolation mode. Defaults to ``"linear"``. See also: https://pytorch.org/docs/stable/nn.html#upsample align_corners: set the align_corners parameter of `torch.nn.Upsample`. Defaults to True. - Only used in the nontrainable mode. + Only used in the "nontrainable" mode. bias: whether to have a bias term in the default preconv and deconv layers. Defaults to True. apply_pad_pool: if True the upsampled tensor is padded then average pooling is applied with a kernel the size of `scale_factor` with a stride of 1. See also: :py:class:`monai.networks.blocks.SubpixelUpsample`. - Only used in the pixelshuffle mode. + Only used in the "pixelshuffle" mode. """ super().__init__() scale_factor_ = ensure_tuple_rep(scale_factor, dimensions) @@ -104,6 +104,10 @@ def __init__( ) elif pre_conv is not None and pre_conv != "default": self.add_module("preconv", pre_conv) # type: ignore + elif pre_conv is None and (out_channels != in_channels): + raise ValueError( + "in the nontrainable mode, if not setting pre_conv, out_channels should equal to in_channels." + ) interp_mode = InterpolateMode(interp_mode) linear_mode = [InterpolateMode.LINEAR, InterpolateMode.BILINEAR, InterpolateMode.TRILINEAR] diff --git a/monai/networks/nets/basic_unet.py b/monai/networks/nets/basic_unet.py index 08f2c92272..18bb1d3a6c 100644 --- a/monai/networks/nets/basic_unet.py +++ b/monai/networks/nets/basic_unet.py @@ -9,7 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Sequence, Union +from typing import Optional, Sequence, Union import torch import torch.nn as nn @@ -92,6 +92,9 @@ def __init__( norm: Union[str, tuple], dropout: Union[float, tuple] = 0.0, upsample: str = "deconv", + pre_conv: Optional[Union[nn.Module, str]] = "default", + interp_mode: str = "linear", + align_corners: Optional[bool] = True, halves: bool = True, ): """ @@ -105,12 +108,30 @@ def __init__( dropout: dropout ratio. Defaults to no dropout. upsample: upsampling mode, available options are ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``. + pre_conv: a conv block applied before upsampling. + Only used in the "nontrainable" or "pixelshuffle" mode. + interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``} + Only used in the "nontrainable" mode. + align_corners: set the align_corners parameter for upsample. Defaults to True. + Only used in the "nontrainable" mode. halves: whether to halve the number of channels during upsampling. + This parameter does not work on ``nontrainable`` mode if ``pre_conv`` is `None`. """ super().__init__() - - up_chns = in_chns // 2 if halves else in_chns - self.upsample = UpSample(dim, in_chns, up_chns, 2, mode=upsample) + if upsample == "nontrainable" and pre_conv is None: + up_chns = in_chns + else: + up_chns = in_chns // 2 if halves else in_chns + self.upsample = UpSample( + dim, + in_chns, + up_chns, + 2, + mode=upsample, + pre_conv=pre_conv, + interp_mode=interp_mode, + align_corners=align_corners, + ) self.convs = TwoConv(dim, cat_chns + up_chns, out_chns, act, norm, dropout) def forward(self, x: torch.Tensor, x_e: torch.Tensor): diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index b0ea249c6a..3922249b78 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -26,7 +26,7 @@ class DynUNetSkipLayer(nn.Module): Defines a layer in the UNet topology which combines the downsample and upsample pathways with the skip connection. The member `next_layer` may refer to instances of this class or the final bottleneck layer at the bottom the UNet structure. The purpose of using a recursive class like this is to get around the Torchscript restrictions on - looping over lists of layers and accumulating lists of output tensors which much be indexed. The `heads` list is + looping over lists of layers and accumulating lists of output tensors which must be indexed. The `heads` list is shared amongst all the instances of this class and is used to store the output from the supervision heads during forward passes of the network. """ From db81f4072f4dcc70c8bcedded281d1849503c219 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Sun, 15 Aug 2021 19:13:51 +0800 Subject: [PATCH 35/89] 2768 Add parameter bias for conv blocks (#2773) * add bias option Signed-off-by: Yiheng Wang * fix black error Signed-off-by: Yiheng Wang --- monai/networks/blocks/aspp.py | 5 +++ monai/networks/nets/autoencoder.py | 8 ++++ monai/networks/nets/basic_unet.py | 59 +++++++++++++++++---------- monai/networks/nets/highresnet.py | 13 ++++++ monai/networks/nets/unet.py | 11 ++++- monai/networks/nets/varautoencoder.py | 2 + monai/networks/nets/vnet.py | 49 ++++++++++++++++------ tests/test_vnet.py | 4 ++ 8 files changed, 115 insertions(+), 36 deletions(-) diff --git a/monai/networks/blocks/aspp.py b/monai/networks/blocks/aspp.py index 41ed39c359..f8bf8a5ba6 100644 --- a/monai/networks/blocks/aspp.py +++ b/monai/networks/blocks/aspp.py @@ -39,6 +39,7 @@ def __init__( dilations: Sequence[int] = (1, 2, 4, 6), norm_type: Optional[Union[Tuple, str]] = "BATCH", acti_type: Optional[Union[Tuple, str]] = "LEAKYRELU", + bias: bool = False, ) -> None: """ Args: @@ -54,6 +55,9 @@ def __init__( Defaults to batch norm. acti_type: final kernel-size-one convolution activation type. Defaults to leaky ReLU. + bias: whether to have a bias term in convolution blocks. Defaults to False. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. Raises: ValueError: When ``kernel_sizes`` length differs from ``dilations``. @@ -88,6 +92,7 @@ def __init__( kernel_size=1, act=acti_type, norm=norm_type, + bias=bias, ) def forward(self, x: torch.Tensor) -> torch.Tensor: diff --git a/monai/networks/nets/autoencoder.py b/monai/networks/nets/autoencoder.py index d0089198d5..d0a54b8148 100644 --- a/monai/networks/nets/autoencoder.py +++ b/monai/networks/nets/autoencoder.py @@ -37,6 +37,7 @@ def __init__( act: Optional[Union[Tuple, str]] = Act.PRELU, norm: Union[Tuple, str] = Norm.INSTANCE, dropout: Optional[Union[Tuple, str, float]] = None, + bias: bool = True, ) -> None: super().__init__() @@ -51,6 +52,7 @@ def __init__( self.act = act self.norm = norm self.dropout = dropout + self.bias = bias self.num_inter_units = num_inter_units self.inter_channels = inter_channels if inter_channels is not None else [] self.inter_dilations = list(inter_dilations or [1] * len(self.inter_channels)) @@ -103,6 +105,7 @@ def _get_intermediate_module(self, in_channels: int, num_inter_units: int) -> Tu norm=self.norm, dropout=self.dropout, dilation=di, + bias=self.bias, ) else: unit = Convolution( @@ -115,6 +118,7 @@ def _get_intermediate_module(self, in_channels: int, num_inter_units: int) -> Tu norm=self.norm, dropout=self.dropout, dilation=di, + bias=self.bias, ) intermediate.add_module("inter_%i" % i, unit) @@ -148,6 +152,7 @@ def _get_encode_layer(self, in_channels: int, out_channels: int, strides: int, i act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, last_conv_only=is_last, ) return Convolution( @@ -159,6 +164,7 @@ def _get_encode_layer(self, in_channels: int, out_channels: int, strides: int, i act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, conv_only=is_last, ) @@ -175,6 +181,7 @@ def _get_decode_layer(self, in_channels: int, out_channels: int, strides: int, i act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, conv_only=is_last and self.num_res_units == 0, is_transposed=True, ) @@ -192,6 +199,7 @@ def _get_decode_layer(self, in_channels: int, out_channels: int, strides: int, i act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, last_conv_only=is_last, ) diff --git a/monai/networks/nets/basic_unet.py b/monai/networks/nets/basic_unet.py index 18bb1d3a6c..63205f45ee 100644 --- a/monai/networks/nets/basic_unet.py +++ b/monai/networks/nets/basic_unet.py @@ -31,6 +31,7 @@ def __init__( out_chns: int, act: Union[str, tuple], norm: Union[str, tuple], + bias: bool, dropout: Union[float, tuple] = 0.0, ): """ @@ -40,12 +41,14 @@ def __init__( out_chns: number of output channels. act: activation type and arguments. norm: feature normalization type and arguments. + bias: whether to have a bias term in convolution blocks. dropout: dropout ratio. Defaults to no dropout. + """ super().__init__() - conv_0 = Convolution(dim, in_chns, out_chns, act=act, norm=norm, dropout=dropout, padding=1) - conv_1 = Convolution(dim, out_chns, out_chns, act=act, norm=norm, dropout=dropout, padding=1) + conv_0 = Convolution(dim, in_chns, out_chns, act=act, norm=norm, dropout=dropout, bias=bias, padding=1) + conv_1 = Convolution(dim, out_chns, out_chns, act=act, norm=norm, dropout=dropout, bias=bias, padding=1) self.add_module("conv_0", conv_0) self.add_module("conv_1", conv_1) @@ -60,6 +63,7 @@ def __init__( out_chns: int, act: Union[str, tuple], norm: Union[str, tuple], + bias: bool, dropout: Union[float, tuple] = 0.0, ): """ @@ -69,12 +73,14 @@ def __init__( out_chns: number of output channels. act: activation type and arguments. norm: feature normalization type and arguments. + bias: whether to have a bias term in convolution blocks. dropout: dropout ratio. Defaults to no dropout. + """ super().__init__() max_pooling = Pool["MAX", dim](kernel_size=2) - convs = TwoConv(dim, in_chns, out_chns, act, norm, dropout) + convs = TwoConv(dim, in_chns, out_chns, act, norm, bias, dropout) self.add_module("max_pooling", max_pooling) self.add_module("convs", convs) @@ -90,6 +96,7 @@ def __init__( out_chns: int, act: Union[str, tuple], norm: Union[str, tuple], + bias: bool, dropout: Union[float, tuple] = 0.0, upsample: str = "deconv", pre_conv: Optional[Union[nn.Module, str]] = "default", @@ -105,6 +112,7 @@ def __init__( out_chns: number of output channels. act: activation type and arguments. norm: feature normalization type and arguments. + bias: whether to have a bias term in convolution blocks. dropout: dropout ratio. Defaults to no dropout. upsample: upsampling mode, available options are ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``. @@ -132,9 +140,9 @@ def __init__( interp_mode=interp_mode, align_corners=align_corners, ) - self.convs = TwoConv(dim, cat_chns + up_chns, out_chns, act, norm, dropout) + self.convs = TwoConv(dim, cat_chns + up_chns, out_chns, act, norm, bias, dropout) - def forward(self, x: torch.Tensor, x_e: torch.Tensor): + def forward(self, x: torch.Tensor, x_e: Optional[torch.Tensor]): """ Args: @@ -143,15 +151,18 @@ def forward(self, x: torch.Tensor, x_e: torch.Tensor): """ x_0 = self.upsample(x) - # handling spatial shapes due to the 2x maxpooling with odd edge lengths. - dimensions = len(x.shape) - 2 - sp = [0] * (dimensions * 2) - for i in range(dimensions): - if x_e.shape[-i - 1] != x_0.shape[-i - 1]: - sp[i * 2 + 1] = 1 - x_0 = torch.nn.functional.pad(x_0, sp, "replicate") + if x_e is not None: + # handling spatial shapes due to the 2x maxpooling with odd edge lengths. + dimensions = len(x.shape) - 2 + sp = [0] * (dimensions * 2) + for i in range(dimensions): + if x_e.shape[-i - 1] != x_0.shape[-i - 1]: + sp[i * 2 + 1] = 1 + x_0 = torch.nn.functional.pad(x_0, sp, "replicate") + x = self.convs(torch.cat([x_e, x_0], dim=1)) # input channels: (cat_chns + up_chns) + else: + x = self.convs(x_0) - x = self.convs(torch.cat([x_e, x_0], dim=1)) # input channels: (cat_chns + up_chns) return x @@ -164,6 +175,7 @@ def __init__( features: Sequence[int] = (32, 32, 64, 128, 256, 32), act: Union[str, tuple] = ("LeakyReLU", {"negative_slope": 0.1, "inplace": True}), norm: Union[str, tuple] = ("instance", {"affine": True}), + bias: bool = True, dropout: Union[float, tuple] = 0.0, upsample: str = "deconv", ): @@ -188,6 +200,9 @@ def __init__( act: activation type and arguments. Defaults to LeakyReLU. norm: feature normalization type and arguments. Defaults to instance norm. + bias: whether to have a bias term in convolution blocks. Defaults to True. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. dropout: dropout ratio. Defaults to no dropout. upsample: upsampling mode, available options are ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``. @@ -214,16 +229,16 @@ def __init__( fea = ensure_tuple_rep(features, 6) print(f"BasicUNet features: {fea}.") - self.conv_0 = TwoConv(dimensions, in_channels, features[0], act, norm, dropout) - self.down_1 = Down(dimensions, fea[0], fea[1], act, norm, dropout) - self.down_2 = Down(dimensions, fea[1], fea[2], act, norm, dropout) - self.down_3 = Down(dimensions, fea[2], fea[3], act, norm, dropout) - self.down_4 = Down(dimensions, fea[3], fea[4], act, norm, dropout) + self.conv_0 = TwoConv(dimensions, in_channels, features[0], act, norm, bias, dropout) + self.down_1 = Down(dimensions, fea[0], fea[1], act, norm, bias, dropout) + self.down_2 = Down(dimensions, fea[1], fea[2], act, norm, bias, dropout) + self.down_3 = Down(dimensions, fea[2], fea[3], act, norm, bias, dropout) + self.down_4 = Down(dimensions, fea[3], fea[4], act, norm, bias, dropout) - self.upcat_4 = UpCat(dimensions, fea[4], fea[3], fea[3], act, norm, dropout, upsample) - self.upcat_3 = UpCat(dimensions, fea[3], fea[2], fea[2], act, norm, dropout, upsample) - self.upcat_2 = UpCat(dimensions, fea[2], fea[1], fea[1], act, norm, dropout, upsample) - self.upcat_1 = UpCat(dimensions, fea[1], fea[0], fea[5], act, norm, dropout, upsample, halves=False) + self.upcat_4 = UpCat(dimensions, fea[4], fea[3], fea[3], act, norm, bias, dropout, upsample) + self.upcat_3 = UpCat(dimensions, fea[3], fea[2], fea[2], act, norm, bias, dropout, upsample) + self.upcat_2 = UpCat(dimensions, fea[2], fea[1], fea[1], act, norm, bias, dropout, upsample) + self.upcat_1 = UpCat(dimensions, fea[1], fea[0], fea[5], act, norm, bias, dropout, upsample, halves=False) self.final_conv = Conv["conv", dimensions](fea[5], out_channels, kernel_size=1) diff --git a/monai/networks/nets/highresnet.py b/monai/networks/nets/highresnet.py index a67a5088ce..12908a9119 100644 --- a/monai/networks/nets/highresnet.py +++ b/monai/networks/nets/highresnet.py @@ -43,6 +43,7 @@ def __init__( dilation: Union[Sequence[int], int] = 1, norm_type: Union[Tuple, str] = ("batch", {"affine": True}), acti_type: Union[Tuple, str] = ("relu", {"inplace": True}), + bias: bool = False, channel_matching: Union[ChannelMatching, str] = ChannelMatching.PAD, ) -> None: """ @@ -56,6 +57,9 @@ def __init__( Defaults to ``("batch", {"affine": True})``. acti_type: {``"relu"``, ``"prelu"``, ``"relu6"``} Non-linear activation using ReLU or PReLU. Defaults to ``"relu"``. + bias: whether to have a bias term in convolution blocks. Defaults to False. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. channel_matching: {``"pad"``, ``"project"``} Specifies handling residual branch and conv branch channel mismatches. Defaults to ``"pad"``. @@ -85,6 +89,7 @@ def __init__( out_channels=_out_chns, kernel_size=kernel_size, dilation=dilation, + bias=bias, ) ) _in_chns = _out_chns @@ -116,6 +121,9 @@ class HighResNet(nn.Module): Defaults to ``("relu", {"inplace": True})``. dropout_prob: probability of the feature map to be zeroed (only applies to the penultimate conv layer). + bias: whether to have a bias term in convolution blocks. Defaults to False. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. layer_params: specifying key parameters of each layer/block. channel_matching: {``"pad"``, ``"project"``} Specifies handling residual branch and conv branch channel mismatches. Defaults to ``"pad"``. @@ -132,6 +140,7 @@ def __init__( norm_type: Union[str, tuple] = ("batch", {"affine": True}), acti_type: Union[str, tuple] = ("relu", {"inplace": True}), dropout_prob: Optional[Union[Tuple, str, float]] = 0.0, + bias: bool = False, layer_params: Sequence[Dict] = DEFAULT_LAYER_PARAMS_3D, channel_matching: Union[ChannelMatching, str] = ChannelMatching.PAD, ) -> None: @@ -151,6 +160,7 @@ def __init__( adn_ordering="NA", act=acti_type, norm=norm_type, + bias=bias, ) ) @@ -168,6 +178,7 @@ def __init__( dilation=_dilation, norm_type=norm_type, acti_type=acti_type, + bias=bias, channel_matching=channel_matching, ) ) @@ -185,6 +196,7 @@ def __init__( adn_ordering="NAD", act=acti_type, norm=norm_type, + bias=bias, dropout=dropout_prob, ) ) @@ -200,6 +212,7 @@ def __init__( adn_ordering="NAD", act=acti_type, norm=norm_type, + bias=bias, dropout=dropout_prob, ) ) diff --git a/monai/networks/nets/unet.py b/monai/networks/nets/unet.py index 158b154042..70cc816fe9 100644 --- a/monai/networks/nets/unet.py +++ b/monai/networks/nets/unet.py @@ -38,7 +38,8 @@ def __init__( num_res_units: int = 0, act: Union[Tuple, str] = Act.PRELU, norm: Union[Tuple, str] = Norm.INSTANCE, - dropout=0.0, + dropout: float = 0.0, + bias: bool = True, ) -> None: """ Enhanced version of UNet which has residual units implemented with the ResidualUnit class. @@ -60,6 +61,9 @@ def __init__( act: activation type and arguments. Defaults to PReLU. norm: feature normalization type and arguments. Defaults to instance norm. dropout: dropout ratio. Defaults to no dropout. + bias: whether to have a bias term in convolution blocks. Defaults to True. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. Note: The acceptable spatial size of input data depends on the parameters of the network, to set appropriate spatial size, please check the tutorial for more details: @@ -97,6 +101,7 @@ def __init__( self.act = act self.norm = norm self.dropout = dropout + self.bias = bias def _create_block( inc: int, outc: int, channels: Sequence[int], strides: Sequence[int], is_top: bool @@ -151,6 +156,7 @@ def _get_down_layer(self, in_channels: int, out_channels: int, strides: int, is_ act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, ) return Convolution( self.dimensions, @@ -161,6 +167,7 @@ def _get_down_layer(self, in_channels: int, out_channels: int, strides: int, is_ act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, ) def _get_bottom_layer(self, in_channels: int, out_channels: int) -> nn.Module: @@ -190,6 +197,7 @@ def _get_up_layer(self, in_channels: int, out_channels: int, strides: int, is_to act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, conv_only=is_top and self.num_res_units == 0, is_transposed=True, ) @@ -205,6 +213,7 @@ def _get_up_layer(self, in_channels: int, out_channels: int, strides: int, is_to act=self.act, norm=self.norm, dropout=self.dropout, + bias=self.bias, last_conv_only=is_top, ) conv = nn.Sequential(conv, ru) diff --git a/monai/networks/nets/varautoencoder.py b/monai/networks/nets/varautoencoder.py index 72caa3a2cb..7f54890992 100644 --- a/monai/networks/nets/varautoencoder.py +++ b/monai/networks/nets/varautoencoder.py @@ -43,6 +43,7 @@ def __init__( act: Optional[Union[Tuple, str]] = Act.PRELU, norm: Union[Tuple, str] = Norm.INSTANCE, dropout: Optional[Union[Tuple, str, float]] = None, + bias: bool = True, ) -> None: self.in_channels, *self.in_shape = in_shape @@ -65,6 +66,7 @@ def __init__( act, norm, dropout, + bias, ) padding = same_padding(self.kernel_size) diff --git a/monai/networks/nets/vnet.py b/monai/networks/nets/vnet.py index dc71cb104b..72f3290a89 100644 --- a/monai/networks/nets/vnet.py +++ b/monai/networks/nets/vnet.py @@ -29,7 +29,7 @@ def get_acti_layer(act: Union[Tuple[str, Dict], str], nchan: int = 0): class LUConv(nn.Module): - def __init__(self, spatial_dims: int, nchan: int, act: Union[Tuple[str, Dict], str]): + def __init__(self, spatial_dims: int, nchan: int, act: Union[Tuple[str, Dict], str], bias: bool = False): super(LUConv, self).__init__() self.act_function = get_acti_layer(act, nchan) @@ -40,6 +40,7 @@ def __init__(self, spatial_dims: int, nchan: int, act: Union[Tuple[str, Dict], s kernel_size=5, act=None, norm=Norm.BATCH, + bias=bias, ) def forward(self, x): @@ -48,15 +49,22 @@ def forward(self, x): return out -def _make_nconv(spatial_dims: int, nchan: int, depth: int, act: Union[Tuple[str, Dict], str]): +def _make_nconv(spatial_dims: int, nchan: int, depth: int, act: Union[Tuple[str, Dict], str], bias: bool = False): layers = [] for _ in range(depth): - layers.append(LUConv(spatial_dims, nchan, act)) + layers.append(LUConv(spatial_dims, nchan, act, bias)) return nn.Sequential(*layers) class InputTransition(nn.Module): - def __init__(self, spatial_dims: int, in_channels: int, out_channels: int, act: Union[Tuple[str, Dict], str]): + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + act: Union[Tuple[str, Dict], str], + bias: bool = False, + ): super(InputTransition, self).__init__() if 16 % in_channels != 0: @@ -72,6 +80,7 @@ def __init__(self, spatial_dims: int, in_channels: int, out_channels: int, act: kernel_size=5, act=None, norm=Norm.BATCH, + bias=bias, ) def forward(self, x): @@ -91,6 +100,7 @@ def __init__( act: Union[Tuple[str, Dict], str], dropout_prob: Optional[float] = None, dropout_dim: int = 3, + bias: bool = False, ): super(DownTransition, self).__init__() @@ -99,11 +109,11 @@ def __init__( dropout_type: Type[Union[nn.Dropout, nn.Dropout2d, nn.Dropout3d]] = Dropout[Dropout.DROPOUT, dropout_dim] out_channels = 2 * in_channels - self.down_conv = conv_type(in_channels, out_channels, kernel_size=2, stride=2) + self.down_conv = conv_type(in_channels, out_channels, kernel_size=2, stride=2, bias=bias) self.bn1 = norm_type(out_channels) self.act_function1 = get_acti_layer(act, out_channels) self.act_function2 = get_acti_layer(act, out_channels) - self.ops = _make_nconv(spatial_dims, out_channels, nconvs, act) + self.ops = _make_nconv(spatial_dims, out_channels, nconvs, act, bias) self.dropout = dropout_type(dropout_prob) if dropout_prob is not None else None def forward(self, x): @@ -156,7 +166,14 @@ def forward(self, x, skipx): class OutputTransition(nn.Module): - def __init__(self, spatial_dims: int, in_channels: int, out_channels: int, act: Union[Tuple[str, Dict], str]): + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + act: Union[Tuple[str, Dict], str], + bias: bool = False, + ): super(OutputTransition, self).__init__() conv_type: Type[Union[nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims] @@ -169,6 +186,7 @@ def __init__(self, spatial_dims: int, in_channels: int, out_channels: int, act: kernel_size=5, act=None, norm=Norm.BATCH, + bias=bias, ) self.conv2 = conv_type(out_channels, out_channels, kernel_size=1) @@ -201,6 +219,10 @@ class VNet(nn.Module): - ``dropout_dim = 1``, randomly zeroes some of the elements for each channel. - ``dropout_dim = 2``, Randomly zeroes out entire channels (a channel is a 2D feature map). - ``dropout_dim = 3``, Randomly zeroes out entire channels (a channel is a 3D feature map). + bias: whether to have a bias term in convolution blocks. Defaults to False. + According to `Performance Tuning Guide `_, + if a conv layer is directly followed by a batch norm layer, bias should be False. + """ def __init__( @@ -211,22 +233,23 @@ def __init__( act: Union[Tuple[str, Dict], str] = ("elu", {"inplace": True}), dropout_prob: float = 0.5, dropout_dim: int = 3, + bias: bool = False, ): super().__init__() if spatial_dims not in (2, 3): raise AssertionError("spatial_dims can only be 2 or 3.") - self.in_tr = InputTransition(spatial_dims, in_channels, 16, act) - self.down_tr32 = DownTransition(spatial_dims, 16, 1, act) - self.down_tr64 = DownTransition(spatial_dims, 32, 2, act) - self.down_tr128 = DownTransition(spatial_dims, 64, 3, act, dropout_prob=dropout_prob) - self.down_tr256 = DownTransition(spatial_dims, 128, 2, act, dropout_prob=dropout_prob) + self.in_tr = InputTransition(spatial_dims, in_channels, 16, act, bias=bias) + self.down_tr32 = DownTransition(spatial_dims, 16, 1, act, bias=bias) + self.down_tr64 = DownTransition(spatial_dims, 32, 2, act, bias=bias) + self.down_tr128 = DownTransition(spatial_dims, 64, 3, act, dropout_prob=dropout_prob, bias=bias) + self.down_tr256 = DownTransition(spatial_dims, 128, 2, act, dropout_prob=dropout_prob, bias=bias) self.up_tr256 = UpTransition(spatial_dims, 256, 256, 2, act, dropout_prob=dropout_prob) self.up_tr128 = UpTransition(spatial_dims, 256, 128, 2, act, dropout_prob=dropout_prob) self.up_tr64 = UpTransition(spatial_dims, 128, 64, 1, act) self.up_tr32 = UpTransition(spatial_dims, 64, 32, 1, act) - self.out_tr = OutputTransition(spatial_dims, 32, out_channels, act) + self.out_tr = OutputTransition(spatial_dims, 32, out_channels, act, bias=bias) def forward(self, x): out16 = self.in_tr(x) diff --git a/tests/test_vnet.py b/tests/test_vnet.py index c64b566c42..4eba5396b2 100644 --- a/tests/test_vnet.py +++ b/tests/test_vnet.py @@ -73,3 +73,7 @@ def test_script(self): net = VNet(spatial_dims=3, in_channels=1, out_channels=3, dropout_dim=3) test_data = torch.randn(1, 1, 32, 32, 32) test_script_save(net, test_data) + + +if __name__ == "__main__": + unittest.main() From 1cacfd30dbab9f81b16c418ed96e3cd647dcac02 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 16 Aug 2021 17:11:03 +0800 Subject: [PATCH 36/89] [DLMED] add numpy pad args (#2780) Signed-off-by: Nic Ma --- monai/transforms/croppad/array.py | 6 +++++- monai/transforms/croppad/dictionary.py | 5 +++++ monai/transforms/spatial/array.py | 14 ++++++++++++-- monai/transforms/spatial/dictionary.py | 13 +++++++++++-- tests/test_crop_foreground.py | 2 +- tests/test_crop_foregroundd.py | 2 ++ tests/test_rand_zoom.py | 9 ++++++++- tests/test_rand_zoomd.py | 10 +++++++++- tests/test_zoom.py | 2 +- tests/test_zoomd.py | 2 +- 10 files changed, 55 insertions(+), 10 deletions(-) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 240836ce0b..0eca034950 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -572,6 +572,7 @@ def __init__( return_coords: bool = False, k_divisible: Union[Sequence[int], int] = 1, mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + **np_kwargs, ) -> None: """ Args: @@ -586,6 +587,8 @@ def __init__( ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} one of the listed string values or a user supplied function. Defaults to ``"constant"``. see also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html """ self.select_fn = select_fn @@ -594,6 +597,7 @@ def __init__( self.return_coords = return_coords self.k_divisible = k_divisible self.mode: NumpyPadMode = look_up_option(mode, NumpyPadMode) + self.np_kwargs = np_kwargs def compute_bounding_box(self, img: np.ndarray): """ @@ -621,7 +625,7 @@ def crop_pad(self, img: np.ndarray, box_start: np.ndarray, box_end: np.ndarray): pad_to_start = np.maximum(-box_start, 0) pad_to_end = np.maximum(box_end - np.asarray(img.shape[1:]), 0) pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - return BorderPad(spatial_border=pad, mode=self.mode)(cropped) + return BorderPad(spatial_border=pad, mode=self.mode, **self.np_kwargs)(cropped) def __call__(self, img: np.ndarray): """ diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 4a2ae32607..cdcc861c82 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -794,6 +794,7 @@ def __init__( start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", allow_missing_keys: bool = False, + **np_kwargs, ) -> None: """ Args: @@ -813,6 +814,9 @@ def __init__( start_coord_key: key to record the start coordinate of spatial bounding box for foreground. end_coord_key: key to record the end coordinate of spatial bounding box for foreground. allow_missing_keys: don't raise exception if key is missing. + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ super().__init__(keys, allow_missing_keys) self.source_key = source_key @@ -824,6 +828,7 @@ def __init__( margin=margin, k_divisible=k_divisible, mode=mode, + **np_kwargs, ) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index dcbc7aa2f6..86f0e84249 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -546,6 +546,9 @@ class Zoom(Transform): 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate keep_size: Should keep original size (padding/slicing if needed), default is True. + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ def __init__( @@ -555,12 +558,14 @@ def __init__( padding_mode: Union[NumpyPadMode, str] = NumpyPadMode.EDGE, align_corners: Optional[bool] = None, keep_size: bool = True, + **np_kwargs, ) -> None: self.zoom = zoom self.mode: InterpolateMode = InterpolateMode(mode) self.padding_mode: NumpyPadMode = NumpyPadMode(padding_mode) self.align_corners = align_corners self.keep_size = keep_size + self.np_kwargs = np_kwargs def __call__( self, @@ -607,7 +612,7 @@ def __call__( slice_vec[idx] = slice(half, half + od) padding_mode = look_up_option(self.padding_mode if padding_mode is None else padding_mode, NumpyPadMode) - zoomed = np.pad(zoomed, pad_vec, mode=padding_mode.value) # type: ignore + zoomed = np.pad(zoomed, pad_vec, mode=padding_mode.value, **self.np_kwargs) # type: ignore return zoomed[tuple(slice_vec)] @@ -868,6 +873,9 @@ class RandZoom(RandomizableTransform): 'linear', 'bilinear', 'bicubic' or 'trilinear'. Default: None. See also: https://pytorch.org/docs/stable/nn.functional.html#interpolate keep_size: Should keep original size (pad if needed), default is True. + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ def __init__( @@ -879,6 +887,7 @@ def __init__( padding_mode: Union[NumpyPadMode, str] = NumpyPadMode.EDGE, align_corners: Optional[bool] = None, keep_size: bool = True, + **np_kwargs, ) -> None: RandomizableTransform.__init__(self, prob) self.min_zoom = ensure_tuple(min_zoom) @@ -889,6 +898,7 @@ def __init__( self.padding_mode: NumpyPadMode = look_up_option(padding_mode, NumpyPadMode) self.align_corners = align_corners self.keep_size = keep_size + self.np_kwargs = np_kwargs self._zoom: Sequence[float] = [1.0] @@ -928,7 +938,7 @@ def __call__( elif len(self._zoom) == 2 and img.ndim > 3: # if 2 zoom factors provided for 3D data, use the first factor for H and W dims, second factor for D dim self._zoom = ensure_tuple_rep(self._zoom[0], img.ndim - 2) + ensure_tuple(self._zoom[-1]) - zoomer = Zoom(self._zoom, keep_size=self.keep_size) + zoomer = Zoom(self._zoom, keep_size=self.keep_size, **self.np_kwargs) return np.asarray( zoomer( img, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index a7eeceacf9..d953fd63ea 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -1534,6 +1534,9 @@ class Zoomd(MapTransform, InvertibleTransform): It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. keep_size: Should keep original size (pad if needed), default is True. allow_missing_keys: don't raise exception if key is missing. + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ def __init__( @@ -1545,12 +1548,13 @@ def __init__( align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, + **np_kwargs, ) -> None: super().__init__(keys, allow_missing_keys) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) - self.zoomer = Zoom(zoom=zoom, keep_size=keep_size) + self.zoomer = Zoom(zoom=zoom, keep_size=keep_size, **np_kwargs) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) @@ -1630,6 +1634,9 @@ class RandZoomd(RandomizableTransform, MapTransform, InvertibleTransform): It also can be a sequence of bool or None, each element corresponds to a key in ``keys``. keep_size: Should keep original size (pad if needed), default is True. allow_missing_keys: don't raise exception if key is missing. + np_kwargs: other args for `np.pad` API, note that `np.pad` treats channel dimension as the first dimension. + more details: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ def __init__( @@ -1643,6 +1650,7 @@ def __init__( align_corners: Union[Sequence[Optional[bool]], Optional[bool]] = None, keep_size: bool = True, allow_missing_keys: bool = False, + **np_kwargs, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) @@ -1655,6 +1663,7 @@ def __init__( self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) self.keep_size = keep_size + self.np_kwargs = np_kwargs self._zoom: Sequence[float] = [1.0] @@ -1674,7 +1683,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda elif len(self._zoom) == 2 and img_dims > 3: # if 2 zoom factors provided for 3D data, use the first factor for H and W dims, second factor for D dim self._zoom = ensure_tuple_rep(self._zoom[0], img_dims - 2) + ensure_tuple(self._zoom[-1]) - zoomer = Zoom(self._zoom, keep_size=self.keep_size) + zoomer = Zoom(self._zoom, keep_size=self.keep_size, **self.np_kwargs) for key, mode, padding_mode, align_corners in self.key_iterator( d, self.mode, self.padding_mode, self.align_corners ): diff --git a/tests/test_crop_foreground.py b/tests/test_crop_foreground.py index 8eae8f484e..71e488cac8 100644 --- a/tests/test_crop_foreground.py +++ b/tests/test_crop_foreground.py @@ -53,7 +53,7 @@ ] TEST_CASE_7 = [ - {"select_fn": lambda x: x > 0, "channel_indices": None, "margin": 0, "k_divisible": 10}, + {"select_fn": lambda x: x > 0, "channel_indices": None, "margin": 0, "k_divisible": 10, "constant_values": 2}, np.array([[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]), np.zeros((1, 0, 0)), ] diff --git a/tests/test_crop_foregroundd.py b/tests/test_crop_foregroundd.py index 37abfb8c55..f51ca7e2df 100644 --- a/tests/test_crop_foregroundd.py +++ b/tests/test_crop_foregroundd.py @@ -23,6 +23,8 @@ "select_fn": lambda x: x > 0, "channel_indices": None, "margin": 0, + "mode": "constant", + "constant_values": 2, }, { "img": np.array([[[1, 0, 2, 0, 1], [0, 1, 2, 1, 0], [2, 2, 3, 2, 2], [0, 1, 2, 1, 0], [1, 0, 2, 0, 1]]]), diff --git a/tests/test_rand_zoom.py b/tests/test_rand_zoom.py index 35cf30bcb1..c21bc8b9e9 100644 --- a/tests/test_rand_zoom.py +++ b/tests/test_rand_zoom.py @@ -41,7 +41,14 @@ def test_correct_results(self, min_zoom, max_zoom, mode, keep_size): np.testing.assert_allclose(zoomed, expected, atol=1.0) def test_keep_size(self): - random_zoom = RandZoom(prob=1.0, min_zoom=0.6, max_zoom=0.7, keep_size=True) + random_zoom = RandZoom( + prob=1.0, + min_zoom=0.6, + max_zoom=0.7, + keep_size=True, + padding_mode="constant", + constant_values=2, + ) zoomed = random_zoom(self.imt[0]) self.assertTrue(np.array_equal(zoomed.shape, self.imt.shape[1:])) zoomed = random_zoom(self.imt[0]) diff --git a/tests/test_rand_zoomd.py b/tests/test_rand_zoomd.py index fd50c490d5..4ccb1aad64 100644 --- a/tests/test_rand_zoomd.py +++ b/tests/test_rand_zoomd.py @@ -45,7 +45,15 @@ def test_correct_results(self, min_zoom, max_zoom, mode, align_corners, keep_siz def test_keep_size(self): key = "img" - random_zoom = RandZoomd(key, prob=1.0, min_zoom=0.6, max_zoom=0.7, keep_size=True) + random_zoom = RandZoomd( + keys=key, + prob=1.0, + min_zoom=0.6, + max_zoom=0.7, + keep_size=True, + padding_mode="constant", + constant_values=2, + ) zoomed = random_zoom({key: self.imt[0]}) self.assertTrue(np.array_equal(zoomed[key].shape, self.imt.shape[1:])) diff --git a/tests/test_zoom.py b/tests/test_zoom.py index dcc401f16c..e6710ede29 100644 --- a/tests/test_zoom.py +++ b/tests/test_zoom.py @@ -38,7 +38,7 @@ def test_correct_results(self, zoom, mode): np.testing.assert_allclose(zoomed, expected, atol=1.0) def test_keep_size(self): - zoom_fn = Zoom(zoom=[0.6, 0.6], keep_size=True, align_corners=True) + zoom_fn = Zoom(zoom=[0.6, 0.6], keep_size=True, align_corners=True, padding_mode="constant", constant_values=2) zoomed = zoom_fn(self.imt[0], mode="bilinear") np.testing.assert_allclose(zoomed.shape, self.imt.shape[1:]) diff --git a/tests/test_zoomd.py b/tests/test_zoomd.py index b17ecd1bf0..1a1a905d80 100644 --- a/tests/test_zoomd.py +++ b/tests/test_zoomd.py @@ -45,7 +45,7 @@ def test_correct_results(self, zoom, mode, keep_size): def test_keep_size(self): key = "img" - zoom_fn = Zoomd(key, zoom=0.6, keep_size=True) + zoom_fn = Zoomd(key, zoom=0.6, keep_size=True, padding_mode="constant", constant_values=2) zoomed = zoom_fn({key: self.imt[0]}) self.assertTrue(np.array_equal(zoomed[key].shape, self.imt.shape[1:])) From 4801ecfa6568acb577d1ebf59fb506498aeab775 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 16 Aug 2021 16:59:58 +0100 Subject: [PATCH 37/89] add unittest.main (#2783) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/test_savitzky_golay_filter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_savitzky_golay_filter.py b/tests/test_savitzky_golay_filter.py index 9163204810..c9bcd9687e 100644 --- a/tests/test_savitzky_golay_filter.py +++ b/tests/test_savitzky_golay_filter.py @@ -150,3 +150,7 @@ class TestSavitzkyGolayGPUREP(unittest.TestCase): def test_value(self, arguments, image, expected_data, atol): result = SavitzkyGolayFilter(**arguments)(image.to(device="cuda")) np.testing.assert_allclose(result.cpu(), expected_data, atol=atol) + + +if __name__ == "__main__": + unittest.main() From 2d99ae546160f32df08527354a2ee7504bd8e4bd Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Tue, 17 Aug 2021 01:42:48 +0800 Subject: [PATCH 38/89] 2697 enhance norm (#2784) * enhance norm Signed-off-by: Yiheng Wang --- monai/networks/nets/densenet.py | 76 +++++++++++++++++++++-------- monai/networks/nets/efficientnet.py | 52 +++++++------------- tests/test_densenet.py | 4 +- tests/test_efficientnet.py | 31 +++++++++--- 4 files changed, 101 insertions(+), 62 deletions(-) diff --git a/monai/networks/nets/densenet.py b/monai/networks/nets/densenet.py index 4c98fb9936..3e30987bdc 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -17,7 +17,8 @@ import torch.nn as nn from torch.hub import load_state_dict_from_url -from monai.networks.layers.factories import Conv, Dropout, Norm, Pool +from monai.networks.layers.factories import Conv, Dropout, Pool +from monai.networks.layers.utils import get_act_layer, get_norm_layer __all__ = [ "DenseNet", @@ -40,7 +41,14 @@ class _DenseLayer(nn.Module): def __init__( - self, spatial_dims: int, in_channels: int, growth_rate: int, bn_size: int, dropout_prob: float + self, + spatial_dims: int, + in_channels: int, + growth_rate: int, + bn_size: int, + dropout_prob: float, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", ) -> None: """ Args: @@ -50,22 +58,23 @@ def __init__( bn_size: multiplicative factor for number of bottle neck layers. (i.e. bn_size * k features in the bottleneck layer) dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. """ super(_DenseLayer, self).__init__() out_channels = bn_size * growth_rate conv_type: Callable = Conv[Conv.CONV, spatial_dims] - norm_type: Callable = Norm[Norm.BATCH, spatial_dims] dropout_type: Callable = Dropout[Dropout.DROPOUT, spatial_dims] self.layers = nn.Sequential() - self.layers.add_module("norm1", norm_type(in_channels)) - self.layers.add_module("relu1", nn.ReLU(inplace=True)) + self.layers.add_module("norm1", get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=in_channels)) + self.layers.add_module("relu1", get_act_layer(name=act)) self.layers.add_module("conv1", conv_type(in_channels, out_channels, kernel_size=1, bias=False)) - self.layers.add_module("norm2", norm_type(out_channels)) - self.layers.add_module("relu2", nn.ReLU(inplace=True)) + self.layers.add_module("norm2", get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=out_channels)) + self.layers.add_module("relu2", get_act_layer(name=act)) self.layers.add_module("conv2", conv_type(out_channels, growth_rate, kernel_size=3, padding=1, bias=False)) if dropout_prob > 0: @@ -78,7 +87,15 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _DenseBlock(nn.Sequential): def __init__( - self, spatial_dims: int, layers: int, in_channels: int, bn_size: int, growth_rate: int, dropout_prob: float + self, + spatial_dims: int, + layers: int, + in_channels: int, + bn_size: int, + growth_rate: int, + dropout_prob: float, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", ) -> None: """ Args: @@ -89,30 +106,40 @@ def __init__( (i.e. bn_size * k features in the bottleneck layer) growth_rate: how many filters to add each layer (k in paper). dropout_prob: dropout rate after each dense layer. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. """ super(_DenseBlock, self).__init__() for i in range(layers): - layer = _DenseLayer(spatial_dims, in_channels, growth_rate, bn_size, dropout_prob) + layer = _DenseLayer(spatial_dims, in_channels, growth_rate, bn_size, dropout_prob, act=act, norm=norm) in_channels += growth_rate self.add_module("denselayer%d" % (i + 1), layer) class _Transition(nn.Sequential): - def __init__(self, spatial_dims: int, in_channels: int, out_channels: int) -> None: + def __init__( + self, + spatial_dims: int, + in_channels: int, + out_channels: int, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", + ) -> None: """ Args: spatial_dims: number of spatial dimensions of the input image. in_channels: number of the input channel. out_channels: number of the output classes. + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. """ super(_Transition, self).__init__() conv_type: Callable = Conv[Conv.CONV, spatial_dims] - norm_type: Callable = Norm[Norm.BATCH, spatial_dims] pool_type: Callable = Pool[Pool.AVG, spatial_dims] - self.add_module("norm", norm_type(in_channels)) - self.add_module("relu", nn.ReLU(inplace=True)) + self.add_module("norm", get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=in_channels)) + self.add_module("relu", get_act_layer(name=act)) self.add_module("conv", conv_type(in_channels, out_channels, kernel_size=1, bias=False)) self.add_module("pool", pool_type(kernel_size=2, stride=2)) @@ -131,6 +158,8 @@ class DenseNet(nn.Module): block_config: how many layers in each pooling block. bn_size: multiplicative factor for number of bottle neck layers. (i.e. bn_size * k features in the bottleneck layer) + act: activation type and arguments. Defaults to relu. + norm: feature normalization type and arguments. Defaults to batch norm. dropout_prob: dropout rate after each dense layer. """ @@ -143,13 +172,14 @@ def __init__( growth_rate: int = 32, block_config: Sequence[int] = (6, 12, 24, 16), bn_size: int = 4, + act: Union[str, tuple] = ("relu", {"inplace": True}), + norm: Union[str, tuple] = "batch", dropout_prob: float = 0.0, ) -> None: super(DenseNet, self).__init__() conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims] - norm_type: Type[Union[nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d]] = Norm[Norm.BATCH, spatial_dims] pool_type: Type[Union[nn.MaxPool1d, nn.MaxPool2d, nn.MaxPool3d]] = Pool[Pool.MAX, spatial_dims] avg_pool_type: Type[Union[nn.AdaptiveAvgPool1d, nn.AdaptiveAvgPool2d, nn.AdaptiveAvgPool3d]] = Pool[ Pool.ADAPTIVEAVG, spatial_dims @@ -159,8 +189,8 @@ def __init__( OrderedDict( [ ("conv0", conv_type(in_channels, init_features, kernel_size=7, stride=2, padding=3, bias=False)), - ("norm0", norm_type(init_features)), - ("relu0", nn.ReLU(inplace=True)), + ("norm0", get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=init_features)), + ("relu0", get_act_layer(name=act)), ("pool0", pool_type(kernel_size=3, stride=2, padding=1)), ] ) @@ -175,14 +205,20 @@ def __init__( bn_size=bn_size, growth_rate=growth_rate, dropout_prob=dropout_prob, + act=act, + norm=norm, ) self.features.add_module(f"denseblock{i + 1}", block) in_channels += num_layers * growth_rate if i == len(block_config) - 1: - self.features.add_module("norm5", norm_type(in_channels)) + self.features.add_module( + "norm5", get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=in_channels) + ) else: _out_channels = in_channels // 2 - trans = _Transition(spatial_dims, in_channels=in_channels, out_channels=_out_channels) + trans = _Transition( + spatial_dims, in_channels=in_channels, out_channels=_out_channels, act=act, norm=norm + ) self.features.add_module(f"transition{i + 1}", trans) in_channels = _out_channels @@ -190,7 +226,7 @@ def __init__( self.class_layers = nn.Sequential( OrderedDict( [ - ("relu", nn.ReLU(inplace=True)), + ("relu", get_act_layer(name=act)), ("pool", avg_pool_type(1)), ("flatten", nn.Flatten(1)), ("out", nn.Linear(in_channels, out_channels)), @@ -201,7 +237,7 @@ def __init__( for m in self.modules(): if isinstance(m, conv_type): nn.init.kaiming_normal_(torch.as_tensor(m.weight)) - elif isinstance(m, norm_type): + elif isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)): nn.init.constant_(torch.as_tensor(m.weight), 1) nn.init.constant_(torch.as_tensor(m.bias), 0) elif isinstance(m, nn.Linear): diff --git a/monai/networks/nets/efficientnet.py b/monai/networks/nets/efficientnet.py index fcb50c29f3..cb8e195b04 100644 --- a/monai/networks/nets/efficientnet.py +++ b/monai/networks/nets/efficientnet.py @@ -19,7 +19,8 @@ from torch import nn from torch.utils import model_zoo -from monai.networks.layers.factories import Act, Conv, Norm, Pad, Pool +from monai.networks.layers.factories import Act, Conv, Pad, Pool +from monai.networks.layers.utils import get_norm_layer __all__ = ["EfficientNet", "EfficientNetBN", "get_efficientnet_image_size", "drop_connect"] @@ -48,8 +49,7 @@ def __init__( expand_ratio: int, se_ratio: Optional[float], id_skip: Optional[bool] = True, - batch_norm_momentum: float = 0.99, - batch_norm_epsilon: float = 1e-3, + norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.01}), drop_connect_rate: Optional[float] = 0.2, ) -> None: """ @@ -65,8 +65,7 @@ def __init__( expand_ratio: expansion ratio for inverted bottleneck. se_ratio: squeeze-excitation ratio for se layers. id_skip: whether to use skip connection. - batch_norm_momentum: momentum for batch norm. - batch_norm_epsilon: epsilon for batch norm. + norm: feature normalization type and arguments. Defaults to batch norm. drop_connect_rate: dropconnect rate for drop connection (individual weights) layers. References: @@ -79,7 +78,6 @@ def __init__( # select the type of N-Dimensional layers to use # these are based on spatial dims and selected from MONAI factories conv_type = Conv["conv", spatial_dims] - batchnorm_type = Norm["batch", spatial_dims] adaptivepool_type = Pool["adaptiveavg", spatial_dims] self.in_channels = in_channels @@ -95,9 +93,6 @@ def __init__( else: self.has_se = False - bn_mom = 1.0 - batch_norm_momentum # pytorch"s difference from tensorflow - bn_eps = batch_norm_epsilon - # Expansion phase (Inverted Bottleneck) inp = in_channels # number of input channels oup = in_channels * expand_ratio # number of output channels @@ -105,7 +100,7 @@ def __init__( self._expand_conv = conv_type(in_channels=inp, out_channels=oup, kernel_size=1, bias=False) self._expand_conv_padding = _make_same_padder(self._expand_conv, image_size) - self._bn0 = batchnorm_type(num_features=oup, momentum=bn_mom, eps=bn_eps) + self._bn0 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=oup) else: # need to have the following to fix JIT error: # "Module 'MBConvBlock' has no attribute '_expand_conv'" @@ -125,7 +120,7 @@ def __init__( bias=False, ) self._depthwise_conv_padding = _make_same_padder(self._depthwise_conv, image_size) - self._bn1 = batchnorm_type(num_features=oup, momentum=bn_mom, eps=bn_eps) + self._bn1 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=oup) image_size = _calculate_output_image_size(image_size, self.stride) # Squeeze and Excitation layer, if desired @@ -141,7 +136,7 @@ def __init__( final_oup = out_channels self._project_conv = conv_type(in_channels=oup, out_channels=final_oup, kernel_size=1, bias=False) self._project_conv_padding = _make_same_padder(self._project_conv, image_size) - self._bn2 = batchnorm_type(num_features=final_oup, momentum=bn_mom, eps=bn_eps) + self._bn2 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=final_oup) # swish activation to use - using memory efficient swish by default # can be switched to normal swish using self.set_swish() function call @@ -207,8 +202,7 @@ def __init__( depth_coefficient: float = 1.0, dropout_rate: float = 0.2, image_size: int = 224, - batch_norm_momentum: float = 0.99, - batch_norm_epsilon: float = 1e-3, + norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.01}), drop_connect_rate: float = 0.2, depth_divisor: int = 8, ) -> None: @@ -226,8 +220,7 @@ def __init__( depth_coefficient: depth multiplier coefficient (d in paper). dropout_rate: dropout rate for dropout layers. image_size: input image resolution. - batch_norm_momentum: momentum for batch norm. - batch_norm_epsilon: epsilon for batch norm. + norm: feature normalization type and arguments. Defaults to batch norm. drop_connect_rate: dropconnect rate for drop connection (individual weights) layers. depth_divisor: depth divisor for channel rounding. """ @@ -239,7 +232,6 @@ def __init__( # select the type of N-Dimensional layers to use # these are based on spatial dims and selected from MONAI factories conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv["conv", spatial_dims] - batchnorm_type: Type[Union[nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d]] = Norm["batch", spatial_dims] adaptivepool_type: Type[Union[nn.AdaptiveAvgPool1d, nn.AdaptiveAvgPool2d, nn.AdaptiveAvgPool3d]] = Pool[ "adaptiveavg", spatial_dims ] @@ -262,16 +254,12 @@ def __init__( # expand input image dimensions to list current_image_size = [image_size] * spatial_dims - # parameters for batch norm - bn_mom = 1 - batch_norm_momentum # 1 - bn_m to convert tensorflow's arg to pytorch bn compatible - bn_eps = batch_norm_epsilon - # Stem stride = 2 out_channels = _round_filters(32, width_coefficient, depth_divisor) # number of output channels self._conv_stem = conv_type(self.in_channels, out_channels, kernel_size=3, stride=stride, bias=False) self._conv_stem_padding = _make_same_padder(self._conv_stem, current_image_size) - self._bn0 = batchnorm_type(num_features=out_channels, momentum=bn_mom, eps=bn_eps) + self._bn0 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=out_channels) current_image_size = _calculate_output_image_size(current_image_size, stride) # build MBConv blocks @@ -312,8 +300,7 @@ def __init__( expand_ratio=block_args.expand_ratio, se_ratio=block_args.se_ratio, id_skip=block_args.id_skip, - batch_norm_momentum=batch_norm_momentum, - batch_norm_epsilon=batch_norm_epsilon, + norm=norm, drop_connect_rate=blk_drop_connect_rate, ), ) @@ -344,8 +331,7 @@ def __init__( expand_ratio=block_args.expand_ratio, se_ratio=block_args.se_ratio, id_skip=block_args.id_skip, - batch_norm_momentum=batch_norm_momentum, - batch_norm_epsilon=batch_norm_epsilon, + norm=norm, drop_connect_rate=blk_drop_connect_rate, ), ) @@ -360,7 +346,7 @@ def __init__( out_channels = _round_filters(1280, width_coefficient, depth_divisor) self._conv_head = conv_type(head_in_channels, out_channels, kernel_size=1, bias=False) self._conv_head_padding = _make_same_padder(self._conv_head, current_image_size) - self._bn1 = batchnorm_type(num_features=out_channels, momentum=bn_mom, eps=bn_eps) + self._bn1 = get_norm_layer(name=norm, spatial_dims=spatial_dims, channels=out_channels) # final linear layer self._avg_pooling = adaptivepool_type(1) @@ -449,6 +435,7 @@ def __init__( spatial_dims: int = 2, in_channels: int = 3, num_classes: int = 1000, + norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.01}), ) -> None: """ Generic wrapper around EfficientNet, used to initialize EfficientNet-B0 to EfficientNet-B7 models @@ -457,11 +444,13 @@ def __init__( Args: model_name: name of model to initialize, can be from [efficientnet-b0, ..., efficientnet-b7]. - pretrained: whether to initialize pretrained ImageNet weights, only available for spatial_dims=2. + pretrained: whether to initialize pretrained ImageNet weights, only available for spatial_dims=2 and batch + norm is used. progress: whether to show download progress for pretrained weights download. spatial_dims: number of spatial dimensions. in_channels: number of input channels. num_classes: number of output classes. + norm: feature normalization type and arguments. Defaults to batch norm. Examples:: @@ -515,6 +504,7 @@ def __init__( dropout_rate=dropout_rate, image_size=image_size, drop_connect_rate=dropconnect_rate, + norm=norm, ) # attempt to load pretrained @@ -527,12 +517,6 @@ def __init__( # only pretrained for when `spatial_dims` is 2 _load_state_dict(self, model_name, progress, load_fc) - else: - print( - "Skipping loading pretrained weights for non-default {}, pretrained={}, is_default_model={}".format( - model_name, pretrained, is_default_model - ) - ) def get_efficientnet_image_size(model_name: str) -> int: diff --git a/tests/test_densenet.py b/tests/test_densenet.py index fe0a3a5222..ba4b7afcb4 100644 --- a/tests/test_densenet.py +++ b/tests/test_densenet.py @@ -32,13 +32,13 @@ device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_1 = [ # 4-channel 3D, batch 2 - {"pretrained": False, "spatial_dims": 3, "in_channels": 2, "out_channels": 3}, + {"pretrained": False, "spatial_dims": 3, "in_channels": 2, "out_channels": 3, "norm": ("instance", {"eps": 1e-5})}, (2, 2, 32, 64, 48), (2, 3), ] TEST_CASE_2 = [ # 4-channel 2D, batch 2 - {"pretrained": False, "spatial_dims": 2, "in_channels": 2, "out_channels": 3}, + {"pretrained": False, "spatial_dims": 2, "in_channels": 2, "out_channels": 3, "act": "PRELU"}, (2, 2, 32, 64), (2, 3), ] diff --git a/tests/test_efficientnet.py b/tests/test_efficientnet.py index f11fc8d433..6567e3af9a 100644 --- a/tests/test_efficientnet.py +++ b/tests/test_efficientnet.py @@ -75,7 +75,15 @@ def get_block_args(): ] -def make_shape_cases(models, spatial_dims, batches, pretrained, in_channels=3, num_classes=1000): +def make_shape_cases( + models, + spatial_dims, + batches, + pretrained, + in_channels=3, + num_classes=1000, + norm=("batch", {"eps": 1e-3, "momentum": 0.01}), +): ret_tests = [] for spatial_dim in spatial_dims: # selected spatial_dims for batch in batches: # check single batch as well as multiple batch input @@ -88,6 +96,7 @@ def make_shape_cases(models, spatial_dims, batches, pretrained, in_channels=3, n "spatial_dims": spatial_dim, "in_channels": in_channels, "num_classes": num_classes, + "norm": norm, } ret_tests.append( [ @@ -115,10 +124,22 @@ def make_shape_cases(models, spatial_dims, batches, pretrained, in_channels=3, n # 2D and 3D models are expensive so use selected models CASES_2D = make_shape_cases( - models=SEL_MODELS, spatial_dims=[2], batches=[1, 4], pretrained=[False], in_channels=3, num_classes=1000 + models=SEL_MODELS, + spatial_dims=[2], + batches=[1, 4], + pretrained=[False], + in_channels=3, + num_classes=1000, + norm="instance", ) CASES_3D = make_shape_cases( - models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=3, num_classes=1000 + models=[SEL_MODELS[0]], + spatial_dims=[3], + batches=[1], + pretrained=[False], + in_channels=3, + num_classes=1000, + norm="batch", ) # pretrained=True cases @@ -134,6 +155,7 @@ def make_shape_cases(models, spatial_dims, batches, pretrained, in_channels=3, n "spatial_dims": 2, "in_channels": 3, "num_classes": 1000, + "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}), }, os.path.join(os.path.dirname(__file__), "testing_data", "kitty_test.jpg"), 282, # ~ tiger cat @@ -209,7 +231,6 @@ class TestEFFICIENTNET(unittest.TestCase): @parameterized.expand(CASES_1D + CASES_2D + CASES_3D + CASES_VARIATIONS) def test_shape(self, input_param, input_shape, expected_shape): device = "cuda" if torch.cuda.is_available() else "cpu" - print(input_param) # initialize model net = EfficientNetBN(**input_param).to(device) @@ -224,7 +245,6 @@ def test_shape(self, input_param, input_shape, expected_shape): @parameterized.expand(CASES_1D + CASES_2D) def test_non_default_shapes(self, input_param, input_shape, expected_shape): device = "cuda" if torch.cuda.is_available() else "cpu" - print(input_param) # initialize model net = EfficientNetBN(**input_param).to(device) @@ -234,7 +254,6 @@ def test_non_default_shapes(self, input_param, input_shape, expected_shape): non_default_sizes = [128, 256, 512] for candidate_size in non_default_sizes: input_shape = input_shape[0:2] + (candidate_size,) * num_dims - print(input_shape) # run inference with random tensor with eval_mode(net): result = net(torch.randn(input_shape).to(device)) From 2bf67d4907eaf5815b58515a47c1546767739894 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Tue, 17 Aug 2021 13:59:35 +0800 Subject: [PATCH 39/89] [DLMED] fix typo (#2790) Signed-off-by: Nic Ma --- monai/transforms/compose.py | 2 +- monai/transforms/intensity/array.py | 4 ++-- tests/test_mask_intensity.py | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py index 8737abd0fa..4bf175769b 100644 --- a/monai/transforms/compose.py +++ b/monai/transforms/compose.py @@ -45,7 +45,7 @@ class Compose(Randomizable, InvertibleTransform): ndarray / tensor / tensor-like parameter. #. With a series of transforms that accept and return a dictionary that contains one or more parameters. Such transforms must have pass-through - semantics; unused values in the dictionary must be copied to the return + semantics that unused values in the dictionary must be copied to the return dictionary. It is required that the dictionary is copied between input and output of each transform. diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 258d896eb6..e418ee819f 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -798,13 +798,13 @@ class MaskIntensity(Transform): of input image. if multiple channels, the number of channels must match the input data. the intensity values of input image corresponding to the selected values in the mask data will keep the original value, - others will be set to `0`. + others will be set to `0`. if None, must specify the `mask_data` at runtime. select_fn: function to select valid values of the `mask_data`, default is to select `values > 0`. """ - def __init__(self, mask_data: Optional[np.ndarray], select_fn: Callable = is_positive) -> None: + def __init__(self, mask_data: Optional[np.ndarray] = None, select_fn: Callable = is_positive) -> None: self.mask_data = mask_data self.select_fn = select_fn diff --git a/tests/test_mask_intensity.py b/tests/test_mask_intensity.py index da9eda6416..a3662eec49 100644 --- a/tests/test_mask_intensity.py +++ b/tests/test_mask_intensity.py @@ -50,6 +50,14 @@ def test_value(self, argments, image, expected_data): result = MaskIntensity(**argments)(image) np.testing.assert_allclose(result, expected_data) + def test_runtime_mask(self): + mask_data = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]]]) + img = np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3]], [[4, 4, 4], [5, 5, 5], [6, 6, 6]]]) + expected = np.array([[[0, 0, 0], [0, 2, 0], [0, 0, 0]], [[0, 0, 0], [0, 5, 0], [0, 0, 0]]]) + + result = MaskIntensity()(img=img, mask_data=mask_data) + np.testing.assert_allclose(result, expected) + if __name__ == "__main__": unittest.main() From dea3f701eb0bc9d108c3aca3710b73e5a311236b Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 17 Aug 2021 10:07:55 +0100 Subject: [PATCH 40/89] fixes #2771 (#2793) Signed-off-by: Wenqi Li --- monai/networks/nets/highresnet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monai/networks/nets/highresnet.py b/monai/networks/nets/highresnet.py index 12908a9119..f644a7835a 100644 --- a/monai/networks/nets/highresnet.py +++ b/monai/networks/nets/highresnet.py @@ -90,6 +90,7 @@ def __init__( kernel_size=kernel_size, dilation=dilation, bias=bias, + conv_only=True, ) ) _in_chns = _out_chns From a38bae30ca96a3e28207538c1e2b8022fbf07571 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 17 Aug 2021 14:48:19 +0100 Subject: [PATCH 41/89] remove post-merge dev branch test (#2786) Signed-off-by: Wenqi Li --- .github/workflows/setupapp.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/setupapp.yml b/.github/workflows/setupapp.yml index 295d4814c8..d0dc3a9f10 100644 --- a/.github/workflows/setupapp.yml +++ b/.github/workflows/setupapp.yml @@ -4,7 +4,6 @@ on: # full tests for all the important branches push: branches: - - dev - main - releasing/* - feature/* From cf6edc7705be06b310580ce152c4e540824ae05b Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 17 Aug 2021 17:15:33 +0100 Subject: [PATCH 42/89] RandGaussianNoise and SavitzkyGolaySmooth Torch/NumpyTransforms (#2740) * RandGaussianNoise and SavitzkyGolaySmooth Torch/NumpyTransforms Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- docs/source/transforms.rst | 5 - docs/source/utils.rst | 8 + monai/config/type_definitions.py | 6 +- monai/transforms/__init__.py | 14 +- monai/transforms/intensity/array.py | 140 ++++++------ monai/transforms/intensity/dictionary.py | 38 ++-- monai/transforms/transform.py | 46 +--- monai/transforms/utility/array.py | 12 +- monai/transforms/utility/dictionary.py | 6 +- monai/transforms/utils.py | 132 ++++-------- monai/utils/__init__.py | 12 +- monai/utils/misc.py | 32 +-- monai/utils/type_conversion.py | 201 ++++++++++++++++++ tests/test_convert_data_type.py | 67 ++++++ tests/test_get_equivalent_dtype.py | 37 ++++ tests/test_rand_gaussian_noise.py | 31 ++- tests/test_rand_gaussian_noised.py | 48 ++--- tests/test_savitzky_golay_smooth.py | 16 +- .../test_scale_intensity_range_percentiles.py | 8 +- ...test_scale_intensity_range_percentilesd.py | 8 +- tests/utils.py | 9 +- 21 files changed, 543 insertions(+), 333 deletions(-) create mode 100644 monai/utils/type_conversion.py create mode 100644 tests/test_convert_data_type.py create mode 100644 tests/test_get_equivalent_dtype.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index a1bafaf103..65b35b0dc8 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -53,11 +53,6 @@ Generic Interfaces .. autoclass:: Decollated :members: -`Fourier` -^^^^^^^^^^^^^ -.. autoclass:: Fourier - :members: - Vanilla Transforms ------------------ diff --git a/docs/source/utils.rst b/docs/source/utils.rst index 321e6acdfc..ecb8daffdc 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -22,6 +22,7 @@ Aliases .. automodule:: monai.utils.aliases :members: + Misc ---- .. automodule:: monai.utils.misc @@ -33,7 +34,14 @@ Profiling .. automodule:: monai.utils.profiling :members: + Deprecated ---------- .. automodule:: monai.utils.deprecated :members: + + +Type conversion +--------------- +.. automodule:: monai.utils.type_conversion + :members: diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index 375ae460b2..478830437e 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -56,11 +56,7 @@ """ -DtypeLike = Union[ - np.dtype, - type, - None, -] +DtypeLike = Union[np.dtype, type, None] """Type of datatypes adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py """ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index f259ff86bc..b3b3b15a1f 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -352,15 +352,7 @@ ZoomD, ZoomDict, ) -from .transform import ( - Fourier, - MapTransform, - Randomizable, - RandomizableTransform, - ThreadUnsafe, - Transform, - apply_transform, -) +from .transform import MapTransform, Randomizable, RandomizableTransform, ThreadUnsafe, Transform, apply_transform from .utility.array import ( AddChannel, AddExtremePointsChannel, @@ -493,11 +485,10 @@ TransposeDict, ) from .utils import ( + Fourier, allow_missing_keys_mode, compute_divisible_spatial_size, convert_inverse_interp_mode, - convert_to_numpy, - convert_to_tensor, copypaste_arrays, create_control_grid, create_grid, @@ -524,7 +515,6 @@ rescale_array_int_max, rescale_instance_array, resize_center, - tensor_to_numpy, weighted_patch_samples, zero_margins, ) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index e418ee819f..113fbadbb1 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -21,13 +21,16 @@ import torch from monai.config import DtypeLike +from monai.config.type_definitions import NdarrayTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter -from monai.transforms.transform import Fourier, RandomizableTransform, Transform -from monai.transforms.utils import equalize_hist, is_positive, rescale_array +from monai.transforms.transform import RandomizableTransform, Transform +from monai.transforms.utils import Fourier, equalize_hist, is_positive, rescale_array from monai.utils import ( PT_BEFORE_1_7, InvalidPyTorchVersionError, + convert_data_type, + convert_to_dst_type, dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, @@ -78,6 +81,8 @@ class RandGaussianNoise(RandomizableTransform): std: Standard deviation (spread) of distribution. """ + backend = ["torch", "numpy"] + def __init__(self, prob: float = 0.1, mean: Union[Sequence[float], float] = 0.0, std: float = 0.1) -> None: RandomizableTransform.__init__(self, prob) self.mean = mean @@ -88,17 +93,17 @@ def randomize(self, im_shape: Sequence[int]) -> None: super().randomize(None) self._noise = self.R.normal(self.mean, self.R.uniform(0, self.std), size=im_shape) - def __call__(self, img: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]: + def __call__(self, img: NdarrayTensor) -> NdarrayTensor: """ Apply the transform to `img`. """ self.randomize(img.shape) if self._noise is None: - raise AssertionError + raise RuntimeError("randomized factor should not be None.") if not self._do_transform: return img - dtype = dtype_torch_to_numpy(img.dtype) if isinstance(img, torch.Tensor) else img.dtype - return img + self._noise.astype(dtype) + noise, *_ = convert_to_dst_type(self._noise, img) + return img + noise # type: ignore class RandRicianNoise(RandomizableTransform): @@ -149,7 +154,7 @@ def _add_noise(self, img: Union[torch.Tensor, np.ndarray], mean: float, std: flo self._noise1 = self.R.normal(mean, _std, size=im_shape) self._noise2 = self.R.normal(mean, _std, size=im_shape) if self._noise1 is None or self._noise2 is None: - raise AssertionError + raise RuntimeError("noise should not be None.") dtype = dtype_torch_to_numpy(img.dtype) if isinstance(img, torch.Tensor) else img.dtype return np.sqrt((img + self._noise1.astype(dtype)) ** 2 + self._noise2.astype(dtype) ** 2) @@ -167,12 +172,12 @@ def __call__(self, img: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, img[i] = self._add_noise(d, mean=_mean[i], std=_std[i] * d.std() if self.relative else _std[i]) else: if not isinstance(self.mean, (int, float)): - raise AssertionError("If channel_wise is False, mean must be a float or int number.") + raise RuntimeError("If channel_wise is False, mean must be a float or int number.") if not isinstance(self.std, (int, float)): - raise AssertionError("If channel_wise is False, std must be a float or int number.") + raise RuntimeError("If channel_wise is False, std must be a float or int number.") std = self.std * img.std() if self.relative else self.std if not isinstance(std, (int, float)): - raise AssertionError + raise RuntimeError("std must be a float or int number.") img = self._add_noise(img, mean=self.mean, std=std) return img @@ -212,9 +217,9 @@ def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1 RandomizableTransform.__init__(self, prob) if isinstance(offsets, (int, float)): self.offsets = (min(-offsets, offsets), max(-offsets, offsets)) + elif len(offsets) != 2: + raise ValueError("offsets should be a number or pair of numbers.") else: - if len(offsets) != 2: - raise AssertionError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) self._offset = self.offsets[0] self._shfiter = ShiftIntensity(self._offset) @@ -310,9 +315,9 @@ def __init__( RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") else: - if len(factors) != 2: - raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) self.factor = self.factors[0] self.nonzero = nonzero @@ -388,9 +393,9 @@ def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1 RandomizableTransform.__init__(self, prob) if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") else: - if len(factors) != 2: - raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) self.factor = self.factors[0] @@ -576,7 +581,7 @@ class ThresholdIntensity(Transform): def __init__(self, threshold: float, above: bool = True, cval: float = 0.0) -> None: if not isinstance(threshold, (int, float)): - raise AssertionError("threshold must be a float or int number.") + raise ValueError("threshold must be a float or int number.") self.threshold = threshold self.above = above self.cval = cval @@ -637,7 +642,7 @@ class AdjustContrast(Transform): def __init__(self, gamma: float) -> None: if not isinstance(gamma, (int, float)): - raise AssertionError("gamma must be a float or int number.") + raise ValueError("gamma must be a float or int number.") self.gamma = gamma def __call__(self, img: np.ndarray): @@ -667,13 +672,13 @@ def __init__(self, prob: float = 0.1, gamma: Union[Sequence[float], float] = (0. if isinstance(gamma, (int, float)): if gamma <= 0.5: - raise AssertionError( + raise ValueError( "if gamma is single number, must greater than 0.5 and value is picked from (0.5, gamma)" ) self.gamma = (0.5, gamma) + elif len(gamma) != 2: + raise ValueError("gamma should be a number or pair of numbers.") else: - if len(gamma) != 2: - raise AssertionError("gamma should be a number or pair of numbers.") self.gamma = (min(gamma), max(gamma)) self.gamma_value: float @@ -688,7 +693,7 @@ def __call__(self, img: np.ndarray): """ self.randomize() if self.gamma_value is None: - raise AssertionError + raise ValueError("gamma_value is not set.") if not self._do_transform: return img adjuster = AdjustContrast(self.gamma_value) @@ -754,9 +759,9 @@ def __init__( self, lower: float, upper: float, b_min: float, b_max: float, clip: bool = False, relative: bool = False ) -> None: if lower < 0.0 or lower > 100.0: - raise AssertionError("Percentiles must be in the range [0, 100]") + raise ValueError("Percentiles must be in the range [0, 100]") if upper < 0.0 or upper > 100.0: - raise AssertionError("Percentiles must be in the range [0, 100]") + raise ValueError("Percentiles must be in the range [0, 100]") self.lower = lower self.upper = upper self.b_min = b_min @@ -847,6 +852,8 @@ class SavitzkyGolaySmooth(Transform): or ``'circular'``. Default: ``'zeros'``. See ``torch.nn.Conv1d()`` for more information. """ + backend = ["numpy"] + def __init__(self, window_length: int, order: int, axis: int = 1, mode: str = "zeros"): if axis < 0: @@ -856,21 +863,24 @@ def __init__(self, window_length: int, order: int, axis: int = 1, mode: str = "z self.order = order self.axis = axis self.mode = mode + self.img_t: torch.Tensor = torch.tensor(0.0) - def __call__(self, img: np.ndarray): + def __call__(self, img: NdarrayTensor) -> torch.Tensor: """ Args: - img: numpy.ndarray containing input data. Must be real and in shape [channels, spatial1, spatial2, ...]. + img: array containing input data. Must be real and in shape [channels, spatial1, spatial2, ...]. Returns: - np.ndarray containing smoothed result. + array containing smoothed result. """ + self.img_t, *_ = convert_data_type(img, torch.Tensor) + # add one to transform axis because a batch axis will be added at dimension 0 savgol_filter = SavitzkyGolayFilter(self.window_length, self.order, self.axis + 1, self.mode) # convert to Tensor and add Batch axis expected by HilbertTransform - input_data = torch.as_tensor(np.ascontiguousarray(img)).unsqueeze(0) - return savgol_filter(input_data).squeeze(0).numpy() + out: torch.Tensor = savgol_filter(self.img_t.unsqueeze(0)).squeeze(0) + return out class DetectEnvelope(Transform): @@ -1113,13 +1123,13 @@ def __init__(self, num_control_points: Union[Tuple[int, int], int] = 10, prob: f if isinstance(num_control_points, int): if num_control_points <= 2: - raise AssertionError("num_control_points should be greater than or equal to 3") + raise ValueError("num_control_points should be greater than or equal to 3") self.num_control_points = (num_control_points, num_control_points) else: if len(num_control_points) != 2: - raise AssertionError("num_control points should be a number or a pair of numbers") + raise ValueError("num_control points should be a number or a pair of numbers") if min(num_control_points) <= 2: - raise AssertionError("num_control_points should be greater than or equal to 3") + raise ValueError("num_control_points should be greater than or equal to 3") self.num_control_points = (min(num_control_points), max(num_control_points)) def randomize(self, data: Optional[Any] = None) -> None: @@ -1169,11 +1179,11 @@ class RandGibbsNoise(RandomizableTransform): def __init__(self, prob: float = 0.1, alpha: Sequence[float] = (0.0, 1.0), as_tensor_output: bool = True) -> None: if len(alpha) != 2: - raise AssertionError("alpha length must be 2.") + raise ValueError("alpha length must be 2.") if alpha[1] > 1 or alpha[0] < 0: - raise AssertionError("alpha must take values in the interval [0,1]") + raise ValueError("alpha must take values in the interval [0,1]") if alpha[0] > alpha[1]: - raise AssertionError("When alpha = [a,b] we need a < b.") + raise ValueError("When alpha = [a,b] we need a < b.") self.alpha = alpha self.sampled_alpha = -1.0 # stores last alpha sampled by randomize() @@ -1230,7 +1240,7 @@ class GibbsNoise(Transform, Fourier): def __init__(self, alpha: float = 0.5, as_tensor_output: bool = True) -> None: if alpha > 1 or alpha < 0: - raise AssertionError("alpha must take values in the interval [0,1].") + raise ValueError("alpha must take values in the interval [0,1].") self.alpha = alpha self.as_tensor_output = as_tensor_output @@ -1329,14 +1339,13 @@ def __init__( # assert one-to-one relationship between factors and locations if isinstance(k_intensity, Sequence): if not isinstance(loc[0], Sequence): - raise AssertionError( + raise ValueError( "If a sequence is passed to k_intensity, then a sequence of locations must be passed to loc" ) if len(k_intensity) != len(loc): - raise AssertionError("There must be one intensity_factor value for each tuple of indices in loc.") - if isinstance(self.loc[0], Sequence) and k_intensity is not None: - if not isinstance(self.k_intensity, Sequence): - raise AssertionError("There must be one intensity_factor value for each tuple of indices in loc.") + raise ValueError("There must be one intensity_factor value for each tuple of indices in loc.") + if isinstance(self.loc[0], Sequence) and k_intensity is not None and not isinstance(self.k_intensity, Sequence): + raise ValueError("There must be one intensity_factor value for each tuple of indices in loc.") def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, np.ndarray]: """ @@ -1347,11 +1356,11 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, self._check_indices(img) if len(img.shape) < 3: - raise AssertionError("Image needs a channel direction.") + raise RuntimeError("Image needs a channel direction.") if isinstance(self.loc[0], int) and len(img.shape) == 4 and len(self.loc) == 2: - raise AssertionError("Input images of dimension 4 need location tuple to be length 3 or 4") + raise RuntimeError("Input images of dimension 4 need location tuple to be length 3 or 4") if isinstance(self.loc[0], Sequence) and len(img.shape) == 4 and min(map(lambda x: len(x), self.loc)) == 2: - raise AssertionError("Input images of dimension 4 need location tuple to be length 3 or 4") + raise RuntimeError("Input images of dimension 4 need location tuple to be length 3 or 4") n_dims = len(img.shape[1:]) @@ -1392,8 +1401,8 @@ def _check_indices(self, img) -> None: loc[i] = [0] + list(loc[i]) for i in range(len(img.shape)): - if img.shape[i] <= max([x[i] for x in loc]): - raise AssertionError( + if img.shape[i] <= max(x[i] for x in loc): + raise ValueError( f"The index value at position {i} of one of the tuples in loc = {self.loc} is out of bounds for current image." ) @@ -1407,10 +1416,7 @@ def _set_spike(self, k: torch.Tensor, idx: Tuple, val: Union[Sequence[float], fl val: value of intensity to write in. """ if len(k.shape) == len(idx): - if isinstance(val, Sequence): - k[idx] = val[idx[0]] - else: - k[idx] = val + k[idx] = val[idx[0]] if isinstance(val, Sequence) else val elif len(k.shape) == 4 and len(idx) == 3: k[:, idx[0], idx[1], idx[2]] = val # type: ignore elif len(k.shape) == 3 and len(idx) == 2: @@ -1470,11 +1476,8 @@ def __init__( self.sampled_k_intensity: List = [] self.sampled_locs: List[Tuple] = [] - if intensity_range is not None: - if isinstance(intensity_range[0], Sequence) and not channel_wise: - raise AssertionError( - "When channel_wise = False, intensity_range should be a 2-tuple (low, high) or None." - ) + if intensity_range is not None and isinstance(intensity_range[0], Sequence) and not channel_wise: + raise ValueError("When channel_wise = False, intensity_range should be a 2-tuple (low, high) or None.") super().__init__(prob) @@ -1485,11 +1488,14 @@ def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> Union[torch.Tensor, Args: img: image with dimensions (C, H, W) or (C, H, W, D) """ - if self.intensity_range is not None: - if isinstance(self.intensity_range[0], Sequence) and len(self.intensity_range) != img.shape[0]: - raise AssertionError( - "If intensity_range is a sequence of sequences, then there must be one (low, high) tuple for each channel." - ) + if ( + self.intensity_range is not None + and isinstance(self.intensity_range[0], Sequence) + and len(self.intensity_range) != img.shape[0] + ): + raise RuntimeError( + "If intensity_range is a sequence of sequences, then there must be one (low, high) tuple for each channel." + ) self.sampled_k_intensity = [] self.sampled_locs = [] @@ -1539,15 +1545,14 @@ def _make_sequence(self, x: torch.Tensor) -> Sequence[Sequence[float]]: """ Formats the sequence of intensities ranges to Sequence[Sequence[float]]. """ - if self.intensity_range is not None: - if not isinstance(self.intensity_range[0], Sequence): - intensity_range = (ensure_tuple(self.intensity_range),) * x.shape[0] - return intensity_range - return ensure_tuple(self.intensity_range) - else: + if self.intensity_range is None: # set default range if one not provided return self._set_default_range(x) + if not isinstance(self.intensity_range[0], Sequence): + return (ensure_tuple(self.intensity_range),) * x.shape[0] + return ensure_tuple(self.intensity_range) + def _set_default_range(self, img: torch.Tensor) -> Sequence[Sequence[float]]: """ Sets default intensity ranges to be sampled. @@ -1560,8 +1565,7 @@ def _set_default_range(self, img: torch.Tensor) -> Sequence[Sequence[float]]: k = self.shift_fourier(img, n_dims) log_abs = torch.log(torch.absolute(k) + 1e-10) shifted_means = torch.mean(log_abs, dim=tuple(range(-n_dims, 0))) * 2.5 - intensity_sequence = tuple((i * 0.95, i * 1.1) for i in shifted_means) - return intensity_sequence + return tuple((i * 0.95, i * 1.1) for i in shifted_means) class RandCoarseDropout(RandomizableTransform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index bc5534b402..d3780641ae 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -21,7 +21,7 @@ import numpy as np import torch -from monai.config import DtypeLike, KeysCollection +from monai.config import DtypeLike, KeysCollection, NdarrayTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.intensity.array import ( AdjustContrast, @@ -44,7 +44,7 @@ ) from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive -from monai.utils import dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils import convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple __all__ = [ "RandGaussianNoised", @@ -144,6 +144,8 @@ class RandGaussianNoised(RandomizableTransform, MapTransform): allow_missing_keys: don't raise exception if key is missing. """ + backend = ["torch", "numpy"] + def __init__( self, keys: KeysCollection, @@ -164,18 +166,18 @@ def randomize(self, im_shape: Sequence[int]) -> None: for m in self.mean: self._noise.append(self.R.normal(m, self.R.uniform(0, self.std), size=im_shape)) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) image_shape = d[self.keys[0]].shape # image shape from the first data key self.randomize(image_shape) if len(self._noise) != len(self.keys): - raise AssertionError + raise RuntimeError("inconsistent noise items and keys.") if not self._do_transform: return d for key, noise in self.key_iterator(d, self._noise): - dtype = dtype_torch_to_numpy(d[key].dtype) if isinstance(d[key], torch.Tensor) else d[key].dtype - d[key] = d[key] + noise.astype(dtype) + noise, *_ = convert_to_dst_type(noise, d[key]) + d[key] = d[key] + noise return d @@ -333,7 +335,7 @@ def __init__( self.offsets = (min(-offsets, offsets), max(-offsets, offsets)) else: if len(offsets) != 2: - raise AssertionError("offsets should be a number or pair of numbers.") + raise ValueError("offsets should be a number or pair of numbers.") self.offsets = (min(offsets), max(offsets)) self._offset = self.offsets[0] self.factor_key = ensure_tuple_rep(factor_key, len(self.keys)) @@ -429,9 +431,9 @@ def __init__( if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") else: - if len(factors) != 2: - raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) self.factor = self.factors[0] self.nonzero = nonzero @@ -517,9 +519,9 @@ def __init__( if isinstance(factors, (int, float)): self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") else: - if len(factors) != 2: - raise AssertionError("factors should be a number or pair of numbers.") self.factors = (min(factors), max(factors)) self.factor = self.factors[0] @@ -739,13 +741,13 @@ def __init__( if isinstance(gamma, (int, float)): if gamma <= 0.5: - raise AssertionError( + raise ValueError( "if gamma is single number, must greater than 0.5 and value is picked from (0.5, gamma)" ) self.gamma = (0.5, gamma) + elif len(gamma) != 2: + raise ValueError("gamma should be a number or pair of numbers.") else: - if len(gamma) != 2: - raise AssertionError("gamma should be a number or pair of numbers.") self.gamma = (min(gamma), max(gamma)) self.gamma_value: Optional[float] = None @@ -758,7 +760,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda d = dict(data) self.randomize() if self.gamma_value is None: - raise AssertionError + raise RuntimeError("gamma_value is not set.") if not self._do_transform: return d adjuster = AdjustContrast(self.gamma_value) @@ -1067,13 +1069,13 @@ def __init__( RandomizableTransform.__init__(self, prob) if isinstance(num_control_points, int): if num_control_points <= 2: - raise AssertionError("num_control_points should be greater than or equal to 3") + raise ValueError("num_control_points should be greater than or equal to 3") self.num_control_points = (num_control_points, num_control_points) else: if len(num_control_points) != 2: - raise AssertionError("num_control points should be a number or a pair of numbers") + raise ValueError("num_control points should be a number or a pair of numbers") if min(num_control_points) <= 2: - raise AssertionError("num_control_points should be greater than or equal to 3") + raise ValueError("num_control_points should be greater than or equal to 3") self.num_control_points = (min(num_control_points), max(num_control_points)) def randomize(self, data: Optional[Any] = None) -> None: diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index ac371c5782..aff468b2a5 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -30,7 +30,6 @@ "RandomizableTransform", "Transform", "MapTransform", - "Fourier", ] ReturnType = TypeVar("ReturnType") @@ -213,6 +212,11 @@ class Transform(ABC): :py:class:`monai.transforms.Compose` """ + backend: List[str] = [] + """Transforms should add data types to this list if they are capable of performing a transform without + modifying the input type. For example, [\"torch.Tensor\", \"np.ndarray\"] means that no copies of the data + are required if the input is either \"torch.Tensor\" or \"np.ndarray\".""" + @abstractmethod def __call__(self, data: Any): """ @@ -374,43 +378,3 @@ def key_iterator( yield (key,) + tuple(_ex_iters) if extra_iterables else key elif not self.allow_missing_keys: raise KeyError(f"Key was missing ({key}) and allow_missing_keys==False") - - -class Fourier: - """ - Helper class storing Fourier mappings - """ - - @staticmethod - def shift_fourier(x: torch.Tensor, n_dims: int) -> torch.Tensor: - """ - Applies fourier transform and shifts the zero-frequency component to the - center of the spectrum. Only the spatial dimensions get transformed. - - Args: - x: Image to transform. - n_dims: Number of spatial dimensions. - Returns - k: K-space data. - """ - k: torch.Tensor = torch.fft.fftshift( - torch.fft.fftn(x, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) - ) - return k - - @staticmethod - def inv_shift_fourier(k: torch.Tensor, n_dims: int) -> torch.Tensor: - """ - Applies inverse shift and fourier transform. Only the spatial - dimensions are transformed. - - Args: - k: K-space data. - n_dims: Number of spatial dimensions. - Returns: - x: Tensor in image space. - """ - x: torch.Tensor = torch.fft.ifftn( - torch.fft.ifftshift(k, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) - ).real - return x diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index fe73c6189c..c41983787d 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -25,14 +25,20 @@ from monai.config import DtypeLike, NdarrayTensor from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( - convert_to_numpy, - convert_to_tensor, extreme_points_to_image, get_extreme_points, map_binary_to_indices, map_classes_to_indices, ) -from monai.utils import ensure_tuple, issequenceiterable, look_up_option, min_version, optional_import +from monai.utils import ( + convert_to_numpy, + convert_to_tensor, + ensure_tuple, + issequenceiterable, + look_up_option, + min_version, + optional_import, +) PILImageImage, has_pil = optional_import("PIL.Image", name="Image") pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index fb9963601d..67fe7653e0 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -55,8 +55,8 @@ ToTensor, Transpose, ) -from monai.transforms.utils import extreme_points_to_image, get_extreme_points, tensor_to_numpy -from monai.utils import ensure_tuple, ensure_tuple_rep +from monai.transforms.utils import extreme_points_to_image, get_extreme_points +from monai.utils import convert_to_numpy, ensure_tuple, ensure_tuple_rep from monai.utils.enums import InverseKeys __all__ = [ @@ -489,7 +489,7 @@ def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: for key in self.key_iterator(d): # FIXME: currently, only convert tensor data to numpy array or scalar number, # need to also invert numpy array but it's not easy to determine the previous data type - d[key] = tensor_to_numpy(d[key]) + d[key] = convert_to_numpy(d[key]) # Remove the applied transform self.pop_transform(d, key) return d diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index e996d7c9ea..2468294279 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -11,7 +11,6 @@ import itertools import random -import re import warnings from contextlib import contextmanager from typing import Callable, Iterable, List, Optional, Sequence, Tuple, Union @@ -46,8 +45,6 @@ "allow_missing_keys_mode", "compute_divisible_spatial_size", "convert_inverse_interp_mode", - "convert_to_numpy", - "convert_to_tensor", "copypaste_arrays", "create_control_grid", "create_grid", @@ -57,6 +54,7 @@ "create_translate", "extreme_points_to_image", "fill_holes", + "Fourier", "generate_label_classes_crop_centers", "generate_pos_neg_label_crop_centers", "generate_spatial_bounding_box", @@ -74,7 +72,6 @@ "rescale_array_int_max", "rescale_instance_array", "resize_center", - "tensor_to_numpy", "weighted_patch_samples", "zero_margins", "equalize_hist", @@ -1032,93 +1029,6 @@ def compute_divisible_spatial_size(spatial_shape: Sequence[int], k: Union[Sequen return new_size -def convert_to_tensor(data): - """ - Utility to convert the input data to a PyTorch Tensor. If passing a dictionary, list or tuple, - recursively check every item and convert it to PyTorch Tensor. - - Args: - data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. - will convert Tensor, Numpy array, float, int, bool to Tensors, strings and objects keep the original. - for dictionary, list or tuple, convert every item to a Tensor if applicable. - - """ - if isinstance(data, torch.Tensor): - return data.contiguous() - if isinstance(data, np.ndarray): - # skip array of string classes and object, refer to: - # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 - if re.search(r"[SaUO]", data.dtype.str) is None: - # numpy array with 0 dims is also sequence iterable, - # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims - return torch.as_tensor(data if data.ndim == 0 else np.ascontiguousarray(data)) - elif isinstance(data, (float, int, bool)): - return torch.as_tensor(data) - elif isinstance(data, dict): - return {k: convert_to_tensor(v) for k, v in data.items()} - elif isinstance(data, list): - return [convert_to_tensor(i) for i in data] - elif isinstance(data, tuple): - return tuple(convert_to_tensor(i) for i in data) - - return data - - -def convert_to_numpy(data): - """ - Utility to convert the input data to a numpy array. If passing a dictionary, list or tuple, - recursively check every item and convert it to numpy array. - - Args: - data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. - will convert Tensor, Numpy array, float, int, bool to numpy arrays, strings and objects keep the original. - for dictionary, list or tuple, convert every item to a numpy array if applicable. - - """ - if isinstance(data, torch.Tensor): - data = data.detach().cpu().numpy() - elif has_cp and isinstance(data, cp_ndarray): - data = cp.asnumpy(data) - elif isinstance(data, (float, int, bool)): - data = np.asarray(data) - elif isinstance(data, dict): - return {k: convert_to_numpy(v) for k, v in data.items()} - elif isinstance(data, list): - return [convert_to_numpy(i) for i in data] - elif isinstance(data, tuple): - return tuple([convert_to_numpy(i) for i in data]) - - if isinstance(data, np.ndarray) and data.ndim > 0: - data = np.ascontiguousarray(data) - - return data - - -def tensor_to_numpy(data): - """ - Utility to convert the input PyTorch Tensor data to numpy array, if scalar Tensor, convert to regular number. - If passing a dictionary, list or tuple, recursively check every PyTorch Tensor item and convert it to numpy arrays. - - Args: - data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. - will convert the Tensor data to numpy array, others keep the original. for dictionary, list or tuple, - convert every Tensor item to numpy array if applicable. - - """ - - if isinstance(data, torch.Tensor): - # invert Tensor to numpy, if scalar data, convert to number - return data.item() if data.ndim == 0 else np.ascontiguousarray(data.detach().cpu().numpy()) - if isinstance(data, dict): - return {k: tensor_to_numpy(v) for k, v in data.items()} - if isinstance(data, list): - return [tensor_to_numpy(i) for i in data] - if isinstance(data, tuple): - return tuple(tensor_to_numpy(i) for i in data) - - return data - - def equalize_hist( img: np.ndarray, mask: Optional[np.ndarray] = None, @@ -1159,3 +1069,43 @@ def equalize_hist( img = np.interp(img.flatten(), bins, cum) return img.reshape(orig_shape).astype(dtype) + + +class Fourier: + """ + Helper class storing Fourier mappings + """ + + @staticmethod + def shift_fourier(x: torch.Tensor, n_dims: int) -> torch.Tensor: + """ + Applies fourier transform and shifts the zero-frequency component to the + center of the spectrum. Only the spatial dimensions get transformed. + + Args: + x: Image to transform. + n_dims: Number of spatial dimensions. + Returns + k: K-space data. + """ + k: torch.Tensor = torch.fft.fftshift( + torch.fft.fftn(x, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) + ) + return k + + @staticmethod + def inv_shift_fourier(k: torch.Tensor, n_dims: int) -> torch.Tensor: + """ + Applies inverse shift and fourier transform. Only the spatial + dimensions are transformed. + + Args: + k: K-space data. + n_dims: Number of spatial dimensions. + Returns: + x: Tensor in image space. + """ + x: torch.Tensor = torch.fft.ifftn( + torch.fft.ifftshift(k, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) + ).real + return x diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index af3cd87652..16231ba17e 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -38,8 +38,6 @@ MAX_SEED, ImageMetaKey, copy_to_device, - dtype_numpy_to_torch, - dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, @@ -74,3 +72,13 @@ ) from .profiling import PerfContext, torch_profiler_full, torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end from .state_cacher import StateCacher +from .type_conversion import ( + convert_data_type, + convert_to_dst_type, + convert_to_numpy, + convert_to_tensor, + dtype_numpy_to_torch, + dtype_torch_to_numpy, + get_dtype, + get_equivalent_dtype, +) diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 86dc55aa9e..66f6557032 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -39,8 +39,6 @@ "get_seed", "set_determinism", "list_to_dict", - "dtype_torch_to_numpy", - "dtype_numpy_to_torch", "MAX_SEED", "copy_to_device", "ImageMetaKey", @@ -125,6 +123,10 @@ def ensure_tuple_rep(tup: Any, dim: int) -> Tuple[Any, ...]: ValueError: Sequence must have length 3, got length 2. """ + if isinstance(tup, torch.Tensor): + tup = tup.detach().cpu().numpy() + if isinstance(tup, np.ndarray): + tup = tup.tolist() if not issequenceiterable(tup): return (tup,) * dim if len(tup) == dim: @@ -299,32 +301,6 @@ def _parse_var(s): return d -_torch_to_np_dtype = { - torch.bool: bool, - torch.uint8: np.uint8, - torch.int8: np.int8, - torch.int16: np.int16, - torch.int32: np.int32, - torch.int64: np.int64, - torch.float16: np.float16, - torch.float32: np.float32, - torch.float64: np.float64, - torch.complex64: np.complex64, - torch.complex128: np.complex128, -} -_np_to_torch_dtype = {value: key for key, value in _torch_to_np_dtype.items()} - - -def dtype_torch_to_numpy(dtype): - """Convert a torch dtype to its numpy equivalent.""" - return _torch_to_np_dtype[dtype] - - -def dtype_numpy_to_torch(dtype): - """Convert a numpy dtype to its torch equivalent.""" - return _np_to_torch_dtype[dtype] - - def copy_to_device( obj: Any, device: Optional[Union[str, torch.device]], diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py new file mode 100644 index 0000000000..da2373e03b --- /dev/null +++ b/monai/utils/type_conversion.py @@ -0,0 +1,201 @@ +import re +from typing import Any, Optional, Sequence, Tuple, Union + +import numpy as np +import torch + +from monai.config.type_definitions import DtypeLike, NdarrayTensor +from monai.utils import optional_import + +cp, has_cp = optional_import("cupy") +cp_ndarray, _ = optional_import("cupy", name="ndarray") + +__all__ = [ + "dtype_torch_to_numpy", + "dtype_numpy_to_torch", + "get_equivalent_dtype", + "convert_data_type", + "get_dtype", + "convert_to_numpy", + "convert_to_tensor", + "convert_to_dst_type", +] + + +_torch_to_np_dtype = { + torch.bool: np.dtype(bool), + torch.uint8: np.dtype(np.uint8), + torch.int8: np.dtype(np.int8), + torch.int16: np.dtype(np.int16), + torch.int32: np.dtype(np.int32), + torch.int64: np.dtype(np.int64), + torch.float16: np.dtype(np.float16), + torch.float32: np.dtype(np.float32), + torch.float64: np.dtype(np.float64), + torch.complex64: np.dtype(np.complex64), + torch.complex128: np.dtype(np.complex128), +} +_np_to_torch_dtype = {value: key for key, value in _torch_to_np_dtype.items()} + + +def dtype_torch_to_numpy(dtype): + """Convert a torch dtype to its numpy equivalent.""" + if dtype not in _torch_to_np_dtype: + raise ValueError(f"Unsupported torch to numpy dtype '{dtype}'.") + return _torch_to_np_dtype[dtype] + + +def dtype_numpy_to_torch(dtype): + """Convert a numpy dtype to its torch equivalent.""" + # np dtypes can be given as np.float32 and np.dtype(np.float32) so unify them + dtype = np.dtype(dtype) if type(dtype) is type else dtype + if dtype not in _np_to_torch_dtype: + raise ValueError(f"Unsupported numpy to torch dtype '{dtype}'.") + return _np_to_torch_dtype[dtype] + + +def get_equivalent_dtype(dtype, data_type): + """Convert to the `dtype` that corresponds to `data_type`. + Example: + im = torch.tensor(1) + dtype = dtype_convert(np.float32, type(im)) + """ + if data_type is torch.Tensor: + if type(dtype) is torch.dtype: + return dtype + return dtype_numpy_to_torch(dtype) + if type(dtype) is not torch.dtype: + return dtype + return dtype_torch_to_numpy(dtype) + + +def get_dtype(data: Any): + """Get the dtype of an image, or if there is a sequence, recursively call the method on the 0th element. + + This therefore assumes that in a `Sequence`, all types are the same. + """ + if hasattr(data, "dtype"): + return data.dtype + # need recursion + if isinstance(data, Sequence): + return get_dtype(data[0]) + # objects like float don't have dtype, so return their type + return type(data) + + +def convert_to_tensor(data): + """ + Utility to convert the input data to a PyTorch Tensor. If passing a dictionary, list or tuple, + recursively check every item and convert it to PyTorch Tensor. + + Args: + data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. + will convert Tensor, Numpy array, float, int, bool to Tensors, strings and objects keep the original. + for dictionary, list or tuple, convert every item to a Tensor if applicable. + + """ + if isinstance(data, torch.Tensor): + return data.contiguous() + if isinstance(data, np.ndarray): + # skip array of string classes and object, refer to: + # https://github.com/pytorch/pytorch/blob/v1.9.0/torch/utils/data/_utils/collate.py#L13 + if re.search(r"[SaUO]", data.dtype.str) is None: + # numpy array with 0 dims is also sequence iterable, + # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims + return torch.as_tensor(data if data.ndim == 0 else np.ascontiguousarray(data)) + elif isinstance(data, (float, int, bool)): + return torch.as_tensor(data) + elif isinstance(data, dict): + return {k: convert_to_tensor(v) for k, v in data.items()} + elif isinstance(data, list): + return [convert_to_tensor(i) for i in data] + elif isinstance(data, tuple): + return tuple(convert_to_tensor(i) for i in data) + + return data + + +def convert_to_numpy(data): + """ + Utility to convert the input data to a numpy array. If passing a dictionary, list or tuple, + recursively check every item and convert it to numpy array. + + Args: + data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. + will convert Tensor, Numpy array, float, int, bool to numpy arrays, strings and objects keep the original. + for dictionary, list or tuple, convert every item to a numpy array if applicable. + + """ + if isinstance(data, torch.Tensor): + data = data.detach().cpu().numpy() + elif has_cp and isinstance(data, cp_ndarray): + data = cp.asnumpy(data) + elif isinstance(data, (float, int, bool)): + data = np.asarray(data) + elif isinstance(data, dict): + return {k: convert_to_numpy(v) for k, v in data.items()} + elif isinstance(data, list): + return [convert_to_numpy(i) for i in data] + elif isinstance(data, tuple): + return tuple(convert_to_numpy(i) for i in data) + + if isinstance(data, np.ndarray) and data.ndim > 0: + data = np.ascontiguousarray(data) + + return data + + +def convert_data_type( + data: Any, + output_type: Optional[type] = None, + device: Optional[torch.device] = None, + dtype: Optional[Union[DtypeLike, torch.dtype]] = None, +) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: + """ + Convert to `torch.Tensor`/`np.ndarray` from `torch.Tensor`/`np.ndarray`/`float`/`int` etc. + + Args: + data: data to be converted + output_type: `torch.Tensor` or `np.ndarray` (if blank, unchanged) + device: if output is `torch.Tensor`, select device (if blank, unchanged) + dtype: dtype of output data. Converted to correct library type (e.g., + `np.float32` is converted to `torch.float32` if output type is `torch.Tensor`). + If left blank, it remains unchanged. + Returns: + modified data, orig_type, orig_device + """ + orig_type = type(data) + orig_device = data.device if isinstance(data, torch.Tensor) else None + + output_type = output_type or orig_type + + dtype = get_equivalent_dtype(dtype or get_dtype(data), output_type) + + if output_type is torch.Tensor: + if orig_type is not torch.Tensor: + data = convert_to_tensor(data) + if dtype != data.dtype: + data = data.to(dtype) # type: ignore + elif output_type is np.ndarray: + if orig_type is not np.ndarray: + data = convert_to_numpy(data) + if data is not None and dtype != data.dtype: + data = data.astype(dtype) # type: ignore + + if isinstance(data, torch.Tensor) and device is not None: + data = data.to(device) + + return data, orig_type, orig_device + + +def convert_to_dst_type(src: Any, dst: NdarrayTensor) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: + """ + Convert `src` to the same `torch.Tensor`/`np.ndarray` and data type as `dst`. + + See Also: + :func:`convert_data_type` + """ + device = None + if isinstance(dst, torch.Tensor): + device = dst.device + return convert_data_type(data=src, output_type=type(dst), device=device, dtype=dst.dtype) diff --git a/tests/test_convert_data_type.py b/tests/test_convert_data_type.py new file mode 100644 index 0000000000..a7fc64f950 --- /dev/null +++ b/tests/test_convert_data_type.py @@ -0,0 +1,67 @@ +# Copyright 2020 - 2021 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 +from typing import List, Tuple + +import numpy as np +import torch +from parameterized import parameterized + +from monai.utils.type_conversion import convert_data_type, convert_to_dst_type +from tests.utils import TEST_NDARRAYS + +TESTS: List[Tuple] = [] +for in_type in TEST_NDARRAYS + (int, float): + for out_type in TEST_NDARRAYS: + TESTS.append((in_type(np.array(1.0)), out_type(np.array(1.0)))) # type: ignore + + +class TestConvertDataType(unittest.TestCase): + @parameterized.expand(TESTS) + def test_convert_data_type(self, in_image, im_out): + converted_im, orig_type, orig_device = convert_data_type(in_image, type(im_out)) + # check input is unchanged + self.assertEqual(type(in_image), orig_type) + if isinstance(in_image, torch.Tensor): + self.assertEqual(in_image.device, orig_device) + # check output is desired type + self.assertEqual(type(converted_im), type(im_out)) + # check dtype is unchanged + if isinstance(in_type, (np.ndarray, torch.Tensor)): + self.assertEqual(converted_im.dtype, im_out.dtype) + + def test_neg_stride(self): + _ = convert_data_type(np.array((1, 2))[::-1], torch.Tensor) + + def test_ill_arg(self): + with self.assertRaises(ValueError): + convert_data_type(None, torch.Tensor) + convert_data_type(None, np.ndarray) + + +class TestConvertDataSame(unittest.TestCase): + @parameterized.expand(TESTS) + def test_convert_data_type(self, in_image, im_out): + converted_im, orig_type, orig_device = convert_to_dst_type(in_image, im_out) + # check input is unchanged + self.assertEqual(type(in_image), orig_type) + if isinstance(in_image, torch.Tensor): + self.assertEqual(in_image.device, orig_device) + # check output is desired type + self.assertEqual(type(converted_im), type(im_out)) + # check dtype is unchanged + if isinstance(in_type, (np.ndarray, torch.Tensor)): + self.assertEqual(converted_im.dtype, im_out.dtype) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_get_equivalent_dtype.py b/tests/test_get_equivalent_dtype.py new file mode 100644 index 0000000000..96f4a4d720 --- /dev/null +++ b/tests/test_get_equivalent_dtype.py @@ -0,0 +1,37 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.utils.type_conversion import get_equivalent_dtype +from tests.utils import TEST_NDARRAYS + +DTYPES = [torch.float32, np.float32, np.dtype(np.float32)] + +TESTS = [] +for p in TEST_NDARRAYS: + for im_dtype in DTYPES: + TESTS.append((p(np.array(1.0, dtype=np.float32)), im_dtype)) + + +class TestDtypeConvert(unittest.TestCase): + @parameterized.expand(TESTS) + def test_dtype_convert(self, im, input_dtype): + out_dtype = get_equivalent_dtype(input_dtype, type(im)) + self.assertEqual(out_dtype, im.dtype) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_gaussian_noise.py b/tests/test_rand_gaussian_noise.py index 96f1fa8e6d..d376add460 100644 --- a/tests/test_rand_gaussian_noise.py +++ b/tests/test_rand_gaussian_noise.py @@ -12,35 +12,32 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandGaussianNoise -from tests.utils import NumpyImageTestCase2D, TorchImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append(("test_zero_mean", p, 0, 0.1)) + TESTS.append(("test_non_zero_mean", p, 1, 0.5)) -class TestRandGaussianNoise(NumpyImageTestCase2D): - @parameterized.expand([("test_zero_mean", 0, 0.1), ("test_non_zero_mean", 1, 0.5)]) - def test_correct_results(self, _, mean, std): - seed = 0 - gaussian_fn = RandGaussianNoise(prob=1.0, mean=mean, std=std) - gaussian_fn.set_random_state(seed) - noised = gaussian_fn(self.imt) - np.random.seed(seed) - np.random.random() - expected = self.imt + np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) - np.testing.assert_allclose(expected, noised, atol=1e-5) - -class TestRandGaussianNoiseTorch(TorchImageTestCase2D): - @parameterized.expand([("test_zero_mean", 0, 0.1), ("test_non_zero_mean", 1, 0.5)]) - def test_correct_results(self, _, mean, std): +class TestRandGaussianNoise(NumpyImageTestCase2D): + @parameterized.expand(TESTS) + def test_correct_results(self, _, im_type, mean, std): seed = 0 gaussian_fn = RandGaussianNoise(prob=1.0, mean=mean, std=std) gaussian_fn.set_random_state(seed) - noised = gaussian_fn(self.imt) + im = im_type(self.imt) + noised = gaussian_fn(im) np.random.seed(seed) np.random.random() expected = self.imt + np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) + self.assertEqual(type(im), type(noised)) + if isinstance(noised, torch.Tensor): + noised = noised.cpu() np.testing.assert_allclose(expected, noised, atol=1e-5) diff --git a/tests/test_rand_gaussian_noised.py b/tests/test_rand_gaussian_noised.py index 442a85ca77..4b0d2a311a 100644 --- a/tests/test_rand_gaussian_noised.py +++ b/tests/test_rand_gaussian_noised.py @@ -12,41 +12,35 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandGaussianNoised -from tests.utils import NumpyImageTestCase2D, TorchImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D -TEST_CASE_0 = ["test_zero_mean", ["img1", "img2"], 0, 0.1] -TEST_CASE_1 = ["test_non_zero_mean", ["img1", "img2"], 1, 0.5] -TEST_CASES = [TEST_CASE_0, TEST_CASE_1] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append(["test_zero_mean", p, ["img1", "img2"], 0, 0.1]) + TESTS.append(["test_non_zero_mean", p, ["img1", "img2"], 1, 0.5]) seed = 0 -def test_numpy_or_torch(keys, mean, std, imt): - gaussian_fn = RandGaussianNoised(keys=keys, prob=1.0, mean=mean, std=std) - gaussian_fn.set_random_state(seed) - noised = gaussian_fn({k: imt for k in keys}) - np.random.seed(seed) - np.random.random() - for k in keys: - expected = imt + np.random.normal(mean, np.random.uniform(0, std), size=imt.shape) - np.testing.assert_allclose(expected, noised[k], atol=1e-5, rtol=1e-5) - - -# Test with numpy -class TestRandGaussianNoisedNumpy(NumpyImageTestCase2D): - @parameterized.expand(TEST_CASES) - def test_correct_results(self, _, keys, mean, std): - test_numpy_or_torch(keys, mean, std, self.imt) - - -# Test with torch -class TestRandGaussianNoisedTorch(TorchImageTestCase2D): - @parameterized.expand(TEST_CASES) - def test_correct_results(self, _, keys, mean, std): - test_numpy_or_torch(keys, mean, std, self.imt) +class TestRandGaussianNoised(NumpyImageTestCase2D): + @parameterized.expand(TESTS) + def test_correct_results(self, _, im_type, keys, mean, std): + gaussian_fn = RandGaussianNoised(keys=keys, prob=1.0, mean=mean, std=std) + gaussian_fn.set_random_state(seed) + im = im_type(self.imt) + noised = gaussian_fn({k: im for k in keys}) + np.random.seed(seed) + np.random.random() + for k in keys: + expected = self.imt + np.random.normal(mean, np.random.uniform(0, std), size=self.imt.shape) + self.assertEqual(type(im), type(noised[k])) + if isinstance(noised[k], torch.Tensor): + noised[k] = noised[k].cpu() + np.testing.assert_allclose(expected, noised[k], atol=1e-5, rtol=1e-5) if __name__ == "__main__": diff --git a/tests/test_savitzky_golay_smooth.py b/tests/test_savitzky_golay_smooth.py index 63dcce1b05..45d0ea3e4d 100644 --- a/tests/test_savitzky_golay_smooth.py +++ b/tests/test_savitzky_golay_smooth.py @@ -12,9 +12,11 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import SavitzkyGolaySmooth +from tests.utils import TEST_NDARRAYS # Zero-padding trivial tests @@ -59,12 +61,18 @@ class TestSavitzkyGolaySmooth(unittest.TestCase): @parameterized.expand([TEST_CASE_SINGLE_VALUE, TEST_CASE_2D_AXIS_2, TEST_CASE_SINE_SMOOTH]) def test_value(self, arguments, image, expected_data, atol): - result = SavitzkyGolaySmooth(**arguments)(image) - np.testing.assert_allclose(result, expected_data, atol=atol) + for p in TEST_NDARRAYS: + result = SavitzkyGolaySmooth(**arguments)(p(image)) + torch.testing.assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-7, atol=atol) class TestSavitzkyGolaySmoothREP(unittest.TestCase): @parameterized.expand([TEST_CASE_SINGLE_VALUE_REP]) def test_value(self, arguments, image, expected_data, atol): - result = SavitzkyGolaySmooth(**arguments)(image) - np.testing.assert_allclose(result, expected_data, atol=atol) + for p in TEST_NDARRAYS: + result = SavitzkyGolaySmooth(**arguments)(p(image)) + torch.testing.assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-7, atol=atol) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scale_intensity_range_percentiles.py b/tests/test_scale_intensity_range_percentiles.py index 8393d7c082..015162c8de 100644 --- a/tests/test_scale_intensity_range_percentiles.py +++ b/tests/test_scale_intensity_range_percentiles.py @@ -50,10 +50,10 @@ def test_relative_scaling(self): self.assertTrue(np.allclose(expected_img, scaler(img))) def test_invalid_instantiation(self): - self.assertRaises(AssertionError, ScaleIntensityRangePercentiles, lower=-10, upper=99, b_min=0, b_max=255) - self.assertRaises(AssertionError, ScaleIntensityRangePercentiles, lower=101, upper=99, b_min=0, b_max=255) - self.assertRaises(AssertionError, ScaleIntensityRangePercentiles, lower=30, upper=-20, b_min=0, b_max=255) - self.assertRaises(AssertionError, ScaleIntensityRangePercentiles, lower=30, upper=900, b_min=0, b_max=255) + self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=-10, upper=99, b_min=0, b_max=255) + self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=101, upper=99, b_min=0, b_max=255) + self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=30, upper=-20, b_min=0, b_max=255) + self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=30, upper=900, b_min=0, b_max=255) if __name__ == "__main__": diff --git a/tests/test_scale_intensity_range_percentilesd.py b/tests/test_scale_intensity_range_percentilesd.py index 5057c1e32c..9d0fe8284a 100644 --- a/tests/test_scale_intensity_range_percentilesd.py +++ b/tests/test_scale_intensity_range_percentilesd.py @@ -59,16 +59,16 @@ def test_relative_scaling(self): def test_invalid_instantiation(self): self.assertRaises( - AssertionError, ScaleIntensityRangePercentilesd, keys=["img"], lower=-1, upper=99, b_min=0, b_max=255 + ValueError, ScaleIntensityRangePercentilesd, keys=["img"], lower=-1, upper=99, b_min=0, b_max=255 ) self.assertRaises( - AssertionError, ScaleIntensityRangePercentilesd, keys=["img"], lower=101, upper=99, b_min=0, b_max=255 + ValueError, ScaleIntensityRangePercentilesd, keys=["img"], lower=101, upper=99, b_min=0, b_max=255 ) self.assertRaises( - AssertionError, ScaleIntensityRangePercentilesd, keys=["img"], lower=30, upper=-2, b_min=0, b_max=255 + ValueError, ScaleIntensityRangePercentilesd, keys=["img"], lower=30, upper=-2, b_min=0, b_max=255 ) self.assertRaises( - AssertionError, ScaleIntensityRangePercentilesd, keys=["img"], lower=30, upper=1000, b_min=0, b_max=255 + ValueError, ScaleIntensityRangePercentilesd, keys=["img"], lower=30, upper=1000, b_min=0, b_max=255 ) diff --git a/tests/utils.py b/tests/utils.py index c3f604f12e..1148af7551 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,9 +21,10 @@ import traceback import unittest import warnings +from functools import partial from io import BytesIO from subprocess import PIPE, Popen -from typing import Optional +from typing import Callable, Optional, Tuple from urllib.error import ContentTooShortError, HTTPError, URLError import numpy as np @@ -600,5 +601,11 @@ def query_memory(n=2): return ",".join(f"{int(x)}" for x in ids) +TEST_NDARRAYS: Tuple[Callable] = (np.array, torch.as_tensor) # type: ignore +if torch.cuda.is_available(): + gpu_tensor: Callable = partial(torch.as_tensor, device="cuda") + TEST_NDARRAYS = TEST_NDARRAYS + (gpu_tensor,) # type: ignore + + if __name__ == "__main__": print(query_memory()) From 0840dbb404a57e2cda316f00b514730fc66753e7 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Wed, 18 Aug 2021 01:36:43 +0800 Subject: [PATCH 43/89] enhance dynunet docstrings (#2792) * enhance dynunet docstrings Signed-off-by: Yiheng Wang --- monai/networks/nets/ahnet.py | 9 +++++---- monai/networks/nets/dynunet.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/monai/networks/nets/ahnet.py b/monai/networks/nets/ahnet.py index 3147f3d4e6..5ca6813efe 100644 --- a/monai/networks/nets/ahnet.py +++ b/monai/networks/nets/ahnet.py @@ -318,10 +318,11 @@ class AHNet(nn.Module): ``"transpose"`` rather than linear interpolations is faster. Therefore, this implementation sets ``"transpose"`` as the default upsampling method. - To meet to requirements of the structure, for ``transpose`` mode, the input size of the first ``dim-1`` dimensions should - be divisible by 2 ** (psp_block_num + 3) and no less than 32. For other modes, the input size of the first - ``dim-1`` dimensions should be divisible by 32 and no less than 2 ** (psp_block_num + 3). In addition, at least one - dimension should have a no less than 64 size. + To meet the requirements of the structure, the input size for each spatial dimension + (except the last one) should be: divisible by 2 ** (psp_block_num + 3) and no less than 32 in ``transpose`` mode, + and should be divisible by 32 and no less than 2 ** (psp_block_num + 3) in other upsample modes. + In addition, the input size for the last spatial dimension should be divisible by 32, and at least one spatial size + should be no less than 64. Args: layers: number of residual blocks for 4 layers of the network (layer1...layer4). Defaults to ``(3, 4, 6, 3)``. diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index 3922249b78..4af70b22c7 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -70,7 +70,13 @@ class DynUNet(nn.Module): The first and last kernel and stride values of the input sequences are used for input block and bottleneck respectively, and the rest value(s) are used for downsample and upsample blocks. Therefore, pleasure ensure that the length of input sequences (``kernel_size`` and ``strides``) - is no less than 3 in order to have at least one downsample upsample blocks. + is no less than 3 in order to have at least one downsample and upsample blocks. + + To meet the requirements of the structure, the input size for each spatial dimension should be divisible + by `2 * the product of all strides in the corresponding dimension`. The output size for each spatial dimension + equals to the input size of the correponding dimension divided by the stride in strides[0]. + For example, if `strides=((1, 2, 4), 2, 1, 1)`, the minimal spatial size of the input is `(8, 16, 32)`, and + the spatial size of the output is `(8, 8, 8)`. Args: spatial_dims: number of spatial dimensions. @@ -78,7 +84,8 @@ class DynUNet(nn.Module): out_channels: number of output channels. kernel_size: convolution kernel size. strides: convolution strides for each blocks. - upsample_kernel_size: convolution kernel size for transposed convolution layers. + upsample_kernel_size: convolution kernel size for transposed convolution layers. The values should + equal to strides[1:]. norm_name: feature normalization type and arguments. Defaults to ``INSTANCE``. deep_supervision: whether to add deep supervision head before output. Defaults to ``False``. If ``True``, in training mode, the forward function will output not only the last feature From cc31355cddc5a0ac0ef068aa2f9bc1711d7e65b1 Mon Sep 17 00:00:00 2001 From: Ev Lacey Date: Tue, 17 Aug 2021 12:32:42 -0700 Subject: [PATCH 44/89] Update license example (#2788) Signed-off-by: Everett Lacey --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca40261dea..1170411c70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ python -m pip install -U -r requirements-dev.txt License information: all source code files should start with this paragraph: ``` -# Copyright 2020 - 2021 MONAI Consortium +# Copyright 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 From 46d5f2a847df36642afebd5e8fd9c85686befd4e Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 17 Aug 2021 18:08:46 -0400 Subject: [PATCH 45/89] Add NVTX handlers (#2765) * Add NVTX handlers Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/handlers.rst | 5 + monai/handlers/__init__.py | 1 + monai/handlers/nvtx_handlers.py | 201 ++++++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_handler_nvtx.py | 114 ++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 monai/handlers/nvtx_handlers.py create mode 100644 tests/test_handler_nvtx.py diff --git a/docs/source/handlers.rst b/docs/source/handlers.rst index 096777cdef..5caccc6b4b 100644 --- a/docs/source/handlers.rst +++ b/docs/source/handlers.rst @@ -165,6 +165,11 @@ Decollate batch .. autoclass:: DecollateBatch :members: +NVTX Handlers +------------- +.. automodule:: monai.handlers.nvtx_handlers + :members: + Utilities --------- .. automodule:: monai.handlers.utils diff --git a/monai/handlers/__init__.py b/monai/handlers/__init__.py index 42a716ced0..c9eecc6d46 100644 --- a/monai/handlers/__init__.py +++ b/monai/handlers/__init__.py @@ -22,6 +22,7 @@ from .mean_dice import MeanDice from .metric_logger import MetricLogger, MetricLoggerKeys from .metrics_saver import MetricsSaver +from .nvtx_handlers import MarkHandler, RangeHandler, RangePopHandler, RangePushHandler from .parameter_scheduler import ParamSchedulerHandler from .postprocessing import PostProcessing from .regression_metrics import MeanAbsoluteError, MeanSquaredError, PeakSignalToNoiseRatio, RootMeanSquaredError diff --git a/monai/handlers/nvtx_handlers.py b/monai/handlers/nvtx_handlers.py new file mode 100644 index 0000000000..aba7a7ec0e --- /dev/null +++ b/monai/handlers/nvtx_handlers.py @@ -0,0 +1,201 @@ +# Copyright 2020 - 2021 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. +""" +Wrapper around NVIDIA Tools Extension for profiling MONAI ignite workflow +""" + +from typing import TYPE_CHECKING, Optional, Tuple, Union + +from monai.config import IgniteInfo +from monai.utils import ensure_tuple, min_version, optional_import + +_nvtx, _ = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") +if TYPE_CHECKING: + from ignite.engine import Engine, Events +else: + Engine, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Engine") + Events, _ = optional_import("ignite.engine", IgniteInfo.OPT_IMPORT_VERSION, min_version, "Events") + + +__all__ = ["RangeHandler", "RangePushHandler", "RangePopHandler", "MarkHandler"] + + +class RangeHandler: + """ + Attach a NVTX range to a pair of Ignite events. + It pushes an NVTX range at the first event and pops it at the second event. + Stores zero-based depth of the range that is started. + + Args: + events: a string, pair of Ignite events, pair of Ignite event literals, or pair of Ignite events and literals. + If a single string is provided, it should describe the base name of a pair of default Ignite events + with _STARTED and _COMPLETED postfix (like "EPOCH" for Events.EPOCH_STARTED and Events.EPOCH_COMPLETED). + The accepted events are: BATCH, ITERATION, EPOCH, and ENGINE. + If pair of literals, each should be the literal equivalent of an Ignite event, fo instance: + ("EPOCH_STARTED" and "EPOCH_COMPLETED"). + One can combine events and literals, like (Events.EPOCH_STARTED and "EPOCH_COMPLETED"). + For the complete list of Events, + check https://pytorch.org/ignite/generated/ignite.engine.events.Events.html. + + msg: ASCII message to associate with range. + If not provided, the name of first event will be assigned to the NVTX range. + """ + + def __init__( + self, + events: Union[str, Tuple[Union[str, Events], Union[str, Events]]], + msg: Optional[str] = None, + ) -> None: + self.events = self.resolve_events(events) + if msg is None: + if isinstance(events, str): + # assign the prefix of the events + msg = events + else: + # combine events' names + msg = "/".join([e.name for e in self.events]) + self.msg = msg + self.depth = None + + def resolve_events(self, events: Union[str, Tuple]) -> Tuple[Events, Events]: + """ + Resolve the input events to create a pair of Ignite events + """ + events = ensure_tuple(events) + if len(events) == 1: + return self.create_paired_events(events[0]) + if len(events) == 2: + return ( + self.get_event(events[0]), + self.get_event(events[1]), + ) + raise ValueError(f"Exactly two Ignite events should be provided [received {len(events)}].") + + def create_paired_events(self, event: str) -> Tuple[Events, Events]: + """ + Create pair of Ignite events from a event prefix name + """ + event = event.upper() + event_prefix = { + "": "", + "ENGINE": "", + "EPOCH": "EPOCH_", + "ITERATION": "ITERATION_", + "BATCH": "GET_BATCH_", + } + return ( + self.get_event(event_prefix[event] + "STARTED"), + self.get_event(event_prefix[event] + "COMPLETED"), + ) + + def get_event(self, event: Union[str, Events]) -> Events: + if isinstance(event, str): + event = event.upper() + return Events[event] + + def attach(self, engine: Engine) -> None: + """ + Attach an NVTX Range to specific Ignite events + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(self.events[0], self.range_push) + engine.add_event_handler(self.events[1], self.range_pop) + + def range_push(self): + self.depth = _nvtx.rangePushA(self.msg) + + def range_pop(self): + _nvtx.rangePop() + + +class RangePushHandler: + """ + At a specific event, pushes a range onto a stack of nested range span. + Stores zero-based depth of the range that is started. + + Args: + msg: ASCII message to associate with range + """ + + def __init__(self, event: Events, msg: Optional[str] = None) -> None: + if isinstance(event, str): + event = event.upper() + self.event = Events[event] + if msg is None: + msg = self.event.name + self.msg = msg + self.depth = None + + def attach(self, engine: Engine) -> None: + """ + Push an NVTX range at a specific Ignite event + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(self.event, self.range_push) + + def range_push(self): + self.depth = _nvtx.rangePushA(self.msg) + + +class RangePopHandler: + """ + At a specific event, pop a previously pushed range. + Stores zero-based depth of the range that is started. + + Args: + msg: ASCII message to associate with range + """ + + def __init__(self, event: Events) -> None: + if isinstance(event, str): + event = event.upper() + self.event = Events[event] + + def attach(self, engine: Engine) -> None: + """ + Pop an NVTX range at a specific Ignite event + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(self.event, self.range_pop) + + def range_pop(self): + _nvtx.rangePop() + + +class MarkHandler: + """ + Mark an instantaneous event that occurred at some point. + + Args: + msg: ASCII message to associate with range + """ + + def __init__(self, event: Events, msg: Optional[str] = None) -> None: + if isinstance(event, str): + event = event.upper() + self.event = Events[event] + if msg is None: + msg = self.event.name + self.msg = msg + + def attach(self, engine: Engine) -> None: + """ + Add an NVTX mark to a specific Ignite event + Args: + engine: Ignite Engine, it can be a trainer, validator or evaluator. + """ + engine.add_event_handler(self.event, self.mark) + + def mark(self): + _nvtx.markA(self.msg) diff --git a/tests/min_tests.py b/tests/min_tests.py index afe88f7433..d2f8f0aff6 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -60,6 +60,7 @@ def run_testsuit(): "test_handler_mean_dice", "test_handler_metrics_saver", "test_handler_metrics_saver_dist", + "test_handler_nvtx", "test_handler_parameter_scheduler", "test_handler_post_processing", "test_handler_prob_map_producer", diff --git a/tests/test_handler_nvtx.py b/tests/test_handler_nvtx.py new file mode 100644 index 0000000000..fee29af344 --- /dev/null +++ b/tests/test_handler_nvtx.py @@ -0,0 +1,114 @@ +# Copyright 2020 - 2021 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 torch +from ignite.engine import Events +from parameterized import parameterized + +from monai.engines import SupervisedEvaluator +from monai.handlers import StatsHandler, from_engine +from monai.handlers.nvtx_handlers import MarkHandler, RangeHandler, RangePopHandler, RangePushHandler +from monai.utils import optional_import + +_, has_nvtx = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") + +TENSOR_0 = torch.tensor( + [ + [ + [[1.0], [2.0]], + [[3.0], [4.0]], + ] + ] +) + +TENSOR_1 = torch.tensor( + [ + [ + [[0.0], [-2.0]], + [[-3.0], [4.0]], + ] + ] +) + +TENSOR_1_EXPECTED = torch.tensor( + [ + [[1.0], [0.5]], + [[0.25], [5.0]], + ] +) + +TEST_CASE_0 = [[{"image": TENSOR_0}], TENSOR_0[0] + 1.0] +TEST_CASE_1 = [[{"image": TENSOR_1}], TENSOR_1_EXPECTED] + + +class TestHandlerDecollateBatch(unittest.TestCase): + @parameterized.expand( + [ + TEST_CASE_0, + TEST_CASE_1, + ] + ) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") + def test_compute(self, data, expected): + # Set up handlers + handlers = [ + # Mark with Ignite Event + MarkHandler(Events.STARTED), + # Mark with literal + MarkHandler("EPOCH_STARTED"), + # Mark with literal and providing the message + MarkHandler("EPOCH_STARTED", "Start of the epoch"), + # Define a range using one prefix (between BATCH_STARTED and BATCH_COMPLETED) + RangeHandler("Batch"), + # Define a range using a pair of events + RangeHandler((Events.STARTED, Events.COMPLETED)), + # Define a range using a pair of literals + RangeHandler(("GET_BATCH_STARTED", "GET_BATCH_COMPLETED"), msg="Batching!"), + # Define a range using a pair of literal and events + RangeHandler(("GET_BATCH_STARTED", Events.COMPLETED)), + # Define the start of range using literal + RangePushHandler("ITERATION_STARTED"), + # Define the start of range using event + RangePushHandler(Events.ITERATION_STARTED, "Iteration 2"), + # Define the start of range using literals and providing message + RangePushHandler("EPOCH_STARTED", "Epoch 2"), + # Define the end of range using Ignite Event + RangePopHandler(Events.ITERATION_COMPLETED), + RangePopHandler(Events.EPOCH_COMPLETED), + # Define the end of range using literal + RangePopHandler("ITERATION_COMPLETED"), + # Other handlers + StatsHandler(tag_name="train", output_transform=from_engine(["label"], first=True)), + ] + + # Set up an engine + engine = SupervisedEvaluator( + device=torch.device("cpu:0"), + val_data_loader=data, + epoch_length=1, + network=torch.nn.PReLU(), + postprocessing=lambda x: dict(pred=x["pred"] + 1.0), + decollate=True, + val_handlers=handlers, + ) + # Run the engine + engine.run() + + # Get the output from the engine + output = engine.state.output[0] + + torch.testing.assert_allclose(output["pred"], expected) + + +if __name__ == "__main__": + unittest.main() From 28856b88044e6310d0c5bfff2c088dcbea00324f Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 18 Aug 2021 09:22:18 +0800 Subject: [PATCH 46/89] 2789 Add ToDevice transform (#2791) * [DLMED] add ToDevice transform Signed-off-by: Nic Ma * [DLMED] fix type-hints Signed-off-by: Nic Ma * [DLMED] inherit Transform Signed-off-by: Nic Ma * [DLMED] add kwargs Signed-off-by: Nic Ma --- docs/source/transforms.rst | 12 ++++++++ monai/transforms/__init__.py | 4 +++ monai/transforms/utility/array.py | 26 +++++++++++++++++ monai/transforms/utility/dictionary.py | 36 +++++++++++++++++++++++ tests/test_to_device.py | 40 ++++++++++++++++++++++++++ tests/test_to_deviced.py | 37 ++++++++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 tests/test_to_device.py create mode 100644 tests/test_to_deviced.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 65b35b0dc8..9a538aae82 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -718,6 +718,12 @@ Utility :members: :special-members: __call__ +`ToDevice` +"""""""""" + .. autoclass:: ToDevice + :members: + :special-members: __call__ + Dictionary Transforms --------------------- @@ -1347,6 +1353,12 @@ Utility (Dict) :members: :special-members: __call__ +`ToDeviced` +""""""""""" + .. autoclass:: ToDeviced + :members: + :special-members: __call__ + Transform Adaptors ------------------ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index b3b3b15a1f..180a690e5d 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -377,6 +377,7 @@ SplitChannel, SqueezeDim, ToCupy, + ToDevice, ToNumpy, ToPIL, TorchVision, @@ -468,6 +469,9 @@ ToCupyd, ToCupyD, ToCupyDict, + ToDeviced, + ToDeviceD, + ToDeviceDict, ToNumpyd, ToNumpyD, ToNumpyDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index c41983787d..1871eedb6f 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -73,6 +73,7 @@ "TorchVision", "MapLabelValue", "IntensityStats", + "ToDevice", ] @@ -1021,3 +1022,28 @@ def _compute(op: Callable, data: np.ndarray): raise ValueError("ops must be key string for predefined operations or callable function.") return img, meta_data + + +class ToDevice(Transform): + """ + Move PyTorch Tensor to the specified device. + It can help cache data into GPU and execute following logic on GPU directly. + + """ + + def __init__(self, device: Union[torch.device, str], **kwargs) -> None: + """ + Args: + device: target device to move the Tensor, for example: "cuda:1". + kwargs: other args for the PyTorch `Tensor.to()` API, for more details: + https://pytorch.org/docs/stable/generated/torch.Tensor.to.html. + + """ + self.device = device + self.kwargs = kwargs + + def __call__(self, img: torch.Tensor): + if not isinstance(img, torch.Tensor): + raise ValueError("img must be PyTorch Tensor, consider converting img by `EnsureType` transform first.") + + return img.to(self.device, **self.kwargs) diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 67fe7653e0..9c0a709bbf 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -49,6 +49,7 @@ SplitChannel, SqueezeDim, ToCupy, + ToDevice, ToNumpy, ToPIL, TorchVision, @@ -141,6 +142,9 @@ "ToCupyD", "ToCupyDict", "ToCupyd", + "ToDeviced", + "ToDeviceD", + "ToDeviceDict", "ToNumpyD", "ToNumpyDict", "ToNumpyd", @@ -1354,6 +1358,37 @@ def __call__(self, data) -> Dict[Hashable, np.ndarray]: return d +class ToDeviced(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.ToDevice`. + """ + + def __init__( + self, + keys: KeysCollection, + device: Union[torch.device, str], + allow_missing_keys: bool = False, + **kwargs, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + device: target device to move the Tensor, for example: "cuda:1". + allow_missing_keys: don't raise exception if key is missing. + kwargs: other args for the PyTorch `Tensor.to()` API, for more details: + https://pytorch.org/docs/stable/generated/torch.Tensor.to.html. + """ + super().__init__(keys, allow_missing_keys) + self.converter = ToDevice(device=device, **kwargs) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.converter(d[key]) + return d + + IdentityD = IdentityDict = Identityd AsChannelFirstD = AsChannelFirstDict = AsChannelFirstd AsChannelLastD = AsChannelLastDict = AsChannelLastd @@ -1389,3 +1424,4 @@ def __call__(self, data) -> Dict[Hashable, np.ndarray]: RandLambdaD = RandLambdaDict = RandLambdad MapLabelValueD = MapLabelValueDict = MapLabelValued IntensityStatsD = IntensityStatsDict = IntensityStatsd +ToDeviceD = ToDeviceDict = ToDeviced diff --git a/tests/test_to_device.py b/tests/test_to_device.py new file mode 100644 index 0000000000..9855a353f0 --- /dev/null +++ b/tests/test_to_device.py @@ -0,0 +1,40 @@ +# Copyright 2020 - 2021 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 torch +from parameterized import parameterized + +from monai.transforms import ToDevice +from tests.utils import skip_if_no_cuda + +TEST_CASE_1 = ["cuda:0"] + +TEST_CASE_2 = ["cuda"] + +TEST_CASE_3 = [torch.device("cpu:0")] + +TEST_CASE_4 = ["cpu"] + + +@skip_if_no_cuda +class TestToDevice(unittest.TestCase): + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_value(self, device): + converter = ToDevice(device=device, non_blocking=True) + data = torch.tensor([1, 2, 3, 4]) + ret = converter(data) + torch.testing.assert_allclose(ret, data.to(device)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_to_deviced.py b/tests/test_to_deviced.py new file mode 100644 index 0000000000..0d5d1d1cdc --- /dev/null +++ b/tests/test_to_deviced.py @@ -0,0 +1,37 @@ +# Copyright 2020 - 2021 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 torch + +from monai.data import CacheDataset, ThreadDataLoader +from monai.transforms import ToDeviced +from tests.utils import skip_if_no_cuda + + +@skip_if_no_cuda +class TestToDeviced(unittest.TestCase): + def test_value(self): + device = "cuda:0" + data = [{"img": torch.tensor(i)} for i in range(4)] + dataset = CacheDataset( + data=data, + transform=ToDeviced(keys="img", device=device, non_blocking=True), + cache_rate=1.0, + ) + dataloader = ThreadDataLoader(dataset=dataset, num_workers=0, batch_size=1) + for i, d in enumerate(dataloader): + torch.testing.assert_allclose(d["img"], torch.tensor([i], device=device)) + + +if __name__ == "__main__": + unittest.main() From e223932ee88509d63407fefbddad8606200e7e69 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 18 Aug 2021 21:11:18 +0800 Subject: [PATCH 47/89] 2796 Enhance doc-string of cache datasets (#2799) * [DLMED] enhance doc Signed-off-by: Nic Ma * Update monai/data/dataset.py Co-authored-by: Wenqi Li Signed-off-by: Nic Ma * Update monai/data/dataset.py Co-authored-by: Wenqi Li Signed-off-by: Nic Ma Co-authored-by: Wenqi Li --- monai/data/dataset.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index e8ec02e2a8..c970e83d0d 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -103,24 +103,28 @@ class PersistentDataset(Dataset): If passing slicing indices, will return a PyTorch Subset, for example: `data: Subset = dataset[1:4]`, for more details, please check: https://pytorch.org/docs/stable/data.html#torch.utils.data.Subset + The transforms which are supposed to be cached must implement the `monai.transforms.Transform` + interface and should not be `Randomizable`. This dataset will cache the outcomes before the first + `Randomizable` `Transform` within a `Compose` instance. + For example, typical input data can be a list of dictionaries:: [{ { { - 'image': 'image1.nii.gz', 'image': 'image2.nii.gz', 'image': 'image3.nii.gz', - 'label': 'label1.nii.gz', 'label': 'label2.nii.gz', 'label': 'label3.nii.gz', - 'extra': 123 'extra': 456 'extra': 789 - }, }, }] + 'image': 'image1.nii.gz', 'image': 'image2.nii.gz', 'image': 'image3.nii.gz', + 'label': 'label1.nii.gz', 'label': 'label2.nii.gz', 'label': 'label3.nii.gz', + 'extra': 123 'extra': 456 'extra': 789 + }, }, }] For a composite transform like .. code-block:: python [ LoadImaged(keys=['image', 'label']), - Orientationd(keys=['image', 'label'], axcodes='RAS'), - ScaleIntensityRanged(keys=['image'], a_min=-57, a_max=164, b_min=0.0, b_max=1.0, clip=True), - RandCropByPosNegLabeld(keys=['image', 'label'], label_key='label', spatial_size=(96, 96, 96), - pos=1, neg=1, num_samples=4, image_key='image', image_threshold=0), - ToTensord(keys=['image', 'label'])] + Orientationd(keys=['image', 'label'], axcodes='RAS'), + ScaleIntensityRanged(keys=['image'], a_min=-57, a_max=164, b_min=0.0, b_max=1.0, clip=True), + RandCropByPosNegLabeld(keys=['image', 'label'], label_key='label', spatial_size=(96, 96, 96), + pos=1, neg=1, num_samples=4, image_key='image', image_threshold=0), + ToTensord(keys=['image', 'label'])] Upon first use a filename based dataset will be processed by the transform for the [LoadImaged, Orientationd, ScaleIntensityRanged] and the resulting tensor written to @@ -524,7 +528,10 @@ class CacheDataset(Dataset): Users can set the cache rate or number of items to cache. It is recommended to experiment with different `cache_num` or `cache_rate` to identify the best training speed. - To improve the caching efficiency, please always put as many as possible non-random transforms + The transforms which are supposed to be cached must implement the `monai.transforms.Transform` + interface and should not be `Randomizable`. This dataset will cache the outcomes before the first + `Randomizable` `Transform` within a `Compose` instance. + So to improve the caching efficiency, please always put as many as possible non-random transforms before the randomized ones when composing the chain of transforms. If passing slicing indices, will return a PyTorch Subset, for example: `data: Subset = dataset[1:4]`, for more details, please check: https://pytorch.org/docs/stable/data.html#torch.utils.data.Subset From ce196e0cc586bd6558c9f90875ec4583ed6eaccb Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 19 Aug 2021 16:40:38 +0800 Subject: [PATCH 48/89] [DLMED] enhance samples crop (#2804) Signed-off-by: Nic Ma --- monai/transforms/croppad/dictionary.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index cdcc861c82..a642bf406b 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -1096,8 +1096,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n d = dict(data) label = d[self.label_key] image = d[self.image_key] if self.image_key else None - fg_indices = d.get(self.fg_indices_key) if self.fg_indices_key is not None else None - bg_indices = d.get(self.bg_indices_key) if self.bg_indices_key is not None else None + fg_indices = d.pop(self.fg_indices_key, None) if self.fg_indices_key is not None else None + bg_indices = d.pop(self.bg_indices_key, None) if self.bg_indices_key is not None else None self.randomize(label, fg_indices, bg_indices, image) if not isinstance(self.spatial_size, tuple): @@ -1106,12 +1106,12 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n raise ValueError("no available ROI centers to crop.") # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, np.ndarray]] = [dict(data) for _ in range(self.num_samples)] + results: List[Dict[Hashable, np.ndarray]] = [dict(d) for _ in range(self.num_samples)] for i, center in enumerate(self.centers): # fill in the extra keys with unmodified data - for key in set(data.keys()).difference(set(self.keys)): - results[i][key] = deepcopy(data[key]) + for key in set(d.keys()).difference(set(self.keys)): + results[i][key] = deepcopy(d[key]) for key in self.key_iterator(d): img = d[key] cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore @@ -1276,7 +1276,7 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, np.ndarr d = dict(data) label = d[self.label_key] image = d[self.image_key] if self.image_key else None - indices = d.get(self.indices_key) if self.indices_key is not None else None + indices = d.pop(self.indices_key, None) if self.indices_key is not None else None self.randomize(label, indices, image) if not isinstance(self.spatial_size, tuple): @@ -1285,12 +1285,12 @@ def __call__(self, data: Mapping[Hashable, Any]) -> List[Dict[Hashable, np.ndarr raise ValueError("no available ROI centers to crop.") # initialize returned list with shallow copy to preserve key ordering - results: List[Dict[Hashable, np.ndarray]] = [dict(data) for _ in range(self.num_samples)] + results: List[Dict[Hashable, np.ndarray]] = [dict(d) for _ in range(self.num_samples)] for i, center in enumerate(self.centers): # fill in the extra keys with unmodified data - for key in set(data.keys()).difference(set(self.keys)): - results[i][key] = deepcopy(data[key]) + for key in set(d.keys()).difference(set(self.keys)): + results[i][key] = deepcopy(d[key]) for key in self.key_iterator(d): img = d[key] cropper = SpatialCrop(roi_center=tuple(center), roi_size=self.spatial_size) # type: ignore From 7f05d7873cfe9cb8aaeee4341e8585ca96ada1dd Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 Aug 2021 13:05:21 +0100 Subject: [PATCH 49/89] get number of data conversions (#2800) * get number of data conversions Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * improve docs Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * move fn to utility Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/__init__.py | 1 + monai/transforms/utils.py | 44 ++++++- monai/utils/type_conversion.py | 2 +- tests/test_compose_get_number_conversions.py | 119 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 tests/test_compose_get_number_conversions.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 180a690e5d..5f9ed84bcd 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -507,6 +507,7 @@ generate_spatial_bounding_box, get_extreme_points, get_largest_connected_component_mask, + get_number_image_type_conversions, img_bounds, in_bounds, is_empty, diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 2468294279..5886c35974 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -13,14 +13,15 @@ import random import warnings from contextlib import contextmanager -from typing import Callable, Iterable, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Hashable, Iterable, List, Optional, Sequence, Tuple, Union import numpy as np import torch +import monai.transforms.transform from monai.config import DtypeLike, IndexSelection from monai.networks.layers import GaussianFilter -from monai.transforms.compose import Compose +from monai.transforms.compose import Compose, OneOf from monai.transforms.transform import MapTransform from monai.utils import ( GridSampleMode, @@ -75,6 +76,7 @@ "weighted_patch_samples", "zero_margins", "equalize_hist", + "get_number_image_type_conversions", ] @@ -1109,3 +1111,41 @@ def inv_shift_fourier(k: torch.Tensor, n_dims: int) -> torch.Tensor: torch.fft.ifftshift(k, dim=tuple(range(-n_dims, 0))), dim=tuple(range(-n_dims, 0)) ).real return x + + +def get_number_image_type_conversions(transform: Compose, test_data: Any, key: Optional[Hashable] = None) -> int: + """ + Get the number of times that the data need to be converted (e.g., numpy to torch). + Conversions between different devices are also counted (e.g., CPU to GPU). + + Args: + transform: composed transforms to be tested + test_data: data to be used to count the number of conversions + key: if using dictionary transforms, this key will be used to check the number of conversions. + """ + + def _get_data(obj, key): + return obj if key is None else obj[key] + + # if the starting point is a string (e.g., input to LoadImage), start + # at -1 since we don't want to count the string -> image conversion. + num_conversions = 0 if not isinstance(_get_data(test_data, key), str) else -1 + + tr = transform.flatten().transforms + + if isinstance(transform, OneOf) or any(isinstance(i, OneOf) for i in tr): + raise RuntimeError("Not compatible with `OneOf`, as the applied transform is deterministically chosen.") + + for _transform in tr: + prev_data = _get_data(test_data, key) + prev_type = type(prev_data) + prev_device = prev_data.device if isinstance(prev_data, torch.Tensor) else None + test_data = monai.transforms.transform.apply_transform( + _transform, test_data, transform.map_items, transform.unpack_items + ) + # every time the type or device changes, increment the counter + curr_data = _get_data(test_data, key) + curr_device = curr_data.device if isinstance(curr_data, torch.Tensor) else None + if not isinstance(curr_data, prev_type) or curr_device != prev_device: + num_conversions += 1 + return num_conversions diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index da2373e03b..d4d911c5b2 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -58,7 +58,7 @@ def get_equivalent_dtype(dtype, data_type): """Convert to the `dtype` that corresponds to `data_type`. Example: im = torch.tensor(1) - dtype = dtype_convert(np.float32, type(im)) + dtype = get_equivalent_dtype(np.float32, type(im)) """ if data_type is torch.Tensor: if type(dtype) is torch.dtype: diff --git a/tests/test_compose_get_number_conversions.py b/tests/test_compose_get_number_conversions.py new file mode 100644 index 0000000000..eb10c7d5ef --- /dev/null +++ b/tests/test_compose_get_number_conversions.py @@ -0,0 +1,119 @@ +# Copyright 2020 - 2021 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 +from copy import deepcopy +from typing import List, Tuple + +import numpy as np +import torch +from parameterized import parameterized + +from monai.transforms import Compose +from monai.transforms.compose import OneOf +from monai.transforms.transform import Transform +from monai.transforms.utils import get_number_image_type_conversions +from monai.utils import convert_to_numpy, convert_to_tensor + +NP_ARR = np.ones((10, 10, 10)) +PT_ARR = torch.as_tensor(NP_ARR) +KEY = "IMAGE" + + +def _apply(x, fn): + if isinstance(x, dict): + d = deepcopy(x) + d[KEY] = fn(d[KEY]) + return d + return fn(x) + + +class Load(Transform): + def __init__(self, as_tensor): + self.fn = lambda _: PT_ARR if as_tensor else NP_ARR + + def __call__(self, x): + return _apply(x, self.fn) + + +class N(Transform): + def __call__(self, x): + return _apply(x, convert_to_numpy) + + +class T(Transform): + def __call__(self, x): + return _apply(x, convert_to_tensor) + + +class NT(Transform): + def __call__(self, x): + return _apply(x, lambda x: x) + + +class TCPU(Transform): + def __call__(self, x): + return _apply(x, lambda x: convert_to_tensor(x).cpu()) + + +class TGPU(Transform): + def __call__(self, x): + return _apply(x, lambda x: convert_to_tensor(x).cuda()) + + +TESTS: List[Tuple] = [] +for is_dict in (False, True): + # same type depends on input + TESTS.append(((N(), N()), is_dict, NP_ARR, 0)) + TESTS.append(((N(), N()), is_dict, PT_ARR, 1)) + TESTS.append(((T(), T()), is_dict, NP_ARR, 1)) + TESTS.append(((T(), T()), is_dict, PT_ARR, 0)) + + # loading depends on loader's output type and following transform + TESTS.append(((Load(as_tensor=False), N()), is_dict, "fname.nii", 0)) + TESTS.append(((Load(as_tensor=True), N()), is_dict, "fname.nii", 1)) + TESTS.append(((Load(as_tensor=False), T()), is_dict, "fname.nii", 1)) + TESTS.append(((Load(as_tensor=True), T()), is_dict, "fname.nii", 0)) + TESTS.append(((Load(as_tensor=True), NT()), is_dict, "fname.nii", 0)) + TESTS.append(((Load(as_tensor=True), NT()), is_dict, "fname.nii", 0)) + + # no changes for ambivalent transforms + TESTS.append(((NT(), NT()), is_dict, NP_ARR, 0)) + TESTS.append(((NT(), NT()), is_dict, PT_ARR, 0)) + + # multiple conversions + TESTS.append(((N(), T(), N()), is_dict, PT_ARR, 3)) + TESTS.append(((N(), NT(), T(), T(), NT(), NT(), N()), is_dict, PT_ARR, 3)) + + # shouldn't matter that there are nested composes + TESTS.append(((N(), NT(), T(), Compose([T(), NT(), NT(), N()])), is_dict, PT_ARR, 3)) + + # changing device also counts + if torch.cuda.is_available(): + TESTS.append(((TCPU(), TGPU(), TCPU()), is_dict, PT_ARR, 2)) + + +class TestComposeNumConversions(unittest.TestCase): + @parameterized.expand(TESTS) + def test_get_number_of_conversions(self, transforms, is_dict, input, expected): + input = input if not is_dict else {KEY: input, "Other": NP_ARR} + tr = Compose(transforms) + n = get_number_image_type_conversions(tr, input, key=KEY if is_dict else None) + self.assertEqual(n, expected) + + def test_raises(self): + tr = Compose([N(), OneOf([T(), T()])]) + with self.assertRaises(RuntimeError): + get_number_image_type_conversions(tr, NP_ARR) + + +if __name__ == "__main__": + unittest.main() From 5fe5a0b42145696f5c4325d13db8775b40d439fc Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 Aug 2021 19:45:23 +0100 Subject: [PATCH 50/89] print backends of all MONAI transforms (#2801) print backends of all MONAI transforms --- monai/transforms/__init__.py | 1 + monai/transforms/intensity/array.py | 5 +- monai/transforms/intensity/dictionary.py | 3 +- monai/transforms/transform.py | 3 +- monai/transforms/utils.py | 61 +++++++++++++++++++++++- monai/utils/__init__.py | 1 + monai/utils/enums.py | 10 ++++ tests/test_print_transform_backends.py | 23 +++++++++ 8 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/test_print_transform_backends.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 5f9ed84bcd..f409c0bd8c 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -515,6 +515,7 @@ map_binary_to_indices, map_classes_to_indices, map_spatial_axes, + print_transform_backends, rand_choice, rescale_array, rescale_array_int_max, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 113fbadbb1..5d26ee0e63 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -37,6 +37,7 @@ ensure_tuple_size, fall_back_tuple, ) +from monai.utils.enums import TransformBackends __all__ = [ "RandGaussianNoise", @@ -81,7 +82,7 @@ class RandGaussianNoise(RandomizableTransform): std: Standard deviation (spread) of distribution. """ - backend = ["torch", "numpy"] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, prob: float = 0.1, mean: Union[Sequence[float], float] = 0.0, std: float = 0.1) -> None: RandomizableTransform.__init__(self, prob) @@ -852,7 +853,7 @@ class SavitzkyGolaySmooth(Transform): or ``'circular'``. Default: ``'zeros'``. See ``torch.nn.Conv1d()`` for more information. """ - backend = ["numpy"] + backend = [TransformBackends.NUMPY] def __init__(self, window_length: int, order: int, axis: int = 1, mode: str = "zeros"): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index d3780641ae..7ca21432c5 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -45,6 +45,7 @@ from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive from monai.utils import convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils.enums import TransformBackends __all__ = [ "RandGaussianNoised", @@ -144,7 +145,7 @@ class RandGaussianNoised(RandomizableTransform, MapTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = ["torch", "numpy"] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( self, diff --git a/monai/transforms/transform.py b/monai/transforms/transform.py index aff468b2a5..ef49bc706c 100644 --- a/monai/transforms/transform.py +++ b/monai/transforms/transform.py @@ -22,6 +22,7 @@ from monai import transforms from monai.config import KeysCollection from monai.utils import MAX_SEED, ensure_tuple +from monai.utils.enums import TransformBackends __all__ = [ "ThreadUnsafe", @@ -212,7 +213,7 @@ class Transform(ABC): :py:class:`monai.transforms.Compose` """ - backend: List[str] = [] + backend: List[TransformBackends] = [] """Transforms should add data types to this list if they are capable of performing a transform without modifying the input type. For example, [\"torch.Tensor\", \"np.ndarray\"] means that no copies of the data are required if the input is either \"torch.Tensor\" or \"np.ndarray\".""" diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 5886c35974..e81cb7ca17 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -13,16 +13,18 @@ import random import warnings from contextlib import contextmanager +from inspect import getmembers, isclass from typing import Any, Callable, Hashable, Iterable, List, Optional, Sequence, Tuple, Union import numpy as np import torch +import monai import monai.transforms.transform from monai.config import DtypeLike, IndexSelection from monai.networks.layers import GaussianFilter from monai.transforms.compose import Compose, OneOf -from monai.transforms.transform import MapTransform +from monai.transforms.transform import MapTransform, Transform from monai.utils import ( GridSampleMode, InterpolateMode, @@ -77,6 +79,7 @@ "zero_margins", "equalize_hist", "get_number_image_type_conversions", + "print_transform_backends", ] @@ -1149,3 +1152,59 @@ def _get_data(obj, key): if not isinstance(curr_data, prev_type) or curr_device != prev_device: num_conversions += 1 return num_conversions + + +def print_transform_backends(): + """Prints a list of backends of all MONAI transforms.""" + + class Colours: + red = "91" + green = "92" + yellow = "93" + + def print_colour(t, colour): + print(f"\033[{colour}m{t}\033[00m") + + tr_total = 0 + tr_t_or_np = 0 + tr_t = 0 + tr_np = 0 + tr_uncategorised = 0 + unique_transforms = [] + for n, obj in getmembers(monai.transforms): + # skip aliases + if obj in unique_transforms: + continue + unique_transforms.append(obj) + + if isclass(obj) and issubclass(obj, Transform): + if n in [ + "Transform", + "InvertibleTransform", + "Lambda", + "LambdaD", + "Compose", + "RandomizableTransform", + "OneOf", + "BatchInverseTransform", + "InverteD", + ]: + continue + tr_total += 1 + if obj.backend == ["torch", "numpy"]: + tr_t_or_np += 1 + print_colour(f"TorchOrNumpy: {n}", Colours.green) + elif obj.backend == ["torch"]: + tr_t += 1 + print_colour(f"Torch: {n}", Colours.green) + elif obj.backend == ["numpy"]: + tr_np += 1 + print_colour(f"Numpy: {n}", Colours.yellow) + else: + tr_uncategorised += 1 + print_colour(f"Uncategorised: {n}", Colours.red) + print("Total number of transforms:", tr_total) + print_colour(f"Number transforms allowing both torch and numpy: {tr_t_or_np}", Colours.green) + print_colour(f"Number of TorchTransform: {tr_t}", Colours.green) + print_colour(f"Number of NumpyTransform: {tr_np}", Colours.yellow) + print_colour(f"Number of uncategorised: {tr_uncategorised}", Colours.red) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 16231ba17e..dd300fce34 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -30,6 +30,7 @@ NumpyPadMode, PytorchPadMode, SkipMode, + TransformBackends, UpsampleMode, Weight, ) diff --git a/monai/utils/enums.py b/monai/utils/enums.py index 014363e14f..847df9e2d3 100644 --- a/monai/utils/enums.py +++ b/monai/utils/enums.py @@ -29,6 +29,7 @@ "InverseKeys", "CommonKeys", "ForwardMode", + "TransformBackends", ] @@ -233,3 +234,12 @@ class CommonKeys: LABEL = "label" PRED = "pred" LOSS = "loss" + + +class TransformBackends(Enum): + """ + Transform backends. + """ + + TORCH = "torch" + NUMPY = "numpy" diff --git a/tests/test_print_transform_backends.py b/tests/test_print_transform_backends.py new file mode 100644 index 0000000000..09828f0a27 --- /dev/null +++ b/tests/test_print_transform_backends.py @@ -0,0 +1,23 @@ +# Copyright 2020 - 2021 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 + +from monai.transforms.utils import print_transform_backends + + +class TestPrintTransformBackends(unittest.TestCase): + def test_get_number_of_conversions(self): + print_transform_backends() + + +if __name__ == "__main__": + unittest.main() From 765986911de45847af0cecf0fe18f19f2fb55365 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Thu, 19 Aug 2021 23:38:02 +0100 Subject: [PATCH 51/89] Pad base class (#2802) * print backends of all MONAI transforms Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * Pad base class Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/config/type_definitions.py | 9 ++- monai/transforms/croppad/array.py | 91 ++++++++++++++++++++++--- monai/transforms/croppad/dictionary.py | 15 +++-- monai/transforms/utility/array.py | 17 ++--- monai/transforms/utility/dictionary.py | 8 +-- monai/utils/type_conversion.py | 16 ++--- tests/test_border_pad.py | 12 ++-- tests/test_cast_to_type.py | 21 ++++-- tests/test_divisible_pad.py | 47 ++++++++----- tests/test_get_equivalent_dtype.py | 4 +- tests/test_spatial_pad.py | 92 +++++++++++++++++++------- 11 files changed, 241 insertions(+), 91 deletions(-) diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index 478830437e..5e294e0687 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -58,13 +58,16 @@ DtypeLike = Union[np.dtype, type, None] """Type of datatypes -adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py + +Adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py """ -# Generic type which can represent either a numpy.ndarray or a torch.Tensor -# Unlike Union can create a dependence between parameter(s) / return(s) NdarrayTensor = TypeVar("NdarrayTensor", np.ndarray, torch.Tensor) +"""NdarrayTensor +Generic type which can represent either a numpy.ndarray or a torch.Tensor +Unlike Union can create a dependence between parameter(s) / return(s) +""" TensorOrList = Union[torch.Tensor, Sequence[torch.Tensor]] """TensorOrList diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 0eca034950..06d6badf80 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -19,8 +19,10 @@ import numpy as np import torch +from torch.nn.functional import pad as pad_pt from monai.config import IndexSelection +from monai.config.type_definitions import NdarrayTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.transform import Randomizable, Transform from monai.transforms.utils import ( @@ -34,6 +36,8 @@ weighted_patch_samples, ) from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple, look_up_option +from monai.utils.enums import TransformBackends +from monai.utils.type_conversion import convert_data_type __all__ = [ "SpatialPad", @@ -54,9 +58,72 @@ ] +class Pad(Transform): + """ + Perform padding for a given an amount of padding in each dimension. + If input is `torch.Tensor` and mode is `constant`, `torch.nn.functional.pad` will be used. + Otherwise, `np.pad` will be used (input converted to `np.ndarray` if necessary). + Uses np.pad so in practice, a mode needs to be provided. See numpy.lib.arraypad.pad + for additional details. + Args: + to_pad: the amount to be padded in each dimension [(low_H, high_H), (low_W, high_W), ...]. + 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 ``"constant"``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + to_pad: List[Tuple[int, int]], + mode: Union[NumpyPadMode, str, None] = NumpyPadMode.CONSTANT, + **np_kwargs, + ) -> None: + self.to_pad = to_pad + self.mode = mode or NumpyPadMode.CONSTANT + self.np_kwargs = np_kwargs + + @staticmethod + def _np_pad(img: np.ndarray, all_pad_width, mode, **np_kwargs) -> np.ndarray: + img_np, *_ = convert_data_type(img, np.ndarray) + return np.pad(img_np, all_pad_width, mode=mode, **np_kwargs) # type: ignore + + @staticmethod + def _pt_pad(img: torch.Tensor, all_pad_width, mode, **np_kwargs) -> torch.Tensor: + pt_pad_width = [val for sublist in all_pad_width for val in sublist[::-1]][::-1] + return pad_pt(img, pt_pad_width, mode=mode, **np_kwargs) + + def __call__(self, img: NdarrayTensor, mode: Optional[Union[NumpyPadMode, str]] = None) -> NdarrayTensor: + """ + Args: + img: data to be transformed, assuming `img` is channel-first and + padding doesn't apply to the channel dim. + 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 ``self.mode``. + See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + """ + if not np.asarray(self.to_pad).any(): + # all zeros, skip padding + return img + mode = mode or self.mode + mode = mode.value if isinstance(mode, NumpyPadMode) else mode + if isinstance(img, torch.Tensor) and mode == "constant" and not self.np_kwargs: + pad = self._pt_pad + else: + pad = self._np_pad # type: ignore + return pad(img, self.to_pad, mode, **self.np_kwargs) + + class SpatialPad(Transform): """ Performs padding to the data, symmetric for all sides or all on one side for each dimension. + + If input is `torch.Tensor` and mode is `constant`, `torch.nn.functional.pad` will be used. + Otherwise, `np.pad` will be used (input converted to `np.ndarray` if necessary). + Uses np.pad so in practice, a mode needs to be provided. See numpy.lib.arraypad.pad for additional details. @@ -77,6 +144,8 @@ class SpatialPad(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, spatial_size: Union[Sequence[int], int], @@ -99,7 +168,7 @@ def _determine_data_pad_width(self, data_shape: Sequence[int]) -> List[Tuple[int return pad_width return [(0, max(sp_i - data_shape[i], 0)) for i, sp_i in enumerate(spatial_size)] - def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None) -> np.ndarray: + def __call__(self, img: NdarrayTensor, mode: Optional[Union[NumpyPadMode, str]] = None) -> NdarrayTensor: """ Args: img: data to be transformed, assuming `img` is channel-first and @@ -115,9 +184,9 @@ def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = N # all zeros, skip padding return img - mode = look_up_option(self.mode if mode is None else mode, NumpyPadMode).value - img = np.pad(img, all_pad_width, mode=mode, **self.np_kwargs) - return img + mode = look_up_option(mode or self.mode, NumpyPadMode) + padder = Pad(all_pad_width, mode, **self.np_kwargs) + return padder(img) class BorderPad(Transform): @@ -145,6 +214,8 @@ class BorderPad(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, spatial_border: Union[Sequence[int], int], @@ -155,7 +226,7 @@ def __init__( self.mode: NumpyPadMode = look_up_option(mode, NumpyPadMode) self.np_kwargs = np_kwargs - def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None): + def __call__(self, img: NdarrayTensor, mode: Optional[Union[NumpyPadMode, str]] = None) -> NdarrayTensor: """ Args: img: data to be transformed, assuming `img` is channel-first and @@ -189,8 +260,10 @@ def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = N f"[1, len(spatial_shape)={len(spatial_shape)}, 2*len(spatial_shape)={2*len(spatial_shape)}]." ) - mode = look_up_option(self.mode if mode is None else mode, NumpyPadMode).value - return np.pad(img, [(0, 0)] + data_pad_width, mode=mode, **self.np_kwargs) + all_pad_width = [(0, 0)] + data_pad_width + mode = look_up_option(mode or self.mode, NumpyPadMode) + padder = Pad(all_pad_width, mode, **self.np_kwargs) + return padder(img) class DivisiblePad(Transform): @@ -198,6 +271,8 @@ class DivisiblePad(Transform): Pad the input data, so that the spatial sizes are divisible by `k`. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, k: Union[Sequence[int], int], @@ -226,7 +301,7 @@ def __init__( self.method: Method = Method(method) self.np_kwargs = np_kwargs - def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None) -> np.ndarray: + def __call__(self, img: NdarrayTensor, mode: Optional[Union[NumpyPadMode, str]] = None) -> NdarrayTensor: """ Args: img: data to be transformed, assuming `img` is channel-first diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index a642bf406b..7c517cae96 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -25,6 +25,7 @@ import numpy as np from monai.config import IndexSelection, KeysCollection +from monai.config.type_definitions import NdarrayTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.croppad.array import ( BorderPad, @@ -49,7 +50,7 @@ ) from monai.utils import ImageMetaKey as Key from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple -from monai.utils.enums import InverseKeys +from monai.utils.enums import InverseKeys, TransformBackends __all__ = [ "NumpyPadModeSequence", @@ -106,6 +107,8 @@ class SpatialPadd(MapTransform, InvertibleTransform): Performs padding to the data, symmetric for all sides or all on one side for each dimension. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, keys: KeysCollection, @@ -140,7 +143,7 @@ def __init__( self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = SpatialPad(spatial_size, method, **np_kwargs) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key, m in self.key_iterator(d, self.mode): self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) @@ -174,6 +177,8 @@ class BorderPadd(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.BorderPad`. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, keys: KeysCollection, @@ -211,7 +216,7 @@ def __init__( self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = BorderPad(spatial_border=spatial_border, **np_kwargs) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key, m in self.key_iterator(d, self.mode): self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) @@ -249,6 +254,8 @@ class DivisiblePadd(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.DivisiblePad`. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, keys: KeysCollection, @@ -283,7 +290,7 @@ def __init__( self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padder = DivisiblePad(k=k, method=method, **np_kwargs) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key, m in self.key_iterator(d, self.mode): self.push_transform(d, key, extra_info={"mode": m.value if isinstance(m, Enum) else m}) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 1871eedb6f..59b4d64174 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -39,6 +39,8 @@ min_version, optional_import, ) +from monai.utils.enums import TransformBackends +from monai.utils.type_conversion import convert_data_type PILImageImage, has_pil = optional_import("PIL.Image", name="Image") pil_image_fromarray, _ = optional_import("PIL.Image", name="fromarray") @@ -288,6 +290,8 @@ class CastToType(Transform): specified PyTorch data type. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, dtype=np.float32) -> None: """ Args: @@ -295,9 +299,7 @@ def __init__(self, dtype=np.float32) -> None: """ self.dtype = dtype - def __call__( - self, img: Union[np.ndarray, torch.Tensor], dtype: Optional[Union[DtypeLike, torch.dtype]] = None - ) -> Union[np.ndarray, torch.Tensor]: + def __call__(self, img: NdarrayTensor, dtype: Optional[Union[DtypeLike, torch.dtype]] = None) -> NdarrayTensor: """ Apply the transform to `img`, assuming `img` is a numpy array or PyTorch Tensor. @@ -308,11 +310,10 @@ def __call__( TypeError: When ``img`` type is not in ``Union[numpy.ndarray, torch.Tensor]``. """ - if isinstance(img, np.ndarray): - return img.astype(self.dtype if dtype is None else dtype) # type: ignore - if isinstance(img, torch.Tensor): - return torch.as_tensor(img, dtype=self.dtype if dtype is None else dtype) - raise TypeError(f"img must be one of (numpy.ndarray, torch.Tensor) but is {type(img).__name__}.") + if not isinstance(img, (torch.Tensor, np.ndarray)): + raise TypeError(f"img must be one of (numpy.ndarray, torch.Tensor) but is {type(img).__name__}.") + img_out, *_ = convert_data_type(img, output_type=type(img), dtype=dtype or self.dtype) + return img_out class ToTensor(Transform): diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 9c0a709bbf..9f6680fdf5 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -58,7 +58,7 @@ ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.utils import convert_to_numpy, ensure_tuple, ensure_tuple_rep -from monai.utils.enums import InverseKeys +from monai.utils.enums import InverseKeys, TransformBackends __all__ = [ "AddChannelD", @@ -393,6 +393,8 @@ class CastToTyped(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.CastToType`. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, keys: KeysCollection, @@ -413,9 +415,7 @@ def __init__( self.dtype = ensure_tuple_rep(dtype, len(self.keys)) self.converter = CastToType() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) for key, dtype in self.key_iterator(d, self.dtype): d[key] = self.converter(d[key], dtype=dtype) diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index d4d911c5b2..54c92c46ef 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -1,5 +1,5 @@ import re -from typing import Any, Optional, Sequence, Tuple, Union +from typing import Any, Optional, Sequence, Tuple, Type, Union, cast import numpy as np import torch @@ -147,7 +147,7 @@ def convert_to_numpy(data): def convert_data_type( data: Any, - output_type: Optional[type] = None, + output_type: Optional[Type[NdarrayTensor]] = None, device: Optional[torch.device] = None, dtype: Optional[Union[DtypeLike, torch.dtype]] = None, ) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: @@ -176,16 +176,16 @@ def convert_data_type( data = convert_to_tensor(data) if dtype != data.dtype: data = data.to(dtype) # type: ignore - elif output_type is np.ndarray: + if device is not None: + data = data.to(device) + return cast(NdarrayTensor, data), orig_type, orig_device # pytype: disable=invalid-annotation + if output_type is np.ndarray: if orig_type is not np.ndarray: data = convert_to_numpy(data) if data is not None and dtype != data.dtype: data = data.astype(dtype) # type: ignore - - if isinstance(data, torch.Tensor) and device is not None: - data = data.to(device) - - return data, orig_type, orig_device + return cast(NdarrayTensor, data), orig_type, orig_device # pytype: disable=invalid-annotation + raise ValueError(f"Unsupported output type: {output_type}") def convert_to_dst_type(src: Any, dst: NdarrayTensor) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: diff --git a/tests/test_border_pad.py b/tests/test_border_pad.py index b011601694..9e6a8a6a08 100644 --- a/tests/test_border_pad.py +++ b/tests/test_border_pad.py @@ -16,6 +16,7 @@ from monai.transforms import BorderPad from monai.utils import NumpyPadMode +from tests.utils import TEST_NDARRAYS TEST_CASE_1 = [ {"spatial_border": 2, "mode": "constant"}, @@ -45,11 +46,12 @@ class TestBorderPad(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) def test_pad_shape(self, input_param, input_data, expected_val): - padder = BorderPad(**input_param) - result = padder(input_data) - self.assertAlmostEqual(result.shape, expected_val.shape) - result = padder(input_data, mode=input_param["mode"]) - self.assertAlmostEqual(result.shape, expected_val.shape) + for p in TEST_NDARRAYS: + padder = BorderPad(**input_param) + r1 = padder(p(input_data)) + r2 = padder(input_data, mode=input_param["mode"]) + self.assertAlmostEqual(r1.shape, expected_val.shape) + self.assertAlmostEqual(r2.shape, expected_val.shape) def test_pad_kwargs(self): padder = BorderPad(spatial_border=2, mode="constant", constant_values=((0, 0), (1, 1), (2, 2))) diff --git a/tests/test_cast_to_type.py b/tests/test_cast_to_type.py index 5e81b41650..0ef25cbafa 100644 --- a/tests/test_cast_to_type.py +++ b/tests/test_cast_to_type.py @@ -16,17 +16,24 @@ from parameterized import parameterized from monai.transforms import CastToType +from monai.utils.type_conversion import get_equivalent_dtype +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"dtype": np.float64}, np.array([[0, 1], [1, 2]], dtype=np.float32), np.float64] - -TEST_CASE_2 = [{"dtype": torch.float64}, torch.tensor([[0, 1], [1, 2]], dtype=torch.float32), torch.float64] +TESTS = [] +for p in TEST_NDARRAYS: + for out_dtype in (np.float64, torch.float64): + TESTS.append([out_dtype, p(np.array([[0, 1], [1, 2]], dtype=np.float32)), out_dtype]) class TestCastToType(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) - def test_type(self, input_param, input_data, expected_type): - result = CastToType(**input_param)(input_data) - self.assertEqual(result.dtype, expected_type) + @parameterized.expand(TESTS) + def test_type(self, out_dtype, input_data, expected_type): + + result = CastToType(dtype=out_dtype)(input_data) + self.assertEqual(result.dtype, get_equivalent_dtype(expected_type, type(result))) + + result = CastToType()(input_data, out_dtype) + self.assertEqual(result.dtype, get_equivalent_dtype(expected_type, type(result))) if __name__ == "__main__": diff --git a/tests/test_divisible_pad.py b/tests/test_divisible_pad.py index e4415a2f22..ca15b4b347 100644 --- a/tests/test_divisible_pad.py +++ b/tests/test_divisible_pad.py @@ -12,27 +12,36 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import DivisiblePad - -# pad first dim to be divisible by 7, the second unchanged. -TEST_CASE_1 = [ - {"k": (7, -1), "mode": "constant"}, - np.zeros((3, 8, 7)), - np.zeros((3, 14, 7)), -] - -# pad all dimensions to be divisible by 5 -TEST_CASE_2 = [ - {"k": 5, "mode": "constant", "method": "end"}, - np.zeros((3, 10, 5, 17)), - np.zeros((3, 10, 5, 20)), -] +from tests.utils import TEST_NDARRAYS + +TESTS = [] + +for p in TEST_NDARRAYS: + # pad first dim to be divisible by 7, the second unchanged. + TESTS.append( + [ + {"k": (7, -1), "mode": "constant"}, + p(np.zeros((3, 8, 7))), + p(np.zeros((3, 14, 7))), + ] + ) + + # pad all dimensions to be divisible by 5 + TESTS.append( + [ + {"k": 5, "mode": "constant", "method": "end"}, + p(np.zeros((3, 10, 5, 17))), + p(np.zeros((3, 10, 5, 20))), + ] + ) class TestDivisiblePad(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2]) + @parameterized.expand(TESTS) def test_pad_shape(self, input_param, input_data, expected_val): padder = DivisiblePad(**input_param) result = padder(input_data) @@ -42,9 +51,11 @@ def test_pad_shape(self, input_param, input_data, expected_val): def test_pad_kwargs(self): padder = DivisiblePad(k=5, mode="constant", constant_values=((0, 0), (1, 1), (2, 2))) - result = padder(np.zeros((3, 8, 4))) - np.testing.assert_allclose(result[:, :1, :4], np.ones((3, 1, 4))) - np.testing.assert_allclose(result[:, :, 4:5], np.ones((3, 10, 1)) + 1) + for p in TEST_NDARRAYS: + result = padder(p(np.zeros((3, 8, 4)))) + result = result.cpu() if isinstance(result, torch.Tensor) else result + torch.testing.assert_allclose(result[:, :1, :4], np.ones((3, 1, 4)), rtol=1e-7, atol=0) + torch.testing.assert_allclose(result[:, :, 4:5], np.ones((3, 10, 1)) + 1, rtol=1e-7, atol=0) if __name__ == "__main__": diff --git a/tests/test_get_equivalent_dtype.py b/tests/test_get_equivalent_dtype.py index 96f4a4d720..04ba5ae5fb 100644 --- a/tests/test_get_equivalent_dtype.py +++ b/tests/test_get_equivalent_dtype.py @@ -26,9 +26,9 @@ TESTS.append((p(np.array(1.0, dtype=np.float32)), im_dtype)) -class TestDtypeConvert(unittest.TestCase): +class TestGetEquivalentDtype(unittest.TestCase): @parameterized.expand(TESTS) - def test_dtype_convert(self, im, input_dtype): + def test_get_equivalent_dtype(self, im, input_dtype): out_dtype = get_equivalent_dtype(input_dtype, type(im)) self.assertEqual(out_dtype, im.dtype) diff --git a/tests/test_spatial_pad.py b/tests/test_spatial_pad.py index 93241610de..86d010bbad 100644 --- a/tests/test_spatial_pad.py +++ b/tests/test_spatial_pad.py @@ -10,47 +10,91 @@ # limitations under the License. import unittest +from typing import List import numpy as np +import torch from parameterized import parameterized from monai.transforms import SpatialPad +from monai.utils.enums import NumpyPadMode +from monai.utils.misc import set_determinism +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [ - {"spatial_size": [15, 8, 8], "method": "symmetric", "mode": "constant"}, - np.zeros((3, 8, 8, 4)), - np.zeros((3, 15, 8, 8)), -] +TESTS = [] -TEST_CASE_2 = [ - {"spatial_size": [15, 8, 8], "method": "end", "mode": "constant"}, - np.zeros((3, 8, 8, 4)), - np.zeros((3, 15, 8, 8)), +# Numpy modes +MODES: List = [ + "constant", + "edge", + "linear_ramp", + "maximum", + "mean", + "median", + "minimum", + "reflect", + "symmetric", + "wrap", + "empty", ] +MODES += [NumpyPadMode(i) for i in MODES] -TEST_CASE_3 = [ - {"spatial_size": [15, 4, -1], "method": "symmetric", "mode": "constant"}, - np.zeros((3, 8, 8, 4)), - np.zeros((3, 15, 8, 4)), -] +for mode in MODES: + TESTS.append( + [ + {"spatial_size": [50, 50], "method": "end", "mode": mode}, + (1, 2, 2), + (1, 50, 50), + ] + ) + + TESTS.append( + [ + {"spatial_size": [15, 4, -1], "method": "symmetric", "mode": mode}, + (3, 8, 8, 4), + (3, 15, 8, 4), + ] + ) class TestSpatialPad(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_pad_shape(self, input_param, input_data, expected_val): - padder = SpatialPad(**input_param) - result = padder(input_data) - np.testing.assert_allclose(result.shape, expected_val.shape) - result = padder(input_data, mode=input_param["mode"]) - np.testing.assert_allclose(result.shape, expected_val.shape) + def setUp(self) -> None: + set_determinism(seed=0) + + def tearDown(self) -> None: + set_determinism(None) + + @staticmethod + def get_arr(shape): + return np.random.randint(100, size=shape).astype(float) + + @parameterized.expand(TESTS) + def test_pad_shape(self, input_param, input_shape, expected_shape): + results_1 = [] + results_2 = [] + input_data = self.get_arr(input_shape) + # check result is the same regardless of input type + for p in TEST_NDARRAYS: + padder = SpatialPad(**input_param) + r1 = padder(p(input_data)) + r2 = padder(p(input_data), mode=input_param["mode"]) + results_1.append(r1.cpu() if isinstance(r1, torch.Tensor) else r1) + results_2.append(r2.cpu() if isinstance(r2, torch.Tensor) else r2) + for results in (results_1, results_2): + np.testing.assert_allclose(results[-1].shape, expected_shape) + if input_param["mode"] not in ("empty", NumpyPadMode.EMPTY): + torch.testing.assert_allclose(results[0], results[-1], atol=0, rtol=1e-5) def test_pad_kwargs(self): padder = SpatialPad( spatial_size=[15, 8], method="end", mode="constant", constant_values=((0, 0), (1, 1), (2, 2)) ) - result = padder(np.zeros((3, 8, 4))) - np.testing.assert_allclose(result[:, 8:, :4], np.ones((3, 7, 4))) - np.testing.assert_allclose(result[:, :, 4:], np.ones((3, 15, 4)) + 1) + for p in TEST_NDARRAYS: + result = padder(p(np.zeros((3, 8, 4)))) + if isinstance(result, torch.Tensor): + result = result.cpu().numpy() + torch.testing.assert_allclose(result[:, 8:, :4], np.ones((3, 7, 4)), rtol=1e-7, atol=0) + torch.testing.assert_allclose(result[:, :, 4:], np.ones((3, 15, 4)) + 1, rtol=1e-7, atol=0) if __name__ == "__main__": From 2c7ec574856adda1081e5a6638a8f6aa8de55bbb Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 20 Aug 2021 09:23:19 +0800 Subject: [PATCH 52/89] [DLMED] enhance CropForegroundd transform (#2808) Signed-off-by: Nic Ma --- monai/transforms/croppad/array.py | 14 ++++++++++---- monai/transforms/croppad/dictionary.py | 9 +++++---- tests/test_crop_foregroundd.py | 10 +++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 06d6badf80..56bd10c5a3 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -691,7 +691,13 @@ def compute_bounding_box(self, img: np.ndarray): box_end_ = box_start_ + spatial_size return box_start_, box_end_ - def crop_pad(self, img: np.ndarray, box_start: np.ndarray, box_end: np.ndarray): + def crop_pad( + self, + img: np.ndarray, + box_start: np.ndarray, + box_end: np.ndarray, + mode: Optional[Union[NumpyPadMode, str]] = None, + ): """ Crop and pad based on the bounding box. @@ -700,15 +706,15 @@ def crop_pad(self, img: np.ndarray, box_start: np.ndarray, box_end: np.ndarray): pad_to_start = np.maximum(-box_start, 0) pad_to_end = np.maximum(box_end - np.asarray(img.shape[1:]), 0) pad = list(chain(*zip(pad_to_start.tolist(), pad_to_end.tolist()))) - return BorderPad(spatial_border=pad, mode=self.mode, **self.np_kwargs)(cropped) + return BorderPad(spatial_border=pad, mode=mode or self.mode, **self.np_kwargs)(cropped) - def __call__(self, img: np.ndarray): + def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None): """ Apply the transform to `img`, assuming `img` is channel-first and slicing doesn't change the channel dim. """ box_start, box_end = self.compute_bounding_box(img) - cropped = self.crop_pad(img, box_start, box_end) + cropped = self.crop_pad(img, box_start, box_end, mode) if self.return_coords: return cropped, box_start, box_end diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index 7c517cae96..c417b3fe8a 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -797,7 +797,7 @@ def __init__( channel_indices: Optional[IndexSelection] = None, margin: Union[Sequence[int], int] = 0, k_divisible: Union[Sequence[int], int] = 1, - mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT, + mode: NumpyPadModeSequence = NumpyPadMode.CONSTANT, start_coord_key: str = "foreground_start_coord", end_coord_key: str = "foreground_end_coord", allow_missing_keys: bool = False, @@ -818,6 +818,7 @@ def __init__( ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``} one of the listed string values or a user supplied function. Defaults to ``"constant"``. see also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html + it also can be a sequence of string, each element corresponds to a key in ``keys``. start_coord_key: key to record the start coordinate of spatial bounding box for foreground. end_coord_key: key to record the end coordinate of spatial bounding box for foreground. allow_missing_keys: don't raise exception if key is missing. @@ -834,18 +835,18 @@ def __init__( channel_indices=channel_indices, margin=margin, k_divisible=k_divisible, - mode=mode, **np_kwargs, ) + self.mode = ensure_tuple_rep(mode, len(self.keys)) def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: d = dict(data) box_start, box_end = self.cropper.compute_bounding_box(img=d[self.source_key]) d[self.start_coord_key] = box_start d[self.end_coord_key] = box_end - for key in self.key_iterator(d): + for key, m in self.key_iterator(d, self.mode): self.push_transform(d, key, extra_info={"box_start": box_start, "box_end": box_end}) - d[key] = self.cropper.crop_pad(d[key], box_start, box_end) + d[key] = self.cropper.crop_pad(img=d[key], box_start=box_start, box_end=box_end, mode=m) return d def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: diff --git a/tests/test_crop_foregroundd.py b/tests/test_crop_foregroundd.py index f51ca7e2df..efe6b65b4b 100644 --- a/tests/test_crop_foregroundd.py +++ b/tests/test_crop_foregroundd.py @@ -15,6 +15,7 @@ from parameterized import parameterized from monai.transforms import CropForegroundd +from monai.utils import NumpyPadMode TEST_CASE_1 = [ { @@ -59,15 +60,18 @@ TEST_CASE_6 = [ { - "keys": ["img"], + "keys": ["img", "seg"], "source_key": "img", "select_fn": lambda x: x > 0, "channel_indices": 0, "margin": 0, "k_divisible": [4, 6], - "mode": "edge", + "mode": ["edge", NumpyPadMode.CONSTANT], + }, + { + "img": np.array([[[0, 2, 1, 2, 0], [1, 1, 2, 1, 1], [2, 2, 3, 2, 2], [1, 1, 2, 1, 1], [0, 0, 0, 0, 0]]]), + "seg": np.array([[[0, 2, 1, 2, 0], [1, 1, 2, 1, 1], [2, 2, 3, 2, 2], [1, 1, 2, 1, 1], [0, 0, 0, 0, 0]]]), }, - {"img": np.array([[[0, 2, 1, 2, 0], [1, 1, 2, 1, 1], [2, 2, 3, 2, 2], [1, 1, 2, 1, 1], [0, 0, 0, 0, 0]]])}, np.array([[[0, 2, 1, 2, 0, 0], [1, 1, 2, 1, 1, 1], [2, 2, 3, 2, 2, 2], [1, 1, 2, 1, 1, 1]]]), ] From c39f6fa6414d608b580cbdbdeed48a77c302be3a Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Sat, 21 Aug 2021 07:21:52 +0100 Subject: [PATCH 53/89] sunset premerge self-hosted pipelines (#2803) Signed-off-by: Wenqi Li Co-authored-by: Mohammad Adil --- .github/workflows/pythonapp.yml | 114 -------------------------------- CONTRIBUTING.md | 1 + 2 files changed, 1 insertion(+), 114 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index b2ddb74d34..482e1937e1 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -200,120 +200,6 @@ jobs: env: QUICKTEST: True - GPU-quick-py3: # GPU with full dependencies - if: github.repository == 'Project-MONAI/MONAI' - strategy: - matrix: - environment: - - "PT16+CUDA110" - - "PT17+CUDA102" - - "PT17+CUDA110" - - "PT18+CUDA102" - - "PT19+CUDA113" - - "PT19+CUDA102" - include: - - environment: PT16+CUDA110 - # we explicitly set pytorch to -h to avoid pip install error - pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:20.07-py3" - - environment: PT17+CUDA102 - pytorch: "torch==1.7.1 torchvision==0.8.2" - base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - - environment: PT17+CUDA110 - # we explicitly set pytorch to -h to avoid pip install error - pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:20.09-py3" - - environment: PT18+CUDA102 - pytorch: "torch==1.8.1 torchvision==0.9.1" - base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - - environment: PT19+CUDA113 - # we explicitly set pytorch to -h to avoid pip install error - pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:21.06-py3" - - environment: PT19+CUDA102 - pytorch: "torch==1.9.0 torchvision==0.10.0" - base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - container: - image: ${{ matrix.base }} - options: --gpus all - runs-on: [self-hosted, linux, x64, common] - steps: - - uses: actions/checkout@v2 - - name: apt install - run: | - if [ ${{ matrix.environment }} = "PT17+CUDA102" ] || \ - [ ${{ matrix.environment }} = "PT18+CUDA102" ] || \ - [ ${{ matrix.environment }} = "PT19+CUDA102" ] - then - PYVER=3.6 PYSFX=3 DISTUTILS=python3-distutils && \ - apt-get update && apt-get install -y --no-install-recommends \ - curl \ - pkg-config \ - python$PYVER \ - python$PYVER-dev \ - python$PYSFX-pip \ - $DISTUTILS \ - rsync \ - swig \ - unzip \ - zip \ - zlib1g-dev \ - libboost-locale-dev \ - libboost-program-options-dev \ - libboost-system-dev \ - libboost-thread-dev \ - libboost-test-dev \ - libgoogle-glog-dev \ - libjsoncpp-dev \ - cmake \ - git && \ - rm -rf /var/lib/apt/lists/* && \ - export PYTHONIOENCODING=utf-8 LC_ALL=C.UTF-8 && \ - rm -f /usr/bin/python && \ - rm -f /usr/bin/python`echo $PYVER | cut -c1-1` && \ - ln -s /usr/bin/python$PYVER /usr/bin/python && \ - ln -s /usr/bin/python$PYVER /usr/bin/python`echo $PYVER | cut -c1-1` && - curl -O https://bootstrap.pypa.io/get-pip.py && \ - python get-pip.py && \ - rm get-pip.py; - fi - - name: Install dependencies - run: | - which python - python -m pip install --upgrade pip wheel - python -m pip install ${{ matrix.pytorch }} - python -m pip install -r requirements-dev.txt - python -m pip list - - name: Run quick tests (GPU) - run: | - git clone --depth 1 \ - https://github.com/Project-MONAI/MONAI-extra-test-data.git /MONAI-extra-test-data - export MONAI_EXTRA_TEST_DATA="/MONAI-extra-test-data" - nvidia-smi - export LAUNCH_DELAY=$(python -c "import numpy; print(numpy.random.randint(30) * 10)") - echo "Sleep $LAUNCH_DELAY" - sleep $LAUNCH_DELAY - export CUDA_VISIBLE_DEVICES=$(coverage run -m tests.utils) - echo $CUDA_VISIBLE_DEVICES - trap 'if pgrep python; then pkill python; fi;' ERR - python -c $'import torch\na,b=torch.zeros(1,device="cuda:0"),torch.zeros(1,device="cuda:1");\nwhile True:print(a,b)' > /dev/null & - python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" - python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' - python -c "import monai; monai.config.print_config()" - # build for the current self-hosted CI Tesla V100 - BUILD_MONAI=1 TORCH_CUDA_ARCH_LIST="7.0" ./runtests.sh --quick --unittests - if [ ${{ matrix.environment }} = "PT19+CUDA102" ]; then - # test the clang-format tool downloading once - coverage run -m tests.clang_format_utils - fi - coverage xml - if pgrep python; then pkill python; fi - shell: bash - - name: Upload coverage - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - packaging: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1170411c70..e52180a798 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -257,6 +257,7 @@ All code review comments should be specific, constructive, and actionable. 1. Make in-line comments to specific code segments, [request for changes](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-request-reviews) if needed. 1. Review any further code changes until all comments addressed by the contributors. 1. Comment to trigger `/black` and/or `/integration-test` for optional auto code formatting and [integration tests](.github/workflows/integration.yml). +1. [Maintainers] Review the changes and comment `/build` to trigger internal full tests. 1. Merge the pull request to the dev branch. 1. Close the corresponding task ticket on [the issue list][monai issue list]. From 93d467d00920aa3a27d46f3697a92a13c4866e74 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 23 Aug 2021 19:17:14 +0100 Subject: [PATCH 54/89] torch transforms - RandRicianNoise, StdShiftIntensity, RandStdShiftIntensity (#2815) * torch transforms - RandRicianNoise, StdShiftIntensity, RandStdShiftIntensity Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * unused imports Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * NdarrayTensorUnion Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code review Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * simplify backend Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * fix test Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * in place transforms Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/config/__init__.py | 2 +- monai/config/type_definitions.py | 8 ++- monai/transforms/croppad/array.py | 6 +- monai/transforms/croppad/dictionary.py | 8 +-- monai/transforms/intensity/array.py | 85 +++++++++++++++--------- monai/transforms/intensity/dictionary.py | 23 +++++-- monai/transforms/utility/array.py | 3 +- monai/transforms/utility/dictionary.py | 7 +- monai/utils/type_conversion.py | 22 +++--- tests/test_rand_rician_noise.py | 33 ++++----- tests/test_rand_rician_noised.py | 55 ++++++--------- tests/test_rand_std_shift_intensity.py | 19 +++--- tests/test_rand_std_shift_intensityd.py | 22 +++--- tests/test_std_shift_intensity.py | 62 +++++++++-------- 14 files changed, 196 insertions(+), 159 deletions(-) diff --git a/monai/config/__init__.py b/monai/config/__init__.py index baa4400467..c929cb2362 100644 --- a/monai/config/__init__.py +++ b/monai/config/__init__.py @@ -19,4 +19,4 @@ print_gpu_info, print_system_info, ) -from .type_definitions import DtypeLike, IndexSelection, KeysCollection, NdarrayTensor, TensorOrList +from .type_definitions import DtypeLike, IndexSelection, KeysCollection, NdarrayOrTensor, NdarrayTensor, TensorOrList diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index 5e294e0687..b236467bbc 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -14,7 +14,7 @@ import numpy as np import torch -__all__ = ["KeysCollection", "IndexSelection", "DtypeLike", "NdarrayTensor", "TensorOrList"] +__all__ = ["KeysCollection", "IndexSelection", "DtypeLike", "NdarrayTensor", "NdarrayOrTensor", "TensorOrList"] """Commonly used concepts This module provides naming and type specifications for commonly used concepts @@ -69,6 +69,12 @@ Unlike Union can create a dependence between parameter(s) / return(s) """ +NdarrayOrTensor = Union[np.ndarray, torch.Tensor] +"""NdarrayOrTensor + +Union of numpy.ndarray and torch.Tensor to be used for typing +""" + TensorOrList = Union[torch.Tensor, Sequence[torch.Tensor]] """TensorOrList diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py index 56bd10c5a3..74f556cc1a 100644 --- a/monai/transforms/croppad/array.py +++ b/monai/transforms/croppad/array.py @@ -144,7 +144,7 @@ class SpatialPad(Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = Pad.backend def __init__( self, @@ -214,7 +214,7 @@ class BorderPad(Transform): """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = Pad.backend def __init__( self, @@ -271,7 +271,7 @@ class DivisiblePad(Transform): Pad the input data, so that the spatial sizes are divisible by `k`. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = SpatialPad.backend def __init__( self, diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py index c417b3fe8a..9e33ab2db1 100644 --- a/monai/transforms/croppad/dictionary.py +++ b/monai/transforms/croppad/dictionary.py @@ -50,7 +50,7 @@ ) from monai.utils import ImageMetaKey as Key from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple -from monai.utils.enums import InverseKeys, TransformBackends +from monai.utils.enums import InverseKeys __all__ = [ "NumpyPadModeSequence", @@ -107,7 +107,7 @@ class SpatialPadd(MapTransform, InvertibleTransform): Performs padding to the data, symmetric for all sides or all on one side for each dimension. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = SpatialPad.backend def __init__( self, @@ -177,7 +177,7 @@ class BorderPadd(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.BorderPad`. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = BorderPad.backend def __init__( self, @@ -254,7 +254,7 @@ class DivisiblePadd(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.DivisiblePad`. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = DivisiblePad.backend def __init__( self, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 5d26ee0e63..4b7c8d6997 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -14,6 +14,7 @@ """ from collections.abc import Iterable +from functools import partial from typing import Any, Callable, List, Optional, Sequence, Tuple, Union from warnings import warn @@ -21,7 +22,7 @@ import torch from monai.config import DtypeLike -from monai.config.type_definitions import NdarrayTensor +from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.networks.layers import GaussianFilter, HilbertTransform, SavitzkyGolayFilter from monai.transforms.transform import RandomizableTransform, Transform @@ -31,13 +32,13 @@ InvalidPyTorchVersionError, convert_data_type, convert_to_dst_type, - dtype_torch_to_numpy, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple, ) from monai.utils.enums import TransformBackends +from monai.utils.type_conversion import convert_to_tensor, get_equivalent_dtype __all__ = [ "RandGaussianNoise", @@ -94,7 +95,7 @@ def randomize(self, im_shape: Sequence[int]) -> None: super().randomize(None) self._noise = self.R.normal(self.mean, self.R.uniform(0, self.std), size=im_shape) - def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ @@ -104,7 +105,7 @@ def __call__(self, img: NdarrayTensor) -> NdarrayTensor: if not self._do_transform: return img noise, *_ = convert_to_dst_type(self._noise, img) - return img + noise # type: ignore + return img + noise class RandRicianNoise(RandomizableTransform): @@ -130,6 +131,8 @@ class RandRicianNoise(RandomizableTransform): uniformly from 0 to std. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, prob: float = 0.1, @@ -146,20 +149,23 @@ def __init__( self.channel_wise = channel_wise self.relative = relative self.sample_std = sample_std - self._noise1: np.ndarray - self._noise2: np.ndarray + self._noise1: NdarrayOrTensor + self._noise2: NdarrayOrTensor - def _add_noise(self, img: Union[torch.Tensor, np.ndarray], mean: float, std: float): + def _add_noise(self, img: NdarrayTensor, mean: float, std: float): + dtype_np = get_equivalent_dtype(img.dtype, np.ndarray) im_shape = img.shape _std = self.R.uniform(0, std) if self.sample_std else std - self._noise1 = self.R.normal(mean, _std, size=im_shape) - self._noise2 = self.R.normal(mean, _std, size=im_shape) - if self._noise1 is None or self._noise2 is None: - raise RuntimeError("noise should not be None.") - dtype = dtype_torch_to_numpy(img.dtype) if isinstance(img, torch.Tensor) else img.dtype - return np.sqrt((img + self._noise1.astype(dtype)) ** 2 + self._noise2.astype(dtype) ** 2) - - def __call__(self, img: Union[torch.Tensor, np.ndarray]) -> Union[torch.Tensor, np.ndarray]: + self._noise1 = self.R.normal(mean, _std, size=im_shape).astype(dtype_np) + self._noise2 = self.R.normal(mean, _std, size=im_shape).astype(dtype_np) + if isinstance(img, torch.Tensor): + n1 = torch.tensor(self._noise1, device=img.device) + n2 = torch.tensor(self._noise2, device=img.device) + return torch.sqrt((img + n1) ** 2 + n2 ** 2) + + return np.sqrt((img + self._noise1) ** 2 + self._noise2 ** 2) + + def __call__(self, img: NdarrayTensor) -> NdarrayTensor: """ Apply the transform to `img`. """ @@ -191,16 +197,21 @@ class ShiftIntensity(Transform): offset: offset value to shift the intensity of image. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, offset: float) -> None: self.offset = offset - def __call__(self, img: np.ndarray, offset: Optional[float] = None) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor, offset: Optional[float] = None) -> NdarrayOrTensor: """ Apply the transform to `img`. """ offset = self.offset if offset is None else offset - return np.asarray((img + offset), dtype=img.dtype) + out = img + offset + if isinstance(out, torch.Tensor): + return out.type(img.dtype) + return out.astype(img.dtype) # type: ignore class RandShiftIntensity(RandomizableTransform): @@ -208,6 +219,8 @@ class RandShiftIntensity(RandomizableTransform): Randomly shift intensity with randomly picked offset. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1) -> None: """ Args: @@ -229,7 +242,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) super().randomize(None) - def __call__(self, img: np.ndarray, factor: Optional[float] = None) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor, factor: Optional[float] = None) -> NdarrayOrTensor: """ Apply the transform to `img`. @@ -260,6 +273,8 @@ class StdShiftIntensity(Transform): dtype: output data type, defaults to float32. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, factor: float, nonzero: bool = False, channel_wise: bool = False, dtype: DtypeLike = np.float32 ) -> None: @@ -268,22 +283,30 @@ def __init__( self.channel_wise = channel_wise self.dtype = dtype - def _stdshift(self, img: np.ndarray) -> np.ndarray: - slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=bool) - if not np.any(slices): - return img - offset = self.factor * np.std(img[slices]) - img[slices] = img[slices] + offset + def _stdshift(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + ones: Callable + std: Callable + if isinstance(img, torch.Tensor): + ones = torch.ones + std = partial(torch.std, unbiased=False) + else: + ones = np.ones + std = np.std + + slices = (img != 0) if self.nonzero else ones(img.shape, dtype=bool) + if slices.any(): + offset = self.factor * std(img[slices]) + img[slices] = img[slices] + offset return img - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - img = img.astype(self.dtype) + img, *_ = convert_data_type(img, dtype=self.dtype) if self.channel_wise: for i, d in enumerate(img): - img[i] = self._stdshift(d) + img[i] = self._stdshift(d) # type: ignore else: img = self._stdshift(img) return img @@ -295,6 +318,8 @@ class RandStdShiftIntensity(RandomizableTransform): by: ``v = v + factor * std(v)`` where the `factor` is randomly picked. """ + backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, factors: Union[Tuple[float, float], float], @@ -329,7 +354,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) super().randomize(None) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ @@ -866,7 +891,7 @@ def __init__(self, window_length: int, order: int, axis: int = 1, mode: str = "z self.mode = mode self.img_t: torch.Tensor = torch.tensor(0.0) - def __call__(self, img: NdarrayTensor) -> torch.Tensor: + def __call__(self, img: NdarrayOrTensor) -> torch.Tensor: """ Args: img: array containing input data. Must be real and in shape [channels, spatial1, spatial2, ...]. @@ -875,7 +900,7 @@ def __call__(self, img: NdarrayTensor) -> torch.Tensor: array containing smoothed result. """ - self.img_t, *_ = convert_data_type(img, torch.Tensor) + self.img_t = convert_to_tensor(img) # add one to transform axis because a batch axis will be added at dimension 0 savgol_filter = SavitzkyGolayFilter(self.window_length, self.order, self.axis + 1, self.mode) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 7ca21432c5..522007df29 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -22,6 +22,7 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor +from monai.config.type_definitions import NdarrayOrTensor from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.intensity.array import ( AdjustContrast, @@ -33,6 +34,7 @@ MaskIntensity, NormalizeIntensity, RandBiasField, + RandGaussianNoise, RandKSpaceSpikeNoise, RandRicianNoise, ScaleIntensity, @@ -45,7 +47,6 @@ from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive from monai.utils import convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple -from monai.utils.enums import TransformBackends __all__ = [ "RandGaussianNoised", @@ -145,7 +146,7 @@ class RandGaussianNoised(RandomizableTransform, MapTransform): allow_missing_keys: don't raise exception if key is missing. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = RandGaussianNoise.backend def __init__( self, @@ -207,6 +208,8 @@ class RandRicianNoised(RandomizableTransform, MapTransform): allow_missing_keys: Don't raise exception if key is missing. """ + backend = RandRicianNoise.backend + def __init__( self, keys: KeysCollection, @@ -223,9 +226,11 @@ def __init__( RandomizableTransform.__init__(self, global_prob) self.rand_rician_noise = RandRicianNoise(prob, mean, std, channel_wise, relative, sample_std) - def __call__( - self, data: Mapping[Hashable, Union[torch.Tensor, np.ndarray]] - ) -> Dict[Hashable, Union[torch.Tensor, np.ndarray]]: + def set_random_state(self, seed=None, state=None): + super().set_random_state(seed, state) + self.rand_rician_noise.set_random_state(seed, state) + + def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: d = dict(data) super().randomize(None) if not self._do_transform: @@ -370,6 +375,8 @@ class StdShiftIntensityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.StdShiftIntensity`. """ + backend = StdShiftIntensity.backend + def __init__( self, keys: KeysCollection, @@ -393,7 +400,7 @@ def __init__( super().__init__(keys, allow_missing_keys) self.shifter = StdShiftIntensity(factor, nonzero, channel_wise, dtype) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.shifter(d[key]) @@ -405,6 +412,8 @@ class RandStdShiftIntensityd(RandomizableTransform, MapTransform): Dictionary-based version :py:class:`monai.transforms.RandStdShiftIntensity`. """ + backend = StdShiftIntensity.backend + def __init__( self, keys: KeysCollection, @@ -445,7 +454,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) super().randomize(None) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) self.randomize() if not self._do_transform: diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 59b4d64174..3ef6413090 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -23,6 +23,7 @@ import torch from monai.config import DtypeLike, NdarrayTensor +from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( extreme_points_to_image, @@ -299,7 +300,7 @@ def __init__(self, dtype=np.float32) -> None: """ self.dtype = dtype - def __call__(self, img: NdarrayTensor, dtype: Optional[Union[DtypeLike, torch.dtype]] = None) -> NdarrayTensor: + def __call__(self, img: NdarrayOrTensor, dtype: Optional[Union[DtypeLike, torch.dtype]] = None) -> NdarrayOrTensor: """ Apply the transform to `img`, assuming `img` is a numpy array or PyTorch Tensor. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 9f6680fdf5..a53e4f3235 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -24,6 +24,7 @@ import torch from monai.config import DtypeLike, KeysCollection, NdarrayTensor +from monai.config.type_definitions import NdarrayOrTensor from monai.data.utils import no_collation from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform @@ -58,7 +59,7 @@ ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.utils import convert_to_numpy, ensure_tuple, ensure_tuple_rep -from monai.utils.enums import InverseKeys, TransformBackends +from monai.utils.enums import InverseKeys __all__ = [ "AddChannelD", @@ -393,7 +394,7 @@ class CastToTyped(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.CastToType`. """ - backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = CastToType.backend def __init__( self, @@ -415,7 +416,7 @@ def __init__( self.dtype = ensure_tuple_rep(dtype, len(self.keys)) self.converter = CastToType() - def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, dtype in self.key_iterator(d, self.dtype): d[key] = self.converter(d[key], dtype=dtype) diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index 54c92c46ef..e6df607764 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -1,10 +1,10 @@ import re -from typing import Any, Optional, Sequence, Tuple, Type, Union, cast +from typing import Any, Optional, Sequence, Tuple, Union import numpy as np import torch -from monai.config.type_definitions import DtypeLike, NdarrayTensor +from monai.config.type_definitions import DtypeLike, NdarrayOrTensor from monai.utils import optional_import cp, has_cp = optional_import("cupy") @@ -147,10 +147,10 @@ def convert_to_numpy(data): def convert_data_type( data: Any, - output_type: Optional[Type[NdarrayTensor]] = None, + output_type: Optional[type] = None, device: Optional[torch.device] = None, dtype: Optional[Union[DtypeLike, torch.dtype]] = None, -) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: +) -> Tuple[NdarrayOrTensor, type, Optional[torch.device]]: """ Convert to `torch.Tensor`/`np.ndarray` from `torch.Tensor`/`np.ndarray`/`float`/`int` etc. @@ -175,20 +175,20 @@ def convert_data_type( if orig_type is not torch.Tensor: data = convert_to_tensor(data) if dtype != data.dtype: - data = data.to(dtype) # type: ignore + data = data.to(dtype) if device is not None: data = data.to(device) - return cast(NdarrayTensor, data), orig_type, orig_device # pytype: disable=invalid-annotation - if output_type is np.ndarray: + elif output_type is np.ndarray: if orig_type is not np.ndarray: data = convert_to_numpy(data) if data is not None and dtype != data.dtype: - data = data.astype(dtype) # type: ignore - return cast(NdarrayTensor, data), orig_type, orig_device # pytype: disable=invalid-annotation - raise ValueError(f"Unsupported output type: {output_type}") + data = data.astype(dtype) + else: + raise ValueError(f"Unsupported output type: {output_type}") + return data, orig_type, orig_device -def convert_to_dst_type(src: Any, dst: NdarrayTensor) -> Tuple[NdarrayTensor, type, Optional[torch.device]]: +def convert_to_dst_type(src: Any, dst: NdarrayOrTensor) -> Tuple[NdarrayOrTensor, type, Optional[torch.device]]: """ Convert `src` to the same `torch.Tensor`/`np.ndarray` and data type as `dst`. diff --git a/tests/test_rand_rician_noise.py b/tests/test_rand_rician_noise.py index 6504fd9069..7ec5fc4dc4 100644 --- a/tests/test_rand_rician_noise.py +++ b/tests/test_rand_rician_noise.py @@ -12,36 +12,25 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandRicianNoise -from tests.utils import NumpyImageTestCase2D, TorchImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D - -class TestRandRicianNoise(NumpyImageTestCase2D): - @parameterized.expand([("test_zero_mean", 0, 0.1), ("test_non_zero_mean", 1, 0.5)]) - def test_correct_results(self, _, mean, std): - seed = 0 - rician_fn = RandRicianNoise(prob=1.0, mean=mean, std=std) - rician_fn.set_random_state(seed) - noised = rician_fn(self.imt) - np.random.seed(seed) - np.random.random() - _std = np.random.uniform(0, std) - expected = np.sqrt( - (self.imt + np.random.normal(mean, _std, size=self.imt.shape)) ** 2 - + np.random.normal(mean, _std, size=self.imt.shape) ** 2 - ) - np.testing.assert_allclose(expected, noised, atol=1e-5) +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append(("test_zero_mean", p, 0, 0.1)) + TESTS.append(("test_non_zero_mean", p, 1, 0.5)) -class TestRandRicianNoiseTorch(TorchImageTestCase2D): - @parameterized.expand([("test_zero_mean", 0, 0.1), ("test_non_zero_mean", 1, 0.5)]) - def test_correct_results(self, _, mean, std): +class TestRandRicianNoise(NumpyImageTestCase2D): + @parameterized.expand(TESTS) + def test_correct_results(self, _, in_type, mean, std): seed = 0 rician_fn = RandRicianNoise(prob=1.0, mean=mean, std=std) rician_fn.set_random_state(seed) - noised = rician_fn(self.imt) + noised = rician_fn(in_type(self.imt)) np.random.seed(seed) np.random.random() _std = np.random.uniform(0, std) @@ -49,6 +38,8 @@ def test_correct_results(self, _, mean, std): (self.imt + np.random.normal(mean, _std, size=self.imt.shape)) ** 2 + np.random.normal(mean, _std, size=self.imt.shape) ** 2 ) + if isinstance(noised, torch.Tensor): + noised = noised.cpu() np.testing.assert_allclose(expected, noised, atol=1e-5) diff --git a/tests/test_rand_rician_noised.py b/tests/test_rand_rician_noised.py index 3dbfce154d..010bbcb310 100644 --- a/tests/test_rand_rician_noised.py +++ b/tests/test_rand_rician_noised.py @@ -12,48 +12,37 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandRicianNoised -from tests.utils import NumpyImageTestCase2D, TorchImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D -TEST_CASE_0 = ["test_zero_mean", ["img1", "img2"], 0, 0.1] -TEST_CASE_1 = ["test_non_zero_mean", ["img1", "img2"], 1, 0.5] -TEST_CASES = [TEST_CASE_0, TEST_CASE_1] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append(["test_zero_mean", p, ["img1", "img2"], 0, 0.1]) + TESTS.append(["test_non_zero_mean", p, ["img1", "img2"], 1, 0.5]) seed = 0 -def test_numpy_or_torch(keys, mean, std, imt): - rician_fn = RandRicianNoised(keys=keys, global_prob=1.0, prob=1.0, mean=mean, std=std) - rician_fn.set_random_state(seed) - rician_fn.rand_rician_noise.set_random_state(seed) - noised = rician_fn({k: imt for k in keys}) - np.random.seed(seed) - np.random.random() - np.random.seed(seed) - for k in keys: - np.random.random() - _std = np.random.uniform(0, std) - expected = np.sqrt( - (imt + np.random.normal(mean, _std, size=imt.shape)) ** 2 - + np.random.normal(mean, _std, size=imt.shape) ** 2 - ) - np.testing.assert_allclose(expected, noised[k], atol=1e-5, rtol=1e-5) - - -# Test with numpy class TestRandRicianNoisedNumpy(NumpyImageTestCase2D): - @parameterized.expand(TEST_CASES) - def test_correct_results(self, _, keys, mean, std): - test_numpy_or_torch(keys, mean, std, self.imt) - - -# Test with torch -class TestRandRicianNoisedTorch(TorchImageTestCase2D): - @parameterized.expand(TEST_CASES) - def test_correct_results(self, _, keys, mean, std): - test_numpy_or_torch(keys, mean, std, self.imt) + @parameterized.expand(TESTS) + def test_correct_results(self, _, in_type, keys, mean, std): + rician_fn = RandRicianNoised(keys=keys, global_prob=1.0, prob=1.0, mean=mean, std=std) + rician_fn.set_random_state(seed) + noised = rician_fn({k: in_type(self.imt) for k in keys}) + np.random.seed(seed) + for k in keys: + np.random.random() + _std = np.random.uniform(0, std) + expected = np.sqrt( + (self.imt + np.random.normal(mean, _std, size=self.imt.shape)) ** 2 + + np.random.normal(mean, _std, size=self.imt.shape) ** 2 + ) + if isinstance(noised[k], torch.Tensor): + noised[k] = noised[k].cpu() + np.testing.assert_allclose(expected, noised[k], atol=1e-5, rtol=1e-5) if __name__ == "__main__": diff --git a/tests/test_rand_std_shift_intensity.py b/tests/test_rand_std_shift_intensity.py index 9aff50ab66..0c6382555e 100644 --- a/tests/test_rand_std_shift_intensity.py +++ b/tests/test_rand_std_shift_intensity.py @@ -12,20 +12,23 @@ import unittest import numpy as np +import torch from monai.transforms import RandStdShiftIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D class TestRandStdShiftIntensity(NumpyImageTestCase2D): def test_value(self): - shifter = RandStdShiftIntensity(factors=1.0, prob=1.0) - shifter.set_random_state(seed=0) - result = shifter(self.imt) - np.random.seed(0) - factor = np.random.uniform(low=-1.0, high=1.0) - expected = self.imt + factor * np.std(self.imt) - np.testing.assert_allclose(result, expected, rtol=1e-5) + for p in TEST_NDARRAYS: + np.random.seed(0) + factor = np.random.uniform(low=-1.0, high=1.0) + offset = factor * np.std(self.imt) + expected = p(self.imt + offset) + shifter = RandStdShiftIntensity(factors=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter(p(self.imt)) + torch.testing.assert_allclose(result, expected, atol=0, rtol=1e-5) if __name__ == "__main__": diff --git a/tests/test_rand_std_shift_intensityd.py b/tests/test_rand_std_shift_intensityd.py index 0cb6bd66be..0ab017a42d 100644 --- a/tests/test_rand_std_shift_intensityd.py +++ b/tests/test_rand_std_shift_intensityd.py @@ -12,21 +12,25 @@ import unittest import numpy as np +import torch from monai.transforms import RandStdShiftIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D class TestRandStdShiftIntensityd(NumpyImageTestCase2D): def test_value(self): - key = "img" - shifter = RandStdShiftIntensityd(keys=[key], factors=1.0, prob=1.0) - shifter.set_random_state(seed=0) - result = shifter({key: self.imt}) - np.random.seed(0) - factor = np.random.uniform(low=-1.0, high=1.0) - expected = self.imt + factor * np.std(self.imt) - np.testing.assert_allclose(result[key], expected, rtol=1e-5) + for p in TEST_NDARRAYS: + key = "img" + np.random.seed(0) + factor = np.random.uniform(low=-1.0, high=1.0) + expected = self.imt + factor * np.std(self.imt) + shifter = RandStdShiftIntensityd(keys=[key], factors=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter({key: p(self.imt)})[key] + if isinstance(result, torch.Tensor): + result = result.cpu() + np.testing.assert_allclose(result, expected, rtol=1e-5) if __name__ == "__main__": diff --git a/tests/test_std_shift_intensity.py b/tests/test_std_shift_intensity.py index f317330435..5c16e14c45 100644 --- a/tests/test_std_shift_intensity.py +++ b/tests/test_std_shift_intensity.py @@ -12,45 +12,53 @@ import unittest import numpy as np +import torch from monai.transforms import ShiftIntensity, StdShiftIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D class TestStdShiftIntensity(NumpyImageTestCase2D): def test_value(self): - factor = np.random.rand() - offset = np.std(self.imt) * factor - shifter = ShiftIntensity(offset=offset) - expected = shifter(self.imt) - std_shifter = StdShiftIntensity(factor=factor) - result = std_shifter(self.imt) - np.testing.assert_allclose(result, expected, rtol=1e-5) + for p in TEST_NDARRAYS: + imt = p(self.imt) + factor = np.random.rand() + offset = np.std(self.imt) * factor + shifter = ShiftIntensity(offset=offset) + expected = shifter(imt) + std_shifter = StdShiftIntensity(factor=factor) + result = std_shifter(imt) + torch.testing.assert_allclose(result, expected, atol=0, rtol=1e-5) def test_zerostd(self): - image = np.ones([2, 3, 3]) - for nonzero in [True, False]: - for channel_wise in [True, False]: - factor = np.random.rand() - std_shifter = StdShiftIntensity(factor=factor, nonzero=nonzero, channel_wise=channel_wise) - result = std_shifter(image) - np.testing.assert_allclose(result, image, rtol=1e-5) + for p in TEST_NDARRAYS: + image = p(np.ones([2, 3, 3], dtype=np.float32)) + for nonzero in [True, False]: + for channel_wise in [True, False]: + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, nonzero=nonzero, channel_wise=channel_wise) + result = std_shifter(image) + torch.testing.assert_allclose(result, image, atol=0, rtol=1e-5) def test_nonzero(self): - image = np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]]) # std = 1 - factor = np.random.rand() - std_shifter = StdShiftIntensity(factor=factor, nonzero=True) - result = std_shifter(image) - expected = np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]]) - np.testing.assert_allclose(result, expected, rtol=1e-5) + for p in TEST_NDARRAYS: + image = p(np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]])) # std = 1 + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, nonzero=True) + result = std_shifter(image) + expected = p(np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]], dtype=np.float32)) + torch.testing.assert_allclose(result, expected, atol=0, rtol=1e-5) def test_channel_wise(self): - image = np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0]))) # std: 0.5, 0 - factor = np.random.rand() - std_shifter = StdShiftIntensity(factor=factor, channel_wise=True) - result = std_shifter(image) - expected = np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1]))) - np.testing.assert_allclose(result, expected, rtol=1e-5) + for p in TEST_NDARRAYS: + image = p(np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0])))) # std: 0.5, 0 + factor = np.random.rand() + std_shifter = StdShiftIntensity(factor=factor, channel_wise=True) + result = std_shifter(image) + expected = p( + np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1]))).astype(np.float32) + ) + torch.testing.assert_allclose(result, expected, atol=0, rtol=1e-5) def test_dtype(self): trans_dtype = np.float32 From aad8bb2a3fdd4237b920883f1d63c43f8df77d31 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 23 Aug 2021 22:49:36 +0100 Subject: [PATCH 55/89] sunset nightly tests (#2820) Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index c3d8a4c3b1..9803d459dc 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -1,8 +1,8 @@ name: crons on: - schedule: - - cron: "0 2 * * *" # at 02:00 UTC + # schedule: + # - cron: "0 2 * * *" # at 02:00 UTC # Allows you to run this workflow manually from the Actions tab workflow_dispatch: From 42ad892979fdea98279cccb706602bfa9d46adc5 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 24 Aug 2021 07:28:57 -0400 Subject: [PATCH 56/89] NVTX Range universal decorator (#2814) * Implement NVTX Range decorator and context manager Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Implement unittests for nvtx range Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update utils init Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove Range transform in favor of Range decorator Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docstrings Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update description Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Change torch testing to numpy testing Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Handle special methods Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Change typing Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * REmove torch.nn Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update method resolution logic Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update tests Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Change _call_impl to __call__ Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove debugging outputs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/transforms.rst | 8 -- docs/source/utils.rst | 6 + monai/transforms/__init__.py | 8 -- monai/transforms/nvtx.py | 43 ------- monai/utils/__init__.py | 1 + monai/utils/nvtx.py | 149 ++++++++++++++++++++++ tests/test_nvtx_decorator.py | 237 +++++++++++++++++++++++++++++++++++ tests/test_nvtx_transform.py | 60 --------- 8 files changed, 393 insertions(+), 119 deletions(-) create mode 100644 monai/utils/nvtx.py create mode 100644 tests/test_nvtx_decorator.py diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 9a538aae82..da8ceda0e3 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -356,14 +356,6 @@ NVIDIA Tool Extension (NVTX) """""""""""""" .. autoclass:: RandRangePop -`Range` -""""""" -.. autoclass:: Range - -`RandRange` -""""""""""" -.. autoclass:: RandRange - `Mark` """""" .. autoclass:: Mark diff --git a/docs/source/utils.rst b/docs/source/utils.rst index ecb8daffdc..a9aea7932b 100644 --- a/docs/source/utils.rst +++ b/docs/source/utils.rst @@ -29,6 +29,12 @@ Misc :members: +NVTX Annotations +---------------- +.. automodule:: monai.utils.nvtx + :members: + + Profiling --------- .. automodule:: monai.utils.profiling diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index f409c0bd8c..4203724a7d 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -204,10 +204,6 @@ RandMarkd, RandMarkD, RandMarkDict, - RandRange, - RandRanged, - RandRangeD, - RandRangeDict, RandRangePop, RandRangePopd, RandRangePopD, @@ -216,10 +212,6 @@ RandRangePushd, RandRangePushD, RandRangePushDict, - Range, - Ranged, - RangeD, - RangeDict, RangePop, RangePopd, RangePopD, diff --git a/monai/transforms/nvtx.py b/monai/transforms/nvtx.py index a500eb3c90..6dd5c3b0a3 100644 --- a/monai/transforms/nvtx.py +++ b/monai/transforms/nvtx.py @@ -12,8 +12,6 @@ Wrapper around NVIDIA Tools Extension for profiling MONAI transformations """ -from typing import Optional - from monai.transforms.transform import RandomizableTransform, Transform from monai.utils import optional_import @@ -28,10 +26,6 @@ "RandMarkd", "RandMarkD", "RandMarkDict", - "RandRange", - "RandRanged", - "RandRangeD", - "RandRangeDict", "RandRangePop", "RandRangePopd", "RandRangePopD", @@ -40,10 +34,6 @@ "RandRangePushd", "RandRangePushD", "RandRangePushDict", - "Range", - "Ranged", - "RangeD", - "RangeDict", "RangePop", "RangePopd", "RangePopD", @@ -101,36 +91,6 @@ class RandRangePop(RangePop, RandomizableTransform): """ -class Range(Transform): - """ - Pushes an NVTX range before a transform, and pops it afterwards. - Stores zero-based depth of the range that is started. - - Args: - msg: ASCII message to associate with range - """ - - def __init__(self, transform: Transform, msg: Optional[str] = None) -> None: - if msg is None: - msg = type(transform).__name__ - self.msg = msg - self.transform = transform - self.depth = None - - def __call__(self, data): - self.depth = _nvtx.rangePushA(self.msg) - data = self.transform(data) - _nvtx.rangePop() - return data - - -class RandRange(Range, RandomizableTransform): - """ - Pushes an NVTX range at the before a transfrom, and pops it afterwards.(RandomizableTransform). - Stores zero-based depth of the range that is ended. - """ - - class Mark(Transform): """ Mark an instantaneous event that occurred at some point. @@ -163,8 +123,5 @@ class RandMark(Mark, RandomizableTransform): RangePopDict = RangePopD = RangePopd = RangePop RandRangePopDict = RandRangePopD = RandRangePopd = RandRangePop -RangeDict = RangeD = Ranged = Range -RandRangeDict = RandRangeD = RandRanged = RandRange - MarkDict = MarkD = Markd = Mark RandMarkDict = RandMarkD = RandMarkd = RandMark diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index dd300fce34..0ea5afc40c 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -71,6 +71,7 @@ optional_import, version_leq, ) +from .nvtx import Range from .profiling import PerfContext, torch_profiler_full, torch_profiler_time_cpu_gpu, torch_profiler_time_end_to_end from .state_cacher import StateCacher from .type_conversion import ( diff --git a/monai/utils/nvtx.py b/monai/utils/nvtx.py new file mode 100644 index 0000000000..c2f0acd97f --- /dev/null +++ b/monai/utils/nvtx.py @@ -0,0 +1,149 @@ +# Copyright 2020 - 2021 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. +""" +Decorators and context managers for NVIDIA Tools Extension to profile MONAI components +""" + +from collections import defaultdict +from functools import wraps +from typing import Any, Optional, Tuple, Union + +from torch.autograd import Function +from torch.nn import Module +from torch.optim import Optimizer +from torch.utils.data import DataLoader, Dataset + +# from monai.transforms.transform import Transform +from monai.utils import ensure_tuple, optional_import + +_nvtx, _ = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") + +__all__ = ["Range"] + + +class Range: + """ + A decorator and context manager for NVIDIA Tools Extension (NVTX) Range for profiling. + When used as a decorator it encloses a specific method of the object with an NVTX Range. + When used as a context manager, it encloses the runtime context (created by with statement) with an NVTX Range. + + Args: + name: the name to be associated to the range + methods: (only when used as decorator) the name of a method (or a list of the name of the methods) + to be wrapped by NVTX range. + If None (default), the method(s) will be inferred based on the object's type for various MONAI components, + such as Networks, Losses, Optimizers, Functions, Transforms, Datasets, and Dataloaders. + Otherwise, it look up predefined methods: "forward", "__call__", "__next__", "__getitem__" + append_method_name: if append the name of the methods to be decorated to the range's name + If None (default), it appends the method's name only if we are annotating more than one method. + + """ + + name_counter: dict = defaultdict(int) + + def __init__( + self, + name: Optional[str] = None, + methods: Optional[Union[str, Tuple[str, ...]]] = None, + append_method_name: Optional[bool] = None, + ) -> None: + self.name = name + self.methods = methods + self.append_method_name = append_method_name + + def __call__(self, obj: Any): + # Define the name to be associated to the range if not provided + if self.name is None: + name = type(obj).__name__ + self.name_counter[name] += 1 + self.name = f"{name}_{self.name_counter[name]}" + + # Define the methods to be wrapped if not provided + if self.methods is None: + self.methods = self._get_method(obj) + else: + self.methods = ensure_tuple(self.methods) + + # Check if to append method's name to the range's name + if self.append_method_name is None: + if len(self.methods) > 1: + self.append_method_name = True + else: + self.append_method_name = False + + # Decorate the methods + for method in self.methods: + self._decorate_method(obj, method, self.append_method_name) + + return obj + + def _decorate_method(self, obj, method, append_method_name): + # Append the method's name to the range's name + if append_method_name: + name = f"{self.name}.{method}" + else: + name = self.name + + # Get the class for special functions + if method.startswith("_"): + owner = type(obj) + else: + owner = obj + + # Get the method to be wrapped + _temp_func = getattr(owner, method) + + # Wrap the method with NVTX range (range push/pop) + @wraps(_temp_func) + def range_wrapper(*args, **kwargs): + _nvtx.rangePushA(name) + output = _temp_func(*args, **kwargs) + _nvtx.rangePop() + return output + + # Replace the method with the wrapped version + setattr(owner, method, range_wrapper) + + def _get_method(self, obj: Any) -> tuple: + if isinstance(obj, Module): + method_list = ["forward", "__call__"] + elif isinstance(obj, Optimizer): + method_list = ["step"] + elif isinstance(obj, Function): + method_list = ["forward", "backward"] + elif isinstance(obj, Dataset): + method_list = ["__getitem__"] + elif isinstance(obj, DataLoader): + method_list = ["_next_data"] + else: + default_methods = ["forward", "__call__", "__next__", "__getitem__"] + method_list = [] + for method in default_methods: + if hasattr(obj, method): + method_list.append(method) + if len(method_list) < 1: + raise ValueError( + f"The method to be wrapped for this object [{type(obj)}] is not recognized." + "The name of the method should be provied or the object should have one of these methods:" + f"{default_methods}" + ) + return ensure_tuple(method_list) + + def __enter__(self): + if self.name is None: + # Number the range with class variable counter to avoid duplicate names. + self.name_counter["context"] += 1 + self.name = f"context_{self.name_counter['context']}" + + _nvtx.rangePushA(self.name) + + def __exit__(self, type, value, traceback): + _nvtx.rangePop() diff --git a/tests/test_nvtx_decorator.py b/tests/test_nvtx_decorator.py new file mode 100644 index 0000000000..e2a9ad67b8 --- /dev/null +++ b/tests/test_nvtx_decorator.py @@ -0,0 +1,237 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.transforms import ( + Compose, + Flip, + FlipD, + RandAdjustContrast, + RandFlip, + Randomizable, + Rotate90, + ToTensor, + ToTensorD, +) +from monai.utils import Range, optional_import + +_, has_nvtx = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") + + +TEST_CASE_ARRAY_0 = [ + np.random.randn(3, 3), +] +TEST_CASE_ARRAY_1 = [ + np.random.randn(3, 10, 10), +] + +TEST_CASE_DICT_0 = [ + {"image": np.random.randn(3, 3)}, +] +TEST_CASE_DICT_1 = [ + {"image": np.random.randn(3, 10, 10)}, +] + +TEST_CASE_TORCH_0 = [ + torch.randn(3, 3), +] +TEST_CASE_TORCH_1 = [ + torch.randn(3, 10, 10), +] + + +class TestNVTXRangeDecorator(unittest.TestCase): + @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_tranform_array(self, input): + transforms = Compose( + [ + Range("random flip")(Flip()), + Range()(ToTensor()), + ] + ) + # Apply transforms + output = transforms(input) + + # Decorate with NVTX Range + transforms1 = Range()(transforms) + transforms2 = Range("Transforms2")(transforms) + transforms3 = Range(name="Transforms3", methods="__call__")(transforms) + + # Apply transforms with Range + output1 = transforms1(input) + output2 = transforms2(input) + output3 = transforms3(input) + + # Check the outputs + self.assertIsInstance(output, torch.Tensor) + self.assertIsInstance(output1, torch.Tensor) + self.assertIsInstance(output2, torch.Tensor) + self.assertIsInstance(output3, torch.Tensor) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output3.numpy()) + + @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_tranform_dict(self, input): + transforms = Compose( + [ + Range("random flip dict")(FlipD(keys="image")), + Range()(ToTensorD("image")), + ] + ) + # Apply transforms + output = transforms(input)["image"] + + # Decorate with NVTX Range + transforms1 = Range()(transforms) + transforms2 = Range("Transforms2")(transforms) + transforms3 = Range(name="Transforms3", methods="__call__")(transforms) + + # Apply transforms with Range + output1 = transforms1(input)["image"] + output2 = transforms2(input)["image"] + output3 = transforms3(input)["image"] + + # Check the outputs + self.assertIsInstance(output, torch.Tensor) + self.assertIsInstance(output1, torch.Tensor) + self.assertIsInstance(output2, torch.Tensor) + self.assertIsInstance(output3, torch.Tensor) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output2.numpy()) + np.testing.assert_equal(output.numpy(), output3.numpy()) + + @parameterized.expand([TEST_CASE_ARRAY_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_tranform_randomized(self, input): + # Compose deterministic and randomized transforms + transforms = Compose( + [ + Range("flip")(Flip()), + Rotate90(), + Range()(RandAdjustContrast(prob=0.0)), + Range("random flip")(RandFlip(prob=1.0)), + ToTensor(), + ] + ) + # Apply transforms + output = transforms(input) + + # Decorate with NVTX Range + transforms1 = Range()(transforms) + transforms2 = Range("Transforms2")(transforms) + transforms3 = Range(name="Transforms3", methods="__call__")(transforms) + + # Apply transforms with Range + output1 = transforms1(input) + output2 = transforms2(input) + output3 = transforms3(input) + + # Check if the outputs are equal + self.assertIsInstance(output, torch.Tensor) + self.assertIsInstance(output1, torch.Tensor) + self.assertIsInstance(output2, torch.Tensor) + self.assertIsInstance(output3, torch.Tensor) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output2.numpy()) + np.testing.assert_equal(output.numpy(), output3.numpy()) + + # Check if the first randomized is RandAdjustContrast + for tran in transforms.transforms: + if isinstance(tran, Randomizable): + self.assertIsInstance(tran, RandAdjustContrast) + break + + @parameterized.expand([TEST_CASE_TORCH_0, TEST_CASE_TORCH_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_network(self, input): + # Create a network + model = torch.nn.Sequential( + torch.nn.ReLU(), + torch.nn.Sigmoid(), + ) + + # Forward + output = model(input) + + # Decorate with NVTX Range + model1 = Range()(model) + model2 = Range("Model2")(model) + model3 = Range(name="Model3", methods="forward")(model) + + # Forward with Range + output1 = model1(input) + output2 = model2(input) + output3 = model3(input) + + # Check the outputs + self.assertIsInstance(output, torch.Tensor) + self.assertIsInstance(output1, torch.Tensor) + self.assertIsInstance(output2, torch.Tensor) + self.assertIsInstance(output3, torch.Tensor) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output2.numpy()) + np.testing.assert_equal(output.numpy(), output3.numpy()) + + @parameterized.expand([TEST_CASE_TORCH_0, TEST_CASE_TORCH_1]) + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_loss(self, input): + # Create a network and loss + model = torch.nn.Sigmoid() + loss = torch.nn.BCELoss() + pred = model(input) + target = torch.empty_like(input).random_(2) + + # Loss evaluation + output = loss(pred, target) + + # Decorate with NVTX Range + loss1 = Range()(loss) + loss2 = Range("Loss2")(loss) + loss3 = Range(name="Loss3", methods="forward")(loss) + + # Loss evaluation with Range + output1 = loss1(pred, target) + output2 = loss2(pred, target) + output3 = loss3(pred, target) + + # Check the outputs + self.assertIsInstance(output, torch.Tensor) + self.assertIsInstance(output1, torch.Tensor) + self.assertIsInstance(output2, torch.Tensor) + self.assertIsInstance(output3, torch.Tensor) + np.testing.assert_equal(output.numpy(), output1.numpy()) + np.testing.assert_equal(output.numpy(), output2.numpy()) + np.testing.assert_equal(output.numpy(), output3.numpy()) + + @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX Range!") + def test_context_manager(self): + model = torch.nn.Sigmoid() + loss = torch.nn.BCELoss() + + with Range(): + input = torch.randn(3, requires_grad=True) + target = torch.empty(3).random_(2) + + with Range("Model"): + output = loss(model(input), target) + output.backward() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nvtx_transform.py b/tests/test_nvtx_transform.py index 01f2e80d26..6bcfe00078 100644 --- a/tests/test_nvtx_transform.py +++ b/tests/test_nvtx_transform.py @@ -21,14 +21,10 @@ MarkD, RandMark, RandMarkD, - RandRange, - RandRangeD, RandRangePop, RandRangePopD, RandRangePush, RandRangePushD, - Range, - RangeD, RangePop, RangePopD, RangePush, @@ -155,62 +151,6 @@ def test_nvtx_transfroms_dict(self, input): self.assertIsInstance(output["image"], torch.Tensor) np.testing.assert_array_equal(input["image"], Flip()(output["image"].numpy())) - @parameterized.expand([TEST_CASE_ARRAY_0, TEST_CASE_ARRAY_1]) - @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") - def test_nvtx_range_array(self, input): - # with prob == 0.0 - transforms = Compose( - [ - RandMark("Mark: Transforms (p=0) Start!"), - RandRange(RandFlip(prob=0.0)), - Range(ToTensor()), - Mark("Mark: Transforms (p=0) End!"), - ] - ) - output = transforms(input) - self.assertIsInstance(output, torch.Tensor) - np.testing.assert_array_equal(input, output) - # with prob == 1.0 - transforms = Compose( - [ - RandMark("Mark: Transforms (p=1) Start!"), - RandRange(RandFlip(prob=1.0)), - Range(ToTensor()), - Mark("Mark: Transforms (p=1) End!"), - ] - ) - output = transforms(input) - self.assertIsInstance(output, torch.Tensor) - np.testing.assert_array_equal(input, Flip()(output.numpy())) - - @parameterized.expand([TEST_CASE_DICT_0, TEST_CASE_DICT_1]) - @unittest.skipUnless(has_nvtx, "CUDA is required for NVTX!") - def test_nvtx_range_dict(self, input): - # with prob == 0.0 - transforms = Compose( - [ - RandMarkD("Mark: Transforms (p=0) Start!"), - RandRangeD(RandFlipD(keys="image", prob=0.0)), - RangeD(ToTensorD(keys=("image"))), - MarkD("Mark: Transforms (p=0) End!"), - ] - ) - output = transforms(input) - self.assertIsInstance(output["image"], torch.Tensor) - np.testing.assert_array_equal(input["image"], output["image"]) - # with prob == 1.0 - transforms = Compose( - [ - RandMarkD("Mark: Transforms (p=1) Start!"), - RandRangeD(RandFlipD(keys="image", prob=1.0)), - RangeD(ToTensorD(keys=("image"))), - MarkD("Mark: Transforms (p=1) End!"), - ] - ) - output = transforms(input) - self.assertIsInstance(output["image"], torch.Tensor) - np.testing.assert_array_equal(input["image"], Flip()(output["image"].numpy())) - if __name__ == "__main__": unittest.main() From fab8467c1b95a557cae6b287963b29f3b6d07ccd Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 24 Aug 2021 14:21:43 +0100 Subject: [PATCH 57/89] Torch flip (#2822) torch transforms - RandRicianNoise, StdShiftIntensity, RandStdShiftIntensity --- monai/transforms/inverse.py | 5 ++--- monai/transforms/spatial/array.py | 23 ++++++++++++++------ monai/transforms/spatial/dictionary.py | 30 ++++++++++++-------------- tests/test_flip.py | 20 +++++++++++------ tests/test_flipd.py | 20 ++++++++++------- tests/test_rand_axis_flip.py | 20 ++++++++++------- tests/test_rand_axis_flipd.py | 20 ++++++++++------- tests/test_rand_flip.py | 20 +++++++++++------ tests/test_rand_flipd.py | 20 ++++++++++------- tests/test_testtimeaugmentation.py | 14 ++++++------ 10 files changed, 114 insertions(+), 78 deletions(-) diff --git a/monai/transforms/inverse.py b/monai/transforms/inverse.py index 5d6b4d87fd..58f3526086 100644 --- a/monai/transforms/inverse.py +++ b/monai/transforms/inverse.py @@ -9,9 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Dict, Hashable, Optional, Tuple +from typing import Hashable, Optional, Tuple -import numpy as np import torch from monai.transforms.transform import RandomizableTransform, Transform @@ -113,7 +112,7 @@ def pop_transform(self, data: dict, key: Hashable) -> None: """Remove most recent transform.""" data[str(key) + InverseKeys.KEY_SUFFIX].pop() - def inverse(self, data: dict) -> Dict[Hashable, np.ndarray]: + def inverse(self, data: dict) -> dict: """ Inverse of ``__call__``. diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 86f0e84249..a7d93f88f3 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -20,6 +20,7 @@ import torch from monai.config import USE_COMPILED, DtypeLike +from monai.config.type_definitions import NdarrayOrTensor from monai.data.utils import compute_shape_offset, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.transforms.croppad.array import CenterSpatialCrop @@ -45,6 +46,7 @@ issequenceiterable, optional_import, ) +from monai.utils.enums import TransformBackends from monai.utils.module import look_up_option nib, _ = optional_import("nibabel") @@ -317,17 +319,20 @@ class Flip(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: self.spatial_axis = spatial_axis - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), """ - - result: np.ndarray = np.flip(img, map_spatial_axes(img.ndim, self.spatial_axis)) - return result.astype(img.dtype) + if isinstance(img, np.ndarray): + return np.ascontiguousarray(np.flip(img, map_spatial_axes(img.ndim, self.spatial_axis))) + else: + return torch.flip(img, map_spatial_axes(img.ndim, self.spatial_axis)) class Resize(Transform): @@ -800,11 +805,13 @@ class RandFlip(RandomizableTransform): spatial_axis: Spatial axes along which to flip over. Default is None. """ + backend = Flip.backend + def __init__(self, prob: float = 0.1, spatial_axis: Optional[Union[Sequence[int], int]] = None) -> None: RandomizableTransform.__init__(self, prob) self.flipper = Flip(spatial_axis=spatial_axis) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), @@ -826,15 +833,17 @@ class RandAxisFlip(RandomizableTransform): """ + backend = Flip.backend + def __init__(self, prob: float = 0.1) -> None: RandomizableTransform.__init__(self, prob) self._axis: Optional[int] = None - def randomize(self, data: np.ndarray) -> None: + def randomize(self, data: NdarrayOrTensor) -> None: super().randomize(None) self._axis = self.R.randint(data.ndim - 1) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Args: img: channel first array, must have shape: (num_channels, H[, W, ..., ]), diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d953fd63ea..b0558a6556 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -23,6 +23,7 @@ import torch from monai.config import DtypeLike, KeysCollection +from monai.config.type_definitions import NdarrayOrTensor from monai.networks.layers import AffineTransform from monai.networks.layers.simplelayers import GaussianFilter from monai.transforms.croppad.array import CenterSpatialCrop, SpatialPad @@ -1128,6 +1129,8 @@ class Flipd(MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ + backend = Flip.backend + def __init__( self, keys: KeysCollection, @@ -1137,20 +1140,17 @@ def __init__( super().__init__(keys, allow_missing_keys) self.flipper = Flip(spatial_axis=spatial_axis) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): self.push_transform(d, key) d[key] = self.flipper(d[key]) return d - def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): _ = self.get_most_recent_transform(d, key) - # Might need to convert to numpy - if isinstance(d[key], torch.Tensor): - d[key] = torch.Tensor(d[key]).cpu().numpy() # Inverse is same as forward d[key] = self.flipper(d[key]) # Remove the applied transform @@ -1173,6 +1173,8 @@ class RandFlipd(RandomizableTransform, MapTransform, InvertibleTransform): allow_missing_keys: don't raise exception if key is missing. """ + backend = Flip.backend + def __init__( self, keys: KeysCollection, @@ -1186,7 +1188,7 @@ def __init__( self.flipper = Flip(spatial_axis=spatial_axis) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: self.randomize(None) d = dict(data) for key in self.key_iterator(d): @@ -1195,15 +1197,12 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.push_transform(d, key) return d - def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) if transform[InverseKeys.DO_TRANSFORM]: - # Might need to convert to numpy - if isinstance(d[key], torch.Tensor): - d[key] = torch.Tensor(d[key]).cpu().numpy() # Inverse is same as forward d[key] = self.flipper(d[key]) # Remove the applied transform @@ -1225,16 +1224,18 @@ class RandAxisFlipd(RandomizableTransform, MapTransform, InvertibleTransform): """ + backend = Flip.backend + def __init__(self, keys: KeysCollection, prob: float = 0.1, allow_missing_keys: bool = False) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) self._axis: Optional[int] = None - def randomize(self, data: np.ndarray) -> None: + def randomize(self, data: NdarrayOrTensor) -> None: super().randomize(None) self._axis = self.R.randint(data.ndim - 1) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: self.randomize(data=data[self.keys[0]]) flipper = Flip(spatial_axis=self._axis) @@ -1245,16 +1246,13 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda self.push_transform(d, key, extra_info={"axis": self._axis}) return d - def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): transform = self.get_most_recent_transform(d, key) # Check if random transform was actually performed (based on `prob`) if transform[InverseKeys.DO_TRANSFORM]: flipper = Flip(spatial_axis=transform[InverseKeys.EXTRA_INFO]["axis"]) - # Might need to convert to numpy - if isinstance(d[key], torch.Tensor): - d[key] = torch.Tensor(d[key]).cpu().numpy() # Inverse is same as forward d[key] = flipper(d[key]) # Remove the applied transform diff --git a/tests/test_flip.py b/tests/test_flip.py index fe169c4da8..bd0162fb8b 100644 --- a/tests/test_flip.py +++ b/tests/test_flip.py @@ -12,10 +12,11 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import Flip -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -31,12 +32,17 @@ def test_invalid_inputs(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - flip = Flip(spatial_axis=spatial_axis) - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, spatial_axis)) - expected = np.stack(expected) - self.assertTrue(np.allclose(expected, flip(self.imt[0]))) + for p in TEST_NDARRAYS: + im = p(self.imt[0]) + flip = Flip(spatial_axis=spatial_axis) + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, spatial_axis)) + expected = np.stack(expected) + result = flip(im) + if isinstance(result, torch.Tensor): + result = result.cpu() + self.assertTrue(np.allclose(expected, result)) if __name__ == "__main__": diff --git a/tests/test_flipd.py b/tests/test_flipd.py index b8996dee42..cec4a99cbf 100644 --- a/tests/test_flipd.py +++ b/tests/test_flipd.py @@ -12,10 +12,11 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import Flipd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -31,13 +32,16 @@ def test_invalid_cases(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - flip = Flipd(keys="img", spatial_axis=spatial_axis) - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, spatial_axis)) - expected = np.stack(expected) - res = flip({"img": self.imt[0]}) - assert np.allclose(expected, res["img"]) + for p in TEST_NDARRAYS: + flip = Flipd(keys="img", spatial_axis=spatial_axis) + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, spatial_axis)) + expected = np.stack(expected) + result = flip({"img": p(self.imt[0])})["img"] + if isinstance(result, torch.Tensor): + result = result.cpu() + assert np.allclose(expected, result) if __name__ == "__main__": diff --git a/tests/test_rand_axis_flip.py b/tests/test_rand_axis_flip.py index 0bc2eb130e..bd53fa1fb0 100644 --- a/tests/test_rand_axis_flip.py +++ b/tests/test_rand_axis_flip.py @@ -12,20 +12,24 @@ import unittest import numpy as np +import torch from monai.transforms import RandAxisFlip -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D class TestRandAxisFlip(NumpyImageTestCase2D): def test_correct_results(self): - flip = RandAxisFlip(prob=1.0) - result = flip(self.imt[0]) - - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, flip._axis)) - self.assertTrue(np.allclose(np.stack(expected), result)) + for p in TEST_NDARRAYS: + flip = RandAxisFlip(prob=1.0) + result = flip(p(self.imt[0])) + if isinstance(result, torch.Tensor): + result = result.cpu() + + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, flip._axis)) + self.assertTrue(np.allclose(np.stack(expected), result)) if __name__ == "__main__": diff --git a/tests/test_rand_axis_flipd.py b/tests/test_rand_axis_flipd.py index 154d7813cb..518d78dd29 100644 --- a/tests/test_rand_axis_flipd.py +++ b/tests/test_rand_axis_flipd.py @@ -12,20 +12,24 @@ import unittest import numpy as np +import torch from monai.transforms import RandAxisFlipd -from tests.utils import NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase3D class TestRandAxisFlip(NumpyImageTestCase3D): def test_correct_results(self): - flip = RandAxisFlipd(keys="img", prob=1.0) - result = flip({"img": self.imt[0]}) - - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, flip._axis)) - self.assertTrue(np.allclose(np.stack(expected), result["img"])) + for p in TEST_NDARRAYS: + flip = RandAxisFlipd(keys="img", prob=1.0) + result = flip({"img": p(self.imt[0])})["img"] + if isinstance(result, torch.Tensor): + result = result.cpu() + + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, flip._axis)) + self.assertTrue(np.allclose(np.stack(expected), result)) if __name__ == "__main__": diff --git a/tests/test_rand_flip.py b/tests/test_rand_flip.py index b7a019136c..c20c13fec5 100644 --- a/tests/test_rand_flip.py +++ b/tests/test_rand_flip.py @@ -12,10 +12,11 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandFlip -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -31,12 +32,17 @@ def test_invalid_inputs(self, _, spatial_axis, raises): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - flip = RandFlip(prob=1.0, spatial_axis=spatial_axis) - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, spatial_axis)) - expected = np.stack(expected) - self.assertTrue(np.allclose(expected, flip(self.imt[0]))) + for p in TEST_NDARRAYS: + im = p(self.imt[0]) + flip = RandFlip(prob=1.0, spatial_axis=spatial_axis) + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, spatial_axis)) + expected = np.stack(expected) + result = flip(im) + if isinstance(result, torch.Tensor): + result = result.cpu() + self.assertTrue(np.allclose(expected, result)) if __name__ == "__main__": diff --git a/tests/test_rand_flipd.py b/tests/test_rand_flipd.py index 7bbd15f04c..42c7dfe4b5 100644 --- a/tests/test_rand_flipd.py +++ b/tests/test_rand_flipd.py @@ -12,10 +12,11 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RandFlipd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D VALID_CASES = [("no_axis", None), ("one_axis", 1), ("many_axis", [0, 1])] @@ -23,13 +24,16 @@ class TestRandFlipd(NumpyImageTestCase2D): @parameterized.expand(VALID_CASES) def test_correct_results(self, _, spatial_axis): - flip = RandFlipd(keys="img", prob=1.0, spatial_axis=spatial_axis) - res = flip({"img": self.imt[0]}) - expected = [] - for channel in self.imt[0]: - expected.append(np.flip(channel, spatial_axis)) - expected = np.stack(expected) - self.assertTrue(np.allclose(expected, res["img"])) + for p in TEST_NDARRAYS: + flip = RandFlipd(keys="img", prob=1.0, spatial_axis=spatial_axis) + result = flip({"img": p(self.imt[0])})["img"] + if isinstance(result, torch.Tensor): + result = result.cpu() + expected = [] + for channel in self.imt[0]: + expected.append(np.flip(channel, spatial_axis)) + expected = np.stack(expected) + self.assertTrue(np.allclose(expected, result)) if __name__ == "__main__": diff --git a/tests/test_testtimeaugmentation.py b/tests/test_testtimeaugmentation.py index 66d7627971..a07d59703d 100644 --- a/tests/test_testtimeaugmentation.py +++ b/tests/test_testtimeaugmentation.py @@ -25,6 +25,7 @@ from monai.transforms.croppad.dictionary import SpatialPadd from monai.transforms.spatial.dictionary import Rand2DElasticd, RandFlipd, Spacingd from monai.utils import optional_import, set_determinism +from tests.utils import TEST_NDARRAYS if TYPE_CHECKING: import tqdm @@ -40,7 +41,7 @@ class TestTestTimeAugmentation(unittest.TestCase): @staticmethod - def get_data(num_examples, input_size, include_label=True): + def get_data(num_examples, input_size, data_type=np.asarray, include_label=True): custom_create_test_image_2d = partial( create_test_image_2d, *input_size, rad_max=7, num_seg_classes=1, num_objs=1 ) @@ -48,10 +49,10 @@ def get_data(num_examples, input_size, include_label=True): for _ in range(num_examples): im, label = custom_create_test_image_2d() d = {} - d["image"] = im + d["image"] = data_type(im) d["image_meta_dict"] = {"affine": np.eye(4)} if include_label: - d["label"] = label + d["label"] = data_type(label) d["label_meta_dict"] = {"affine": np.eye(4)} data.append(d) return data[0] if num_examples == 1 else data @@ -142,9 +143,10 @@ def test_fail_random_but_not_invertible(self): TestTimeAugmentation(transforms, None, None, None) def test_single_transform(self): - transforms = RandFlipd(["image", "label"], prob=1.0) - tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x) - tta(self.get_data(1, (20, 20))) + for p in TEST_NDARRAYS: + transforms = RandFlipd(["image", "label"], prob=1.0) + tta = TestTimeAugmentation(transforms, batch_size=5, num_workers=0, inferrer_fn=lambda x: x) + tta(self.get_data(1, (20, 20), data_type=p)) def test_image_no_label(self): transforms = RandFlipd(["image"], prob=1.0) From b4def1a0d2f70ce709a5fc0a0f26a37f8af6f831 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 24 Aug 2021 18:19:16 +0100 Subject: [PATCH 58/89] all close (#2829) Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- tests/test_fill_holes.py | 5 ++-- tests/test_flip.py | 7 ++---- tests/test_flipd.py | 7 ++---- .../test_keep_largest_connected_component.py | 6 ++--- tests/test_label_filter.py | 5 ++-- tests/test_rand_axis_flip.py | 8 ++---- tests/test_rand_axis_flipd.py | 7 ++---- tests/test_rand_flip.py | 7 ++---- tests/test_rand_flipd.py | 7 ++---- tests/utils.py | 25 ++++++------------- 10 files changed, 27 insertions(+), 57 deletions(-) diff --git a/tests/test_fill_holes.py b/tests/test_fill_holes.py index 294bbd8c87..6ea83c239b 100644 --- a/tests/test_fill_holes.py +++ b/tests/test_fill_holes.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.transforms import FillHoles -from tests.utils import allclose, clone +from tests.utils import assert_allclose, clone grid_1_raw = [ [1, 1, 1], @@ -278,10 +278,9 @@ def test_correct_results(self, _, args, input_image, expected): converter = FillHoles(**args) if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): result = converter(clone(input_image).cuda()) - assert allclose(result, expected.cuda()) else: result = converter(clone(input_image)) - assert allclose(result, expected) + assert_allclose(result, expected) @parameterized.expand(INVALID_CASES) def test_raise_exception(self, _, args, input_image, expected_error): diff --git a/tests/test_flip.py b/tests/test_flip.py index bd0162fb8b..404a3def7d 100644 --- a/tests/test_flip.py +++ b/tests/test_flip.py @@ -12,11 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import Flip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -40,9 +39,7 @@ def test_correct_results(self, _, spatial_axis): expected.append(np.flip(channel, spatial_axis)) expected = np.stack(expected) result = flip(im) - if isinstance(result, torch.Tensor): - result = result.cpu() - self.assertTrue(np.allclose(expected, result)) + assert_allclose(expected, result) if __name__ == "__main__": diff --git a/tests/test_flipd.py b/tests/test_flipd.py index cec4a99cbf..1676723800 100644 --- a/tests/test_flipd.py +++ b/tests/test_flipd.py @@ -12,11 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import Flipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -39,9 +38,7 @@ def test_correct_results(self, _, spatial_axis): expected.append(np.flip(channel, spatial_axis)) expected = np.stack(expected) result = flip({"img": p(self.imt[0])})["img"] - if isinstance(result, torch.Tensor): - result = result.cpu() - assert np.allclose(expected, result) + assert_allclose(expected, result) if __name__ == "__main__": diff --git a/tests/test_keep_largest_connected_component.py b/tests/test_keep_largest_connected_component.py index 670dd2d2ee..527d986614 100644 --- a/tests/test_keep_largest_connected_component.py +++ b/tests/test_keep_largest_connected_component.py @@ -15,7 +15,7 @@ from parameterized import parameterized from monai.transforms import KeepLargestConnectedComponent -from tests.utils import allclose, clone +from tests.utils import assert_allclose, clone grid_1 = torch.tensor([[[0, 0, 1, 0, 0], [0, 2, 1, 1, 1], [1, 2, 1, 0, 0], [1, 2, 0, 1, 0], [2, 2, 0, 0, 2]]]) grid_2 = torch.tensor([[[0, 0, 0, 0, 1], [0, 0, 1, 1, 1], [1, 0, 1, 1, 2], [1, 0, 1, 2, 2], [0, 0, 0, 0, 1]]]) @@ -327,10 +327,10 @@ def test_correct_results(self, _, args, input_image, expected): converter = KeepLargestConnectedComponent(**args) if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): result = converter(clone(input_image).cuda()) - assert allclose(result, expected.cuda()) + else: result = converter(clone(input_image)) - assert allclose(result, expected) + assert_allclose(result, expected) @parameterized.expand(INVALID_CASES) def test_raise_exception(self, _, args, input_image, expected_error): diff --git a/tests/test_label_filter.py b/tests/test_label_filter.py index 9165fddc40..c699fb31fd 100644 --- a/tests/test_label_filter.py +++ b/tests/test_label_filter.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.transforms import LabelFilter -from tests.utils import allclose, clone +from tests.utils import assert_allclose, clone grid_1 = torch.tensor( [ @@ -108,10 +108,9 @@ def test_correct_results(self, _, args, input_image, expected): converter = LabelFilter(**args) if isinstance(input_image, torch.Tensor) and torch.cuda.is_available(): result = converter(clone(input_image).cuda()) - assert allclose(result, expected.cuda()) else: result = converter(clone(input_image)) - assert allclose(result, expected) + assert_allclose(result, expected) @parameterized.expand(INVALID_CASES) def test_raise_exception(self, _, args, input_image, expected_error): diff --git a/tests/test_rand_axis_flip.py b/tests/test_rand_axis_flip.py index bd53fa1fb0..c05c3a1e0d 100644 --- a/tests/test_rand_axis_flip.py +++ b/tests/test_rand_axis_flip.py @@ -12,10 +12,9 @@ import unittest import numpy as np -import torch from monai.transforms import RandAxisFlip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandAxisFlip(NumpyImageTestCase2D): @@ -23,13 +22,10 @@ def test_correct_results(self): for p in TEST_NDARRAYS: flip = RandAxisFlip(prob=1.0) result = flip(p(self.imt[0])) - if isinstance(result, torch.Tensor): - result = result.cpu() - expected = [] for channel in self.imt[0]: expected.append(np.flip(channel, flip._axis)) - self.assertTrue(np.allclose(np.stack(expected), result)) + assert_allclose(np.stack(expected), result) if __name__ == "__main__": diff --git a/tests/test_rand_axis_flipd.py b/tests/test_rand_axis_flipd.py index 518d78dd29..7bef0baa63 100644 --- a/tests/test_rand_axis_flipd.py +++ b/tests/test_rand_axis_flipd.py @@ -12,10 +12,9 @@ import unittest import numpy as np -import torch from monai.transforms import RandAxisFlipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase3D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase3D, assert_allclose class TestRandAxisFlip(NumpyImageTestCase3D): @@ -23,13 +22,11 @@ def test_correct_results(self): for p in TEST_NDARRAYS: flip = RandAxisFlipd(keys="img", prob=1.0) result = flip({"img": p(self.imt[0])})["img"] - if isinstance(result, torch.Tensor): - result = result.cpu() expected = [] for channel in self.imt[0]: expected.append(np.flip(channel, flip._axis)) - self.assertTrue(np.allclose(np.stack(expected), result)) + assert_allclose(np.stack(expected), result) if __name__ == "__main__": diff --git a/tests/test_rand_flip.py b/tests/test_rand_flip.py index c20c13fec5..b3c514cb1f 100644 --- a/tests/test_rand_flip.py +++ b/tests/test_rand_flip.py @@ -12,11 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import RandFlip -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose INVALID_CASES = [("wrong_axis", ["s", 1], TypeError), ("not_numbers", "s", TypeError)] @@ -40,9 +39,7 @@ def test_correct_results(self, _, spatial_axis): expected.append(np.flip(channel, spatial_axis)) expected = np.stack(expected) result = flip(im) - if isinstance(result, torch.Tensor): - result = result.cpu() - self.assertTrue(np.allclose(expected, result)) + assert_allclose(expected, result) if __name__ == "__main__": diff --git a/tests/test_rand_flipd.py b/tests/test_rand_flipd.py index 42c7dfe4b5..8972024fd8 100644 --- a/tests/test_rand_flipd.py +++ b/tests/test_rand_flipd.py @@ -12,11 +12,10 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import RandFlipd -from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose VALID_CASES = [("no_axis", None), ("one_axis", 1), ("many_axis", [0, 1])] @@ -27,13 +26,11 @@ def test_correct_results(self, _, spatial_axis): for p in TEST_NDARRAYS: flip = RandFlipd(keys="img", prob=1.0, spatial_axis=spatial_axis) result = flip({"img": p(self.imt[0])})["img"] - if isinstance(result, torch.Tensor): - result = result.cpu() expected = [] for channel in self.imt[0]: expected.append(np.flip(channel, spatial_axis)) expected = np.stack(expected) - self.assertTrue(np.allclose(expected, result)) + assert_allclose(expected, result) if __name__ == "__main__": diff --git a/tests/utils.py b/tests/utils.py index 1148af7551..22720849f1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -33,6 +33,7 @@ from monai.config import NdarrayTensor from monai.config.deviceconfig import USE_COMPILED +from monai.config.type_definitions import NdarrayOrTensor from monai.data import create_test_image_2d, create_test_image_3d from monai.utils import ensure_tuple, optional_import, set_determinism from monai.utils.module import version_leq @@ -55,27 +56,17 @@ def clone(data: NdarrayTensor) -> NdarrayTensor: return copy.deepcopy(data) -def allclose(a: NdarrayTensor, b: NdarrayTensor) -> bool: +def assert_allclose(a: NdarrayOrTensor, b: NdarrayOrTensor, *args, **kwargs): """ - Check if all values of two data objects are close. - - Note: - This method also checks that both data objects are either Pytorch Tensors or numpy arrays. + Assert that all values of two data objects are close. Args: - a (NdarrayTensor): Pytorch Tensor or numpy array for comparison - b (NdarrayTensor): Pytorch Tensor or numpy array to compare against - - Returns: - bool: If both data objects are close. + a (NdarrayOrTensor): Pytorch Tensor or numpy array for comparison + b (NdarrayOrTensor): Pytorch Tensor or numpy array to compare against """ - if isinstance(a, np.ndarray) and isinstance(b, np.ndarray): - return np.allclose(a, b) - - if isinstance(a, torch.Tensor) and isinstance(b, torch.Tensor): - return torch.allclose(a, b) - - return False + a = a.cpu() if isinstance(a, torch.Tensor) else a + b = b.cpu() if isinstance(b, torch.Tensor) else b + np.testing.assert_allclose(a, b, *args, **kwargs) def test_pretrained_networks(network, input_param, device): From fe559e5a8552a80bd21bcd93163144efb8731427 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:46:14 +0100 Subject: [PATCH 59/89] Normalize intensity (#2831) * all close Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * assert_allclose Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * NormalizeIntensity Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/intensity/array.py | 52 +++++++-- monai/transforms/intensity/dictionary.py | 8 +- tests/test_normalize_intensity.py | 139 +++++++++++++++-------- tests/test_normalize_intensityd.py | 85 +++++++++----- 4 files changed, 189 insertions(+), 95 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 4b7c8d6997..8b2bf32145 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -539,10 +539,12 @@ class NormalizeIntensity(Transform): dtype: output data type, defaults to float32. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, - subtrahend: Union[Sequence, np.ndarray, None] = None, - divisor: Union[Sequence, np.ndarray, None] = None, + subtrahend: Union[Sequence, NdarrayOrTensor, None] = None, + divisor: Union[Sequence, NdarrayOrTensor, None] = None, nonzero: bool = False, channel_wise: bool = False, dtype: DtypeLike = np.float32, @@ -553,26 +555,51 @@ def __init__( self.channel_wise = channel_wise self.dtype = dtype - def _normalize(self, img: np.ndarray, sub=None, div=None) -> np.ndarray: - slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=bool) - if not np.any(slices): + @staticmethod + def _mean(x): + if isinstance(x, np.ndarray): + return np.mean(x) + x = torch.mean(x.float()) + return x.item() if x.numel() == 1 else x + + @staticmethod + def _std(x): + if isinstance(x, np.ndarray): + return np.std(x) + x = torch.std(x.float(), unbiased=False) + return x.item() if x.numel() == 1 else x + + def _normalize(self, img: NdarrayOrTensor, sub=None, div=None) -> NdarrayOrTensor: + img, *_ = convert_data_type(img, dtype=torch.float32) + + if self.nonzero: + slices = img != 0 + else: + if isinstance(img, np.ndarray): + slices = np.ones_like(img, dtype=bool) + else: + slices = torch.ones_like(img, dtype=torch.bool) + if not slices.any(): return img - _sub = sub if sub is not None else np.mean(img[slices]) - if isinstance(_sub, np.ndarray): + _sub = sub if sub is not None else self._mean(img[slices]) + if isinstance(_sub, (torch.Tensor, np.ndarray)): + _sub, *_ = convert_to_dst_type(_sub, img) _sub = _sub[slices] - _div = div if div is not None else np.std(img[slices]) + _div = div if div is not None else self._std(img[slices]) if np.isscalar(_div): if _div == 0.0: _div = 1.0 - elif isinstance(_div, np.ndarray): + elif isinstance(_div, (torch.Tensor, np.ndarray)): + _div, *_ = convert_to_dst_type(_div, img) _div = _div[slices] _div[_div == 0.0] = 1.0 + img[slices] = (img[slices] - _sub) / _div return img - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`, assuming `img` is a channel-first array if `self.channel_wise` is True, """ @@ -583,7 +610,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray: raise ValueError(f"img has {len(img)} channels, but divisor has {len(self.divisor)} components.") for i, d in enumerate(img): - img[i] = self._normalize( + img[i] = self._normalize( # type: ignore d, sub=self.subtrahend[i] if self.subtrahend is not None else None, div=self.divisor[i] if self.divisor is not None else None, @@ -591,7 +618,8 @@ def __call__(self, img: np.ndarray) -> np.ndarray: else: img = self._normalize(img, self.subtrahend, self.divisor) - return img.astype(self.dtype) + out, *_ = convert_data_type(img, dtype=self.dtype) + return out class ThresholdIntensity(Transform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 522007df29..bce45b57d3 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -612,11 +612,13 @@ class NormalizeIntensityd(MapTransform): allow_missing_keys: don't raise exception if key is missing. """ + backend = NormalizeIntensity.backend + def __init__( self, keys: KeysCollection, - subtrahend: Optional[np.ndarray] = None, - divisor: Optional[np.ndarray] = None, + subtrahend: Optional[NdarrayOrTensor] = None, + divisor: Optional[NdarrayOrTensor] = None, nonzero: bool = False, channel_wise: bool = False, dtype: DtypeLike = np.float32, @@ -625,7 +627,7 @@ def __init__( super().__init__(keys, allow_missing_keys) self.normalizer = NormalizeIntensity(subtrahend, divisor, nonzero, channel_wise, dtype) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.normalizer(d[key]) diff --git a/tests/test_normalize_intensity.py b/tests/test_normalize_intensity.py index 9d474faea7..2755eb4c25 100644 --- a/tests/test_normalize_intensity.py +++ b/tests/test_normalize_intensity.py @@ -12,70 +12,111 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import NormalizeIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASES = [ - [{"nonzero": True}, np.array([0.0, 3.0, 0.0, 4.0]), np.array([0.0, -1.0, 0.0, 1.0])], - [ - {"subtrahend": np.array([3.5, 3.5, 3.5, 3.5]), "divisor": np.array([0.5, 0.5, 0.5, 0.5]), "nonzero": True}, - np.array([0.0, 3.0, 0.0, 4.0]), - np.array([0.0, -1.0, 0.0, 1.0]), - ], - [{"nonzero": True}, np.array([0.0, 0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0, 0.0])], - [{"nonzero": False}, np.array([0.0, 0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0, 0.0])], - [{"nonzero": False}, np.array([1, 1, 1, 1]), np.array([0.0, 0.0, 0.0, 0.0])], - [ - {"nonzero": False, "channel_wise": True, "subtrahend": [1, 2, 3]}, - np.ones((3, 2, 2)), - np.array([[[0.0, 0.0], [0.0, 0.0]], [[-1.0, -1.0], [-1.0, -1.0]], [[-2.0, -2.0], [-2.0, -2.0]]]), - ], - [ - {"nonzero": True, "channel_wise": True, "subtrahend": [1, 2, 3], "divisor": [0, 0, 2]}, - np.ones((3, 2, 2)), - np.array([[[0.0, 0.0], [0.0, 0.0]], [[-1.0, -1.0], [-1.0, -1.0]], [[-1.0, -1.0], [-1.0, -1.0]]]), - ], - [ - {"nonzero": True, "channel_wise": False, "subtrahend": 2, "divisor": 0}, - np.ones((3, 2, 2)), - np.ones((3, 2, 2)) * -1.0, - ], - [ - {"nonzero": True, "channel_wise": False, "subtrahend": np.ones((3, 2, 2)) * 0.5, "divisor": 0}, - np.ones((3, 2, 2)), - np.ones((3, 2, 2)) * 0.5, - ], - [ - {"nonzero": True, "channel_wise": True, "subtrahend": np.ones((3, 2, 2)) * 0.5, "divisor": [0, 1, 0]}, - np.ones((3, 2, 2)), - np.ones((3, 2, 2)) * 0.5, - ], -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, {"nonzero": True}, np.array([0.0, 3.0, 0.0, 4.0]), np.array([0.0, -1.0, 0.0, 1.0])]) + for q in TEST_NDARRAYS: + for u in TEST_NDARRAYS: + TESTS.append( + [ + p, + { + "subtrahend": q(np.array([3.5, 3.5, 3.5, 3.5])), + "divisor": u(np.array([0.5, 0.5, 0.5, 0.5])), + "nonzero": True, + }, + np.array([0.0, 3.0, 0.0, 4.0]), + np.array([0.0, -1.0, 0.0, 1.0]), + ] + ) + TESTS.append([p, {"nonzero": True}, np.array([0.0, 0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0, 0.0])]) + TESTS.append([p, {"nonzero": False}, np.array([0.0, 0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0, 0.0])]) + TESTS.append([p, {"nonzero": False}, np.array([1, 1, 1, 1]), np.array([0.0, 0.0, 0.0, 0.0])]) + TESTS.append( + [ + p, + {"nonzero": False, "channel_wise": True, "subtrahend": [1, 2, 3]}, + np.ones((3, 2, 2)), + np.array([[[0.0, 0.0], [0.0, 0.0]], [[-1.0, -1.0], [-1.0, -1.0]], [[-2.0, -2.0], [-2.0, -2.0]]]), + ] + ) + TESTS.append( + [ + p, + {"nonzero": True, "channel_wise": True, "subtrahend": [1, 2, 3], "divisor": [0, 0, 2]}, + np.ones((3, 2, 2)), + np.array([[[0.0, 0.0], [0.0, 0.0]], [[-1.0, -1.0], [-1.0, -1.0]], [[-1.0, -1.0], [-1.0, -1.0]]]), + ] + ) + TESTS.append( + [ + p, + {"nonzero": True, "channel_wise": False, "subtrahend": 2, "divisor": 0}, + np.ones((3, 2, 2)), + np.ones((3, 2, 2)) * -1.0, + ] + ) + TESTS.append( + [ + p, + {"nonzero": True, "channel_wise": False, "subtrahend": np.ones((3, 2, 2)) * 0.5, "divisor": 0}, + np.ones((3, 2, 2)), + np.ones((3, 2, 2)) * 0.5, + ] + ) + TESTS.append( + [ + p, + {"nonzero": True, "channel_wise": True, "subtrahend": np.ones((3, 2, 2)) * 0.5, "divisor": [0, 1, 0]}, + np.ones((3, 2, 2)), + np.ones((3, 2, 2)) * 0.5, + ] + ) class TestNormalizeIntensity(NumpyImageTestCase2D): - def test_default(self): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_default(self, im_type): + im = im_type(self.imt.copy()) normalizer = NormalizeIntensity() - normalized = normalizer(self.imt.copy()) - self.assertTrue(normalized.dtype == np.float32) + normalized = normalizer(im) + self.assertEqual(type(im), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(im.device, normalized.device) + self.assertTrue(normalized.dtype in (np.float32, torch.float32)) expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) - np.testing.assert_allclose(normalized, expected, rtol=1e-3) + assert_allclose(expected, normalized, rtol=1e-3) - @parameterized.expand(TEST_CASES) - def test_nonzero(self, input_param, input_data, expected_data): + @parameterized.expand(TESTS) + def test_nonzero(self, in_type, input_param, input_data, expected_data): normalizer = NormalizeIntensity(**input_param) - np.testing.assert_allclose(expected_data, normalizer(input_data)) + im = in_type(input_data) + normalized = normalizer(im) + self.assertEqual(type(im), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(im.device, normalized.device) + assert_allclose(expected_data, normalized) - def test_channel_wise(self): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, im_type): normalizer = NormalizeIntensity(nonzero=True, channel_wise=True) - input_data = np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]]) + input_data = im_type(np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]])) expected = np.array([[0.0, -1.0, 0.0, 1.0], [0.0, -1.0, 0.0, 1.0]]) - np.testing.assert_allclose(expected, normalizer(input_data)) + normalized = normalizer(input_data) + self.assertEqual(type(input_data), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(input_data.device, normalized.device) + assert_allclose(expected, normalized) - def test_value_errors(self): - input_data = np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]]) + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value_errors(self, im_type): + input_data = im_type(np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]])) normalizer = NormalizeIntensity(nonzero=True, channel_wise=True, subtrahend=[1]) with self.assertRaises(ValueError): normalizer(input_data) diff --git a/tests/test_normalize_intensityd.py b/tests/test_normalize_intensityd.py index 482d1a3f5b..e2cec5407a 100644 --- a/tests/test_normalize_intensityd.py +++ b/tests/test_normalize_intensityd.py @@ -12,54 +12,77 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import NormalizeIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASE_1 = [ - {"keys": ["img"], "nonzero": True}, - {"img": np.array([0.0, 3.0, 0.0, 4.0])}, - np.array([0.0, -1.0, 0.0, 1.0]), -] - -TEST_CASE_2 = [ - { - "keys": ["img"], - "subtrahend": np.array([3.5, 3.5, 3.5, 3.5]), - "divisor": np.array([0.5, 0.5, 0.5, 0.5]), - "nonzero": True, - }, - {"img": np.array([0.0, 3.0, 0.0, 4.0])}, - np.array([0.0, -1.0, 0.0, 1.0]), -] - -TEST_CASE_3 = [ - {"keys": ["img"], "nonzero": True}, - {"img": np.array([0.0, 0.0, 0.0, 0.0])}, - np.array([0.0, 0.0, 0.0, 0.0]), -] +TESTS = [] +for p in TEST_NDARRAYS: + for q in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": ["img"], "nonzero": True}, + {"img": p(np.array([0.0, 3.0, 0.0, 4.0]))}, + np.array([0.0, -1.0, 0.0, 1.0]), + ] + ) + TESTS.append( + [ + { + "keys": ["img"], + "subtrahend": q(np.array([3.5, 3.5, 3.5, 3.5])), + "divisor": q(np.array([0.5, 0.5, 0.5, 0.5])), + "nonzero": True, + }, + {"img": p(np.array([0.0, 3.0, 0.0, 4.0]))}, + np.array([0.0, -1.0, 0.0, 1.0]), + ] + ) + TESTS.append( + [ + {"keys": ["img"], "nonzero": True}, + {"img": p(np.array([0.0, 0.0, 0.0, 0.0]))}, + np.array([0.0, 0.0, 0.0, 0.0]), + ] + ) class TestNormalizeIntensityd(NumpyImageTestCase2D): - def test_image_normalize_intensityd(self): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_image_normalize_intensityd(self, im_type): key = "img" + im = im_type(self.imt) normalizer = NormalizeIntensityd(keys=[key]) - normalized = normalizer({key: self.imt}) + normalized = normalizer({key: im})[key] expected = (self.imt - np.mean(self.imt)) / np.std(self.imt) - np.testing.assert_allclose(normalized[key], expected, rtol=1e-3) + self.assertEqual(type(im), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(im.device, normalized.device) + assert_allclose(normalized, expected, rtol=1e-3) - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + @parameterized.expand(TESTS) def test_nonzero(self, input_param, input_data, expected_data): + key = "img" normalizer = NormalizeIntensityd(**input_param) - np.testing.assert_allclose(expected_data, normalizer(input_data)["img"]) + normalized = normalizer(input_data)[key] + self.assertEqual(type(input_data[key]), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(input_data[key].device, normalized.device) + assert_allclose(normalized, expected_data) - def test_channel_wise(self): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_channel_wise(self, im_type): key = "img" normalizer = NormalizeIntensityd(keys=key, nonzero=True, channel_wise=True) - input_data = {key: np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]])} + input_data = {key: im_type(np.array([[0.0, 3.0, 0.0, 4.0], [0.0, 4.0, 0.0, 5.0]]))} + normalized = normalizer(input_data)[key] + self.assertEqual(type(input_data[key]), type(normalized)) + if isinstance(normalized, torch.Tensor): + self.assertEqual(input_data[key].device, normalized.device) expected = np.array([[0.0, -1.0, 0.0, 1.0], [0.0, -1.0, 0.0, 1.0]]) - np.testing.assert_allclose(expected, normalizer(input_data)[key]) + assert_allclose(normalized, expected) if __name__ == "__main__": From 38ecaef80c07606964212954c9142e9ffa7e3f28 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:03:45 -0400 Subject: [PATCH 60/89] Update Range decorator (#2834) * Change default methods for nn.Module Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove DataLoader Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- monai/utils/nvtx.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/monai/utils/nvtx.py b/monai/utils/nvtx.py index c2f0acd97f..2dfbd03529 100644 --- a/monai/utils/nvtx.py +++ b/monai/utils/nvtx.py @@ -19,9 +19,8 @@ from torch.autograd import Function from torch.nn import Module from torch.optim import Optimizer -from torch.utils.data import DataLoader, Dataset +from torch.utils.data import Dataset -# from monai.transforms.transform import Transform from monai.utils import ensure_tuple, optional_import _nvtx, _ = optional_import("torch._C._nvtx", descriptor="NVTX is not installed. Are you sure you have a CUDA build?") @@ -40,7 +39,7 @@ class Range: methods: (only when used as decorator) the name of a method (or a list of the name of the methods) to be wrapped by NVTX range. If None (default), the method(s) will be inferred based on the object's type for various MONAI components, - such as Networks, Losses, Optimizers, Functions, Transforms, Datasets, and Dataloaders. + such as Networks, Losses, Functions, Transforms, and Datasets. Otherwise, it look up predefined methods: "forward", "__call__", "__next__", "__getitem__" append_method_name: if append the name of the methods to be decorated to the range's name If None (default), it appends the method's name only if we are annotating more than one method. @@ -114,15 +113,13 @@ def range_wrapper(*args, **kwargs): def _get_method(self, obj: Any) -> tuple: if isinstance(obj, Module): - method_list = ["forward", "__call__"] + method_list = ["forward"] elif isinstance(obj, Optimizer): method_list = ["step"] elif isinstance(obj, Function): method_list = ["forward", "backward"] elif isinstance(obj, Dataset): method_list = ["__getitem__"] - elif isinstance(obj, DataLoader): - method_list = ["_next_data"] else: default_methods = ["forward", "__call__", "__next__", "__getitem__"] method_list = [] From fa5bc15666220d09c2f95755279a72bf0b0fbe89 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 25 Aug 2021 05:22:26 +0100 Subject: [PATCH 61/89] Scale intensity (#2832) * all close Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * assert_allclose Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * ScaleIntensity Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/data/synthetic.py | 4 ++-- monai/transforms/intensity/array.py | 14 +++++++---- monai/transforms/intensity/dictionary.py | 8 +++++-- monai/transforms/utils.py | 12 ++++++---- monai/visualize/img2tensorboard.py | 2 +- tests/test_rand_scale_intensity.py | 15 ++++++------ tests/test_rand_scale_intensityd.py | 17 +++++++------- tests/test_scale_intensity.py | 26 ++++++++++---------- tests/test_scale_intensityd.py | 30 +++++++++++++----------- 9 files changed, 74 insertions(+), 54 deletions(-) diff --git a/monai/data/synthetic.py b/monai/data/synthetic.py index 20a7829cab..6eec9fd277 100644 --- a/monai/data/synthetic.py +++ b/monai/data/synthetic.py @@ -76,7 +76,7 @@ def create_test_image_2d( labels = np.ceil(image).astype(np.int32) norm = rs.uniform(0, num_seg_classes * noise_max, size=image.shape) - noisyimage = rescale_array(np.maximum(image, norm)) + noisyimage: np.ndarray = rescale_array(np.maximum(image, norm)) # type: ignore if channel_dim is not None: if not (isinstance(channel_dim, int) and channel_dim in (-1, 0, 2)): @@ -151,7 +151,7 @@ def create_test_image_3d( labels = np.ceil(image).astype(np.int32) norm = rs.uniform(0, num_seg_classes * noise_max, size=image.shape) - noisyimage = rescale_array(np.maximum(image, norm)) + noisyimage: np.ndarray = rescale_array(np.maximum(image, norm)) # type: ignore if channel_dim is not None: if not (isinstance(channel_dim, int) and channel_dim in (-1, 0, 3)): diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 8b2bf32145..46c512c96c 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -373,6 +373,8 @@ class ScaleIntensity(Transform): If `minv` and `maxv` not provided, use `factor` to scale image by ``v = v * (1 + factor)``. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, minv: Optional[float] = 0.0, maxv: Optional[float] = 1.0, factor: Optional[float] = None ) -> None: @@ -387,7 +389,7 @@ def __init__( self.maxv = maxv self.factor = factor - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. @@ -396,9 +398,11 @@ def __call__(self, img: np.ndarray) -> np.ndarray: """ if self.minv is not None and self.maxv is not None: - return np.asarray(rescale_array(img, self.minv, self.maxv, img.dtype)) + return rescale_array(img, self.minv, self.maxv, img.dtype) if self.factor is not None: - return np.asarray(img * (1 + self.factor), dtype=img.dtype) + out = img * (1 + self.factor) + out, *_ = convert_data_type(out, dtype=img.dtype) + return out raise ValueError("Incompatible values: minv=None or maxv=None and factor=None.") @@ -408,6 +412,8 @@ class RandScaleIntensity(RandomizableTransform): is randomly picked. """ + backend = ScaleIntensity.backend + def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1) -> None: """ Args: @@ -429,7 +435,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) super().randomize(None) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index bce45b57d3..227b6fb434 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -472,6 +472,8 @@ class ScaleIntensityd(MapTransform): If `minv` and `maxv` not provided, use `factor` to scale image by ``v = v * (1 + factor)``. """ + backend = ScaleIntensity.backend + def __init__( self, keys: KeysCollection, @@ -494,7 +496,7 @@ def __init__( super().__init__(keys, allow_missing_keys) self.scaler = ScaleIntensity(minv, maxv, factor) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.scaler(d[key]) @@ -506,6 +508,8 @@ class RandScaleIntensityd(RandomizableTransform, MapTransform): Dictionary-based version :py:class:`monai.transforms.RandScaleIntensity`. """ + backend = ScaleIntensity.backend + def __init__( self, keys: KeysCollection, @@ -539,7 +543,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) super().randomize(None) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) self.randomize() if not self._do_transform: diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index e81cb7ca17..e3e61b6c97 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -22,6 +22,7 @@ import monai import monai.transforms.transform from monai.config import DtypeLike, IndexSelection +from monai.config.type_definitions import NdarrayOrTensor from monai.networks.layers import GaussianFilter from monai.transforms.compose import Compose, OneOf from monai.transforms.transform import MapTransform, Transform @@ -37,6 +38,7 @@ min_version, optional_import, ) +from monai.utils.type_conversion import convert_data_type measure, _ = optional_import("skimage.measure", "0.14.2", min_version) ndimage, _ = optional_import("scipy.ndimage") @@ -130,15 +132,17 @@ def zero_margins(img: np.ndarray, margin: int) -> bool: return not np.any(img[:, :margin, :]) and not np.any(img[:, -margin:, :]) -def rescale_array(arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: DtypeLike = np.float32): +def rescale_array( + arr: NdarrayOrTensor, minv: float = 0.0, maxv: float = 1.0, dtype: Union[DtypeLike, torch.dtype] = np.float32 +) -> NdarrayOrTensor: """ Rescale the values of numpy array `arr` to be from `minv` to `maxv`. """ if dtype is not None: - arr = arr.astype(dtype) + arr, *_ = convert_data_type(arr, dtype=dtype) - mina = np.min(arr) - maxa = np.max(arr) + mina = arr.min() + maxa = arr.max() if mina == maxa: return arr * minv diff --git a/monai/visualize/img2tensorboard.py b/monai/visualize/img2tensorboard.py index 4a17607320..ccdbdc2396 100644 --- a/monai/visualize/img2tensorboard.py +++ b/monai/visualize/img2tensorboard.py @@ -188,7 +188,7 @@ def plot_2d_or_3d_image( d: np.ndarray = data_index.detach().cpu().numpy() if isinstance(data_index, torch.Tensor) else data_index if d.ndim == 2: - d = rescale_array(d, 0, 1) + d = rescale_array(d, 0, 1) # type: ignore dataformats = "HW" writer.add_image(f"{tag}_{dataformats}", d, step, dataformats=dataformats) return diff --git a/tests/test_rand_scale_intensity.py b/tests/test_rand_scale_intensity.py index 2126301758..750d88bfad 100644 --- a/tests/test_rand_scale_intensity.py +++ b/tests/test_rand_scale_intensity.py @@ -14,17 +14,18 @@ import numpy as np from monai.transforms import RandScaleIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandScaleIntensity(NumpyImageTestCase2D): def test_value(self): - scaler = RandScaleIntensity(factors=0.5, prob=1.0) - scaler.set_random_state(seed=0) - result = scaler(self.imt) - np.random.seed(0) - expected = (self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32) - np.testing.assert_allclose(result, expected) + for p in TEST_NDARRAYS: + scaler = RandScaleIntensity(factors=0.5, prob=1.0) + scaler.set_random_state(seed=0) + result = scaler(p(self.imt)) + np.random.seed(0) + expected = p((self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32)) + assert_allclose(result, expected, rtol=1e-7, atol=0) if __name__ == "__main__": diff --git a/tests/test_rand_scale_intensityd.py b/tests/test_rand_scale_intensityd.py index 6e207e3cc2..a8d2e63f65 100644 --- a/tests/test_rand_scale_intensityd.py +++ b/tests/test_rand_scale_intensityd.py @@ -14,18 +14,19 @@ import numpy as np from monai.transforms import RandScaleIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandScaleIntensityd(NumpyImageTestCase2D): def test_value(self): - key = "img" - scaler = RandScaleIntensityd(keys=[key], factors=0.5, prob=1.0) - scaler.set_random_state(seed=0) - result = scaler({key: self.imt}) - np.random.seed(0) - expected = (self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32) - np.testing.assert_allclose(result[key], expected) + for p in TEST_NDARRAYS: + key = "img" + scaler = RandScaleIntensityd(keys=[key], factors=0.5, prob=1.0) + scaler.set_random_state(seed=0) + result = scaler({key: p(self.imt)}) + np.random.seed(0) + expected = (self.imt * (1 + np.random.uniform(low=-0.5, high=0.5))).astype(np.float32) + assert_allclose(result[key], expected) if __name__ == "__main__": diff --git a/tests/test_scale_intensity.py b/tests/test_scale_intensity.py index 61e89191fd..c2485af616 100644 --- a/tests/test_scale_intensity.py +++ b/tests/test_scale_intensity.py @@ -14,24 +14,26 @@ import numpy as np from monai.transforms import ScaleIntensity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestScaleIntensity(NumpyImageTestCase2D): def test_range_scale(self): - scaler = ScaleIntensity(minv=1.0, maxv=2.0) - result = scaler(self.imt) - mina = np.min(self.imt) - maxa = np.max(self.imt) - norm = (self.imt - mina) / (maxa - mina) - expected = (norm * (2.0 - 1.0)) + 1.0 - np.testing.assert_allclose(result, expected) + for p in TEST_NDARRAYS: + scaler = ScaleIntensity(minv=1.0, maxv=2.0) + result = scaler(p(self.imt)) + mina = self.imt.min() + maxa = self.imt.max() + norm = (self.imt - mina) / (maxa - mina) + expected = p((norm * (2.0 - 1.0)) + 1.0) + assert_allclose(result, expected, rtol=1e-7, atol=0) def test_factor_scale(self): - scaler = ScaleIntensity(minv=None, maxv=None, factor=0.1) - result = scaler(self.imt) - expected = (self.imt * (1 + 0.1)).astype(np.float32) - np.testing.assert_allclose(result, expected) + for p in TEST_NDARRAYS: + scaler = ScaleIntensity(minv=None, maxv=None, factor=0.1) + result = scaler(p(self.imt)) + expected = p((self.imt * (1 + 0.1)).astype(np.float32)) + assert_allclose(result, expected, rtol=1e-7, atol=0) if __name__ == "__main__": diff --git a/tests/test_scale_intensityd.py b/tests/test_scale_intensityd.py index 688c99c6af..6e13dbc272 100644 --- a/tests/test_scale_intensityd.py +++ b/tests/test_scale_intensityd.py @@ -14,26 +14,28 @@ import numpy as np from monai.transforms import ScaleIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestScaleIntensityd(NumpyImageTestCase2D): def test_range_scale(self): - key = "img" - scaler = ScaleIntensityd(keys=[key], minv=1.0, maxv=2.0) - result = scaler({key: self.imt}) - mina = np.min(self.imt) - maxa = np.max(self.imt) - norm = (self.imt - mina) / (maxa - mina) - expected = (norm * (2.0 - 1.0)) + 1.0 - np.testing.assert_allclose(result[key], expected) + for p in TEST_NDARRAYS: + key = "img" + scaler = ScaleIntensityd(keys=[key], minv=1.0, maxv=2.0) + result = scaler({key: p(self.imt)}) + mina = np.min(self.imt) + maxa = np.max(self.imt) + norm = (self.imt - mina) / (maxa - mina) + expected = (norm * (2.0 - 1.0)) + 1.0 + assert_allclose(result[key], expected) def test_factor_scale(self): - key = "img" - scaler = ScaleIntensityd(keys=[key], minv=None, maxv=None, factor=0.1) - result = scaler({key: self.imt}) - expected = (self.imt * (1 + 0.1)).astype(np.float32) - np.testing.assert_allclose(result[key], expected) + for p in TEST_NDARRAYS: + key = "img" + scaler = ScaleIntensityd(keys=[key], minv=None, maxv=None, factor=0.1) + result = scaler({key: p(self.imt)}) + expected = (self.imt * (1 + 0.1)).astype(np.float32) + assert_allclose(result[key], expected) if __name__ == "__main__": From acf4a9fce51097b8bb95ecaa0d7d6a797e56a65c Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 25 Aug 2021 14:57:49 +0100 Subject: [PATCH 62/89] backends -> backend (#2838) * backends -> backend Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format2 Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/__init__.py | 1 + monai/transforms/intensity/array.py | 10 +-- monai/transforms/utility/array.py | 2 +- monai/transforms/utils.py | 96 +++++++++++++++++--------- tests/test_print_transform_backends.py | 8 ++- 5 files changed, 76 insertions(+), 41 deletions(-) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 4203724a7d..5267af4048 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -500,6 +500,7 @@ get_extreme_points, get_largest_connected_component_mask, get_number_image_type_conversions, + get_transform_backends, img_bounds, in_bounds, is_empty, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 46c512c96c..b36c7adf96 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -131,7 +131,7 @@ class RandRicianNoise(RandomizableTransform): uniformly from 0 to std. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( self, @@ -197,7 +197,7 @@ class ShiftIntensity(Transform): offset: offset value to shift the intensity of image. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, offset: float) -> None: self.offset = offset @@ -219,7 +219,7 @@ class RandShiftIntensity(RandomizableTransform): Randomly shift intensity with randomly picked offset. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, offsets: Union[Tuple[float, float], float], prob: float = 0.1) -> None: """ @@ -273,7 +273,7 @@ class StdShiftIntensity(Transform): dtype: output data type, defaults to float32. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( self, factor: float, nonzero: bool = False, channel_wise: bool = False, dtype: DtypeLike = np.float32 @@ -318,7 +318,7 @@ class RandStdShiftIntensity(RandomizableTransform): by: ``v = v + factor * std(v)`` where the `factor` is randomly picked. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( self, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 3ef6413090..d56bca0d8d 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -291,7 +291,7 @@ class CastToType(Transform): specified PyTorch data type. """ - backends = [TransformBackends.TORCH, TransformBackends.NUMPY] + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__(self, dtype=np.float32) -> None: """ diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index e3e61b6c97..30aa5e7b99 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -38,6 +38,7 @@ min_version, optional_import, ) +from monai.utils.enums import TransformBackends from monai.utils.type_conversion import convert_data_type measure, _ = optional_import("skimage.measure", "0.14.2", min_version) @@ -81,6 +82,7 @@ "zero_margins", "equalize_hist", "get_number_image_type_conversions", + "get_transform_backends", "print_transform_backends", ] @@ -1158,22 +1160,17 @@ def _get_data(obj, key): return num_conversions -def print_transform_backends(): - """Prints a list of backends of all MONAI transforms.""" - - class Colours: - red = "91" - green = "92" - yellow = "93" - - def print_colour(t, colour): - print(f"\033[{colour}m{t}\033[00m") +def get_transform_backends(): + """Get the backends of all MONAI transforms. - tr_total = 0 - tr_t_or_np = 0 - tr_t = 0 - tr_np = 0 - tr_uncategorised = 0 + Returns: + Dictionary, where each key is a transform, and its + corresponding values are a boolean list, stating + whether that transform supports (1) `torch.Tensor`, + and (2) `np.ndarray` as input without needing to + convert. + """ + backends = {} unique_transforms = [] for n, obj in getmembers(monai.transforms): # skip aliases @@ -1194,21 +1191,54 @@ def print_colour(t, colour): "InverteD", ]: continue - tr_total += 1 - if obj.backend == ["torch", "numpy"]: - tr_t_or_np += 1 - print_colour(f"TorchOrNumpy: {n}", Colours.green) - elif obj.backend == ["torch"]: - tr_t += 1 - print_colour(f"Torch: {n}", Colours.green) - elif obj.backend == ["numpy"]: - tr_np += 1 - print_colour(f"Numpy: {n}", Colours.yellow) - else: - tr_uncategorised += 1 - print_colour(f"Uncategorised: {n}", Colours.red) - print("Total number of transforms:", tr_total) - print_colour(f"Number transforms allowing both torch and numpy: {tr_t_or_np}", Colours.green) - print_colour(f"Number of TorchTransform: {tr_t}", Colours.green) - print_colour(f"Number of NumpyTransform: {tr_np}", Colours.yellow) - print_colour(f"Number of uncategorised: {tr_uncategorised}", Colours.red) + + backends[n] = [ + TransformBackends.TORCH in obj.backend, + TransformBackends.NUMPY in obj.backend, + ] + return backends + + +def print_transform_backends(): + """Prints a list of backends of all MONAI transforms.""" + + class Colors: + none = "" + red = "91" + green = "92" + yellow = "93" + + def print_color(t, color): + print(f"\033[{color}m{t}\033[00m") + + def print_table_column(name, torch, numpy, color=Colors.none): + print_color("{:<50} {:<8} {:<8}".format(name, torch, numpy), color) + + backends = get_transform_backends() + n_total = len(backends) + n_t_or_np, n_t, n_np, n_uncategorized = 0, 0, 0, 0 + print_table_column("Transform", "Torch?", "Numpy?") + for k, v in backends.items(): + if all(v): + color = Colors.green + n_t_or_np += 1 + elif v[0]: + color = Colors.green + n_t += 1 + elif v[1]: + color = Colors.yellow + n_np += 1 + else: + color = Colors.red + n_uncategorized += 1 + print_table_column(k, *v, color) + + print("Total number of transforms:", n_total) + print_color(f"Number transforms allowing both torch and numpy: {n_t_or_np}", Colors.green) + print_color(f"Number of TorchTransform: {n_t}", Colors.green) + print_color(f"Number of NumpyTransform: {n_np}", Colors.yellow) + print_color(f"Number of uncategorised: {n_uncategorized}", Colors.red) + + +if __name__ == "__main__": + print_transform_backends() diff --git a/tests/test_print_transform_backends.py b/tests/test_print_transform_backends.py index 09828f0a27..4164687f01 100644 --- a/tests/test_print_transform_backends.py +++ b/tests/test_print_transform_backends.py @@ -11,13 +11,17 @@ import unittest -from monai.transforms.utils import print_transform_backends +from monai.transforms.utils import get_transform_backends, print_transform_backends class TestPrintTransformBackends(unittest.TestCase): def test_get_number_of_conversions(self): + tr_t_or_np, *_ = get_transform_backends() + self.assertGreater(len(tr_t_or_np), 0) print_transform_backends() if __name__ == "__main__": - unittest.main() + # unittest.main() + a = TestPrintTransformBackends() + a.test_get_number_of_conversions() From a88976c1b66bc945ec9ec5a3549b6e25d015f94d Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Thu, 26 Aug 2021 01:40:56 +0800 Subject: [PATCH 63/89] 2707 Add support to randomly fill cutout holes (#2837) * [DLMED] support random fill Signed-off-by: Nic Ma * [DLMED] update for test Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] update dict transform Signed-off-by: Nic Ma * [DLMED] fix mypy Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] fix typo Signed-off-by: Nic Ma * [DLMED] support auto computing min and max Signed-off-by: Nic Ma * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot Co-authored-by: Wenqi Li --- monai/transforms/intensity/array.py | 15 +++++++++--- monai/transforms/intensity/dictionary.py | 15 +++++++++--- tests/test_rand_coarse_dropout.py | 29 ++++++++++++++++++------ tests/test_rand_coarse_dropoutd.py | 29 ++++++++++++++++++------ 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index b36c7adf96..a68f6a0a2e 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1643,7 +1643,10 @@ class RandCoarseDropout(RandomizableTransform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. - fill_value: target value to fill the dropout regions. + fill_value: target value to fill the dropout regions, if providing a number, will use it as constant + value to fill all the regions. if providing a tuple for the `min` and `max`, will randomly select + value for every pixel / voxel from the range `[min, max)`. if None, will compute the `min` and `max` + value of input image then randomly select value to fill, default to None. max_holes: if not None, define the maximum number to randomly select the expected number of regions. max_spatial_size: if not None, define the maximum spatial size to randomly select size for every region. if some components of the `max_spatial_size` are non-positive values, the transform will use the @@ -1657,7 +1660,7 @@ def __init__( self, holes: int, spatial_size: Union[Sequence[int], int], - fill_value: Union[float, int] = 0, + fill_value: Optional[Union[Tuple[float, float], float]] = None, max_holes: Optional[int] = None, max_spatial_size: Optional[Union[Sequence[int], int]] = None, prob: float = 0.1, @@ -1688,7 +1691,13 @@ def __call__(self, img: np.ndarray): self.randomize(img.shape[1:]) if self._do_transform: for h in self.hole_coords: - img[h] = self.fill_value + fill_value = (img.min(), img.max()) if self.fill_value is None else self.fill_value + if isinstance(fill_value, (tuple, list)): + if len(fill_value) != 2: + raise ValueError("fill_value should contain 2 numbers if providing the `min` and `max`.") + img[h] = self.R.uniform(fill_value[0], fill_value[1], size=img[h].shape) + else: + img[h] = fill_value return img diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 227b6fb434..beb210c645 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -1435,7 +1435,10 @@ class RandCoarseDropoutd(RandomizableTransform, MapTransform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. - fill_value: target value to fill the dropout regions. + fill_value: target value to fill the dropout regions, if providing a number, will use it as constant + value to fill all the regions. if providing a tuple for the `min` and `max`, will randomly select + value for every pixel / voxel from the range `[min, max)`. if None, will compute the `min` and `max` + value of input image then randomly select value to fill, default to None. max_holes: if not None, define the maximum number to randomly select the expected number of regions. max_spatial_size: if not None, define the maximum spatial size to randomly select size for every region. if some components of the `max_spatial_size` are non-positive values, the transform will use the @@ -1451,7 +1454,7 @@ def __init__( keys: KeysCollection, holes: int, spatial_size: Union[Sequence[int], int], - fill_value: Union[float, int] = 0, + fill_value: Optional[Union[Tuple[float, float], float]] = None, max_holes: Optional[int] = None, max_spatial_size: Optional[Union[Sequence[int], int]] = None, prob: float = 0.1, @@ -1487,7 +1490,13 @@ def __call__(self, data): if self._do_transform: for key in self.key_iterator(d): for h in self.hole_coords: - d[key][h] = self.fill_value + fill_value = (d[key].min(), d[key].max()) if self.fill_value is None else self.fill_value + if isinstance(fill_value, (tuple, list)): + if len(fill_value) != 2: + raise ValueError("fill_value should contain 2 numbers if providing the `min` and `max`.") + d[key][h] = self.R.uniform(fill_value[0], fill_value[1], size=d[key][h].shape) + else: + d[key][h] = fill_value return d diff --git a/tests/test_rand_coarse_dropout.py b/tests/test_rand_coarse_dropout.py index 235a391567..18d026e573 100644 --- a/tests/test_rand_coarse_dropout.py +++ b/tests/test_rand_coarse_dropout.py @@ -20,31 +20,37 @@ TEST_CASE_0 = [ {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "prob": 1.0}, np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), ] TEST_CASE_1 = [ {"holes": 1, "spatial_size": [1, 2, 3], "fill_value": 5, "max_holes": 5, "prob": 1.0}, np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), ] TEST_CASE_2 = [ {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "max_spatial_size": [4, 4, 3], "prob": 1.0}, np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), ] TEST_CASE_3 = [ {"holes": 2, "spatial_size": [2, -1, 2], "fill_value": 5, "max_spatial_size": [4, 4, -1], "prob": 1.0}, np.random.randint(0, 2, size=[3, 3, 3, 4]), - (3, 3, 3, 4), +] + +TEST_CASE_4 = [ + {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": (3, 6), "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), +] + +TEST_CASE_5 = [ + {"holes": 2, "spatial_size": [2, 2, 2], "fill_value": None, "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), ] class TestRandCoarseDropout(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_value(self, input_param, input_data, expected_shape): + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + def test_value(self, input_param, input_data): dropout = RandCoarseDropout(**input_param) result = dropout(input_data) holes = input_param.get("holes") @@ -60,7 +66,16 @@ def test_value(self, input_param, input_data, expected_shape): for h in dropout.hole_coords: data = result[h] - np.testing.assert_allclose(data, input_param.get("fill_value", 0)) + fill_value = input_param.get("fill_value", None) + if isinstance(fill_value, (int, float)): + np.testing.assert_allclose(data, fill_value) + elif fill_value is not None: + min_value = data.min() + max_value = data.max() + self.assertGreaterEqual(max_value, min_value) + self.assertGreaterEqual(min_value, fill_value[0]) + self.assertLess(max_value, fill_value[1]) + if max_spatial_size is None: self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) else: diff --git a/tests/test_rand_coarse_dropoutd.py b/tests/test_rand_coarse_dropoutd.py index d189a80f56..932e65c8cf 100644 --- a/tests/test_rand_coarse_dropoutd.py +++ b/tests/test_rand_coarse_dropoutd.py @@ -20,13 +20,11 @@ TEST_CASE_0 = [ {"keys": "img", "holes": 2, "spatial_size": [2, 2, 2], "fill_value": 5, "prob": 1.0}, {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, - (3, 3, 3, 4), ] TEST_CASE_1 = [ {"keys": "img", "holes": 1, "spatial_size": [1, 2, 3], "fill_value": 5, "max_holes": 5, "prob": 1.0}, {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, - (3, 3, 3, 4), ] TEST_CASE_2 = [ @@ -39,7 +37,6 @@ "prob": 1.0, }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, - (3, 3, 3, 4), ] TEST_CASE_3 = [ @@ -52,13 +49,22 @@ "prob": 1.0, }, {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])}, - (3, 3, 3, 4), +] + +TEST_CASE_4 = [ + {"keys": "img", "holes": 2, "spatial_size": [2, 2, 2], "fill_value": (0.2, 0.6), "prob": 1.0}, + {"img": np.random.rand(3, 3, 3, 4)}, +] + +TEST_CASE_5 = [ + {"keys": "img", "holes": 2, "spatial_size": [2, 2, 2], "fill_value": None, "prob": 1.0}, + {"img": np.random.rand(3, 3, 3, 4)}, ] class TestRandCoarseDropoutd(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_value(self, input_param, input_data, expected_shape): + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + def test_value(self, input_param, input_data): dropout = RandCoarseDropoutd(**input_param) result = dropout(input_data)["img"] holes = input_param.get("holes") @@ -74,7 +80,16 @@ def test_value(self, input_param, input_data, expected_shape): for h in dropout.hole_coords: data = result[h] - np.testing.assert_allclose(data, input_param.get("fill_value", 0)) + fill_value = input_param.get("fill_value", 0) + if isinstance(fill_value, (int, float)): + np.testing.assert_allclose(data, fill_value) + elif fill_value is not None: + min_value = data.min() + max_value = data.max() + self.assertGreaterEqual(max_value, min_value) + self.assertGreaterEqual(min_value, fill_value[0]) + self.assertLess(max_value, fill_value[1]) + if max_spatial_size is None: self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) else: From 26f844684613b4d85c0c4214e0616c2ab2f82c52 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Wed, 25 Aug 2021 20:45:36 +0100 Subject: [PATCH 64/89] ShiftIntensityd, RandShiftIntensityd (#2839) backends -> backend --- monai/transforms/intensity/dictionary.py | 8 ++++++-- tests/test_rand_shift_intensityd.py | 17 +++++++++-------- tests/test_shift_intensityd.py | 11 ++++++----- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index beb210c645..23f72c5677 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -245,6 +245,8 @@ class ShiftIntensityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ShiftIntensity`. """ + backend = ShiftIntensity.backend + def __init__( self, keys: KeysCollection, @@ -283,7 +285,7 @@ def __init__( self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) self.shifter = ShiftIntensity(offset) - def __call__(self, data) -> Dict[Hashable, np.ndarray]: + def __call__(self, data) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, factor_key, meta_key, meta_key_postfix in self.key_iterator( d, self.factor_key, self.meta_keys, self.meta_key_postfix @@ -300,6 +302,8 @@ class RandShiftIntensityd(RandomizableTransform, MapTransform): Dictionary-based version :py:class:`monai.transforms.RandShiftIntensity`. """ + backend = ShiftIntensity.backend + def __init__( self, keys: KeysCollection, @@ -355,7 +359,7 @@ def randomize(self, data: Optional[Any] = None) -> None: self._offset = self.R.uniform(low=self.offsets[0], high=self.offsets[1]) super().randomize(None) - def __call__(self, data) -> Dict[Hashable, np.ndarray]: + def __call__(self, data) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) self.randomize() if not self._do_transform: diff --git a/tests/test_rand_shift_intensityd.py b/tests/test_rand_shift_intensityd.py index 71cfd8fc50..6766236146 100644 --- a/tests/test_rand_shift_intensityd.py +++ b/tests/test_rand_shift_intensityd.py @@ -14,18 +14,19 @@ import numpy as np from monai.transforms import IntensityStatsd, RandShiftIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestRandShiftIntensityd(NumpyImageTestCase2D): def test_value(self): - key = "img" - shifter = RandShiftIntensityd(keys=[key], offsets=1.0, prob=1.0) - shifter.set_random_state(seed=0) - result = shifter({key: self.imt}) - np.random.seed(0) - expected = self.imt + np.random.uniform(low=-1.0, high=1.0) - np.testing.assert_allclose(result[key], expected) + for p in TEST_NDARRAYS: + key = "img" + shifter = RandShiftIntensityd(keys=[key], offsets=1.0, prob=1.0) + shifter.set_random_state(seed=0) + result = shifter({key: p(self.imt)}) + np.random.seed(0) + expected = self.imt + np.random.uniform(low=-1.0, high=1.0) + assert_allclose(result[key], expected) def test_factor(self): key = "img" diff --git a/tests/test_shift_intensityd.py b/tests/test_shift_intensityd.py index 71cfffc9c5..0396857781 100644 --- a/tests/test_shift_intensityd.py +++ b/tests/test_shift_intensityd.py @@ -14,16 +14,17 @@ import numpy as np from monai.transforms import IntensityStatsd, ShiftIntensityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestShiftIntensityd(NumpyImageTestCase2D): def test_value(self): key = "img" - shifter = ShiftIntensityd(keys=[key], offset=1.0) - result = shifter({key: self.imt}) - expected = self.imt + 1.0 - np.testing.assert_allclose(result[key], expected) + for p in TEST_NDARRAYS: + shifter = ShiftIntensityd(keys=[key], offset=1.0) + result = shifter({key: p(self.imt)}) + expected = self.imt + 1.0 + assert_allclose(result[key], expected) def test_factor(self): key = "img" From 415f9949c98ff555642a0ac365bb6fbfc2026607 Mon Sep 17 00:00:00 2001 From: Yiheng Wang <68361391+yiheng-wang-nv@users.noreply.github.com> Date: Thu, 26 Aug 2021 09:23:40 +0800 Subject: [PATCH 65/89] 2812 enhance swish and efficientnet (#2813) * enhance swish Signed-off-by: Yiheng Wang * add inplace to swish Signed-off-by: Yiheng Wang * add efn features Signed-off-by: Yiheng Wang * add more pretrained weights url Signed-off-by: Yiheng Wang * fix flake8 errors Signed-off-by: Yiheng Wang * use look up option Signed-off-by: Yiheng Wang * fix deepsource issues Signed-off-by: Yiheng Wang * fix type issues Signed-off-by: Yiheng Wang Co-authored-by: Wenqi Li --- docs/source/networks.rst | 5 + monai/networks/blocks/activation.py | 37 ++++- monai/networks/nets/__init__.py | 9 +- monai/networks/nets/densenet.py | 9 +- monai/networks/nets/efficientnet.py | 218 ++++++++++++++++++++-------- monai/networks/nets/senet.py | 8 +- tests/test_efficientnet.py | 42 +++++- 7 files changed, 255 insertions(+), 73 deletions(-) diff --git a/docs/source/networks.rst b/docs/source/networks.rst index a5ce86287a..54c2756535 100644 --- a/docs/source/networks.rst +++ b/docs/source/networks.rst @@ -358,6 +358,11 @@ Nets .. autoclass:: EfficientNetBN :members: +`EfficientNetBNFeatures` +~~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: EfficientNetBNFeatures + :members: + `SegResNet` ~~~~~~~~~~~ .. autoclass:: SegResNet diff --git a/monai/networks/blocks/activation.py b/monai/networks/blocks/activation.py index ef2d19b550..a380f8e757 100644 --- a/monai/networks/blocks/activation.py +++ b/monai/networks/blocks/activation.py @@ -16,16 +16,28 @@ if optional_import("torch.nn.functional", name="mish")[1]: - def monai_mish(x): - return torch.nn.functional.mish(x, inplace=True) + def monai_mish(x, inplace: bool = False): + return torch.nn.functional.mish(x, inplace=inplace) else: - def monai_mish(x): + def monai_mish(x, inplace: bool = False): return x * torch.tanh(torch.nn.functional.softplus(x)) +if optional_import("torch.nn.functional", name="silu")[1]: + + def monai_swish(x, inplace: bool = False): + return torch.nn.functional.silu(x, inplace=inplace) + + +else: + + def monai_swish(x, inplace: bool = False): + return SwishImplementation.apply(x) + + class Swish(nn.Module): r"""Applies the element-wise function: @@ -92,6 +104,9 @@ class MemoryEfficientSwish(nn.Module): Citation: Searching for Activation Functions, Ramachandran et al., 2017, https://arxiv.org/abs/1710.05941. + From Pytorch 1.7.0+, the optimized version of `Swish` named `SiLU` is implemented, + this class will utilize `torch.nn.functional.silu` to do the calculation if meets the version. + Shape: - Input: :math:`(N, *)` where `*` means, any number of additional dimensions @@ -107,8 +122,13 @@ class MemoryEfficientSwish(nn.Module): >>> output = m(input) """ + def __init__(self, inplace: bool = False): + super(MemoryEfficientSwish, self).__init__() + # inplace only works when using torch.nn.functional.silu + self.inplace = inplace + def forward(self, input: torch.Tensor): - return SwishImplementation.apply(input) + return monai_swish(input, self.inplace) class Mish(nn.Module): @@ -119,6 +139,8 @@ class Mish(nn.Module): Citation: Mish: A Self Regularized Non-Monotonic Activation Function, Diganta Misra, 2019, https://arxiv.org/abs/1908.08681. + From Pytorch 1.9.0+, the optimized version of `Mish` is implemented, + this class will utilize `torch.nn.functional.mish` to do the calculation if meets the version. Shape: - Input: :math:`(N, *)` where `*` means, any number of additional @@ -135,5 +157,10 @@ class Mish(nn.Module): >>> output = m(input) """ + def __init__(self, inplace: bool = False): + super(Mish, self).__init__() + # inplace only works when using torch.nn.functional.mish + self.inplace = inplace + def forward(self, input: torch.Tensor): - return monai_mish(input) + return monai_mish(input, self.inplace) diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py index 9cf6c5e07f..ad1ca2418b 100644 --- a/monai/networks/nets/__init__.py +++ b/monai/networks/nets/__init__.py @@ -31,7 +31,14 @@ densenet264, ) from .dynunet import DynUNet, DynUnet, Dynunet, dynunet -from .efficientnet import BlockArgs, EfficientNet, EfficientNetBN, drop_connect, get_efficientnet_image_size +from .efficientnet import ( + BlockArgs, + EfficientNet, + EfficientNetBN, + EfficientNetBNFeatures, + drop_connect, + get_efficientnet_image_size, +) from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet from .generator import Generator from .highresnet import HighResBlock, HighResNet diff --git a/monai/networks/nets/densenet.py b/monai/networks/nets/densenet.py index 3e30987bdc..e9f3b6d33e 100644 --- a/monai/networks/nets/densenet.py +++ b/monai/networks/nets/densenet.py @@ -19,6 +19,7 @@ from monai.networks.layers.factories import Conv, Dropout, Pool from monai.networks.layers.utils import get_act_layer, get_norm_layer +from monai.utils.module import look_up_option __all__ = [ "DenseNet", @@ -249,7 +250,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -def _load_state_dict(model, arch, progress): +def _load_state_dict(model: nn.Module, arch: str, progress: bool): """ This function is used to load pretrained models. Adapted from PyTorch Hub 2D version: https://pytorch.org/vision/stable/models.html#id16. @@ -260,12 +261,12 @@ def _load_state_dict(model, arch, progress): "densenet169": "https://download.pytorch.org/models/densenet169-b2777c0a.pth", "densenet201": "https://download.pytorch.org/models/densenet201-c1103571.pth", } - if arch in model_urls: - model_url = model_urls[arch] - else: + model_url = look_up_option(arch, model_urls, None) + if model_url is None: raise ValueError( "only 'densenet121', 'densenet169' and 'densenet201' are supported to load pretrained weights." ) + pattern = re.compile( r"^(.*denselayer\d+)(\.(?:norm|relu|conv))\.((?:[12])\.(?:weight|bias|running_mean|running_var))$" ) diff --git a/monai/networks/nets/efficientnet.py b/monai/networks/nets/efficientnet.py index cb8e195b04..453916758a 100644 --- a/monai/networks/nets/efficientnet.py +++ b/monai/networks/nets/efficientnet.py @@ -21,6 +21,7 @@ from monai.networks.layers.factories import Act, Conv, Pad, Pool from monai.networks.layers.utils import get_norm_layer +from monai.utils.module import look_up_option __all__ = ["EfficientNet", "EfficientNetBN", "get_efficientnet_image_size", "drop_connect"] @@ -34,6 +35,29 @@ "efficientnet-b5": (1.6, 2.2, 456, 0.4, 0.2), "efficientnet-b6": (1.8, 2.6, 528, 0.5, 0.2), "efficientnet-b7": (2.0, 3.1, 600, 0.5, 0.2), + "efficientnet-b8": (2.2, 3.6, 672, 0.5, 0.2), + "efficientnet-l2": (4.3, 5.3, 800, 0.5, 0.2), +} + +url_map = { + "efficientnet-b0": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b0-355c32eb.pth", + "efficientnet-b1": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b1-f1951068.pth", + "efficientnet-b2": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b2-8bb594d6.pth", + "efficientnet-b3": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b3-5fb5a3c3.pth", + "efficientnet-b4": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b4-6ed6700e.pth", + "efficientnet-b5": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b5-b6417697.pth", + "efficientnet-b6": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b6-c76e70fd.pth", + "efficientnet-b7": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b7-dcc49843.pth", + # trained with adversarial examples, simplify the name to decrease string length + "b0-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b0-b64d5a18.pth", + "b1-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b1-0f3ce85a.pth", + "b2-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b2-6e9d97e5.pth", + "b3-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b3-cdd7c0f4.pth", + "b4-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b4-44fb3a87.pth", + "b5-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b5-86493f6b.pth", + "b6-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b6-ac80338e.pth", + "b7-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b7-4652b6dd.pth", + "b8-ap": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/adv-efficientnet-b8-22a8fe65.pth", } @@ -140,7 +164,7 @@ def __init__( # swish activation to use - using memory efficient swish by default # can be switched to normal swish using self.set_swish() function call - self._swish = Act["memswish"]() + self._swish = Act["memswish"](inplace=True) def forward(self, inputs: torch.Tensor): """MBConvBlock"s forward function. @@ -188,7 +212,7 @@ def set_swish(self, memory_efficient: bool = True) -> None: Args: memory_efficient (bool): Whether to use memory-efficient version of swish. """ - self._swish = Act["memswish"]() if memory_efficient else Act["swish"](alpha=1.0) + self._swish = Act["memswish"](inplace=True) if memory_efficient else Act["swish"](alpha=1.0) class EfficientNet(nn.Module): @@ -208,8 +232,7 @@ def __init__( ) -> None: """ EfficientNet based on `Rethinking Model Scaling for Convolutional Neural Networks `_. - Adapted from `EfficientNet-PyTorch - `_. + Adapted from `EfficientNet-PyTorch `_. Args: blocks_args_str: block definitions. @@ -220,9 +243,10 @@ def __init__( depth_coefficient: depth multiplier coefficient (d in paper). dropout_rate: dropout rate for dropout layers. image_size: input image resolution. - norm: feature normalization type and arguments. Defaults to batch norm. + norm: feature normalization type and arguments. drop_connect_rate: dropconnect rate for drop connection (individual weights) layers. depth_divisor: depth divisor for channel rounding. + """ super().__init__() @@ -266,6 +290,8 @@ def __init__( num_blocks = 0 self._blocks = nn.Sequential() + self.extract_stacks = [] + # update baseline blocks to input/output filters and number of repeats based on width and depth multipliers. for idx, block_args in enumerate(self._blocks_args): block_args = block_args._replace( @@ -278,17 +304,23 @@ def __init__( # calculate the total number of blocks - needed for drop_connect estimation num_blocks += block_args.num_repeat + if block_args.stride > 1: + self.extract_stacks.append(idx) + + self.extract_stacks.append(len(self._blocks_args)) + # create and add MBConvBlocks to self._blocks idx = 0 # block index counter - for block_args in self._blocks_args: + for stack_idx, block_args in enumerate(self._blocks_args): blk_drop_connect_rate = self.drop_connect_rate # scale drop connect_rate if blk_drop_connect_rate: blk_drop_connect_rate *= float(idx) / num_blocks + sub_stack = nn.Sequential() # the first block needs to take care of stride and filter size increase. - self._blocks.add_module( + sub_stack.add_module( str(idx), MBConvBlock( spatial_dims=spatial_dims, @@ -319,7 +351,7 @@ def __init__( blk_drop_connect_rate *= float(idx) / num_blocks # add blocks - self._blocks.add_module( + sub_stack.add_module( str(idx), MBConvBlock( spatial_dims=spatial_dims, @@ -337,9 +369,14 @@ def __init__( ) idx += 1 # increment blocks index counter + self._blocks.add_module( + str(stack_idx), + sub_stack, + ) + # sanity check to see if len(self._blocks) equal expected num_blocks - if len(self._blocks) != num_blocks: - raise ValueError("number of blocks created != num_blocks") + if idx != num_blocks: + raise ValueError("total number of blocks created != num_blocks") # Head head_in_channels = block_args.output_filters @@ -369,8 +406,9 @@ def set_swish(self, memory_efficient: bool = True) -> None: """ self._swish = Act["memswish"]() if memory_efficient else Act["swish"](alpha=1.0) - for block in self._blocks: - block.set_swish(memory_efficient) + for sub_stack in self._blocks: + for block in sub_stack: + block.set_swish(memory_efficient) def forward(self, inputs: torch.Tensor): """ @@ -379,8 +417,7 @@ def forward(self, inputs: torch.Tensor): ``(Batch, in_channels, dim_0[, dim_1, ..., dim_N])``, N is defined by `dimensions`. Returns: - A torch Tensor of classification prediction in shape - ``(Batch, num_classes)``. + a torch Tensor of classification prediction in shape ``(Batch, num_classes)``. """ # Stem x = self._conv_stem(self._conv_stem_padding(inputs)) @@ -436,21 +473,24 @@ def __init__( in_channels: int = 3, num_classes: int = 1000, norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.01}), + adv_prop: bool = False, ) -> None: """ Generic wrapper around EfficientNet, used to initialize EfficientNet-B0 to EfficientNet-B7 models model_name is mandatory argument as there is no EfficientNetBN itself, - it needs the N in [0, 1, 2, 3, 4, 5, 6, 7] to be a model + it needs the N in [0, 1, 2, 3, 4, 5, 6, 7, 8] to be a model Args: - model_name: name of model to initialize, can be from [efficientnet-b0, ..., efficientnet-b7]. + model_name: name of model to initialize, can be from [efficientnet-b0, ..., efficientnet-b8, efficientnet-l2]. pretrained: whether to initialize pretrained ImageNet weights, only available for spatial_dims=2 and batch norm is used. progress: whether to show download progress for pretrained weights download. spatial_dims: number of spatial dimensions. in_channels: number of input channels. num_classes: number of output classes. - norm: feature normalization type and arguments. Defaults to batch norm. + norm: feature normalization type and arguments. + adv_prop: whether to use weights trained with adversarial examples. + This argument only works when `pretrained` is `True`. Examples:: @@ -471,7 +511,7 @@ def __init__( >>> model = EfficientNetBN("efficientnet-b7", spatial_dims=2) """ - # block args for EfficientNet-B0 to EfficientNet-B7 + # block args blocks_args_str = [ "r1_k3_s11_e1_i32_o16_se0.25", "r2_k3_s22_e6_i16_o24_se0.25", @@ -507,16 +547,91 @@ def __init__( norm=norm, ) - # attempt to load pretrained - is_default_model = (spatial_dims == 2) and (in_channels == 3) - loadable_from_file = pretrained and is_default_model + # only pretrained for when `spatial_dims` is 2 + if pretrained and (spatial_dims == 2): + _load_state_dict(self, model_name, progress, adv_prop) + + +class EfficientNetBNFeatures(EfficientNet): + def __init__( + self, + model_name: str, + pretrained: bool = True, + progress: bool = True, + spatial_dims: int = 2, + in_channels: int = 3, + num_classes: int = 1000, + norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.01}), + adv_prop: bool = False, + ) -> None: + """ + Initialize EfficientNet-B0 to EfficientNet-B7 models as a backbone, the backbone can + be used as an encoder for segmentation and objection models. + Compared with the class `EfficientNetBN`, the only different place is the forward function. + + This class refers to `PyTorch image models `_. - if loadable_from_file: - # skip loading fc layers for transfer learning applications - load_fc = num_classes == 1000 + """ + blocks_args_str = [ + "r1_k3_s11_e1_i32_o16_se0.25", + "r2_k3_s22_e6_i16_o24_se0.25", + "r2_k5_s22_e6_i24_o40_se0.25", + "r3_k3_s22_e6_i40_o80_se0.25", + "r3_k5_s11_e6_i80_o112_se0.25", + "r4_k5_s22_e6_i112_o192_se0.25", + "r1_k3_s11_e6_i192_o320_se0.25", + ] + + # check if model_name is valid model + if model_name not in efficientnet_params.keys(): + raise ValueError( + "invalid model_name {} found, must be one of {} ".format( + model_name, ", ".join(efficientnet_params.keys()) + ) + ) + + # get network parameters + weight_coeff, depth_coeff, image_size, dropout_rate, dropconnect_rate = efficientnet_params[model_name] + + # create model and initialize random weights + super(EfficientNetBNFeatures, self).__init__( + blocks_args_str=blocks_args_str, + spatial_dims=spatial_dims, + in_channels=in_channels, + num_classes=num_classes, + width_coefficient=weight_coeff, + depth_coefficient=depth_coeff, + dropout_rate=dropout_rate, + image_size=image_size, + drop_connect_rate=dropconnect_rate, + norm=norm, + ) + + # only pretrained for when `spatial_dims` is 2 + if pretrained and (spatial_dims == 2): + _load_state_dict(self, model_name, progress, adv_prop) + + def forward(self, inputs: torch.Tensor): + """ + Args: + inputs: input should have spatially N dimensions + ``(Batch, in_channels, dim_0[, dim_1, ..., dim_N])``, N is defined by `dimensions`. + + Returns: + a list of torch Tensors. + """ + # Stem + x = self._conv_stem(self._conv_stem_padding(inputs)) + x = self._swish(self._bn0(x)) - # only pretrained for when `spatial_dims` is 2 - _load_state_dict(self, model_name, progress, load_fc) + features = [] + if 0 in self.extract_stacks: + features.append(x) + for i, block in enumerate(self._blocks): + x = block(x) + if i + 1 in self.extract_stacks: + features.append(x) + return features def get_efficientnet_image_size(model_name: str) -> int: @@ -588,38 +703,25 @@ def drop_connect(inputs: torch.Tensor, p: float, training: bool) -> torch.Tensor return output -def _load_state_dict(model: nn.Module, model_name: str, progress: bool, load_fc: bool) -> None: - url_map = { - "efficientnet-b0": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b0-355c32eb.pth", - "efficientnet-b1": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b1-f1951068.pth", - "efficientnet-b2": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b2-8bb594d6.pth", - "efficientnet-b3": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b3-5fb5a3c3.pth", - "efficientnet-b4": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b4-6ed6700e.pth", - "efficientnet-b5": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b5-b6417697.pth", - "efficientnet-b6": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b6-c76e70fd.pth", - "efficientnet-b7": "https://github.com/lukemelas/EfficientNet-PyTorch/releases/download/1.0/efficientnet-b7-dcc49843.pth", - } - # load state dict from url - model_url = url_map[model_name] - state_dict = model_zoo.load_url(model_url, progress=progress) - - # load state dict into model parameters - if load_fc: # load everything - ret = model.load_state_dict(state_dict, strict=False) - if ret.missing_keys: - raise ValueError("Found missing keys when loading pretrained weights: {}".format(ret.missing_keys)) - else: # skip final FC layers, for transfer learning cases - state_dict.pop("_fc.weight") - state_dict.pop("_fc.bias") - ret = model.load_state_dict(state_dict, strict=False) - - # check if no other keys missing except FC layer parameters - if set(ret.missing_keys) != {"_fc.weight", "_fc.bias"}: - raise ValueError("Found missing keys when loading pretrained weights: {}".format(ret.missing_keys)) - - # check for any unexpected keys - if ret.unexpected_keys: - raise ValueError("Missing keys when loading pretrained weights: {}".format(ret.unexpected_keys)) +def _load_state_dict(model: nn.Module, arch: str, progress: bool, adv_prop: bool) -> None: + if adv_prop: + arch = arch.split("efficientnet-")[-1] + "-ap" + model_url = look_up_option(arch, url_map, None) + if model_url is None: + print("pretrained weights of {} is not provided".format(arch)) + else: + # load state dict from url + model_url = url_map[arch] + pretrain_state_dict = model_zoo.load_url(model_url, progress=progress) + model_state_dict = model.state_dict() + + pattern = re.compile(r"(.+)\.\d+(\.\d+\..+)") + for key, value in model_state_dict.items(): + pretrain_key = re.sub(pattern, r"\1\2", key) + if pretrain_key in pretrain_state_dict and value.shape == pretrain_state_dict[pretrain_key].shape: + model_state_dict[key] = pretrain_state_dict[pretrain_key] + + model.load_state_dict(model_state_dict) def _get_same_padding_conv_nd( diff --git a/monai/networks/nets/senet.py b/monai/networks/nets/senet.py index 7292b2a1d5..9b7035c259 100644 --- a/monai/networks/nets/senet.py +++ b/monai/networks/nets/senet.py @@ -20,6 +20,7 @@ from monai.networks.blocks.convolutions import Convolution from monai.networks.blocks.squeeze_and_excitation import SEBottleneck, SEResNetBottleneck, SEResNeXtBottleneck from monai.networks.layers.factories import Act, Conv, Dropout, Norm, Pool +from monai.utils.module import look_up_option __all__ = ["SENet", "SENet154", "SEResNet50", "SEResNet101", "SEResNet152", "SEResNeXt50", "SEResNext101"] @@ -249,7 +250,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: return x -def _load_state_dict(model, arch, progress): +def _load_state_dict(model: nn.Module, arch: str, progress: bool): """ This function is used to load pretrained models. """ @@ -261,9 +262,8 @@ def _load_state_dict(model, arch, progress): "se_resnext50_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext50_32x4d-a260b3a4.pth", "se_resnext101_32x4d": "http://data.lip6.fr/cadene/pretrainedmodels/se_resnext101_32x4d-3b2fe3d8.pth", } - if arch in model_urls: - model_url = model_urls[arch] - else: + model_url = look_up_option(arch, model_urls, None) + if model_url is None: raise ValueError( "only 'senet154', 'se_resnet50', 'se_resnet101', 'se_resnet152', 'se_resnext50_32x4d', " + "and se_resnext101_32x4d are supported to load pretrained weights." diff --git a/tests/test_efficientnet.py b/tests/test_efficientnet.py index 6567e3af9a..6befba108a 100644 --- a/tests/test_efficientnet.py +++ b/tests/test_efficientnet.py @@ -18,7 +18,13 @@ from parameterized import parameterized from monai.networks import eval_mode -from monai.networks.nets import BlockArgs, EfficientNetBN, drop_connect, get_efficientnet_image_size +from monai.networks.nets import ( + BlockArgs, + EfficientNetBN, + EfficientNetBNFeatures, + drop_connect, + get_efficientnet_image_size, +) from monai.utils import optional_import from tests.utils import skip_if_quick, test_pretrained_networks, test_script_save @@ -156,6 +162,7 @@ def make_shape_cases( "in_channels": 3, "num_classes": 1000, "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}), + "adv_prop": False, }, os.path.join(os.path.dirname(__file__), "testing_data", "kitty_test.jpg"), 282, # ~ tiger cat @@ -226,6 +233,21 @@ def make_shape_cases( ) ) +CASE_EXTRACT_FEATURES = [ + ( + { + "model_name": "efficientnet-b8", + "pretrained": True, + "progress": False, + "spatial_dims": 2, + "in_channels": 2, + "adv_prop": True, + }, + [1, 2, 224, 224], + ([1, 32, 112, 112], [1, 56, 56, 56], [1, 88, 28, 28], [1, 248, 14, 14], [1, 704, 7, 7]), + ), +] + class TestEFFICIENTNET(unittest.TestCase): @parameterized.expand(CASES_1D + CASES_2D + CASES_3D + CASES_VARIATIONS) @@ -355,5 +377,23 @@ def test_script(self): test_script_save(net, test_data) +class TestExtractFeatures(unittest.TestCase): + @parameterized.expand(CASE_EXTRACT_FEATURES) + def test_shape(self, input_param, input_shape, expected_shapes): + device = "cuda" if torch.cuda.is_available() else "cpu" + + # initialize model + net = EfficientNetBNFeatures(**input_param).to(device) + + # run inference with random tensor + with eval_mode(net): + features = net(torch.randn(input_shape).to(device)) + + # check output shape + self.assertEqual(len(features), len(expected_shapes)) + for feature, expected_shape in zip(features, expected_shapes): + self.assertEqual(feature.shape, torch.Size(expected_shape)) + + if __name__ == "__main__": unittest.main() From 1065dadb95b69ea84330ddbe4d58f5ced2491ac0 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Thu, 26 Aug 2021 17:24:25 +0100 Subject: [PATCH 66/89] temp. resume github premerge tests (#2853) * resume github premerge tests Signed-off-by: Wenqi Li * fixes #2821 Signed-off-by: Wenqi Li * update group names Signed-off-by: Wenqi Li --- .github/workflows/pythonapp-gpu.yml | 130 ++++++++++++++++++++++++++++ tests/test_gaussian_filter.py | 3 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pythonapp-gpu.yml diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml new file mode 100644 index 0000000000..e29f7631b0 --- /dev/null +++ b/.github/workflows/pythonapp-gpu.yml @@ -0,0 +1,130 @@ +name: build-gpu + +on: + # quick tests for pull requests and the releasing branches + push: + branches: + - dev + - main + - releasing/* + pull_request: + +concurrency: + # automatically cancel the previously triggered workflows when there's a newer version + group: build-gpu-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + GPU-quick-py3: # GPU with full dependencies + if: github.repository == 'Project-MONAI/MONAI' + strategy: + matrix: + environment: + - "PT16+CUDA110" + - "PT17+CUDA102" + - "PT17+CUDA110" + - "PT18+CUDA102" + - "PT19+CUDA113" + - "PT19+CUDA102" + include: + - environment: PT16+CUDA110 + # we explicitly set pytorch to -h to avoid pip install error + pytorch: "-h" + base: "nvcr.io/nvidia/pytorch:20.07-py3" + - environment: PT17+CUDA102 + pytorch: "torch==1.7.1 torchvision==0.8.2" + base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" + - environment: PT17+CUDA110 + # we explicitly set pytorch to -h to avoid pip install error + pytorch: "-h" + base: "nvcr.io/nvidia/pytorch:20.09-py3" + - environment: PT18+CUDA102 + pytorch: "torch==1.8.1 torchvision==0.9.1" + base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" + - environment: PT19+CUDA113 + # we explicitly set pytorch to -h to avoid pip install error + pytorch: "-h" + base: "nvcr.io/nvidia/pytorch:21.06-py3" + - environment: PT19+CUDA102 + pytorch: "torch==1.9.0 torchvision==0.10.0" + base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" + container: + image: ${{ matrix.base }} + options: --gpus all + runs-on: [self-hosted, linux, x64, common] + steps: + - uses: actions/checkout@v2 + - name: apt install + run: | + if [ ${{ matrix.environment }} = "PT17+CUDA102" ] || \ + [ ${{ matrix.environment }} = "PT18+CUDA102" ] || \ + [ ${{ matrix.environment }} = "PT19+CUDA102" ] + then + PYVER=3.6 PYSFX=3 DISTUTILS=python3-distutils && \ + apt-get update && apt-get install -y --no-install-recommends \ + curl \ + pkg-config \ + python$PYVER \ + python$PYVER-dev \ + python$PYSFX-pip \ + $DISTUTILS \ + rsync \ + swig \ + unzip \ + zip \ + zlib1g-dev \ + libboost-locale-dev \ + libboost-program-options-dev \ + libboost-system-dev \ + libboost-thread-dev \ + libboost-test-dev \ + libgoogle-glog-dev \ + libjsoncpp-dev \ + cmake \ + git && \ + rm -rf /var/lib/apt/lists/* && \ + export PYTHONIOENCODING=utf-8 LC_ALL=C.UTF-8 && \ + rm -f /usr/bin/python && \ + rm -f /usr/bin/python`echo $PYVER | cut -c1-1` && \ + ln -s /usr/bin/python$PYVER /usr/bin/python && \ + ln -s /usr/bin/python$PYVER /usr/bin/python`echo $PYVER | cut -c1-1` && + curl -O https://bootstrap.pypa.io/get-pip.py && \ + python get-pip.py && \ + rm get-pip.py; + fi + - name: Install dependencies + run: | + which python + python -m pip install --upgrade pip wheel + python -m pip install ${{ matrix.pytorch }} + python -m pip install -r requirements-dev.txt + python -m pip list + - name: Run quick tests (GPU) + run: | + git clone --depth 1 \ + https://github.com/Project-MONAI/MONAI-extra-test-data.git /MONAI-extra-test-data + export MONAI_EXTRA_TEST_DATA="/MONAI-extra-test-data" + nvidia-smi + export LAUNCH_DELAY=$(python -c "import numpy; print(numpy.random.randint(30) * 10)") + echo "Sleep $LAUNCH_DELAY" + sleep $LAUNCH_DELAY + export CUDA_VISIBLE_DEVICES=$(coverage run -m tests.utils) + echo $CUDA_VISIBLE_DEVICES + trap 'if pgrep python; then pkill python; fi;' ERR + python -c $'import torch\na,b=torch.zeros(1,device="cuda:0"),torch.zeros(1,device="cuda:1");\nwhile True:print(a,b)' > /dev/null & + python -c "import torch; print(torch.__version__); print('{} of GPUs available'.format(torch.cuda.device_count()))" + python -c 'import torch; print(torch.rand(5, 3, device=torch.device("cuda:0")))' + python -c "import monai; monai.config.print_config()" + # build for the current self-hosted CI Tesla V100 + BUILD_MONAI=1 TORCH_CUDA_ARCH_LIST="7.0" ./runtests.sh --quick --unittests + if [ ${{ matrix.environment }} = "PT19+CUDA102" ]; then + # test the clang-format tool downloading once + coverage run -m tests.clang_format_utils + fi + coverage xml + if pgrep python; then pkill python; fi + shell: bash + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml diff --git a/tests/test_gaussian_filter.py b/tests/test_gaussian_filter.py index e056c961c9..7636aa5459 100644 --- a/tests/test_gaussian_filter.py +++ b/tests/test_gaussian_filter.py @@ -16,7 +16,7 @@ from parameterized import parameterized from monai.networks.layers import GaussianFilter -from tests.utils import skip_if_quick +from tests.utils import SkipIfBeforePyTorchVersion, skip_if_quick TEST_CASES = [[{"type": "erf", "gt": 2.0}], [{"type": "scalespace", "gt": 3.0}], [{"type": "sampled", "gt": 5.0}]] TEST_CASES_GPU = [ @@ -85,6 +85,7 @@ def code_to_run(self, input_args): ) @parameterized.expand(TEST_CASES + TEST_CASES_GPU + TEST_CASES_3d) + @SkipIfBeforePyTorchVersion((1, 7)) def test_train_quick(self, input_args): self.code_to_run(input_args) From ce139f6ed86c3d9c80fd29893d1a3e8c977ec80a Mon Sep 17 00:00:00 2001 From: Jirka Borovec Date: Thu, 26 Aug 2021 19:19:30 +0200 Subject: [PATCH 67/89] CI: pre-commit (#2843) * add pre-commit Signed-off-by: Jirka --- .deepsource.toml | 2 +- .dockerignore | 1 - .github/ISSUE_TEMPLATE/question.md | 2 +- .github/workflows/weekly-preview.yml | 1 - .pre-commit-config.yaml | 63 +++++++++++++ docs/source/metrics.rst | 2 +- monai/config/type_definitions.py | 88 ++++++++----------- .../bilateral/bilateralfilter_cpu.cpp | 2 +- monai/csrc/filtering/filtering.h | 2 +- .../filtering/permutohedral/hash_table.cuh | 2 +- .../filtering/permutohedral/permutohedral.h | 2 +- .../permutohedral/permutohedral_cpu.cpp | 2 +- 12 files changed, 108 insertions(+), 61 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.deepsource.toml b/.deepsource.toml index f090928ffa..a67393d967 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -24,4 +24,4 @@ enabled = true [[analyzers]] name = "shell" -enabled = true \ No newline at end of file +enabled = true diff --git a/.dockerignore b/.dockerignore index 4e1161bfb2..19bbf580ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,3 @@ coverage.xml *.toml !README.md - diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index 9c2dd97bb2..e822461059 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -9,4 +9,4 @@ assignees: '' **Please use MONAI's Discussions tab** For questions relating to MONAI usage, please do not create an issue. -Instead, use [MONAI's GitHub Discussions tab](https://github.com/Project-MONAI/MONAI/discussions). This can be found next to Issues and Pull Requests along the top of our repository. \ No newline at end of file +Instead, use [MONAI's GitHub Discussions tab](https://github.com/Project-MONAI/MONAI/discussions). This can be found next to Issues and Pull Requests along the top of our repository. diff --git a/.github/workflows/weekly-preview.yml b/.github/workflows/weekly-preview.yml index 981ca5cdaf..df0b5dd759 100644 --- a/.github/workflows/weekly-preview.yml +++ b/.github/workflows/weekly-preview.yml @@ -43,4 +43,3 @@ jobs: with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..fd6a351d59 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,63 @@ +default_language_version: + python: python3.8 + +ci: + autofix_prs: true + autoupdate_commit_msg: '[pre-commit.ci] pre-commit suggestions' + autoupdate_schedule: quarterly + # submodules: true + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-toml + - id: check-case-conflict + - id: check-added-large-files + args: ['--maxkb=1024'] + - id: detect-private-key + + #- repo: https://github.com/asottile/pyupgrade + # rev: v2.23.2 + # hooks: + # - id: pyupgrade + # args: [--py36-plus] + # name: Upgrade code + + #- repo: https://github.com/asottile/yesqa + # rev: v1.2.3 + # hooks: + # - id: yesqa + # name: Unused noqa + + #- repo: https://github.com/PyCQA/isort + # rev: 5.9.3 + # hooks: + # - id: isort + # name: Format imports + + - repo: https://github.com/psf/black + rev: 21.7b0 + hooks: + - id: black + name: Format code + + #- repo: https://github.com/executablebooks/mdformat + # rev: 0.7.8 + # hooks: + # - id: mdformat + # additional_dependencies: + # - mdformat-gfm + # - mdformat_frontmatter + # exclude: CHANGELOG.md + + - repo: https://github.com/PyCQA/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + name: Check PEP8 diff --git a/docs/source/metrics.rst b/docs/source/metrics.rst index e6605065c4..c1ed25831d 100644 --- a/docs/source/metrics.rst +++ b/docs/source/metrics.rst @@ -83,4 +83,4 @@ Metrics `Peak signal to noise ratio` ---------------------------- .. autoclass:: PSNRMetric - :members: \ No newline at end of file + :members: diff --git a/monai/config/type_definitions.py b/monai/config/type_definitions.py index b236467bbc..91ac74961b 100644 --- a/monai/config/type_definitions.py +++ b/monai/config/type_definitions.py @@ -14,69 +14,55 @@ import numpy as np import torch +# Commonly used concepts +# This module provides naming and type specifications for commonly used concepts +# within the MONAI package. The intent is to explicitly identify information +# that should be used consistently throughout the entire MONAI package. +# +# A type would be named as type_definitions.KeysCollection +# which includes a meaningful name for the consent in the name itself. The +# definitions in this file map context meaningful names to the underlying +# object properties that define the expected API. +# +# A conceptual type is represented by a new type name but is also one which +# can be different depending on an environment (i.e. differences for python 3.6 vs 3.9 +# may be implemented). Consistent use of the concept and recorded documentation of +# the rationale and convention behind it lowers the learning curve for new +# developers. For readability, short names are preferred. __all__ = ["KeysCollection", "IndexSelection", "DtypeLike", "NdarrayTensor", "NdarrayOrTensor", "TensorOrList"] -"""Commonly used concepts -This module provides naming and type specifications for commonly used concepts -within the MONAI package. The intent is to explicitly identify information -that should be used consistently throughout the entire MONAI package. - -A type would be named as type_definitions.KeysCollection -which includes a meaningful name for the consent in the name itself. The -definitions in this file map context meaningful names to the underlying -object properties that define the expected API. - -A conceptual type is represented by a new type name but is also one which -can be different depending on an environment (i.e. differences for python 3.6 vs 3.9 -may be implemented). Consistent use of the concept and recorded documentation of -the rationale and convention behind it lowers the learning curve for new -developers. For readability, short names are preferred. -""" +#: KeysCollection +# +# The KeyCollection type is used to for defining variables +# that store a subset of keys to select items from a dictionary. +# The container of keys must contain hashable elements. +# NOTE: `Hashable` is not a collection, but is provided as a +# convenience to end-users. All supplied values will be +# internally converted to a tuple of `Hashable`'s before +# use KeysCollection = Union[Collection[Hashable], Hashable] -"""KeysCollection - -The KeyCollection type is used to for defining variables -that store a subset of keys to select items from a dictionary. -The container of keys must contain hashable elements. -NOTE: `Hashable` is not a collection, but is provided as a - convenience to end-users. All supplied values will be - internally converted to a tuple of `Hashable`'s before - use -""" - +#: IndexSelection +# +# The IndexSelection type is used to for defining variables +# that store a subset of indices to select items from a List or Array like objects. +# The indices must be integers, and if a container of indices is specified, the +# container must be iterable. IndexSelection = Union[Iterable[int], int] -"""IndexSelection - -The IndexSelection type is used to for defining variables -that store a subset of indices to select items from a List or Array like objects. -The indices must be integers, and if a container of indices is specified, the -container must be iterable. -""" - +#: Type of datatypes: Adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py DtypeLike = Union[np.dtype, type, None] -"""Type of datatypes - -Adapted from https://github.com/numpy/numpy/blob/master/numpy/typing/_dtype_like.py -""" +#: NdarrayTensor +# +# Generic type which can represent either a numpy.ndarray or a torch.Tensor +# Unlike Union can create a dependence between parameter(s) / return(s) NdarrayTensor = TypeVar("NdarrayTensor", np.ndarray, torch.Tensor) -"""NdarrayTensor -Generic type which can represent either a numpy.ndarray or a torch.Tensor -Unlike Union can create a dependence between parameter(s) / return(s) -""" +#: NdarrayOrTensor: Union of numpy.ndarray and torch.Tensor to be used for typing NdarrayOrTensor = Union[np.ndarray, torch.Tensor] -"""NdarrayOrTensor - -Union of numpy.ndarray and torch.Tensor to be used for typing -""" +#: TensorOrList: The TensorOrList type is used for defining `batch-first Tensor` or `list of channel-first Tensor`. TensorOrList = Union[torch.Tensor, Sequence[torch.Tensor]] -"""TensorOrList - -The TensorOrList type is used for defining `batch-first Tensor` or `list of channel-first Tensor`. -""" diff --git a/monai/csrc/filtering/bilateral/bilateralfilter_cpu.cpp b/monai/csrc/filtering/bilateral/bilateralfilter_cpu.cpp index 474d24b4fa..2e6c7dbe20 100644 --- a/monai/csrc/filtering/bilateral/bilateralfilter_cpu.cpp +++ b/monai/csrc/filtering/bilateral/bilateralfilter_cpu.cpp @@ -164,4 +164,4 @@ torch::Tensor BilateralFilterCpu(torch::Tensor inputTensor, float spatialSigma, })); return outputTensor; -} \ No newline at end of file +} diff --git a/monai/csrc/filtering/filtering.h b/monai/csrc/filtering/filtering.h index 25186b182a..3dcdfc473b 100644 --- a/monai/csrc/filtering/filtering.h +++ b/monai/csrc/filtering/filtering.h @@ -14,4 +14,4 @@ limitations under the License. #pragma once #include "bilateral/bilateral.h" -#include "permutohedral/permutohedral.h" \ No newline at end of file +#include "permutohedral/permutohedral.h" diff --git a/monai/csrc/filtering/permutohedral/hash_table.cuh b/monai/csrc/filtering/permutohedral/hash_table.cuh index f9893dffe2..1acff5f276 100644 --- a/monai/csrc/filtering/permutohedral/hash_table.cuh +++ b/monai/csrc/filtering/permutohedral/hash_table.cuh @@ -257,4 +257,4 @@ __device__ static int hashTableRetrieve(signed short* key) { if (h == table_capacity * 2) h = 0; } -} \ No newline at end of file +} diff --git a/monai/csrc/filtering/permutohedral/permutohedral.h b/monai/csrc/filtering/permutohedral/permutohedral.h index 32ffee83e5..1c9d1a031e 100644 --- a/monai/csrc/filtering/permutohedral/permutohedral.h +++ b/monai/csrc/filtering/permutohedral/permutohedral.h @@ -25,4 +25,4 @@ template void PermutohedralCuda(scalar_t* data, scalar_t* features, int elementCount, bool accurate); #endif -torch::Tensor PermutohedralFilter(torch::Tensor input, torch::Tensor features); \ No newline at end of file +torch::Tensor PermutohedralFilter(torch::Tensor input, torch::Tensor features); diff --git a/monai/csrc/filtering/permutohedral/permutohedral_cpu.cpp b/monai/csrc/filtering/permutohedral/permutohedral_cpu.cpp index 0876997448..8c0dc8e546 100644 --- a/monai/csrc/filtering/permutohedral/permutohedral_cpu.cpp +++ b/monai/csrc/filtering/permutohedral/permutohedral_cpu.cpp @@ -499,4 +499,4 @@ void PermutohedralCPU(scalar_t* data, scalar_t* features, int dataChannels, int } template void PermutohedralCPU(float* data, float* features, int dataChannels, int featureChannels, int elementCount); -template void PermutohedralCPU(double* data, double* features, int dataChannels, int featureChannels, int elementCount); \ No newline at end of file +template void PermutohedralCPU(double* data, double* features, int dataChannels, int featureChannels, int elementCount); From 3eb71c7dc6b0801a12735ae228d90818829c3275 Mon Sep 17 00:00:00 2001 From: Vishwesh Date: Thu, 26 Aug 2021 14:33:29 -0500 Subject: [PATCH 68/89] Local Patch Shuffle Transform Initial Version Added (#2757) * Local Patch Shuffle Transform Initial Version Added Signed-off-by: vnath --- .pre-commit-config.yaml | 20 +++--- docs/source/transforms.rst | 5 ++ monai/transforms/__init__.py | 1 + monai/transforms/intensity/array.py | 94 ++++++++++++++++++++++++++ monai/transforms/spatial/array.py | 1 - tests/test_rand_local_patch_shuffle.py | 49 ++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 tests/test_rand_local_patch_shuffle.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd6a351d59..c36c96186c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,11 +41,11 @@ repos: # - id: isort # name: Format imports - - repo: https://github.com/psf/black - rev: 21.7b0 - hooks: - - id: black - name: Format code + # - repo: https://github.com/psf/black + # rev: 21.7b0 + # hooks: + # - id: black + # name: Format code #- repo: https://github.com/executablebooks/mdformat # rev: 0.7.8 @@ -56,8 +56,8 @@ repos: # - mdformat_frontmatter # exclude: CHANGELOG.md - - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 - hooks: - - id: flake8 - name: Check PEP8 + # - repo: https://github.com/PyCQA/flake8 + # rev: 3.9.2 + # hooks: + # - id: flake8 + # name: Check PEP8 diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index da8ceda0e3..b8f57e0dbe 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -320,6 +320,11 @@ Intensity :members: :special-members: __call__ +`LocalPatchShuffling` +""""""""""""""""""""" +.. autoclass:: LocalPatchShuffling + :members: + :special-members: __call__ IO ^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 5267af4048..41b0872698 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -85,6 +85,7 @@ GibbsNoise, HistogramNormalize, KSpaceSpikeNoise, + LocalPatchShuffling, MaskIntensity, NormalizeIntensity, RandAdjustContrast, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index a68f6a0a2e..ddf79bdbd6 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -13,6 +13,7 @@ https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ +import copy from collections.abc import Iterable from functools import partial from typing import Any, Callable, List, Optional, Sequence, Tuple, Union @@ -70,6 +71,7 @@ "RandKSpaceSpikeNoise", "RandCoarseDropout", "HistogramNormalize", + "LocalPatchShuffling", ] @@ -1742,3 +1744,95 @@ def __call__(self, img: np.ndarray, mask: Optional[np.ndarray] = None) -> np.nda max=self.max, dtype=self.dtype, ) + + +class LocalPatchShuffling(RandomizableTransform): + """ + Takes a 3D image and based on input of the local patch size, shuffles the pixels of the local patch within it. + This process is repeated a for N number of times where every time a different random block is selected for local + pixel shuffling. + + Kang, Guoliang, et al. "Patchshuffle regularization." arXiv preprint arXiv:1707.07103 (2017). + """ + + def __init__( + self, + prob: float = 1.0, + number_blocks: int = 1000, + blocksize_ratio: int = 10, + channel_wise: bool = True, + device: Optional[torch.device] = None, + image_only: bool = False, + ) -> None: + """ + Args: + prob: The chance of this transform occuring on the given volume. + number_blocks: Total number of time a random 3D block will be selected for local shuffling of pixels/voxels + contained in the block. + blocksize_ratio: This ratio can be used to estimate the local 3D block sizes that will be selected. + channel_wise: If True, treats each channel of the image separately. + device: device on which the tensor will be allocated. + image_only: if True return only the image volume, otherwise return (image, affine). + """ + RandomizableTransform.__init__(self, prob) + self.prob = prob + self.number_blocks = number_blocks + self.blocksize_ratio = blocksize_ratio + self.channel_wise = channel_wise + + def _local_patch_shuffle(self, img: Union[torch.Tensor, np.ndarray], number_blocks: int, blocksize_ratio: int): + im_shape = img.shape + img_copy = copy.deepcopy(img) + for _each_block in range(number_blocks): + + block_size_x = self.R.randint(1, im_shape[0] // blocksize_ratio) + block_size_y = self.R.randint(1, im_shape[1] // blocksize_ratio) + block_size_z = self.R.randint(1, im_shape[2] // blocksize_ratio) + + noise_x = self.R.randint(0, im_shape[0] - block_size_x) + noise_y = self.R.randint(0, im_shape[1] - block_size_y) + noise_z = self.R.randint(0, im_shape[2] - block_size_z) + + local_patch = img[ + noise_x : noise_x + block_size_x, + noise_y : noise_y + block_size_y, + noise_z : noise_z + block_size_z, + ] + + local_patch = local_patch.flatten() + self.R.shuffle(local_patch) + local_patch = local_patch.reshape((block_size_x, block_size_y, block_size_z)) + + img_copy[ + noise_x : noise_x + block_size_x, noise_y : noise_y + block_size_y, noise_z : noise_z + block_size_z + ] = local_patch + + shuffled_image = img_copy + return shuffled_image + + def __call__( + self, + img: Union[np.ndarray, torch.Tensor], + # spatial_size: Optional[Union[Sequence[int], int]] = None, + # mode: Optional[Union[GridSampleMode, str]] = None, + # padding_mode: Optional[Union[GridSamplePadMode, str]] = None, + ): + """ + Args: + img: shape must be (num_channels, H, W[, D]), + + """ + + super().randomize(None) + if not self._do_transform: + return img + + if self.channel_wise: + # img = self._local_patch_shuffle(img=img) + for i, _d in enumerate(img): + img[i] = self._local_patch_shuffle( + img=img[i], blocksize_ratio=self.blocksize_ratio, number_blocks=self.number_blocks + ) + else: + raise AssertionError("If channel_wise is False, the image needs to be set to channel first") + return img diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index a7d93f88f3..c3bd4a3433 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -12,7 +12,6 @@ A collection of "vanilla" transforms for spatial operations https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design """ - import warnings from typing import Any, List, Optional, Sequence, Tuple, Union diff --git a/tests/test_rand_local_patch_shuffle.py b/tests/test_rand_local_patch_shuffle.py new file mode 100644 index 0000000000..8e2eefb5d1 --- /dev/null +++ b/tests/test_rand_local_patch_shuffle.py @@ -0,0 +1,49 @@ +# Copyright 2020 - 2021 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 import LocalPatchShuffling + +TEST_CASES = [ + [ + {"number_blocks": 10, "blocksize_ratio": 1, "prob": 0.0}, + {"img": np.arange(8).reshape((1, 2, 2, 2))}, + np.arange(8).reshape((1, 2, 2, 2)), + ], + [ + {"number_blocks": 10, "blocksize_ratio": 1, "prob": 1.0}, + {"img": np.arange(27).reshape((1, 3, 3, 3))}, + [ + [ + [[9, 1, 2], [3, 4, 5], [6, 7, 8]], + [[0, 10, 11], [12, 4, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ] + ], + ], +] + + +class TestLocalPatchShuffle(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_local_patch_shuffle(self, input_param, input_data, expected_val): + g = LocalPatchShuffling(**input_param) + g.set_random_state(seed=12) + result = g(**input_data) + np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4) + + +if __name__ == "__main__": + unittest.main() From dd56514d28c5fbe08795676e150da0d1872f108c Mon Sep 17 00:00:00 2001 From: Jirka Borovec Date: Thu, 26 Aug 2021 23:19:44 +0200 Subject: [PATCH 69/89] extend ResNet pretrained info (#2854) Signed-off-by: Jirka --- monai/networks/nets/resnet.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/monai/networks/nets/resnet.py b/monai/networks/nets/resnet.py index 647f2648c8..f34de563ce 100644 --- a/monai/networks/nets/resnet.py +++ b/monai/networks/nets/resnet.py @@ -308,7 +308,10 @@ def _resnet( # Author of paper zipped the state_dict on googledrive, # so would need to download, unzip and read (2.8gb file for a ~150mb state dict). # Would like to load dict from url but need somewhere to save the state dicts. - raise NotImplementedError("Currently not implemented, see comments in source code") + raise NotImplementedError( + "Currently not implemented. You need to manually download weights provided by the paper's author" + " and load then to the model with `state_dict`. See https://github.com/Tencent/MedicalNet" + ) return model From f981ad080ec3f902dfa20653cc9ea7d3d37890e8 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 27 Aug 2021 08:42:16 +0800 Subject: [PATCH 70/89] 2707 Fill the outside of holes (#2848) * [DLMED] add support to fill outside Signed-off-by: Nic Ma * [DLMED] add paper link for cutout Signed-off-by: Nic Ma Co-authored-by: Wenqi Li --- monai/transforms/intensity/array.py | 35 +++++++++++----- monai/transforms/intensity/dictionary.py | 52 +++++++++--------------- tests/test_rand_coarse_dropout.py | 29 ++++++++----- tests/test_rand_coarse_dropoutd.py | 37 ++++++++++------- 4 files changed, 87 insertions(+), 66 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index ddf79bdbd6..5268794c7d 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1633,8 +1633,9 @@ def _set_default_range(self, img: torch.Tensor) -> Sequence[Sequence[float]]: class RandCoarseDropout(RandomizableTransform): """ Randomly coarse dropout regions in the image, then fill in the rectangular regions with specified value. - Refer to: https://arxiv.org/abs/1708.04552 and: - https://albumentations.ai/docs/api_reference/augmentations/transforms/ + Or keep the rectangular regions and fill in the other areas with specified value. + Refer to papers: https://arxiv.org/abs/1708.04552, https://arxiv.org/pdf/1604.07379 + And other implementation: https://albumentations.ai/docs/api_reference/augmentations/transforms/ #albumentations.augmentations.transforms.CoarseDropout. Args: @@ -1645,6 +1646,8 @@ class RandCoarseDropout(RandomizableTransform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. + dropout_holes: if `True`, dropout the regions of holes and fill value, if `False`, keep the holes and + dropout the outside and fill value. default to `True`. fill_value: target value to fill the dropout regions, if providing a number, will use it as constant value to fill all the regions. if providing a tuple for the `min` and `max`, will randomly select value for every pixel / voxel from the range `[min, max)`. if None, will compute the `min` and `max` @@ -1662,6 +1665,7 @@ def __init__( self, holes: int, spatial_size: Union[Sequence[int], int], + dropout_holes: bool = True, fill_value: Optional[Union[Tuple[float, float], float]] = None, max_holes: Optional[int] = None, max_spatial_size: Optional[Union[Sequence[int], int]] = None, @@ -1672,6 +1676,10 @@ def __init__( raise ValueError("number of holes must be greater than 0.") self.holes = holes self.spatial_size = spatial_size + self.dropout_holes = dropout_holes + if isinstance(fill_value, (tuple, list)): + if len(fill_value) != 2: + raise ValueError("fill value should contain 2 numbers if providing the `min` and `max`.") self.fill_value = fill_value self.max_holes = max_holes self.max_spatial_size = max_spatial_size @@ -1692,16 +1700,23 @@ def randomize(self, img_size: Sequence[int]) -> None: def __call__(self, img: np.ndarray): self.randomize(img.shape[1:]) if self._do_transform: - for h in self.hole_coords: - fill_value = (img.min(), img.max()) if self.fill_value is None else self.fill_value + fill_value = (img.min(), img.max()) if self.fill_value is None else self.fill_value + + if self.dropout_holes: + for h in self.hole_coords: + if isinstance(fill_value, (tuple, list)): + img[h] = self.R.uniform(fill_value[0], fill_value[1], size=img[h].shape) + else: + img[h] = fill_value + return img + else: if isinstance(fill_value, (tuple, list)): - if len(fill_value) != 2: - raise ValueError("fill_value should contain 2 numbers if providing the `min` and `max`.") - img[h] = self.R.uniform(fill_value[0], fill_value[1], size=img[h].shape) + ret = self.R.uniform(fill_value[0], fill_value[1], size=img.shape).astype(img.dtype) else: - img[h] = fill_value - - return img + ret = np.full_like(img, fill_value) + for h in self.hole_coords: + ret[h] = img[h] + return ret class HistogramNormalize(Transform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 23f72c5677..bc53fb6b7b 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -23,7 +23,6 @@ from monai.config import DtypeLike, KeysCollection, NdarrayTensor from monai.config.type_definitions import NdarrayOrTensor -from monai.data.utils import get_random_patch, get_valid_patch_size from monai.transforms.intensity.array import ( AdjustContrast, GaussianSharpen, @@ -34,6 +33,7 @@ MaskIntensity, NormalizeIntensity, RandBiasField, + RandCoarseDropout, RandGaussianNoise, RandKSpaceSpikeNoise, RandRicianNoise, @@ -44,9 +44,9 @@ StdShiftIntensity, ThresholdIntensity, ) -from monai.transforms.transform import MapTransform, RandomizableTransform +from monai.transforms.transform import MapTransform, Randomizable, RandomizableTransform from monai.transforms.utils import is_positive -from monai.utils import convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size, fall_back_tuple +from monai.utils import convert_to_dst_type, ensure_tuple, ensure_tuple_rep, ensure_tuple_size __all__ = [ "RandGaussianNoised", @@ -1423,7 +1423,7 @@ def _to_numpy(self, d: Union[torch.Tensor, np.ndarray]) -> np.ndarray: return d_numpy -class RandCoarseDropoutd(RandomizableTransform, MapTransform): +class RandCoarseDropoutd(Randomizable, MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.RandCoarseDropout`. Expect all the data specified by `keys` have same spatial shape and will randomly dropout the same regions @@ -1439,6 +1439,8 @@ class RandCoarseDropoutd(RandomizableTransform, MapTransform): if some components of the `spatial_size` are non-positive values, the transform will use the corresponding components of input img size. For example, `spatial_size=(32, -1)` will be adapted to `(32, 64)` if the second spatial dimension size of img is `64`. + dropout_holes: if `True`, dropout the regions of holes and fill value, if `False`, keep the holes and + dropout the outside and fill value. default to `True`. fill_value: target value to fill the dropout regions, if providing a number, will use it as constant value to fill all the regions. if providing a tuple for the `min` and `max`, will randomly select value for every pixel / voxel from the range `[min, max)`. if None, will compute the `min` and `max` @@ -1458,6 +1460,7 @@ def __init__( keys: KeysCollection, holes: int, spatial_size: Union[Sequence[int], int], + dropout_holes: bool = True, fill_value: Optional[Union[Tuple[float, float], float]] = None, max_holes: Optional[int] = None, max_spatial_size: Optional[Union[Sequence[int], int]] = None, @@ -1465,42 +1468,27 @@ def __init__( allow_missing_keys: bool = False, ): MapTransform.__init__(self, keys, allow_missing_keys) - RandomizableTransform.__init__(self, prob) - if holes < 1: - raise ValueError("number of holes must be greater than 0.") - self.holes = holes - self.spatial_size = spatial_size - self.fill_value = fill_value - self.max_holes = max_holes - self.max_spatial_size = max_spatial_size - self.hole_coords: List = [] + self.dropper = RandCoarseDropout( + holes=holes, + spatial_size=spatial_size, + dropout_holes=dropout_holes, + fill_value=fill_value, + max_holes=max_holes, + max_spatial_size=max_spatial_size, + prob=prob, + ) def randomize(self, img_size: Sequence[int]) -> None: - super().randomize(None) - size = fall_back_tuple(self.spatial_size, img_size) - self.hole_coords = [] # clear previously computed coords - num_holes = self.holes if self.max_holes is None else self.R.randint(self.holes, self.max_holes + 1) - for _ in range(num_holes): - if self.max_spatial_size is not None: - max_size = fall_back_tuple(self.max_spatial_size, img_size) - size = tuple(self.R.randint(low=size[i], high=max_size[i] + 1) for i in range(len(img_size))) - valid_size = get_valid_patch_size(img_size, size) - self.hole_coords.append((slice(None),) + get_random_patch(img_size, valid_size, self.R)) + self.dropper.randomize(img_size=img_size) def __call__(self, data): d = dict(data) # expect all the specified keys have same spatial shape self.randomize(d[self.keys[0]].shape[1:]) - if self._do_transform: + if self.dropper._do_transform: for key in self.key_iterator(d): - for h in self.hole_coords: - fill_value = (d[key].min(), d[key].max()) if self.fill_value is None else self.fill_value - if isinstance(fill_value, (tuple, list)): - if len(fill_value) != 2: - raise ValueError("fill_value should contain 2 numbers if providing the `min` and `max`.") - d[key][h] = self.R.uniform(fill_value[0], fill_value[1], size=d[key][h].shape) - else: - d[key][h] = fill_value + d[key] = self.dropper(img=d[key]) + return d diff --git a/tests/test_rand_coarse_dropout.py b/tests/test_rand_coarse_dropout.py index 18d026e573..830832c2a5 100644 --- a/tests/test_rand_coarse_dropout.py +++ b/tests/test_rand_coarse_dropout.py @@ -47,9 +47,14 @@ np.random.randint(0, 2, size=[3, 3, 3, 4]), ] +TEST_CASE_6 = [ + {"holes": 2, "spatial_size": [2, 2, 2], "dropout_holes": False, "fill_value": (3, 6), "prob": 1.0}, + np.random.randint(0, 2, size=[3, 3, 3, 4]), +] + class TestRandCoarseDropout(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) def test_value(self, input_param, input_data): dropout = RandCoarseDropout(**input_param) result = dropout(input_data) @@ -66,15 +71,19 @@ def test_value(self, input_param, input_data): for h in dropout.hole_coords: data = result[h] - fill_value = input_param.get("fill_value", None) - if isinstance(fill_value, (int, float)): - np.testing.assert_allclose(data, fill_value) - elif fill_value is not None: - min_value = data.min() - max_value = data.max() - self.assertGreaterEqual(max_value, min_value) - self.assertGreaterEqual(min_value, fill_value[0]) - self.assertLess(max_value, fill_value[1]) + # test hole value + if input_param.get("dropout_holes", True): + fill_value = input_param.get("fill_value", None) + if isinstance(fill_value, (int, float)): + np.testing.assert_allclose(data, fill_value) + elif fill_value is not None: + min_value = data.min() + max_value = data.max() + self.assertGreaterEqual(max_value, min_value) + self.assertGreaterEqual(min_value, fill_value[0]) + self.assertLess(max_value, fill_value[1]) + else: + np.testing.assert_allclose(data, input_data[h]) if max_spatial_size is None: self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) diff --git a/tests/test_rand_coarse_dropoutd.py b/tests/test_rand_coarse_dropoutd.py index 932e65c8cf..fc898a9fca 100644 --- a/tests/test_rand_coarse_dropoutd.py +++ b/tests/test_rand_coarse_dropoutd.py @@ -61,9 +61,14 @@ {"img": np.random.rand(3, 3, 3, 4)}, ] +TEST_CASE_6 = [ + {"keys": "img", "holes": 2, "spatial_size": [2, 2, 2], "dropout_holes": False, "fill_value": 0.5, "prob": 1.0}, + {"img": np.random.rand(3, 3, 3, 4)}, +] + class TestRandCoarseDropoutd(unittest.TestCase): - @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6]) def test_value(self, input_param, input_data): dropout = RandCoarseDropoutd(**input_param) result = dropout(input_data)["img"] @@ -73,22 +78,26 @@ def test_value(self, input_param, input_data): max_spatial_size = fall_back_tuple(input_param.get("max_spatial_size"), input_data["img"].shape[1:]) if max_holes is None: - self.assertEqual(len(dropout.hole_coords), holes) + self.assertEqual(len(dropout.dropper.hole_coords), holes) else: - self.assertGreaterEqual(len(dropout.hole_coords), holes) - self.assertLessEqual(len(dropout.hole_coords), max_holes) + self.assertGreaterEqual(len(dropout.dropper.hole_coords), holes) + self.assertLessEqual(len(dropout.dropper.hole_coords), max_holes) - for h in dropout.hole_coords: + for h in dropout.dropper.hole_coords: data = result[h] - fill_value = input_param.get("fill_value", 0) - if isinstance(fill_value, (int, float)): - np.testing.assert_allclose(data, fill_value) - elif fill_value is not None: - min_value = data.min() - max_value = data.max() - self.assertGreaterEqual(max_value, min_value) - self.assertGreaterEqual(min_value, fill_value[0]) - self.assertLess(max_value, fill_value[1]) + # test hole value + if input_param.get("dropout_holes", True): + fill_value = input_param.get("fill_value", 0) + if isinstance(fill_value, (int, float)): + np.testing.assert_allclose(data, fill_value) + elif fill_value is not None: + min_value = data.min() + max_value = data.max() + self.assertGreaterEqual(max_value, min_value) + self.assertGreaterEqual(min_value, fill_value[0]) + self.assertLess(max_value, fill_value[1]) + else: + np.testing.assert_allclose(data, input_data["img"][h]) if max_spatial_size is None: self.assertTupleEqual(data.shape[1:], tuple(spatial_size)) From f99ebdac91136c97914b89fc5a6787b3328a452d Mon Sep 17 00:00:00 2001 From: Andres Diaz-Pinto Date: Fri, 27 Aug 2021 10:12:09 +0100 Subject: [PATCH 71/89] Add deepedit transforms (#2810) * Add deepedit transforms Signed-off-by: Andres * Run unittests - autofix Signed-off-by: Andres * Update transform Signed-off-by: Andres --- monai/apps/deepedit/__init__.py | 10 ++ monai/apps/deepedit/transforms.py | 167 ++++++++++++++++++++++++++++++ tests/min_tests.py | 1 + tests/test_deepedit_transforms.py | 97 +++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 monai/apps/deepedit/__init__.py create mode 100644 monai/apps/deepedit/transforms.py create mode 100644 tests/test_deepedit_transforms.py diff --git a/monai/apps/deepedit/__init__.py b/monai/apps/deepedit/__init__.py new file mode 100644 index 0000000000..14ae193634 --- /dev/null +++ b/monai/apps/deepedit/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2020 - 2021 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. diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py new file mode 100644 index 0000000000..2ab2722076 --- /dev/null +++ b/monai/apps/deepedit/transforms.py @@ -0,0 +1,167 @@ +import json +import logging +from typing import Dict, Hashable, Mapping, Tuple + +import numpy as np + +from monai.config import KeysCollection +from monai.transforms.transform import MapTransform, Randomizable, Transform + +logger = logging.getLogger(__name__) + +from monai.utils import optional_import + +distance_transform_cdt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_cdt") + + +class DiscardAddGuidanced(MapTransform): + def __init__( + self, + keys: KeysCollection, + probability: float = 1.0, + allow_missing_keys: bool = False, + ): + """ + Discard positive and negative points randomly or Add the two channels for inference time + + :param probability: Discard probability; For inference it will be always 1.0 + """ + super().__init__(keys, allow_missing_keys) + self.probability = probability + + def _apply(self, image): + if self.probability >= 1.0 or np.random.choice([True, False], p=[self.probability, 1 - self.probability]): + signal = np.zeros((1, image.shape[-3], image.shape[-2], image.shape[-1]), dtype=np.float32) + if image.shape[0] == 3: + image[1] = signal + image[2] = signal + else: + image = np.concatenate((image, signal, signal), axis=0) + return image + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + d: Dict = dict(data) + for key in self.key_iterator(d): + if key == "image": + d[key] = self._apply(d[key]) + else: + print("This transform only applies to the image") + return d + + +class ResizeGuidanceCustomd(Transform): + """ + Resize the guidance based on cropped vs resized image. + """ + + def __init__( + self, + guidance: str, + ref_image: str, + ) -> None: + self.guidance = guidance + self.ref_image = ref_image + + def __call__(self, data): + d = dict(data) + current_shape = d[self.ref_image].shape[1:] + + factor = np.divide(current_shape, d["image_meta_dict"]["dim"][1:4]) + pos_clicks, neg_clicks = d["foreground"], d["background"] + + pos = np.multiply(pos_clicks, factor).astype(int).tolist() if len(pos_clicks) else [] + neg = np.multiply(neg_clicks, factor).astype(int).tolist() if len(neg_clicks) else [] + + d[self.guidance] = [pos, neg] + return d + + +class ClickRatioAddRandomGuidanced(Randomizable, Transform): + """ + Add random guidance based on discrepancies that were found between label and prediction. + Args: + guidance: key to guidance source, shape (2, N, # of dim) + discrepancy: key that represents discrepancies found between label and prediction, shape (2, C, D, H, W) or (2, C, H, W) + probability: key that represents click/interaction probability, shape (1) + fn_fp_click_ratio: ratio of clicks between FN and FP + """ + + def __init__( + self, + guidance: str = "guidance", + discrepancy: str = "discrepancy", + probability: str = "probability", + fn_fp_click_ratio: Tuple[float, float] = (1.0, 1.0), + ): + self.guidance = guidance + self.discrepancy = discrepancy + self.probability = probability + self.fn_fp_click_ratio = fn_fp_click_ratio + self._will_interact = None + + def randomize(self, data=None): + probability = data[self.probability] + self._will_interact = self.R.choice([True, False], p=[probability, 1.0 - probability]) + + def find_guidance(self, discrepancy): + distance = distance_transform_cdt(discrepancy).flatten() + probability = np.exp(distance) - 1.0 + idx = np.where(discrepancy.flatten() > 0)[0] + + if np.sum(discrepancy > 0) > 0: + seed = self.R.choice(idx, size=1, p=probability[idx] / np.sum(probability[idx])) + dst = distance[seed] + + g = np.asarray(np.unravel_index(seed, discrepancy.shape)).transpose().tolist()[0] + g[0] = dst[0] + return g + return None + + def add_guidance(self, discrepancy, will_interact): + if not will_interact: + return None, None + + pos_discr = discrepancy[0] + neg_discr = discrepancy[1] + + can_be_positive = np.sum(pos_discr) > 0 + can_be_negative = np.sum(neg_discr) > 0 + + pos_prob = self.fn_fp_click_ratio[0] / (self.fn_fp_click_ratio[0] + self.fn_fp_click_ratio[1]) + neg_prob = self.fn_fp_click_ratio[1] / (self.fn_fp_click_ratio[0] + self.fn_fp_click_ratio[1]) + + correct_pos = self.R.choice([True, False], p=[pos_prob, neg_prob]) + + if can_be_positive and not can_be_negative: + return self.find_guidance(pos_discr), None + + if not can_be_positive and can_be_negative: + return None, self.find_guidance(neg_discr) + + if correct_pos and can_be_positive: + return self.find_guidance(pos_discr), None + + if not correct_pos and can_be_negative: + return None, self.find_guidance(neg_discr) + return None, None + + def _apply(self, guidance, discrepancy): + guidance = guidance.tolist() if isinstance(guidance, np.ndarray) else guidance + guidance = json.loads(guidance) if isinstance(guidance, str) else guidance + pos, neg = self.add_guidance(discrepancy, self._will_interact) + if pos: + guidance[0].append(pos) + guidance[1].append([-1] * len(pos)) + if neg: + guidance[0].append([-1] * len(neg)) + guidance[1].append(neg) + + return json.dumps(np.asarray(guidance).astype(int).tolist()) + + def __call__(self, data): + d = dict(data) + guidance = d[self.guidance] + discrepancy = d[self.discrepancy] + self.randomize(data) + d[self.guidance] = self._apply(guidance, discrepancy) + return d diff --git a/tests/min_tests.py b/tests/min_tests.py index d2f8f0aff6..5b376d7b57 100644 --- a/tests/min_tests.py +++ b/tests/min_tests.py @@ -37,6 +37,7 @@ def run_testsuit(): "test_csv_iterable_dataset", "test_dataset", "test_dataset_summary", + "test_deepedit_transforms", "test_deepgrow_dataset", "test_deepgrow_interaction", "test_deepgrow_transforms", diff --git a/tests/test_deepedit_transforms.py b/tests/test_deepedit_transforms.py new file mode 100644 index 0000000000..c2b11e8ee7 --- /dev/null +++ b/tests/test_deepedit_transforms.py @@ -0,0 +1,97 @@ +# Copyright 2020 - 2021 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.apps.deepedit.transforms import ClickRatioAddRandomGuidanced, DiscardAddGuidanced, ResizeGuidanceCustomd + +IMAGE = np.array([[[[1, 0, 2, 0, 1], [0, 1, 2, 1, 0], [2, 2, 3, 2, 2], [0, 1, 2, 1, 0], [1, 0, 2, 0, 1]]]]) +LABEL = np.array([[[[0, 0, 0, 0, 0], [0, 1, 0, 1, 0], [0, 0, 1, 0, 0], [0, 1, 0, 1, 0], [0, 0, 0, 0, 0]]]]) + +DATA_1 = { + "image": IMAGE, + "label": LABEL, + "image_meta_dict": {"dim": IMAGE.shape}, + "label_meta_dict": {}, + "foreground": [0, 0, 0], + "background": [0, 0, 0], +} + +DISCARD_ADD_GUIDANCE_TEST_CASE = [ + {"image": IMAGE, "label": LABEL}, + DATA_1, + (3, 1, 5, 5), +] + +DATA_2 = { + "image": IMAGE, + "label": LABEL, + "guidance": np.array([[[1, 0, 2, 2]], [[-1, -1, -1, -1]]]), + "discrepancy": np.array( + [ + [[[[0, 0, 0, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]], + [[[[0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]]]], + ] + ), + "probability": 1.0, +} + +CLICK_RATIO_ADD_RANDOM_GUIDANCE_TEST_CASE_1 = [ + {"guidance": "guidance", "discrepancy": "discrepancy", "probability": "probability"}, + DATA_2, + "[[[1, 0, 2, 2], [-1, -1, -1, -1]], [[-1, -1, -1, -1], [1, 0, 2, 1]]]", +] + +DATA_3 = { + "image": np.arange(1000).reshape((1, 5, 10, 20)), + "image_meta_dict": {"foreground_cropped_shape": (1, 10, 20, 40), "dim": [3, 512, 512, 128]}, + "guidance": [[[6, 10, 14], [8, 10, 14]], [[8, 10, 16]]], + "foreground": [[10, 14, 6], [10, 14, 8]], + "background": [[10, 16, 8]], +} + +RESIZE_GUIDANCE_TEST_CASE_1 = [ + {"ref_image": "image", "guidance": "guidance"}, + DATA_3, + [[[0, 0, 0], [0, 0, 1]], [[0, 0, 1]]], +] + + +class TestDiscardAddGuidanced(unittest.TestCase): + @parameterized.expand([DISCARD_ADD_GUIDANCE_TEST_CASE]) + def test_correct_results(self, arguments, input_data, expected_result): + add_fn = DiscardAddGuidanced(arguments) + result = add_fn(input_data) + self.assertEqual(result["image"].shape, expected_result) + + +class TestClickRatioAddRandomGuidanced(unittest.TestCase): + @parameterized.expand([CLICK_RATIO_ADD_RANDOM_GUIDANCE_TEST_CASE_1]) + def test_correct_results(self, arguments, input_data, expected_result): + seed = 0 + add_fn = ClickRatioAddRandomGuidanced(**arguments) + add_fn.set_random_state(seed) + result = add_fn(input_data) + self.assertEqual(result[arguments["guidance"]], expected_result) + + +class TestResizeGuidanced(unittest.TestCase): + @parameterized.expand([RESIZE_GUIDANCE_TEST_CASE_1]) + def test_correct_results(self, arguments, input_data, expected_result): + result = ResizeGuidanceCustomd(**arguments)(input_data) + self.assertEqual(result[arguments["guidance"]], expected_result) + + +if __name__ == "__main__": + unittest.main() From 321454c6596223783cc1e264127a8c4ca75bf07f Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Fri, 27 Aug 2021 18:34:38 +0800 Subject: [PATCH 72/89] [DLMED] add missing return (#2857) Signed-off-by: Nic Ma --- monai/transforms/intensity/array.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 5268794c7d..20d306be04 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -1699,16 +1699,16 @@ def randomize(self, img_size: Sequence[int]) -> None: def __call__(self, img: np.ndarray): self.randomize(img.shape[1:]) + ret = img if self._do_transform: fill_value = (img.min(), img.max()) if self.fill_value is None else self.fill_value if self.dropout_holes: for h in self.hole_coords: if isinstance(fill_value, (tuple, list)): - img[h] = self.R.uniform(fill_value[0], fill_value[1], size=img[h].shape) + ret[h] = self.R.uniform(fill_value[0], fill_value[1], size=img[h].shape) else: - img[h] = fill_value - return img + ret[h] = fill_value else: if isinstance(fill_value, (tuple, list)): ret = self.R.uniform(fill_value[0], fill_value[1], size=img.shape).astype(img.dtype) @@ -1716,7 +1716,7 @@ def __call__(self, img: np.ndarray): ret = np.full_like(img, fill_value) for h in self.hole_coords: ret[h] = img[h] - return ret + return ret class HistogramNormalize(Transform): From aa5fa1d7bc54a0cb7fac7ffeddfa38ac51c498ab Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Fri, 27 Aug 2021 14:50:18 +0100 Subject: [PATCH 73/89] add cupy tests (#2858) Signed-off-by: Wenqi Li --- .github/workflows/pythonapp-gpu.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index e29f7631b0..72e164a499 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -4,7 +4,6 @@ on: # quick tests for pull requests and the releasing branches push: branches: - - dev - main - releasing/* pull_request: @@ -92,6 +91,9 @@ jobs: python get-pip.py && \ rm get-pip.py; fi + - if: matrix.environment == 'PT19+CUDA113' + name: Optional Cupy dependency (cuda113) + run: echo "cupy-cuda113" >> requirements-dev.txt - name: Install dependencies run: | which python From fe6ac0a542b6f4d5cfc390904f3468c265128169 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Fri, 27 Aug 2021 16:11:57 +0100 Subject: [PATCH 74/89] AddChannel, AsChannelFirst, AsChannelLast, EnsureChannelFirst, Identity, RepeatChannel (#2840) --- monai/transforms/__init__.py | 1 + monai/transforms/utility/array.py | 37 +++++++++++------ monai/transforms/utility/dictionary.py | 26 ++++++++---- .../utils_pytorch_numpy_unification.py | 41 +++++++++++++++++++ tests/test_add_channeld.py | 17 +++++--- tests/test_as_channel_first.py | 22 ++++++---- tests/test_as_channel_firstd.py | 21 +++++----- tests/test_as_channel_last.py | 17 ++++---- tests/test_as_channel_lastd.py | 21 +++++----- tests/test_ensure_channel_first.py | 8 ++-- tests/test_ensure_channel_firstd.py | 9 ++-- tests/test_identity.py | 11 +++-- tests/test_identityd.py | 13 +++--- tests/test_repeat_channel.py | 8 ++-- tests/test_repeat_channeld.py | 17 +++++--- 15 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 monai/transforms/utils_pytorch_numpy_unification.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 41b0872698..b0ba1e39d9 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -518,3 +518,4 @@ weighted_patch_samples, zero_margins, ) +from .utils_pytorch_numpy_unification import moveaxis diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index d56bca0d8d..580c6c8b3c 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -31,6 +31,7 @@ map_binary_to_indices, map_classes_to_indices, ) +from monai.transforms.utils_pytorch_numpy_unification import moveaxis from monai.utils import ( convert_to_numpy, convert_to_tensor, @@ -82,17 +83,18 @@ class Identity(Transform): """ - Convert the input to an np.ndarray, if input data is np.ndarray or subclasses, return unchanged data. + Do nothing to the data. As the output value is same as input, it can be used as a testing tool to verify the transform chain, Compose or transform adaptor, etc. - """ - def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> np.ndarray: + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return np.asanyarray(img) + return img class AsChannelFirst(Transform): @@ -111,16 +113,18 @@ class AsChannelFirst(Transform): channel_dim: which dimension of input image is the channel, default is the last dimension. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, channel_dim: int = -1) -> None: if not (isinstance(channel_dim, int) and channel_dim >= -1): raise AssertionError("invalid channel dimension.") self.channel_dim = channel_dim - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return np.moveaxis(img, self.channel_dim, 0) + return moveaxis(img, self.channel_dim, 0) class AsChannelLast(Transform): @@ -138,16 +142,18 @@ class AsChannelLast(Transform): channel_dim: which dimension of input image is the channel, default is the first dimension. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, channel_dim: int = 0) -> None: if not (isinstance(channel_dim, int) and channel_dim >= -1): raise AssertionError("invalid channel dimension.") self.channel_dim = channel_dim - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ - return np.moveaxis(img, self.channel_dim, -1) + return moveaxis(img, self.channel_dim, -1) class AddChannel(Transform): @@ -164,7 +170,9 @@ class AddChannel(Transform): transforms. """ - def __call__(self, img: NdarrayTensor): + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ @@ -179,6 +187,8 @@ class EnsureChannelFirst(Transform): Convert the data to `channel_first` based on the `original_channel_dim` information. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, strict_check: bool = True): """ Args: @@ -186,7 +196,7 @@ def __init__(self, strict_check: bool = True): """ self.strict_check = strict_check - def __call__(self, img: np.ndarray, meta_dict: Optional[Mapping] = None): + def __call__(self, img: NdarrayOrTensor, meta_dict: Optional[Mapping] = None) -> NdarrayOrTensor: """ Apply the transform to `img`. """ @@ -220,16 +230,19 @@ class RepeatChannel(Transform): repeats: the number of repetitions for each element. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, repeats: int) -> None: if repeats <= 0: raise AssertionError("repeats count must be greater than 0.") self.repeats = repeats - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`, assuming `img` is a "channel-first" array. """ - return np.repeat(img, self.repeats, 0) + repeeat_fn = torch.repeat_interleave if isinstance(img, torch.Tensor) else np.repeat + return repeeat_fn(img, self.repeats, 0) # type: ignore class RemoveRepeatedChannel(Transform): diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index a53e4f3235..41c2a1b9b9 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -169,6 +169,8 @@ class Identityd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.Identity`. """ + backend = Identity.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -180,9 +182,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.identity = Identity() - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.identity(d[key]) @@ -194,6 +194,8 @@ class AsChannelFirstd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AsChannelFirst`. """ + backend = AsChannelFirst.backend + def __init__(self, keys: KeysCollection, channel_dim: int = -1, allow_missing_keys: bool = False) -> None: """ Args: @@ -205,7 +207,7 @@ def __init__(self, keys: KeysCollection, channel_dim: int = -1, allow_missing_ke super().__init__(keys, allow_missing_keys) self.converter = AsChannelFirst(channel_dim=channel_dim) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -217,6 +219,8 @@ class AsChannelLastd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AsChannelLast`. """ + backend = AsChannelLast.backend + def __init__(self, keys: KeysCollection, channel_dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: @@ -228,7 +232,7 @@ def __init__(self, keys: KeysCollection, channel_dim: int = 0, allow_missing_key super().__init__(keys, allow_missing_keys) self.converter = AsChannelLast(channel_dim=channel_dim) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -240,6 +244,8 @@ class AddChanneld(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AddChannel`. """ + backend = AddChannel.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -250,7 +256,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.adder = AddChannel() - def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.adder(d[key]) @@ -262,6 +268,8 @@ class EnsureChannelFirstd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.EnsureChannelFirst`. """ + backend = EnsureChannelFirst.backend + def __init__( self, keys: KeysCollection, @@ -289,7 +297,7 @@ def __init__( self.meta_keys = ensure_tuple_rep(meta_keys, len(self.keys)) self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys)) - def __call__(self, data) -> Dict[Hashable, np.ndarray]: + def __call__(self, data) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, meta_key, meta_key_postfix in zip(self.keys, self.meta_keys, self.meta_key_postfix): d[key] = self.adjuster(d[key], d[meta_key or f"{key}_{meta_key_postfix}"]) @@ -301,6 +309,8 @@ class RepeatChanneld(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.RepeatChannel`. """ + backend = RepeatChannel.backend + def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool = False) -> None: """ Args: @@ -312,7 +322,7 @@ def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool super().__init__(keys, allow_missing_keys) self.repeater = RepeatChannel(repeats) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.repeater(d[key]) diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py new file mode 100644 index 0000000000..e6dc151596 --- /dev/null +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -0,0 +1,41 @@ +# Copyright 2020 - 2021 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 numpy as np +import torch + +from monai.config.type_definitions import NdarrayOrTensor + +__all__ = [ + "moveaxis", +] + + +def moveaxis(x: NdarrayOrTensor, src: int, dst: int) -> NdarrayOrTensor: + if isinstance(x, torch.Tensor): + if hasattr(torch, "moveaxis"): + return torch.moveaxis(x, src, dst) + # moveaxis only available in pytorch as of 1.8.0 + else: + # get original indices + indices = list(range(x.ndim)) + # make src and dst positive + if src < 0: + src = len(indices) + src + if dst < 0: + dst = len(indices) + dst + # remove desired index and insert it in new position + indices.pop(src) + indices.insert(dst, src) + return x.permute(indices) + elif isinstance(x, np.ndarray): + return np.moveaxis(x, src, dst) + raise RuntimeError() diff --git a/tests/test_add_channeld.py b/tests/test_add_channeld.py index ca4af37271..8bdd89a4ae 100644 --- a/tests/test_add_channeld.py +++ b/tests/test_add_channeld.py @@ -15,16 +15,21 @@ from parameterized import parameterized from monai.transforms import AddChanneld +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [ - {"keys": ["img", "seg"]}, - {"img": np.array([[0, 1], [1, 2]]), "seg": np.array([[0, 1], [1, 2]])}, - (1, 2, 2), -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": ["img", "seg"]}, + {"img": p(np.array([[0, 1], [1, 2]])), "seg": p(np.array([[0, 1], [1, 2]]))}, + (1, 2, 2), + ] + ) class TestAddChanneld(unittest.TestCase): - @parameterized.expand([TEST_CASE_1]) + @parameterized.expand(TESTS) def test_shape(self, input_param, input_data, expected_shape): result = AddChanneld(**input_param)(input_data) self.assertEqual(result["img"].shape, expected_shape) diff --git a/tests/test_as_channel_first.py b/tests/test_as_channel_first.py index e7d9866ae1..0d1b1c7d3a 100644 --- a/tests/test_as_channel_first.py +++ b/tests/test_as_channel_first.py @@ -12,23 +12,29 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import AsChannelFirst +from tests.utils import TEST_NDARRAYS, assert_allclose -TEST_CASE_1 = [{"channel_dim": -1}, (4, 1, 2, 3)] - -TEST_CASE_2 = [{"channel_dim": 3}, (4, 1, 2, 3)] - -TEST_CASE_3 = [{"channel_dim": 2}, (3, 1, 2, 4)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, {"channel_dim": -1}, (4, 1, 2, 3)]) + TESTS.append([p, {"channel_dim": 3}, (4, 1, 2, 3)]) + TESTS.append([p, {"channel_dim": 2}, (3, 1, 2, 4)]) class TestAsChannelFirst(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_param, expected_shape): - test_data = np.random.randint(0, 2, size=[1, 2, 3, 4]) + @parameterized.expand(TESTS) + def test_value(self, in_type, input_param, expected_shape): + test_data = in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])) result = AsChannelFirst(**input_param)(test_data) self.assertTupleEqual(result.shape, expected_shape) + if isinstance(test_data, torch.Tensor): + test_data = test_data.cpu().numpy() + expected = np.moveaxis(test_data, input_param["channel_dim"], 0) + assert_allclose(expected, result) if __name__ == "__main__": diff --git a/tests/test_as_channel_firstd.py b/tests/test_as_channel_firstd.py index e70c2e1b47..68d33434c1 100644 --- a/tests/test_as_channel_firstd.py +++ b/tests/test_as_channel_firstd.py @@ -15,21 +15,22 @@ from parameterized import parameterized from monai.transforms import AsChannelFirstd +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"keys": ["image", "label", "extra"], "channel_dim": -1}, (4, 1, 2, 3)] - -TEST_CASE_2 = [{"keys": ["image", "label", "extra"], "channel_dim": 3}, (4, 1, 2, 3)] - -TEST_CASE_3 = [{"keys": ["image", "label", "extra"], "channel_dim": 2}, (3, 1, 2, 4)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": -1}, (4, 1, 2, 3)]) + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": 3}, (4, 1, 2, 3)]) + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": 2}, (3, 1, 2, 4)]) class TestAsChannelFirstd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_param, expected_shape): + @parameterized.expand(TESTS) + def test_shape(self, in_type, input_param, expected_shape): test_data = { - "image": np.random.randint(0, 2, size=[1, 2, 3, 4]), - "label": np.random.randint(0, 2, size=[1, 2, 3, 4]), - "extra": np.random.randint(0, 2, size=[1, 2, 3, 4]), + "image": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), + "label": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), + "extra": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), } result = AsChannelFirstd(**input_param)(test_data) self.assertTupleEqual(result["image"].shape, expected_shape) diff --git a/tests/test_as_channel_last.py b/tests/test_as_channel_last.py index 6ec6c8d6e6..55a7a08676 100644 --- a/tests/test_as_channel_last.py +++ b/tests/test_as_channel_last.py @@ -15,18 +15,19 @@ from parameterized import parameterized from monai.transforms import AsChannelLast +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"channel_dim": 0}, (2, 3, 4, 1)] - -TEST_CASE_2 = [{"channel_dim": 1}, (1, 3, 4, 2)] - -TEST_CASE_3 = [{"channel_dim": 3}, (1, 2, 3, 4)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, {"channel_dim": 0}, (2, 3, 4, 1)]) + TESTS.append([p, {"channel_dim": 1}, (1, 3, 4, 2)]) + TESTS.append([p, {"channel_dim": 3}, (1, 2, 3, 4)]) class TestAsChannelLast(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_param, expected_shape): - test_data = np.random.randint(0, 2, size=[1, 2, 3, 4]) + @parameterized.expand(TESTS) + def test_shape(self, in_type, input_param, expected_shape): + test_data = in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])) result = AsChannelLast(**input_param)(test_data) self.assertTupleEqual(result.shape, expected_shape) diff --git a/tests/test_as_channel_lastd.py b/tests/test_as_channel_lastd.py index 2ef4dd4da1..350f639f3f 100644 --- a/tests/test_as_channel_lastd.py +++ b/tests/test_as_channel_lastd.py @@ -15,21 +15,22 @@ from parameterized import parameterized from monai.transforms import AsChannelLastd +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"keys": ["image", "label", "extra"], "channel_dim": 0}, (2, 3, 4, 1)] - -TEST_CASE_2 = [{"keys": ["image", "label", "extra"], "channel_dim": 1}, (1, 3, 4, 2)] - -TEST_CASE_3 = [{"keys": ["image", "label", "extra"], "channel_dim": 3}, (1, 2, 3, 4)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": 0}, (2, 3, 4, 1)]) + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": 1}, (1, 3, 4, 2)]) + TESTS.append([p, {"keys": ["image", "label", "extra"], "channel_dim": 3}, (1, 2, 3, 4)]) class TestAsChannelLastd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_shape(self, input_param, expected_shape): + @parameterized.expand(TESTS) + def test_shape(self, in_type, input_param, expected_shape): test_data = { - "image": np.random.randint(0, 2, size=[1, 2, 3, 4]), - "label": np.random.randint(0, 2, size=[1, 2, 3, 4]), - "extra": np.random.randint(0, 2, size=[1, 2, 3, 4]), + "image": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), + "label": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), + "extra": in_type(np.random.randint(0, 2, size=[1, 2, 3, 4])), } result = AsChannelLastd(**input_param)(test_data) self.assertTupleEqual(result["image"].shape, expected_shape) diff --git a/tests/test_ensure_channel_first.py b/tests/test_ensure_channel_first.py index 6b9def1cea..23126d326f 100644 --- a/tests/test_ensure_channel_first.py +++ b/tests/test_ensure_channel_first.py @@ -21,6 +21,7 @@ from monai.data import ITKReader from monai.transforms import EnsureChannelFirst, LoadImage +from tests.utils import TEST_NDARRAYS TEST_CASE_1 = [{"image_only": False}, ["test_image.nii.gz"], None] @@ -61,9 +62,10 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): for i, name in enumerate(filenames): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - result, header = LoadImage(**input_param)(filenames) - result = EnsureChannelFirst()(result, header) - self.assertEqual(result.shape[0], len(filenames)) + for p in TEST_NDARRAYS: + result, header = LoadImage(**input_param)(filenames) + result = EnsureChannelFirst()(p(result), header) + self.assertEqual(result.shape[0], len(filenames)) @parameterized.expand([TEST_CASE_7]) def test_itk_dicom_series_reader(self, input_param, filenames, original_channel_dim): diff --git a/tests/test_ensure_channel_firstd.py b/tests/test_ensure_channel_firstd.py index 59eb32c576..b4cde02a8f 100644 --- a/tests/test_ensure_channel_firstd.py +++ b/tests/test_ensure_channel_firstd.py @@ -19,6 +19,7 @@ from PIL import Image from monai.transforms import EnsureChannelFirstd, LoadImaged +from tests.utils import TEST_NDARRAYS TEST_CASE_1 = [{"keys": "img"}, ["test_image.nii.gz"], None] @@ -43,9 +44,11 @@ def test_load_nifti(self, input_param, filenames, original_channel_dim): for i, name in enumerate(filenames): filenames[i] = os.path.join(tempdir, name) nib.save(nib.Nifti1Image(test_image, np.eye(4)), filenames[i]) - result = LoadImaged(**input_param)({"img": filenames}) - result = EnsureChannelFirstd(**input_param)(result) - self.assertEqual(result["img"].shape[0], len(filenames)) + for p in TEST_NDARRAYS: + result = LoadImaged(**input_param)({"img": filenames}) + result["img"] = p(result["img"]) + result = EnsureChannelFirstd(**input_param)(result) + self.assertEqual(result["img"].shape[0], len(filenames)) def test_load_png(self): spatial_size = (256, 256, 3) diff --git a/tests/test_identity.py b/tests/test_identity.py index 2dff2bb13d..172860668c 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -11,17 +11,16 @@ import unittest -import numpy as np - from monai.transforms.utility.array import Identity -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestIdentity(NumpyImageTestCase2D): def test_identity(self): - img = self.imt - identity = Identity() - self.assertTrue(np.allclose(img, identity(img))) + for p in TEST_NDARRAYS: + img = p(self.imt) + identity = Identity() + assert_allclose(img, identity(img)) if __name__ == "__main__": diff --git a/tests/test_identityd.py b/tests/test_identityd.py index 8796f28da8..665b7d5d1c 100644 --- a/tests/test_identityd.py +++ b/tests/test_identityd.py @@ -12,16 +12,17 @@ import unittest from monai.transforms.utility.dictionary import Identityd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestIdentityd(NumpyImageTestCase2D): def test_identityd(self): - img = self.imt - data = {} - data["img"] = img - identity = Identityd(keys=data.keys()) - self.assertEqual(data, identity(data)) + for p in TEST_NDARRAYS: + img = p(self.imt) + data = {} + data["img"] = img + identity = Identityd(keys=data.keys()) + assert_allclose(img, identity(data)["img"]) if __name__ == "__main__": diff --git a/tests/test_repeat_channel.py b/tests/test_repeat_channel.py index 643ebc64de..e246dd1212 100644 --- a/tests/test_repeat_channel.py +++ b/tests/test_repeat_channel.py @@ -11,16 +11,18 @@ import unittest -import numpy as np from parameterized import parameterized from monai.transforms import RepeatChannel +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"repeats": 3}, np.array([[[0, 1], [1, 2]]]), (3, 2, 2)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([{"repeats": 3}, p([[[0, 1], [1, 2]]]), (3, 2, 2)]) class TestRepeatChannel(unittest.TestCase): - @parameterized.expand([TEST_CASE_1]) + @parameterized.expand(TESTS) def test_shape(self, input_param, input_data, expected_shape): result = RepeatChannel(**input_param)(input_data) self.assertEqual(result.shape, expected_shape) diff --git a/tests/test_repeat_channeld.py b/tests/test_repeat_channeld.py index 7bd58bd1fe..3b73962bb9 100644 --- a/tests/test_repeat_channeld.py +++ b/tests/test_repeat_channeld.py @@ -15,16 +15,21 @@ from parameterized import parameterized from monai.transforms import RepeatChanneld +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [ - {"keys": ["img"], "repeats": 3}, - {"img": np.array([[[0, 1], [1, 2]]]), "seg": np.array([[[0, 1], [1, 2]]])}, - (3, 2, 2), -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": ["img"], "repeats": 3}, + {"img": p(np.array([[[0, 1], [1, 2]]])), "seg": p(np.array([[[0, 1], [1, 2]]]))}, + (3, 2, 2), + ] + ) class TestRepeatChanneld(unittest.TestCase): - @parameterized.expand([TEST_CASE_1]) + @parameterized.expand(TESTS) def test_shape(self, input_param, input_data, expected_shape): result = RepeatChanneld(**input_param)(input_data) self.assertEqual(result["img"].shape, expected_shape) From c99bd414f2a7fb402e1ce4e420e161f1a8216e08 Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Sun, 29 Aug 2021 23:49:21 +0100 Subject: [PATCH 75/89] EnsureType, RemoveRepeatedChannel, SplitChannel, ToCupy, ToNumpy, ToPil, ToTensor, Transpose (#2850) * backends -> backend Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format2 Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * AddChannel, AsChannelFirst, AsChannelLast, EnsureChannelFirst, Identity, RepeatChannel Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * moveaxis backwards compatible Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * code format Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * EnsureType, RemoveRepeatedChannel, SplitChannel, ToCupy, ToNumpy, ToPil, ToTensor, Transpose Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * trigger ci/cd Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * permute requires positive indices Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * correct permute Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * correct permute Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> * has_pil Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/utility/array.py | 67 +++++++++++------------ monai/transforms/utility/dictionary.py | 33 ++++++++---- monai/utils/type_conversion.py | 21 +++++--- tests/test_ensure_type.py | 15 ++++-- tests/test_ensure_typed.py | 15 ++++-- tests/test_remove_repeated_channel.py | 7 ++- tests/test_remove_repeated_channeld.py | 22 +++++--- tests/test_split_channel.py | 17 +++--- tests/test_split_channeld.py | 74 +++++++++++++++----------- tests/test_to_cupy.py | 12 +++++ tests/test_to_cupyd.py | 12 +++++ tests/test_to_numpy.py | 23 +++++--- tests/test_to_numpyd.py | 17 ++++-- tests/test_to_pil.py | 34 ++++-------- tests/test_to_pild.py | 35 ++++-------- tests/test_to_tensor.py | 38 ++++++++----- tests/test_transpose.py | 33 +++++++----- tests/test_transposed.py | 55 +++++++++++-------- 18 files changed, 317 insertions(+), 213 deletions(-) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 580c6c8b3c..f38a94302e 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -32,15 +32,7 @@ map_classes_to_indices, ) from monai.transforms.utils_pytorch_numpy_unification import moveaxis -from monai.utils import ( - convert_to_numpy, - convert_to_tensor, - ensure_tuple, - issequenceiterable, - look_up_option, - min_version, - optional_import, -) +from monai.utils import convert_to_numpy, convert_to_tensor, ensure_tuple, look_up_option, min_version, optional_import from monai.utils.enums import TransformBackends from monai.utils.type_conversion import convert_data_type @@ -255,20 +247,22 @@ class RemoveRepeatedChannel(Transform): repeats: the number of repetitions to be deleted for each element. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, repeats: int) -> None: if repeats <= 0: raise AssertionError("repeats count must be greater than 0.") self.repeats = repeats - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`, assuming `img` is a "channel-first" array. """ - if np.shape(img)[0] < 2: + if img.shape[0] < 2: raise AssertionError("Image must have more than one channel") - return np.array(img[:: self.repeats, :]) + return img[:: self.repeats, :] class SplitChannel(Transform): @@ -281,10 +275,12 @@ class SplitChannel(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, channel_dim: int = 0) -> None: self.channel_dim = channel_dim - def __call__(self, img: Union[np.ndarray, torch.Tensor]) -> List[Union[np.ndarray, torch.Tensor]]: + def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: n_classes = img.shape[self.channel_dim] if n_classes <= 1: raise RuntimeError("input image does not contain multiple channels.") @@ -335,18 +331,13 @@ class ToTensor(Transform): Converts the input image to a tensor without applying any other transformations. """ - def __call__(self, img) -> torch.Tensor: + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, img: NdarrayOrTensor) -> torch.Tensor: """ Apply the transform to `img` and make it contiguous. """ - if isinstance(img, torch.Tensor): - return img.contiguous() - if issequenceiterable(img): - # numpy array with 0 dims is also sequence iterable - if not (isinstance(img, np.ndarray) and img.ndim == 0): - # `ascontiguousarray` will add 1 dim if img has no dim, so we only apply on data with dims - img = np.ascontiguousarray(img) - return torch.as_tensor(img) + return convert_to_tensor(img, wrap_sequence=True) # type: ignore class EnsureType(Transform): @@ -361,6 +352,8 @@ class EnsureType(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, data_type: str = "tensor") -> None: data_type = data_type.lower() if data_type not in ("tensor", "numpy"): @@ -368,7 +361,7 @@ def __init__(self, data_type: str = "tensor") -> None: self.data_type = data_type - def __call__(self, data): + def __call__(self, data: NdarrayOrTensor) -> NdarrayOrTensor: """ Args: data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. @@ -377,7 +370,7 @@ def __call__(self, data): if applicable. """ - return convert_to_tensor(data) if self.data_type == "tensor" else convert_to_numpy(data) + return convert_to_tensor(data) if self.data_type == "tensor" else convert_to_numpy(data) # type: ignore class ToNumpy(Transform): @@ -385,17 +378,13 @@ class ToNumpy(Transform): Converts the input data to numpy array, can support list or tuple of numbers and PyTorch Tensor. """ - def __call__(self, img) -> np.ndarray: + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, img: NdarrayOrTensor) -> np.ndarray: """ Apply the transform to `img` and make it contiguous. """ - if isinstance(img, torch.Tensor): - img = img.detach().cpu().numpy() - elif has_cp and isinstance(img, cp_ndarray): - img = cp.asnumpy(img) - - array: np.ndarray = np.asarray(img) - return np.ascontiguousarray(array) if array.ndim > 0 else array + return convert_to_numpy(img) # type: ignore class ToCupy(Transform): @@ -403,13 +392,15 @@ class ToCupy(Transform): Converts the input data to CuPy array, can support list or tuple of numbers, NumPy and PyTorch Tensor. """ - def __call__(self, img): + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img` and make it contiguous. """ if isinstance(img, torch.Tensor): img = img.detach().cpu().numpy() - return cp.ascontiguousarray(cp.asarray(img)) + return cp.ascontiguousarray(cp.asarray(img)) # type: ignore class ToPIL(Transform): @@ -417,6 +408,8 @@ class ToPIL(Transform): Converts the input image (in the form of NumPy array or PyTorch Tensor) to PIL image """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __call__(self, img): """ Apply the transform to `img`. @@ -433,13 +426,17 @@ class Transpose(Transform): Transposes the input image based on the given `indices` dimension ordering. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, indices: Optional[Sequence[int]]) -> None: self.indices = None if indices is None else tuple(indices) - def __call__(self, img: np.ndarray) -> np.ndarray: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ + if isinstance(img, torch.Tensor): + return img.permute(self.indices or tuple(range(img.ndim)[::-1])) return img.transpose(self.indices) # type: ignore diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 41c2a1b9b9..1b63b308d9 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -334,6 +334,8 @@ class RemoveRepeatedChanneld(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.RemoveRepeatedChannel`. """ + backend = RemoveRepeatedChannel.backend + def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool = False) -> None: """ Args: @@ -345,7 +347,7 @@ def __init__(self, keys: KeysCollection, repeats: int, allow_missing_keys: bool super().__init__(keys, allow_missing_keys) self.repeater = RemoveRepeatedChannel(repeats) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.repeater(d[key]) @@ -356,9 +358,10 @@ class SplitChanneld(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.SplitChannel`. All the input specified by `keys` should be split into same count of data. - """ + backend = SplitChannel.backend + def __init__( self, keys: KeysCollection, @@ -382,9 +385,7 @@ def __init__( self.output_postfixes = output_postfixes self.splitter = SplitChannel(channel_dim=channel_dim) - def __call__( - self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]] - ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): rets = self.splitter(d[key]) @@ -439,6 +440,8 @@ class ToTensord(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToTensor`. """ + backend = ToTensor.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -449,14 +452,14 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.converter = ToTensor() - def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): self.push_transform(d, key) d[key] = self.converter(d[key]) return d - def inverse(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + def inverse(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = deepcopy(dict(data)) for key in self.key_iterator(d): # Create inverse transform @@ -481,6 +484,8 @@ class EnsureTyped(MapTransform, InvertibleTransform): """ + backend = EnsureType.backend + def __init__(self, keys: KeysCollection, data_type: str = "tensor", allow_missing_keys: bool = False) -> None: """ Args: @@ -492,7 +497,7 @@ def __init__(self, keys: KeysCollection, data_type: str = "tensor", allow_missin super().__init__(keys, allow_missing_keys) self.converter = EnsureType(data_type=data_type) - def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): self.push_transform(d, key) @@ -515,6 +520,8 @@ class ToNumpyd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToNumpy`. """ + backend = ToNumpy.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -537,6 +544,8 @@ class ToCupyd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToCupy`. """ + backend = ToCupy.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -547,7 +556,7 @@ def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> No super().__init__(keys, allow_missing_keys) self.converter = ToCupy() - def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -559,6 +568,8 @@ class ToPILd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.ToNumpy`. """ + backend = ToPIL.backend + def __init__(self, keys: KeysCollection, allow_missing_keys: bool = False) -> None: """ Args: @@ -581,13 +592,15 @@ class Transposed(MapTransform, InvertibleTransform): Dictionary-based wrapper of :py:class:`monai.transforms.Transpose`. """ + backend = Transpose.backend + def __init__( self, keys: KeysCollection, indices: Optional[Sequence[int]], allow_missing_keys: bool = False ) -> None: super().__init__(keys, allow_missing_keys) self.transform = Transpose(indices) - def __call__(self, data: Mapping[Hashable, Any]) -> Dict[Hashable, Any]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.transform(d[key]) diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index e6df607764..14300eeca0 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -83,7 +83,7 @@ def get_dtype(data: Any): return type(data) -def convert_to_tensor(data): +def convert_to_tensor(data, wrap_sequence: bool = False): """ Utility to convert the input data to a PyTorch Tensor. If passing a dictionary, list or tuple, recursively check every item and convert it to PyTorch Tensor. @@ -92,6 +92,8 @@ def convert_to_tensor(data): data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. will convert Tensor, Numpy array, float, int, bool to Tensors, strings and objects keep the original. for dictionary, list or tuple, convert every item to a Tensor if applicable. + wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[tensor(1), tensor(2)]`. + If `True`, then `[1, 2]` -> `tensor([1, 2])`. """ if isinstance(data, torch.Tensor): @@ -105,17 +107,19 @@ def convert_to_tensor(data): return torch.as_tensor(data if data.ndim == 0 else np.ascontiguousarray(data)) elif isinstance(data, (float, int, bool)): return torch.as_tensor(data) - elif isinstance(data, dict): - return {k: convert_to_tensor(v) for k, v in data.items()} + elif isinstance(data, Sequence) and wrap_sequence: + return torch.as_tensor(data) elif isinstance(data, list): return [convert_to_tensor(i) for i in data] elif isinstance(data, tuple): return tuple(convert_to_tensor(i) for i in data) + elif isinstance(data, dict): + return {k: convert_to_tensor(v) for k, v in data.items()} return data -def convert_to_numpy(data): +def convert_to_numpy(data, wrap_sequence: bool = False): """ Utility to convert the input data to a numpy array. If passing a dictionary, list or tuple, recursively check every item and convert it to numpy array. @@ -124,7 +128,8 @@ def convert_to_numpy(data): data: input data can be PyTorch Tensor, numpy array, list, dictionary, int, float, bool, str, etc. will convert Tensor, Numpy array, float, int, bool to numpy arrays, strings and objects keep the original. for dictionary, list or tuple, convert every item to a numpy array if applicable. - + wrap_sequence: if `False`, then lists will recursively call this function. E.g., `[1, 2]` -> `[array(1), array(2)]`. + If `True`, then `[1, 2]` -> `array([1, 2])`. """ if isinstance(data, torch.Tensor): data = data.detach().cpu().numpy() @@ -132,12 +137,14 @@ def convert_to_numpy(data): data = cp.asnumpy(data) elif isinstance(data, (float, int, bool)): data = np.asarray(data) - elif isinstance(data, dict): - return {k: convert_to_numpy(v) for k, v in data.items()} + elif isinstance(data, Sequence) and wrap_sequence: + return np.asarray(data) elif isinstance(data, list): return [convert_to_numpy(i) for i in data] elif isinstance(data, tuple): return tuple(convert_to_numpy(i) for i in data) + elif isinstance(data, dict): + return {k: convert_to_numpy(v) for k, v in data.items()} if isinstance(data, np.ndarray) and data.ndim > 0: data = np.ascontiguousarray(data) diff --git a/tests/test_ensure_type.py b/tests/test_ensure_type.py index 11cf6760fb..8feb96ed37 100644 --- a/tests/test_ensure_type.py +++ b/tests/test_ensure_type.py @@ -15,26 +15,33 @@ import torch from monai.transforms import EnsureType +from tests.utils import assert_allclose class TestEnsureType(unittest.TestCase): def test_array_input(self): - for test_data in (np.array([[1, 2], [3, 4]]), torch.as_tensor([[1, 2], [3, 4]])): + test_datas = [np.array([[1, 2], [3, 4]]), torch.as_tensor([[1, 2], [3, 4]])] + if torch.cuda.is_available(): + test_datas.append(test_datas[-1].cuda()) + for test_data in test_datas: for dtype in ("tensor", "NUMPY"): result = EnsureType(data_type=dtype)(test_data) self.assertTrue(isinstance(result, torch.Tensor if dtype == "tensor" else np.ndarray)) - torch.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) self.assertTupleEqual(result.shape, (2, 2)) def test_single_input(self): - for test_data in (5, 5.0, False, np.asarray(5), torch.tensor(5)): + test_datas = [5, 5.0, False, np.asarray(5), torch.tensor(5)] + if torch.cuda.is_available(): + test_datas.append(test_datas[-1].cuda()) + for test_data in test_datas: for dtype in ("tensor", "numpy"): result = EnsureType(data_type=dtype)(test_data) self.assertTrue(isinstance(result, torch.Tensor if dtype == "tensor" else np.ndarray)) if isinstance(test_data, bool): self.assertFalse(result) else: - torch.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) self.assertEqual(result.ndim, 0) def test_string(self): diff --git a/tests/test_ensure_typed.py b/tests/test_ensure_typed.py index c5f588d423..96f482afc2 100644 --- a/tests/test_ensure_typed.py +++ b/tests/test_ensure_typed.py @@ -15,26 +15,33 @@ import torch from monai.transforms import EnsureTyped +from tests.utils import assert_allclose class TestEnsureTyped(unittest.TestCase): def test_array_input(self): - for test_data in (np.array([[1, 2], [3, 4]]), torch.as_tensor([[1, 2], [3, 4]])): + test_datas = [np.array([[1, 2], [3, 4]]), torch.as_tensor([[1, 2], [3, 4]])] + if torch.cuda.is_available(): + test_datas.append(test_datas[-1].cuda()) + for test_data in test_datas: for dtype in ("tensor", "NUMPY"): result = EnsureTyped(keys="data", data_type=dtype)({"data": test_data})["data"] self.assertTrue(isinstance(result, torch.Tensor if dtype == "tensor" else np.ndarray)) - torch.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) self.assertTupleEqual(result.shape, (2, 2)) def test_single_input(self): - for test_data in (5, 5.0, False, np.asarray(5), torch.tensor(5)): + test_datas = [5, 5.0, False, np.asarray(5), torch.tensor(5)] + if torch.cuda.is_available(): + test_datas.append(test_datas[-1].cuda()) + for test_data in test_datas: for dtype in ("tensor", "numpy"): result = EnsureTyped(keys="data", data_type=dtype)({"data": test_data})["data"] self.assertTrue(isinstance(result, torch.Tensor if dtype == "tensor" else np.ndarray)) if isinstance(test_data, bool): self.assertFalse(result) else: - torch.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) self.assertEqual(result.ndim, 0) def test_string(self): diff --git a/tests/test_remove_repeated_channel.py b/tests/test_remove_repeated_channel.py index 070e0e2b8d..ebbe6c730c 100644 --- a/tests/test_remove_repeated_channel.py +++ b/tests/test_remove_repeated_channel.py @@ -12,15 +12,18 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import RemoveRepeatedChannel -TEST_CASE_1 = [{"repeats": 2}, np.array([[1, 2], [1, 2], [3, 4], [3, 4]]), (2, 2)] +TEST_CASES = [] +for q in (torch.Tensor, np.array): + TEST_CASES.append([{"repeats": 2}, q([[1, 2], [1, 2], [3, 4], [3, 4]]), (2, 2)]) # type: ignore class TestRemoveRepeatedChannel(unittest.TestCase): - @parameterized.expand([TEST_CASE_1]) + @parameterized.expand(TEST_CASES) def test_shape(self, input_param, input_data, expected_shape): result = RemoveRepeatedChannel(**input_param)(input_data) self.assertEqual(result.shape, expected_shape) diff --git a/tests/test_remove_repeated_channeld.py b/tests/test_remove_repeated_channeld.py index 46c68bbdc2..9d4812791e 100644 --- a/tests/test_remove_repeated_channeld.py +++ b/tests/test_remove_repeated_channeld.py @@ -15,16 +15,24 @@ from parameterized import parameterized from monai.transforms import RemoveRepeatedChanneld - -TEST_CASE_1 = [ - {"keys": ["img"], "repeats": 2}, - {"img": np.array([[1, 2], [1, 2], [3, 4], [3, 4]]), "seg": np.array([[1, 2], [1, 2], [3, 4], [3, 4]])}, - (2, 2), -] +from tests.utils import TEST_NDARRAYS + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": ["img"], "repeats": 2}, + { + "img": p(np.array([[1, 2], [1, 2], [3, 4], [3, 4]])), + "seg": p(np.array([[1, 2], [1, 2], [3, 4], [3, 4]])), + }, + (2, 2), + ] + ) class TestRemoveRepeatedChanneld(unittest.TestCase): - @parameterized.expand([TEST_CASE_1]) + @parameterized.expand(TESTS) def test_shape(self, input_param, input_data, expected_shape): result = RemoveRepeatedChanneld(**input_param)(input_data) self.assertEqual(result["img"].shape, expected_shape) diff --git a/tests/test_split_channel.py b/tests/test_split_channel.py index 91e93aedcc..38315a102c 100644 --- a/tests/test_split_channel.py +++ b/tests/test_split_channel.py @@ -12,22 +12,21 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import SplitChannel +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"channel_dim": 1}, torch.randint(0, 2, size=(4, 3, 3, 4)), (4, 1, 3, 4)] - -TEST_CASE_2 = [{"channel_dim": 0}, np.random.randint(2, size=(3, 3, 4)), (1, 3, 4)] - -TEST_CASE_3 = [{"channel_dim": 2}, np.random.randint(2, size=(3, 2, 4)), (3, 2, 1)] - -TEST_CASE_4 = [{"channel_dim": -1}, np.random.randint(2, size=(3, 2, 4)), (3, 2, 1)] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([{"channel_dim": 1}, p(np.random.randint(2, size=(4, 3, 3, 4))), (4, 1, 3, 4)]) + TESTS.append([{"channel_dim": 0}, p(np.random.randint(2, size=(3, 3, 4))), (1, 3, 4)]) + TESTS.append([{"channel_dim": 2}, p(np.random.randint(2, size=(3, 2, 4))), (3, 2, 1)]) + TESTS.append([{"channel_dim": -1}, p(np.random.randint(2, size=(3, 2, 4))), (3, 2, 1)]) class TestSplitChannel(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4]) + @parameterized.expand(TESTS) def test_shape(self, input_param, test_data, expected_shape): result = SplitChannel(**input_param)(test_data) for data in result: diff --git a/tests/test_split_channeld.py b/tests/test_split_channeld.py index 57c7099b9f..f1df24364d 100644 --- a/tests/test_split_channeld.py +++ b/tests/test_split_channeld.py @@ -12,44 +12,56 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import SplitChanneld +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [ - {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3"], "channel_dim": 1}, - {"pred": torch.randint(0, 2, size=(4, 3, 3, 4))}, - (4, 1, 3, 4), -] - -TEST_CASE_2 = [ - {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3"], "channel_dim": 0}, - {"pred": np.random.randint(2, size=(3, 3, 4))}, - (1, 3, 4), -] - -TEST_CASE_3 = [ - {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3", "cls4"], "channel_dim": 2}, - {"pred": np.random.randint(2, size=(3, 2, 4))}, - (3, 2, 1), -] - -TEST_CASE_4 = [ - {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3", "cls4"], "channel_dim": -1}, - {"pred": np.random.randint(2, size=(3, 2, 4))}, - (3, 2, 1), -] - -TEST_CASE_5 = [ - {"keys": "pred", "channel_dim": 1}, - {"pred": np.random.randint(2, size=(3, 2, 4))}, - (3, 1, 4), -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3"], "channel_dim": 1}, + {"pred": p(np.random.randint(2, size=(4, 3, 3, 4)))}, + (4, 1, 3, 4), + ] + ) + + TESTS.append( + [ + {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3"], "channel_dim": 0}, + {"pred": p(np.random.randint(2, size=(3, 3, 4)))}, + (1, 3, 4), + ] + ) + + TESTS.append( + [ + {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3", "cls4"], "channel_dim": 2}, + {"pred": p(np.random.randint(2, size=(3, 2, 4)))}, + (3, 2, 1), + ] + ) + + TESTS.append( + [ + {"keys": "pred", "output_postfixes": ["cls1", "cls2", "cls3", "cls4"], "channel_dim": -1}, + {"pred": p(np.random.randint(2, size=(3, 2, 4)))}, + (3, 2, 1), + ] + ) + + TESTS.append( + [ + {"keys": "pred", "channel_dim": 1}, + {"pred": p(np.random.randint(2, size=(3, 2, 4)))}, + (3, 1, 4), + ] + ) class TestSplitChanneld(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand(TESTS) def test_shape(self, input_param, test_data, expected_shape): result = SplitChanneld(**input_param)(test_data) for k, v in result.items(): diff --git a/tests/test_to_cupy.py b/tests/test_to_cupy.py index 76c9464b20..a9460bc825 100644 --- a/tests/test_to_cupy.py +++ b/tests/test_to_cupy.py @@ -17,6 +17,7 @@ from monai.transforms import ToCupy from monai.utils import optional_import +from tests.utils import skip_if_no_cuda cp, has_cp = optional_import("cupy") @@ -52,6 +53,17 @@ def test_tensor_input(self): self.assertTrue(result.flags["C_CONTIGUOUS"]) cp.testing.assert_allclose(result, test_data.numpy()) + @skipUnless(has_cp, "CuPy is required.") + @skip_if_no_cuda + def test_tensor_cuda_input(self): + test_data = torch.tensor([[1, 2], [3, 4]]).cuda() + test_data = test_data.rot90() + self.assertFalse(test_data.is_contiguous()) + result = ToCupy()(test_data) + self.assertTrue(isinstance(result, cp.ndarray)) + self.assertTrue(result.flags["C_CONTIGUOUS"]) + cp.testing.assert_allclose(result, test_data.cpu().numpy()) + @skipUnless(has_cp, "CuPy is required.") def test_list_tuple(self): test_data = [[1, 2], [3, 4]] diff --git a/tests/test_to_cupyd.py b/tests/test_to_cupyd.py index b869bedc96..2f3c42dd1f 100644 --- a/tests/test_to_cupyd.py +++ b/tests/test_to_cupyd.py @@ -17,6 +17,7 @@ from monai.transforms import ToCupyd from monai.utils import optional_import +from tests.utils import skip_if_no_cuda cp, has_cp = optional_import("cupy") @@ -52,6 +53,17 @@ def test_tensor_input(self): self.assertTrue(result.flags["C_CONTIGUOUS"]) cp.testing.assert_allclose(result, test_data.numpy()) + @skipUnless(has_cp, "CuPy is required.") + @skip_if_no_cuda + def test_tensor_cuda_input(self): + test_data = torch.tensor([[1, 2], [3, 4]]).cuda() + test_data = test_data.rot90() + self.assertFalse(test_data.is_contiguous()) + result = ToCupyd(keys="img")({"img": test_data})["img"] + self.assertTrue(isinstance(result, cp.ndarray)) + self.assertTrue(result.flags["C_CONTIGUOUS"]) + cp.testing.assert_allclose(result, test_data.cpu().numpy()) + @skipUnless(has_cp, "CuPy is required.") def test_list_tuple(self): test_data = [[1, 2], [3, 4]] diff --git a/tests/test_to_numpy.py b/tests/test_to_numpy.py index 291601ffeb..fd49a3d473 100644 --- a/tests/test_to_numpy.py +++ b/tests/test_to_numpy.py @@ -17,6 +17,7 @@ from monai.transforms import ToNumpy from monai.utils import optional_import +from tests.utils import assert_allclose, skip_if_no_cuda cp, has_cp = optional_import("cupy") @@ -30,7 +31,7 @@ def test_cumpy_input(self): result = ToNumpy()(test_data) self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data.get()) + assert_allclose(result, test_data.get()) def test_numpy_input(self): test_data = np.array([[1, 2], [3, 4]]) @@ -39,7 +40,7 @@ def test_numpy_input(self): result = ToNumpy()(test_data) self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) def test_tensor_input(self): test_data = torch.tensor([[1, 2], [3, 4]]) @@ -48,21 +49,31 @@ def test_tensor_input(self): result = ToNumpy()(test_data) self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data.numpy()) + assert_allclose(result, test_data) + + @skip_if_no_cuda + def test_tensor_cuda_input(self): + test_data = torch.tensor([[1, 2], [3, 4]]).cuda() + test_data = test_data.rot90() + self.assertFalse(test_data.is_contiguous()) + result = ToNumpy()(test_data) + self.assertTrue(isinstance(result, np.ndarray)) + self.assertTrue(result.flags["C_CONTIGUOUS"]) + assert_allclose(result, test_data) def test_list_tuple(self): test_data = [[1, 2], [3, 4]] result = ToNumpy()(test_data) - np.testing.assert_allclose(result, np.asarray(test_data)) + assert_allclose(result, np.asarray(test_data)) test_data = ((1, 2), (3, 4)) result = ToNumpy()(test_data) - np.testing.assert_allclose(result, np.asarray(test_data)) + assert_allclose(result, np.asarray(test_data)) def test_single_value(self): for test_data in [5, np.array(5), torch.tensor(5)]: result = ToNumpy()(test_data) self.assertTrue(isinstance(result, np.ndarray)) - np.testing.assert_allclose(result, np.asarray(test_data)) + assert_allclose(result, np.asarray(test_data)) self.assertEqual(result.ndim, 0) diff --git a/tests/test_to_numpyd.py b/tests/test_to_numpyd.py index 1fb43ea2ac..adfab65904 100644 --- a/tests/test_to_numpyd.py +++ b/tests/test_to_numpyd.py @@ -17,6 +17,7 @@ from monai.transforms import ToNumpyd from monai.utils import optional_import +from tests.utils import assert_allclose, skip_if_no_cuda cp, has_cp = optional_import("cupy") @@ -30,7 +31,7 @@ def test_cumpy_input(self): result = ToNumpyd(keys="img")({"img": test_data})["img"] self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data.get()) + assert_allclose(result, test_data.get()) def test_numpy_input(self): test_data = np.array([[1, 2], [3, 4]]) @@ -39,7 +40,7 @@ def test_numpy_input(self): result = ToNumpyd(keys="img")({"img": test_data})["img"] self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data) + assert_allclose(result, test_data) def test_tensor_input(self): test_data = torch.tensor([[1, 2], [3, 4]]) @@ -48,7 +49,17 @@ def test_tensor_input(self): result = ToNumpyd(keys="img")({"img": test_data})["img"] self.assertTrue(isinstance(result, np.ndarray)) self.assertTrue(result.flags["C_CONTIGUOUS"]) - np.testing.assert_allclose(result, test_data.numpy()) + assert_allclose(result, test_data) + + @skip_if_no_cuda + def test_tensor_cuda_input(self): + test_data = torch.tensor([[1, 2], [3, 4]]).cuda() + test_data = test_data.rot90() + self.assertFalse(test_data.is_contiguous()) + result = ToNumpyd(keys="img")({"img": test_data})["img"] + self.assertTrue(isinstance(result, np.ndarray)) + self.assertTrue(result.flags["C_CONTIGUOUS"]) + assert_allclose(result, test_data) if __name__ == "__main__": diff --git a/tests/test_to_pil.py b/tests/test_to_pil.py index ec63750ce4..5690645dd8 100644 --- a/tests/test_to_pil.py +++ b/tests/test_to_pil.py @@ -14,11 +14,11 @@ from unittest import skipUnless import numpy as np -import torch from parameterized import parameterized from monai.transforms import ToPIL from monai.utils import optional_import +from tests.utils import TEST_NDARRAYS, assert_allclose if TYPE_CHECKING: from PIL.Image import Image as PILImageImage @@ -29,35 +29,21 @@ pil_image_fromarray, has_pil = optional_import("PIL.Image", name="fromarray") PILImageImage, _ = optional_import("PIL.Image", name="Image") -TEST_CASE_ARRAY_1 = [np.array([[1.0, 2.0], [3.0, 4.0]])] -TEST_CASE_TENSOR_1 = [torch.tensor([[1.0, 2.0], [3.0, 4.0]])] +im = [[1.0, 2.0], [3.0, 4.0]] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([p(im)]) +if has_pil: + TESTS.append([pil_image_fromarray(np.array(im))]) class TestToPIL(unittest.TestCase): - @parameterized.expand([TEST_CASE_ARRAY_1]) + @parameterized.expand(TESTS) @skipUnless(has_pil, "Requires `pillow` package.") - def test_numpy_input(self, test_data): - self.assertTrue(isinstance(test_data, np.ndarray)) + def test_value(self, test_data): result = ToPIL()(test_data) self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data) - - @parameterized.expand([TEST_CASE_TENSOR_1]) - @skipUnless(has_pil, "Requires `pillow` package.") - def test_tensor_input(self, test_data): - self.assertTrue(isinstance(test_data, torch.Tensor)) - result = ToPIL()(test_data) - self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data.numpy()) - - @parameterized.expand([TEST_CASE_ARRAY_1]) - @skipUnless(has_pil, "Requires `pillow` package.") - def test_pil_input(self, test_data): - test_data_pil = pil_image_fromarray(test_data) - self.assertTrue(isinstance(test_data_pil, PILImageImage)) - result = ToPIL()(test_data_pil) - self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data) + assert_allclose(np.array(result), test_data) if __name__ == "__main__": diff --git a/tests/test_to_pild.py b/tests/test_to_pild.py index 43778022ee..3a15b1e507 100644 --- a/tests/test_to_pild.py +++ b/tests/test_to_pild.py @@ -14,11 +14,11 @@ from unittest import skipUnless import numpy as np -import torch from parameterized import parameterized from monai.transforms import ToPILd from monai.utils import optional_import +from tests.utils import TEST_NDARRAYS, assert_allclose if TYPE_CHECKING: from PIL.Image import Image as PILImageImage @@ -29,36 +29,21 @@ pil_image_fromarray, has_pil = optional_import("PIL.Image", name="fromarray") PILImageImage, _ = optional_import("PIL.Image", name="Image") -TEST_CASE_ARRAY_1 = [{"keys": "image"}, {"image": np.array([[1.0, 2.0], [3.0, 4.0]])}] -TEST_CASE__TENSOR_1 = [{"keys": "image"}, {"image": torch.tensor([[1.0, 2.0], [3.0, 4.0]])}] +im = [[1.0, 2.0], [3.0, 4.0]] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append([{"keys": "image"}, {"image": p(im)}]) +if has_pil: + TESTS.append([{"keys": "image"}, {"image": pil_image_fromarray(np.array(im))}]) class TestToPIL(unittest.TestCase): - @parameterized.expand([TEST_CASE_ARRAY_1]) + @parameterized.expand(TESTS) @skipUnless(has_pil, "Requires `pillow` package.") - def test_numpy_input(self, input_param, test_data): - self.assertTrue(isinstance(test_data[input_param["keys"]], np.ndarray)) + def test_values(self, input_param, test_data): result = ToPILd(**input_param)(test_data)[input_param["keys"]] self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]]) - - @parameterized.expand([TEST_CASE__TENSOR_1]) - @skipUnless(has_pil, "Requires `pillow` package.") - def test_tensor_input(self, input_param, test_data): - self.assertTrue(isinstance(test_data[input_param["keys"]], torch.Tensor)) - result = ToPILd(**input_param)(test_data)[input_param["keys"]] - self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]].numpy()) - - @parameterized.expand([TEST_CASE_ARRAY_1]) - @skipUnless(has_pil, "Requires `pillow` package.") - def test_pil_input(self, input_param, test_data): - input_array = test_data[input_param["keys"]] - test_data[input_param["keys"]] = pil_image_fromarray(input_array) - self.assertTrue(isinstance(test_data[input_param["keys"]], PILImageImage)) - result = ToPILd(**input_param)(test_data)[input_param["keys"]] - self.assertTrue(isinstance(result, PILImageImage)) - np.testing.assert_allclose(np.array(result), test_data[input_param["keys"]]) + assert_allclose(np.array(result), test_data[input_param["keys"]]) if __name__ == "__main__": diff --git a/tests/test_to_tensor.py b/tests/test_to_tensor.py index 4a36254743..6ac06983f6 100644 --- a/tests/test_to_tensor.py +++ b/tests/test_to_tensor.py @@ -11,24 +11,36 @@ import unittest -import numpy as np -import torch +from parameterized import parameterized from monai.transforms import ToTensor +from tests.utils import TEST_NDARRAYS, assert_allclose + +im = [[1, 2], [3, 4]] + +TESTS = [] +TESTS.append((im, (2, 2))) +for p in TEST_NDARRAYS: + TESTS.append((p(im), (2, 2))) + +TESTS_SINGLE = [] +TESTS_SINGLE.append([5]) +for p in TEST_NDARRAYS: + TESTS_SINGLE.append([p(5)]) class TestToTensor(unittest.TestCase): - def test_array_input(self): - for test_data in ([[1, 2], [3, 4]], np.array([[1, 2], [3, 4]]), torch.as_tensor([[1, 2], [3, 4]])): - result = ToTensor()(test_data) - torch.testing.assert_allclose(result, test_data) - self.assertTupleEqual(result.shape, (2, 2)) - - def test_single_input(self): - for test_data in (5, np.asarray(5), torch.tensor(5)): - result = ToTensor()(test_data) - torch.testing.assert_allclose(result, test_data) - self.assertEqual(result.ndim, 0) + @parameterized.expand(TESTS) + def test_array_input(self, test_data, expected_shape): + result = ToTensor()(test_data) + assert_allclose(result, test_data) + self.assertTupleEqual(result.shape, expected_shape) + + @parameterized.expand(TESTS_SINGLE) + def test_single_input(self, test_data): + result = ToTensor()(test_data) + assert_allclose(result, test_data) + self.assertEqual(result.ndim, 0) if __name__ == "__main__": diff --git a/tests/test_transpose.py b/tests/test_transpose.py index 3b758b5aa2..10882c9dd8 100644 --- a/tests/test_transpose.py +++ b/tests/test_transpose.py @@ -12,28 +12,37 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import Transpose - -TEST_CASE_0 = [ - np.arange(5 * 4).reshape(5, 4), - None, -] -TEST_CASE_1 = [ - np.arange(5 * 4 * 3).reshape(5, 4, 3), - [2, 0, 1], -] -TEST_CASES = [TEST_CASE_0, TEST_CASE_1] +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + p(np.arange(5 * 4).reshape(5, 4)), + None, + ] + ) + TESTS.append( + [ + p(np.arange(5 * 4 * 3).reshape(5, 4, 3)), + [2, 0, 1], + ] + ) class TestTranspose(unittest.TestCase): - @parameterized.expand(TEST_CASES) + @parameterized.expand(TESTS) def test_transpose(self, im, indices): tr = Transpose(indices) out1 = tr(im) + if isinstance(im, torch.Tensor): + im = im.cpu().numpy() out2 = np.transpose(im, indices) - np.testing.assert_array_equal(out1, out2) + assert_allclose(out1, out2) if __name__ == "__main__": diff --git a/tests/test_transposed.py b/tests/test_transposed.py index 56375f3981..88ecd0c872 100644 --- a/tests/test_transposed.py +++ b/tests/test_transposed.py @@ -13,44 +13,57 @@ from copy import deepcopy import numpy as np +import torch from parameterized import parameterized from monai.transforms import Transposed +from tests.utils import TEST_NDARRAYS, assert_allclose -TEST_CASE_0 = [ - np.arange(5 * 4).reshape(5, 4), - [1, 0], -] -TEST_CASE_1 = [ - np.arange(5 * 4).reshape(5, 4), - None, -] -TEST_CASE_2 = [ - np.arange(5 * 4 * 3).reshape(5, 4, 3), - [2, 0, 1], -] -TEST_CASE_3 = [ - np.arange(5 * 4 * 3).reshape(5, 4, 3), - None, -] -TEST_CASES = [TEST_CASE_0, TEST_CASE_1, TEST_CASE_2, TEST_CASE_3] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + p(np.arange(5 * 4).reshape(5, 4)), + [1, 0], + ] + ) + TESTS.append( + [ + p(np.arange(5 * 4).reshape(5, 4)), + None, + ] + ) + TESTS.append( + [ + p(np.arange(5 * 4 * 3).reshape(5, 4, 3)), + [2, 0, 1], + ] + ) + TESTS.append( + [ + p(np.arange(5 * 4 * 3).reshape(5, 4, 3)), + None, + ] + ) class TestTranspose(unittest.TestCase): - @parameterized.expand(TEST_CASES) + @parameterized.expand(TESTS) def test_transpose(self, im, indices): data = {"i": deepcopy(im), "j": deepcopy(im)} tr = Transposed(["i", "j"], indices) out_data = tr(data) out_im1, out_im2 = out_data["i"], out_data["j"] + if isinstance(im, torch.Tensor): + im = im.cpu().numpy() out_gt = np.transpose(im, indices) - np.testing.assert_array_equal(out_im1, out_gt) - np.testing.assert_array_equal(out_im2, out_gt) + assert_allclose(out_im1, out_gt) + assert_allclose(out_im2, out_gt) # test inverse fwd_inv_data = tr.inverse(out_data) for i, j in zip(data.values(), fwd_inv_data.values()): - np.testing.assert_array_equal(i, j) + assert_allclose(i, j) if __name__ == "__main__": From 67aa4cfba3a7e32786f22c5767bf6772c2a393d9 Mon Sep 17 00:00:00 2001 From: Ali Hatamizadeh Date: Sun, 29 Aug 2021 16:59:13 -0700 Subject: [PATCH 76/89] add classification support for ViT Model (#2861) * add classification support for ViT Model Signed-off-by: ahatamizadeh * add classification support for ViT Model Signed-off-by: ahatamizadeh * add classification support for ViT Model Signed-off-by: ahatamizadeh * [MONAI] python code formatting Signed-off-by: monai-bot Co-authored-by: monai-bot --- monai/apps/deepedit/transforms.py | 2 +- monai/networks/nets/unetr.py | 10 +++---- monai/networks/nets/vit.py | 16 +++++++--- tests/test_vit.py | 50 ++++++++++++++++--------------- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/monai/apps/deepedit/transforms.py b/monai/apps/deepedit/transforms.py index 2ab2722076..845e7bd1d0 100644 --- a/monai/apps/deepedit/transforms.py +++ b/monai/apps/deepedit/transforms.py @@ -6,10 +6,10 @@ from monai.config import KeysCollection from monai.transforms.transform import MapTransform, Randomizable, Transform +from monai.utils import optional_import logger = logging.getLogger(__name__) -from monai.utils import optional_import distance_transform_cdt, _ = optional_import("scipy.ndimage.morphology", name="distance_transform_cdt") diff --git a/monai/networks/nets/unetr.py b/monai/networks/nets/unetr.py index ed49847515..9990cb6643 100644 --- a/monai/networks/nets/unetr.py +++ b/monai/networks/nets/unetr.py @@ -34,9 +34,9 @@ def __init__( hidden_size: int = 768, mlp_dim: int = 3072, num_heads: int = 12, - pos_embed: str = "perceptron", + pos_embed: str = "conv", norm_name: Union[Tuple, str] = "instance", - conv_block: bool = False, + conv_block: bool = True, res_block: bool = True, dropout_rate: float = 0.0, spatial_dims: int = 3, @@ -59,13 +59,13 @@ def __init__( Examples:: - # for single channel input 4-channel output with patch size of (96,96,96), feature size of 32 and batch norm + # for single channel input 4-channel output with image size of (96,96,96), feature size of 32 and batch norm >>> net = UNETR(in_channels=1, out_channels=4, img_size=(96,96,96), feature_size=32, norm_name='batch') - # for single channel input 4-channel output with patch size of (96,96), feature size of 32 and batch norm + # for single channel input 4-channel output with image size of (96,96), feature size of 32 and batch norm >>> net = UNETR(in_channels=1, out_channels=4, img_size=96, feature_size=32, norm_name='batch', spatial_dims=2) - # for 4-channel input 3-channel output with patch size of (128,128,128), conv position embedding and instance norm + # for 4-channel input 3-channel output with image size of (128,128,128), conv position embedding and instance norm >>> net = UNETR(in_channels=4, out_channels=3, img_size=(128,128,128), pos_embed='conv', norm_name='instance') """ diff --git a/monai/networks/nets/vit.py b/monai/networks/nets/vit.py index 0fd55cac62..3a5d94cc37 100644 --- a/monai/networks/nets/vit.py +++ b/monai/networks/nets/vit.py @@ -12,6 +12,7 @@ from typing import Sequence, Union +import torch import torch.nn as nn from monai.networks.blocks.patchembedding import PatchEmbeddingBlock @@ -33,7 +34,7 @@ def __init__( mlp_dim: int = 3072, num_layers: int = 12, num_heads: int = 12, - pos_embed: str = "perceptron", + pos_embed: str = "conv", classification: bool = False, num_classes: int = 2, dropout_rate: float = 0.0, @@ -56,12 +57,15 @@ def __init__( Examples:: - # for single channel input with patch size of (96,96,96), conv position embedding and segmentation backbone + # for single channel input with image size of (96,96,96), conv position embedding and segmentation backbone >>> net = ViT(in_channels=1, img_size=(96,96,96), pos_embed='conv') - # for 3-channel with patch size of (128,128,128), 24 layers and classification backbone + # for 3-channel with image size of (128,128,128), 24 layers and classification backbone >>> net = ViT(in_channels=3, img_size=(128,128,128), pos_embed='conv', classification=True) + # for 3-channel with image size of (224,224), 12 layers and classification backbone + >>> net = ViT(in_channels=3, img_size=(224,224), pos_embed='conv', classification=True, spatial_dims=2) + """ super(ViT, self).__init__() @@ -88,10 +92,14 @@ def __init__( ) self.norm = nn.LayerNorm(hidden_size) if self.classification: - self.classification_head = nn.Linear(hidden_size, num_classes) + self.cls_token = nn.Parameter(torch.zeros(1, 1, hidden_size)) + self.classification_head = nn.Sequential(nn.Linear(hidden_size, num_classes), nn.Tanh()) def forward(self, x): x = self.patch_embedding(x) + if self.classification: + cls_token = self.cls_token.expand(x.shape[0], -1, -1) + x = torch.cat((cls_token, x), dim=1) hidden_states_out = [] for blk in self.blocks: x = blk(x) diff --git a/tests/test_vit.py b/tests/test_vit.py index 0dce73b0cb..cdf0888222 100644 --- a/tests/test_vit.py +++ b/tests/test_vit.py @@ -26,30 +26,32 @@ for num_heads in [12]: for mlp_dim in [3072]: for num_layers in [4]: - for num_classes in [2]: + for num_classes in [8]: for pos_embed in ["conv"]: - # for classification in [False, True]: # TODO: test classification - for nd in (2, 3): - test_case = [ - { - "in_channels": in_channels, - "img_size": (img_size,) * nd, - "patch_size": (patch_size,) * nd, - "hidden_size": hidden_size, - "mlp_dim": mlp_dim, - "num_layers": num_layers, - "num_heads": num_heads, - "pos_embed": pos_embed, - "classification": False, - "num_classes": num_classes, - "dropout_rate": dropout_rate, - }, - (2, in_channels, *([img_size] * nd)), - (2, (img_size // patch_size) ** nd, hidden_size), - ] - if nd == 2: - test_case[0]["spatial_dims"] = 2 # type: ignore - TEST_CASE_Vit.append(test_case) + for classification in [False, True]: + for nd in (2, 3): + test_case = [ + { + "in_channels": in_channels, + "img_size": (img_size,) * nd, + "patch_size": (patch_size,) * nd, + "hidden_size": hidden_size, + "mlp_dim": mlp_dim, + "num_layers": num_layers, + "num_heads": num_heads, + "pos_embed": pos_embed, + "classification": classification, + "num_classes": num_classes, + "dropout_rate": dropout_rate, + }, + (2, in_channels, *([img_size] * nd)), + (2, (img_size // patch_size) ** nd, hidden_size), + ] + if nd == 2: + test_case[0]["spatial_dims"] = 2 # type: ignore + if test_case[0]["classification"]: # type: ignore + test_case[2] = (2, test_case[0]["num_classes"]) # type: ignore + TEST_CASE_Vit.append(test_case) class TestPatchEmbeddingBlock(unittest.TestCase): @@ -113,7 +115,7 @@ def test_ill_arg(self): num_layers=12, num_heads=8, pos_embed="perceptron", - classification=False, + classification=True, dropout_rate=0.3, ) From 895592e5980de410e489b642d77ff14d83a87a21 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 30 Aug 2021 23:10:45 +0800 Subject: [PATCH 77/89] Add thread args to ThreadBuffer (#2862) * [DLMED] add args to ThreadDataLoader Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma --- monai/data/thread_buffer.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/monai/data/thread_buffer.py b/monai/data/thread_buffer.py index 2901335bd5..da5847465e 100644 --- a/monai/data/thread_buffer.py +++ b/monai/data/thread_buffer.py @@ -33,11 +33,11 @@ class ThreadBuffer: timeout: Time to wait for an item from the buffer, or to wait while the buffer is full when adding items """ - def __init__(self, src, buffer_size=1, timeout=0.01): + def __init__(self, src, buffer_size: int = 1, timeout: float = 0.01): self.src = src self.buffer_size = buffer_size self.timeout = timeout - self.buffer = Queue(self.buffer_size) + self.buffer: Queue = Queue(self.buffer_size) self.gen_thread = None self.is_running = False @@ -82,11 +82,27 @@ class ThreadDataLoader(DataLoader): Subclass of `DataLoader` using a `ThreadBuffer` object to implement `__iter__` method asynchronously. This will iterate over data from the loader as expected however the data is generated on a separate thread. Use this class where a `DataLoader` instance is required and not just an iterable object. + + Args: + dataset: input dataset. + buffer_size: number of items to buffer from the data source. + buffer_timeout: time to wait for an item from the buffer, or to wait while the buffer is full when adding items. + num_workers: number of the multi-prcessing workers in PyTorch DataLoader. + """ - def __init__(self, dataset: Dataset, num_workers: int = 0, **kwargs): + def __init__( + self, + dataset: Dataset, + buffer_size: int = 1, + buffer_timeout: float = 0.01, + num_workers: int = 0, + **kwargs, + ): super().__init__(dataset, num_workers, **kwargs) + self.buffer_size = buffer_size + self.buffer_timeout = buffer_timeout def __iter__(self): - buffer = ThreadBuffer(super().__iter__()) + buffer = ThreadBuffer(src=super().__iter__(), buffer_size=self.buffer_size, timeout=self.buffer_timeout) yield from buffer From a25b733afd7ab48b5dfdb863ac6baba0fe7e561e Mon Sep 17 00:00:00 2001 From: Richard Brown <33289025+rijobro@users.noreply.github.com> Date: Mon, 30 Aug 2021 19:09:47 +0100 Subject: [PATCH 78/89] DataStats, LabelToMask, Lambda, RandLambda, SqueezeDim, is_module_ver_at_least (#2859) * DataStats, LabelToMask, Lambda, RandLambda, SqueezeDim, is_module_ver_at_least Signed-off-by: Richard Brown <33289025+rijobro@users.noreply.github.com> --- monai/transforms/__init__.py | 2 +- monai/transforms/utility/array.py | 86 +++++++++++------ monai/transforms/utility/dictionary.py | 50 ++++++---- .../utils_pytorch_numpy_unification.py | 39 +++++--- monai/utils/__init__.py | 1 + monai/utils/misc.py | 16 +++- tests/test_data_stats.py | 2 +- tests/test_data_statsd.py | 2 +- tests/test_label_to_mask.py | 75 ++++++++------- tests/test_label_to_maskd.py | 80 +++++++++------- tests/test_lambda.py | 26 +++--- tests/test_lambdad.py | 39 ++++---- tests/test_squeezedim.py | 28 +++--- tests/test_squeezedimd.py | 92 +++++++++++-------- tests/test_to_cupy.py | 2 +- tests/test_to_cupyd.py | 2 +- tests/test_to_numpy.py | 2 +- tests/test_to_numpyd.py | 2 +- tests/utils.py | 4 +- 19 files changed, 332 insertions(+), 218 deletions(-) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index b0ba1e39d9..2ea7e3aa63 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -518,4 +518,4 @@ weighted_patch_samples, zero_margins, ) -from .utils_pytorch_numpy_unification import moveaxis +from .utils_pytorch_numpy_unification import in1d, moveaxis diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index f38a94302e..918763405f 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -22,7 +22,7 @@ import numpy as np import torch -from monai.config import DtypeLike, NdarrayTensor +from monai.config import DtypeLike from monai.config.type_definitions import NdarrayOrTensor from monai.transforms.transform import Randomizable, RandomizableTransform, Transform from monai.transforms.utils import ( @@ -31,9 +31,10 @@ map_binary_to_indices, map_classes_to_indices, ) -from monai.transforms.utils_pytorch_numpy_unification import moveaxis +from monai.transforms.utils_pytorch_numpy_unification import in1d, moveaxis from monai.utils import convert_to_numpy, convert_to_tensor, ensure_tuple, look_up_option, min_version, optional_import from monai.utils.enums import TransformBackends +from monai.utils.misc import is_module_ver_at_least from monai.utils.type_conversion import convert_data_type PILImageImage, has_pil = optional_import("PIL.Image", name="Image") @@ -445,6 +446,8 @@ class SqueezeDim(Transform): Squeeze a unitary dimension. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, dim: Optional[int] = 0) -> None: """ Args: @@ -459,12 +462,17 @@ def __init__(self, dim: Optional[int] = 0) -> None: raise TypeError(f"dim must be None or a int but is {type(dim).__name__}.") self.dim = dim - def __call__(self, img: NdarrayTensor) -> NdarrayTensor: + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Args: img: numpy arrays with required dimension `dim` removed """ - return img.squeeze(self.dim) # type: ignore + if self.dim is None: + return img.squeeze() + # for pytorch/numpy unification + if img.shape[self.dim] != 1: + raise ValueError("Can only squeeze singleton dimension") + return img.squeeze(self.dim) class DataStats(Transform): @@ -475,6 +483,8 @@ class DataStats(Transform): so it can be used in pre-processing and post-processing. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, prefix: str = "Data", @@ -523,14 +533,14 @@ def __init__( def __call__( self, - img: NdarrayTensor, + img: NdarrayOrTensor, prefix: Optional[str] = None, data_type: Optional[bool] = None, data_shape: Optional[bool] = None, value_range: Optional[bool] = None, data_value: Optional[bool] = None, additional_info: Optional[Callable] = None, - ) -> NdarrayTensor: + ) -> NdarrayOrTensor: """ Apply the transform to `img`, optionally take arguments similar to the class constructor. """ @@ -570,6 +580,8 @@ class SimulateDelay(Transform): to sub-optimal design choices. """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, delay_time: float = 0.0) -> None: """ Args: @@ -579,7 +591,7 @@ def __init__(self, delay_time: float = 0.0) -> None: super().__init__() self.delay_time: float = delay_time - def __call__(self, img: NdarrayTensor, delay_time: Optional[float] = None) -> NdarrayTensor: + def __call__(self, img: NdarrayOrTensor, delay_time: Optional[float] = None) -> NdarrayOrTensor: """ Args: img: data remain unchanged throughout this transform. @@ -612,12 +624,14 @@ class Lambda(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, func: Optional[Callable] = None) -> None: if func is not None and not callable(func): raise TypeError(f"func must be None or callable but is {type(func).__name__}.") self.func = func - def __call__(self, img: Union[np.ndarray, torch.Tensor], func: Optional[Callable] = None): + def __call__(self, img: NdarrayOrTensor, func: Optional[Callable] = None): """ Apply `self.func` to `img`. @@ -648,14 +662,15 @@ class RandLambda(Lambda, RandomizableTransform): prob: probability of executing the random function, default to 1.0, with 100% probability to execute. For more details, please check :py:class:`monai.transforms.Lambda`. - """ + backend = Lambda.backend + def __init__(self, func: Optional[Callable] = None, prob: float = 1.0) -> None: Lambda.__init__(self=self, func=func) RandomizableTransform.__init__(self=self, prob=prob) - def __call__(self, img: Union[np.ndarray, torch.Tensor], func: Optional[Callable] = None): + def __call__(self, img: NdarrayOrTensor, func: Optional[Callable] = None): self.randomize(img) return super().__call__(img=img, func=func) if self._do_transform else img @@ -679,6 +694,8 @@ class LabelToMask(Transform): """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( # pytype: disable=annotation-type-mismatch self, select_labels: Union[Sequence[int], int], @@ -688,8 +705,11 @@ def __init__( # pytype: disable=annotation-type-mismatch self.merge_channels = merge_channels def __call__( - self, img: np.ndarray, select_labels: Optional[Union[Sequence[int], int]] = None, merge_channels: bool = False - ): + self, + img: NdarrayOrTensor, + select_labels: Optional[Union[Sequence[int], int]] = None, + merge_channels: bool = False, + ) -> NdarrayOrTensor: """ Args: select_labels: labels to generate mask from. for 1 channel label, the `select_labels` @@ -706,26 +726,40 @@ def __call__( if img.shape[0] > 1: data = img[[*select_labels]] else: - data = np.where(np.in1d(img, select_labels), True, False).reshape(img.shape) + where = np.where if isinstance(img, np.ndarray) else torch.where + if isinstance(img, np.ndarray) or is_module_ver_at_least(torch, (1, 8, 0)): + data = where(in1d(img, select_labels), True, False).reshape(img.shape) + # pre pytorch 1.8.0, need to use 1/0 instead of True/False + else: + data = where( + in1d(img, select_labels), torch.tensor(1, device=img.device), torch.tensor(0, device=img.device) + ).reshape(img.shape) - return np.any(data, axis=0, keepdims=True) if (merge_channels or self.merge_channels) else data + if merge_channels or self.merge_channels: + if isinstance(img, np.ndarray) or is_module_ver_at_least(torch, (1, 8, 0)): + return data.any(0)[None] + # pre pytorch 1.8.0 compatibility + return data.to(torch.uint8).any(0)[None].to(bool) # type: ignore + + return data class FgBgToIndices(Transform): - def __init__(self, image_threshold: float = 0.0, output_shape: Optional[Sequence[int]] = None) -> None: - """ - Compute foreground and background of the input label data, return the indices. - If no output_shape specified, output data will be 1 dim indices after flattening. - This transform can help pre-compute foreground and background regions for other transforms. - A typical usage is to randomly select foreground and background to crop. - The main logic is based on :py:class:`monai.transforms.utils.map_binary_to_indices`. + """ + Compute foreground and background of the input label data, return the indices. + If no output_shape specified, output data will be 1 dim indices after flattening. + This transform can help pre-compute foreground and background regions for other transforms. + A typical usage is to randomly select foreground and background to crop. + The main logic is based on :py:class:`monai.transforms.utils.map_binary_to_indices`. - Args: - image_threshold: if enabled `image` at runtime, use ``image > image_threshold`` to - determine the valid image content area and select background only in this area. - output_shape: expected shape of output indices. if not None, unravel indices to specified shape. + Args: + image_threshold: if enabled `image` at runtime, use ``image > image_threshold`` to + determine the valid image content area and select background only in this area. + output_shape: expected shape of output indices. if not None, unravel indices to specified shape. - """ + """ + + def __init__(self, image_threshold: float = 0.0, output_shape: Optional[Sequence[int]] = None) -> None: self.image_threshold = image_threshold self.output_shape = output_shape diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 1b63b308d9..e9bcce93b0 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -23,7 +23,7 @@ import numpy as np import torch -from monai.config import DtypeLike, KeysCollection, NdarrayTensor +from monai.config import DtypeLike, KeysCollection from monai.config.type_definitions import NdarrayOrTensor from monai.data.utils import no_collation from monai.transforms.inverse import InvertibleTransform @@ -59,7 +59,7 @@ ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.utils import convert_to_numpy, ensure_tuple, ensure_tuple_rep -from monai.utils.enums import InverseKeys +from monai.utils.enums import InverseKeys, TransformBackends __all__ = [ "AddChannelD", @@ -650,6 +650,8 @@ class SqueezeDimd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.SqueezeDim`. """ + backend = SqueezeDim.backend + def __init__(self, keys: KeysCollection, dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: @@ -661,7 +663,7 @@ def __init__(self, keys: KeysCollection, dim: int = 0, allow_missing_keys: bool super().__init__(keys, allow_missing_keys) self.converter = SqueezeDim(dim=dim) - def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) @@ -673,6 +675,8 @@ class DataStatsd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.DataStats`. """ + backend = DataStats.backend + def __init__( self, keys: KeysCollection, @@ -719,7 +723,7 @@ def __init__( self.logger_handler = logger_handler self.printer = DataStats(logger_handler=logger_handler) - def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, prefix, data_type, data_shape, value_range, data_value, additional_info in self.key_iterator( d, self.prefix, self.data_type, self.data_shape, self.value_range, self.data_value, self.additional_info @@ -741,6 +745,8 @@ class SimulateDelayd(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.SimulateDelay`. """ + backend = SimulateDelay.backend + def __init__( self, keys: KeysCollection, delay_time: Union[Sequence[float], float] = 0.0, allow_missing_keys: bool = False ) -> None: @@ -757,7 +763,7 @@ def __init__( self.delay_time = ensure_tuple_rep(delay_time, len(self.keys)) self.delayer = SimulateDelay() - def __call__(self, data: Mapping[Hashable, NdarrayTensor]) -> Dict[Hashable, NdarrayTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, delay_time in self.key_iterator(d, self.delay_time): d[key] = self.delayer(d[key], delay_time=delay_time) @@ -768,9 +774,10 @@ class CopyItemsd(MapTransform): """ Copy specified items from data dictionary and save with different key names. It can copy several items together and copy several times. - """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__( self, keys: KeysCollection, times: int, names: KeysCollection, allow_missing_keys: bool = False ) -> None: @@ -802,7 +809,7 @@ def __init__( ) self.names = names - def __call__(self, data): + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: """ Raises: KeyError: When a key in ``self.names`` already exists in ``data``. @@ -814,10 +821,11 @@ def __call__(self, data): for key, new_key in self.key_iterator(d, self.names[i * key_len : (i + 1) * key_len]): if new_key in d: raise KeyError(f"Key {new_key} already exists in data.") - if isinstance(d[key], torch.Tensor): - d[new_key] = d[key].detach().clone() + val = d[key] + if isinstance(val, torch.Tensor): + d[new_key] = val.detach().clone() else: - d[new_key] = copy.deepcopy(d[key]) + d[new_key] = copy.deepcopy(val) return d @@ -825,9 +833,10 @@ class ConcatItemsd(MapTransform): """ Concatenate specified items from data dictionary together on the first dim to construct a big array. Expect all the items are numpy array or PyTorch Tensor. - """ + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + def __init__(self, keys: KeysCollection, name: str, dim: int = 0, allow_missing_keys: bool = False) -> None: """ Args: @@ -841,7 +850,7 @@ def __init__(self, keys: KeysCollection, name: str, dim: int = 0, allow_missing_ self.name = name self.dim = dim - def __call__(self, data): + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: """ Raises: TypeError: When items in ``data`` differ in type. @@ -857,10 +866,10 @@ def __call__(self, data): elif not isinstance(d[key], data_type): raise TypeError("All items in data must have the same type.") output.append(d[key]) - if data_type == np.ndarray: + if data_type is np.ndarray: d[self.name] = np.concatenate(output, axis=self.dim) - elif data_type == torch.Tensor: - d[self.name] = torch.cat(output, dim=self.dim) + elif data_type is torch.Tensor: + d[self.name] = torch.cat(output, dim=self.dim) # type: ignore else: raise TypeError(f"Unsupported data type: {data_type}, available options are (numpy.ndarray, torch.Tensor).") return d @@ -896,6 +905,8 @@ class Lambdad(MapTransform, InvertibleTransform): """ + backend = Lambda.backend + def __init__( self, keys: KeysCollection, @@ -913,7 +924,7 @@ def __init__( def _transform(self, data: Any, func: Callable): return self._lambd(data, func=func) - def __call__(self, data): + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key, func, overwrite in self.key_iterator(d, self.func, self.overwrite): ret = self._transform(data=d[key], func=func) @@ -958,9 +969,10 @@ class RandLambdad(Lambdad, RandomizableTransform): Note: The inverse operation doesn't allow to define `extra_info` or access other information, such as the image's original size. If need these complicated information, please write a new InvertibleTransform directly. - """ + backend = Lambda.backend + def __init__( self, keys: KeysCollection, @@ -1007,6 +1019,8 @@ class LabelToMaskd(MapTransform): """ + backend = LabelToMask.backend + def __init__( # pytype: disable=annotation-type-mismatch self, keys: KeysCollection, @@ -1017,7 +1031,7 @@ def __init__( # pytype: disable=annotation-type-mismatch super().__init__(keys, allow_missing_keys) self.converter = LabelToMask(select_labels=select_labels, merge_channels=merge_channels) - def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): d[key] = self.converter(d[key]) diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index e6dc151596..2eebe3eda3 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -16,26 +16,37 @@ __all__ = [ "moveaxis", + "in1d", ] def moveaxis(x: NdarrayOrTensor, src: int, dst: int) -> NdarrayOrTensor: + """`moveaxis` for pytorch and numpy, using `permute` for pytorch ver < 1.8""" if isinstance(x, torch.Tensor): if hasattr(torch, "moveaxis"): return torch.moveaxis(x, src, dst) - # moveaxis only available in pytorch as of 1.8.0 - else: - # get original indices - indices = list(range(x.ndim)) - # make src and dst positive - if src < 0: - src = len(indices) + src - if dst < 0: - dst = len(indices) + dst - # remove desired index and insert it in new position - indices.pop(src) - indices.insert(dst, src) - return x.permute(indices) - elif isinstance(x, np.ndarray): + return _moveaxis_with_permute(x, src, dst) # type: ignore + if isinstance(x, np.ndarray): return np.moveaxis(x, src, dst) raise RuntimeError() + + +def _moveaxis_with_permute(x, src, dst): + # get original indices + indices = list(range(x.ndim)) + # make src and dst positive + if src < 0: + src = len(indices) + src + if dst < 0: + dst = len(indices) + dst + # remove desired index and insert it in new position + indices.pop(src) + indices.insert(dst, src) + return x.permute(indices) + + +def in1d(x, y): + """`np.in1d` with equivalent implementation for torch.""" + if isinstance(x, np.ndarray): + return np.in1d(x, y) + return (x[..., None] == torch.tensor(y, device=x.device)).any(-1).view(-1) diff --git a/monai/utils/__init__.py b/monai/utils/__init__.py index 0ea5afc40c..aa8f02f815 100644 --- a/monai/utils/__init__.py +++ b/monai/utils/__init__.py @@ -46,6 +46,7 @@ first, get_seed, has_option, + is_module_ver_at_least, is_scalar, is_scalar_tensor, issequenceiterable, diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 66f6557032..3b287b3fe4 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -22,7 +22,7 @@ import numpy as np import torch -from monai.utils.module import get_torch_version_tuple +from monai.utils.module import get_torch_version_tuple, version_leq __all__ = [ "zip_with", @@ -42,6 +42,7 @@ "MAX_SEED", "copy_to_device", "ImageMetaKey", + "is_module_ver_at_least", ] _seed = None @@ -355,3 +356,16 @@ def has_option(obj, keywords: Union[str, Sequence[str]]) -> bool: return False sig = inspect.signature(obj) return all(key in sig.parameters for key in ensure_tuple(keywords)) + + +def is_module_ver_at_least(module, version): + """Determine if a module's version is at least equal to the given value. + + Args: + module: imported module's name, e.g., `np` or `torch`. + version: required version, given as a tuple, e.g., `(1, 8, 0)`. + Returns: + `True` if module is the given version or newer. + """ + test_ver = ".".join(map(str, version)) + return module.__version__ != test_ver and version_leq(test_ver, module.__version__) diff --git a/tests/test_data_stats.py b/tests/test_data_stats.py index 43068797a3..50536f2a5c 100644 --- a/tests/test_data_stats.py +++ b/tests/test_data_stats.py @@ -117,7 +117,7 @@ "additional_info": lambda x: torch.mean(x.float()), "logger_handler": None, }, - torch.tensor([[0, 1], [1, 2]]), + torch.tensor([[0, 1], [1, 2]]).to("cuda" if torch.cuda.is_available() else "cpu"), ( "test data statistics:\nType: \nShape: torch.Size([2, 2])\nValue range: (0, 2)\n" "Value: tensor([[0, 1],\n [1, 2]])\nAdditional info: 1.0" diff --git a/tests/test_data_statsd.py b/tests/test_data_statsd.py index be7e54bc25..aea0f1e721 100644 --- a/tests/test_data_statsd.py +++ b/tests/test_data_statsd.py @@ -124,7 +124,7 @@ "additional_info": lambda x: torch.mean(x.float()), "logger_handler": None, }, - {"img": torch.tensor([[0, 1], [1, 2]])}, + {"img": torch.tensor([[0, 1], [1, 2]]).to("cuda" if torch.cuda.is_available() else "cpu")}, ( "test data statistics:\nType: \nShape: torch.Size([2, 2])\nValue range: (0, 2)\n" "Value: tensor([[0, 1],\n [1, 2]])\nAdditional info: 1.0" diff --git a/tests/test_label_to_mask.py b/tests/test_label_to_mask.py index 2a84c7bea6..9caa7252f3 100644 --- a/tests/test_label_to_mask.py +++ b/tests/test_label_to_mask.py @@ -12,46 +12,59 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import LabelToMask +from tests.utils import TEST_NDARRAYS, assert_allclose -TEST_CASE_1 = [ - {"select_labels": [2, 3], "merge_channels": False}, - np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]]), - np.array([[[0, 0, 0], [1, 1, 1], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), -] - -TEST_CASE_2 = [ - {"select_labels": 2, "merge_channels": False}, - np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]]), - np.array([[[0, 0, 0], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), -] - -TEST_CASE_3 = [ - {"select_labels": [1, 2], "merge_channels": False}, - np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), - np.array([[[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), -] - -TEST_CASE_4 = [ - {"select_labels": 2, "merge_channels": False}, - np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), - np.array([[[1, 0, 1], [1, 1, 0]]]), -] - -TEST_CASE_5 = [ - {"select_labels": [1, 2], "merge_channels": True}, - np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), - np.array([[[1, 0, 1], [1, 1, 1]]]), -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"select_labels": [2, 3], "merge_channels": False}, + p(np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]])), + np.array([[[0, 0, 0], [1, 1, 1], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), + ] + ) + TESTS.append( + [ + {"select_labels": 2, "merge_channels": False}, + p(np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]])), + np.array([[[0, 0, 0], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), + ] + ) + TESTS.append( + [ + {"select_labels": [1, 2], "merge_channels": False}, + p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])), + np.array([[[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), + ] + ) + TESTS.append( + [ + {"select_labels": 2, "merge_channels": False}, + p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])), + np.array([[[1, 0, 1], [1, 1, 0]]]), + ] + ) + TESTS.append( + [ + {"select_labels": [1, 2], "merge_channels": True}, + p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])), + np.array([[[1, 0, 1], [1, 1, 1]]]), + ] + ) class TestLabelToMask(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) + @parameterized.expand(TESTS) def test_value(self, argments, image, expected_data): result = LabelToMask(**argments)(image) - np.testing.assert_allclose(result, expected_data) + self.assertEqual(type(result), type(image)) + if isinstance(result, torch.Tensor): + self.assertEqual(result.device, image.device) + assert_allclose(result, expected_data) if __name__ == "__main__": diff --git a/tests/test_label_to_maskd.py b/tests/test_label_to_maskd.py index f046390c19..b8f0d3c171 100644 --- a/tests/test_label_to_maskd.py +++ b/tests/test_label_to_maskd.py @@ -12,46 +12,60 @@ import unittest import numpy as np +import torch from parameterized import parameterized from monai.transforms import LabelToMaskd +from tests.utils import TEST_NDARRAYS, assert_allclose -TEST_CASE_1 = [ - {"keys": "img", "select_labels": [2, 3], "merge_channels": False}, - {"img": np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]])}, - np.array([[[0, 0, 0], [1, 1, 1], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), -] - -TEST_CASE_2 = [ - {"keys": "img", "select_labels": 2, "merge_channels": False}, - {"img": np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]])}, - np.array([[[0, 0, 0], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), -] - -TEST_CASE_3 = [ - {"keys": "img", "select_labels": [1, 2], "merge_channels": False}, - {"img": np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])}, - np.array([[[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), -] - -TEST_CASE_4 = [ - {"keys": "img", "select_labels": 2, "merge_channels": False}, - {"img": np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])}, - np.array([[[1, 0, 1], [1, 1, 0]]]), -] - -TEST_CASE_5 = [ - {"keys": "img", "select_labels": [1, 2], "merge_channels": True}, - {"img": np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]])}, - np.array([[[1, 0, 1], [1, 1, 1]]]), -] +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": "img", "select_labels": [2, 3], "merge_channels": False}, + {"img": p(np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]]))}, + np.array([[[0, 0, 0], [1, 1, 1], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), + ] + ) + TESTS.append( + [ + {"keys": "img", "select_labels": 2, "merge_channels": False}, + {"img": p(np.array([[[1, 1, 1], [2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5], [6, 6, 6]]]))}, + np.array([[[0, 0, 0], [1, 1, 1], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]]), + ] + ) + TESTS.append( + [ + {"keys": "img", "select_labels": [1, 2], "merge_channels": False}, + {"img": p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]))}, + np.array([[[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]), + ] + ) + TESTS.append( + [ + {"keys": "img", "select_labels": 2, "merge_channels": False}, + {"img": p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]))}, + np.array([[[1, 0, 1], [1, 1, 0]]]), + ] + ) + TESTS.append( + [ + {"keys": "img", "select_labels": [1, 2], "merge_channels": True}, + {"img": p(np.array([[[0, 0, 1], [0, 1, 0]], [[1, 0, 0], [0, 1, 1]], [[1, 0, 1], [1, 1, 0]]]))}, + np.array([[[1, 0, 1], [1, 1, 1]]]), + ] + ) class TestLabelToMaskd(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5]) - def test_value(self, argments, image, expected_data): - result = LabelToMaskd(**argments)(image) - np.testing.assert_allclose(result["img"], expected_data) + @parameterized.expand(TESTS) + def test_value(self, argments, input_data, expected_data): + result = LabelToMaskd(**argments)(input_data) + r, i = result["img"], input_data["img"] + self.assertEqual(type(r), type(i)) + if isinstance(r, torch.Tensor): + self.assertEqual(r.device, i.device) + assert_allclose(r, expected_data) if __name__ == "__main__": diff --git a/tests/test_lambda.py b/tests/test_lambda.py index e71eb3e5b0..738c81130d 100644 --- a/tests/test_lambda.py +++ b/tests/test_lambda.py @@ -11,30 +11,30 @@ import unittest -import numpy as np - from monai.transforms.utility.array import Lambda -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestLambda(NumpyImageTestCase2D): def test_lambda_identity(self): - img = self.imt + for p in TEST_NDARRAYS: + img = p(self.imt) - def identity_func(x): - return x + def identity_func(x): + return x - lambd = Lambda(func=identity_func) - self.assertTrue(np.allclose(identity_func(img), lambd(img))) + lambd = Lambda(func=identity_func) + assert_allclose(identity_func(img), lambd(img)) def test_lambda_slicing(self): - img = self.imt + for p in TEST_NDARRAYS: + img = p(self.imt) - def slice_func(x): - return x[:, :, :6, ::-2] + def slice_func(x): + return x[:, :, :6, ::2] - lambd = Lambda(func=slice_func) - self.assertTrue(np.allclose(slice_func(img), lambd(img))) + lambd = Lambda(func=slice_func) + assert_allclose(slice_func(img), lambd(img)) if __name__ == "__main__": diff --git a/tests/test_lambdad.py b/tests/test_lambdad.py index ca28af778b..05ba0ff6bc 100644 --- a/tests/test_lambdad.py +++ b/tests/test_lambdad.py @@ -11,37 +11,36 @@ import unittest -import numpy as np - from monai.transforms.utility.dictionary import Lambdad -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestLambdad(NumpyImageTestCase2D): def test_lambdad_identity(self): - img = self.imt - data = {"img": img, "prop": 1.0} + for p in TEST_NDARRAYS: + img = p(self.imt) + data = {"img": img, "prop": 1.0} - def noise_func(x): - return x + 1.0 + def noise_func(x): + return x + 1.0 - expected = {"img": noise_func(data["img"]), "prop": 1.0} - ret = Lambdad(keys=["img", "prop"], func=noise_func, overwrite=[True, False])(data) - self.assertTrue(np.allclose(expected["img"], ret["img"])) - self.assertTrue(np.allclose(expected["prop"], ret["prop"])) + expected = {"img": noise_func(data["img"]), "prop": 1.0} + ret = Lambdad(keys=["img", "prop"], func=noise_func, overwrite=[True, False])(data) + assert_allclose(expected["img"], ret["img"]) + assert_allclose(expected["prop"], ret["prop"]) def test_lambdad_slicing(self): - img = self.imt - data = {} - data["img"] = img + for p in TEST_NDARRAYS: + img = p(self.imt) + data = {"img": img} - def slice_func(x): - return x[:, :, :6, ::-2] + def slice_func(x): + return x[:, :, :6, ::2] - lambd = Lambdad(keys=data.keys(), func=slice_func) - expected = {} - expected["img"] = slice_func(data["img"]) - self.assertTrue(np.allclose(expected["img"], lambd(data)["img"])) + lambd = Lambdad(keys=data.keys(), func=slice_func) + expected = {} + expected["img"] = slice_func(data["img"]) + assert_allclose(expected["img"], lambd(data)["img"]) if __name__ == "__main__": diff --git a/tests/test_squeezedim.py b/tests/test_squeezedim.py index 01ea489320..15ff7e94d6 100644 --- a/tests/test_squeezedim.py +++ b/tests/test_squeezedim.py @@ -12,34 +12,32 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import SqueezeDim +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [{"dim": None}, np.random.rand(1, 2, 1, 3), (2, 3)] +TESTS, TESTS_FAIL = [], [] +for p in TEST_NDARRAYS: + TESTS.append([{"dim": None}, p(np.random.rand(1, 2, 1, 3)), (2, 3)]) + TESTS.append([{"dim": 2}, p(np.random.rand(1, 2, 1, 8, 16)), (1, 2, 8, 16)]) + TESTS.append([{"dim": -1}, p(np.random.rand(1, 1, 16, 8, 1)), (1, 1, 16, 8)]) + TESTS.append([{}, p(np.random.rand(1, 2, 1, 3)), (2, 1, 3)]) -TEST_CASE_2 = [{"dim": 2}, np.random.rand(1, 2, 1, 8, 16), (1, 2, 8, 16)] - -TEST_CASE_3 = [{"dim": -1}, np.random.rand(1, 1, 16, 8, 1), (1, 1, 16, 8)] - -TEST_CASE_4 = [{}, np.random.rand(1, 2, 1, 3), (2, 1, 3)] - -TEST_CASE_4_PT = [{}, torch.rand(1, 2, 1, 3), (2, 1, 3)] - -TEST_CASE_5 = [ValueError, {"dim": -2}, np.random.rand(1, 1, 16, 8, 1)] - -TEST_CASE_6 = [TypeError, {"dim": 0.5}, np.random.rand(1, 1, 16, 8, 1)] + TESTS_FAIL.append([ValueError, {"dim": -2}, p(np.random.rand(1, 1, 16, 8, 1))]) + TESTS_FAIL.append([TypeError, {"dim": 0.5}, p(np.random.rand(1, 1, 16, 8, 1))]) class TestSqueezeDim(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_4_PT]) + @parameterized.expand(TESTS) def test_shape(self, input_param, test_data, expected_shape): + result = SqueezeDim(**input_param)(test_data) self.assertTupleEqual(result.shape, expected_shape) - @parameterized.expand([TEST_CASE_5, TEST_CASE_6]) + @parameterized.expand(TESTS_FAIL) def test_invalid_inputs(self, exception, input_param, test_data): + with self.assertRaises(exception): SqueezeDim(**input_param)(test_data) diff --git a/tests/test_squeezedimd.py b/tests/test_squeezedimd.py index dcbd9212c7..35e7cd5d74 100644 --- a/tests/test_squeezedimd.py +++ b/tests/test_squeezedimd.py @@ -12,62 +12,78 @@ import unittest import numpy as np -import torch from parameterized import parameterized from monai.transforms import SqueezeDimd +from tests.utils import TEST_NDARRAYS -TEST_CASE_1 = [ - {"keys": ["img", "seg"], "dim": None}, - {"img": np.random.rand(1, 2, 1, 3), "seg": np.random.randint(0, 2, size=[1, 2, 1, 3])}, - (2, 3), -] +TESTS, TESTS_FAIL = [], [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + {"keys": ["img", "seg"], "dim": None}, + {"img": p(np.random.rand(1, 2, 1, 3)), "seg": p(np.random.randint(0, 2, size=[1, 2, 1, 3]))}, + (2, 3), + ] + ) -TEST_CASE_2 = [ - {"keys": ["img", "seg"], "dim": 2}, - {"img": np.random.rand(1, 2, 1, 8, 16), "seg": np.random.randint(0, 2, size=[1, 2, 1, 8, 16])}, - (1, 2, 8, 16), -] + TESTS.append( + [ + {"keys": ["img", "seg"], "dim": 2}, + {"img": p(np.random.rand(1, 2, 1, 8, 16)), "seg": p(np.random.randint(0, 2, size=[1, 2, 1, 8, 16]))}, + (1, 2, 8, 16), + ] + ) -TEST_CASE_3 = [ - {"keys": ["img", "seg"], "dim": -1}, - {"img": np.random.rand(1, 1, 16, 8, 1), "seg": np.random.randint(0, 2, size=[1, 1, 16, 8, 1])}, - (1, 1, 16, 8), -] + TESTS.append( + [ + {"keys": ["img", "seg"], "dim": -1}, + {"img": p(np.random.rand(1, 1, 16, 8, 1)), "seg": p(np.random.randint(0, 2, size=[1, 1, 16, 8, 1]))}, + (1, 1, 16, 8), + ] + ) -TEST_CASE_4 = [ - {"keys": ["img", "seg"]}, - {"img": np.random.rand(1, 2, 1, 3), "seg": np.random.randint(0, 2, size=[1, 2, 1, 3])}, - (2, 1, 3), -] + TESTS.append( + [ + {"keys": ["img", "seg"]}, + {"img": p(np.random.rand(1, 2, 1, 3)), "seg": p(np.random.randint(0, 2, size=[1, 2, 1, 3]))}, + (2, 1, 3), + ] + ) -TEST_CASE_4_PT = [ - {"keys": ["img", "seg"], "dim": 0}, - {"img": torch.rand(1, 2, 1, 3), "seg": torch.randint(0, 2, size=[1, 2, 1, 3])}, - (2, 1, 3), -] + TESTS.append( + [ + {"keys": ["img", "seg"], "dim": 0}, + {"img": p(np.random.rand(1, 2, 1, 3)), "seg": p(np.random.randint(0, 2, size=[1, 2, 1, 3]))}, + (2, 1, 3), + ] + ) -TEST_CASE_5 = [ - ValueError, - {"keys": ["img", "seg"], "dim": -2}, - {"img": np.random.rand(1, 1, 16, 8, 1), "seg": np.random.randint(0, 2, size=[1, 1, 16, 8, 1])}, -] + TESTS_FAIL.append( + [ + ValueError, + {"keys": ["img", "seg"], "dim": -2}, + {"img": p(np.random.rand(1, 1, 16, 8, 1)), "seg": p(np.random.randint(0, 2, size=[1, 1, 16, 8, 1]))}, + ] + ) -TEST_CASE_6 = [ - TypeError, - {"keys": ["img", "seg"], "dim": 0.5}, - {"img": np.random.rand(1, 1, 16, 8, 1), "seg": np.random.randint(0, 2, size=[1, 1, 16, 8, 1])}, -] + TESTS_FAIL.append( + [ + TypeError, + {"keys": ["img", "seg"], "dim": 0.5}, + {"img": p(np.random.rand(1, 1, 16, 8, 1)), "seg": p(np.random.randint(0, 2, size=[1, 1, 16, 8, 1]))}, + ] + ) class TestSqueezeDim(unittest.TestCase): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_4_PT]) + @parameterized.expand(TESTS) def test_shape(self, input_param, test_data, expected_shape): result = SqueezeDimd(**input_param)(test_data) self.assertTupleEqual(result["img"].shape, expected_shape) self.assertTupleEqual(result["seg"].shape, expected_shape) - @parameterized.expand([TEST_CASE_5, TEST_CASE_6]) + @parameterized.expand(TESTS_FAIL) def test_invalid_inputs(self, exception, input_param, test_data): with self.assertRaises(exception): SqueezeDimd(**input_param)(test_data) diff --git a/tests/test_to_cupy.py b/tests/test_to_cupy.py index a9460bc825..8b00e12539 100644 --- a/tests/test_to_cupy.py +++ b/tests/test_to_cupy.py @@ -24,7 +24,7 @@ class TestToCupy(unittest.TestCase): @skipUnless(has_cp, "CuPy is required.") - def test_cumpy_input(self): + def test_cupy_input(self): test_data = cp.array([[1, 2], [3, 4]]) test_data = cp.rot90(test_data) self.assertFalse(test_data.flags["C_CONTIGUOUS"]) diff --git a/tests/test_to_cupyd.py b/tests/test_to_cupyd.py index 2f3c42dd1f..6f40bafe1c 100644 --- a/tests/test_to_cupyd.py +++ b/tests/test_to_cupyd.py @@ -24,7 +24,7 @@ class TestToCupyd(unittest.TestCase): @skipUnless(has_cp, "CuPy is required.") - def test_cumpy_input(self): + def test_cupy_input(self): test_data = cp.array([[1, 2], [3, 4]]) test_data = cp.rot90(test_data) self.assertFalse(test_data.flags["C_CONTIGUOUS"]) diff --git a/tests/test_to_numpy.py b/tests/test_to_numpy.py index fd49a3d473..b48727c01d 100644 --- a/tests/test_to_numpy.py +++ b/tests/test_to_numpy.py @@ -24,7 +24,7 @@ class TestToNumpy(unittest.TestCase): @skipUnless(has_cp, "CuPy is required.") - def test_cumpy_input(self): + def test_cupy_input(self): test_data = cp.array([[1, 2], [3, 4]]) test_data = cp.rot90(test_data) self.assertFalse(test_data.flags["C_CONTIGUOUS"]) diff --git a/tests/test_to_numpyd.py b/tests/test_to_numpyd.py index adfab65904..5acaef39c7 100644 --- a/tests/test_to_numpyd.py +++ b/tests/test_to_numpyd.py @@ -24,7 +24,7 @@ class TestToNumpyd(unittest.TestCase): @skipUnless(has_cp, "CuPy is required.") - def test_cumpy_input(self): + def test_cupy_input(self): test_data = cp.array([[1, 2], [3, 4]]) test_data = cp.rot90(test_data) self.assertFalse(test_data.flags["C_CONTIGUOUS"]) diff --git a/tests/utils.py b/tests/utils.py index 22720849f1..1375cd2d72 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -36,6 +36,7 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.data import create_test_image_2d, create_test_image_3d from monai.utils import ensure_tuple, optional_import, set_determinism +from monai.utils.misc import is_module_ver_at_least from monai.utils.module import version_leq nib, _ = optional_import("nibabel") @@ -142,8 +143,7 @@ class SkipIfBeforePyTorchVersion: def __init__(self, pytorch_version_tuple): self.min_version = pytorch_version_tuple - test_ver = ".".join(map(str, self.min_version)) - self.version_too_old = torch.__version__ != test_ver and version_leq(torch.__version__, test_ver) + self.version_too_old = not is_module_ver_at_least(torch, pytorch_version_tuple) def __call__(self, obj): return unittest.skipIf( From 6dc9b3ec3ba3c41cd41bb5981abb6a7bb090745c Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 31 Aug 2021 09:19:04 +0100 Subject: [PATCH 79/89] update to use pytorch 21.08 base image (#2867) * update cron tests Signed-off-by: Wenqi Li * temp tests Signed-off-by: Wenqi Li * update gpu info Signed-off-by: Wenqi Li * tmp tests Signed-off-by: Wenqi Li * temp test Signed-off-by: Wenqi Li * Revert "temp test" This reverts commit 3a5b8f849d077aff121ea55332d23f74d4375a89. Signed-off-by: Wenqi Li * Revert "tmp tests" This reverts commit e960dac2e0eb81fe518bf0a80b12d6bb1bdf965a. Signed-off-by: Wenqi Li * Revert "temp tests" This reverts commit 911c3322e6ad731abf7c5a7de3f62ca697b373dd. Signed-off-by: Wenqi Li --- .github/workflows/cron.yml | 6 +++--- .github/workflows/pythonapp-gpu.yml | 14 ++++++++------ Dockerfile | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 9803d459dc..a36cfbcdb9 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -62,7 +62,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.06"] # 21.02 for backward comp. + container: ["pytorch:21.02", "pytorch:21.08"] # 21.02 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -106,7 +106,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' strategy: matrix: - container: ["pytorch:21.02", "pytorch:21.06"] # 21.02 for backward comp. + container: ["pytorch:21.02", "pytorch:21.08"] # 21.02 for backward comp. container: image: nvcr.io/nvidia/${{ matrix.container }}-py3 # testing with the latest pytorch base image options: "--gpus all" @@ -204,7 +204,7 @@ jobs: if: github.repository == 'Project-MONAI/MONAI' needs: cron-gpu # so that monai itself is verified first container: - image: nvcr.io/nvidia/pytorch:21.06-py3 # testing with the latest pytorch base image + image: nvcr.io/nvidia/pytorch:21.08-py3 # testing with the latest pytorch base image options: "--gpus all --ipc=host" runs-on: [self-hosted, linux, x64, common] steps: diff --git a/.github/workflows/pythonapp-gpu.yml b/.github/workflows/pythonapp-gpu.yml index 72e164a499..999567ae16 100644 --- a/.github/workflows/pythonapp-gpu.yml +++ b/.github/workflows/pythonapp-gpu.yml @@ -23,7 +23,7 @@ jobs: - "PT17+CUDA102" - "PT17+CUDA110" - "PT18+CUDA102" - - "PT19+CUDA113" + - "PT19+CUDA114" - "PT19+CUDA102" include: - environment: PT16+CUDA110 @@ -40,10 +40,12 @@ jobs: - environment: PT18+CUDA102 pytorch: "torch==1.8.1 torchvision==0.9.1" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" - - environment: PT19+CUDA113 + - environment: PT19+CUDA114 # we explicitly set pytorch to -h to avoid pip install error + # https://docs.nvidia.com/deeplearning/frameworks/pytorch-release-notes + # 21.08: 1.10.0a0+3fd9dcf pytorch: "-h" - base: "nvcr.io/nvidia/pytorch:21.06-py3" + base: "nvcr.io/nvidia/pytorch:21.08-py3" - environment: PT19+CUDA102 pytorch: "torch==1.9.0 torchvision==0.10.0" base: "nvcr.io/nvidia/cuda:10.2-devel-ubuntu18.04" @@ -91,9 +93,9 @@ jobs: python get-pip.py && \ rm get-pip.py; fi - - if: matrix.environment == 'PT19+CUDA113' - name: Optional Cupy dependency (cuda113) - run: echo "cupy-cuda113" >> requirements-dev.txt + - if: matrix.environment == 'PT19+CUDA114' + name: Optional Cupy dependency (cuda114) + run: echo "cupy-cuda114" >> requirements-dev.txt - name: Install dependencies run: | which python diff --git a/Dockerfile b/Dockerfile index ac06183768..77fe1f828f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # To build with a different base image # please run `docker build` using the `--build-arg PYTORCH_IMAGE=...` flag. -ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.06-py3 +ARG PYTORCH_IMAGE=nvcr.io/nvidia/pytorch:21.08-py3 FROM ${PYTORCH_IMAGE} LABEL maintainer="monai.contact@gmail.com" From e9f6e511800dd800795034200f03fc1631ae2890 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 31 Aug 2021 10:11:06 +0100 Subject: [PATCH 80/89] 2639 documentation for the deprecated API (#2865) * fixes #2639 Signed-off-by: Wenqi Li * fixes typo Signed-off-by: Wenqi Li --- CONTRIBUTING.md | 7 +++++++ monai/handlers/segmentation_saver.py | 3 +++ monai/handlers/transform_inverter.py | 3 +++ monai/handlers/utils.py | 6 ++++++ monai/networks/nets/dynunet_v1.py | 4 ++++ monai/networks/nets/torchvision_fc.py | 4 ++++ monai/utils/deprecated.py | 9 +++++++++ 7 files changed, 36 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e52180a798..0dce26582a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ 1. [Automatic code formatting](#automatic-code-formatting) 1. [Signing your work](#signing-your-work) 1. [Utility functions](#utility-functions) + 1. [Backwards compatibility](#backwards-compatibility) * [Submitting pull requests](#submitting-pull-requests) - [The code reviewing process (for the maintainers)](#the-code-reviewing-process) * [Reviewing pull requests](#reviewing-pull-requests) @@ -229,6 +230,12 @@ for example, ``import monai.transforms.Spacing`` is the equivalent of ``monai.tr For string definition, [f-string](https://www.python.org/dev/peps/pep-0498/) is recommended to use over `%-print` and `format-print` from python 3.6. So please try to use `f-string` if you need to define any string object. +#### Backwards compatibility +MONAI is currently under active development, and with major version zero (following the [Semantic Versioning](https://semver.org/)). +The backwards compatibility of the API is not always guaranteed at this initial development stage. +However, utility functions are provided in the `monai.utils.deprecated` modules to help users migrate to the new API. +The use of these functions is encouraged. + ### Submitting pull requests All code changes to the dev branch must be done via [pull requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). diff --git a/monai/handlers/segmentation_saver.py b/monai/handlers/segmentation_saver.py index 8b937f35a0..535f58945b 100644 --- a/monai/handlers/segmentation_saver.py +++ b/monai/handlers/segmentation_saver.py @@ -37,6 +37,9 @@ class SegmentationSaver: use index from 0 as the filename prefix. The predictions can be PyTorch Tensor with [B, C, H, W, [D]] shape or a list of Tensor without batch dim. + .. deprecated:: 0.6.0 + Use :class:`monai.transforms.SaveImage` or :class:`monai.transforms.SaveImaged` instead. + """ def __init__( diff --git a/monai/handlers/transform_inverter.py b/monai/handlers/transform_inverter.py index c6f38e4afd..83b5f56396 100644 --- a/monai/handlers/transform_inverter.py +++ b/monai/handlers/transform_inverter.py @@ -36,6 +36,9 @@ class TransformInverter: And the inverted meta dict will be stored in `engine.state.batch` with key: "{meta_keys}" or "{key}_{meta_key_postfix}". + .. deprecated:: 0.6.0 + Use :class:`monai.transforms.Invertd` instead. + """ def __init__( diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py index bedc340daa..13f23c582a 100644 --- a/monai/handlers/utils.py +++ b/monai/handlers/utils.py @@ -68,6 +68,9 @@ def evenly_divisible_all_gather(data: torch.Tensor) -> torch.Tensor: Note: The input data on different ranks must have exactly same `dtype`. + .. versionchanged:: 0.6.0 + The API had been moved to `monai.utils`. + """ if not isinstance(data, torch.Tensor): raise ValueError("input data must be PyTorch Tensor.") @@ -98,6 +101,9 @@ def string_list_all_gather(strings: List[str]) -> List[str]: Args: strings: a list of strings to all gather. + .. versionchanged:: 0.6.0 + The API had been moved to `monai.utils`. + """ world_size = idist.get_world_size() if world_size <= 1: diff --git a/monai/networks/nets/dynunet_v1.py b/monai/networks/nets/dynunet_v1.py index 9f817d2c8d..feb05d1762 100644 --- a/monai/networks/nets/dynunet_v1.py +++ b/monai/networks/nets/dynunet_v1.py @@ -43,6 +43,10 @@ class DynUNetV1(DynUNet): deep_supr_num: number of feature maps that will output during deep supervision head. Defaults to 1. res_block: whether to use residual connection based convolution blocks during the network. Defaults to ``False``. + + .. deprecated:: 0.6.0 + Use :class:`monai.networks.nets.DynUNet` instead. + """ def __init__( diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py index 66d905be85..2c4c7c8c32 100644 --- a/monai/networks/nets/torchvision_fc.py +++ b/monai/networks/nets/torchvision_fc.py @@ -81,6 +81,10 @@ class TorchVisionFullyConvModel(TorchVisionFCModel): pool_size: the kernel size for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to (7, 7). pool_stride: the stride for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to 1. pretrained: whether to use the imagenet pretrained weights. Default to False. + + .. deprecated:: 0.6.0 + Use :class:`monai.networks.nets.TorchVisionFCModel` instead. + """ def __init__( diff --git a/monai/utils/deprecated.py b/monai/utils/deprecated.py index 4c6b2db108..3a4568b06c 100644 --- a/monai/utils/deprecated.py +++ b/monai/utils/deprecated.py @@ -46,6 +46,10 @@ def deprecated( a `DeprecatedError` exception is instead raised if `removed` is given and the current version is at or later than that, or if neither `since` nor `removed` is provided. + The relevant docstring of the deprecating function should also be updated accordingly, + using the Sphinx directives such as `.. versionchanged:: version` and `.. deprecated:: version`. + https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-versionadded + Args: since: version at which the definition was marked deprecated but not removed. removed: version at which the definition was removed and no longer usable. @@ -122,6 +126,11 @@ def deprecated_arg( a `DeprecatedError` exception is instead raised if `removed` is given and the current version is at or later than that, or if neither `since` nor `removed` is provided. + The relevant docstring of the deprecating function should also be updated accordingly, + using the Sphinx directives such as `.. versionchanged:: version` and `.. deprecated:: version`. + https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-versionadded + + Args: name: name of position or keyword argument to mark as deprecated. since: version at which the argument was marked deprecated but not removed. From b0c83db2d8feafa7c05e71d1ceb1c22eab9cfa9a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 1 Sep 2021 03:58:31 +0800 Subject: [PATCH 81/89] [DLMED] enhance doc-string of ToDevice (#2874) Signed-off-by: Nic Ma --- monai/transforms/utility/array.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 918763405f..dd045817fb 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -1075,6 +1075,12 @@ class ToDevice(Transform): Move PyTorch Tensor to the specified device. It can help cache data into GPU and execute following logic on GPU directly. + Note: + If moving data to GPU device in the multi-processing workers of DataLoader, may got below CUDA error: + "RuntimeError: Cannot re-initialize CUDA in forked subprocess. To use CUDA with multiprocessing, + you must use the 'spawn' start method." + So usually suggest to set `num_workers=0` in the `DataLoader` or `ThreadDataLoader`. + """ def __init__(self, device: Union[torch.device, str], **kwargs) -> None: From 9990179752f8db59349df4d0a1d5ec4ca985ca40 Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 1 Sep 2021 07:33:39 +0800 Subject: [PATCH 82/89] [DLMED] simplify random seed (#2875) Signed-off-by: Nic Ma --- monai/utils/misc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/monai/utils/misc.py b/monai/utils/misc.py index 3b287b3fe4..a31452f6ae 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -235,8 +235,7 @@ def set_determinism( if seed is None: # cast to 32 bit seed for CUDA seed_ = torch.default_generator.seed() % (np.iinfo(np.int32).max + 1) - if not torch.cuda._is_in_bad_fork(): - torch.cuda.manual_seed_all(seed_) + torch.manual_seed(seed_) else: seed = int(seed) % MAX_SEED torch.manual_seed(seed) From 7bfa428e82dbae5adc4d280dbb892cb9ce1bed5a Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Wed, 1 Sep 2021 13:43:43 +0800 Subject: [PATCH 83/89] [DLMED] enhance image reader doc-string for multi-files (#2876) Signed-off-by: Nic Ma --- monai/data/image_reader.py | 21 +++++++++++++-------- monai/transforms/io/array.py | 5 ++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 1e3a89eb31..cd1486d6d3 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -178,7 +178,9 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: def read(self, data: Union[Sequence[str], str], **kwargs): """ - Read image data from specified file or files. + Read image data from specified file or files, it can read a list of `no-channel` images + and stack them together as multi-channels data in `get_data()`. + If passing directory path instead of file path, will treat it as DICOM images series and read. Note that the returned object is ITK image object or list of ITK image objects. Args: @@ -218,7 +220,7 @@ def get_data(self, img): Extract data array and meta data from loaded image and return them. This function returns two objects, first is numpy array of image data, second is dict of meta data. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. - When loading a list of files, they are concatenated together at a new dimension as the first dimension, + When loading a list of files, they are stacked together at a new dimension as the first dimension, and the meta data of the first image is used to represent the output meta data. Args: @@ -350,7 +352,8 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: def read(self, data: Union[Sequence[str], str], **kwargs): """ - Read image data from specified file or files. + Read image data from specified file or files, it can read a list of `no-channel` images + and stack them together as multi-channels data in `get_data()`. Note that the returned object is Nibabel image object or list of Nibabel image objects. Args: @@ -376,7 +379,7 @@ def get_data(self, img): Extract data array and meta data from loaded image and return them. This function returns two objects, first is numpy array of image data, second is dict of meta data. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. - When loading a list of files, they are concatenated together at a new dimension as the first dimension, + When loading a list of files, they are stacked together at a new dimension as the first dimension, and the meta data of the first image is used to present the output meta data. Args: @@ -497,7 +500,8 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: def read(self, data: Union[Sequence[str], str], **kwargs): """ - Read image data from specified file or files. + Read image data from specified file or files, it can read a list of `no-channel` data files + and stack them together as multi-channels data in `get_data()`. Note that the returned object is Numpy array or list of Numpy arrays. Args: @@ -529,7 +533,7 @@ def get_data(self, img): Extract data array and meta data from loaded image and return them. This function returns two objects, first is numpy array of image data, second is dict of meta data. It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict. - When loading a list of files, they are concatenated together at a new dimension as the first dimension, + When loading a list of files, they are stacked together at a new dimension as the first dimension, and the meta data of the first image is used to represent the output meta data. Args: @@ -581,7 +585,8 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool: def read(self, data: Union[Sequence[str], str, np.ndarray], **kwargs): """ - Read image data from specified file or files. + Read image data from specified file or files, it can read a list of `no-channel` images + and stack them together as multi-channels data in `get_data()`. Note that the returned object is PIL image or list of PIL image. Args: @@ -609,7 +614,7 @@ def get_data(self, img): Extract data array and meta data from loaded image and return them. This function returns two objects, first is numpy array of image data, second is dict of meta data. It computes `spatial_shape` and stores it in meta dict. - When loading a list of files, they are concatenated together at a new dimension as the first dimension, + When loading a list of files, they are stacked together at a new dimension as the first dimension, and the meta data of the first image is used to represent the output meta data. Args: diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index b8e2f75508..38a2861c8b 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -169,7 +169,10 @@ def __call__(self, filename: Union[Sequence[str], str, Path, Sequence[Path]], re Args: filename: path file or file-like object or a list of files. will save the filename to meta_data with key `filename_or_obj`. - if provided a list of files, use the filename of first file. + if provided a list of files, use the filename of first file to save, + and will stack them together as multi-channels data. + if provided directory path instead of file path, will treat it as + DICOM images series and read. reader: runtime reader to load image file and meta data. """ From 50dcf8d127a2e0748d0b4ee5c2139754ff2dd722 Mon Sep 17 00:00:00 2001 From: Jirka Borovec Date: Fri, 3 Sep 2021 01:23:48 +0200 Subject: [PATCH 84/89] split CI package (#2863) * split CI package Signed-off-by: Jirka * tmpdir Signed-off-by: Jirka * tmpdir Signed-off-by: Jirka --- .github/workflows/pythonapp.yml | 41 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 482e1937e1..3f18263e9e 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -202,6 +202,9 @@ jobs: packaging: runs-on: ubuntu-latest + env: + QUICKTEST: True + shell: bash steps: - uses: actions/checkout@v2 with: @@ -229,51 +232,57 @@ jobs: # however, "pip install monai*.tar.gz" will build cpp/cuda with an isolated # fresh torch installation according to pyproject.toml python -m pip install torch>=1.5 torchvision - - name: Test source archive and wheel file + - name: Check packages run: | pip uninstall monai pip list | grep -iv monai git fetch --depth=1 origin +refs/tags/*:refs/tags/* - root_dir=$PWD - echo "$root_dir" set -e # build tar.gz and wheel python setup.py check -m -s python setup.py sdist bdist_wheel python -m twine check dist/* - + - run: echo "::set-output name=pwd::$PWD" + id: root + - run: echo "::set-output name=tmp_dir::$(mktemp -d)" + id: mktemp + - name: Move packages + run: | + printf ${{ steps.root.outputs.pwd }} + printf ${{ steps.mktemp.outputs.tmp_dir }} # move packages to a temp dir - tmp_dir=$(mktemp -d) - cp dist/monai* "$tmp_dir" + cp dist/monai* "${{ steps.mktemp.outputs.tmp_dir }}" rm -r build dist monai.egg-info - cd "$tmp_dir" + cd "${{ steps.mktemp.outputs.tmp_dir }}" ls -al - + - name: Install wheel file + working-directory: ${{ steps.mktemp.outputs.tmp_dir }} + run: | # install from wheel python -m pip install monai*.whl python -c 'import monai; monai.config.print_config()' 2>&1 | grep -iv "unknown" python -c 'import monai; print(monai.__file__)' python -m pip uninstall -y monai rm monai*.whl - + - name: Install source archive + working-directory: ${{ steps.mktemp.outputs.tmp_dir }} + run: | # install from tar.gz name=$(ls *.tar.gz | head -n1) echo $name python -m pip install $name[all] python -c 'import monai; monai.config.print_config()' 2>&1 | grep -iv "unknown" python -c 'import monai; print(monai.__file__)' - + - name: Quick test + working-directory: ${{ steps.mktemp.outputs.tmp_dir }} + run: | # run min tests - cp $root_dir/requirements*.txt "$tmp_dir" - cp -r $root_dir/tests "$tmp_dir" - pwd + cp ${{ steps.root.outputs.pwd }}/requirements*.txt . + cp -r ${{ steps.root.outputs.pwd }}/tests . ls -al python -m pip install -r requirements-dev.txt python -m unittest -v - env: - QUICKTEST: True - shell: bash build-docs: runs-on: ubuntu-latest From 245ab94ccceb198e46416961546b445f9fd54d16 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Fri, 3 Sep 2021 11:47:52 -0400 Subject: [PATCH 85/89] Split On Grid (#2879) * Implement SplitOnGrid Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Implement dictionary-based SplitOnGrid Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update inits Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update docs Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Change imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update input logic in SplitOnGrid) Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Add unittests for SplitOnGrid and SplitOnGridDict Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Sort import Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove imports Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Address comments Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Remove optional Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Address thread safety issues Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> --- docs/source/apps.rst | 8 ++ monai/apps/pathology/transforms/__init__.py | 2 + .../pathology/transforms/spatial/__init__.py | 13 ++ .../pathology/transforms/spatial/array.py | 77 ++++++++++ .../transforms/spatial/dictionary.py | 56 ++++++++ tests/test_split_on_grid.py | 131 ++++++++++++++++++ tests/test_split_on_grid_dict.py | 131 ++++++++++++++++++ 7 files changed, 418 insertions(+) create mode 100644 monai/apps/pathology/transforms/spatial/__init__.py create mode 100644 monai/apps/pathology/transforms/spatial/array.py create mode 100644 monai/apps/pathology/transforms/spatial/dictionary.py create mode 100644 tests/test_split_on_grid.py create mode 100644 tests/test_split_on_grid_dict.py diff --git a/docs/source/apps.rst b/docs/source/apps.rst index 1a2efeff48..11d60767ec 100644 --- a/docs/source/apps.rst +++ b/docs/source/apps.rst @@ -110,3 +110,11 @@ Clara MMARs :members: .. autoclass:: NormalizeHEStainsd :members: + +.. automodule:: monai.apps.pathology.transforms.spatial.array +.. autoclass:: SplitOnGrid + :members: + +.. automodule:: monai.apps.pathology.transforms.spatial.dictionary +.. autoclass:: SplitOnGridd + :members: diff --git a/monai/apps/pathology/transforms/__init__.py b/monai/apps/pathology/transforms/__init__.py index 0df016244b..1be96b8e34 100644 --- a/monai/apps/pathology/transforms/__init__.py +++ b/monai/apps/pathology/transforms/__init__.py @@ -9,6 +9,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .spatial.array import SplitOnGrid +from .spatial.dictionary import SplitOnGridd, SplitOnGridD, SplitOnGridDict from .stain.array import ExtractHEStains, NormalizeHEStains from .stain.dictionary import ( ExtractHEStainsd, diff --git a/monai/apps/pathology/transforms/spatial/__init__.py b/monai/apps/pathology/transforms/spatial/__init__.py new file mode 100644 index 0000000000..07ba222ab0 --- /dev/null +++ b/monai/apps/pathology/transforms/spatial/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2020 - 2021 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. + +from .array import SplitOnGrid +from .dictionary import SplitOnGridd, SplitOnGridD, SplitOnGridDict diff --git a/monai/apps/pathology/transforms/spatial/array.py b/monai/apps/pathology/transforms/spatial/array.py new file mode 100644 index 0000000000..53e0c63715 --- /dev/null +++ b/monai/apps/pathology/transforms/spatial/array.py @@ -0,0 +1,77 @@ +# Copyright 2020 - 2021 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. + +from typing import Optional, Tuple, Union + +import torch + +from monai.transforms.transform import Transform + +__all__ = ["SplitOnGrid"] + + +class SplitOnGrid(Transform): + """ + Split the image into patches based on the provided grid shape. + This transform works only with torch.Tensor inputs. + + Args: + grid_shape: a tuple or an integer define the shape of the grid upon which to extract patches. + If it's an integer, the value will be repeated for each dimension. Default is 2x2 + patch_size: a tuple or an integer that defines the output patch sizes. + If it's an integer, the value will be repeated for each dimension. + The default is (0, 0), where the patch size will be infered from the grid shape. + + Note: the shape of the input image is infered based on the first image used. + """ + + def __init__( + self, + grid_size: Union[int, Tuple[int, int]] = (2, 2), + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + ): + # Grid size + if isinstance(grid_size, int): + self.grid_size = (grid_size, grid_size) + else: + self.grid_size = grid_size + # Patch size + self.patch_size = None + if isinstance(patch_size, int): + self.patch_size = (patch_size, patch_size) + else: + self.patch_size = patch_size + + def __call__(self, image: torch.Tensor) -> torch.Tensor: + if self.grid_size == (1, 1) and self.patch_size is None: + return torch.stack([image]) + patch_size, steps = self.get_params(image.shape[1:]) + patches = ( + image.unfold(1, patch_size[0], steps[0]) + .unfold(2, patch_size[1], steps[1]) + .flatten(1, 2) + .transpose(0, 1) + .contiguous() + ) + return patches + + def get_params(self, image_size): + if self.patch_size is None: + patch_size = tuple(image_size[i] // self.grid_size[i] for i in range(2)) + else: + patch_size = self.patch_size + + steps = tuple( + (image_size[i] - patch_size[i]) // (self.grid_size[i] - 1) if self.grid_size[i] > 1 else image_size[i] + for i in range(2) + ) + + return patch_size, steps diff --git a/monai/apps/pathology/transforms/spatial/dictionary.py b/monai/apps/pathology/transforms/spatial/dictionary.py new file mode 100644 index 0000000000..10b01a39de --- /dev/null +++ b/monai/apps/pathology/transforms/spatial/dictionary.py @@ -0,0 +1,56 @@ +# Copyright 2020 - 2021 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. + +from typing import Dict, Hashable, Mapping, Optional, Tuple, Union + +import torch + +from monai.config import KeysCollection +from monai.transforms.transform import MapTransform + +from .array import SplitOnGrid + +__all__ = ["SplitOnGridd", "SplitOnGridD", "SplitOnGridDict"] + + +class SplitOnGridd(MapTransform): + """ + Split the image into patches based on the provided grid shape. + This transform works only with torch.Tensor inputs. + + Args: + grid_shape: a tuple or an integer define the shape of the grid upon which to extract patches. + If it's an integer, the value will be repeated for each dimension. Default is 2x2 + patch_size: a tuple or an integer that defines the output patch sizes. + If it's an integer, the value will be repeated for each dimension. + The default is (0, 0), where the patch size will be infered from the grid shape. + + Note: the shape of the input image is infered based on the first image used. + """ + + def __init__( + self, + keys: KeysCollection, + grid_size: Union[int, Tuple[int, int]] = (2, 2), + patch_size: Optional[Union[int, Tuple[int, int]]] = None, + allow_missing_keys: bool = False, + ): + super().__init__(keys, allow_missing_keys) + self.splitter = SplitOnGrid(grid_size=grid_size, patch_size=patch_size) + + def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.splitter(d[key]) + return d + + +SplitOnGridDict = SplitOnGridD = SplitOnGridd diff --git a/tests/test_split_on_grid.py b/tests/test_split_on_grid.py new file mode 100644 index 0000000000..a187835e7b --- /dev/null +++ b/tests/test_split_on_grid.py @@ -0,0 +1,131 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.apps.pathology.transforms import SplitOnGrid + +A11 = torch.randn(3, 2, 2) +A12 = torch.randn(3, 2, 2) +A21 = torch.randn(3, 2, 2) +A22 = torch.randn(3, 2, 2) + +A1 = torch.cat([A11, A12], 2) +A2 = torch.cat([A21, A22], 2) +A = torch.cat([A1, A2], 1) + +TEST_CASE_0 = [ + {"grid_size": (2, 2)}, + A, + torch.stack([A11, A12, A21, A22]), +] + +TEST_CASE_1 = [ + {"grid_size": (2, 1)}, + A, + torch.stack([A1, A2]), +] + +TEST_CASE_2 = [ + {"grid_size": (1, 2)}, + A1, + torch.stack([A11, A12]), +] + +TEST_CASE_3 = [ + {"grid_size": (1, 2)}, + A2, + torch.stack([A21, A22]), +] + +TEST_CASE_4 = [ + {"grid_size": (1, 1), "patch_size": (2, 2)}, + A, + torch.stack([A11]), +] + +TEST_CASE_5 = [ + {"grid_size": 1, "patch_size": 4}, + A, + torch.stack([A]), +] + +TEST_CASE_6 = [ + {"grid_size": 2, "patch_size": 2}, + A, + torch.stack([A11, A12, A21, A22]), +] + +TEST_CASE_7 = [ + {"grid_size": 1}, + A, + torch.stack([A]), +] + +TEST_CASE_MC_0 = [ + {"grid_size": (2, 2)}, + [A, A], + [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])], +] + + +TEST_CASE_MC_1 = [ + {"grid_size": (2, 1)}, + [A] * 5, + [torch.stack([A1, A2])] * 5, +] + + +TEST_CASE_MC_2 = [ + {"grid_size": (1, 2)}, + [A1, A2], + [torch.stack([A11, A12]), torch.stack([A21, A22])], +] + + +class TestSplitOnGrid(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, + ] + ) + def test_split_pathce_single_call(self, input_parameters, img, expected): + splitter = SplitOnGrid(**input_parameters) + output = splitter(img) + np.testing.assert_equal(output.numpy(), expected.numpy()) + + @parameterized.expand( + [ + TEST_CASE_MC_0, + TEST_CASE_MC_1, + TEST_CASE_MC_2, + ] + ) + def test_split_pathce_multiple_call(self, input_parameters, img_list, expected_list): + splitter = SplitOnGrid(**input_parameters) + for img, expected in zip(img_list, expected_list): + output = splitter(img) + np.testing.assert_equal(output.numpy(), expected.numpy()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_split_on_grid_dict.py b/tests/test_split_on_grid_dict.py new file mode 100644 index 0000000000..96ec095423 --- /dev/null +++ b/tests/test_split_on_grid_dict.py @@ -0,0 +1,131 @@ +# Copyright 2020 - 2021 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 +import torch +from parameterized import parameterized + +from monai.apps.pathology.transforms import SplitOnGridDict + +A11 = torch.randn(3, 2, 2) +A12 = torch.randn(3, 2, 2) +A21 = torch.randn(3, 2, 2) +A22 = torch.randn(3, 2, 2) + +A1 = torch.cat([A11, A12], 2) +A2 = torch.cat([A21, A22], 2) +A = torch.cat([A1, A2], 1) + +TEST_CASE_0 = [ + {"keys": "image", "grid_size": (2, 2)}, + {"image": A}, + torch.stack([A11, A12, A21, A22]), +] + +TEST_CASE_1 = [ + {"keys": "image", "grid_size": (2, 1)}, + {"image": A}, + torch.stack([A1, A2]), +] + +TEST_CASE_2 = [ + {"keys": "image", "grid_size": (1, 2)}, + {"image": A1}, + torch.stack([A11, A12]), +] + +TEST_CASE_3 = [ + {"keys": "image", "grid_size": (1, 2)}, + {"image": A2}, + torch.stack([A21, A22]), +] + +TEST_CASE_4 = [ + {"keys": "image", "grid_size": (1, 1), "patch_size": (2, 2)}, + {"image": A}, + torch.stack([A11]), +] + +TEST_CASE_5 = [ + {"keys": "image", "grid_size": 1, "patch_size": 4}, + {"image": A}, + torch.stack([A]), +] + +TEST_CASE_6 = [ + {"keys": "image", "grid_size": 2, "patch_size": 2}, + {"image": A}, + torch.stack([A11, A12, A21, A22]), +] + +TEST_CASE_7 = [ + {"keys": "image", "grid_size": 1}, + {"image": A}, + torch.stack([A]), +] + +TEST_CASE_MC_0 = [ + {"keys": "image", "grid_size": (2, 2)}, + [{"image": A}, {"image": A}], + [torch.stack([A11, A12, A21, A22]), torch.stack([A11, A12, A21, A22])], +] + + +TEST_CASE_MC_1 = [ + {"keys": "image", "grid_size": (2, 1)}, + [{"image": A}] * 5, + [torch.stack([A1, A2])] * 5, +] + + +TEST_CASE_MC_2 = [ + {"keys": "image", "grid_size": (1, 2)}, + [{"image": A1}, {"image": A2}], + [torch.stack([A11, A12]), torch.stack([A21, A22])], +] + + +class TestSplitOnGridDict(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, + ] + ) + def test_split_pathce_single_call(self, input_parameters, img_dict, expected): + splitter = SplitOnGridDict(**input_parameters) + output = splitter(img_dict)[input_parameters["keys"]] + np.testing.assert_equal(output.numpy(), expected.numpy()) + + @parameterized.expand( + [ + TEST_CASE_MC_0, + TEST_CASE_MC_1, + TEST_CASE_MC_2, + ] + ) + def test_split_pathce_multiple_call(self, input_parameters, img_list, expected_list): + splitter = SplitOnGridDict(**input_parameters) + for img_dict, expected in zip(img_list, expected_list): + output = splitter(img_dict)[input_parameters["keys"]] + np.testing.assert_equal(output.numpy(), expected.numpy()) + + +if __name__ == "__main__": + unittest.main() From 9c7b71f5af0f22ba5d57ca371d770bd5c83d4aac Mon Sep 17 00:00:00 2001 From: Nic Ma Date: Mon, 6 Sep 2021 23:59:38 +0800 Subject: [PATCH 86/89] 2885 2899 Add support to support subclass of Tensor or numpy array (#2900) * [DLMED] fix type issue Signed-off-by: Nic Ma * [DLMED] fix test Signed-off-by: Nic Ma * [DLMED] simplify the change Signed-off-by: Nic Ma * [DLMED] fix flake8 Signed-off-by: Nic Ma --- monai/utils/type_conversion.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/monai/utils/type_conversion.py b/monai/utils/type_conversion.py index 14300eeca0..b0ce187e38 100644 --- a/monai/utils/type_conversion.py +++ b/monai/utils/type_conversion.py @@ -171,7 +171,14 @@ def convert_data_type( Returns: modified data, orig_type, orig_device """ - orig_type = type(data) + orig_type: Any + if isinstance(data, torch.Tensor): + orig_type = torch.Tensor + elif isinstance(data, np.ndarray): + orig_type = np.ndarray + else: + orig_type = type(data) + orig_device = data.device if isinstance(data, torch.Tensor) else None output_type = output_type or orig_type From 1ecf5b62be1a637364bf65e92469bd398d5ccc00 Mon Sep 17 00:00:00 2001 From: Jirka Borovec Date: Mon, 6 Sep 2021 20:20:02 +0200 Subject: [PATCH 87/89] rename `n_classes` to `num_classes` (#2842) * rename n_classes Signed-off-by: Jirka * back compatibility Signed-off-by: Jirka --- CHANGELOG.md | 2 ++ monai/losses/tversky.py | 2 +- monai/metrics/meandice.py | 2 +- monai/metrics/rocauc.py | 4 +-- monai/networks/nets/netadapter.py | 14 ++++++++--- monai/networks/nets/resnet.py | 17 +++++++++---- monai/networks/nets/torchvision_fc.py | 24 ++++++++++++------ monai/transforms/post/array.py | 28 ++++++++++++++------- monai/transforms/post/dictionary.py | 19 ++++++++------ monai/transforms/utility/array.py | 6 ++--- tests/test_as_discrete.py | 8 +++--- tests/test_as_discreted.py | 6 ++--- tests/test_compute_roc_auc.py | 4 +-- tests/test_handler_decollate_batch.py | 2 +- tests/test_handler_post_processing.py | 2 +- tests/test_handler_rocauc.py | 2 +- tests/test_handler_rocauc_dist.py | 2 +- tests/test_integration_classification_2d.py | 2 +- tests/test_net_adapter.py | 10 ++++---- tests/test_resnet.py | 6 ++--- tests/test_torchvision_fc_model.py | 26 +++++++++---------- tests/test_torchvision_fully_conv_model.py | 14 +++++------ 22 files changed, 121 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a0ca11e9..bdbd23e7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +* renamed model's `n_classes` to `num_classes` + ## [0.6.0] - 2021-07-08 ### Added * 10 new transforms, a masked loss wrapper, and a `NetAdapter` for transfer learning diff --git a/monai/losses/tversky.py b/monai/losses/tversky.py index 1d75b9e8cc..1cc0e1d8d7 100644 --- a/monai/losses/tversky.py +++ b/monai/losses/tversky.py @@ -155,7 +155,7 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: if self.reduction == LossReduction.SUM.value: return torch.sum(score) # sum over the batch and channel dims if self.reduction == LossReduction.NONE.value: - return score # returns [N, n_classes] losses + return score # returns [N, num_classes] losses if self.reduction == LossReduction.MEAN.value: return torch.mean(score) raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].') diff --git a/monai/metrics/meandice.py b/monai/metrics/meandice.py index 1bfd85a83e..226c106f7e 100644 --- a/monai/metrics/meandice.py +++ b/monai/metrics/meandice.py @@ -114,7 +114,7 @@ def compute_meandice( the predicted output. Defaults to True. Returns: - Dice scores per batch and per class, (shape [batch_size, n_classes]). + Dice scores per batch and per class, (shape [batch_size, num_classes]). Raises: ValueError: when `y_pred` and `y` have different shapes. diff --git a/monai/metrics/rocauc.py b/monai/metrics/rocauc.py index 3bd6c0d69c..c2679cc2ea 100644 --- a/monai/metrics/rocauc.py +++ b/monai/metrics/rocauc.py @@ -131,9 +131,9 @@ def compute_roc_auc( y_pred_ndim = y_pred.ndimension() y_ndim = y.ndimension() if y_pred_ndim not in (1, 2): - raise ValueError("Predictions should be of shape (batch_size, n_classes) or (batch_size, ).") + raise ValueError("Predictions should be of shape (batch_size, num_classes) or (batch_size, ).") if y_ndim not in (1, 2): - raise ValueError("Targets should be of shape (batch_size, n_classes) or (batch_size, ).") + raise ValueError("Targets should be of shape (batch_size, num_classes) or (batch_size, ).") if y_pred_ndim == 2 and y_pred.shape[1] == 1: y_pred = y_pred.squeeze(dim=-1) y_pred_ndim = 1 diff --git a/monai/networks/nets/netadapter.py b/monai/networks/nets/netadapter.py index bc88454f87..80288f7945 100644 --- a/monai/networks/nets/netadapter.py +++ b/monai/networks/nets/netadapter.py @@ -14,6 +14,7 @@ import torch from monai.networks.layers import Conv, get_pool_layer +from monai.utils import deprecated_arg class NetAdapter(torch.nn.Module): @@ -26,7 +27,7 @@ class NetAdapter(torch.nn.Module): model: a PyTorch model, support both 2D and 3D models. typically, it can be a pretrained model in Torchvision, like: ``resnet18``, ``resnet34m``, ``resnet50``, ``resnet101``, ``resnet152``, etc. more details: https://pytorch.org/vision/stable/models.html. - n_classes: number of classes for the last classification layer. Default to 1. + num_classes: number of classes for the last classification layer. Default to 1. dim: number of spatial dimensions, default to 2. in_channels: number of the input channels of last layer. if None, get it from `in_features` of last layer. use_conv: whether use convolutional layer to replace the last layer, default to False. @@ -38,17 +39,22 @@ class NetAdapter(torch.nn.Module): """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, model: torch.nn.Module, - n_classes: int = 1, + num_classes: int = 1, dim: int = 2, in_channels: Optional[int] = None, use_conv: bool = False, pool: Optional[Tuple[str, Dict[str, Any]]] = ("avg", {"kernel_size": 7, "stride": 1}), bias: bool = True, + n_classes: Optional[int] = None, ): super().__init__() + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes == 1: + num_classes = n_classes layers = list(model.children()) orig_fc = layers[-1] in_channels_: int @@ -74,7 +80,7 @@ def __init__( # add 1x1 conv (it behaves like a FC layer) self.fc = Conv[Conv.CONV, dim]( in_channels=in_channels_, - out_channels=n_classes, + out_channels=num_classes, kernel_size=1, bias=bias, ) @@ -84,7 +90,7 @@ def __init__( # replace the out_features of FC layer self.fc = torch.nn.Linear( in_features=in_channels_, - out_features=n_classes, + out_features=num_classes, bias=bias, ) self.use_conv = use_conv diff --git a/monai/networks/nets/resnet.py b/monai/networks/nets/resnet.py index f34de563ce..a5e6b7ab81 100644 --- a/monai/networks/nets/resnet.py +++ b/monai/networks/nets/resnet.py @@ -10,7 +10,7 @@ # limitations under the License. from functools import partial -from typing import Any, Callable, List, Type, Union +from typing import Any, Callable, List, Optional, Type, Union import torch import torch.nn as nn @@ -20,6 +20,8 @@ __all__ = ["ResNet", "resnet10", "resnet18", "resnet34", "resnet50", "resnet101", "resnet152", "resnet200"] +from monai.utils import deprecated_arg + def get_inplanes(): return [64, 128, 256, 512] @@ -162,9 +164,10 @@ class ResNet(nn.Module): no_max_pool: bool argument to determine if to use maxpool layer. shortcut_type: which downsample block to use. widen_factor: widen output for each layer. - n_classes: number of output (classifications) + num_classes: number of output (classifications) """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, block: Type[Union[ResNetBlock, ResNetBottleneck]], @@ -177,11 +180,15 @@ def __init__( no_max_pool: bool = False, shortcut_type: str = "B", widen_factor: float = 1.0, - n_classes: int = 400, + num_classes: int = 400, feed_forward: bool = True, + n_classes: Optional[int] = None, ) -> None: super(ResNet, self).__init__() + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes == 400: + num_classes = n_classes conv_type: Type[Union[nn.Conv1d, nn.Conv2d, nn.Conv3d]] = Conv[Conv.CONV, spatial_dims] norm_type: Type[Union[nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d]] = Norm[Norm.BATCH, spatial_dims] @@ -215,7 +222,7 @@ def __init__( self.avgpool = avgp_type(block_avgpool[spatial_dims]) if feed_forward: - self.fc = nn.Linear(block_inplanes[3] * block.expansion, n_classes) + self.fc = nn.Linear(block_inplanes[3] * block.expansion, num_classes) for m in self.modules(): if isinstance(m, conv_type): @@ -303,7 +310,7 @@ def _resnet( progress: bool, **kwargs: Any, ) -> ResNet: - model = ResNet(block, layers, block_inplanes, **kwargs) + model: ResNet = ResNet(block, layers, block_inplanes, **kwargs) if pretrained: # Author of paper zipped the state_dict on googledrive, # so would need to download, unzip and read (2.8gb file for a ~150mb state dict). diff --git a/monai/networks/nets/torchvision_fc.py b/monai/networks/nets/torchvision_fc.py index 2c4c7c8c32..1619f877e7 100644 --- a/monai/networks/nets/torchvision_fc.py +++ b/monai/networks/nets/torchvision_fc.py @@ -12,7 +12,7 @@ from typing import Any, Dict, Optional, Tuple, Union from monai.networks.nets import NetAdapter -from monai.utils import deprecated, optional_import +from monai.utils import deprecated, deprecated_arg, optional_import models, _ = optional_import("torchvision.models") @@ -29,7 +29,7 @@ class TorchVisionFCModel(NetAdapter): ``resnet18`` (default), ``resnet34m``, ``resnet50``, ``resnet101``, ``resnet152``, ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``. model details: https://pytorch.org/vision/stable/models.html. - n_classes: number of classes for the last classification layer. Default to 1. + num_classes: number of classes for the last classification layer. Default to 1. dim: number of spatial dimensions, default to 2. in_channels: number of the input channels of last layer. if None, get it from `in_features` of last layer. use_conv: whether use convolutional layer to replace the last layer, default to False. @@ -41,17 +41,22 @@ class TorchVisionFCModel(NetAdapter): pretrained: whether to use the imagenet pretrained weights. Default to False. """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, model_name: str = "resnet18", - n_classes: int = 1, + num_classes: int = 1, dim: int = 2, in_channels: Optional[int] = None, use_conv: bool = False, pool: Optional[Tuple[str, Dict[str, Any]]] = ("avg", {"kernel_size": 7, "stride": 1}), bias: bool = True, pretrained: bool = False, + n_classes: Optional[int] = None, ): + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes == 1: + num_classes = n_classes model = getattr(models, model_name)(pretrained=pretrained) # check if the model is compatible, should have a FC layer at the end if not str(list(model.children())[-1]).startswith("Linear"): @@ -59,7 +64,7 @@ def __init__( super().__init__( model=model, - n_classes=n_classes, + num_classes=num_classes, dim=dim, in_channels=in_channels, use_conv=use_conv, @@ -77,7 +82,7 @@ class TorchVisionFullyConvModel(TorchVisionFCModel): model_name: name of any torchvision with adaptive avg pooling and fully connected layer at the end. ``resnet18`` (default), ``resnet34m``, ``resnet50``, ``resnet101``, ``resnet152``, ``resnext50_32x4d``, ``resnext101_32x8d``, ``wide_resnet50_2``, ``wide_resnet101_2``. - n_classes: number of classes for the last classification layer. Default to 1. + num_classes: number of classes for the last classification layer. Default to 1. pool_size: the kernel size for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to (7, 7). pool_stride: the stride for `AvgPool2d` to replace `AdaptiveAvgPool2d`. Default to 1. pretrained: whether to use the imagenet pretrained weights. Default to False. @@ -87,17 +92,22 @@ class TorchVisionFullyConvModel(TorchVisionFCModel): """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, model_name: str = "resnet18", - n_classes: int = 1, + num_classes: int = 1, pool_size: Union[int, Tuple[int, int]] = (7, 7), pool_stride: Union[int, Tuple[int, int]] = 1, pretrained: bool = False, + n_classes: Optional[int] = None, ): + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes == 1: + num_classes = n_classes super().__init__( model_name=model_name, - n_classes=n_classes, + num_classes=num_classes, use_conv=True, pool=("avg", {"kernel_size": pool_size, "stride": pool_stride}), pretrained=pretrained, diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 7b3e7b4fd2..631947025c 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -25,7 +25,7 @@ from monai.networks.layers import GaussianFilter from monai.transforms.transform import Transform from monai.transforms.utils import fill_holes, get_largest_connected_component_mask -from monai.utils import ensure_tuple, look_up_option +from monai.utils import deprecated_arg, ensure_tuple, look_up_option __all__ = [ "Activations", @@ -120,7 +120,7 @@ class AsDiscrete(Transform): Defaults to ``False``. to_onehot: whether to convert input data into the one-hot format. Defaults to ``False``. - n_classes: the number of classes to convert to One-Hot format. + num_classes: the number of classes to convert to One-Hot format. Defaults to ``None``. threshold_values: whether threshold the float value to int number 0 or 1. Defaults to ``False``. @@ -131,31 +131,38 @@ class AsDiscrete(Transform): """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, argmax: bool = False, to_onehot: bool = False, - n_classes: Optional[int] = None, + num_classes: Optional[int] = None, threshold_values: bool = False, logit_thresh: float = 0.5, rounding: Optional[str] = None, + n_classes: Optional[int] = None, ) -> None: + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes is None: + num_classes = n_classes self.argmax = argmax self.to_onehot = to_onehot - self.n_classes = n_classes + self.num_classes = num_classes self.threshold_values = threshold_values self.logit_thresh = logit_thresh self.rounding = rounding + @deprecated_arg("n_classes", since="0.6") def __call__( self, img: torch.Tensor, argmax: Optional[bool] = None, to_onehot: Optional[bool] = None, - n_classes: Optional[int] = None, + num_classes: Optional[int] = None, threshold_values: Optional[bool] = None, logit_thresh: Optional[float] = None, rounding: Optional[str] = None, + n_classes: Optional[int] = None, ) -> torch.Tensor: """ Args: @@ -165,8 +172,8 @@ def __call__( Defaults to ``self.argmax``. to_onehot: whether to convert input data into the one-hot format. Defaults to ``self.to_onehot``. - n_classes: the number of classes to convert to One-Hot format. - Defaults to ``self.n_classes``. + num_classes: the number of classes to convert to One-Hot format. + Defaults to ``self.num_classes``. threshold_values: whether threshold the float value to int number 0 or 1. Defaults to ``self.threshold_values``. logit_thresh: the threshold value for thresholding operation.. @@ -175,13 +182,16 @@ def __call__( available options: ["torchrounding"]. """ + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes is None: + num_classes = n_classes if argmax or self.argmax: img = torch.argmax(img, dim=0, keepdim=True) if to_onehot or self.to_onehot: - _nclasses = self.n_classes if n_classes is None else n_classes + _nclasses = self.num_classes if num_classes is None else num_classes if not isinstance(_nclasses, int): - raise AssertionError("One of self.n_classes or n_classes must be an integer") + raise AssertionError("One of self.num_classes or num_classes must be an integer") img = one_hot(img, num_classes=_nclasses, dim=0) if threshold_values or self.threshold_values: diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index d4e039339b..2fc3993e3e 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -39,7 +39,7 @@ from monai.transforms.transform import MapTransform from monai.transforms.utility.array import ToTensor from monai.transforms.utils import allow_missing_keys_mode, convert_inverse_interp_mode -from monai.utils import ensure_tuple, ensure_tuple_rep +from monai.utils import deprecated_arg, ensure_tuple, ensure_tuple_rep from monai.utils.enums import InverseKeys __all__ = [ @@ -126,16 +126,18 @@ class AsDiscreted(MapTransform): Dictionary-based wrapper of :py:class:`monai.transforms.AsDiscrete`. """ + @deprecated_arg("n_classes", since="0.6") def __init__( self, keys: KeysCollection, argmax: Union[Sequence[bool], bool] = False, to_onehot: Union[Sequence[bool], bool] = False, - n_classes: Optional[Union[Sequence[int], int]] = None, + num_classes: Optional[Union[Sequence[int], int]] = None, threshold_values: Union[Sequence[bool], bool] = False, logit_thresh: Union[Sequence[float], float] = 0.5, rounding: Union[Sequence[Optional[str]], Optional[str]] = None, allow_missing_keys: bool = False, + n_classes: Optional[int] = None, ) -> None: """ Args: @@ -145,7 +147,7 @@ def __init__( it also can be a sequence of bool, each element corresponds to a key in ``keys``. to_onehot: whether to convert input data into the one-hot format. Defaults to False. it also can be a sequence of bool, each element corresponds to a key in ``keys``. - n_classes: the number of classes to convert to One-Hot format. it also can be a + num_classes: the number of classes to convert to One-Hot format. it also can be a sequence of int, each element corresponds to a key in ``keys``. threshold_values: whether threshold the float value to int number 0 or 1, default is False. it also can be a sequence of bool, each element corresponds to a key in ``keys``. @@ -157,10 +159,13 @@ def __init__( allow_missing_keys: don't raise exception if key is missing. """ + # in case the new num_classes is default but you still call deprecated n_classes + if n_classes is not None and num_classes is None: + num_classes = n_classes super().__init__(keys, allow_missing_keys) self.argmax = ensure_tuple_rep(argmax, len(self.keys)) self.to_onehot = ensure_tuple_rep(to_onehot, len(self.keys)) - self.n_classes = ensure_tuple_rep(n_classes, len(self.keys)) + self.num_classes = ensure_tuple_rep(num_classes, len(self.keys)) self.threshold_values = ensure_tuple_rep(threshold_values, len(self.keys)) self.logit_thresh = ensure_tuple_rep(logit_thresh, len(self.keys)) self.rounding = ensure_tuple_rep(rounding, len(self.keys)) @@ -168,14 +173,14 @@ def __init__( def __call__(self, data: Mapping[Hashable, torch.Tensor]) -> Dict[Hashable, torch.Tensor]: d = dict(data) - for key, argmax, to_onehot, n_classes, threshold_values, logit_thresh, rounding in self.key_iterator( - d, self.argmax, self.to_onehot, self.n_classes, self.threshold_values, self.logit_thresh, self.rounding + for key, argmax, to_onehot, num_classes, threshold_values, logit_thresh, rounding in self.key_iterator( + d, self.argmax, self.to_onehot, self.num_classes, self.threshold_values, self.logit_thresh, self.rounding ): d[key] = self.converter( d[key], argmax, to_onehot, - n_classes, + num_classes, threshold_values, logit_thresh, rounding, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index dd045817fb..2eb6c447c6 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -282,13 +282,13 @@ def __init__(self, channel_dim: int = 0) -> None: self.channel_dim = channel_dim def __call__(self, img: NdarrayOrTensor) -> List[NdarrayOrTensor]: - n_classes = img.shape[self.channel_dim] - if n_classes <= 1: + num_classes = img.shape[self.channel_dim] + if num_classes <= 1: raise RuntimeError("input image does not contain multiple channels.") outputs = [] slices = [slice(None)] * len(img.shape) - for i in range(n_classes): + for i in range(num_classes): slices[self.channel_dim] = slice(i, i + 1) outputs.append(img[tuple(slices)]) diff --git a/tests/test_as_discrete.py b/tests/test_as_discrete.py index b87fafd8f3..bb9457a357 100644 --- a/tests/test_as_discrete.py +++ b/tests/test_as_discrete.py @@ -17,28 +17,28 @@ from monai.transforms import AsDiscrete TEST_CASE_1 = [ - {"argmax": True, "to_onehot": False, "n_classes": None, "threshold_values": False, "logit_thresh": 0.5}, + {"argmax": True, "to_onehot": False, "num_classes": None, "threshold_values": False, "logit_thresh": 0.5}, torch.tensor([[[0.0, 1.0]], [[2.0, 3.0]]]), torch.tensor([[[1.0, 1.0]]]), (1, 1, 2), ] TEST_CASE_2 = [ - {"argmax": True, "to_onehot": True, "n_classes": 2, "threshold_values": False, "logit_thresh": 0.5}, + {"argmax": True, "to_onehot": True, "num_classes": 2, "threshold_values": False, "logit_thresh": 0.5}, torch.tensor([[[0.0, 1.0]], [[2.0, 3.0]]]), torch.tensor([[[0.0, 0.0]], [[1.0, 1.0]]]), (2, 1, 2), ] TEST_CASE_3 = [ - {"argmax": False, "to_onehot": False, "n_classes": None, "threshold_values": True, "logit_thresh": 0.6}, + {"argmax": False, "to_onehot": False, "num_classes": None, "threshold_values": True, "logit_thresh": 0.6}, torch.tensor([[[0.0, 1.0], [2.0, 3.0]]]), torch.tensor([[[0.0, 1.0], [1.0, 1.0]]]), (1, 2, 2), ] TEST_CASE_4 = [ - {"argmax": False, "to_onehot": True, "n_classes": 3}, + {"argmax": False, "to_onehot": True, "num_classes": 3}, torch.tensor(1), torch.tensor([0.0, 1.0, 0.0]), (3,), diff --git a/tests/test_as_discreted.py b/tests/test_as_discreted.py index ac594f0daa..90e98b297b 100644 --- a/tests/test_as_discreted.py +++ b/tests/test_as_discreted.py @@ -21,7 +21,7 @@ "keys": ["pred", "label"], "argmax": [True, False], "to_onehot": True, - "n_classes": 2, + "num_classes": 2, "threshold_values": False, "logit_thresh": 0.5, }, @@ -35,7 +35,7 @@ "keys": ["pred", "label"], "argmax": False, "to_onehot": False, - "n_classes": None, + "num_classes": None, "threshold_values": [True, False], "logit_thresh": 0.6, }, @@ -49,7 +49,7 @@ "keys": ["pred"], "argmax": True, "to_onehot": True, - "n_classes": 2, + "num_classes": 2, "threshold_values": False, "logit_thresh": 0.5, }, diff --git a/tests/test_compute_roc_auc.py b/tests/test_compute_roc_auc.py index 79d62b6436..1cec357b93 100644 --- a/tests/test_compute_roc_auc.py +++ b/tests/test_compute_roc_auc.py @@ -87,7 +87,7 @@ class TestComputeROCAUC(unittest.TestCase): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7]) def test_value(self, y_pred, y, softmax, to_onehot, average, expected_value): y_pred_trans = Compose([ToTensor(), Activations(softmax=softmax)]) - y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=to_onehot, n_classes=2)]) + y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=to_onehot, num_classes=2)]) y_pred = torch.stack([y_pred_trans(i) for i in decollate_batch(y_pred)], dim=0) y = torch.stack([y_trans(i) for i in decollate_batch(y)], dim=0) result = compute_roc_auc(y_pred=y_pred, y=y, average=average) @@ -96,7 +96,7 @@ def test_value(self, y_pred, y, softmax, to_onehot, average, expected_value): @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7]) def test_class_value(self, y_pred, y, softmax, to_onehot, average, expected_value): y_pred_trans = Compose([ToTensor(), Activations(softmax=softmax)]) - y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=to_onehot, n_classes=2)]) + y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=to_onehot, num_classes=2)]) y_pred = [y_pred_trans(i) for i in decollate_batch(y_pred)] y = [y_trans(i) for i in decollate_batch(y)] metric = ROCAUCMetric(average=average) diff --git a/tests/test_handler_decollate_batch.py b/tests/test_handler_decollate_batch.py index bc74cf5328..8f0ffb2b5c 100644 --- a/tests/test_handler_decollate_batch.py +++ b/tests/test_handler_decollate_batch.py @@ -32,7 +32,7 @@ def test_compute(self): [ Activationsd(keys="pred", sigmoid=True), CopyItemsd(keys="filename", times=1, names="filename_bak"), - AsDiscreted(keys="pred", threshold_values=True, to_onehot=True, n_classes=2), + AsDiscreted(keys="pred", threshold_values=True, to_onehot=True, num_classes=2), ] ) ), diff --git a/tests/test_handler_post_processing.py b/tests/test_handler_post_processing.py index 552cde9eb1..e9d57128cb 100644 --- a/tests/test_handler_post_processing.py +++ b/tests/test_handler_post_processing.py @@ -26,7 +26,7 @@ "transform": Compose( [ CopyItemsd(keys="filename", times=1, names="filename_bak"), - AsDiscreted(keys="pred", threshold_values=True, to_onehot=True, n_classes=2), + AsDiscreted(keys="pred", threshold_values=True, to_onehot=True, num_classes=2), ] ), "event": "iteration_completed", diff --git a/tests/test_handler_rocauc.py b/tests/test_handler_rocauc.py index 46594eb629..5b80bc43eb 100644 --- a/tests/test_handler_rocauc.py +++ b/tests/test_handler_rocauc.py @@ -22,7 +22,7 @@ class TestHandlerROCAUC(unittest.TestCase): def test_compute(self): auc_metric = ROCAUC() act = Activations(softmax=True) - to_onehot = AsDiscrete(to_onehot=True, n_classes=2) + to_onehot = AsDiscrete(to_onehot=True, num_classes=2) y_pred = [torch.Tensor([0.1, 0.9]), torch.Tensor([0.3, 1.4])] y = [torch.Tensor([0]), torch.Tensor([1])] diff --git a/tests/test_handler_rocauc_dist.py b/tests/test_handler_rocauc_dist.py index e728c80be6..8316d4c4b6 100644 --- a/tests/test_handler_rocauc_dist.py +++ b/tests/test_handler_rocauc_dist.py @@ -26,7 +26,7 @@ class DistributedROCAUC(DistTestCase): def test_compute(self): auc_metric = ROCAUC() act = Activations(softmax=True) - to_onehot = AsDiscrete(to_onehot=True, n_classes=2) + to_onehot = AsDiscrete(to_onehot=True, num_classes=2) device = f"cuda:{dist.get_rank()}" if torch.cuda.is_available() else "cpu" if dist.get_rank() == 0: diff --git a/tests/test_integration_classification_2d.py b/tests/test_integration_classification_2d.py index db435ee4e4..03b5571973 100644 --- a/tests/test_integration_classification_2d.py +++ b/tests/test_integration_classification_2d.py @@ -80,7 +80,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0", [LoadImage(image_only=True), AddChannel(), Transpose(indices=[0, 2, 1]), ScaleIntensity(), ToTensor()] ) y_pred_trans = Compose([ToTensor(), Activations(softmax=True)]) - y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=True, n_classes=len(np.unique(train_y)))]) + y_trans = Compose([ToTensor(), AsDiscrete(to_onehot=True, num_classes=len(np.unique(train_y)))]) auc_metric = ROCAUCMetric() # create train, val data loaders diff --git a/tests/test_net_adapter.py b/tests/test_net_adapter.py index 1ec3e26203..b2d55129a7 100644 --- a/tests/test_net_adapter.py +++ b/tests/test_net_adapter.py @@ -20,31 +20,31 @@ device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_0 = [ - {"n_classes": 1, "use_conv": True, "dim": 2}, + {"num_classes": 1, "use_conv": True, "dim": 2}, (2, 3, 224, 224), (2, 1, 8, 1), ] TEST_CASE_1 = [ - {"n_classes": 1, "use_conv": True, "dim": 3, "pool": None}, + {"num_classes": 1, "use_conv": True, "dim": 3, "pool": None}, (2, 3, 32, 32, 32), (2, 1, 1, 1, 1), ] TEST_CASE_2 = [ - {"n_classes": 5, "use_conv": True, "dim": 3, "pool": None}, + {"num_classes": 5, "use_conv": True, "dim": 3, "pool": None}, (2, 3, 32, 32, 32), (2, 5, 1, 1, 1), ] TEST_CASE_3 = [ - {"n_classes": 5, "use_conv": True, "pool": ("avg", {"kernel_size": 4, "stride": 1}), "dim": 3}, + {"num_classes": 5, "use_conv": True, "pool": ("avg", {"kernel_size": 4, "stride": 1}), "dim": 3}, (2, 3, 128, 128, 128), (2, 5, 5, 1, 1), ] TEST_CASE_4 = [ - {"n_classes": 5, "use_conv": False, "pool": ("adaptiveavg", {"output_size": (1, 1, 1)}), "dim": 3}, + {"num_classes": 5, "use_conv": False, "pool": ("adaptiveavg", {"output_size": (1, 1, 1)}), "dim": 3}, (2, 3, 32, 32, 32), (2, 5), ] diff --git a/tests/test_resnet.py b/tests/test_resnet.py index a20be298b9..c4ba5c2e16 100644 --- a/tests/test_resnet.py +++ b/tests/test_resnet.py @@ -31,19 +31,19 @@ device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_1 = [ # 3D, batch 3, 2 input channel - {"pretrained": False, "spatial_dims": 3, "n_input_channels": 2, "n_classes": 3}, + {"pretrained": False, "spatial_dims": 3, "n_input_channels": 2, "num_classes": 3}, (3, 2, 32, 64, 48), (3, 3), ] TEST_CASE_2 = [ # 2D, batch 2, 1 input channel - {"pretrained": False, "spatial_dims": 2, "n_input_channels": 1, "n_classes": 3}, + {"pretrained": False, "spatial_dims": 2, "n_input_channels": 1, "num_classes": 3}, (2, 1, 32, 64), (2, 3), ] TEST_CASE_3 = [ # 1D, batch 1, 2 input channels - {"pretrained": False, "spatial_dims": 1, "n_input_channels": 2, "n_classes": 3}, + {"pretrained": False, "spatial_dims": 1, "n_input_channels": 2, "num_classes": 3}, (1, 2, 32), (1, 3), ] diff --git a/tests/test_torchvision_fc_model.py b/tests/test_torchvision_fc_model.py index ae39968266..d6d3ea69c9 100644 --- a/tests/test_torchvision_fc_model.py +++ b/tests/test_torchvision_fc_model.py @@ -24,19 +24,19 @@ device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_0 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": True, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": True, "pretrained": False}, (2, 3, 224, 224), (2, 1, 1, 1), ] TEST_CASE_1 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": True, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": True, "pretrained": False}, (2, 3, 256, 256), (2, 1, 2, 2), ] TEST_CASE_2 = [ - {"model_name": "resnet101", "n_classes": 5, "use_conv": True, "pretrained": False}, + {"model_name": "resnet101", "num_classes": 5, "use_conv": True, "pretrained": False}, (2, 3, 256, 256), (2, 5, 2, 2), ] @@ -44,7 +44,7 @@ TEST_CASE_3 = [ { "model_name": "resnet101", - "n_classes": 5, + "num_classes": 5, "use_conv": True, "pool": ("avg", {"kernel_size": 6, "stride": 1}), "pretrained": False, @@ -54,60 +54,60 @@ ] TEST_CASE_4 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": False, "pool": None, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": False, "pool": None, "pretrained": False}, (2, 3, 224, 224), (2, 1), ] TEST_CASE_5 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": False, "pool": None, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": False, "pool": None, "pretrained": False}, (2, 3, 256, 256), (2, 1), ] TEST_CASE_6 = [ - {"model_name": "resnet101", "n_classes": 5, "use_conv": False, "pool": None, "pretrained": False}, + {"model_name": "resnet101", "num_classes": 5, "use_conv": False, "pool": None, "pretrained": False}, (2, 3, 256, 256), (2, 5), ] TEST_CASE_PRETRAINED_0 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": True, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": True, "pretrained": True}, (2, 3, 224, 224), (2, 1, 1, 1), -0.010419349186122417, ] TEST_CASE_PRETRAINED_1 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": True, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": True, "pretrained": True}, (2, 3, 256, 256), (2, 1, 2, 2), -0.010419349186122417, ] TEST_CASE_PRETRAINED_2 = [ - {"model_name": "resnet18", "n_classes": 5, "use_conv": True, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 5, "use_conv": True, "pretrained": True}, (2, 3, 256, 256), (2, 5, 2, 2), -0.010419349186122417, ] TEST_CASE_PRETRAINED_3 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": False, "pool": None, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": False, "pool": None, "pretrained": True}, (2, 3, 224, 224), (2, 1), -0.010419349186122417, ] TEST_CASE_PRETRAINED_4 = [ - {"model_name": "resnet18", "n_classes": 1, "use_conv": False, "pool": None, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "use_conv": False, "pool": None, "pretrained": True}, (2, 3, 256, 256), (2, 1), -0.010419349186122417, ] TEST_CASE_PRETRAINED_5 = [ - {"model_name": "resnet18", "n_classes": 5, "use_conv": False, "pool": None, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 5, "use_conv": False, "pool": None, "pretrained": True}, (2, 3, 256, 256), (2, 5), -0.010419349186122417, diff --git a/tests/test_torchvision_fully_conv_model.py b/tests/test_torchvision_fully_conv_model.py index 2c65f0d32c..af2c1458d3 100644 --- a/tests/test_torchvision_fully_conv_model.py +++ b/tests/test_torchvision_fully_conv_model.py @@ -24,45 +24,45 @@ device = "cuda" if torch.cuda.is_available() else "cpu" TEST_CASE_0 = [ - {"model_name": "resnet18", "n_classes": 1, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "pretrained": False}, (2, 3, 224, 224), (2, 1, 1, 1), ] TEST_CASE_1 = [ - {"model_name": "resnet18", "n_classes": 1, "pretrained": False}, + {"model_name": "resnet18", "num_classes": 1, "pretrained": False}, (2, 3, 256, 256), (2, 1, 2, 2), ] TEST_CASE_2 = [ - {"model_name": "resnet101", "n_classes": 5, "pretrained": False}, + {"model_name": "resnet101", "num_classes": 5, "pretrained": False}, (2, 3, 256, 256), (2, 5, 2, 2), ] TEST_CASE_3 = [ - {"model_name": "resnet101", "n_classes": 5, "pool_size": 6, "pretrained": False}, + {"model_name": "resnet101", "num_classes": 5, "pool_size": 6, "pretrained": False}, (2, 3, 224, 224), (2, 5, 2, 2), ] TEST_CASE_PRETRAINED_0 = [ - {"model_name": "resnet18", "n_classes": 1, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "pretrained": True}, (2, 3, 224, 224), (2, 1, 1, 1), -0.010419349186122417, ] TEST_CASE_PRETRAINED_1 = [ - {"model_name": "resnet18", "n_classes": 1, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 1, "pretrained": True}, (2, 3, 256, 256), (2, 1, 2, 2), -0.010419349186122417, ] TEST_CASE_PRETRAINED_2 = [ - {"model_name": "resnet18", "n_classes": 5, "pretrained": True}, + {"model_name": "resnet18", "num_classes": 5, "pretrained": True}, (2, 3, 256, 256), (2, 5, 2, 2), -0.010419349186122417, From b7707934d0268bb20b8c3365a95a24bb4b70f4a9 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Mon, 6 Sep 2021 18:52:09 -0400 Subject: [PATCH 88/89] Update NVTX Range (#2890) Signed-off-by: Behrooz <3968947+drbeh@users.noreply.github.com> * Update special method replacement --- monai/utils/nvtx.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/monai/utils/nvtx.py b/monai/utils/nvtx.py index 2dfbd03529..1980ceef71 100644 --- a/monai/utils/nvtx.py +++ b/monai/utils/nvtx.py @@ -92,7 +92,7 @@ def _decorate_method(self, obj, method, append_method_name): name = self.name # Get the class for special functions - if method.startswith("_"): + if method.startswith("__"): owner = type(obj) else: owner = obj @@ -109,7 +109,16 @@ def range_wrapper(*args, **kwargs): return output # Replace the method with the wrapped version - setattr(owner, method, range_wrapper) + if method.startswith("__"): + # If it is a special method, it requires special attention + class NVTXRangeDecoratedClass(owner): + ... + + setattr(NVTXRangeDecoratedClass, method, range_wrapper) + obj.__class__ = NVTXRangeDecoratedClass + + else: + setattr(owner, method, range_wrapper) def _get_method(self, obj: Any) -> tuple: if isinstance(obj, Module): From e24f1b1fdc85882859ba770d363003de6c895392 Mon Sep 17 00:00:00 2001 From: Behrooz <3968947+drbeh@users.noreply.github.com> Date: Tue, 7 Sep 2021 21:43:12 -0400 Subject: [PATCH 89/89] List Comprehension for ITC (#2906) --- monai/apps/pathology/utils.py | 5 +---- monai/handlers/garbage_collector.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/monai/apps/pathology/utils.py b/monai/apps/pathology/utils.py index 0d1f530bff..54d49f5717 100644 --- a/monai/apps/pathology/utils.py +++ b/monai/apps/pathology/utils.py @@ -51,10 +51,7 @@ def compute_isolated_tumor_cells(tumor_mask: np.ndarray, threshold: float) -> Li """ max_label = np.amax(tumor_mask) properties = measure.regionprops(tumor_mask, coordinates="rc") - itc_list = [] - for i in range(max_label): # type: ignore - if properties[i].major_axis_length < threshold: - itc_list.append(i + 1) + itc_list = [i + 1 for i in range(max_label) if properties[i].major_axis_length < threshold] return itc_list diff --git a/monai/handlers/garbage_collector.py b/monai/handlers/garbage_collector.py index ca630be6c1..fffca2a740 100644 --- a/monai/handlers/garbage_collector.py +++ b/monai/handlers/garbage_collector.py @@ -69,7 +69,7 @@ def __call__(self, engine: Engine) -> None: """ # get count before garbage collection pre_count = gc.get_count() - # fits call to garbage collector + # first call to garbage collector gc.collect() # second call to garbage collector unreachable = gc.collect()