diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 340c5eb8fa..6eb19eb4ad 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -37,6 +37,7 @@ LMDBDataset, NPZDictItemDataset, PersistentDataset, + PersistentStagedDataset, SmartCacheDataset, ZipDataset, ) @@ -100,6 +101,7 @@ pad_list_data_collate, partition_dataset, partition_dataset_classes, + pickle_hash_transform_names, pickle_hashing, rectify_header_sform_qform, remove_extra_metadata, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index a20511267b..d7ed994918 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -35,7 +35,7 @@ from torch.utils.data import Subset from monai.data.meta_tensor import MetaTensor -from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hashing +from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hash_transform_names, pickle_hashing from monai.transforms import ( Compose, Randomizable, @@ -419,6 +419,191 @@ def _transform(self, index: int): return self._post_transform(pre_random_item) +class PersistentStagedDataset(PersistentDataset): + """ + PersistentStagedDataset works like PersistentDataset, but additionally allows to cache the data after a first + sequence of non-random transforms and in a second step continue with a second sequence of non-random transforms. + This is useful if parameters of the second sequence of non-random transforms depend on the whole dataset after + the first transform sequence has been applied. + """ + def __init__( + self, + new_transform: Sequence[Callable] | Callable, + old_transform: Sequence[Callable] | Callable, + data: Sequence, + cache_dir: Path | str | None, + hash_func: Callable[..., bytes] = pickle_hashing, + pickle_module: str = "pickle", + pickle_protocol: int = DEFAULT_PROTOCOL, + hash_transform: Callable[..., bytes] | None = pickle_hash_transform_names, + reset_ops_id: bool = True, + ) -> None: + """ + Args: + data: input data file paths to load and transform to generate dataset for model. + `PersistentStagedDataset` expects input data to be a list of serializable + and hashes them as cache keys using `hash_func`. + new_transform: if `old_transform` is not used, PersistentStagedDataset will behave like PersistentDataset + where the role of `transform` is replaced by `new_transform`. If `old_transform` is used, + `PersistentStagedDataset` will first check in the `cache_dir` if the data transformed by `old_transform` + is available. If it is availble, it will load the cached data and apply the `new_transform`, otherwise + it will first apply the `old_transform` and then the `new_transform`. + old_transform: sequence of transforms expected to have been applied and the resulting data to have + been cached in `cache_dir`. + cache_dir: If specified, this is the location for persistent storage + of pre-computed transformed data tensors. The cache_dir is computed once, and + persists on disk until explicitly removed. Different runs, programs, experiments + may share a common cache dir provided that the transforms pre-processing is consistent. + If `cache_dir` doesn't exist, will automatically create it. + If `cache_dir` is `None`, there is effectively no caching. + hash_func: a callable to compute hash from data items to be cached. + defaults to `monai.data.utils.pickle_hashing`. + pickle_module: string representing the module used for pickling metadata and objects, + default to `"pickle"`. due to the pickle limitation in multi-processing of Dataloader, + we can't use `pickle` as arg directly, so here we use a string name instead. + if want to use other pickle module at runtime, just register like: + >>> from monai.data import utils + >>> utils.SUPPORTED_PICKLE_MOD["test"] = other_pickle + this arg is used by `torch.save`, for more details, please check: + https://pytorch.org/docs/stable/generated/torch.save.html#torch.save, + and ``monai.data.utils.SUPPORTED_PICKLE_MOD``. + pickle_protocol: can be specified to override the default protocol, default to `2`. + this arg is used by `torch.save`, for more details, please check: + https://pytorch.org/docs/stable/generated/torch.save.html#torch.save. + hash_transform: a callable to compute hash from the transform information when caching. + This may reduce errors due to transforms changing during experiments. Default to + `monai.data.utils.pickle_hash_transform_names`. + reset_ops_id: whether to set `TraceKeys.ID` to ``Tracekys.NONE``, defaults to ``True``. + When this is enabled, the traced transform instance IDs will be removed from the cached MetaTensors. + This is useful for skipping the transform instance checks when inverting applied operations + using the cached content and with re-created transform instances. + + """ + self.old_transform = old_transform + if not self.old_transform: + # if no old_transform is passed, create a normal PersistentDataset based on the new_transform + super().__init__( + data, new_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, reset_ops_id + ) + + else: + # if an old_transform is passed, create a normal PersistentDataset based on the old_transform + super().__init__( + data, old_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, reset_ops_id + ) + self.new_transform = new_transform + if not isinstance(self.new_transform, Compose): + self.new_transform = Compose(new_transform) + self.new_transform_hash = "" + + if hash_transform: + self.set_combined_transform_hash(hash_transform) + + def set_combined_transform_hash(self, hash_xform_func: Callable[..., bytes]): + """Create a hash from the composition of old and new transforms. Hashable transforms + are deterministic transforms that inherit from `Transform`. We stop + at the first non-deterministic transform, or first that does not + inherit from MONAI's `Transform` class.""" + hashable_transforms = [] + for _tr in self.transform.flatten().transforms + self.new_transform.flatten().transforms: + if isinstance(_tr, RandomizableTrait) or not isinstance(_tr, Transform): + break + hashable_transforms.append(_tr) + # Try to hash. Fall back to a hash of their names + try: + transform_hash = hash_xform_func(hashable_transforms) + except TypeError as te: + if "is not JSON serializable" not in str(te): + raise te + names = "".join(tr.__class__.__name__ for tr in hashable_transforms) + transform_hash = hash_xform_func(names) + self.new_transform_hash = transform_hash.decode("utf-8") + + def _update_cache(self, orig_item_transformed, item_transformed): + """Combine data hash with new transform hash, then load the transformed cached data, or if not found: + transform with new transform, then save under the new hash.""" + if self.cache_dir is not None: + new_data_item_md5 = self.hash_func(orig_item_transformed).decode("utf-8") + new_data_item_md5 += self.new_transform_hash + new_hashfile = self.cache_dir / f"{new_data_item_md5}.pt" + + if new_hashfile is not None and new_hashfile.is_file(): # cache hit + try: + return torch.load(new_hashfile) + except PermissionError as e: + if sys.platform != "win32": + raise e + + # transform with new transform + _item_transformed = self._pre_transform(item_transformed) + if new_hashfile is None: + return _item_transformed + try: + # NOTE: Writing to a temporary directory and then using a nearly atomic rename operation + # to make the cache more robust to manual killing of parent process + # which may leave partially written cache files in an incomplete state + with tempfile.TemporaryDirectory() as tmpdirname: + temp_hash_file = Path(tmpdirname) / new_hashfile.name + torch.save( + obj=_item_transformed, + f=temp_hash_file, + pickle_module=look_up_option(self.pickle_module, SUPPORTED_PICKLE_MOD), + pickle_protocol=self.pickle_protocol, + ) + if temp_hash_file.is_file() and not new_hashfile.is_file(): + # On Unix, if target exists and is a file, it will be replaced silently if the user has permission. + # for more details: https://docs.python.org/3/library/shutil.html#shutil.move. + try: + shutil.move(str(temp_hash_file), new_hashfile) + except FileExistsError: + pass + except PermissionError: # project-monai/monai issue #3613 + pass + return _item_transformed + + def _hash_exists(self, data): + hashfile = None + if self.cache_dir is not None: + new_data_item_md5 = self.hash_func(data).decode("utf-8") + new_data_item_md5 += self.new_transform_hash + hashfile = self.cache_dir / f"{new_data_item_md5}.pt" + + if hashfile is not None and hashfile.is_file(): # cache hit + return True + else: + return False + + def _transform(self, index: int): + # check if new hashfile exists + if self.old_transform: + found_new_transform_hashfile = self._hash_exists(self.data[index]) + else: + found_new_transform_hashfile = False + + if found_new_transform_hashfile: # if the new hashfile was found, skip the old transforms completely + # the following line makes sure that the subsequent transforms are based on new_transform rather than old_transform + self.transform = self.new_transform + pre_random_item = self._update_cache(self.data[index], item_transformed=None) + else: # if the new hashfile was not found, the first transform has to be applied, or the corresponding hashfile loaded + # default behaviour of PersistentDataset (create and/or load first transform hashfile) + pre_random_item = self._cachecheck(self.data[index]) + if ( + self.old_transform + ): # if an old_transform was provided, the new_transform has to update the pre_random_item (and save it in the cache) + # the following line makes sure that the subsequent transforms are based on new_transform rather than old_transform + self.transform = self.new_transform + # load the cached files and apply the new transform, then save the results with the new hash + pre_random_item = self._update_cache(self.data[index], pre_random_item) + + out = self._post_transform(pre_random_item) + + # make sure the transform is reset to the old_transform for the next iteration + if self.old_transform: + self.transform = self.old_transform + + return out + + class CacheNTransDataset(PersistentDataset): """ Extension of `PersistentDataset`, tt can also cache the result of first N transforms, no matter it's random or not. diff --git a/monai/data/utils.py b/monai/data/utils.py index 0880d44b64..80d066fc4a 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -81,6 +81,7 @@ "partition_dataset", "partition_dataset_classes", "pickle_hashing", + "pickle_hash_transform_names", "rectify_header_sform_qform", "reorient_spatial_axes", "resample_datalist", @@ -1402,6 +1403,28 @@ def pickle_hashing(item, protocol=pickle.HIGHEST_PROTOCOL) -> bytes: return f"{cache_key}".encode() +def pickle_hash_transform_names(hashable_transforms): + """ + hash function for transforms based only on the function names. + This will produce the same hash even if the transform code changes between runs as long as the transform names + stay the same therefore, it is required to delete the cache_dir between runs, if the transforms change. + The original pickle_hashing function is not suitable since some transform's (e.g. AffineD) instances change + their hash after it is applied once (but it is required that the hash remains unchanged) + + Args: + hashable_transforms: list of transforms whose names go into the pickle_hashing function + + Returns: the corresponding hash key + """ + from monai.transforms import Compose # needs to be here to avoid circular import + + if not isinstance(hashable_transforms, Compose): + hashable_transforms = Compose(hashable_transforms) + hashable_transforms = hashable_transforms.flatten().transforms + hash = pickle_hashing([h.__class__.__name__ for h in hashable_transforms]) + return hash + + def sorted_dict(item, key=None, reverse=False): """Return a new sorted dictionary from the `item`.""" if not isinstance(item, dict): diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index 59e1e4a758..c582a8554b 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -111,7 +111,7 @@ class DynUNet(nn.Module): 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 final feature map (from `output_block`), but also the feature maps that come from the intermediate up sample layers. - In order to unify the return type (the restriction of TorchScript), all intermediate + Unless return_list=True: In order to unify the return type (the restriction of TorchScript), all intermediate feature maps are interpolated into the same size as the final feature map and stacked together (with a new dimension in the first axis)into one single tensor. For instance, if there are two intermediate feature maps with shapes: (1, 2, 16, 12) and @@ -125,6 +125,9 @@ class DynUNet(nn.Module): res_block: whether to use residual connection based convolution blocks during the network. Defaults to ``False``. trans_bias: whether to set the bias parameter in transposed convolution layers. Defaults to ``False``. + return_list: return a list of the outputs of the top level output and the supervision heads rather than + a single stacked tensor of interpolated outputs. This is done because the interpolated outputs are too large + to be stored in memory. On the downside, torchscript will not work since it requires a tensor as output. """ def __init__( @@ -143,6 +146,7 @@ def __init__( deep_supr_num: int = 1, res_block: bool = False, trans_bias: bool = False, + return_list: bool = False, ): super().__init__() self.spatial_dims = spatial_dims @@ -156,6 +160,7 @@ def __init__( self.dropout = dropout self.conv_block = UnetResBlock if res_block else UnetBasicBlock self.trans_bias = trans_bias + self.return_list = return_list if filters is not None: self.filters = filters self.check_filters() @@ -268,11 +273,15 @@ def check_filters(self): def forward(self, x): out = self.skip_layers(x) out = self.output_block(out) - if self.training and self.deep_supervision: + + if self.training and self.deep_supervision and not self.return_list: out_all = [out] for feature_map in self.heads: out_all.append(interpolate(feature_map, out.shape[2:])) return torch.stack(out_all, dim=1) + elif self.training and self.deep_supervision and self.return_list: + out_all = [out] + self.heads + return out_all return out def get_input_block(self): diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 477ec7a8bd..60b60484c4 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -276,6 +276,7 @@ ) from .post.array import ( Activations, + AppendDownsampled, AsDiscrete, FillHoles, Invert, @@ -292,6 +293,9 @@ ActivationsD, Activationsd, ActivationsDict, + AppendDownsampledd, + AppendDownsampledD, + AppendDownsampledDict, AsDiscreteD, AsDiscreted, AsDiscreteDict, @@ -381,6 +385,7 @@ RandGridPatch, RandRotate, RandRotate90, + RandSimulateLowResolution, RandZoom, Resample, ResampleToMatch, @@ -437,6 +442,9 @@ RandRotated, RandRotateD, RandRotateDict, + RandSimulateLowResolutiond, + RandSimulateLowResolutionD, + RandSimulateLowResolutionDict, RandZoomd, RandZoomD, RandZoomDict, @@ -491,6 +499,7 @@ RandLambda, RemoveRepeatedChannel, RepeatChannel, + SampleForegroundLocations, SimulateDelay, SplitChannel, SplitDim, @@ -591,6 +600,9 @@ RepeatChanneld, RepeatChannelD, RepeatChannelDict, + SampleForegroundLocationsd, + SampleForegroundLocationsD, + SampleForegroundLocationsDict, SelectItemsd, SelectItemsD, SelectItemsDict, diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index df2e807a4b..6b72834d71 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -20,6 +20,7 @@ import numpy as np import torch import torch.nn.functional as F +from torch.nn.functional import interpolate from monai.config.type_definitions import NdarrayOrTensor from monai.data.meta_obj import get_track_meta @@ -28,7 +29,7 @@ from monai.networks.layers import GaussianFilter, apply_filter, separable_filtering from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Transform -from monai.transforms.utility.array import ToTensor +from monai.transforms.utility.array import CastToType, EnsureType, ToTensor from monai.transforms.utils import ( convert_applied_interp_mode, fill_holes, @@ -42,6 +43,7 @@ __all__ = [ "Activations", + "AppendDownsampled", "AsDiscrete", "FillHoles", "KeepLargestConnectedComponent", @@ -127,6 +129,51 @@ def __call__( return out +class AppendDownsampled(Transform): + """ + Convert the input tensor/array into a List of tensors/arrays of downsampled versions of the original tensor/array. + This is useful for deep supervision where outputs of deep supervision heads can be of lower resolution: + + - creates empty List + - appends downsampled tensor for each shape provided in downsampled_shapes + - uses torch.nn.functional.interpolate for downsampling operation + + + Args: + downsampled_shapes: List of shapes of the downsampled tensors/arrays + + """ + + def __init__(self, downsampled_shapes, mode="nearest") -> None: + self.downsampled_shapes = downsampled_shapes + self.mode = mode + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + img = convert_to_tensor(img, track_meta=get_track_meta()) + + spatial_input_size = len(self.downsampled_shapes[0]) + + # make sure input shape is B, C, W, H, (D) + added_dims = 0 + while len(img.shape) - spatial_input_size < 2: + img = img.unsqueeze(0) + added_dims += 1 + + ret = [] + for s in self.downsampled_shapes: + downsampled_img = interpolate(input=img, size=s, mode=self.mode) + downsampled_img = EnsureType()(downsampled_img) + + ret.append(downsampled_img) + + # make sure output shape of each list entry is C, W, H, (D) + for i in range(len(ret)): + for _ in range(added_dims): + ret[i] = ret[i].squeeze(0) + + return ret + + class AsDiscrete(Transform): """ Convert the input tensor/array into discrete values, possible operations are: diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 3fbfe46118..2ba1f4e00c 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -32,6 +32,7 @@ from monai.transforms.inverse import InvertibleTransform from monai.transforms.post.array import ( Activations, + AppendDownsampled, AsDiscrete, FillHoles, KeepLargestConnectedComponent, @@ -52,6 +53,9 @@ "ActivationsD", "ActivationsDict", "Activationsd", + "AppendDownsampledD", + "AppendDownsampledDict", + "AppendDownsampledd", "AsDiscreteD", "AsDiscreteDict", "AsDiscreted", @@ -143,6 +147,34 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class AppendDownsampledd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.AppendDownsampled`. + Convert the input tensor/array specified by `keys` into a List of tensors/arrays of downsampled versions of the + original tensor/array. This is useful for deep supervision where outputs of deep supervision heads can be of lower + resolution. + """ + + def __init__(self, keys: KeysCollection, downsampled_shapes, allow_missing_keys: bool = False) -> None: + super().__init__(keys, allow_missing_keys) + """ + Args: + keys: keys of the corresponding items. + See also: :py:class:`monai.transforms.compose.MapTransform` + downsampled_shapes: List of shapes of the downsampled tensors/arrays + allow_missing_keys: don't raise exception if key is missing. + """ + + self.append_downsampled = AppendDownsampled(downsampled_shapes) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for key in self.key_iterator(d): + d[key] = self.append_downsampled(d[key]) + return d + + class AsDiscreted(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AsDiscrete`. @@ -856,6 +888,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N ActivationsD = ActivationsDict = Activationsd +AppendDownsampledD = AppendDownsampledDict = AppendDownsampledd AsDiscreteD = AsDiscreteDict = AsDiscreted FillHolesD = FillHolesDict = FillHolesd InvertD = InvertDict = Invertd diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 6d95acb3d1..844e68eb5e 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -25,7 +25,7 @@ from monai.config import USE_COMPILED, DtypeLike from monai.config.type_definitions import NdarrayOrTensor -from monai.data.meta_obj import get_track_meta +from monai.data.meta_obj import get_track_meta, set_track_meta from monai.data.meta_tensor import MetaTensor from monai.data.utils import AFFINE_TOL, affine_to_spacing, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull @@ -111,6 +111,7 @@ "RandAffine", "Rand2DElastic", "Rand3DElastic", + "RandSimulateLowResolution", ] RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] @@ -1797,9 +1798,14 @@ class RandAffineGrid(Randomizable, LazyTransform): def __init__( self, rotate_range: RandRange = None, + prob_rotate: float = 1, shear_range: RandRange = None, + prob_shear: float = 1, translate_range: RandRange = None, + prob_translate: float = 1, scale_range: RandRange = None, + prob_scale: float = 1, + foreground_oversampling_prob: float = None, device: torch.device | None = None, dtype: DtypeLike = np.float32, lazy: bool = False, @@ -1812,6 +1818,8 @@ def __init__( 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. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. 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:: @@ -1822,40 +1830,64 @@ def __init__( [params[4], params[5], 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] - + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select voxels to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. 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). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. device: device to store the output grid data. dtype: data type for the grid computation. Defaults to ``np.float32``. If ``None``, use the data type of input data (if `grid` is provided). lazy: a flag to indicate whether this transform should execute lazily or not. Defaults to False - See also: - - :py:meth:`monai.transforms.utils.create_rotate` - - :py:meth:`monai.transforms.utils.create_shear` - - :py:meth:`monai.transforms.utils.create_translate` - - :py:meth:`monai.transforms.utils.create_scale` + See also: + - :py:meth:`monai.transforms.utils.create_rotate` + - :py:meth:`monai.transforms.utils.create_shear` + - :py:meth:`monai.transforms.utils.create_translate` + - :py:meth:`monai.transforms.utils.create_scale` """ LazyTransform.__init__(self, lazy=lazy) self.rotate_range = ensure_tuple(rotate_range) + self.prob_rotate = prob_rotate self.shear_range = ensure_tuple(shear_range) + self.prob_shear = prob_shear self.translate_range = ensure_tuple(translate_range) + self.prob_translate = prob_translate self.scale_range = ensure_tuple(scale_range) + self.prob_scale = prob_scale self.rotate_params: list[float] | None = None self.shear_params: list[float] | None = None self.translate_params: list[float] | None = None self.scale_params: list[float] | None = None + self.foreground_oversampling_prob = foreground_oversampling_prob + self.translate_to_foreground = False + self.device = device self.dtype = dtype self.affine: torch.Tensor | None = torch.eye(4, dtype=torch.float64) + self.rand_norm_translate_params = None + self.rand_float_to_pick_fg_location = None + def _get_rand_param(self, param_range, add_scalar: float = 0.0): out_param = [] for f in param_range: @@ -1868,15 +1900,36 @@ def _get_rand_param(self, param_range, add_scalar: float = 0.0): return out_param def randomize(self, data: Any | None = None) -> None: - self.rotate_params = self._get_rand_param(self.rotate_range) - self.shear_params = self._get_rand_param(self.shear_range) - self.translate_params = self._get_rand_param(self.translate_range) - self.scale_params = self._get_rand_param(self.scale_range, 1.0) + if self.R.rand() < self.prob_rotate: + self.rotate_params = self._get_rand_param(self.rotate_range) + else: + self.rotate_params = None + if self.R.rand() < self.prob_shear: + self.shear_params = self._get_rand_param(self.shear_range) + else: + self.shear_params = None + if self.R.rand() < self.prob_translate: + self.translate_params = self._get_rand_param(self.translate_range) + else: + self.translate_params = None + if self.R.rand() < self.prob_scale: + self.scale_params = self._get_rand_param(self.scale_range, 1.0) + else: + self.scale_params = None + if self.foreground_oversampling_prob is not None and self.R.rand() < self.foreground_oversampling_prob: + self.translate_to_foreground = True + self.rand_float_to_pick_fg_location = self.R.rand() + else: + self.translate_to_foreground = False + self.rand_norm_translate_params = self._get_rand_param(((-1.0, 1.0), (-1.0, 1.0), (-1.0, 1.0))) def __call__( self, spatial_size: Sequence[int] | None = None, grid: NdarrayOrTensor | None = None, + image_size: Optional[Sequence[int]] = None, + foreground_oversampling_prob: Optional[float] = None, + fg_indices: Optional[NdarrayOrTensor] = None, randomize: bool = True, lazy: bool | None = None, ) -> torch.Tensor: @@ -1884,6 +1937,7 @@ def __call__( Args: spatial_size: output grid size. grid: grid to be transformed. Shape must be (3, H, W) for 2D or (4, H, W, D) for 3D. + image_size: size of the image to sample from. This is required to determine the valid translate_range randomize: boolean as to whether the grid parameters governing the grid should be randomized. lazy: a flag to indicate whether this transform should execute lazily or not during this call. Setting this to False or True overrides the ``lazy`` flag set @@ -1894,6 +1948,56 @@ def __call__( """ if randomize: self.randomize() + + # if the foreground_oversampling_prob argument is used, the translation is determined by whether a foreground + # location should initially be targeted, furthermore, any translation will be clipped to a "valid" range + if self.foreground_oversampling_prob is not None: + # define the margin at the image borders (where the center point is not supposed to end up) by half the patch size + patch_size = np.array(grid.shape[1:] if grid is not None else spatial_size) + if self.scale_params is not None: + patch_size = np.array(patch_size) * np.array(self.scale_params) + + margin = patch_size / 2 + max_transl = np.array(image_size) / 2 - margin + + # it is possible that the input image is smaller than the patch size + # in that case no translation should happen, so set those values to 0 + max_transl = np.array([max(tr, 0) for tr in max_transl]) + + if ( + self.translate_to_foreground and (fg_indices is not None) and len(fg_indices) > 0 + ): # first translate to foreground pixel, then add the random translation, then clip to valid range + # randomly pick one of the previously sampled foreground pixels to translate the center point of the grid to + # select one fg sample based on float randomized in randomize function + rand_int = int(np.round(self.rand_float_to_pick_fg_location * (len(fg_indices) - 1))) + random_fg_index = fg_indices[rand_int][1:] + + # from this, calculate a translation to the fg point of a grid which is initially located at the image center + translate_params_to_fg_point = list(np.array(random_fg_index) - np.array(image_size) / 2) + + # add the additional random translation which was randomly selected from the translate_range + if self.translate_params is not None and self.translate_params != []: + assert len(translate_params_to_fg_point) == len(self.translate_params) + translate_params = [ + t_fg + t_range for t_fg, t_range in zip(translate_params_to_fg_point, self.translate_params) + ] + else: + translate_params = translate_params_to_fg_point + + else: # simply translate randomly within valid range (in this case, original translate_params are discarded) + # for each dimension, self.rand_norm_translate_params was randomly sampled between -1 and 1, + # now scale this value to the full range + translate_params = [max_transl[i] * self.rand_norm_translate_params[i] for i in range(len(max_transl))] + + # if the current translation parameters exceed the max_transl, use max_transl in the corresponding direction instead + clipped_translate_params = [ + t_fg if abs(t_fg) <= t_max else t_fg / abs(t_fg) * t_max + for t_fg, t_max in zip(translate_params, max_transl) + ] + + self.translate_params = list(clipped_translate_params) + + # create the affine grid lazy_ = self.lazy if lazy is None else lazy affine_grid = AffineGrid( rotate_params=self.rotate_params, @@ -2330,13 +2434,18 @@ def __init__( self, prob: float = 0.1, rotate_range: RandRange = None, + prob_rotate: float = 1, shear_range: RandRange = None, + prob_shear: float = 1, translate_range: RandRange = None, + prob_translate: float = 1, scale_range: RandRange = None, - spatial_size: Sequence[int] | int | None = None, - mode: str | int = GridSampleMode.BILINEAR, + prob_scale: float = 1, + spatial_size: Optional[Union[Sequence[int], int]] = None, + mode: Union[str, int] = GridSampleMode.BILINEAR, padding_mode: str = GridSamplePadMode.REFLECTION, cache_grid: bool = False, + foreground_oversampling_prob: Optional[float] = None, device: torch.device | None = None, lazy: bool = False, ) -> None: @@ -2350,6 +2459,8 @@ def __init__( 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. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. 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:: @@ -2360,12 +2471,17 @@ def __init__( [params[4], params[5], 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] - + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select pixel/voxel to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. 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). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. 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`. @@ -2384,6 +2500,15 @@ def __init__( When `mode` is an integer, using numpy/cupy backends, this argument accepts {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}. See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. cache_grid: whether to cache the identity sampling grid. If the spatial size is not dynamically defined by input image, enabling this option could accelerate the transform. @@ -2400,9 +2525,14 @@ def __init__( LazyTransform.__init__(self, lazy=lazy) self.rand_affine_grid = RandAffineGrid( rotate_range=rotate_range, + prob_rotate=prob_rotate, shear_range=shear_range, + prob_shear=prob_shear, translate_range=translate_range, + prob_translate=prob_translate, scale_range=scale_range, + prob_scale=prob_scale, + foreground_oversampling_prob=foreground_oversampling_prob, device=device, lazy=lazy, ) @@ -2532,7 +2662,7 @@ def __call__( if grid is None: grid = self.get_identity_grid(sp_size, lazy_) if self._do_transform: - grid = self.rand_affine_grid(grid=grid, randomize=randomize, lazy=lazy_) + grid = self.rand_affine_grid(grid=grid, image_size=img.shape[1:], randomize=randomize, lazy=lazy_) affine = self.rand_affine_grid.get_transformation_matrix() return affine_func( # type: ignore img, @@ -3456,3 +3586,89 @@ def __call__(self, array: NdarrayOrTensor, randomize: bool = True): if randomize: self.randomize(array) return super().__call__(array) + + +class RandSimulateLowResolution(RandomizableTransform): + """ + Random simulation of low resolution corresponding to nnU-Net's SimulateLowResolutionTransform + (https://github.com/MIC-DKFZ/batchgenerators/blob/7651ece69faf55263dd582a9f5cbd149ed9c3ad0/batchgenerators/transforms/resample_transforms.py#L23) + First, the array/tensor is resampled at lower resolution as determined by the zoom_factor which is uniformly sampled + from the `zoom_range`. Then, the array/tensor is resampled at the original resolution. + """ + + backend = Affine.backend + + def __init__( + self, + prob: float = 0.1, + downsample_mode: InterpolateMode | str = InterpolateMode.NEAREST, + upsample_mode: InterpolateMode | str = InterpolateMode.TRILINEAR, + zoom_range: Sequence = (0.5, 1.0), + align_corners=False, + device: Optional[torch.device] = None, + ) -> None: + """ + Args: + prob: probability of performing this augmentation + downsample_mode: how to downsample + upsample_mode: how to upsample + zoom_range: range from which the random zoom factor for the downsampling operation is sampled. It determines + the shape of the downsampled tensor. + align_corners: his only has an effect when downsample_mode or upsample_mode is 'linear', 'bilinear', + 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + device: device on which the tensor will be allocated. + + """ + RandomizableTransform.__init__(self, prob) + + self.downsample_mode = downsample_mode + self.upsample_mode = upsample_mode + self.zoom_range = zoom_range + self.align_corners = align_corners + self.device = device + self.zoom_factor = 1 + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + self.zoom_factor = self.R.uniform(self.zoom_range[0], self.zoom_range[1]) + if not self._do_transform: + return None + + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: + """ + Args: + img: shape must be (num_channels, H, W[, D]), + """ + if randomize: + self.randomize() + + if self._do_transform: + input_shape = np.array(img.shape[1:]) + target_shape = np.round(input_shape * self.zoom_factor).astype(np.int_) + + resize_tfm_downsample = Resize( + spatial_size=target_shape, size_mode="all", mode=self.downsample_mode, anti_aliasing=False + ) + + resize_tfm_upsample = Resize( + spatial_size=input_shape, + size_mode="all", + mode=self.upsample_mode, + anti_aliasing=False, + align_corners=self.align_corners, + ) + # temporarily disable metadata tracking, since we do not want to invert the two Resize functions in post-processing + original_tack_meta_value = get_track_meta() + set_track_meta(False) + + img_downsampled = resize_tfm_downsample(img) + img_upsampled = resize_tfm_upsample(img_downsampled) + set_track_meta(original_tack_meta_value) + + img_upsampled = MetaTensor(img_upsampled) + img_upsampled.copy_meta_from(img) + return img_upsampled + + else: + return img diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 6f53b10fc2..d89ace49e7 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -45,6 +45,7 @@ RandGridDistortion, RandGridPatch, RandRotate, + RandSimulateLowResolution, RandZoom, ResampleToMatch, Resize, @@ -140,6 +141,9 @@ "RandGridPatchd", "RandGridPatchD", "RandGridPatchDict", + "RandSimulateLowResolutiond", + "RandSimulateLowResolutionD", + "RandSimulateLowResolutionDict", ] @@ -1006,12 +1010,18 @@ def __init__( spatial_size: Sequence[int] | int | None = None, prob: float = 0.1, rotate_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_rotate: float = 1, shear_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_shear: float = 1, translate_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_translate: float = 1, scale_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_scale: float = 1, mode: SequenceStr = GridSampleMode.BILINEAR, padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, cache_grid: bool = False, + foreground_oversampling_prob: float = None, + label_key_for_foreground_oversampling: str = None, device: torch.device | None = None, allow_missing_keys: bool = False, lazy: bool = False, @@ -1033,6 +1043,8 @@ def __init__( 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. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. 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:: @@ -1044,11 +1056,17 @@ def __init__( [0.0, 0.0, 0.0, 1.0], ] + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select pixel/voxel to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. 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). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers). Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html @@ -1066,6 +1084,20 @@ def __init__( cache_grid: whether to cache the identity sampling grid. If the spatial size is not dynamically defined by input image, enabling this option could accelerate the transform. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. + label_key_for_foreground_oversampling: key of metatensor whose metadata contains the list of foreground + locations. The list of foreground locations has to be stored under + data[label_key_for_foreground_oversampling].meta['foreground_sample_locations']. + `monai.transforms.SampleForegroundLocations` transform can be used to create and store the list of + foreground locations. device: device on which the tensor will be allocated. allow_missing_keys: don't raise exception if key is missing. lazy: a flag to indicate whether this transform should execute lazily or not. @@ -1074,19 +1106,27 @@ def __init__( See also: - :py:class:`monai.transforms.compose.MapTransform` - :py:class:`RandAffineGrid` for the random affine parameters configurations. + - :py:class:`monai.transforms.SampleForegroundLocations` to sample foreground locations and save in metadata """ MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) + self.label_key_for_foreground_oversampling = label_key_for_foreground_oversampling + self.foreground_oversampling_prob = foreground_oversampling_prob LazyTransform.__init__(self, lazy=lazy) self.rand_affine = RandAffine( prob=1.0, # because probability handled in this class rotate_range=rotate_range, + prob_rotate=prob_rotate, shear_range=shear_range, + prob_shear=prob_shear, translate_range=translate_range, + prob_translate=prob_translate, scale_range=scale_range, + prob_scale=prob_scale, spatial_size=spatial_size, cache_grid=cache_grid, + foreground_oversampling_prob=foreground_oversampling_prob, device=device, lazy=lazy, ) @@ -1140,8 +1180,14 @@ def __call__( if do_resampling: # need to prepare grid grid = self.rand_affine.get_identity_grid(sp_size, lazy=lazy_) if self._do_transform: # add some random factors - grid = self.rand_affine.rand_affine_grid(sp_size, grid=grid, lazy=lazy_) - grid = 0 if grid is None else grid # always provide a grid to self.rand_affine + if self.foreground_oversampling_prob is not None: + fg_indices = d[self.label_key_for_foreground_oversampling].meta["foreground_sample_locations"] + else: + fg_indices = None + + grid = self.rand_affine.rand_affine_grid( + spatial_size=sp_size, grid=grid, image_size=spatial_size, fg_indices=fg_indices, lazy=lazy_ + ) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): # do the transform @@ -2518,6 +2564,88 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class RandSimulateLowResolutiond(RandomizableTransform, MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.RandSimulateLowResolution`. + Random simulation of low resolution corresponding to nnU-Net's SimulateLowResolutionTransform + (https://github.com/MIC-DKFZ/batchgenerators/blob/7651ece69faf55263dd582a9f5cbd149ed9c3ad0/batchgenerators/transforms/resample_transforms.py#L23) + First, the array/tensor is resampled at lower resolution as determined by the zoom_factor which is uniformly sampled + from the `zoom_range`. Then, the array/tensor is resampled at the original resolution. + """ + + backend = RandAffine.backend + + def __init__( + self, + keys: KeysCollection, + prob: float = 0.1, + downsample_mode: InterpolateMode | str = InterpolateMode.NEAREST, + upsample_mode: InterpolateMode | str = InterpolateMode.TRILINEAR, + zoom_range=(0.5, 1.0), + align_corners=False, + allow_missing_keys: bool = False, + device: torch.device | None = None, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + prob: probability of performing this augmentation + downsample_mode: how to downsample + upsample_mode: how to upsample + zoom_range: range from which the random zoom factor for the downsampling operation is sampled. It determines + the shape of the downsampled tensor. + align_corners: his only has an effect when downsample_mode or upsample_mode is 'linear', 'bilinear', + 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. + + See also: + - :py:class:`monai.transforms.compose.MapTransform` + + """ + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + + self.downsample_mode = downsample_mode + self.upsample_mode = upsample_mode + self.zoom_range = zoom_range + self.align_corners = align_corners + self.device = device + + self.sim_lowres_tfm = RandSimulateLowResolution( + prob=1.0, # probability is handled by dictionary class + downsample_mode=self.downsample_mode, + upsample_mode=self.upsample_mode, + zoom_range=self.zoom_range, + align_corners=self.align_corners, + device=self.device, + ) + + def set_random_state( + self, seed: int | None = None, state: np.random.RandomState | None = None + ) -> "RandSimulateLowResolutiond": + super().set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + first_key: Hashable = self.first_key(d) + if first_key == (): + out: dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out + + self.randomize(None) + + for key in self.key_iterator(d): + # do the transform + if self._do_transform: + d[key] = self.sim_lowres_tfm(d[key]) # type: ignore + else: + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta(), dtype=torch.float32) + return d + + SpatialResampleD = SpatialResampleDict = SpatialResampled ResampleToMatchD = ResampleToMatchDict = ResampleToMatchd SpacingD = SpacingDict = Spacingd @@ -2541,3 +2669,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N GridSplitD = GridSplitDict = GridSplitd GridPatchD = GridPatchDict = GridPatchd RandGridPatchD = RandGridPatchDict = RandGridPatchd +RandSimulateLowResolutionD = RandSimulateLowResolutionDict = RandSimulateLowResolutiond diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 75b8199314..57920aa8c6 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -97,6 +97,7 @@ "Lambda", "RandLambda", "LabelToMask", + "SampleForegroundLocations", "FgBgToIndices", "ClassesToIndices", "ConvertToMultiChannelBasedOnBratsClasses", @@ -979,6 +980,38 @@ def __call__( return data +class SampleForegroundLocations(Transform): + """ + Sample foreground locations and store in metadata of the label. The locations can be used for foreground + oversampling. + """ + + def __init__(self, num_samples: int = 1000, dtype: DtypeLike = np.float32) -> None: + """ + Args: + num_samples: number of foreground samples + dtype: data type of output tensor + """ + self.num_samples = num_samples + self.dtype = dtype + + def __call__(self, label: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Sample foreground locations from 'label'. + """ + label = convert_to_tensor(label, track_meta=get_track_meta(), dtype=self.dtype) + + all_locations = (label > 0).nonzero() + + if len(all_locations) > 0: + random_indices = torch.randint(0, len(all_locations), (self.num_samples,)) + random_samples = all_locations[random_indices] + else: + random_samples = torch.tensor([]) + label.meta["foreground_sample_locations"] = random_samples.cpu().numpy() + return label + + class FgBgToIndices(Transform, MultiSampleTrait): """ Compute foreground and background of the input label data, return the indices. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 15025ac961..65738405ae 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -52,6 +52,7 @@ MapLabelValue, RemoveRepeatedChannel, RepeatChannel, + SampleForegroundLocations, SimulateDelay, SplitDim, SqueezeDim, @@ -150,6 +151,9 @@ "RepeatChannelD", "RepeatChannelDict", "RepeatChanneld", + "SampleForegroundLocationsD", + "SampleForegroundLocationsDict", + "SampleForegroundLocationsd", "SelectItemsD", "SelectItemsDict", "SelectItemsd", @@ -1244,6 +1248,39 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class SampleForegroundLocationsd(MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.SampleForegroundLocations`. + """ + + def __init__( + self, + label_keys: KeysCollection, + num_samples: int = 1000, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + label_keys: keys of labels from where the foreground is sampled + See also: :py:class:`monai.transforms.compose.MapTransform` + num_samples: how many samples to draw + type: data type of output tensor + allow_missing_keys: don't raise exception if key is missing. + """ + MapTransform.__init__(self, label_keys, allow_missing_keys) + + self.dtype = dtype + self.sample_foreground_locations = SampleForegroundLocations(num_samples=num_samples, dtype=self.dtype) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for key in self.key_iterator(d): + d[key] = self.sample_foreground_locations(d[key]) + return d + + class FgBgToIndicesd(MapTransform, MultiSampleTrait): """ Dictionary-based wrapper of :py:class:`monai.transforms.FgBgToIndices`. @@ -1851,6 +1888,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N ConcatItemsD = ConcatItemsDict = ConcatItemsd LambdaD = LambdaDict = Lambdad LabelToMaskD = LabelToMaskDict = LabelToMaskd +SampleForegroundLocationsD = SampleForegroundLocationsDict = SampleForegroundLocationsd FgBgToIndicesD = FgBgToIndicesDict = FgBgToIndicesd ClassesToIndicesD = ClassesToIndicesDict = ClassesToIndicesd ConvertToMultiChannelBasedOnBratsClassesD = ( diff --git a/tests/test_append_downsampled.py b/tests/test_append_downsampled.py new file mode 100644 index 0000000000..40b2ef5191 --- /dev/null +++ b/tests/test_append_downsampled.py @@ -0,0 +1,49 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import AppendDownsampled +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for val in [2, 3, -1, -2.5]: + downsampled_shapes = [(5, 5, 5), (4, 4, 4)] + TEST_CASES.append( + [ + {"downsampled_shapes": downsampled_shapes}, + p(np.ones([10, 10, 9]) * val), + [p(np.ones(s) * val) for s in downsampled_shapes], + downsampled_shapes, + ] + ) + + +class TestAppendDownsampled(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, img, out, expected_shapes): + result = AppendDownsampled(**input_param)(img) + + self.assertEqual(len(result), len(expected_shapes)) + + for res, o, exp_shape in zip(result, out, expected_shapes): + self.assertTupleEqual(res.shape, exp_shape) + assert_allclose(res, o, rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_append_downsampledd.py b/tests/test_append_downsampledd.py new file mode 100644 index 0000000000..f188ec6310 --- /dev/null +++ b/tests/test_append_downsampledd.py @@ -0,0 +1,53 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import AppendDownsampledd +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for val in [2, 3]: + downsampled_shapes = [(5, 5, 5), (4, 4, 4)] + TEST_CASES.append( + [ + {"keys": ["label"], "downsampled_shapes": downsampled_shapes}, + {"pred": p(np.ones([10, 10, 9]) * (val - 3.2)), "label": p(np.ones([10, 10, 9]) * val)}, + { + "pred": p(np.ones([10, 10, 9]) * (val - 3.2)), + "label": [p(np.ones(s) * val) for s in downsampled_shapes], + }, + downsampled_shapes, + ] + ) + + +class TestAppendDownsampledd(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, test_input, output, expected_shapes): + result = AppendDownsampledd(**input_param)(test_input) + assert_allclose(result["pred"], output["pred"], rtol=1e-3) # "pred" should not have been affected by transform + if "label" in result: + self.assertEqual(len(result["label"]), len(expected_shapes)) + + for res, o, exp_shape in zip(result["label"], output["label"], expected_shapes): + self.assertTupleEqual(res.shape, exp_shape) + assert_allclose(res, o, rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index 247da14b7d..98c41750bf 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -107,6 +107,41 @@ ] TEST_CASE_DEEP_SUPERVISION.append(test_case) +TEST_CASE_RETURN_LIST = [] +for spatial_dims in [2, 3]: + for deep_supr_num in [1, 2]: + for strides in [(1, 2, 1, 2, 1), (2, 2, 2, 1), (2, 1, 1, 2, 2)]: + input_shape = (1, 1, *[in_size] * spatial_dims) + + expected_shapes = [] + for deep_supr_idx, s in enumerate(strides): + if deep_supr_idx <= deep_supr_num: + if not expected_shapes: + prev_shape = (1, 2, *input_shape[2:]) + else: + prev_shape = expected_shapes[-1] + + expected_shape = (*prev_shape[:2], *[p // s for p in prev_shape[2:]]) + expected_shapes.append(expected_shape) + + test_case = [ + { + "spatial_dims": spatial_dims, + "in_channels": 1, + "out_channels": 2, + "kernel_size": [3] * len(strides), + "strides": strides, + "upsample_kernel_size": strides[1:], + "norm_name": ("group", {"num_groups": 16}), + "deep_supervision": True, + "deep_supr_num": deep_supr_num, + "return_list": True, + }, + input_shape, + expected_shapes, + ] + TEST_CASE_RETURN_LIST.append(test_case) + class TestDynUNet(unittest.TestCase): @parameterized.expand(TEST_CASE_DYNUNET_3D) @@ -169,5 +204,19 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(results.shape, expected_shape) +class TestDynUNetReturnList(unittest.TestCase): + @parameterized.expand(TEST_CASE_RETURN_LIST) + def test_shape(self, input_param, input_shape, expected_shapes): + net = DynUNet(**input_param).to(device) + with torch.no_grad(): + results = net(torch.randn(input_shape).to(device)) + self.assertTrue( + len(results) == len(expected_shapes), + f"Expected output of length {len(expected_shapes)}, but got {len(results)}.", + ) + for res, expected_shape in zip(results, expected_shapes): + self.assertEqual(res.shape, expected_shape) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_nnunet_transforms.py b/tests/test_nnunet_transforms.py new file mode 100644 index 0000000000..10e159fb8b --- /dev/null +++ b/tests/test_nnunet_transforms.py @@ -0,0 +1,175 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import itertools +import os +import tempfile +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.data import MetaTensor, set_track_meta +from monai.transforms import RandAffined, LoadImaged, EnsureChannelFirstd, CropForegroundd, NormalizeIntensityd, \ + SampleForegroundLocationsd, RandGaussianNoised, RandGaussianSmoothd, RandScaleIntensityd, \ + RandScaleIntensityFixedMeand, RandSimulateLowResolutiond, RandAdjustContrastd, RandFlipd, Compose, \ + AppendDownsampledd, CastToTyped, EnsureTyped, ConcatItemsd, DeleteItemsd +from tests.utils import assert_allclose, is_tf32_env +import nibabel as nib + +_rtol = 1e-3 if is_tf32_env() else 1e-4 + +TESTS = [] + +def get_deep_supr_label_shapes(deep_supr_num, patch_size, strides): + supr_label_shapes = [patch_size] + for i in range(deep_supr_num): + last_shape = supr_label_shapes[-1] + curr_strides = strides[ + i + 1] # ignore first set of strides, since they apply to downsampling prior to the first level + downsampled_shape = [int(np.round(last / curr)) for last, curr in zip(last_shape, curr_strides)] + supr_label_shapes.append(downsampled_shape) + return supr_label_shapes + + +modality_keys = ["image_0000", "image_0001"] + +# transform parameters +patch_size= [32, 64, 32] +strides = [[1, 1, 1], [2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2], [1, 2, 1]] +deep_supr_num = 3 +pos_sample_num = 2 +neg_sample_num = 1 +use_nonzero = True +use_prior = False + +label_keys = ["label"] +all_keys = modality_keys + label_keys + +# exclude the prior from the intensity transforms +mod_inty_keys = modality_keys[:-1] if use_prior else modality_keys + +load_image = LoadImaged(keys=all_keys, image_only=True) +ensure_channel_first = EnsureChannelFirstd(keys=all_keys) +crop_transform = CropForegroundd(keys=all_keys, source_key=mod_inty_keys[0], start_coord_key=None, end_coord_key=None) + +norm_transform = NormalizeIntensityd(keys=mod_inty_keys, nonzero=use_nonzero) +sample_foreground_locations = SampleForegroundLocationsd(label_keys=label_keys, num_samples=10000) + +rand_affine = RandAffined( + keys=all_keys, + mode=(3,)*len(modality_keys) + ("nearest", ), # 3 means third order spline interpolation + prob=1.0, + spatial_size=patch_size, + rotate_range= (30 / 360 * 2 * np.pi, 30 / 360 * 2 * np.pi, 30 / 360 * 2 * np.pi), + prob_rotate=0.2, + translate_range=(0, 0, 0), + foreground_oversampling_prob=pos_sample_num / neg_sample_num, + label_key_for_foreground_oversampling="label", + prob_translate=1.0, + scale_range=((-0.3, 0.4), (-0.3, 0.4), (-0.3, 0.4)), + prob_scale=0.2, + padding_mode=("constant",)*len(modality_keys) + ("border", ), +) + +rand_gauss_noise = RandGaussianNoised(keys=mod_inty_keys, std=0.1, prob=0.1) + +rand_gauss_smooth = RandGaussianSmoothd(keys=mod_inty_keys, + sigma_x=(0.5, 1.0), + sigma_y=(0.5, 1.0), + sigma_z=(0.5, 1.0), + prob=0.2 * 0.5, ) # 0.5 comes from the per_channel_probability + +scale_intensity = RandScaleIntensityd(keys=mod_inty_keys, factors=[-0.25, 0.25], prob=0.15) + +shift_intensity = RandScaleIntensityFixedMeand(keys=mod_inty_keys, factors=[-0.25, 0.25], preserve_range=True, + prob=0.15) + +sim_lowres = RandSimulateLowResolutiond(keys=mod_inty_keys, prob=0.25*0.5, zoom_range=(0.5, 1.0)) + +adjust_contrast_inverted = RandAdjustContrastd(keys=mod_inty_keys, prob=0.1 * 1.0, gamma=(0.7, 1.5), + invert_image=True, retain_stats=True) + +adjust_contrast = RandAdjustContrastd(keys=mod_inty_keys, prob=0.3 * 1.0, gamma=(0.7, 1.5), invert_image=False, + retain_stats=True) + +mirror_x = RandFlipd(all_keys, spatial_axis=[0], prob=0.5) +mirror_y = RandFlipd(all_keys, spatial_axis=[1], prob=0.5) +mirror_z = RandFlipd(all_keys, spatial_axis=[2], prob=0.5) + +supr_label_shapes = get_deep_supr_label_shapes(deep_supr_num, patch_size, strides) + +transform = Compose([ + load_image, + ensure_channel_first, + crop_transform, + norm_transform, # -1 + sample_foreground_locations, # 0 + rand_affine, # 1 + rand_gauss_noise, # 2 + rand_gauss_smooth, # 3 + scale_intensity, # 4 + shift_intensity, # 5 + sim_lowres, # 6 + adjust_contrast_inverted, # 7 + adjust_contrast, # 8 + mirror_x, mirror_y, mirror_z, # 9 + AppendDownsampledd(label_keys, downsampled_shapes=supr_label_shapes), + CastToTyped(keys=modality_keys, dtype=np.float32), + EnsureTyped(keys=modality_keys), + ConcatItemsd(keys=modality_keys, name="image", dim=0), + DeleteItemsd(keys=modality_keys), +], unpack_items=True) + +TESTS.append( + [ + dict( + transform=transform, + keys=["image_0000", "image_0001", "label"], + fnames=["test_image_0000.nii.gz", + "test_image_0001.nii.gz", + "test_label.nii.gz"], + data=[np.arange(64 * 64 * 64).reshape((64, 64, 64)).astype(float), + np.arange(64 * 64 * 64).reshape((64, 64, 64)).astype(float), + (np.random.rand(64, 64, 64) > 0.25).astype(float)] + ) + ] +) + + +class TestNnunetTransforms(unittest.TestCase): + @parameterized.expand(x + [y] for x, y in itertools.product(TESTS, (True,))) + def test_nnunet_transforms(self, input_params, track_meta): + set_track_meta(track_meta) + + with tempfile.TemporaryDirectory() as tempdir: + data_dict = {} + for i in range(len(input_params['fnames'])): + name = input_params['fnames'][i] + key = input_params['keys'][i] + data = input_params['data'][i] + filename = os.path.join(tempdir, name) + nib.save(nib.Nifti1Image(data, np.eye(4)), filename) + data_dict.update({key: filename}) + + transform = input_params['transform'] + out = transform(data_dict) + + self.assertTrue("label" in out.keys()) + self.assertTrue("image" in out.keys()) # concatenation of image_000X .... + self.assertEqual(list(out['image'].shape), [len(modality_keys)]+patch_size) # concatenation has the right shape? + self.assertEqual(len(out['label']), deep_supr_num + 1) # list of tensor outputs (top level output plus deep supervision heads + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_persistentstageddataset.py b/tests/test_persistentstageddataset.py new file mode 100644 index 0000000000..902bbfec66 --- /dev/null +++ b/tests/test_persistentstageddataset.py @@ -0,0 +1,212 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +import pickle +import tempfile +import unittest + +import nibabel as nib +import numpy as np +import torch.testing +from parameterized import parameterized + +from monai.data import PersistentStagedDataset, json_hashing +from monai.transforms import Compose, Flip, Identity, LoadImaged, SimulateDelayd, Transform + +TEST_CASE_1 = [ + Compose( + [ + LoadImaged(keys=["image", "label", "extra"]), + SimulateDelayd(keys=["image", "label", "extra"], delay_time=[1e-7, 1e-6, 1e-5]), + ] + ), + (128, 128, 128), +] + +TEST_CASE_2 = [ + [ + LoadImaged(keys=["image", "label", "extra"]), + SimulateDelayd(keys=["image", "label", "extra"], delay_time=[1e-7, 1e-6, 1e-5]), + ], + (128, 128, 128), +] + +TEST_CASE_3 = [None, (128, 128, 128)] + + +class _InplaceXform(Transform): + def __call__(self, data): + if data: + data[0] = data[0] + np.pi + else: + data.append(1) + return data + + +class TestDataset(unittest.TestCase): + def test_cache(self): + """testing no inplace change to the hashed item""" + items = [[list(range(i))] for i in range(5)] + + with tempfile.TemporaryDirectory() as tempdir: + ds = PersistentStagedDataset( + data=items, + new_transform=_InplaceXform(), + old_transform=None, + cache_dir=tempdir, + pickle_module="pickle", + pickle_protocol=pickle.HIGHEST_PROTOCOL, + ) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + ds1 = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir + ) + self.assertEqual(list(ds1), list(ds)) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + + ds = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing + ) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + ds1 = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing + ) + self.assertEqual(list(ds1), list(ds)) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_shape(self, transform, expected_shape): + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=[128, 128, 128]).astype(float), np.eye(4)) + with tempfile.TemporaryDirectory() as tempdir: + nib.save(test_image, os.path.join(tempdir, "test_image1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_label1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_extra1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_image2.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_label2.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_extra2.nii.gz")) + test_data = [ + { + "image": os.path.join(tempdir, "test_image1.nii.gz"), + "label": os.path.join(tempdir, "test_label1.nii.gz"), + "extra": os.path.join(tempdir, "test_extra1.nii.gz"), + }, + { + "image": os.path.join(tempdir, "test_image2.nii.gz"), + "label": os.path.join(tempdir, "test_label2.nii.gz"), + "extra": os.path.join(tempdir, "test_extra2.nii.gz"), + }, + ] + + cache_dir = os.path.join(os.path.join(tempdir, "cache"), "data") + dataset_precached = PersistentStagedDataset( + data=test_data, new_transform=transform, old_transform=None, cache_dir=cache_dir + ) + data1_precached = dataset_precached[0] + data2_precached = dataset_precached[1] + + dataset_postcached = PersistentStagedDataset( + data=test_data, new_transform=transform, old_transform=None, cache_dir=cache_dir + ) + data1_postcached = dataset_postcached[0] + data2_postcached = dataset_postcached[1] + data3_postcached = dataset_postcached[0:2] + + if transform is None: + self.assertEqual(data1_precached["image"], os.path.join(tempdir, "test_image1.nii.gz")) + self.assertEqual(data2_precached["label"], os.path.join(tempdir, "test_label2.nii.gz")) + self.assertEqual(data1_postcached["image"], os.path.join(tempdir, "test_image1.nii.gz")) + self.assertEqual(data2_postcached["extra"], os.path.join(tempdir, "test_extra2.nii.gz")) + else: + self.assertTupleEqual(data1_precached["image"].shape, expected_shape) + self.assertTupleEqual(data1_precached["label"].shape, expected_shape) + self.assertTupleEqual(data1_precached["extra"].shape, expected_shape) + self.assertTupleEqual(data2_precached["image"].shape, expected_shape) + self.assertTupleEqual(data2_precached["label"].shape, expected_shape) + self.assertTupleEqual(data2_precached["extra"].shape, expected_shape) + + self.assertTupleEqual(data1_postcached["image"].shape, expected_shape) + self.assertTupleEqual(data1_postcached["label"].shape, expected_shape) + self.assertTupleEqual(data1_postcached["extra"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["image"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["label"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["extra"].shape, expected_shape) + for d in data3_postcached: + self.assertTupleEqual(d["image"].shape, expected_shape) + + # update the data to cache + test_data_new = [ + { + "image": os.path.join(tempdir, "test_image1_new.nii.gz"), + "label": os.path.join(tempdir, "test_label1_new.nii.gz"), + "extra": os.path.join(tempdir, "test_extra1_new.nii.gz"), + }, + { + "image": os.path.join(tempdir, "test_image2_new.nii.gz"), + "label": os.path.join(tempdir, "test_label2_new.nii.gz"), + "extra": os.path.join(tempdir, "test_extra2_new.nii.gz"), + }, + ] + dataset_postcached.set_data(data=test_data_new) + # test new exchanged cache content + if transform is None: + self.assertEqual(dataset_postcached[0]["image"], os.path.join(tempdir, "test_image1_new.nii.gz")) + self.assertEqual(dataset_postcached[0]["label"], os.path.join(tempdir, "test_label1_new.nii.gz")) + self.assertEqual(dataset_postcached[1]["extra"], os.path.join(tempdir, "test_extra2_new.nii.gz")) + + def test_different_transforms(self): + """ + Different instances of `PersistentDataset` with the same cache_dir, + same input data, but different transforms should give different results. + """ + shape = (1, 10, 9, 8) + im = np.arange(0, np.prod(shape)).reshape(shape) + with tempfile.TemporaryDirectory() as path: + im1 = PersistentStagedDataset(data=[im], new_transform=Identity(), old_transform=None, cache_dir=path)[0] + im2 = PersistentStagedDataset(data=[im], new_transform=Flip(1), old_transform=None, cache_dir=path)[0] + l2 = ((im1 - im2) ** 2).sum() ** 0.5 + self.assertTrue(l2 > 1) + + class FlipCallCheck(Flip): + """wrapper for Flip transform that keeps track of how many times the transform was called""" + + call_counter = 0 + + def __call__(self, *args, **kwargs): + self.call_counter += 1 + return super().__call__(*args, **kwargs) + + def test_old_transform(self): + """ + Different instances of `PersistentDataset` with the same cache_dir, + same input data, but different transforms should give different results. + """ + shape = (1, 2, 2, 2) + im = torch.arange(0, np.prod(shape)).reshape(shape) + flip_tfm = self.FlipCallCheck(1) + with tempfile.TemporaryDirectory() as path: + # step 1: cache im data transformed with new_transform (flip) + PersistentStagedDataset(data=[im], new_transform=flip_tfm, old_transform=None, cache_dir=path) + # step 2: apply new_transform (flip again) to cached data ([im]+old_transform points to cached data) + im2 = PersistentStagedDataset(data=[im], new_transform=flip_tfm, old_transform=flip_tfm, cache_dir=path)[0] + + # check output: flipping twice should return the original data + torch.testing.assert_close(im, im2) + + # make sure that the Flip transform was called only 2 times (once in step 1 and once in step 2, i.e., the + # cached data was found and loaded in step 2, and the old_transform was not applied again. + self.assertTrue(flip_tfm.call_counter == 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 915b14bf51..fd628f5021 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -70,7 +70,7 @@ device=device, ), {"img": p(torch.ones((1, 3, 3, 3))), "mode": "bilinear"}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), + p(torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]])), ] ) TESTS.append( @@ -86,7 +86,7 @@ device=device, ), {"img": p(torch.ones((1, 3, 3, 3))), "mode": "bilinear"}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), + p(torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]])), ] ) TESTS.append( @@ -102,7 +102,7 @@ {"img": p(torch.arange(64).reshape((1, 8, 8))), "spatial_size": (3, 3)}, p( torch.tensor( - [[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]] + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] ) ), ] @@ -122,7 +122,31 @@ {"img": p(torch.arange(64).reshape((1, 8, 8)))}, p( torch.tensor( - [[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]] + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] + ) + ), + ] + ) + TESTS.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + prob_rotate=1.0, + shear_range=[1, 2], + prob_shear=1.0, + translate_range=[2, 1], + prob_translate=1.0, + scale_range=[0.1, 0.2], + prob_scale=1.0, + spatial_size=(3, 3), + cache_grid=True, + device=device, + ), + {"img": p(torch.arange(64).reshape((1, 8, 8)))}, + p( + torch.tensor( + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] ) ), ] @@ -138,6 +162,40 @@ for initial_randomize in (False, True): TEST_RANDOMIZE.append((initial_randomize, cache_grid)) +TEST_FOREGROUND_OVERSAMPLING = [] +for p in TEST_NDARRAYS_ALL: + TEST_FOREGROUND_OVERSAMPLING.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + prob_rotate=1.0, + shear_range=[1, 2], + prob_shear=1.0, + translate_range=[2, 1], + prob_translate=1.0, + scale_range=[0.1, 0.2], + prob_scale=1.0, + spatial_size=(3, 3), + foreground_oversampling_prob=0.5, + cache_grid=True, + device=device, + ), + {"img": p(torch.arange(64).reshape((1, 8, 8)))}, + p( + torch.tensor( + [ + [ + [1.100917, 3.219612, 9.162791], + [7.743001, 13.68618, 19.629358], + [18.209568, 24.152748, 30.095926], + ] + ] + ) + ), + ] + ) + class TestRandAffine(unittest.TestCase): @parameterized.expand(TESTS) @@ -197,6 +255,24 @@ def test_no_randomize(self, initial_randomize, cache_grid): assert_allclose(m1, m2) assert_allclose(arr1, arr2) + @parameterized.expand(TEST_FOREGROUND_OVERSAMPLING) + def test_rand_foreground_oversampling(self, input_param, input_data, expected_val): + g = RandAffine(**input_param) + g.set_random_state(123) + + grid = g.rand_affine_grid( + spatial_size=input_param["spatial_size"], + grid=None, + image_size=input_data["img"].shape[1:], + fg_indices=[[1, 2], [2, 2]], + ) + + result = g(**input_data, grid=grid) + test_resampler_lazy(g, result, input_param, input_data, seed=123) + if input_param.get("cache_grid", False): + self.assertTrue(g._cached_grid is not None) + assert_allclose(result, expected_val, rtol=_rtol, atol=1e-4, type_test="tensor") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index 113987a85c..ddcc7582ad 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -33,17 +33,9 @@ p( np.array( [ - [ - [-32.81998, -33.910976, -35.001972], - [-36.092968, -37.183964, -38.27496], - [-39.36596, -40.456955, -41.54795], - ], - [ - [2.1380205, 3.1015975, 4.0651755], - [5.028752, 5.9923296, 6.955907], - [7.919484, 8.883063, 9.84664], - ], - [[18.0, 19.0, 20.0], [21.0, 22.0, 23.0], [24.0, 25.0, 26.0]], + [[17.7142, 19.8156, 21.9171], [24.0185, 26.1199, 28.2214], [30.3228, 32.4242, 34.5257]], + [[58.8789, 62.1901, 65.5013], [68.8125, 72.1238, 75.4350], [78.7462, 82.0574, 85.3686]], + [[18.0000, 19.0000, 20.0000], [21.0000, 22.0000, 23.0000], [24.0000, 25.0000, 26.0000]], ] ) ), @@ -56,60 +48,24 @@ np.array( [ [ - [ - [0.17881513, 0.17881513, 0.17881513], - [0.17881513, 0.17881513, 0.17881513], - [0.17881513, 0.17881513, 0.17881513], - ], - [ - [1.1788151, 1.1788151, 1.1788151], - [1.1788151, 1.1788151, 1.1788151], - [1.1788151, 1.1788151, 1.1788151], - ], - [ - [2.1788151, 2.1788151, 2.1788151], - [2.1788151, 2.1788151, 2.1788151], - [2.1788151, 2.1788151, 2.1788151], - ], + [[-0.6921, -0.6921, -0.6921], [-0.6921, -0.6921, -0.6921], [-0.6921, -0.6921, -0.6921]], + [[0.3079, 0.3079, 0.3079], [0.3079, 0.3079, 0.3079], [0.3079, 0.3079, 0.3079]], + [[1.3079, 1.3079, 1.3079], [1.3079, 1.3079, 1.3079], [1.3079, 1.3079, 1.3079]], ], [ - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], ], [ - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], ], [ - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], ], ] ), @@ -124,70 +80,120 @@ [ [ [ - [-9.4201e00, -8.1672e00, -6.9143e00], - [-5.6614e00, -4.4085e00, -3.1556e00], - [-1.9027e00, -6.4980e-01, 6.0310e-01], + [-30.7709, -30.5800, -30.3891], + [-30.1981, -30.0072, -29.8163], + [-29.6254, -29.4344, -29.2435], ], [ - [1.8560e00, 3.1089e00, 4.3618e00], - [5.6147e00, 6.8676e00, 8.1205e00], - [9.3734e00, 1.0626e01, 1.1879e01], + [-29.0526, -28.8617, -28.6707], + [-28.4798, -28.2889, -28.0980], + [-27.9070, -27.7161, -27.5252], ], [ - [1.3132e01, 1.4385e01, 1.5638e01], - [1.6891e01, 1.8144e01, 1.9397e01], - [2.0650e01, 2.1902e01, 2.3155e01], + [-27.3343, -27.1433, -26.9524], + [-26.7615, -26.5706, -26.3796], + [-26.1887, -25.9978, -25.8069], ], ], + [ + [[42.8536, 44.3798, 45.9061], [47.4324, 48.9586, 50.4849], [52.0111, 53.5374, 55.0636]], + [[56.5899, 58.1161, 59.6424], [61.1686, 62.6949, 64.2211], [65.7474, 67.2736, 68.7999]], + [[70.3261, 71.8524, 73.3786], [74.9049, 76.4312, 77.9574], [79.4837, 81.0099, 82.5362]], + ], + [ + [[29.3580, 30.0760, 30.7941], [31.5121, 32.2301, 32.9481], [33.6661, 34.3842, 35.1022]], + [[35.8202, 36.5382, 37.2563], [37.9743, 38.6923, 39.4103], [40.1283, 40.8464, 41.5644]], + [[42.2824, 43.0004, 43.7184], [44.4365, 45.1545, 45.8725], [46.5905, 47.3085, 48.0266]], + ], + [ + [[81.0000, 82.0000, 83.0000], [84.0000, 85.0000, 86.0000], [87.0000, 88.0000, 89.0000]], + [[90.0000, 91.0000, 92.0000], [93.0000, 94.0000, 95.0000], [96.0000, 97.0000, 98.0000]], + [ + [99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000], + ], + ], + ] + ) + ), + ] + ) + TESTS.append( + [ + { + "device": device, + "rotate_range": (1.0, 1.0, 1.0), + "shear_range": (0.1,), + "scale_range": (1.2,), + "foreground_oversampling_prob": 0.9, + }, + { + "grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3))), + "image_size": [5, 5, 5], + "fg_indices": [[1, 1, 1, 1], [1, 2, 2, 2]], + }, + p( + np.array( + [ [ [ - [9.9383e-02, -4.8845e-01, -1.0763e00], - [-1.6641e00, -2.2519e00, -2.8398e00], - [-3.4276e00, -4.0154e00, -4.6032e00], + [-50.6643, -50.7190, -50.7737], + [-50.8284, -50.8830, -50.9377], + [-50.9924, -51.0471, -51.1017], ], [ - [-5.1911e00, -5.7789e00, -6.3667e00], - [-6.9546e00, -7.5424e00, -8.1302e00], - [-8.7180e00, -9.3059e00, -9.8937e00], + [-51.1564, -51.2111, -51.2657], + [-51.3204, -51.3751, -51.4298], + [-51.4844, -51.5391, -51.5938], ], [ - [-1.0482e01, -1.1069e01, -1.1657e01], - [-1.2245e01, -1.2833e01, -1.3421e01], - [-1.4009e01, -1.4596e01, -1.5184e01], + [-51.6485, -51.7031, -51.7578], + [-51.8125, -51.8671, -51.9218], + [-51.9765, -52.0312, -52.0858], ], ], [ [ - [5.9635e01, 6.1199e01, 6.2764e01], - [6.4328e01, 6.5892e01, 6.7456e01], - [6.9021e01, 7.0585e01, 7.2149e01], + [-90.4777, -90.5975, -90.7173], + [-90.8371, -90.9569, -91.0767], + [-91.1965, -91.3164, -91.4362], ], [ - [7.3714e01, 7.5278e01, 7.6842e01], - [7.8407e01, 7.9971e01, 8.1535e01], - [8.3099e01, 8.4664e01, 8.6228e01], + [-91.5560, -91.6758, -91.7956], + [-91.9154, -92.0352, -92.1550], + [-92.2748, -92.3947, -92.5145], ], [ - [8.7792e01, 8.9357e01, 9.0921e01], - [9.2485e01, 9.4049e01, 9.5614e01], - [9.7178e01, 9.8742e01, 1.0031e02], + [-92.6343, -92.7541, -92.8739], + [-92.9937, -93.1135, -93.2334], + [-93.3532, -93.4730, -93.5928], ], ], [ [ - [8.1000e01, 8.2000e01, 8.3000e01], - [8.4000e01, 8.5000e01, 8.6000e01], - [8.7000e01, 8.8000e01, 8.9000e01], + [-34.4885, -34.5587, -34.6289], + [-34.6991, -34.7693, -34.8395], + [-34.9097, -34.9799, -35.0501], + ], + [ + [-35.1203, -35.1906, -35.2608], + [-35.3310, -35.4012, -35.4714], + [-35.5416, -35.6118, -35.6820], ], [ - [9.0000e01, 9.1000e01, 9.2000e01], - [9.3000e01, 9.4000e01, 9.5000e01], - [9.6000e01, 9.7000e01, 9.8000e01], + [-35.7522, -35.8224, -35.8926], + [-35.9628, -36.0330, -36.1032], + [-36.1735, -36.2437, -36.3139], ], + ], + [ + [[81.0000, 82.0000, 83.0000], [84.0000, 85.0000, 86.0000], [87.0000, 88.0000, 89.0000]], + [[90.0000, 91.0000, 92.0000], [93.0000, 94.0000, 95.0000], [96.0000, 97.0000, 98.0000]], [ - [9.9000e01, 1.0000e02, 1.0100e02], - [1.0200e02, 1.0300e02, 1.0400e02], - [1.0500e02, 1.0600e02, 1.0700e02], + [99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000], ], ], ] diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index a607029c1a..4ef3732eb3 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -74,7 +74,7 @@ mode="bilinear", ), {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, - torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]]), + torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]]), ] ) TESTS.append( @@ -93,7 +93,9 @@ "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), }, - torch.tensor([[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]]), + torch.tensor( + [[[32.10919, 22.357067, 12.604945], [38.193462, 28.44134, 18.689217], [44.277733, 34.52561, 24.773487]]] + ), ] ) TESTS.append( @@ -118,14 +120,14 @@ torch.tensor( [ [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], ] ] ) ), - "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(torch.tensor([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), }, ] ) @@ -143,7 +145,7 @@ mode=GridSampleMode.BILINEAR, ), {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, - torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]]), + torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]]), ] ) TESTS.append( @@ -168,14 +170,14 @@ np.array( [ [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], ] ] ) ), - "seg": MetaTensor(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(np.array([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), }, ] ) @@ -202,14 +204,50 @@ torch.tensor( [ [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], + ] + ] + ) + ), + "seg": MetaTensor(torch.tensor([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), + }, + ] + ) + + seg = MetaTensor(torch.arange(64).reshape((1, 8, 8))) + seg.meta["foreground_sample_locations"] = [[1, 2, 3], [2, 3, 4]] + TESTS.append( + [ + dict( + prob=0.9, + mode=(GridSampleMode.BILINEAR, GridSampleMode.NEAREST), + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + scale_range=[0.1, 0.2], + spatial_size=(3, 3), + foreground_oversampling_prob=0.9, + label_key_for_foreground_oversampling="seg", + cache_grid=True, + keys=("img", "seg"), + device=device, + ), + {"img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), "seg": seg}, + { + "img": MetaTensor( + torch.tensor( + [ + [ + [25.449093, 24.070652, 21.291996], + [28.34988, 27.144796, 25.939713], + [31.250668, 30.045584, 28.8405], ] ] ) ), - "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(torch.tensor([[[22.0, 23.0, 23.0], [28.0, 30.0, 23.0], [35.0, 28.0, 29.0]]])), }, ] ) @@ -226,6 +264,8 @@ def test_rand_affined(self, input_param, input_data, expected_val, track_meta): if track_meta and input_data["img"].ndim in (3, 4): if "mode" not in input_param.keys(): input_param["mode"] = "bilinear" + if "padding_mode" not in input_param.keys(): + input_param["padding_mode"] = "reflection" if not isinstance(input_param["keys"], str): input_param["mode"] = ensure_tuple_rep(input_param["mode"], len(input_param["keys"])) lazy_init_param = input_param.copy() diff --git a/tests/test_rand_simulate_low_resolution.py b/tests/test_rand_simulate_low_resolution.py new file mode 100644 index 0000000000..7d05faad36 --- /dev/null +++ b/tests/test_rand_simulate_low_resolution.py @@ -0,0 +1,83 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandSimulateLowResolution +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + dict(prob=1.0, zoom_range=(0.8, 0.81)), + p( + np.array( + [ + [ + [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]], + [[16, 17, 18, 19], [20, 21, 22, 23], [24, 25, 26, 27], [28, 29, 30, 31]], + [[32, 33, 34, 35], [36, 37, 38, 39], [40, 41, 42, 43], [44, 45, 46, 47]], + [[48, 49, 50, 51], [52, 53, 54, 55], [56, 57, 58, 59], [60, 61, 62, 63]], + ] + ] + ) + ), + np.array( + [ + [ + [ + [0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000], + ], + [ + [10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000], + ], + [ + [22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000], + ], + [ + [32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000], + ], + ] + ] + ), + ] + ) + + +class TestRandGaussianSmooth(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, arguments, image, expected_data): + randsimlowres = RandSimulateLowResolution(**arguments) + randsimlowres.set_random_state(seed=0) + result = randsimlowres(image) + assert_allclose(result, expected_data, rtol=1e-4, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_simulate_low_resolutiond.py b/tests/test_rand_simulate_low_resolutiond.py new file mode 100644 index 0000000000..f058ec3b2b --- /dev/null +++ b/tests/test_rand_simulate_low_resolutiond.py @@ -0,0 +1,73 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandSimulateLowResolutiond +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + dict(keys=["img", "seg"], prob=1.0, zoom_range=(0.8, 0.81)), + {"img": p(np.arange(64).reshape(1, 4, 4, 4)), "seg": p(np.arange(64).reshape(1, 4, 4, 4))}, + np.array( + [ + [ + [ + [0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000], + ], + [ + [10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000], + ], + [ + [22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000], + ], + [ + [32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000], + ], + ] + ] + ), + ] + ) + + +class TestRandGaussianSmoothd(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, arguments, image, expected_data): + converter = RandSimulateLowResolutiond(**arguments) + converter.set_random_state(seed=0) + result = converter(image) + assert_allclose(result["img"], expected_data, rtol=1e-4, type_test=False) + assert_allclose(result["seg"], expected_data, rtol=1e-4, type_test=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sample_foreground_locations.py b/tests/test_sample_foreground_locations.py new file mode 100644 index 0000000000..2725e0db84 --- /dev/null +++ b/tests/test_sample_foreground_locations.py @@ -0,0 +1,46 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import SampleForegroundLocations +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append([{"num_samples": num_samples}, p(np.ones([10, 10, 9])), p(np.ones([10, 10, 9])), num_samples]) + +# test the case when input patch has no foreground +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append([{"num_samples": num_samples}, p(np.zeros([10, 10, 9])), p(np.zeros([10, 10, 9])), 0]) + + +class TestSampleForegroundLocations(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, img, out, expected_num_samples): + result = SampleForegroundLocations(**input_param)(img) + + # check if metadata entry has the expected length + self.assertEqual(len(result.meta["foreground_sample_locations"]), expected_num_samples) + + # output tensor should remain unchanged + assert_allclose(result, out, rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sample_foreground_locationsd.py b/tests/test_sample_foreground_locationsd.py new file mode 100644 index 0000000000..10f1a29381 --- /dev/null +++ b/tests/test_sample_foreground_locationsd.py @@ -0,0 +1,62 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import SampleForegroundLocationsd +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append( + [ + {"label_keys": ["label"], "num_samples": num_samples}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.ones([10, 10, 9]))}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.ones([10, 10, 9]))}, + num_samples, + ] + ) + +# test the case when input patch has no foreground +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append( + [ + {"label_keys": ["label"], "num_samples": num_samples}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.zeros([10, 10, 9]))}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.zeros([10, 10, 9]))}, + 0, + ] + ) + + +class TestSampleForegroundLocationsd(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, test_input, output, expected_num_samples): + result = SampleForegroundLocationsd(**input_param)(test_input) + assert_allclose( + result["image"], output["image"], rtol=1e-3 + ) # "image" should not have been affected by transform + assert_allclose( + result["label"], output["label"], rtol=1e-3, type_test="tensor" + ) # "label" should not have been affected by transform + if "label" in result: + self.assertEqual(len(result["label"].meta["foreground_sample_locations"]), expected_num_samples) + + +if __name__ == "__main__": + unittest.main()